오늘 배운 것

오늘은 어제 진행 중이던 이슈를 계속 진행해 볼 예정이다. 더불어 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

 오늘 배운 것

어제 프로덕션 서버를 배포하고, 이제 남은 작업은 배포된 프로덕션 서버를 사전에 구매한 도메인 주소와 연결시키면 된다. 그러기 위해서 Route53 서비스로 들어가 보았다. 

 

맨 밑에 있는, 구매한 도메인 이름 앞에 dev 서브도메인을 추가한 레코드는 현재 개발 서버와 잘 연결되어 있다. 사실 이 중에서 맨 밑에 CNAME 타입으로 연결된 레코드 빼고는 내가 연결한 게 아니고 어떻게 연결하는지도 잘 모르기 때문에... 일단 CNAME 타입의 레코드를 하나 더 생성해서 구매한 도메인과 연결해 보겠다. 

 

 

서브도메인 값을 아예 비워 두었더니 이런 오류가 났다. GPT에게 물어보니 CNAME 타입의 레코드는 서브도메인을 지정해야만 사용할 수 있으며, 루트 도메인(apex domain)에는 사용할 수 없다고 한다. 대신 ALIAS 타입의 레코드는 CNAME 타입과 유사하게 동작하지만 루트 도메인에서도 사용할 수 있다고 해서, 이 방법을 시도해 보았다. 

 

정확히는 레코드 타입에 'ALIAS'가 없어서, 'A' 타입을 클릭하고, '별칭 사용'을 활성화한 뒤, 다음과 같이 해당되는 리전, 해당되는 로드밸런서를 명시해 주었다. 

잘 된다!!!

잘 된다! 그런데 이 사이트는 HTTPS가 아니라 HTTP로 연결되는데, 이러면 보안 측면에서 불안전하다고 한다. 이 부분만 별도로 고칠 수 있다면 좋겠다. 

 

 

 

궁금한 점

1. Route53에 등록된 도메인을 어떻게 실행 중인 서버와 연결한다는 것일까? 생각해보면 도메인을 구매할 때 별도의 원격에서 잘 실행중인 서버를 구매하는 것은 아니고, 나중에 Route53 도메인에서 DNS 레코드의 값으로 지정할 수 있는 '값'을 받는다고 알고 있다. 내가 궁금한 것은 Route53에 도메인 레코드를 추가하거나 값을 바꿔주는 방식만으로 어떻게 구매한 도메인 주소를 입력하면 해당 로드밸런서의 DNS 주소로 연결이 되는 것인지가 궁금했다. 대략 찾아보니 여러 링크에 관련된 설명이 있는 것 같고 분명히 전공 지식에서도 배웠던 내용이라... 이 부분은 더 찾아보고 별도의 포스팅으로 올려야겠다. 

2. 왜 CNAME 타입의 레코드는 루트 도메인에는 사용할 수 없을까?

3. 찾아보니 ALIAS 타입 레코드는 CNAME 타입과 유사하게 동작하지만 루트 도메인에서도 사용할 수 있다고 한다. 이 둘의 차이는 또 무엇일까

4. 결과적으로 궁금한 점은 이 간단한 설정이 어떻게 도메인 검색을 여기까지 연결시켰느냐이다. 

5. HTTPS가 HTTP보다 보안에 좋다는 것은 알고 있는데 이걸 나 스스로 설명하지는 못하는 것 같다. 이것도 별도의 포스팅으로 만들어 보자. 

 

 오늘 배운 것

오늘 작업할 예정인 이슈들은 다음과 같다. 

1. 어제 작업하면서 알았던 인박스 뷰에서의 서브투두 날짜설정 이슈

2. prod와 dev로 개발환경 분리하기

3. prod 개발환경 실행시키기

 

1번은 팀원들이 모두 도착한 뒤에 논의가 가능하기 때문에 2번과 3번을 먼저 처리할 예정이다. 

 

기존의 onestep_be/settings.py의 파일의 내용을 전부 onestep_be/settings/base.py 파일을 만들어서 복사해준 뒤, 같은 디렉토리에 dev.py와 prod.py라는 파일을 만들었다. base.py 파일에는 dev와 prod 환경에서 공통적으로 사용하는 속성들을 모두 정의해 주고, 나머지 다른 설정을 dev랑 prod에만 해 볼 예정이다. 

 

또한 manage.py 및 asgi.py, wsgi.py에서 기본으로 장고의 세팅 파일을 어떤 파일로 참조할지 지정해주는 로직이 있다. 기본 값은 'onestep_be.settings' 였는데 이제는 파일의 경로가 변경되었으므로 'onestep_be.settings.dev' 경로로 바꿔주었더니 잘 동작하였다. 아래처럼 어떤 settings 파일을 사용할지를 이 코드에서 연결해주는 것 같아서 값을 바꿔주었다. 

 

이러면 python manage.py runserver 명령어를 입력했을 때는 DJANGO_SETTINGS_MODULE 변수에 기본값으로 할당된 dev 환경이 실행된다. 만약 runserver 명령어로 prod 환경을 실행시키고 싶다면, 명령어에 환경변수 값으로 prod 환경 파일의 경로를 넣어주면 된다. 

기본값 dev
prod 환경으로도 실행 가능

 

그리고 생각해보니 일반 개발환경에서는 python manage.py runserver가 실행되어야 하고, 나머지 경우에는 프로덕션 환경을 사용해야 한다. 그러므로 도커파일의 커맨드도 prod 환경으로 실행되는 커맨드로 바꿔주어야 하겠다. 그런데 또 생각해보니 개발서버는 dev 환경을 사용해야하고 프로덕션 서버는 prod 환경을 사용해야 한다. 그러면 도커파일의 커맨드도 각 브랜치별로 달라야 하는데... 이게 가능한가 싶기도 하다. 

 

그래서 또 생각해보니 굳이 dev 서버의 debug 모드를 true로 배포해야 할 필요가 있나 싶었다. 어차피 dev 서버를 호출하는 것도 프론트엔드의 앱일 텐데, 그러면 html로 디테일한 웹 메시지를 보여준다해도 어차피 api 통신 환경에서는 못 보기 때문이다. 혹시나 싶어 장고 공식문서의 settings DEBUG 속성을 찾아보았는데, DEBUG 속성을 true로 했을 때는 크게 두 가지 차이점이 있다고 한다: 

1. 에러가 발생했을 때 페이지로 오류의 구체적인 원인과 trace 등을 보여준다. 

2. 장고 서버에서 실행되는 모든 SQL 쿼리를 서버가 기억하고 있다. 이는 에러 트래킹을 하는 데는 유리하지만, 프로덕션 서버에서 사용할 경우 메모리를 많이 사용할 수 있다. 

 

아무튼 찾아보았는데 별 이상이 없어서, 도커파일에서 사용하는 커맨드를 기존의 python manage.py runserver에서 python manage.py runserver --settings=onestep_be.settings.prod로 바꿔 주었다. 


이제 프로덕션 환경에서 사용할 RDS를 생성해 주었다. 그 다음으로 AWS secrets manager에 방금 생성한 RDS의 퍼블릭 엔드포인트 주소와 사용자 이름 및 비밀번호를 넣어주어서 장고 서버에서 해당 값에 접근할 수 있도록 해 주면 된다. 그런데 이 작업을 하다보니 문제점이 생각났다. 처음엔 dev 서버와 prod 서버에서 모두 production 설정을 참조하면 되겠다고 막연히 생각했는데, 생각해보니 dev 서버에서는 개발 RDS DB를, prod 서버에서는 프로덕션 RDS DB를 참조해야 했다... 즉 이러나 저러나 dev 서버와 prod 서버는 다른 환경을 참조해야 하는 것이다. 

 

 

GPT에게 도움 요청을 해 보았다. GPT는 github action에서 사용하는 yml 파일에서 ECS 클러스터에서 사용할 태스크를 동적으로 정의하고, 그 과정에서 ECS 태스크의 환경변수로 DJANGO_SETTINGS_MODULE의 값으로 개발서버는 onestep_be.settings.dev를, 프로덕션 서버는 onestep_be.settings.prod를 값으로 주는 방법을 제시했다. 지금으로써는 이게 최선이지 않을까 싶어 이 방법을 적용해 보기로 했다. 

 

또한 yml 파일을 보니 프로덕션 RDS DB의 정보 말고도 추가로 필요한 정보가 있었다. 예를 들면 프로덕션 서버를 배포할 때 이미지를 올릴 ECR 레포지토리의 이름, ECS 클러스터의 이름 및 ECS 서비스의 이름이 필요해 보였다. 참고로 이 변수들은 AWS secrets manager 뿐만 아니라 github actions에도 secret으로 정의해야 yml 파일에서 인식할 수 있을 것 같았다. 이것들도 별도로 생성해준 뒤 yml 파일을 다시 작성해 보았다. 

 

그리고 생각해보니 개발환경과 프로덕션에서 사용하는 환경 변수가 다른데, 이걸 아예 별도의 보안 암호 이름을 두고 분리해야 하지 않을까 싶었다. 그래야 개발환경과 프로덕션 환경이 더 명확히 분리될 것 같았다. 그래서 기존의 개발환경에서 사용하던 onestep/dev는 그대로 두고, 프로덕션에서 사용하기 위해 onestep/prod 보안 암호를 생성해 주었다. 

 

그리고 production 세팅을 사용하도록 명령어에 값을 넣어준 뒤 runserver를 실행해 보았는데, 다음과 같은 에러가 났다. 아마 RDS DB에 접근하려는 database가 없기 때문인 것 같아서, RDS에 터미널을 통해 접속한 뒤 원하는 이름의 database를 생성해 주었다. 

 

다시 실행해 보니 잘 동작한다!

 

이제 다시 돌아가서 프로덕션 서버에서 사용할 ECS 레포지토리와 ECS 클러스터, ECS 태스크 정의를 생성해 주고, 로드밸런서를 생성해 주자. 그리고 ECS 서비스 설정을 마친 다음, 이 중에서 필요한 값들을 github action secret에 할당해 주자. 

 

main 브랜치(프로덕션 서버에 반영될 코드 내용)의 코드를 도커 이미지로 push할 ECR 레포지토리를 우선 만들어줬다.

 

그리고 해당 클러스터의 서비스에서 사용될 태스크 정의를 새로 만들어줬다. 

 

그리고 바로 ECS 서비스를 만드려고 했는데, 생각해보니 서비스를 만들려면 별도의 로드밸런서가 필요했고 로드밸런서를 생성하려면 별도의 대상 그룹이 필요했다. 그래서 대상 그룹부터 만들어주려고 했다. 

 

평화롭게 잘 만들어가던 중, VPC 서브넷의 IPv4 주소를 입력하라는 창이 떴다. 그래서 VPC 서비스로 가 보았다. 'VPC 서브넷의 IPv4 주소'가 무엇인지 모르겠어서 GPT에게 질문해보았다. 

 

GPT는 서브넷의 CIDR 블록에 있는 주소를 입력하라고 해서, 해당 주소를 입력해 주었다. 그렇게 대상 그룹을 생성하고, 로드밸런서를 생성하고, 서비스를 순차적으로 생성했다. 지금은 ECR 레포지토리에 올라온 도커 이미지가 아예 없어서 아마 ECS 서비스가 태스크를 실행할 수 없을 것이다. 

 

그리고 github action secrets에 관련 파일도 올려두었다. 기존에 개발환경에서 사용하는 관련 변수들 뒤에 _PROD를 붙여서 해당 변수들은 프로덕션 환경에서 사용하게끔 yaml 파일도 작성했다. 

이런 값들이 들어있다

 

처음엔 잘 돌아가나 싶더니 뭔가 이상했다. 10분이 지나고 ECS의 서비스를 확인해 봤는데 태스크가 실행되고 있지 않았다. 

 

혹시나 싶어 ECR에 도커 이미지가 올라왔나 확인해봤는데 올라오지 않았다. 아무래도 yaml 파일에서 ECR 레포지토리에 이미지를 업로드하는 과정에서 뭔가가 오류가 있는 듯 하다. 이 오류는 집에 가서 정신이 멀쩡해지면 다시 해결해 보겠다. 


오류가 아니었다! 집에 와서 확인해보니, 21분 만에(...) develop 브랜치에 있는 내용이 ECR을 거쳐 ECS를 통해 배포되었다... 근데 이게 과연 성공이라고 말할 수 있을지 모르겠다. 지금은 무중단 배포도 아닌 걸로 알고 있는데 2초도 아니고 20분이 붕 뜬다..? 지금 배포되는 방식이 중단 배포인지 무중단 배포인지 알아보고, 중단 배포라면 무중단 배포 방법을 알아보아야겠다. 

21분 실화냐

암튼 이 과정이 완료되고 나서, 기대에 부푼 마음으로 main 브랜치의 workflow를 실행시켜 보았다. 여기서는 에러가 났다. 로그를 보니 ECS 서비스 안의 컨테이너 이름이 yaml 파일과 일치하지 않은 것 같았다. 

 

알고보니 yaml 파일에 오류가 난 task 부분의 container name이 개발 서버에서 사용하는 컨테이너 이름으로 되어 있어서 바꿔줬다. 

 

다시 띄워보니 성공했다!

 

ECS 클러스터>서비스>태스크>네트워크의 ENI 정보>퍼블릭 DNS로 접속하니, 접속이 잘 되었다.

 

그런데 로드밸런서에 설정해 놓은 health check url로 들어가니 swagger가 나오지 않고 다음과 같은 django login 창이 떴다. 

 

현재 개발서버의 똑같은 URL로 들어가면 swagger 화면이 잘 나오기 때문에, 이는 prod와 dev의 환경 중 일부 값이 다른 것이 원인인 것 같다. 어쩌면 DEBUG 옵션일 수도 있겠다. 어쨌건 서버는 잘 뜨니 이 문제도 추후 확인해 보면 좋겠다. 

 

 궁금한 점

1. 이렇게 환경변수를 명령어로 할당하는 것 말고 다른 방식으로도 필요할 때만 prod 환경 설정을 사용하는 방법이 있을지 궁금하다. 

2. 로드 밸런서를 생성할 때 VPC와 서브넷과 로드밸런서가 구체적으로 어떤 관계가 있는 것인지 잘 모르겠다. 

3. DEBUG=False 옵션과 /swagger UI에서 django login 화면이 뜨는 것이 관련이 있는지 궁금하다. 없다면 어떤 원인일지도 궁금하다. 

 오늘 배운 것

오늘은 어제 작업하다가 미처 처리하지 못한 인박스 뷰의 남은 이슈와, 프론트에서 다른 팀원들과 협업이나 논의가 필요한 일부 버그 빼고 나머지 기능들이 잘 동작하는지를 확인하는 것이 목표였다. 그리고 백엔드 이슈의 경우는 이제는 ECS에서 fargate로 잘 돌아가고 있는 서버와 사전에 구매해둔 도메인을 AWS의 Route53을 이용해서 잘 연결해 두었었다. 문제는 지금까지 연결한 도메인은 앞에 'dev.'가 붙은 개발 환경 도메인이었고, 실제 개발 환경과 프로덕션 환경 최소 2개는 있어야 된다고 멘토님이 사전에 말씀하셨었다. 그래서 dev.를 뺀 도메인을 뉴 서버랑 연결해서 해당 이슈만 처리하면 급한 불은 끄게 되겠다. 

 

그러니까 크게 작업해야 할 부분은 세 가지겠다. 

1. 인박스 뷰 남은 이슈 처리

2. 프론트 최종 점검

3. 백엔드 프로덕션 환경 연결하기

 

우선은 1번 이슈인 인박스 뷰를 작업해 보려고 했다. 어제 못 처리하고 남았던 이슈는 두 가지였다. 

1-1. 인박스 뷰에서 카테고리가 달라짐에 따라 다른 투두들이 나타나도록 구현하기

1-2. 인박스에 있던 투두에서 날짜를 설정해 주면 인박스 뷰에서 사라지고 해당 날짜에 뜨도록 하기

 

1-1번 이슈의 경우, 인박스 리스트 뷰(InboxTodos)의 useEffect를 사용하는 부분에서 dependency array에 selectedCategory 변수를 추가해주니 해결되었다. 즉 selectedCategory(선택된 카테고리)의 값이 바뀔 때마다 보여줄 투두 리스트를 다시 필터링하도록 변경해 준 것이다. 

 

1-2번 이슈의 경우, 인박스에 있던 투두에 날짜를 설정하고 해당 날짜로 가 보니 투두가 잘 나왔다. 그런데 다시 인박스로 가 보니 투두가 안 사라지고 그대로 있었다. 인박스에 있는 투두를 보여주는 API를 호출해 보니 투두에 startDate와 endDate 값은 null이 아닌 설정한 날짜로 잘 할당되어 있었는데 인박스 API에 투두가 나타나는 오류가 있었다. 내일 다른 팀원에게 이슈를 공유하고 이 오류를 해결한 다음, 다시 잘 작동하는지 확인해 보면 되겠다. 


그래서 1번은 일단 이렇게 마무리하고, 2번 이슈를 작업하면서 1번에서 해결하지 못한 이슈들을 제외하고 또 오류가 나는 기능들이 있는지 살펴보았다. 그랬더니 완료/미완료 상황이 다음과 같았다. 

 

특히나 문제인 부분은 하위투두의 날짜를 변경하는 부분이었다. 현재 프론트 레포에서는 DailyTodos라는 리스트 컴포넌트 안에 DailyTodo라는 개별 컴포넌트가 있고, 그 안에 DailySubTodo라는 하위 투두 컴포넌트가 있다. 그러니까 어떤 날짜에 대한 투두를 불러올 때 그 날짜 안에 해당되는 투두가 없다면 당연히 서브투두는 불러와지지 않는 거였다. 이 로직은 당연히 필요하고 또 맞다고 생각한다. 

 

그러나 문제는 현재 캘린더 기준으로 날짜 필터링이 프론트에 구현되어 있지 않다는 거였다. 캘린더 모달창은 나타나는데, 예를 들어서 하위 투두의 날짜변경을 할 때, 상위투두의 startDate랑 endDate 사이의 날짜만 선택되도록 강제하는 기능을 구현하지는 않았었다. 그래서 그 외의 날짜를 사용자가 선택할 경우, 투두가 제 날짜에 나타나지 않는 거였다. 또한 그렇게 해서 날짜이동을 한 하위투두가 이전 날짜에서도 그대로 보였다. 이것은 프론트에서 서브투두를 맞게 필터링해야 하는 것으로 보인다...

 

그런데 이렇게 문제가 복잡하다 보니 작은 부분의 기능이나 UI를 위해서 너무 많은 것들(DB 설계, API, 프론트 필터링 로직 및 상태관리 등)이 필요하다는 생각이 들었다. 우리의 MVP에서 사실 상위 투두가 꼭 여러 날짜에 걸쳐서 진행되는 기능은 핵심 기능은 아니라는 생각이 들었다. 하지만 이것은 내 개인적인 생각일 뿐이라서, 내일 팀원들과 만나서 이 개인 이슈를 공유해 봐야겠다는 생각이 들었다. 

 

그래서 2번 이슈는 일단은 이렇게 일단락이 되었다. 논의가 길어지면 기획과도 연결될 수 있고 무엇보다 당장 확정된 합의가 없는데 이걸 섣불리 적용했다가 다시 엎게 되는 상황만은 피하고 싶어서... 일단은 안 손대고 공유만 하는 것이 맞겠다는 생각이 들었다. 이럴 때 어떻게 대응해야 할지도 멘토님께 다음에 여쭤봐야겠다. 일단 PR까지는 올려두었다. 


이제 남은 것은 3번 이슈이다. 작게는 이런 하위 이슈로 나눌 수 있을 것 같다. 

 

3-1. 프로덕션 환경용 RDS 인스턴스 시작 및 연결

3-2. 깃허브 액션의 기존 yaml 파일에서 타깃 브랜치를 develop으로 변경

3-3. 프로덕션 서버에서 사용할 로드밸런서 생성

3-4. main 브랜치에서 새로 ECR->ECS에 fargate로 서버 띄우는 yaml 파일 작성

3-5. 엔드포인트로 잘 접속되는지 확인

3-6. 도메인 연결하기(앞에 dev 뺀 도메인으로 연결하기)

 

이 이슈는 저녁을 먹고 나서, 다시 멀쩡해진 정신으로 처리해 봐야겠다. 

+ Recent posts