오늘 배운 것

저번에 멘토님께 피드백을 받은 대로 Context API에 액세스토큰 값을 넣어두는 대신 Api 클래스를 싱글톤 패턴으로 만들고, 해당 클래스의 메소드를 사용하도록 컴포넌트 내부의 코드들도 수정해주었다. 그런데도 여전히 401 AxiosError가 발생하고 있었다. 

 

해당 에러를 수정해야 무사히 PR을 하고 잘 동작하는 앱 화면을 볼 수 있기에, 여기에 달려있는 dependency가 많다고 느껴져서 이 일을 급하다고 판단했다. 

 

현재 Api 클래스의 일부 코드는 다음과 같다. 여기서 핵심은 request 메소드에서 this.accessToken으로 Api 싱글톤 클래스가 갖고 있는 토큰값을 넣어주고, 에러가 날 경우 Sentry에 로그를 남긴 뒤 해당 에러가 액세스토큰이 만료되어 발생하는 401 AxiosError라면 액세스토큰을 갱신시키고 다시 시도하는 로직이다. 

class Api {

  // 싱글톤 패턴으로 구현

  async request(url, options) {
    try {
      const response = await axios.request(url, {
        ...options,
        'Content-Type': 'application/json',
        Authorization: `Bearer ${this.accessToken}`,
      });
      return response.data;
    } catch (e) {
      Sentry.captureException(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;

 

그런데 로그를 찍어보니 401 AxiosError가 발생하였다고 콘솔에는 나오는데 catch 문에서 해당 에러를 못 잡고 있었다. 대신 콘솔에서는 react query의 useQuery 메소드에서 에러가 호출되고 있었다. 내가 원하는 건 useQuery에러 에러를 캐치하기 전에 위의 request 메소드에서 에러를 먼저 캐치하고, 액세스토큰 관련 에러일 경우 에러가 나지 않도록 액세스토큰을 갱신하는 것이었다. 

 

 

질문을 통해 얻은 해결 방법은 axios의 interceptor 기능을 사용하는 것이었다. 제시된 다른 해결방법 중에는 react query에서 axios 인스턴스를 직접 사용하거나 react query의 메소드(useQuery, useMutation)에서 onError 파라미터로 401 에러가 났을 때 그것을 처리하는 함수를 인자로 넘겨주는 방법도 있었다. 그러나 지금 하려는 작업은 API 서버로 보내는 모든 요청에 대해서 적용되어야 했기 때문에 이 방법들을 쓰면 각 API를 요청하는 코드를 일일이 수정해 주어야 해서 번거로웠다. 정말 신기했던 게 어제 멘토링을 하면서 멘토님도 axios의 interceptor 기능을 추천해주셨었다. 이 상황을 미리 내다보신 것일까? 신기했다. 

 

그런데 별도의 파일에 axios interceptor를 적용하고 그 axios 인스턴스를 import해서 사용하는 기존 방법은 기존에 만들었던 Api 싱글톤 인스턴스와는 별개로 동작하는 방법이었다. 나는 Api 싱글톤 인스턴스를 사용하면서, 그 안의 request 메소드에서 사용하는 axios 인스턴스에 interceptor를 적용하고 싶었다. 

 

 

적용하려는 두 가지 방법(Api 싱글톤 클래스, axios 인스턴스)은 별도의 문제였다. 즉 둘 다 같이 적용할 수 있었다. Api 싱글톤 클래스 안에 axios 인스턴스를 만들고, 그 인스턴스에 대해 interceptor 기능을 적용하면 되는 문제였다. 

 

그랬더니 이번에는 401 대신 400 AxiosError가 떴다. 이것은 클라이언트에서 요청을 뭔가 잘못 보내고 있는 문제이니, 로직을 수정해보면 될 것 같다. 

 

일단 콘솔로 로그를 찍어서(바람직하지 못한 방법이긴 하다... 중간평가 끝나고는 꼭 디버거를 써 봐야지) 에러 객체를 그대로 찍어봤다. 

 

인내심을 가지고 로그를 쭉 읽다 보니 응답 정보 쪽에 이런 메시지가 보였다. 

"_response": "{\"refresh\":[\"This field is required.\"]}"

 

해당 코드는 401 에러가 나서 토큰 갱신 API를 다시 호출하고 있는 로직에서 난 400 에러를 캡처한 부분이다. 즉 토큰 갱신 API를 통해 액세스토큰을 갱신하려면 파라미터로 리프레시 토큰을 넣어줘야 하는데, 그 부분이 빠져서 400에러가 난 것으로 보였다. 

 

 오늘의 할 일 계획

시간 카테고리 할 일 상세
13:00-15:30 SOMA 멘토링
16:00-19:00 SOMA 발표 대본 작성
22:00-23:00 취업 이력서 작성하기

 

계속 할 일이 많으니 심적 여유나 심리적 안전감이 떨어지는 느낌이다. 일을 안 하면 당연히 회복은 될 텐데 그런 방법은 일이 많을 때는 쓸 수가 없다. 이럴 때는 어떻게 해야 할까? 현재 생각나는 방법으로는 취미 활동을 중간에 30분씩이라도 끼워 넣으면서 중간중간 회복을 해 주는 방법인데, 다른 방법이 또 있을지도 찾아봐야겠다. 

 

 오늘 배운 것

오늘은 개발적으로는 이슈 처리를 하지는 않았다. 왜냐하면 발표 대본을 써야 하고, 그 발표 대본이 있어야 다른 팀원이 PPT를 만들 수 있는 모종의 dependency가 걸려있는 상황이었기 때문이다. 오늘까지 발표 대본 및 보고서를 대부분 작성하고, 내일은 남은 이슈(SZ-243, SZ-215)를 처리한 다음, 스프링 사이드 프로젝트에도 1시간 정도만 써 봐야겠다. 

 

궁금한 점 / 개선점 / 논의할 점

1. 멘토님이 보여주신 공식문서를 보니 RN에서 디버거를 사용하는 방법에 대해서 자세히 나와있었다. 사실 다른 팀원이 이미 이 문제를 해결하고 있긴 한데, 나도 중간평가 끝나고 나서부터는 기능을 개발하고 테스트할 때 RN/React에서 디버거를 사용해야 더 쉽게 개발할 수 있어서 이 부분을 중간평가 끝나고 잘 배워봐야겠다. 

 

 오늘의 할 일

오늘 할 일이 뭔가 매우 많은데 정리되어 있지는 않아서 얼레벌레 그냥 했다가는 일정이 지연될 것 같았다. 그래서 시간별로 할 일을 정리해보았다. 각 일정은 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 코드 볼 때 잠깐 봤었는데 이 녀석의 정의가 궁금하다.

 

 오늘 배운 것

오늘은 멘토님과의 멘토링에서 어제 Locust로 API 서버 부하테스트를 했다는 부분을 말씀드리면서 피드백을 받았다. 당시 RPS(request per second)가 30-40 정도 나오고 있어서 괜찮은지 여쭤봤더니 멘토님께서는 1000 이상은 나오는 것이 안정적이라고 피드백을 주셨다! 그래서 왜 이렇게 RPS가 낮은지를 생각하고 있었는데, 지금 서버는 단일 서버로 돌아가고 있기 때문이었다. 따라서 uvicorn을 사용해서 서버를 여러 대 띄워야 RPS를 늘리고 서버의 부하를 줄일 수 있다고 하셨다. 

 

그래서 장고에서 uvicorn을 사용하는 공식문서를 찾아보았다. uvicorn은 pip로 쉽게 설치할 수 있는 라이브러리였다. uvicorn을 사용하려면 gunicorn도 같이 필요하다고 해서 같이 설치했다. 

pip install uvicorn gunicorn

 

gunicorn은 뭐고 왜 필요하지? 라는 의문이 들었는데 공식문서를 보니 바로 해결되었다. gunicorn은 unix 운영체제를 위한 python wsgi http 서버라고 한다. 그럼 WSGI가 뭐였지? 라는 의문이 또 드는데, 문서를 찾아보니 WSGI(Web Server Gateway Interface)는 웹 서버와 웹 어플리케이션이 어떻게 통신하고, 여러 개의 웹 어플리케이션이 하나의 요청을 처리하기 위해서 체인처럼 연결될 수 있는지를 정의한 인터페이스였다. 이제 gunicorn이 뭔지는 알았다. 

 

그러면 uvicorn은 뭐고 왜 필요하지? 라는 의문이 든다. uvicorn은 파이썬 웹 서버에서 사용하기 위해 ASGI 인터페이스를 구현한 것이다. ASGI는 뭘까? 문서에는 ASGI(Asynchronous Server Gateway Interface)는 WSGI를 상속받은 또 다른 인터페이스이며, 비동기 요청을 처리하는 웹 서버와 웹 어플리케이션이 어떻게 통신하고, 비동기 요청을 처리하는 여러 개의 웹 어플리케이션들이 하나의 요청을 처리하기 위해서 체인처럼 연결될 수 있는지를 정의한 인터페이스였다. Implementation을 보니 uvicorn 말고도 daphne, granian, hypercorn 등 여러 신기한 라이브러리들이 있었다. 

 

그런데 이렇게만 봐도 그래서 이 라이브러리들을 사용하는 게 서버를 여러 대로 늘리는 것과 무슨 상관이 있는지 사실 잘 와닿지 않았다. 

 

알고보니 uvicorn은 별다른 설정이 없으면 단일 프로세스로 실행되는 ASGI 서버이다. 성능을 확장하는 방법은 여러 개의 uvicorn 프로세스(인스턴스)를 실행하여 각 프로세스가 독립적으로 요청을 처리하게 하는 것이다. 그러면 물론 당연히 하나의 서버에 실행 가능한 프로세스 수는 제한이 있겠지만, 적어도 늘어난 프로세스만큼 요청을 병렬적으로 처리하게 되어 서버의 성능이 향상되겠다고 이해했다. 

 

그러면 gunicorn은 왜 필요한지 의아할 수도 있지만, 이렇게 여러 개의 uvicorn 프로세스를 통합해서 관리하도록 도와주는 것이 gunicorn이다. gunicorn에서는 워커(worker)라는 개념이 있다. 워커는 uvicorn 서버의 독립적인 인스턴스이다. 이쯤 되면 조금 헷갈린다. 아까는 여러 개의 uvicorn 프로세스를 통합해서 관리한다면서, 지금 얘기를 들으면 여러 개의 uvicorn 서버의 독립적인 인스턴스를 관리한다는 건가? 헷갈린다. 

요점만 정리하자면 여기서 말하는 '워커'는 uvicorn 인스턴스가 맞았다. 그리고 uvicorn을 단독 실행할 때와 gunicorn과 같이 실행할 때의 차이점도 알 수 있었다. uvicorn을 단독 실행할 때도 기본적인 워커 관리 기능을 제공하지만 그 기능이 다소 제한적이다. 반면 gunicorn과 uvicorn을 같이 사용하면 워커 관리의 제어권을 gunicorn이 갖는 대신에 더 확장된 기능을 제공한다고 한다. 예를 들면 gunicorn과 같이 사용할 때는 워커를 모니터링하거나, 필요에 따라 재시작하는 등의 부가 기능이 있다고 한다. 

 

gunicorn, uvicorn을 사용해서는 다음 명령어로 서버를 띄울 수 있다고 한다. 다만 우리 프로젝트에서는 개발, 프로덕션, 테스트의 세 가지 환경을 사용하고 있었기에 관련된 값도 넣어줘야 했다. 

# 4개의 worker를 사용하는 경우
gunicorn onestep_be.asgi:application -w 4 -k uvicorn.workers.UvicornWorker

 

그리고 해당 문서를 보면 gunicorn에서 몇 개의 uvicorn 워커를 갖는 것이 적당한가? 라는 물음에 대한 답이 나와있다. 기본적으로 4개 ~ 12개 사이의 uvicorn 워커로 초당 수백 개에서 수천 개의 요청을 처리할 수 있다고 하며, 2*(core의 개수)+1개의 uvicorn 워커를 사용할 것을 권장하고 있었다. 왜 저런 식에 근거하였는지도 궁금하다. 

 

프로젝트는 현재 ECS fargate 옵션으로 서버가 실행되고 있었는데, 몇 개의 uvicorn 워커가 필요한지를 알기 위해서는 서버에서 몇 개의 cpu core를 사용하고 있는지를 알아봐야 했다. 

 

공식문서에서는 ECS fargate에서 cpu core의 수는 vCPU와 직접적으로 연관된다고 한다(왜인지는 모른다). 그리고 vCPU(virtualized CPU)의 수는 태스크 정의에서 정의된 "cpu"의 값에 따라 달라진다고 한다. ECS의 태스크 정의에서 확인하니 개발 서버의 경우 1vCPU(1024)를 사용하고 있었다. 

 

그렇다면 공식으로 계산해보면 필요한 uvicorn 워커의 개수는 3개이다. 그러나 공식문서에서는 4개-12개 사이의 값을 권장하는 듯 보여서 4개로 설정해 주었다. 

 

그리고 해당 값을 Dockerfile에 커맨드로 추가해 주었다. 

# production 환경의 경우
CMD ["sh", "-c", "python manage.py migrate && DJANGO_SETTINGS_MODULE=onestep_be.setting.prod gunicorn onestep_be.asgi:application -w 4 -k uvicorn.workers.UvicornWorker"]

 

 궁금한 점

1. 가상화된 CPU(vCPU) 개념은 무엇일까

2. 테스트 서버도 로드밸런서가 필요할까?

 오늘 배운 것

그저께의 이슈가 오늘까지 이어지고 있다! 하지만 이 글을 쓰기 시작한 시점에 멘토님의 피드백과 설명을 듣고 문제가 무엇이었는지를 조금이나마 이해한 상황이라서, 오늘이면 이 이슈를 마무리할 수 있지 않을까 기대해본다. 

 

일단 야심차게 시작하긴 했는데... 결론을 스포하자면 또 Invalid hook call 에러가 났다. 그래도 나름 삽질해서 애정이 있는 코드니... 일단 올려보자. 

// Api.js
  const handleRequest = async (request, headerFunction) => {
    try {
      const response = await request(headerFunction());
      return response.data;
    } catch (err) {
      Sentry.captureException(err);
      console.log('err', err);
      if (
        (err.response.status === 401 &&
          err.response.data.detail === TOKEN_INVALID_OR_EXPIRED_MESSAGE) ||
        err.response.data.detail === TOKEN_INVALID_TYPE_MESSAGE
      ) {
        try {
          const accessToken = await AsyncStorage.getItem('accessToken');
          const refreshToken = await AsyncStorage.getItem('refreshToken');
          const responseData = await axios.post(API_PATH.renew, {
            refresh: refreshToken,
            access: accessToken,
          });
          await AsyncStorage.setItem('accessToken', responseData.data.access);

          const secondRequest = await request(headerFunction());
          return secondRequest.data;
        } catch (refreshError) {
          if (refreshError.response.status === 401) {
            router.replace('index');
          } else {
            throw refreshError;
          }
        }
      } else {
        throw err;
      }
    }
  };
  
  
  const fetchTodos = (userId, headerFunction) => {
    return handleRequest(
      header => axios.get(`${API_PATH.todos}?user_id=${userId}`, { header }),
      headerFunction,
    );
  };

 

현재 문제가 발생한 코드는 이렇게 Api.js 파일에 정의된 함수들을 컴포넌트에서 가져와서 사용하는 방식으로 되어 있었다.


위의 프론트 이슈는 멘토님이 멘토링 시간 때 잠시 봐주시기로 했다. 이미 해당 이슈에 2시간을 넘게 쓴 상황이라, 더 이상의 시간을 쏟는 것보다 다른 이슈를 처리하는 것이 좋다고 판단했다. 그래서 백엔드로 Context Switching을 해 보기로 했다. 

 

어제 작업하던 Locust로 API 부하 테스트하는 작업을 이어서 해 보려고 한다. 어제는 서버가 다운될까봐 쫄린 탓에 user를 1명으로 설정했지만, 이번에는 최대 유저 수를 100명으로 설정해 보았다. 그랬더니 다음과 같은 값을 얻었다. 

초당 약 30-40개의 request를 처리할 수 있다면 어느 정도일까? 보통의 갓 런칭된 서비스라면 어느 정도의 RPS를 목표로 잡아야 할지, 그리고 RPS를 핵심으로 이 표를 보는 것이 맞을지도 헷갈린다. 

 

그리고 지금은 단순 조회 API 하나만 테스트하고 있다. 그러나 실제 유저의 패턴은 훨씬 다양하고 분명 DB 조회뿐만 아니라 write를 하는 API도 불러올 것이기에, 이 API를 테스트 해봐야 겠다. 그러려면 테스트 환경에서 사용하는 DB는 테스트 DB로 별도 세팅이 필요하겠다. 

 

GPT는 별도로 환경변수를 test DB를 가리키도록 설정해서 locust 스크립트를 돌리라고 말해주었다. 그런데 나는 그런 것보다 settings.py 환경파일에 test DB를 설정해놓으면 locust에서 해당 설정을 자동으로 참조하도록 해서 더 편하게 테스트를 하고 싶었다. 어떤 글에서도 나와 비슷한 사람을 발견했다. 

 

일단은 별도의 테스트 환경에서 사용하기 위해서 RDS를 하나 더 만들어주자. 해당 값들을 AWS Secrets Manager에도 저장한 다음, settings 파일에도 테스트 DB와 관련된 설정을 하나 더 추가해줬다. 

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.mysql",
        "NAME": SECRETS.get("DB_NAME"),
        "USER": SECRETS.get("DB_USER"),
        "PASSWORD": SECRETS.get("DB_PASSWORD"),
        "HOST": SECRETS.get("DB_HOST"),
        "PORT": SECRETS.get("DB_PORT"),
    },
    "test": {
        "ENGINE": "django.db.backends.mysql",
        "NAME": SECRETS.get("TEST_DB_NAME"),
        "USER": SECRETS.get("TEST_DB_USER"),
        "PASSWORD": SECRETS.get("TEST_DB_PASSWORD"),
        "HOST": SECRETS.get("TEST_DB_HOST"),
        "PORT": SECRETS.get("TEST_DB_PORT"),
    }
}

 

이제 이 테스트 DB 설정값을 어디서 불러와야 할까? 공식문서와 소스코드를 찾아보니 모든 태스크(부하 테스트)를 실행하기 전에 on_start 메소드를 호출하는 것 같았다. 

 

기존에 사용하던 locust test 클래스에 on_start 메소드를 오버라이딩 해 주었다. 

from locust import HttpUser, task
from django.conf import settings

class TestTodos(HttpUser):

    def on_start(self) -> None:
        test_db_info = settings.DATABASES.get("test")
        self.db_host = test_db_info.get("HOST")
        self.db_name = test_db_info.get("NAME")
        self.db_user = test_db_info.get("USER")
        self.db_password = test_db_info.get("PASSWORD")

 

이렇게 설정을 작성하고 'locust' 명령어를 입력하니 다음과 같은 에러가 났다. 

 

알고보니 django에서 settings 정보를 가져오려면, 다음과 같은 코드를 먼저 선언해 주어야 했다. 

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "onestep_be.settings")

 

이유를 물어보았더니, 장고의 설정은 위에서 가져온 것처럼 django.conf.settings 모듈에서 관리된다. 그런데 이 모듈을 사용해서 설정 정보를 불러오기 전에 설정이 로드되지 않으면 ImproperlyConfigured 예외가 발생한다고 한다. 그렇기 때문에 django.conf.settings에서 불러올 환경이 어떤 기본값을 사용할지를 os.environ.setdefault() 함수를 통해 설정해 주어야 예외가 발생하지 않는다.

 

해당 에러를 해결하고 Locust의 로컬 서버를 띄웠다. 그랬더니 API에서 정상 반응을 리턴하지 않고 401, 500 에러가 떴다. 테스트 서버라서 유저 객체가 없었기 때문에 500 에러가 났고, 해당 API는 액세스토큰이 있는 유저에게만 401 응답을 리턴했기 때문이다. 

이럴 경우엔 여러 가지 방법을 사용할 수 있어 보인다. GPT가 제시한 방법들 중 '사전에 발급된 액세스토큰 사용'이나 '프론트 URL을 통한 인증 절차 거치기'는 번거롭기도 하고 프론트가 웹이 아니라 앱이기 때문에 사용하기 어려웠다. 그래서 테스트 환경으로 실행할 시 인증을 건너뛰도록 설정해 봐야겠다. 테스트 환경에서는 모든 API에 대해서 인증을 잠시 우회할 것이므로, 특정 뷰에서만 동작하는 dispatch 메소드를 사용하는 대신 미들웨어를 사용하는 것이 더 간단하다고 판단했다. 

 

우선 test.py라는 파일을 settings에 만들어 주고, 인증을 건너뛸지에 대한 여부를 저장하는 SKIP_AUTHENTICATION 변수를 settings.py에 정의했다. 기본값은 False였다. test.py 파일에서만 이 변수를 True로 세팅해 주었다. 이제 테스트 환경에서는 이 test.py에 설정된 환경을 가져오면 되겠다. 

 

그리고 테스트 환경에서 서버가 실행될 경우 추가로 동작할 미들웨어 클래스도 정의해주었다.

from django.conf import settings

class SkipAuthMiddleware:

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if settings.SKIP_AUTHENTICATION:
            request.user = None
        response = self.get_response(request)
        return response

 

다만 해당 미들웨어 클래스는 테스트 환경에서만 동작해야 하므로, 테스트 환경 설정 파일에서만 아래와 같은 로직을 추가해서 MIDDLEWARE 변수의 맨 앞에 해당 미들웨어를 추가해 주었다. 미들웨어는 chain 구조로, 순서대로 사용자의 요청을 받고 그 반대 순서로 응답을 리턴하게 되어 있다. 따라서 맨 처음 요청을 받아 이를 승인해 주려면 MIDDLEWARE 변수 중 맨 앞에 해당 미들웨어를 넣어 주어야 하겠다.

# test.py
from onestep_be.settings import *

SKIP_AUTHENTICATION = True

if SKIP_AUTHENTICATION:
    MIDDLEWARE.insert(0, "accounts.middleware.SkipAuthMiddleware")

 

그런데 고민이 생겼다. 지금 서버는 개발 서버와 프로덕션 서버 2개로 되어있다. 그래서 테스트 환경을 실행할 별도의 서버가 없다. 처음에는 기존 API URL에 '/test'만 붙인 테스트 전용 API를 만들어야 하나 고민도 되었다. 하지만 팀원과도 얘기해본 결과 그냥 별도의 테스트 환경이 하나 더 있으면 좋겠다는 결론이 나왔다. 

 

그래서 develop 브랜치와 똑같은 코드를 배포하는 테스트 서버를 만들어야 하겠다. 즉 하나의 브랜치를 사용해서, 두 개의 서버를 다른 환경으로 띄워야 하는 상황이다. 

 

처음에는 워크플로우 하나에 여러 개의 Job을 만들어서 해당 작업을 해야 하나, 아니면 별도의 워크플로우를 만들어서 작업해야 하나 고민했었다. 그러나 현재 ECR에서 도커 이미지에 태그로 달고 있는 값이 github.sha 값인데 이 값은 워크플로우가 가지는 고유한 값이므로, 두 개의 서버(개발 서버와 테스트 서버)가 모두 같은 도커 이미지를 참조해야 한다고 생각했다. 왜냐하면 어차피 두 서버는 모두 같은 브랜치의 내용을 반영할 건데 굳이 같은 하나의 이미지를 참조하면 될 일을 두 개의 다른 이미지를 각자 참조하게 만들 필요가 없기 때문이다. 

 

...라고 생각했는데, 생각해보니 도커 이미지는 도커파일을 통해 만들어지는데, 두 환경에서 사용하는 도커파일이 달랐다. 그러므로 두 서버에서 사용하는 이미지들은 별개의 워크플로우를 사용해서 별도의 도커 이미지들로 만들어주는 것이 맞겠다. 그러므로 해당 작업은 별도로 하나의 워크플로우를 더 파서 진행하자. 

 

그런데 또 고민이 생겼다. 

 

그러면 하나의 서버가 더 필요한 것은 알겠다. 그런데 이를 별도의 클러스터를 하나 더 만들어서 진행할지, 아니면 같은 클러스터 안에서 서비스를 더 만들어서 진행할지, 그것도 아니면 같은 서비스 내에서 태스크를 하나 더 만들어서 진행할지를 모르겠다는 것이다. 멘토님은 정확히 잘 모르면 일단 해 보고 나중에 고치라는 방향으로 피드백을 주셨어서, 일단은 별도의 클러스터를 하나 만들어서 진행해 보려고 한다. 그러니까 현재 ECS에는 개발, 프로덕션, 테스트 이렇게 총 세 개의 클러스터가 있게 되겠다. 


다시 멘토링 시간에는 프론트엔드로 Context Switching을 해 보았다. 애를 먹던 커스텀 훅 사용 문제가 있었는데, 멘토님의 피드백으로 도달한 결론은 커스텀 훅을 사용하는 대신 AsyncStorage로 토큰의 값을 저장소에서 가져오고, 토큰 값이 바뀔 때만 해당 값을 업데이트 해 주는 방법이었다. 이 방법도 크게 두 가지로 구현할 수 있었다. 

 

첫 번째는 javascript class를 사용해서 Api 클래스를 구현하고, 그 안에 필드(멤버 변수)로 액세스 토큰값을 갖고 있는 방법이었다. 두 번째는 액세스 토큰값을 담은 전역변수를 사용해서 맨 처음엔 AsyncStorage에서 초기 액세스 토큰값을 가져온 뒤, 만약 값이 업데이트되면 해당 변수의 값을 바꿔주는 방법이었다. 두 방법 모두 동작했다. 

 

그러나 첫 번째 방법이 좀 더 '객체지향적'이고 코드를 한 눈에 이해하기가 편하다고 판단했기에, 로직이 조금 더 복잡해져도 클래스를 통해 로직을 구현해보려고 한다. 

 

그런데 이슈를 작업하고 블로그도 쓰다 보니 벌써 10시라서... 오늘은 여기까지만 하고 내일 위에서 언급한 부분들을 마저 마무리하자.

+ 이력서와 중간발표 대본 초안도!

 

궁금한 점 / 논의점 / 보완점

1. 테스트 DB로 사용하려는 RDS는 어떻게 관리해야 할까? 예를 들면 해당 RDS는 테스트 때만 요청을 처리하고 나머지는 IDLE한 상태로 있는데, 이럴 때는 그대로 두는 게 맞을지 아니면 테스트 때만 활성화하는 것이 맞을지 모르겠다. 

2. LazyObject는 무엇일까? Lazy Loading이라는 말은 들어보았는데 추측해보면 이와 비슷한 맥락 같다. 

3. (사실상 1번과 같은 고민) 테스트 서버를 하나 더 띄워두면 서버 유지 측면에서 뭔가 비용 등이 들 것 같아서, 이를 테스트 할 때만 켜 둬야 할지 고민이다. 그렇다고 매번 테스트를 할 때마다 테스트용 서버를 띄웠다 내렸다 하는 것도 번거로워 보이는데... 보통 기업이나 서비스에서는 어떻게 하는지도 궁금하다. 

4. 테스트 서버를 어디다 띄워야 할지 고민이다. 별도의 클러스터? 개발 서버와 같은 클러스터에 있는 별도의 서비스? 아니면 개발 서버와 같은 서비스에 있는 별도의 태스크? 어떻게 하면 좋을지 고민이다. 아무래도 내가 ECS의 '클러스터', '서비스', '태스크'의 개념이 각각 정확히 무엇을 의미하는지를 잘 몰라서 이런 고민을 하는 것 같으니, 이 부분에 대한 정의도 찾아보자!

 

 오늘 배운 것

어제부터 이어진 시도가 계속되고 있다. 그냥 useMetadata()라는 커스텀 훅을 사용해서 헤더에 최신 토큰값을 반영하고 싶었을 뿐인데, 생각보다 많은 양의 코드를 건드리게 되었다. 

 

다행히 vscode 컴파일러(표현이 맞을지 모르겠다)에서는 리액트 커스텀 훅을 부적절하게 사용하면 바로 문법 에러를 내 주었다. 이게 아니었으면 나의 삽질은 더 길어졌을 것이다. 

 

일단 초기 코드는 다음과 같다. 어제 언급한 1, 2, 3단계의 방법으로 레포에 있는 모든 코드를 바꿔주었다.

const handleLocalToken = useCallback(useHandleLocalToken, [
    setAccessToken,
    setUserId,
    useVerifyToken,
  ]);

  const useHandleLocalToken = async () => {
    const token = await getAccessTokenFromLocal();
    const user = await getUserInfoFromLocal();
    useVerifyToken(token)
      .then(() => {
        setAccessToken(token);
        setUserId(user.userId);
        router.replace('(tabs)');
      })
      .catch(e => {
        router.replace('/');
      });
  };

  useEffect(() => {
    handleLocalToken();
  });

 

그리고 문법 에러가 나지 않는 것을 확인하고 앱을 시작해 봤더니, 이런 오류가 났다. 

 

useCallback 콜백 함수로 감싼 handleLocalToken 함수가 undefined 값으로 나오고 있었다. 

 

알고보니 useCallback으로 감싼 handleLocalToken 함수를 useHandleLocalToken 커스텀 훅보다 먼저 정의해서, 즉 useHandleLocalToken의 값이 undefined일 때 해당 값을 가져오려는 handleLocalToken 함수를 사용해서 에러가 난 것이었다. 이 문제는 useHandleLocalToken 함수를 handleLocalToken 콜백함수보다 먼저 정의해 주니 해결되었다. 

 

이제는 로그인 화면까지는 잘 띄워졌다. 그런데 그 다음엔 구글로그인을 하면 다시 TodayView, 즉 투두 리스트 뷰를 보여줘야 하는데, 로그인 화면에서 넘어가지를 않았다. 이번에는 비슷한 문제로 'useVerifyToken'과 'useGoogleLogin'의 값이 undefined로 뜨는 것 같았다. 

 

즉 useVerifyToken과 useGoogleLogin 함수(커스텀 훅)를 실행시켜야 하는데, 이 값이 제대로 인식되지 않아서 함수를 실행하지 못하고, 그러므로 로그인이 되지 않은 상태로 간주되니 투두 리스트 뷰로 넘어가지 못하는 상황으로 이해했다. 이 부분은 알고보니 import 구문에서 오류가 있어서, 이 부분을 수정하니 해결되었다. 

 

문제는 여전히 구글로그인을 하고 나서 투데이 뷰로 넘어가지 않는다는 점이었다. 'Invalid hook call'이라는 부분으로 보아, 어제와 같이 커스텀 훅을 사용하는 과정에서 오류가 있는 것으로 보았다. 

 

GPT에게 언어추론 문제를 내듯이 질문을 여러 차례 해 보았지만 마땅한 해답이 없었다. 즉 내가 리액트 훅을 잘못 사용하고 있는 게 거의 확실한데, 그게 어떤 지점인지를 모르는 게 문제였다. 

 

에러 메시지에서 올려준 공식문서의 링크를 타고 들어가보았다. 요약하면 리액트 훅을 적재적소에 잘 사용하라는 것이고, 반복문이나 조건문 안에 사용할 수 없다는 것이었다. 혹시나 vscode에서 훅의 잘못된 사용을 캐치하지 못한 부분이 있을까 싶어 공식문서에서 추천한 플러그인도 설치해 주었다. 

 

그렇게 삽질을 반복하던 중, 문득 어제 결론으로 내렸던 1번-2번-3번의 방식이 문법적인 오류를 내지는 않을지언정, 리액트 훅을 올바르게 사용하는 방식은 아닐 것 같다는 결론을 내렸다. 왜냐하면 애초에 useCallback 훅은 리렌더링 사이에서 함수를 계속 렌더링하는 것을 막는 용도였지 커스텀 훅의 사용과는 무관한 훅이었기 때문이다. 또한 임시로 만들어둔 'check local token'이라는 버튼을 누르면 토큰을 인증하는 verifyToken API를 호출하도록 되어 있고, 버튼을 누르면 onPress 함수로 useCallback()을 사용한 'handleLocalToken'이라는 함수를 호출하도록 되어 있다. 

 

그러니까 이런 셈이다. 

const handleLocalToken = useCallback(useHandleLocalToken, [
  setAccessToken,
  setUserId,
  useVerifyToken,
]);

//

<Button onPress={() => handleLocalToken()}>Check Local Token</Button>

 

즉 이 버튼을 누르면 바로 handleLocalToken이라는 함수가 실행되어야 하는데, 여기서도 'Invalid hook call'이라는 오류가 떴다. 그러므로 애초에 이 방식이 올바른 방식이 아니라는 결론을 내렸다. 다시 원점으로 가 보자. 


초심으로 돌아가 다시 던져보는 질문

그럴 듯한 방법을 찾았다. 요청을 처리하는 함수 자체를 훅으로 만든 다음, 그 함수를 커스텀 훅이나 컴포넌트에서 사용하도록 하는 방법이었다. 그런데 이걸 사용하는 방법에서도 문제가 있었다. 단순히 컴포넌트 최상단에서 훅을 사용하기만 하면 문제가 없는데, 이 훅을 컴포넌트에 있는 함수에서 사용하게 되면 '커스텀 훅은 일반 함수에서 사용할 수 없다'는 오류가 났다. 

 

그러던 중 이런 오류를 발견했다. 

 

몇번 더 삽질하면서 다음과 같은 질문을 해 보니, 커스텀 훅을 만들어서 사용할 때 내가 몰랐던 점에 대해서도 새롭게 알 수 있었다. 

 

딱 이런 상황이었다. GPT 피셜, 리액트의 (커스텀) 훅은 컴포넌트나 다른 훅의 최상위에서 호출되어야 하기에 컴포넌트 내에서 커스텀 훅을 정의하고 그걸 Button의 콜백함수에서 호출하는 것 자체는 훅의 규칙을 위반한 것이라고 한다. 훅의 사용규칙을 설명하는 공식문서를 찾아보니 같은 내용이 있었다. 

 

그렇다면 지금 작성된 코드를 다시 바꿔줘야 하겠다. 이쯤 되니 커스텀 훅에 대한 지식이 부족하다는 것이 많이 실감되었다... 커스텀 훅 관련 공식문서도 한번 읽어보자.

 

새롭게 정의된 문제 상황은 다음과 같다. 

const useHandleLocalToken = async () => {
    const token = await getAccessTokenFromLocal();
    const user = await getUserInfoFromLocal();
    const { useVerifyToken } = useApi();
    useVerifyToken(token).then(() => {
      setAccessToken(token);
      setUserId(user.userId);
      router.replace('(tabs)');
    });
  };

 

'Login' 컴포넌트 안에서 다음과 같은 'useHandleLocalToken' 이라는 커스텀 훅을 정의해두었다. 그리고 Button 컴포넌트의 onPress 함수로 해당 콜백함수가 호출되도록 하고 싶다. 

 

무슨 문제인지 감이 잡히지 않아서 멘토님께도 도움을 요청드렸다. 멘토님께서는 useVerifyToken을 사용할 때 token만 파라미터로 넘기지 말고 onSuccess라는 콜백함수도 같이 넘기는 형태로 바꿔 보라고 말씀해주셨다. 처음에는 무슨 의미인지 잘 이해가 안 되었는데, GPT를 거친 다음 예제 코드를 보고서 이 피드백이 무슨 의미였는지 더 정확히 이해가 되었다. 

 

그러니까 위의 코드를 이런 식으로 바꾸는 것이었다. 

// index.jsx
const useHandleLocalToken = async () => {
  const token = await getAccessTokenFromLocal();
  const user = await getUserInfoFromLocal();
  const { useVerifyToken } = useApi();
  useVerifyToken(token, () => {
    setAccessToken(token);
    setUserId(user.userId);
    router.replace('(tabs)');
  });
};
  
// useApi.js
const useVerifyToken = async (token, onSuccess) => {
  return useHandleRequest(() => axios.post(API_PATH.verify, { token }))
    .then(response => {
      onSuccess(response);
    })
    .catch(err => {
      Sentry.captureException(err);
    });
};

 

onSuccess 함수(함수가 성공적으로 수행되었을 시 호출하려는 함수) 하나를 인자로 받았을 뿐인데, 이렇게 하면 비동기 작업을 더 유연하게 처리할 수 있겠다는 생각이 든다..! 다만 아직 내가 비동기 함수 호출이나 콜백 등을 깊게 이해한 상태는 아닌 것 같다. 왜 저렇게만 바뀌었는데도 비동기 작업을 유연하게 처리할 수 있는 것이고, 저 상황에서 어떻게 하면 저런 해결책을 떠올릴 수 있을까? 라는 궁금증이 생긴다. 

 

그리고 이렇게 바뀌었어도 useHandleLocalToken 함수는 여전히 커스텀 훅 함수라서 Button 컴포넌트의 onPress 속성으로 전달할 수는 없다. 분명 멘토님이 주신 피드백에 실마리나 힌트가 있었을 것 같은데, 아직 나의 얕은 프론트 지식으론 한 번에 캐치할 수 없었던 것 같다..! 일단 수정된 코드가 담긴 링크를 전달드리고, 위 부분에 대해서 다시 여쭤봐야 할 것 같다. 


프론트 이슈가 잠시 막혔으니 백엔드로 Context Switching을 해 보자. 사실 프론트가 막혀서 그렇지 백엔드도 할 게 정말 많다! 이번 스프린트 때 내가 맡은 이슈들 중 백엔드 건은 크게 두 가지이다. 

 

1. 백엔드 API 서버 부하 테스트

2. EC2 AutoScaling 적용

 

당장 모레 멘토링이 있는데, 이때 전까지 프론트 막힌 부분과 이 두 가지의 이슈를 모두 해결하는 것을 목표로 잡아보자. 그래야 멘토링을 할 때 더 의미 있는 피드백을 받을 수 있을 것 같다. 그리고 그래야 남은 기간동안 예상치 못한 오류도 고치고 중간평가 준비도 할 수 있다...

 

암튼 백엔드 API 서버 부하 테스트 이슈부터 처리해 보자. 

GPT와 구글링에서 찾은 유용한 블로그 글을 참고하며 진행해 보겠다. 우선 장고 API 서버에 부하 테스트를 하기 위해서는 "부하 테스트 도구"를 뭘로 할지를 결정해야 한다. 블로그와 GPT에서는 모두 Locust를 선택했는데, 이유는 테스트 스크립트를 파이썬으로 작성할 수 있다는 장점이 있어서였다. 그렇다면 Locust를 사용해서 테스트를 해 보자. 필요하면 공식문서와 깃허브도 참고해 보자. 

 

시작하기 전에 궁금증이 생겼다. 예를 들면 투두를 생성하는 API에 대해 부하 테스트를 하면 투두가 생성된다. 그러면 그 데이터들은 어디에 저장되어야 할까? 테스트 DB에 저장되는 게 맞아 보이는데, 그러면 테스트 DB와 별도로 연결하는 작업이 필요하지 않을까? 이런 의문이 들었다. 

 

그렇지만 아직 Locust가 '부하 테스트 도구'라는 것 외에는 아무것도 이해하지 못했으므로... 일단 시작해 보자. Locust는 서드파티 라이브러리이기 때문에 pip로 간단히 설치할 수 있다. 

pip install locust

 

그리고 서버의 accounts 디렉토리 안에 locustfile.py 파일을 하나 만들어 두고, 가장 간단한 API를 테스트하는 로직을 작성해 보았다. 해당 API는 서버가 살아있는지 테스트용으로 만든 API로, 'hello world'라는 응답을 주는 것이 전부이다. 

from locust import HttpUser, task

class HelloWorldUser(HttpUser):

    @task
    def hello_world(self):
        self.client.get("/todos/todo?user_id=1")

 

공식문서의 QuickStart 부분을 보니 'locust' 명령어로 Locust 서버를 시작시키고(표현이 맞는지 모르겠다) localhost:8089로 접속하면 Locust의 WEB GUI를 볼 수 있다고 한다. 이때 주의할 점은 단일 파일로 부하 테스트를 시작하고 싶다면 파일의 이름을 locustfile.py로 하고 루트 디렉토리 바로 아래에 위치시켜야 라이브러리에서 인식할 수 있다. 

웹 GUI도 잘 뜬다!! 너무 신기하다... 암튼!

이제 host를 개발서버 엔드포인트로 지정해 주고 요청을 보내 보았다. 사실 해당 필드값이 정확히 뭘 의미하는지, 얼마나 설정해야 유의미할지를 몰라서 그냥 둘다 1로 설정하고 테스트가 되는지만 보기로 했다. 그러니까 지금은 1명의 유저가 요청을 보내는 상황에 대한 테스트가 될 것이다. 

 

GET으로 투두를 조회하는 API를 호출해 보았다. RPS는 Request Per Second로 서버가 현재 1초에 얼만큼의 요청을 처리하고 있는지를 나타내는 것이라고 이해했다. 요청의 95%는 110ms 이내에, 99%는 140ms 이내에 서버에서 응답이 리턴되는 것이라고 이해했다. 다만 이 경우는 유저에 맞는 투두 리스트를 단순 조회하는 API라서 더 복잡한 API는 별도의 테스트 로직을 작성해봐야 하겠다. 

 

 궁금한 점

1. 위에서 정의한 useHandleLocalToken 함수를 어떻게 Button의 onPress 이벤트가 발생했을 때 실행되도록 할 수 있을까?

2. RPS가 높을수록, 그리고 ms 값이 적을수록 서버가 요청을 잘 처리한다는 것은 알겠다. 우리 서비스의 경우는 어느 정도의 값을 목표로 하면 좋을까? 그리고 서버의 성능을 증가시키려면 어떤 작업이 필요할지도 궁금하다. 

 

 오늘 배운 것

어제 언급했던 prod와 dev 개발환경을 제대로 분리해줘야 하겠다. 그리고 그러기 위해서는, 이전에 멘토님한테 코드리뷰를 받았을 때 피드백으로 언급되었던 부분도 같이 고쳐줘야 하겠다. 

 

피드백으로 언급된 부분은 장고에서 환경변수를 주입하는 와중에 기본값을 'settings'가 아니라 'settings.dev' (직접 설정한 커스텀 파일의 경로)로 바꾼 부분이었다. 굳이 기본으로 주입되어 있는 값을 바꾸면, 사람들은 대부분 이 코드를 보지 않고 환경의 기본 설정은 settings로 되어 있겠거니 하는데 여기서 혼란을 줄 수 있다고 말씀하셨던 기억이 있다. 

 

물론 위의 방법처럼 settings.dev로 아예 DJANGO_SETTINGS_MODULE의 값을 바꿔놓으면 python manage.py runserver 명령어에 별도의 변수를 넣어주지 않아도 기본값으로 개발환경이 실행된다는 장점이 있으나, 차라리 매번 runserver를 할 때마다 --settings=settings.dev 처럼 변수를 직접 넣어주더라도 피드백대로 하는 것이 더 좋다고 말씀하셨던 기억이 났다. 

 

아무튼 그래서 기존에 prod와 dev 환경에서 겹치는 환경변수 값들을 넣어두었던 settings/base.py 파일을 기존의 위치인 settings.py로 다시 선언하고, settings.dev로 설정했던 DJANGO_SETTINGS_MODULE 환경변수의 값도 다시 초기값이었던 settings로 바꿔 설정해 두었다. 


이제는 개발환경과 프로덕션 환경별로 별도의 도커파일을 만들어서, 각 워크플로우의 yaml 파일에서 develop 브랜치에 push가 들어올 때는 Dockerfile-dev 버전을, main 브랜치에 push가 들어올 때는 Dockerfile-prod 버전을 실행시켜주면 되겠다. 

 

GPT가 알려준 방법들 중에, 환경별로 다른 Dockerfile을 사용하는 것이 제일 간단해 보여서 위의 방법을 사용해보려고 한다. 우선 두 개의 다른 도커 파일을 만들어 주자. 

 

그리고 아래와 같은 과정을 추가해서 각 워크플로우의 프로세스가 작성되어 있는 yaml 파일에 Dockerfile의 경로를 지정하는 작업을 추가해주었다. 

 

그런데 오류가 났다. 여기서 지정한 Dockerfile을 찾을 수 없다는 오류 같았다. 

 

GPT는 docker build 명령어를 실행할 때 -f 옵션을 붙여서 명시적으로 도커파일 경로를 지정하는 방식을 추천해주었다. 앞서 위의 'Set Dockerfile Path' 단계에서 DOCKERFILE_PATH 라는 변수에 도커파일의 경로를 넣어 주었으니, 'env.DOCKERFILE_PATH'로 도커파일의 경로를 빼서 사용할 수 있다는 말로 이해했다. 

docker build -f ${{ env.DOCKERFILE_PATH }} -t ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.ECR_REPOSITORY_NAME }}:${{ github.sha }} .

 

이렇게 입력하니 깃헙 워크플로우는 약 7-8분만에 성공했다고 떴다. 그런데 예전의 전적이 있어서인지, 이게 과연 최신으로 반영된 상태가 맞을지 의심이 들었다. 그래서 현재 ECS 서비스의 태스크가 사용하고 있는 태스크 정의가 최신인지 확인해 보았다. 

 

태스크는 28버전을 사용하고 있었는데 태스크 정의의 최신 버전은 29 버전이었다. 즉 태스크에서는 최신 버전을 사용하고 있지 않았다. 이런 경우 예전처럼 새 태스크 정의를 사용해서 태스크를 생성했다가 롤백했을 가능성이 있다. 

 

서비스의 이벤트 로그를 보니 'rolling back'이라는 부분에서 ECS가 새 태스크 실행을 시도했다가 롤백했음을 알 수 있었다. 이전의 경험으로 봤을 때 아마도 서버를 실행했을 때 모종의 오류가 발생해서 그랬을 가능성이 높아서, CloudWatch의 최신 이벤트 로그들을 살펴보았다. 

 

최신 이벤트 로그를 보니 역시나 Dockerfile에서 지정한 커맨드를 사용할 때 오류가 난 모양이었다. 

 

이 오류는 dev 환경변수가 모여있는 파이썬 파일의 경로가 잘못 설정되어서 난 오류였다.

 

당시 settings.base 파일을 없애고 그 내용을 settings.py 파일로 옮겨주면서, 처음에 settings 디렉토리와 settings 파일이 한 곳에 있어서 오류가 났었다. 그래서 해당 settings 디렉토리의 이름을 setting으로 바꿔주었었다. 그런데 도커파일에서는 여전히 onestep_be.settings.dev 파일을 불러오고 있어서, settings라는 디렉토리가 없기에 난 에러였다. 

 

이 부분을 수정하고 다시 워크플로우를 실행시키니, 워크플로우가 잘 성공하는 것은 물론이고 ECS 태스크에서도 가장 최신의 태스크 정의를 참조하고 있었다. 


이제 프론트 부분에서 남은 이슈를 해결할 차례였다. 지금까지 프론트에서는 API 요청을 보낼 때 헤더를 만드는 함수를 metadata() 라는 이름으로 정의해서 따로 사용하고 있었다. 여기까지는 문제가 없었는데, 문제는 metadata 함수에서 파라미터로 액세스 토큰값을 받는다는 점이었다. 

 

당시 액세스 토큰이 만료되면 자동으로 refresh API를 호출해서 새 액세스토큰 값을 받아오는 작업을 하고 있었는데, refresh API를 통해 새 액세스토큰 값을 받아오는 데는 성공했다. 문제는 그리고 요청을 다시 한 번 보낼 때, 기존의 만료된 액세스 토큰을 사용해서 요청을 보냈기 때문에 새 액세스토큰 값이 있어도 401 에러가 뜬다는 점이었다. 

 

그래서 매번 AsyncStorage라는 비동기 저장소를 이용해서 해당 저장소에 저장해둔 액세스 토큰값을 가져오는 식으로 작업했었다. 하지만 프론트 멘토님께 여쭤보니 매 요청마다 AsyncStorage 저장소를 조회하는 것은 성능 면에서 Context API에 저장된 값을 조회하는 것과는 차이가 날 수밖에 없다고 말씀해주셨다. 

 

왜냐하면 AsyncStorage 저장소 조회는 일종의 disk를 조회하는 작업이고, Context API 변수를 조회하는 것은 메모리 안의 값을 조회하는 작업이기 때문이다. 그래서 멘토님께서는 차라리 metadata 함수를 리액트 커스텀 훅으로 만들어서, Context API에 정의한 변수값을 사용할 수 있게 만드는 게 좋겠다고 말씀해주셨다. 

 

우선 당시 사용하고 있던 metadata 함수 코드는 다음과 같다. 

const metadata = async accessToken => {
  let headers = null;
  if (accessToken) {
    headers = {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${accessToken}`,
    };
  } else {
    const recentAccessToken = await AsyncStorage.getItem('accessToken');
    headers = {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${recentAccessToken}`,
    };
  }

  return { headers };
};

 

여기서 AsyncStorage를 조회하는 코드를 빼고, Context를 조회하는 코드를 넣으면 된다. 그런데 그냥 냅다 코드를 넣어버리면 'useContext라는 리액트 훅은 리액트 커스텀 훅이나 컴포넌트 안에서만 사용할 수 있다'는 에러가 난다. 그러므로 metadata 함수를 리액트 커스텀 훅으로 만들어 줘야 하겠다. 

 

리액트에서 어떤 함수를 커스텀 훅으로 만들고 싶다면, 함수 앞에 'use' 키워드를 붙여주면 리액트에서 해당 함수를 자동으로 커스텀 훅으로 인식한다. 즉 metadata를 useMetadata로 만들어 주었다. 

 

그랬더니 에러가 난다. 이 에러는 metadata 함수를 훅으로 바꿔서 나는 에러인지는 모르겠어서 확인이 필요할 것 같다. 

 

로그를 찍어보니 'Invalid hook call'이라는 부분에서 metadata 호출 부분 때문에 난 에러일 수도 있어 보인다. 

 

GPT에게 물어보니 커스텀 훅을 제대로 정의하지 않았거나, 커스텀 훅을 올바르게 사용하지 않는 경우 이런 에러 메시지가 표시된다고 했다. 현재 useMetadata() 함수는 axios 안에서 호출되고 있어서, 이 부분이 잘못되지는 않았는지 GPT에게 다시 물어보았다. 

 

GPT 피셜, axios 안에서 useMetadata라는 커스텀 훅을 호출하는 것은 잘못된 접근이라고 한다. 왜냐하면 axios는 컴포넌트도, 별도의 리액트 훅 함수도 아니기 때문이다... 그러려면 컴포넌트 안에서 useMetadata 훅을 사용해서 헤더 값을 받아온 뒤, 그 헤더 값을 사용해서 axios 요청을 보낼 때 사용해야 한다고 한다... 그런데 그러면 헤더 값에 꼭 최신 액세스 토큰값이 들어있으리라는 보장이 없어서 이 방법을 쓸 수는 없다. 

 

그러면 또 방법이 있는 게 handleRequest()라는 요청을 보내고 간단하게 에러를 처리하는 함수를 또 커스텀 리액트 훅으로 만들어서 useHandleRequest()라는 함수를 정의하고, 그 함수를 또 리액트 컴포넌트나 리액트 커스텀 훅 안에서 호출하는... 그런 마트료시카 같은 방법도 있겠다. 그런데 쓰다보니 굳이 번거롭게 그래야 하나? 그럼 다른 사람들은 대체 어떻게 API 요청을 보내면서 그 안에서 커스텀 훅을 사용하나... 라는 의문도 들었다. 

일단 물어보자

몇 번의 티키타카를 한 끝에 나름의 답을 얻었다. 

 

우선 현재 사용하고 있는 Api 코드를 useApi라는 훅으로 만들 수 있겠다. 그리고 useApi라는 커스텀 훅 안에서 여러 기존에 사용하고 있던 함수들을 정의한 다음에, 그 함수들을 export 하는 방식으로 사용해볼 수 있겠다. 

 

GPT가 준 예제 코드를 보면 다음과 같다. 

import { useCallback } from 'react';
import axios from 'axios';
import { useMetadata } from './useMetadata'; // Assuming this is the path to your useMetadata hook
import { API_PATH } from './config';

const useApi = () => {
  const { headers } = useMetadata();

  const fetchTodos = useCallback(async (userId) => {
    try {
      const response = await axios.get(`${API_PATH.todos}?user_id=${userId}`, { headers });
      return response.data;
    } catch (error) {
      // Handle error (similar to your handleRequest function)
      console.error(error);
      throw error;
    }
  }, [headers]);

  // Similar wrappers for other Api functions
  // addTodo, deleteTodo, etc.

  return {
    fetchTodos,
    // other API functions here...
  };
};

export default useApi;

 

그리고 컴포넌트 안에서 관련된 API를 호출하는 함수가 필요할 때는 다음과 같이 가져올 수 있겠다. 

const { fetchTodos } = useApi();

 

즉 현재 구조인 Api 파일은 코드가 잘 나눠져 있으나 안에서 커스텀 훅을 사용하기는 불가능한 구조이기 때문에, 앞으로 useMetadata와 같은 훅을 이 안에서 제한 없이 사용하려면 코드를 변경해 줄 필요가 있겠다. 

 

그럼 팀원들과 논의한 다음 코드를 변경해 보자. 팀원들도 이 방향이 좋을 것 같다고 해서 이 방법대로 가 보려고 한다. 이러려면 자잘하게 변경해줘야 하는 코드들이 꽤 있다. 

 

야심차게 Api를 useApi라는 커스텀 훅으로 바꾸는 작업을 시작했으나, 이내 또 다른 난관에 봉착했다. 

위에 GPT가 준 예시 코드처럼 useCallback이라는 리액트 훅 안에 원하는 함수를 넣었더니, 리액트 커스텀 훅은 콜백 함수(useCallback) 안에는 넣을 수 없다는 에러가 떴다. 그래서 콜백을 빼 보았더니 에러가 안 났다. 

 

문서를 찾아보니, useCallback은 리렌더링 사이에서 함수를 캐싱할 수 있도록 해 주는 리액트 훅이었다. 새삼 useCallback이 뭔지도 잘 모르고 사용했구나 싶어 조금 반성이 되었다. 

 

암튼 그렇게 했더니 또 다른 문제가 있었다. 사실 이 작업은 모두 "어떤 요청을 하더라도 최신의 액세스 토큰을 가지고 요청을 하는 것"이 목적이었다. 그러려면 결국은 요청이 처리되는 handleRequest()라는 함수에서 useMetadata()라는 커스텀 훅을 호출할 수 있어야 했다.

 

그렇다. 즉 handleRequest도 커스텀 훅으로 만들어야 한다는 뜻이었다. 그러면 useHandleRequest()를 정의해 주고, 그 useHandleRequest()라는 커스텀 훅 역시 커스텀 훅이나 컴포넌트 안에서만 사용될 수 있으므로 useHandleRequest()를 사용하는 모든 API 호출 로직에 있는 함수들을 다 훅으로 만들어준 뒤, 컴포넌트에서 그걸 호출하면 되겠다. 

 

쓰다보니 이게 맞나 싶다. 그러나 Context 내부의 변수를 참고하려면 useMetadata라는 훅이 반드시 필요하고, 또 API 호출 단에서 가장 최신 값의 헤더를 얻으려면 useHandleRequest()라는 커스텀 훅 안에서 useMetadata라는 훅을 호출해야 한다. 그러려면 useHandleRequest를 사용하는 다른 함수들도 전부 커스텀 훅으로 만들어야 하겠다. 

 

그리고 나중에 프론트 멘토님께 이 방향이 맞을지에 대해서도 피드백을 받아봐야 하겠다. 

 

...라고 생각하고 있었는데, useApi와 그 안에 선언된 커스텀 훅 함수들을 다른 곳에서 사용하는 과정에서 에러가 났다. 

 

에러가 난 이유는, 컴포넌트 안에 있는 훅 함수가 아닌 함수에서 커스텀 훅 함수를 호출하기 때문에 에러가 났다. 그런데 그 호출하는 함수를 또 마냥 커스텀 훅으로 만들 수는 없었다. 왜냐하면 해당 함수를 useEffect()라는 리액트 훅에서 호출하고 있었는데, 리액트 기본 훅에서는 커스텀 훅 함수를 호출할 수 없었기 때문이다(이것도 왜인지 모르겠다...). 

 

아무튼 그래서 찾은 결론은 다음과 같다. 

1. 커스텀 훅 함수를 호출하는 또 다른 커스텀 훅 함수를 컴포넌트 내에 정의한다. 

2. 해당 1번의 함수를 useCallback() 콜백 함수를 통해 호출하는 '그냥 함수'를 만든다. 

3. useEffect() 등에 함수를 사용해야 할 경우 2번 함수를 사용한다. 

 

그런데 문제는 왜 이 방법으로 하면 되는지를 모르겠다는 것이다. 아마도 지금은 리액트 훅 함수에 대한 기본적인 원리를 잘 이해하지 못해서 그런 것 같다. 어쨌든 이 방법이 잘 되긴 하니, 일단 이렇게 해 보고 차차 원리를 알아보자. 

 

궁금한 점 및 논의/보완점

1. git rebase의 원리가 궁금하다. git pull과 뭐가 다를까?

2. context API 안에 선언된 변수를 커스텀 훅이나 컴포넌트 안에서만 쓸 수밖에 없는 이유가 궁금하다

3. ECS에서 현재 github workflow로 인해 생성된 새 태스크로 기존 태스크를 교체하는 과정에서 무중단 배포가 일어나는 게 맞을까?

4. 팀원이 expo의 SecureStore라는 라이브러리를 추천해주었다. 이게 다른 localStorage나 AsyncStorage와는 또 어떻게 다른 것인지 궁금하다

5. 왜 리액트 기본 훅 함수 안에서는 리액트 커스텀 훅을 사용할 수 없도록 해 두었을까

6. tanstack-query의 useQuery를 사용할 때 queryFn 변수 값으로 리액트 커스텀 훅 함수를 넣는 방법이 없을까? 지금은 임시방편으로 useApi와 Api를 같이 만들어서 사용하고 있지만 이렇게 하면 useQuery()를 사용해서 데이터를 가져오는 도중 액세스 토큰이 만료가 되면 에러처리를 할 방법이 없다. 그래서 이건 결국 임시방편이고... tanstack-query에서 방법을 찾아서 제대로 처리해야 할 것 같다. 

 

 오늘 배운 것

어제의 작업을 통해서 배포 시간은 평균 20분에서 8분으로 약 10분 이상 단축되었다. 그러나 여전히 개발 서버가 develop 브랜치의 최신 내용을 반영하지 못하고 있었다. 이쯤 되면 혹시 develop 브랜치의 내용이 ECR에 안 반영되는 것이 아닌가 하는 의심도 들었는데, ECS 클러스터>서비스>태스크의 컨테이너 정보를 확인해보니 의문이 풀렸다. 

 

왜인지는 모르겠지만, 컨테이너는 ECR 레포지토리의 latest 태그가 붙은 이미지를 참조하고 있었다(참조한다는 표현이 맞는지 모르겠다).

 

그렇다면 다시 yaml 파일을 봐야 하겠다. 사실 지금은 yaml 파일을 이리저리 건드려보는 중이라서... 뭘 해도 어떤 근거로 무조건 develop 브랜치와 잘 연동될거다!는 확신은 없다. 그래도 바꿀 점이 또 있나 찾아본 결과, docker build 구문에서 기존에는 커맨드에 ECR 레포지토리의 값만 넣어주었는데, 막상 전체 주소는 이와 좀 다른 것 같았다. 그래서 이 값을 바꿔주었다. 

 

그리고 해당 아래의 값도 바꿔주었다. 

 

기존에 raw string으로 task definition을 입력해서 ecs-task-def.json 파일에 넣어주는데, 막상 해당 task-definition 필드를 보니 환경변수 값으로 되어있었다. 그보다는 방금 입력한 태스크 정의값이 들어있는 파일을 넣어 주는 것이 인식이 잘 될 것 같아서 값을 바꿔주었다. 


여전히 되지 않는다...(develop 브랜치와 개발 서버의 내용이 다르다) 다시 처음으로 돌아가서 GPT에게 질문을 해 보자. 

+ 검색하다가 yaml 파일의 설정이 어떻게 되어있는지를 알려주는 공식문서를 찾았다! 그동안 이게 뭔지 잘 모르면서 입력했던 설정값이 다 여기서 정의된 값들이었다. 

 

[공식문서]와 [파일]을 주고 GPT에게 문제를 내보았다

GPT는 크게 설정은 수정할 부분이 없으나, ECS 태스크 정의를 렌더링하는 과정에서 revision과 taskDefinitionArn 필드를 빼라고 알려주었다. 이유는 family 필드를 정의하면 이미 ECS에서 태스크 정의를 인식할 수 있고, revision 필드의 경우 수동으로 지정하기보다는 자동으로 (아마도 1씩 revision 값이 증가하도록) 지정하는 것이 원칙인 것 같았다. 

 

이렇게 또 yaml 파일을 변경하고 커밋을 올려보았는데, 아직도 개발 서버와 브랜치의 내용이 달랐다. 그리고 위에서와 같이 서비스가 어떤 태스크 정의를 사용하고 있는지도 보았는데, 또 이전 태스크 정의를 사용하고 있었다. 그래서 이번에는 강제로 서비스에서 사용하고 있는 태스크 정의를 바꿔주는 커맨드를 사용해 보았다. 

aws ecs update-service --cluster your-cluster-name --service your-service-name --task-definition your-task-definition-name

 

이 작업도 실패해서, 이번에는 ECS 클러스터>서비스에 들어가서 '서비스 업데이트'를 누르고 사용하는 태스크 정의를 최신 버전(현재는 27)으로 바꿔주었다. 

 

그런데 문득 이렇게 태스크 정의를 세세히 입력해 주었는데 '태스크 정의'에는 막상 등록한 json 파일 내용들이 잘 들어있고, 서비스에서 사용하는 태스크 정의는 정작 업데이트 되지 않았다는 점이 의아했다. 어쩌면 깃헙 워크플로우의 세부 단계에서 태스크 정의 렌더링(Render Task Definition)은 잘 되었는데 그 다음 작업(태스크 정의 등록 또는 ECS 서비스 배포)에서 무언가 오류가 나서 전체 작업이 롤백 되었을 가능성도 있겠다. 

 

실제로 ECS 클러스터>서비스의 '이벤트' 탭에서 찍힌 기록을 확인해보니 'roll back'이라는 문구가 보였다. ECS에서 새 태스크 정의를 통해 새 태스크를 만들고 이를 서비스와 연결하려다가 만약 실패하면 이 작업을 다시 롤백하는 것으로 보였다. 

 

그래서 롤백하기 전 시도했던 이벤트를 CloudWatch의 로그 그룹에서 찾아서, 구체적인 로그를 찾아보았다. 그랬더니 문제는 예상 외로 다른 곳에서 발생하고 있었다. 

 

 

이 부분은 aws.py라는 직접 만든 파이썬 파일에서 AWS Secrets Manager를 통해 환경변수들을 불러오는 부분이었다. 여기서 에러가 나고 있어서 이후에 ECS 클러스터에서 태스크가 정상적으로 실행되지 않았고, 그래서 다시 서비스가 기존 태스크를 사용하도록 롤백했을 가능성이 있다. 

 

문제를 찾아보기 위해서 AWS Secrets Manager와 develop 브랜치의 aws.py 파일을 확인해 보았다. 추측되는 원인 중 하나는, prod 값에 따라서 해당 파일에서 "AWS_SECRET_NAME_PROD" 라는 변수를 조회하게 될 수 있는데 이 값이 로컬 환경변수에는 있고 태스크를 정의하면서 선언해준 환경변수에는 없다는 것이었다. 

로컬의 .env 파일
태스크 정의 파일에서 주입한 환경변수들

그래서 태스크 정의 파일에다 하나의 환경변수를 더 넣어 주었다. 그리고 다시 로그를 봐야겠다. 


연결 성공했다! 즉 원인은 환경변수가 주입되지 않아서 runserver 명령어 실행 시 에러가 났고, 그것 때문에 다시 새 태스크 정의로 만든 태스크가 롤백이 되었던 것이다. 

위의 투두를 추천해주는 API가 계속 반영이 안 되어서 애를 먹었는데, 이제는 API 목록에 잘 뜬다. 

 

다만 한 가지 걱정되는 점은, 원래는 /swagger URL로 접속하면 django swagger 라이브러리에 의해 모든 API들을 편하게 볼 수 있는 페이지가 나오는데, 이게 더 이상 나오지 않는다는 것이다. (/swagger URL로 접속하면 오류는 안 나지만 빈 페이지가 나온다) 짚이는 원인으로는 settings에 설정한 DEBUG=False 값이 걸리는데, 원래 DEBUG 모드가 아니면 해당 창이 안 나오는 게 정상적인 것인지, 아니면 어떻게 잘 커스텀해서 DEBUG 모드가 아니어도 API를 모아보는 페이지를 보이게 할 수는 없는지도 알아봐야겠다. 

 

이제 이걸 참고해서 main 브랜치의 setting도 위와 같이 바꿔주면 되겠다. 


main 브랜치의 workflow 파일을 바꾸려다 깨달은 점인데, develop 브랜치에서 프로덕션 서버 세팅을 사용하고 있었다... 왜인지 찾아보니 이전에 Dockerfile에서 프로덕션 환경을 주입하는 명령어를 써 놓고는 까먹은 것이었다. 

 

그래서 이 환경을 분리하는 작업도 해 줘야 하겠다. 

 

기타 정보들

1. 관련 문서를 보니 yml 파일에서 github.sha 값을 사용하길래, 이게 커밋을 고유한 값으로 나타내는 것이라고만 알고 있었다. 그런데 SHA는 해싱 알고리즘이었다. 해싱과 암호화의 차이가 뭔가 싶어서 문서를 찾아보니, 해싱은 단방향이고 암호화는 양방향이라고 한다. 즉 커밋 해시값은 커밋의 내용(정확히 어떤 내용인지는 모르겠다. git diff 명령어에서 나오는 커밋에서 변경된 부분을 입력하는 것일 수도 있다고 추측해본다)을 SHA 해싱 알고리즘을 통해 해시 값으로 바꿔서 커밋을 고유하게 나타내기 위해서 사용한다고 이해했다. 

 

2. 참고할 수 있는 좋은 문서를 찾았다! 다음에 또 유사한 문제가 생길 경우 얘를 참고해야겠다. 

 

 궁금한 점

1. github settings에서 secrets와 env의 차이가 궁금하다. 

2. ECS에서 태스크가 실패했을 경우 롤백을 하는 방법이나 그 원리가 궁금하다. 

3. yaml 파일에서 커맨드로 브랜치에 따라 각기 다른 변수값을 주입하지 않고, 설정에서 GUI로 미리 브랜치별 환경변수를 설정하고 싶다. 

 

 

+ Recent posts