오늘 배운 것

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

 

기존의 팀원들과 나눠서 별도의 기능을 개발할 때, 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 화면이 뜨는 것이 관련이 있는지 궁금하다. 없다면 어떤 원인일지도 궁금하다. 

인프런 강의를 들으면서 내가 이해한 내용들과 궁금한 점들을 정리해 보았다.

(중간중간 이해되는 내용이 많아질 경우 계속 내용이 추가될 예정이다)

 

 웹 어플리케이션 서버(WAS)와 웹 서버(WS)의 차이점은 무엇일까

WS는 정적인 컨텐츠에 대한 요청을 처리하며, WAS는 웹 어플리케이션 로직을 통해 동적인 요청을 처리할 수 있다는 점이 가장 큰 차이이다. 그러나 요즘은 WS도 설정하기에 따라 꼭 정적 컨텐츠에 대한 요청만 처리하는 것은 아니라고 해서... 무 자르듯이 아주 정교하게 구분하기는 어렵다고 한다. 또한 예전 멘토링에서 말씀하신 것처럼 WAS 안에 WS가 있는 구조라고 한다. WS는 예를 들면 apache, nginx 등이 있고, WAS는 사용하는 프레임워크 별로 다양하다. 가령 django WAS, tomcat 등이 있다. 

 

그러면 WAS만 사용하면 되는 게 아닌가? 굳이 정적인 요청만 처리할 수 있는 WS는 왜 사용할까

요청별로 처리하는 데 드는 비용이 다르다. 가령 주문과 결제 로직을 사용하는 요청의 경우 처리하는 데 드는 시간과 드는 리소스가 더 크고, 반면 단순 이미지를 요청하는 경우는 리소스가 더 작다. 그런데 이 모든 요청을 WAS에게 요청할 경우 값싼 요청을 처리하는 데에도 똑같이 WAS의 리소스를 할당해야 하기 때문에, 이 경우 요청을 먼저 WS가 처리하게 하여서 정적 컨텐츠 요청의 경우는 WAS의 리소스를 아예 사용하지 않도록 할 수 있다. 

 

 WAS는 어떤 기능을 제공하는가

WAS의 큰 장점 중 하나는 서블릿을 지원한다는 것이다. 서블릿을 사용하게 되면 개발자는 비즈니스 로직에만 더 집중할 수 있다. 서블릿에서는 가령 요청이 들어왔을 때 그 요청에서 헤더를 분리하고, 요청의 경로를 확인하고, 파라미터들을 일일이 다 파싱하고... 등등의 작업을 해 준다. 만약 서블릿이 없다면 개발자는 비즈니스 로직 외에도 위의 작업에 필요한 로직을 다 만들어야 했을 것이다. 이럴 경우 작업 효율성이 떨어지고, 코드도 훨씬 더 복잡해졌을 수 있다. 

 

WAS에서는 서블릿을 지원한다. 요청이 오면 서블릿에서 그 요청을 먼저 처리해서, 위에서 언급한 url 경로를 확인하거나 파라미터 및 헤더를 파싱하는 등의 공통적이고 반복적인 작업을 먼저 처리한다. 그 다음 요청이나 응답을 뷰(MVC 패턴을 쓰는 경우)에서 처리하기 쉽게 요청 및 응답 객체를 새로 생성하여 리턴해 준다. 이는 모든 웹 서버 프레임워크에서 공통적인 것으로 보인다. 지금까지 주로 사용해온 django의 경우도 마찬가지로, view 함수에서는 항상 request 라는 객체를 받았고, 그 안에서 request.headers 등의 기본으로 제공되는 메소드를 통해 요청의 헤더나 파라미터 값들을 쉽게 확인할 수 있었다. 

 

또한 WAS는 멀티스레딩 기능도 제공한다. 싱글 스레드로 클라이언트의 요청을 처리하게 될 경우, 앞선 요청이 지연되는 경우 뒤의 요청들까지 전부 밀리게 된다. 그렇다고 해서 요청이 들어올 때마다 스레드를 생성하게 된다면 스레드를 생성하는 비용이 비싸고, 그렇게 만든 스레드를 재사용할 수 없어서 비효율적이며, 스레드 간 context switching 비용이 발생하며, 요청을 무한대로 제한 없이 받을 수 있어 과도한 요청이 들어오면 WAS가 다운될 수 있다는 단점이 있다. 그래서 보통은 스레드 여러 개를 모아 둔 스레드 풀을 만들어 두고, 요청이 들어오면 스레드 풀에서 필요한 스레드를 꺼내 쓰는 방식으로 사용한다. 

 

✅ 궁금한 점들

django WAS는 다른 WAS와는 어떻게 다를까. 서블릿은 어떻게 구현되어 있는지, 또 멀티스레딩은 파이썬에서 어떻게 구현했을지 궁금하다. 

톰캣에서 사용하는 스레드 풀의 기본 max thread 값은 200개라는데, 현재 개발하고 있는 서비스(onestep)의 경우는 그렇게 많은 스레드가 필요해 보이지는 않는다. 얼마 정도가 적당할지 궁금한데, 아마도 테스트를 통해서 알 수 있을 것 같다. 그러니 테스트도 해 보자.

 

'server-side > server' 카테고리의 다른 글

Mac 환경설정  (0) 2024.07.15
Software Release Life Cycle  (0) 2023.07.15
OAuth 2.0 기본원리  (0) 2022.09.26
인증(Authentication)  (0) 2022.07.14
linux: cron 사용해서 자동으로 스케줄 실행하기  (0) 2022.07.09

 오늘 배운 것

오늘은 어제 작업하다가 미처 처리하지 못한 인박스 뷰의 남은 이슈와, 프론트에서 다른 팀원들과 협업이나 논의가 필요한 일부 버그 빼고 나머지 기능들이 잘 동작하는지를 확인하는 것이 목표였다. 그리고 백엔드 이슈의 경우는 이제는 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 뺀 도메인으로 연결하기)

 

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

 오늘 배운 것

어제 포스팅에서 'Moment'와 'Date' 클래스가 서로 달라서 한 클래스에서 사용 가능한 메소드를 다른 클래스에서 사용하지 못하는 것은 맞지만, 구체적으로 어떻게 다른지는 알아보지 못한 것 같았다. 

 

moment의 경우 javascript 환경에서 사용할 수 있는 라이브러리에서 제공하는 클래스이며, date는 javascript에서 기본으로 제공하는 클래스이다. date와 moment의 가장 큰 차이 중 하나는 moment는 여러 timezone에서 사용 가능한 반면 date는 기본 UTC 시간대에서만 동작한다는 것이다.

 

그리고 moment의 공식문서를 읽다가 발견한 내용인데... 여러 timezone을 지원해야 할 경우 moment를 사용할 때 size(정확히 어떤 크기를 말하는것인지는 잘 모르겠다..!)가 커질 수 있다는 문제가 있다고 한다. 그래서 moment를 개발한 사람들이 더 이상 moment의 기능을 고도화하지 않을 예정이며, 오히려 Luxon같은 타 라이브러리로 쉽게 옮겨갈 수 있도록 지원한다는 내용도 있었다. 

공식문서에서 가져온 내용

 

어제 기존 코드와 바꾸려고 한 코드의 클래스 타입이 다르다는 것을 안 이유도, convertGmtToKst라는 함수를 사용하면 Date 객체를 리턴하는데, 이 객체는 moment에서는 제공하는 isSame()이라는 함수를 사용할 수 없기 때문이었다. 소스코드를 보니 moment에서 제공하는 기본 내장 함수들이 더 많긴 하더라. 캡처한 것보다 훨씬 많고 몇십 개 쯤 되었다. 

moment에는 isSame()이라는 시간대 비교 메소드가 있다
Date에는 없다(이 세개가 전부)

암튼 그러하고, moment의 공식 문서에서도 moment보다는 date-fns나 Luxon의 사용을 권장하고 있는 점이 신기했다. 하긴 모든 라이브러리들은 사용자의 편의성을 위해 만들어졌지... 더 편리한 다른 라이브러리가 있다면 사용자에게 그것을 권장하는 것도 맞겠다는 생각이 들었다. 그래서 현재는 일단 도입한 moment를 잘 사용하되, 공식문서에서 언급했던 그런 문제점들을 실감하게 된다면 위에서 언급한 다른 라이브러리를 고려해봐도 좋을 것 같다. 


오늘 처리하기 위한 프로젝트 이슈는 '인박스 뷰 만들기'이다. 하나의 뷰를 만드는 것이지만 기존에 만들어진 투데이 뷰의 UI나 로직을 대부분 그대로 가져와서 쓰기 때문에 실제로는 하나의 뷰를 바닥부터 만드는 것보다는 훨씬 적은 시간이 들 것 같다. 

 

다만 상태관리나 세부 컴포넌트 부분에서는 변경해 줄 부분들이 꽤 보였다. 첫째로, 인박스 뷰에서는 현재 선택된 날짜를 관리하는 상태 변수인 selectedDate 변수가 필요 없었다. 왜냐하면 인박스 뷰는 날짜들이 지정되지 않은 모든 투두들을 모아 보여주는 뷰이기 때문이다. 둘째로, 기존의 투두는 왼쪽 체크 버튼을 누르면 완료 처리가 가능했는데, 인박스에서는 투두를 완료 처리할 수 없도록 해야 했다. 그러려면 기존에 사용하던 DailyTodo라는 컴포넌트 대신에 다른 컴포넌트를 별도로 정의해서 사용해주어야 했다. 마지막으로, 인박스에서도 투두 컴포넌트를 클릭하면 모달창이 떠서 해당 투두를 수정하거나, 삭제하거나, 오늘 하거나, 다른 날 할 수 있도록 지정하는 기능이 필요했다. 

 

위 기능들을 구현하기 위해서 InboxTodos 컴포넌트를 만들었다. InboxTodos(인박스 투두 리스트를 보여주는 컴포넌트)와 DailyTodos(데일리 투두 리스트를 보여주는 컴포넌트)의 가장 큰 차이점은 현재 어떤 날짜가 선택되었는지를 나타나는 selectedDate라는 상태 변수가 InboxTodos에는 없다는 거였다. 사실 최대한 DailyTodos 컴포넌트를 재사용하고 싶었는데, 괜히 inbox={true} 같은 속성을 넣는 것보다는 별도의 컴포넌트로 정의하는 것이 더 상태관리가 복잡해지지 않는 방향이 아닐까 싶어서 이 방식을 선택했다. 하지만 이 역시 좋은 방향인지는 확실하지 않으니 다음 멘토링 때 멘토님께 조언을 구해보는 것이 좋겠다.

 

그리고 인박스 뷰에서 투두 컴포넌트를 사용하기 위해서는 InboxTodo 컴포넌트를 별도로 만들기로 했다. 사실 DailyTodo 컴포넌트에 inbox 속성을 추가해서 inbox이면 기본값인 체크 아이콘을 다른 아이콘으로 변경하는 방법도 있는데, 그러면 inbox 속성값이 true일 때는 DailyTodo 컴포넌트에 정의해 둔 제법 많은 상태관리 로직들이 아예 쓰일 일이 없었다. 그보다는 InboxTodo라는, 보이는 것은 거의 비슷하지만 상태관리 로직은 거의 없는 컴포넌트를 하나 만드는 것이 더 좋아 보였다. 

 

또한 모달창의 경우는 원래 있던 TodoModal 컴포넌트에 inbox 속성을 추가해보기로 했다. 굳이 모달창만 속성을 추가해서 재사용하고 나머지 InboxTodos나 InboxTodo 컴포넌트는 따로 구현하는 이유는, 내가 생각했을 때 상태관리 로직의 차이가 크기 때문이다. 즉 모달창은 인박스 뷰에서 쓰나 투데이 뷰에서 쓰나 상태관리 로직이 사용되는 정도는 비슷했다. 그러나 나머지 경우, 만약 컴포넌트를 재사용하고 인박스 뷰에서 쓸 경우, 투데이 뷰에서는 쓰이는 여러 상태관리 로직들이 inbox 속성값이 true가 되면 쓰이지 않게 되어버리는데, 이게 맞는 방향인가? 라는 의문이 들었다. 물론 이 논리에는 구체적인 이유나 근거는 없어서... 다음 멘토링 때 프론트 멘토님께 이런 판단이 괜찮을지 조언을 구해봐야겠다. 


백엔드 서버를 로컬에 띄워두고, 프론트 서버에서는 localhost로 요청을 보내면서 작업하고 있었다. 아직 개발 단계라 가끔씩 API와의 통신 오류 등이 날 때 로그를 확인하려면 이 방법이 더 편했기 때문이었다. 그런데 잠시 쉬고 저녁을 먹었다가 다시 작업하는데 이러한 오류가 났다. 이전에도 종종 있었던 오류로, Jwt에 있는 토큰 발행 시각(iAt, issuedAt)보다 컴퓨터의 현재 시각이 이르기 때문에 발생하는 오류였다. 

 

그래서 현재 컴퓨터에서 시간을 올바르기 가리키고 있는지도 잘 확인해 보았다. 이전에 멘토님께 한번 이 문제를 말씀드렸더니, 어쩌면 컴퓨터가 아니라 사용하는 에뮬레이터의 시간 설정에서 문제가 있을 수도 있다고 말씀해주셨었다. 그도 그럴 것이 어떨 때는 잘 되고, 또 다시 에뮬레이터를 재시작하면 안 되고... 그런 문제였기 때문이다. 만약 맥북의 시간 설정에 문제가 있었다면 오히려 항상 이런 오류가 떴어야 하겠다. 

 

GPT에게 물어보니 장고 서버와 맥북의 timezone을 제대로 설정해 보라는 식으로 답변해줬다. 맥북은 이미 timezone이 올바르게 설정되었는지 확인했으니, 장고 서버의 settings.py의 TIME_ZONE 변수의 값을 UTC에서 Asia/Seoul으로 바꿔 주었다. 오류가 어쩌다가 일어날 때도 있고 아닐 때도 있어서(...) 이 방법으로 완전히 해결된 것인지는 잘 모르겠지만, 일단 설정을 고쳐주니 잘 동작하였다. 

TIME_ZONE = 'Asia/Seoul'

 

그리고 공식문서를 찾아보니 장고에서 timezone을 다룰 때, DB에는 UTC 시간으로 저장해 놓은 다음 장고 서버 내부에서 객체에 timezone을 적용해서 end user에게 필요할 경우 그 값을 리턴해 준다고 나와있었다. 그리고 만약 서버에서 timezone을 이렇게 내부적으로 처리하지 않고, 그냥 timezone을 고려하지 않고 단일 UTC를 기준으로 사용하고 싶다면 settings.py에서 USE_TZ 변수값을 False로 바꿔주면 된다고 한다. 

 

그리고 설정을 잘 찾아보면 시간값을 DB에도 UTC가 아닌 다른 timezone으로 저장할 수 있는 것 같았지만 이를 권장하지는 않는다고 했다. 이유가 여러 나라에서 DST(daylight saving time)를 사용하기 때문이라는데, 설명을 읽어보니 DST는 하절기에는 시간이 더 앞으로 이동하고, 동절기에는 더 뒤로 이동하도록 조정한다는 것 같았다. 그래서 시간값을 UTC가 아닌 다른 timezone으로 지정하면 이렇게 1년에 두 번, 시간을 앞으로 또는 뒤로 더 이동시키는 시점에 timezone 관련 오류가 날 수 있다고 나와있어서 매우 신기했다. 

 

그리고 항상 잘 사용하던 파이썬의 datetime.datetime 객체에는 tzinfo라는 timezone 정보를 저장하는 속성이 있다고 한다. 만약 settings.py에 USE_TZ 설정이 True로 되어있다면, 이 속성을 통해 앞서 언급한 것처럼 datetime 등의 object에서 timezone 정보를 처리하는 것으로 이해했다. 즉 USE_TZ 설정이 True라면 생성되는 datetime 객체들은 전부 timezone-aware(timezone에 대한 정보를 갖고 있음)인 것이고, 반대의 경우 timezone-naive(timezone에 대한 정보를 갖고 있지 않음. 기본값은 UTC)인 것이다. 

 

즉 정리해보면 만약 엔드 유저에게 timezone 관련 정보를 리턴해야 할 경우, 그리고 USE_TZ 값이 true로 되어있는 경우, 처음에 DB에서는 UTC로 저장된 값을 꺼내온다. 그러나 datetime 타입 등의 객체를 사용하면서 장고 서버에서 자체적으로 이 객체 안에 tzinfo라는 속성을 넣어줘서 timezone을 인식할 수 있게 한다. 객체가 인식하는 timezone은 아마도 위에서 settings.py에 설정한 TIME_ZONE 변수의 값일 것이다. 그러면 해당 객체를 serializer 등을 통해서 응답으로 리턴할 때 엔드 유저에게는 timezone이 적용된 시간값이 리턴된다고 볼 수 있다. 

 

 궁금한 점

1. timezone에 관련해서 내가 이해한 것이 정확할까 궁금하다. 

'개발 일기장 > SWM Onestep' 카테고리의 다른 글

20240805 TIL: dev와 prod 환경 분리하기  (0) 2024.08.05
20240804 TIL  (0) 2024.08.04
20240802 TIL: 코드 참고하면서 react query로 api 호출하기  (2) 2024.08.02
20240801 TIL  (0) 2024.08.01
20240731 TIL  (0) 2024.07.31

+ Recent posts