오늘 배운 것

오늘의 목표는 스프링 프로젝트에서 기존 장고 서버에서 사용하고 있던 RDS와 연결하는 것이다. 사실 스프링 프로젝트는 정말 오랜만이고 거의 처음과도 다르지 않아서 어떻게 시작해야 할지 감이 오지 않았다. 

모를 땐 GPT

기존에 스프링 프로젝트를 만들 때는 어떤 dependency가 필요할지 몰라서 최소한의 dependency인 Spring Web이랑 Lombok만 사용해서 만들었었다. 그런데 RDS랑 연결하기 위해선 DB Connection이 필요하고, 그러려면 Spring Data JPA와 MySQL Driver dependency가 추가로 필요했다(RDS가 MySQL로 되어있기 때문에 이게 필요하다). 

 

스프링 프로젝트에 대한 dependency를 추가하는 방법은 예전에 몇 번 해봐서 알고 있었다. mavenRepository 공식 사이트에 들어가서, 원하는 dependency를 검색한 다음 해당되는 코드 라인을 build.gradle 파일에 추가해 주면 되겠다. 

implementation 'org.springframework.data:spring-data-jpa'
implementation 'com.mysql:mysql-connector-j'

 

그리고 application.properties 파일과 관련된 설정도 해 줘야 하겠다. 해당 파일에서 DB와 연결하는 데 필요한 정보들을 정의해줄 수 있다. GPT 피셜, 다음과 같은 값들을 설정해 줘야 RDS에 연결이 가능하다고 한다. 

spring.datasource.url
spring.datasource.username
spring.datasource.password
spring.jpa.hibernate.ddl-auto
spring.jpa.show-sql
spring.jpa.properties.hibernate.dialect

 

spring.datasource.url은 말 그대로 연결하려는 DB의 엔드포인트이다. 다행히 서비스에서는 RDS를 사용하고 있고 퍼블릭 IPv4 엔드포인트도 정의되어 있으므로, 이 값을 그대로 가져와주면 되겠다. 

 

spring.datasource.username과 password는 DB에 접속하는 데 필요한 username과 password 값이다. 이 값은 장고 서버에서 정의하고 있는 DB_HOST와 DB_PASSWORD 값을 그대로 사용하면 되겠다. 

 

spring.jpa.hibernate.ddl-auto 값은 예전에 보았는데 생각이 잘 안 나서 문서를 찾아보았다. 기본적으로 스프링 JPA에서는 서버를 재시작할 때 'create-drop' 모드, 즉 매번 DB 테이블을 생성하고 다시 drop하는 것이 기본값으로 되어 있다고 한다. 지금 이 프로젝트는 사이드 프로젝트이며, 장고 서버나 RDS에 어떠한 영향도 주면 안 된다. 그러므로 이 값은 none으로 설정했다. 

 

값을 설정하는 것 자체는 문제가 없었는데, 문제는 이 파일을 그대로 깃허브에 올리면 안 된다는 점이었다. 그러면 application.properties 파일을 아예 올리지 말아야 할지, 아니면 해당 값들을 환경변수로 별도로 처리해야 할지 판단이 잘 서지 않았다. 

GPT는 두 방법 모두 가능하다고 말해주었는데, 내가 판단하기엔 아무리 혼자 개발한다고 해도 application.properties 파일을 아예 올리지 않는 것은 설정 파일을 아예 별도로 로컬에서만 관리하는 식이므로 너무 1인 개발에만 적합한, 확장이 어려운 방식이라는 생각이 들었다. 그래서 환경변수로 별도로 처리해주기로 했다. 

 

그러려면 java-dotenv라는 라이브러리가 별도로 필요했다. 정확히는 로컬에서 export 문으로 환경변수를 선언하면 해당 값을 별도의 라이브러리를 사용하지 않고도 application.properties 파일에서 사용할 수는 있었지만, 애초에 이 값은 프로젝트에서만 필요한 값인데 로컬에 별도로 정의하는 것은 맞지 않는다는 생각이 들었다. 

 

그래서 라이브러리를 어떻게 사용하면 좋을지를 또 물어보았다. 

 

GPT뿐만이 아니라 다른 블로그 글에서도 활용 방법을 세세하게 잘 알려주었다. 

implementation 'io.github.cdimascio:java-dotenv:5.2.2'

 

참고로 해당 라이브러리는 mvnRepository에서 검색해도 안 나오고, 깃허브 레포에서 나온 순수 서드파티 라이브러리인 듯 하다. 공식적으로 지원하는 기능이 아닌 것은 아쉽지만 서드파티이면 어떤가. 오히려 이렇게라도 기능이 있다는 게 감사하다. 제발 오류가 없기를..!

 

아무튼 build.gradle에 해당 라인을 넣어두고 수정사항을 잘 반영한 뒤, 추가로 해 줘야 할 작업이 있다. 

 

우선 src 디렉토리 바로 하위에 .env 파일을 추가하고, 불러오고 싶은 값들을 추가해 주자. 나의 경우는 다음과 같았다. 

 

그리고 이렇게만 하면 .env 파일에서 변수값을 자동으로 가져올 수는 없다고 해서 또 추가적인 작업이 필요하다고 한다. 

별도의 configuration 파일들을 모아두기 위해서 config 디렉토리를 만든 다음, 그 안에 DotenvConfig라는 Configuration 파일을 만들어 주었다. 이렇게 해야 .env 파일에 있는 환경변수 값들을 ${}으로 인식할 수 있는 것 같았다. 

import io.github.cdimascio.dotenv.Dotenv;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DotenvConfig {

    @Bean
    public Dotenv dotenv() {
        return Dotenv.load();
    }

}

 

필요한 설정은 다 되었겠거니 하고 서버를 시작해 보았는데 이런 오류가 났다. 아마도 .env 파일을 설정한 경로가 잘못된 것 같았다. 

 

역시 공식문서를 잘 읽어봐야 하겠다... GPT도, 블로그 글도 결국엔 공식문서를 따라잡을 수 없나보다. 

공식문서에 루트 디렉토리에 .env 파일을 생성하라고 딱 나와있었다. 바꿔서 생성하니 바로 서버가 떴다. 

 

궁금한 점 / 느낀 점 / 보완할 점

1. 프로젝트를 빌드할 때 build.gradle 파일이 구체적으로 어떤 역할을 하는지와 그 원리가 궁금하다. 소스 코드를 찾아보자

2. 스프링에서 application.properties 파일을 어떻게 참조하는지도 궁금하다. 문서가 참 방대해서 다 읽을 수는 없겠는데, 필요한 기능을 그때그때 찾아보고 적어도 내가 쓴 코드가 무슨 의미인지 알려고 해 보자

3. 나중에 spring-boot-dotenv 소스 코드도 한번 보고 어떻게 .env 파일에서 환경변수 값을 잘 가져오는지를 이해해 보자

4. spring.jpa.show-sql을 true로 하거나 false로 해야 하는 특별한 이유가 있을까? 단순히 어떤 SQL 쿼리가 실행되는지를 꼭 봐야 할 필요가 있는지 잘 이해되지 않아서 궁금하다

5. 기존에 잘 선언되어 있는 Django Model을 그대로 Spring Entity로 옮기고 싶다. 이걸 내가 소스 코드를 보면서 하나하나씩 바꾸는 방법도 있겠지만 과연 나와 비슷한 상황에 처한 사람이 한 명도 없었을까? 라는 생각이 든다. GPT와 티키타카를 좀 하면서 쉬운 방법이 있는지 찾아봐야겠다. 

 

 오늘 배운 것

어제 언급했던 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로 미리 브랜치별 환경변수를 설정하고 싶다. 

 

 

 오늘 배운 것

오늘은 어제 진행 중이던 이슈를 계속 진행해 볼 예정이다. 더불어 workflow의 진행이 20분동안 걸리는 것도 문제인 것 같아서, 멘토님께 여쭤보니 ECS에서 health check를 하는 과정에서 시간이 길게 걸릴 가능성이 있다고 말씀해주셨다. 

 

즉 서버 배포 관련해서 해결해야 할 이슈는 긴급도 순으로 정리하면 다음과 같겠다.

1. yaml 파일을 계속 수정하면서 develop 브랜치의 최신 내용이 개발서버에 반영되도록 하기

2. 로드밸런서의 health check에서 시간이 약 20분 정도 걸리는 문제 해결

3. github workflow에 pytest 및 runserver 테스트하는 로직 추가

4. 서버 성능 테스트 해 보기

 

오늘은 이 중에서 1번과, 가능하다면 2번까지 해 보는 것을 목표로 하면 좋을 것 같다. 특히 2번도 꽤 급한 게, 1번이 되는지를 빨리 확인하고 싶은데 계속 몇 분씩 기다려야 해서... 이것도 사실상 1번만큼 급한 이슈인 것 같다. 그냥 1-2번은 가리지 않고 같이 해결하면 될 것 같다. 

답답

github workflow의 로그를 보니 AWS ECS 클러스터>서비스>이벤트의 로그를 보라고 말해주어서, 이 로그를 보면 뭔가 힌트를 얻을 수 있을 것 같아서 찾아봤다. 

로그는 은근 친절하다

 

로그를 보면 (내 추측이지만) 태스크를 시작하고(has started 1 tasks), EC2에서 지정한 타겟 그룹(여기서는 onestep_dev)에 타깃을 등록하고(registered 1 targets in target-group), 해당 태스크에 대해서 커넥션을 차단(draining connections)하는 패턴의 반복인 것 같다. 

 

그리고 이 패턴은 5분마다 반복된다. 왜일까? 이것은 설정값과 관련이 있어 보인다. 사실 이전에 기본값으로 로드밸런서의 헬스체크 간격이 10초로 되어있는데, 이걸 300초로 바꿨었다. 지금 생각해보면 왜 그랬나 싶지만, 그때는 그것 때문에 로드밸런서에서 서버에 10초마다 계속 요청을 보낸다고 생각했었고, 그게 너무 서버에 쓸데없는 요청을 계속 날리는 게 아닌가 싶어서 그 값을 딱 5분인 300초로 설정해 놓았었다... 쓰다보니 이게 원인일 가능성이 높겠다. 

 

그렇다면 어서 바꿔보자. EC2>대상 그룹에 들어가서 해당 대상 그룹의 기존 헬스체크 기준을 보니 2번 연속 성공이고, 2번 연속 실패하면 fail로 간주하며, 각 요청의 간격은 300초(...)로 되어 있었다. 그래서 이 요청의 값을 2번 연속 성공, 4번 연속 실패하면 fail로 간주하며, 요각 요청에서 응답이 올 때까지는 5초간 기다리고, 각 요청의 간격은 10초로 설정해 주었다. 

 

로드밸런서의 설정을 해 주었어도 여전히 태스크가 fail이 나면 다시 실행되는 작업들이 5분 간격으로 진행되고 있었다. 그래서 정확히 뭘 해야할진 모르겠지만 로드밸런서의 타깃 그룹 설정 외에도 다른 것들을 해 주어야 할 것 같다. 마침 멘토님께서 참고하라고 보내주신 링크가 있어서, 이 링크를 읽어보고 내가 하지 않은 부분을 찾아보면 되겠다. 


참고한 문서에서는 로드밸런서의 헬스 체크 기준 체크, 로드 밸런서의 연결 해제(connection draining) 작업 관련 설정, ECR에서 도커 이미지를 업로드하는데 걸리는 시간 등을 고려하라고 말해주었다. 헬스 체크 기준은 위에 언급한 것처럼 각 요청 간의 시도 간격을 10초로 바꿔주어서 괜찮을 것 같았다. 도커 이미지의 경우는 크기도 크지 않을뿐더러 대부분의 github workflow의 시간은 ECS에서 배포하는 데 걸렸지 ECR에 도커 이미지를 업로드하는데 쓰고 있지 않았어서 이것도 원인으로 보지는 않았다. 몰랐던 부분은 '로드 밸런서의 연결 해제 과정'이었다. 

서버에서 캡처해온 사진입니다

 

덕분에 ECS 서비스를 설정할 때 로드밸런서를 지정하는 것이 무슨 의미인지 조금이나마 더 알 수 있었다. ECS 서비스가 disable 되는 과정은 다음과 같았다. 

 

ECS 서비스에서 로드밸런서에게 현재 로드밸런서에 연결 중인 클라이언트에 대해 연결을 끊으라고 요청한다. 그리고 로드밸런서가 클라이언트에 대해 커넥션을 끊었는지를 모니터링 한다. 이 요청을 받았을 때 로드밸런서는 바로 연결을 끊지 않고, 클라이언트(서버에 접속해 있는 브라우저나 앱 등의 클라이언트를 의미한다)가 keep-alive 커넥션을 끊었는지를 확인한다. 만약 끊지 않았다면 로드밸런서는 기본 설정된 값 동안 클라이언트가 keep-alive 커넥션을 끊기를 기다린다. 문서에서는 이 기본값(deregistration delay)이 300초로 설정되어 있다고 했다. 그렇다면 5분 뒤에 새로운 시도가 이어지는 것이 설명된다. 

 

이 기본 시간이 지나면 로드밸런서는 자신과 연결된 클라이언트의 연결을 강제로 종료시킨다. 그러면 ECS 서비스에서 이것을 알 것이고, 그러면 해당 컨테이너의 프로세스를 종료시키는 SIGTERM 시그널을 보낸다. 그런데 그림처럼 컨테이너에 직접 시그널을 보내는 것은 또 아닌 것 같고, 컨테이너와 같은 namespace? network? 어쨌든 같은 도메인이나 무언가에 속해 있는 Agent에게 해당 요청을 보내면 해당 agent가 컨테이너에게 SIGTERM 시그널(혹은 요청)을 보내는 방식으로 이해했다. 

서버에서 캡처해 온 사진입니다

 

이제 이 기본값이 300초로 설정된 deregistration delay 값을 바꿔주면 되겠다. 문서에서는 5초로 바꾸라고 해서 그렇게 해 보면 되겠다. 

GPT는 바로 구체적인 방법을 잘 제시해 주었다. EC2>대상 그룹>설정에서 '등록 취소 지연(드레이닝 간격)'의 값을 확인해 보니 300초로 되어 있었다! 이걸 바로 5초로 바꿔주었다. 

 

이제 임의의 변경사항을 develop 브랜치에 만들어서, github workflow의 실행 시간이 단축되는지를 확인해 보자. 

 

단축은 되었다! 더 단축될 수 있는지는 모르겠지만 확실히 시간은 줄었다. 여전히 개발 서버에 내용이 반영은 되지 않는다... 직접 도커 이미지를 pull 받아서 확인해 보고 싶었지만, 예전에 unauthorized 에러가 났었다. 이쯤 되면 ECR에 최신 이미지가 안 올라오는 것이 아닌가? 하는 추측도 든다. 이 부분에 대해서는 내일 확인해 보면 될 것 같다. 

 

 궁금한 것

1. AWS 내부에서 사용하는 시그널 같은 것이 있나보다. (SIGTERM 시그널을 보낸다는 점에서 그런 추측을 했다) 어떤 시그널이 있고, 어떻게 시그널을 보내도록 되어 있는 것인지도 궁금하다. 

 

 

 

 

 

 

 오늘 배운 것

1. github workflow는 오류 없이 실행되는데 정작 dev 서버와 develop 브랜치의 내용이 다르다. 어디에서 문제가 생긴 것일까? (개인적으로는 ECR의 도커 이미지가 최신이 아니거나 반영이 안 된 것일수도 있다고 생각한다)

 

오늘 아침에 갖게 된 의문점이자 새로 해결할 점들이다. 특히 1번의 경우는 현재 develop 브랜치에 머지 완료된 AI 투두 추천 API가 개발서버에 올라가있지 않기 때문에 서버에서 반영할 수 없다는 단점이 있었다. 

 

 

GPT에게 물어보니 여러 가능성을 제시해주었다. 예를 들면 yml 파일에서 정의한 태스크 정의가 반영되지 않았을 수도 있고, 도커 이미지를 빌드할 때 캐시를 사용해서 이전에 사용하고 있던 도커 이미지를 사용하고 있을 수 있다는 가능성이 있었다. 여러 가지 가능성을 고려해야 했다. 

 

우선은 도커 이미지를 빌드할 때 캐시를 사용하지 말고 가장 최근에 올린 브랜치의 내용을 반영시키기 위해서, 도커 빌드 커맨드에 --no-cache 옵션을 붙였다.

 

그리고 ECS 클러스터>서비스>태스크에 들어가서 현재 태스크가 사용하고 있는 태스크 정의가 가장 최신 버전인지도 확인했다. 현재까지 태스크 정의는 총 7개의 버전이 있는데, 다행히 가장 최근(7)버전을 사용하고 있었다. 

 

알고보니 yaml 파일에서 사용하는 태스크의 정의의 버전이 latest가 아닌 2로 고정되어 있는 것 같았다. 그래서 바꿔줬다. (yaml 안에 태스크 정의와 관련된 json 파일을 지금은 raw string으로 넣고 있는데, 프론트 배포가 완료되면 조만간 고쳐보려고 한다.)

 

그런데 여전히 개발 서버와 develop 브랜치가 달랐다. 멘토님께도 여쭤보니 ECR에 도커 이미지를 올릴 때 latest 태그를 달면 인식을 못 하고, 커밋별로 고유하게 나오는 sha 값을 태그로 달아줘야 개별 도커 이미지를 인식할 수 있다고 한다. 

 

기존에 :latest로 도커 이미지를 ECR에 push하거나 ECS에서 가져오는 태그들을 모두 latest 대신 고유한 커밋 스트링(표현이 맞는지 모르겠다)을 태그값으로 달도록 해 주었다. 이제 관련된 값을 다 바꿔주고, github workflow는 아마 큰 이상없이 올라갈 것이니 자고 나서 아니면 조금 있다가 이번에는 개발서버와 develop 브랜치의 내용이 같은지 확인해 보면 되겠다. 

 

 궁금한 점

1. 빌드가 너무 오래 걸리는 것 같은데(길면 20분이 걸린다) 어떻게 시간을 줄일 수 있을지 궁금하다. 

 

 오늘 배운 것

팀원들이 각자 할 일을 하고 있을 때, 나는 프론트에서 남은 기능상의 자잘한 버그들을 수정하기로 했다. 

 

기존의 팀원들과 나눠서 별도의 기능을 개발할 때, FlatList 기반의 UI Kitten에서 제공하는 List 컴포넌트를 써서 스크롤 기능도 구현했고, 다른 팀원은 DraggableFlatList 컴포넌트를 써서 리스트 컴포넌트를 드래그 앤 드롭하는 기능을 구현했었다. 문제는 이 둘을 합쳐서 스크롤 되면서도 드래그와 드롭이 되는 리스트를 구현하는 것이었다. 

 

기존의 투두를 나타내는 DailyTodo 컴포넌트는 View 컴포넌트로 감싸져 있었는데, 드래그와 드롭을 하려면 이 View를 ScaleDecorator로 감싸야 했다. 그러자 드래그 앤 드롭은 잘 동작하는 대신 잘 되던 스크롤 기능이 되지 않았고, 키보드가 활성화될 때 뷰가 그에 맞춰서 키보드를 피하는, KeyboardAvoidingView의 기능도 동작하지 않았다. 

 

Copilot의 도움으로, DraggableFlatList를 맨 바깥의 GestureHandlerRootView와 KeyboardAvoidingView로 감싸니 스크롤이 되었다! 문제는 키보드가 활성화되면서 텍스트 인풋 컴포넌트를 가린다는 점이었다... 이렇게 말이다. 저게 스크롤을 최대로 내린 것인데도 인풋 컴포넌트가 가려져서 나타나지 않는 상황이다. 

 

이전에 프론트 멘토님께 멘토링 받은 직후에는 Input 컴포넌트가 마치 카톡의 대화창 인풋 컴포넌트처럼 키보드 바로 위에 떴었다. 그때는 드래그 앤 드롭 구현 전이라 그 외의 별다른 문제는 없었었다. 코파일럿에게 물어보니, KeyboardAccessoryView를 사용해서 키보드가 활성화되면 항상 그 위에 나타나게 하고 싶은 컴포넌트를 감싸주었다. 

 

얼핏 보기에는 잘 된 것처럼 보였다. 그러나 키보드가 활성화되면 인풋 창이 저 맨 위로 가버리는 문제(...)가 있어서, 이 부분만 해결된다면 하위 이슈 하나는 완료될 것 같다. 


해결되었다!!

왜인지는 모르겠다.... 코파일럿과 GPT와 몇 번의 대화를 거친 후에 반신반의한 마음으로 적용해 보았다. 

 

일단 해결된 코드는 다음과 같다. 

return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <Fragment>
        <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
          <DraggableFlatList
            data={currentTodos}
            renderItem={renderTodo}
            onDragEnd={handleDragEnd}
            keyExtractor={item => item.id.toString()}
          />
        </KeyboardAvoidingView>
        <KeyboardAccessoryView alwaysVisible androidAdjustResize>
          <View style={styles.inputContainer}>
            <Input
              style={styles.input}
              placeholder="Add a new task"
              value={input}
              onChangeText={setInput}
              onSubmitEditing={handleSubmit}
            />
          </View>
        </KeyboardAccessoryView>
      </Fragment>
    </GestureHandlerRootView>
  );

 

문제를 해결한 과정(GPT 및 코파일럿과의 대화)은 다음과 같다. 

위의 문제 상황에서 이렇게 질문하니, 코파일럿은 KeyboardAccessoryView라는 서드파티 라이브러리를 추천해주었다. 말 그대로 키보드가 활성화될 때 보였으면 하는 컴포넌트를 KeyboardAccessoryView로 감싸면 키보드가 활성화될 때 그 안에 감싸진 컴포넌트를 보이게 해 주는 컴포넌트였다. 그리고 alwaysVisible 속성을 추가하면 키보드가 활성화되지 않아도 해당 컴포넌트가 보이게끔 해 주었다. 

알고보니 공식문서에 잘 나와있었다 머쓱

 

이제 이를 추가해서 Input 컴포넌트가 리스트에 가려지지 않고, 키보드가 활성화될 때도 보이게끔 하는 과정은 성공했다. 그 상황이 바로 위와 같은 상황(Input 컴포넌트가 보이긴 하는데 저 화면 위에 보이는 상황)이다. 이를 해결하기 위해서는 여러 번의 질문 끝에 다음과 같이 물어보았더니 잘 답해주었다. 

 

코드가 크게 변한 부분은 없었고, 해답은 KeyboardAccessoryView 컴포넌트에 androidAdjustResize 옵션을 추가하는 것이었다. 찾아보니 현재 사용하는 기기는 안드로이드 에뮬레이터이고 'android'가 앞에 붙은 걸로 봐서는 안드로이드 기기 한정으로 작동하는 기능일 것이라고 생각했다. 

 

ejected apps은 무엇일까?

공식문서를 잘 참고하자!

찾아보니 안드로이드 기기의 경우 아이폰과 다르게 작동하나보다. 저번에 프론트 멘토님과 멘토링 할 때도 이와 비슷한 문제로 android/app/src/main/AndroidMainfest.xml 파일의 androidAdjustResize 옵션을 변경해 본 적이 있었다. 뭔가 중요한 파일인 것 같아서 공식문서를 대강 봤는데 내용이 방대했다. 나는 그 중에서도 androidAdjustResize 옵션을 설정하는 부분이 아주 조금 궁금했으므로, 관련 문서만 아주 대강 훑어보았다. 

<activity android:windowSoftInputMode="adjustResize">
// 많은 설정들을 생략했다.

 

해당 코드는 <activity> 태그에 있었으므로, <activity> 부분의 문서만 보았다. 

 

해당 속성의 설명에 적힌 내용은 딱 내가 원하는 대로 작동하는 키보드의 모습이었다. 이렇게 2가지의 이슈(드래그 앤 드롭 적용했을 때 화면 스크롤도 되게 하기, Input 컴포넌트가 항상 키보드 바로 위에서 활성화되게 하기)를 해결하였다. 

 

궁금한 점들

1. "VirtualizedLists should never be nested inside plain ScrollViews with the same orientation because it can break windowing and other functionality - use another VirtualizedList-backed container instead." 이 오류는 왜 나는 걸까? ScrollView와 FlatList 모두 스크롤 가능한 리스트 뷰를 제공하는 게 아닌가? 둘의 차이점이 뭐길래 FlatList로 컴포넌트를 감싸면 이런 오류가 없는데 ScrollView로 감싸면 이런 오류가 뜨는 것인지 궁금하다. 

2. (이 포스트와 관련은 없지만) 왜 react hook 함수는 컴포넌트 내부와 다른 react hook 함수 내부에서만 사용할 수 있게 제한을 걸어 놓았을지도 궁금하다. 

 

 오늘 배운 것

자동으로 액세스토큰 갱신하는 것은 어제 잘 해결된 이슈인 줄 알았는데 아니었다. 왜냐하면 어제 당시 작업할 때 액세스 토큰이 만료되는 데 30분이나 걸려서, 개발서버에서 값을 1분으로 두고 과연 1분 뒤에 액세스토큰이 만료되어도 API 호출이 잘 되는지를 실험해 보았었다. 그런데 그 당시에 잘 되어서, 그러면 handleRequest() 함수의 에러 처리 로직이 잘 동작하는 게 아닐까 하고 넘겼는데, 알고보니 애초에 로컬 서버가 아니라 개발 서버(액세스토큰 lifetime을 30분으로 하고 배포했었음)에 요청을 보내고 있었던 것이다. 즉 멀쩡히 동작한 이유는 애초에 액세스토큰이 만료되지 않아서였던 것이다. 그래서 다시 BASE_URL 값을 로컬 URL로 바꾸고 시도하니 401 오류가 났다. 

 

원인은 401 에러가 발생했을 때 API 서버에서 내려주는 메시지가 에러 핸들링 로직의 메시지와 정확히 일치하지 않아서, 즉 if 조건문으로 토큰이 만료되어서 에러가 난 경우가 정확히 잡히고 있지 않기 때문이었다. 그래서 에러 메시지 자체를 콘솔 로그로 찍어서 확인해 보았고, 그 결과 토큰 만료로 인한 에러는 if문에서 잘 잡혔다. 즉 토큰 만료로 에러가 났을 때 작성해둔 if문을 타고 토큰 갱신 API를 한번 호출한 뒤, 다시 시도하는 로직이 실행되었다.

 

그런데 여기서도 에러가 났다. 서버의 로그를 보니 refreshToken API에서 400 에러가 나고 있었다. 클라이언트에서 해당 API에 요청을 잘못 보내고 있는 것이었다. 이 refreshToken API는 simplejwt 라이브러리에서 기본적으로 제공하는 view여서 왜 400 에러를 리턴하는지 파악하기 위해 뷰 내부의 소스코드를 살펴보았다. 

 

Serializer 코드를 보니 필드명이 refresh, access 였다. 나는 refreshToken, accessToken으로 보내주고 있었어서 이 부분을 수정하였더니 정상적으로 응답하였다. 

 

그런데 그러고 난 다음에도 토큰을 갱신한 다음에 다시 시도하는, 즉 토큰이 필요했던 기존 요청에서는 여전히 401 에러가 떴다. 비동기 저장소(AsyncStorage)에 새로 받아온 액세스 토큰의 값을 잘 저장했는데도 오류가 나는 것이다. 알고보니 에러 핸들링 로직에서는 이전에 했었던 요청을 그대로 반복하고 있었다. 즉 아무리 새로 액세스 토큰값을 받아왔어도, 그 값이 아닌 이전 액세스토큰 값으로 요청을 보내고 있어서 401 에러를 받았던 것이다. 

 

그러면 매번 액세스토큰의 최근 값을 참조하게 하면 되지 않나? 라고 생각할 수 있는데, 그러려면 방법이 있긴 하다. 바로 metadata() 함수를 수정해서, 매번 리액트에서 사용하는 비동기 저장소인 AsyncStorage에서 accessToken 값을 가져오는 것이다. 그런데 이 방법은 요청을 보낼 때마다 비동기 저장소를 조회해야 해서 비효율적이라는 단점이 있다. 

 

그래서 해당 metadata 함수에서 useContext API를 사용하려고 시도해 보았다. 그런데 이렇게 시도하니까 위와 같은 에러가 났다. 즉 useContext는 컴포넌트 안이나 리액트 훅 함수 내부에서만 사용할 수 있기 때문에 사용할 수 없다는 것이었다. 여기서 막힌 상황이다. 

 

팀원들과 같이 방법을 찾아보다가 일단은 AsyncStorage를 계속 참고하는 방법으로 제일 간단히 문제가 해결될 것 같아서 일단은 그 방법으로 해 보기로 했다. 코드를 깊게 파 보지는 못했는데, 라이브러리 단의 코드만 봤을 때는 window의 localStorage를 조회하는 식으로 코드가 작성된 것 같았다. 


이상한 점이 있다. 위의 방법을 사용하니 몇 분째 (이번엔 로컬 서버에 연결된 것도 확인했고, 액세스 토큰의 lifetime이 1분인 것도 확인했다) 에러 없이 여러 요청들이 앱 화면에도 잘 반영되고, API 서버에도 API 호출 로그가 잘 찍힌다. 그런데 분명 액세스 토큰이 만료되면 토큰 갱신 API(/auth/token/refresh)를 호출하도록 설정해 두었는데 로그가 없다. 무언가가 잘못된 것인지, 아니면 토큰의 액세스 lifetime이 1분으로 반영되지 않은 것인지는 확인해 보아야 할 것 같다. 하지만 잘 동작한다...!

 

 궁금한 점

1. window.localStorage() 로직을 실행할 때의 비용(로직의 복잡도나 시간)은 어느 정도일까? 그리고 내부 로직은 어떻게 또 구현되어있을지도 궁금하다. 

2. simplejwt 라이브러리에서 그냥 AccessToken과 SlidingToken의 차이는 무엇일까?

 

 오늘 배운 것

현재 서버에는 액세스 및 리프레시 토큰을 발급하는 API, 토큰이 유효한지 확인하는 API, 그리고 액세스 토큰이 만료되었을 경우 리프레시 토큰을 제시하면 새로운 액세스 토큰을 제공하는 API가 있다. 그러나 프론트 앱 서버에서는 현재 토큰이 만료되었을 경우, 리프레시 토큰을 통해 액세스 토큰을 갱신하는 별도의 작업은 하지 않고 있어서 이 작업이 필요하다고 느꼈다. 

 

우선은 프론트 코드에서 토큰을 갱신하는 작업을 하는 로직 작성을 위해 API_PATH 변수에 액세스 토큰을 갱신해주는 API 엔드포인트를 입력하고, 해당 API를 호출하기 위한 커스텀 훅도 만들어 주었다. 

renew: `${BASE_URL}/auth/token/refresh/`,
renewToken: refreshToken => {
  return handleRequest(() => axios.post(API_PATH.renew, { refreshToken }));
},

 

그리고 백엔드 서버에 가서, 해당 API를 어떤 방식으로 호출해야 하는지 알아보았다. 

 

해당 TokenRefreshView에서는 TokenRefreshSerializer를 사용하고 있었고, 해당 serializer에서는 refresh와 access 토큰 2개의 파라미터를 받고 있었다. 

 

TokenViewBase 함수가 어떻게 동작하는지 살펴보니, 핵심 로직은 post 메소드에 있는 것 같았다. 사용하려는 serializer가 유효한지 판단한 후, 해당 serializer의 validated_data를 가져오는 식이었다. 그렇다면 TokenRefreshSerializer의 validate 메소드를 보면 되겠다. validate 메소드에서는 RefreshToken 클래스에 request에서 보낸 정보를 담아 가져오고 있었다. 

 

아무튼 결론은, TokenRefreshView에 POST 방식으로(토큰 관련 데이터를 보내는 것이므로 GET은 아닐 것이라고 추측했다) 리프레시 토큰과 액세스 토큰 데이터를 보내면 되겠다. 


그럼 이제 프론트에서 해당 API로 요청을 보내보자. 주의할 점은 "기존에 다른 API에서 보낸 요청이 토큰 만료로 인해 실패했을 때" 해당 API를 호출해야 하기 때문에, 에러 처리 로직에서 해당 API를 호출하게 될 것이다. 사실 하나의 API에서만 이 작업을 하려면 그냥 queryClient를 사용할 때 onError 로직에 함수를 추가해주면 된다. 그런데 그게 아니라 거의 대부분의 API에서 이 작업을 공통적으로 해야 하므로, 로직을 공통화할 필요가 있다. 

 

그래서 블로그를 찾아보니, 여기서는 queryClient를 별도의 파일에, QueryClientProvider라는 컴포넌트로 QueryClient를 감싸서 그 안에서 기본 에러 로직을 작성해주는 식으로 작업하고 있었다. 그런데 이렇게 하는 경우, QueryClientProvider를 정의한 클래스와 실제 사용하는 클래스가 서로 다른 곳에 위치해 있기 때문에 useRef 훅을 사용해서 해당 QueryClient의 값을 계속 참고하도록 했다. 

 

그런데 우리는 이미 QueryClient를 정의한 곳에서 사용까지 하고 있었기 때문에 이 로직이 불필요하다고 생각해서, 그냥 new QueryClient()로 QueryClient를 생성하는 시점에 default option으로 정의해 주기로 했다. 그런데 그게 또 오류가 났다. 몇 번의 삽질 끝에, useMutation()을 사용해서 custom hook을 만드는 방식으로는 어쨌든 로직을 계속 반복해줘야 한다는 결론이 났다. 

 

그래서 모든 로직이 거쳐가는 handleRequest()라는, 각 API 요청별로 아주 간단한 에러 처리를 하는, 모든 요청이 공통적으로 지나가는 함수에서 try/catch 로직 부분에 다음과 같은 코드를 추가해 주었다. 

const handleRequest = async request => {
  try {
    const response = await request();
    return response.data;
  } catch (err) {
    console.log(JSON.stringify(err.response, null, 2));
    if (
      err.response.status === 401 &&
      err.response.data.message === 'Token expired'
    ) {
      try {
        const responseData = await Api.renewToken();
        AsyncStorage.setItem('accessToken', responseData.accessToken);
        AsyncStorage.setItem('refreshToken', responseData.refreshToken);
        const secondRequest = await request();
        return secondRequest.data;
      } catch (refreshError) {
        if (refreshError.response.status === 401) {
          router.replace('index');
        } else {
          throw refreshError;
        }
      }
    } else {
      throw err;
    }
  }
};

 

 궁금한 점

1. Simplejwt 라이브러리의 동작 원리가 궁금하다. 어떻게 이 설정들을 settings 및 urls에 명시하는 것 만으로도 토큰을 발급 및 갱신할 수 있었을까?

2. SimpleJwt 라이브러리에서 왜 굳이 RefreshToken 클래스에서 property로 accessToken을 넣었을지 궁금하다. 그냥 별도로 RefreshToken과 AccessToken을 만들어서 관리하는 방법은 별로였을까?

3. useRef는 구체적으로 언제 필요한 것일까? 잘 모르겠다. 

 

참고한 사이트

https://velog.io/@heeppyea/QueryClient-%EA%B8%B0%EB%B3%B8-%EC%84%A4%EC%A0%95-%ED%95%B4%EC%A3%BC%EA%B8%B0

+ Recent posts