오늘 배운 것

어제 언급했던 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에서 방법을 찾아서 제대로 처리해야 할 것 같다. 

 

+ Recent posts