오늘의 할 일

오늘 할 일이 뭔가 매우 많은데 정리되어 있지는 않아서 얼레벌레 그냥 했다가는 일정이 지연될 것 같았다. 그래서 시간별로 할 일을 정리해보았다. 각 일정은 1시간 동안 처리하고, 쉬거나 조금 오버되거나 하는 식으로 시간을 더 쓸 수도 있을 것 같아서 각 일정 사이에는 30분의 쉬는 시간을 두었다. 

시간 카테고리 할 일 상세
14:00-15:00 BE SZ-243 uvicorn, gunicorn으로 서버 성능 향상, mysql 커넥션풀링 후 locust로 테스트하기
15:30-16:30 FE SZ-215 기존 액세스토큰 재발급 로직 Api 클래스로 바꾸기
17:00-18:00 SOMA 중간발표 보고서작성
19:00-20:00 SOMA 중간발표 대본작성
20:30-21:30 취업 이력서 피드백 주신대로 바꾸기
23:00-24:00 사이드 프로젝트 Spring JPA로 Entity 클래스 만들기

 

+ 현재 5시인데 아직 SZ-243번 이슈 못 끝냈다..! 역시 생각만큼 쉽지 않다. 

 

 오늘 배운 것

첫 번째 일정부터 해결해 보자. 어제의 포스팅에 이어서 별도로 적지는 않았지만, 테스트 서버를 띄우기 위한 ECS, ECR, 대상그룹, 로드밸런서 등의 설정을 추가로 해 주어서 yaml 파일이 정상 동작한다는 가정 하에 테스트 서버가 뜰 수 있도록 설정을 해 놓았다. 이제는 이와 더불어 현재 장고 서버에서 사용하고 있는 DB 서버가 커넥션 풀링(connection pooling)을 사용하도록 장고 서버에서 설정을 바꾸어 주면 된다. 

 

까먹을까봐 정리해두면 커넥션 풀링(connection pooling)은 HTTP에서도 DB에서도 같은 개념으로 사용된다. 요청이 들어오면 해당 요청을 스레드(thread)로 처리하는데, 싱글 스레드를 사용할 경우 스레드를 재사용할 수 있지만 요청이 한꺼번에 많이 들어올 경우 대기열이 생기면서 응답 시간이 지연되거나 병목(bottleneck)이 생길 수 있다. 반면 요청이 들어올 때마다 새 스레드를 생성해서 요청을 처리하는 경우에는 응답 시간 지연이나 병목의 문제는 덜하겠지만 스레드를 생성하는 작업 자체도 오버헤드가 꽤 있는 작업인데 그렇게 만든 스레드를 요청 하나만 처리하고 버린다는 점에서 비효율적이다. 또한 사용자가 요청하는 대로 요청을 다 받기 때문에 자칫 잘못하면 서버가 감당할 수 없을 양의 스레드가 만들어져서 해당 스레드가 사용하는 메모리나 cpu를 서버가 감당할 수 없게 되어 서버가 다운될 수도 있다. 

 

커넥션 풀링은 이럴 때 사용한다고 알고 있다. 말 그대로 사전에 정의된 개수의 커넥션이나 스레드만 만들어 두고, 이를 커넥션 풀에 담아두고 요청이 들어오면 해당 커넥션을 사용하고, 요청을 처리한 후에는 다시 커넥션 풀에서 해당 커넥션을 대기시키는 것이다. 

 

이 설정을 DB에서 해 줘야 하는 거 아닌가 싶었는데 아닌가보다. 장고 서버에서 현재 서버가 사용하는 DB에서 지정한 수의 커넥션에 대해 커넥션 풀링을 하도록 해 주자. 

 

django-db-connection-pool 이라는 라이브러리를 사용하면 settings 파일에 설정 몇 줄을 추가하는 걸로 DB에서 커넥션 풀링을 하도록 바꿀 수 있다. 

pip install django-db-connection-pool

 

그리고 환경 파일(settings.py)에서 다음과 같이 설정하자. 기존의 ENGINE 값은 'django.db.backends.mysql'로 되어 있었는데 커넥션 풀링을 사용하기 위해서 바꿔주었다. 

DATABASES = {
    "default": {
        'ENGINE': 'dj_db_conn_pool.backends.mysql',
        # 다른 값들
    },
}

 

이렇게만 되어도 해당 DB에서 커넥션 풀링을 할 수 있다. 커넥션 풀에서 기본값으로 유지하는 커넥션 개수(POOL_SIZE)는 5개라고 하며, 이론상 설정할 수 있는 최댓값의 제한은 따로 없다고 한다. 또한 갑자기 요청이 몰릴 시에는 POOL_SIZE 값보다 많은 커넥션을 사용하기도 한다. 그러나 이럴 때라도 최대한으로 사용할 수 있는 커넥션 값의 제한(MAX_OVERFLOW)이 있다. 기본값은 10개로 설정되어 있다. 즉 기본으로 커넥션 풀에서는 5개까지의 커넥션을 사용하지만, 요청이 몰릴 경우는 최대 10개를 '추가로' 사용한다. 즉 커넥션 풀에서 최대로 사용할 수 있는 커넥션의 개수는 15(5+10)개가 되는 셈이다. 

 

그리고 깃헙 워크플로우가 초반에 오류 없이 실행되는 것을 확인하고 잠시 쉬었다가 다시 돌아왔다. 

 

그런데 그 시간 사이에 깃허브 워크플로우 에러가 발생해 있었다. 심지어 30분 동안 완료되지도 않고 실행되다가 오류가 난 상황이다. 

 

에러 로그의 'waiter'라는 말을 보아, worker처럼 작업을 실행하는 단위인가? 라는 추측을 했다. 만약 그렇다면 uvicorn과 gunicorn 관련 에러일 수도 있다고 생각했다. GPT에게도 물어보니, 작업을 기다리다가 timeout 에러가 뜬 것이라고 한다. 이럴 때는 timeout 시간을 더 길게 설정해 주거나, waiter(아마도 프로세스일 것이라고 생각한다)가 기다리고 있는 EC2나 다른 시스템이 제대로 동작하지 않아서 발생한 오류일 수 있겠다. 

 

혹시 추가적인 힌트를 얻을 수 있을까 싶어 AWS 콘솔에 들어가보았다. ECS 서비스의 이벤트 로그를 보니, 로드밸런서의 헬스체크가 fail이 나서 계속해서 기존 태스크에 커넥션 연결을 해제하고 새 태스크를 만들어서 또 시도하고... 이 작업을 반복하다가 fail 처리가 난 것 같았다. 

 

로드밸런서의 헬스체크 상태를 보니 fail이 떠 있었다. 중지된 태스크의 로그를 ECS에서 확인했을 때는, 'Application startup complete'와 'Waiting for application startup' 문구가 반복되어 나와서, 이게 서버가 잘 실행된 것인지 아니면 무언가를 기다리고 있는 것인지 파악하기 어려웠다. 

GPT에게 나름의 추론 문제를 낸 결과 서버는 잘 실행된 것 같다고 알려주었다. 중간에 뜨는 경고들은 서버의 실행에는 영향을 주지 않는 정도라고 했다. 

 

정황상 로드밸런서의 헬스체크가 fail 한 것은 맞아 보인다. 아직 이게 위에서 로그로 나온 "Waiter has timed out"과는 어떤 관련이 있는지는 잘 모르겠지만, 헬스체크가 fail해도 서버가 배포될 수 없는 것은 똑같으니 이 문제도 해결해야 한다고 생각했다. 

로드밸런서의 헬스체크는 대상그룹의 헬스체크 기준이 올바르게 설정되어 있다고만 해서 항상 성공하는 게 아니었고 여러 요소를 고려해야 했다. 그런데 아무리 봐도 개발 서버의 경우 도커파일의 실행 커맨드가 python manage.py runserver 대신 uvicorn과 gunicorn을 사용하도록 바꿔준 것밖에 없어서 이쪽 문제일 것이라는 의심이 들었다. 

관련이 있을 수도 있겠다! gunicorn과 uvicorn을 함께 사용하는 경우, 예를 들면 gunicorn이 uvicorn의 워커를 너무 일찍 타임아웃 처리하고 일찍 종료시킬 수도 있다고 한다. 이럴 때는 gunicorn과 uvicorn의 설정을 적절하게 변경해줘야 하겠다. 해당 문제와 관련있어 보이는 설정은 타임아웃과 워커 개수 설정이었는데, 워커 개수는 이전에 공식문서를 보고 4개로 맞춰주었으니 타임아웃 설정만 변경했다.

 

기존 커맨드에 --timeout 120 부분을 추가해서 uvicorn 워커가 2분 안에 요청을 처리하지 않으면 타임아웃 처리를 하도록 변경했다. 2분이 좀 긴 감이 있는데, 우선 2분으로 아예 길게 설정한 다음 만약 이 설정을 추가했더니 에러가 안 나면 추측대로 uvicorn 워커가 예상보다 빨리 timeout 되어서 깃허브 워크플로우가 fail한 것이 맞다. 이 추측이 맞는지 확인하고, 그 다음에 다시 적절한 값을 찾아서 timeout 설정값을 바꿔줄 계획이다. 

gunicorn onestep_be.asgi:application --timeout 120 -w 4 -k uvicorn.workers.UvicornWorker

잠시 백엔드 이슈가 막혀서 프론트 이슈로 Context Switching을 했다. SZ-215번, 기존에 액세스 토큰 갱신 로직을 작성하다가 커스텀 훅 관련 문제가 있었었다. 원래 커스텀 훅을 컴포넌트나 다른 커스텀 훅 내부 외에는 사용할 수 없는 것이 원칙인데. 그러다 보니 커스텀 훅을 사용하기 위해 연쇄적으로 커스텀 훅을 만들게 되는 문제가 있었다. 이 부분은 결국 커스텀 훅이 아니라 클래스를 사용하는 방향으로 가는 것이 맞다고 결론을 내렸었다. 

 

다만 해당 클래스를 싱글톤 패턴으로 구현해야 하겠다. 왜냐하면 애초에 만드려는 'Api' 클래스는 요청을 보내는 데 사용하는 객체라서 인스턴스가 여러 개일 필요가 없다. 또한 Api 클래스 안에는 액세스 토큰과 리프레시 토큰이 멤버 변수로 정의되어 있다. 그런데 여러 개의 Api 인스턴스가 생길 경우, 예를 들면 어떤 Api 인스턴스의 액세스토큰은 갱신되었는데 다른 Api 인스턴스의 액세스토큰은 예전 값으로 남아있는 등의 관리가 어려운 경우가 생길 수 있다. 

 

예전에 학교 과제에서는 java로는 싱글톤 패턴을 구현해본 적이 있었는데, javascript는 생소했기에 공식문서를 참고해서 진행했다. 

import AsyncStorage from '@react-native-async-storage/async-storage';
import axios from 'axios';
import { API_PATH } from './config';

const TOKEN_INVALID_OR_EXPIRED_MESSAGE = 'Token is invalid or expired';
const TOKEN_INVALID_TYPE_MESSAGE = 'Given token not valid for any token type';

class Api {
  async init() {
    this.accessToken = await AsyncStorage.getItem('accessToken');
    this.refreshToken = await AsyncStorage.getItem('refreshToken');
  }

  constructor() {
    if (Api.instance) {
      throw new Error('You can only create one instance!');
    }
  }

  static getInstance() {
    if (!Api.instance) {
      Api.instance = new Api();
    }
    return Api.instance;
  }

  async request(url, options) {
    try {
      return axios.request(url, {
        ...options,
        'Content-Type': 'application/json',
        Authorization: `Bearer ${this.accessToken}`,
      });
    } catch (e) {
      if (axios.isAxiosError(e)) {
        if (
          (e.response.status === 401 &&
            e.response.data.detail === TOKEN_INVALID_OR_EXPIRED_MESSAGE) ||
          e.response.data.detail === TOKEN_INVALID_TYPE_MESSAGE
        ) {
          // access token 재발급
          const responseData = await axios.post(API_PATH.renew, {
            refresh: this.refreshToken,
            access: this.accessToken,
          });
          const newAccessToken = responseData.data.access;
          await AsyncStorage.setItem('accessToken', newAccessToken);
          this.accessToken = newAccessToken;

          // 다시 요청
          return axios.request(url, {
            ...options,
            'Content-Type': 'application/json',
            Authorization: `Bearer ${this.accessToken}`,
          });
        }
      }
    }
  }

  fetchTodos(userId) {
    return this.request(`${API_PATH.todos}?user_id=${userId}`, {
      method: 'GET',
    });
  }
  
}

export default Api;

 

궁금한 점

1. django-db-connection-pool 라이브러리에서 어떻게 설정만으로 장고에서 사용하는 DB에서 커넥션 풀링을 할 수 있는지가 궁금하다. 나중에라도 소스 코드를 한번쯤은 꼭 찾아보자.

2. 위의 라이브러리의 공식문서에 나와있던 SQLAlchemy는 또 뭘까? 예전에 fastAPI 코드 볼 때 잠깐 봤었는데 이 녀석의 정의가 궁금하다.

 

+ Recent posts