오늘 배운 것

어제 있었던 이슈는 특수 상황이기도 했지만, 프론트 멘토님의 조언을 참고하면 두 가지의 해결 방안이 있는 것으로 보였다.

1. 프로덕션과 개발 DB를 같이 쓰기

2. 개발환경 앱 빌드와 프로덕션 앱 빌드 환경을 분리하기

 

사실 2번의 의미를 정확히는 이해하지 못했다. 빌드 환경을 분리한다는 것은 어떤 의미일까? 아예 다른 환경에서 앱이 실행되는 거라면, 그렇다면 개발 환경에서 사용되는 자원(AsyncStorage 등)과 프로덕션 환경에서의 자원이 분리되는 의미라고 이해했다.

 

이 경우 프로덕션 앱 빌드를 하다가 개발 앱 빌드를 하더라도 둘이 자원을 공유하지 않으니 기존 환경에서의 AsyncStorage에 기존 토큰이 남아있을 리 없다. 

 

사실 1번의 방법도 가능은 하지만, 그래도 원칙상 DB를 서로 분리해두고 싶었다. 그래야 사용자 데이터와 개발하면서 쌓은 데이터를 서로 분리할 수 있을 것 같았다. 

 

여튼 이 문제는 급한 문제가 아니기도 하면서, 여러 가지 해결 방법을 써볼 수 있을 것 같았다. 그래서 일단은 잠시 보류하고 원래 작업하던 사용자 문의 폼을 적용하는 이슈로 돌아와 보자. 


첫 번째 문제는 '구글 폼'이라는 글자를 눌렀는데 구글 폼이 안 열리는 문제였다. 이 문제는 알고보니 RN의 Text 컴포넌트를 누르고 싶다면 onClick이 아니라 onPress 속성을 사용했어야 했다. 즉 속성명을 잘못 사용해서 발생한 문제였다. 

잘 뜬다!

 

두 번째는 문의하기를 누르면 헤더에 'settingsContactView'라는 jsx 파일의 이름이 그대로 뜨는 문제였다. 이 부분은 tabs와 관련이 있을 것 같았다. 

 

'expo router stack.screen' 이라고 검색했더니 나온 공식문서를 참고해봤다. 우리 프론트 프젝에서는 라우팅 라이브러리로 'expo-router'를 사용하기 때문이다. 

 

암튼 그랬더니 <Stack.Screen /> 이라는 컴포넌트 안에 options 이라는 속성값으로 'title' 값을 지정해주면 된다고 나와있었다. expo-router에서는 Stack이라는 컴포넌트를 기준으로 앱에서 navigation을 진행한다. 구현되어 있는 걸 보진 않았지만, 실제 스택처럼 라우팅 기록을 저장하고 필요 시 취소(롤백)하는 식으로 사용하는 것 같았다. 

 

아래와 같은 코드를 _layout.tsx 파일의 Stack 리스트 맨 밑의 원소로 추가해 주었다. 그랬더니 '문의' 화면으로 잘 바뀌었다.

<Stack.Screen
  name="settingsContactView"
  options={{
  headerTitle: '문의',
  headerTitleAlign: 'center',
  }}
/>

 

무사히 PR도 올렸다. 


이제는 다음 이슈인 다국어 지원을 할 차례이다. 

 

'react native 다국어 지원' 이라고만 검색했는데 굉장히 많은 포스트들이 나왔다. 그중 한 개를 클릭해서 보니, 다국어 지원에는 두 가지의 라이브러리가 필요한 것으로 보였다. 

 

첫 번째는 react-native-localize로, 현재 앱이 실행중인 국가의 코드를 얻어오는 라이브러리이다. 사용자의 현 위치에 따라서 자동으로 언어를 설정해 주려면 필요한 라이브러리이다. 두 번째는 i18next, i18next-react로 이것이 다국어 라이브러리라고 한다.

 

공식문서의 Getting Started 가이드와 심화 가이드를 보면서 진행해 보자. 우선 'npm install'을 통해 필요한 i18next, react-i18next 라이브러리를 설치하자. 

npm install react-i18next i18next react-native-localize --save

 

그 다음으로는 앱의 루트 파일에(공식문서에서는 App이 위치한 파일) i18을 초기화하는 코드를 작성해 주어야 했다. 우리는 기본으로 App 파일이 아니라 조금 다른 라우팅 시스템을 사용해서 폴더 구조가 조금은 다른데, 어쨌든 _layout.jsx 파일이 App.js의 역할을 하는 것은 맞다. (위의 이슈에서 Stack.Screen 컴포넌트를 추가한 파일이다.) 

 

해당 파일(_layout.jsx)에다가 다음과 같이 일단 예제 코드를 복붙해 주었다. 

import i18n from 'i18next';
import { getLocales } from 'react-native-localize';

i18n.use(initReactI18next).init({
  resources: {
    en: {
      translation: {
        'Welcome to React': 'Welcome to React and react-i18next',
      },
    },
  },
  lng: getLocales()[0].languageCode,
  fallbackLng: 'en',
  interpolation: {
    escapeValue: false,
  },
});

 

그리고 'resources' 속성 안에는 언어별로 dictionary 형태로 어떤 언어를 사용할지에 대한 정보가 들어가게 되는데, 공식문서에서는 이를 별도의 json 파일로 뺄 것을 추천하고 있었다. 그렇게 해야 코드가 복잡해지지 않을 것 같았다. 'fallbackLng'는 모종의 이유로 다국어 전환이 실패했을 때 어떤 언어를 보여줄지를 의미하는 것 같았다. 

 

그리고 react-native-localize에서 얻은 getLocales() 함수로 현재 접속한 위치에서 사용하는 기본 언어를 띄워서 lng(language) 파라미터 값으로 세팅해 준다. 

 

그리고 다시 실행을 하려는데, 웬걸 본적 없는 에러가 났다. 버전 미스매치 에러인데, 문제는 나는 저 라이브러리를 최근에 손댄 적이 없었다. 아마 직접적인 원인보다는 다른 이유로 난 에러 같았다.

 

에러 페이지깃허브 이슈를 돌아다니며 여러 명령어들을 시도해 보았다. 깃허브 이슈를 참고한 결과 이 이슈는 라이브러리 자체의 의존성 문제보다는 캐시로 인해서 뭔가 데이터가 꼬였을 가능성이 있었다. 

 

아래 명령어를 실행해 보고, 에뮬레이터에 있는 앱을 지웠다가 다시 빌드를 실행하니 그제서야 잘 되더라. 

rm -rf node_modules && npm cache clean --force && npm install && watchman watch-del-all && rm -rf $TMPDIR/haste-map-* && rm -rf $TMPDIR/metro-cache

 

 오늘 배운 것

오늘 팀원들과 모여서 스프린트 회의를 하면서 개발 진행상황을 공유하는데 이상한 점이 있었다.

바로 프론트 팀원이 개발 환경에서 앱을 실행했다가 바로 프로덕션 환경에서 실행했을 때, 전혀 다른 두 아이디로 유저 정보가 나오는 오류가 있었다. 

 

이 문제가 신기했던 것은, 개발 환경 -> 프로덕션 환경으로 실행할 때에 나오는 유저 정보와 프로덕션 환경 -> 개발 환경으로 바꿔서 실행할 때에 나오는 유저 정보가 다르다는 거였다. 

 

알고보니 이 문제의 원인은 다음과 같았다. (개발 환경 -> 프로덕션 환경의 경우)

1. 개발 환경에서 로그인하면서 발급된 액세스토큰을 AsyncStorage에 저장한다. 

2. 프로덕션 환경으로 다시 앱을 실행했을 때 기존에 AsyncStorage에 저장된 액세스토큰이 있는지를 확인하게 된다. 

3. 이때 1번에서 저장한 액세스토큰으로 접근을 시도한다. 

 

여기서 1번에서 AsyncStorage에 저장한 토큰을 디코딩해보면 user_id 값이 저장되어 있었다. 그런데 개발 DB에 해당 user_id로 저장된 유저도 있었고, 프로덕션 DB에 해당 user_id로 저장된 유저도 모두 있었던 것이다. 

 

그래서 두 케이스(개발->프로덕션, 프로덕션->개발)에서 나타나는 이메일이 달랐던 것이다. 

 

그래서 생각해본 해결 방법으로는 다음과 같다. 

1. 프로덕션과 개발 서버의 액세스토큰이 서로 사용 가능해서 생긴 문제이니, 액세스토큰이 호환되지 않도록 한다. 

2. 스태프 계정은 단순 액세스토큰 및 구글로그인으로 접근 불가능하게 한다. 

3. 프로덕션과 개발 서버가 같은 DB를 사용하게 하도록 한다. 

4. 특수한 상황에서 생긴 문제이니 해결하지 않는다. 

 

 오늘 배운 것

어제 만들고 피드백을 받은 폼을 적용하면 된다. '설정' 페이지에서 '문의하기' 버튼을 누르면 폼 링크를 안내하면 되겠다. 

현재 설정 화면은 이렇게 되어 있는데 누르면 아무 변화가 없이 껍데기로 만들어 놓은 상황이다. 이 중에서 '문의' 버튼을 클릭했을 때 폼 링크를 띄워줘야 한다. 

 

그렇게 하기 위해서 기존에는 id 값과 title 값만 있었던 data 배열에 해당 항목을 누르면 호출될 함수를 추가해 주었다. 

const data = [
    {
      title: '내 정보',
      id: 1,
      handlePress: () => {},	// 추가한 속성
    },
    {
      title: '언어 변경',
      id: 2,
      handlePress: () => {},	// 추가한 속성
    },
    {
      title: '문의',
      id: 3,
      handlePress: () => {},	// 추가한 속성
    },
  ];

 

여기서 지금 작업할 부분은 '문의' 부분이다. 이 버튼을 누르면 나타날 페이지 하나를 새로 만들고, 그 페이지로 라우팅시키는 코드를 작성해 보자. 

 

위의 id:3번의 handlePress 함수를 다음과 같이 바꿔주었다. 

handlePress: () => {
  router.push('settingsContactView');
}

 

그리고 'settingsContactView'에 대한 파일도 만들어 주었다. 

const settingsContactView = () => {
  const handleGoogleFormPress = () => {
    Linking.openURL(googleFormUrl);
  };

  return (
    <>
      <IconRegistry icons={EvaIconsPack} />
      <ApplicationProvider {...eva} theme={eva.light}>
        <SafeAreaView style={styles.container}>
          <Layout style={styles.layout} level="1">
            <Text style={styles.text}>
              <Text style={styles.link} onClick={handleGoogleFormPress}>
                구글 폼
              </Text>
              으로 문의해 주세요.
            </Text>
          </Layout>
        </SafeAreaView>
      </ApplicationProvider>
    </>
  );
};

export default settingsContactView;

 

그 결과 위에서의 '문의' 버튼을 누르면 아래와 같이 뜬다.

잘 동작하는 것 같지만 두 가지의 문제가 있다. 

1. 헤더의 'settingsContactView'를 '문의'로 바꿔야 한다. 

2. '구글 폼'을 누르면 아무리 에뮬레이터에서 실행 중이더라도 구글 폼이 웹 상에서 열려야 하는데 열리지 않는다. 

 

이 두 문제를 내일 해결해 보려고 한다. 


아 그리고 앱이 런칭되었다..!! 물론 아직 기능을 붙이고 자잘한 오류가 있으면 고치는 단계지만, 구글 플레이스토어 심사가 오래 걸린다는 말을 많이 들어서 사실 몇 주 전에 개발과 같이 이 런칭을 병행하고 있었다. 

어제 드디어 런칭이 됐다는 소식을 듣고 비록 내가 런칭한 건 아니지만 얼마나 뿌듯하던지... 앱의 링크를 슬쩍 남겨본다. 

https://play.google.com/store/apps/details?id=com.safezone.onestep&pcampaignid=web_share

 

 오늘 배운 것

어제 작업했던 비동기 뷰로 변환하는 일은 1차적으로는 끝냈다. 일단 올려보고 에러가 있다면 수정해보면 되겠다.

 

이제는 버그나 요청사항에 대한 사용자 문의 폼을 만들어보자. 기존에는 직접 프론트에 폼을 만드는 걸 생각했는데 멘토님께서도 그냥 구글폼으로 만들면 더 편하지 않겠냐고 하셨고, 꼭 프론트에서 폼을 직접 구현할 필요가 없었기에 구글폼을 사용하기로 결정했다. 이 부분은 직접적인 개발은 없기에 조금은 기획 쪽에 가깝다고 생각했다. 

 

그렇다면 어떤 내용이 구글폼에 들어가야 할까? 우선 사용자의 신원을 파악할 수 있는 이메일(우리 서비스에 로그인했을 때 사용했던 것)과, 어떤 버그가 있는지, 아니면 어떤 요청사항이 있는지를 적을 수 있게 해야 하겠다. 일단 생각해본 질문들은 다음과 같다. 

 

1. (필수) 로그인할 때 사용했던 이메일을 알려주세요. 

2. (필수) 어떤 종류의 문의를 하시는지 알려주세요. 

3. (선택) 피드백에 대해서 설명해 주시면 감사하겠습니다. 

4. (선택) 관련 스크린샷이나 영상이 있으시다면 첨부해주시면 감사하겠습니다. 

 

일단 생각나는 문항은 이 정도이다. 우선은 팀 공동 이메일로 접속한 다음 예시 폼을 만들어 주었다. 그리고 팀원들에게도 피드백을 받아서 문항을 수정해보면 될 것 같다. 

 

 오늘 배운 것

오늘은 비동기 뷰로 변환하기 위해서, gunicorn을 통해 WSGI 기반으로 동작하는 서버를 uvicorn, gunicorn을 같이 사용하여 ASGI 기반으로 동작하도록 변환해 줄 것이다. 

 

공식문서를 참고해서 기존 커맨드를 다음과 같이 변경해 주었다. 

# 기존 커맨드
gunicorn -w 2 --timeout 300 -b 0.0.0.0:8000 onestep_be.wsgi:application
# 새 커맨드
python -m gunicorn -w 2 -b 0.0.0.0:8000 onestep_be.asgi:application -k uvicorn_worker.UvicornWorker

 

-b는 --bind의 약자로, 0.0.0.0:8000 부분을 추가해주지 않으면 오직 localhost에서 오는 요청만 받는 것이 기본값으로 되어있다. 실제로 그래서 예전에 오류가 있었기에, 꼭 이 -b 옵션을 붙여주자. 

 

또한 기존 커맨드는 gunicorn 기반으로 wsgi.py 코드를 실행하는 반면 새 커맨드는 uvicorn 기반으로 asgi.py 코드를 실행한다. 두 코드는 뭐가 다를까? 

 

# wsgi.py
import os

from django.core.wsgi import get_wsgi_application

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

application = get_wsgi_application()
# asgi.py
import os

from django.core.asgi import get_asgi_application

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

application = get_asgi_application()

 

wsgi.py는 django.core.wsgi에서 get_wsgi_application()을 실행하는 반면 asgi.py는 django.core.asgi에서 get_asgi_application()을 실행하는 것이 유일한 차이였다. 함수 안을 보자. 

 

내부 로직도 비슷하게 둘 다 각각 WSGIHandler, ASGIHandler를 호출하고 있었고, 두 핸들러는 모두 BaseHandler를 상속받고 있었다. BaseHandler의 로직은 복잡해서 다 이해하지는 못했지만, 핸들러가 호출되면 기본적으로 __call__ 메소드가 호출되고, 각각의 두 핸들러는 이걸 오버라이딩 한 것으로 보였다. 그래서 일단은 두 핸들러의 __call__ 메소드 부분만 가져와 보았다. 

# wsgi.py
class WSGIHandler(base.BaseHandler):
    request_class = WSGIRequest

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.load_middleware()

    def __call__(self, environ, start_response):
        set_script_prefix(get_script_name(environ))
        signals.request_started.send(sender=self.__class__, environ=environ)
        request = self.request_class(environ)
        response = self.get_response(request)

        response._handler_class = self.__class__

        status = "%d %s" % (response.status_code, response.reason_phrase)
        response_headers = [
            *response.items(),
            *(("Set-Cookie", c.output(header="")) for c in response.cookies.values()),
        ]
        start_response(status, response_headers)
        if getattr(response, "file_to_stream", None) is not None and environ.get(
            "wsgi.file_wrapper"
        ):
            # If `wsgi.file_wrapper` is used the WSGI server does not call
            # .close on the response, but on the file wrapper. Patch it to use
            # response.close instead which takes care of closing all files.
            response.file_to_stream.close = response.close
            response = environ["wsgi.file_wrapper"](
                response.file_to_stream, response.block_size
            )
        return response
# asgi.py
class ASGIHandler(base.BaseHandler):
    """Handler for ASGI requests."""

    request_class = ASGIRequest
    # Size to chunk response bodies into for multiple response messages.
    chunk_size = 2**16

    def __init__(self):
        super().__init__()
        self.load_middleware(is_async=True)

    async def __call__(self, scope, receive, send):
        """
        Async entrypoint - parses the request and hands off to get_response.
        """
        # Serve only HTTP connections.
        # FIXME: Allow to override this.
        if scope["type"] != "http":
            raise ValueError(
                "Django can only handle ASGI/HTTP connections, not %s." % scope["type"]
            )

        async with ThreadSensitiveContext():
            await self.handle(scope, receive, send)

    async def handle(self, scope, receive, send):
        """
        Handles the ASGI request. Called via the __call__ method.
        """
        # Receive the HTTP request body as a stream object.
        try:
            body_file = await self.read_body(receive)
        except RequestAborted:
            return
        # Request is complete and can be served.
        set_script_prefix(get_script_prefix(scope))
        await signals.request_started.asend(sender=self.__class__, scope=scope)
        # Get the request and check for basic issues.
        request, error_response = self.create_request(scope, body_file)
        if request is None:
            body_file.close()
            await self.send_response(error_response, send)
            await sync_to_async(error_response.close)()
            return

        async def process_request(request, send):
            response = await self.run_get_response(request)
            try:
                await self.send_response(response, send)
            except asyncio.CancelledError:
                # Client disconnected during send_response (ignore exception).
                pass

            return response

        # Try to catch a disconnect while getting response.
        tasks = [
            # Check the status of these tasks and (optionally) terminate them
            # in this order. The listen_for_disconnect() task goes first
            # because it should not raise unexpected errors that would prevent
            # us from cancelling process_request().
            asyncio.create_task(self.listen_for_disconnect(receive)),
            asyncio.create_task(process_request(request, send)),
        ]
        await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
        # Now wait on both tasks (they may have both finished by now).
        for task in tasks:
            if task.done():
                try:
                    task.result()
                except RequestAborted:
                    # Ignore client disconnects.
                    pass
                except AssertionError:
                    body_file.close()
                    raise
            else:
                # Allow views to handle cancellation.
                task.cancel()
                try:
                    await task
                except asyncio.CancelledError:
                    # Task re-raised the CancelledError as expected.
                    pass

        try:
            response = tasks[1].result()
        except asyncio.CancelledError:
            await signals.request_finished.asend(sender=self.__class__)
        else:
            await sync_to_async(response.close)()

        body_file.close()

 

asgi.py는 asyncio라는 비동기 관련 모듈을 이용해서 handle() 함수에서 메인 로직을 실행하는 것으로 보였다. 그리고 응답 body를 여러 개의 메시지로 쪼갤 수 있다는 것을 감안해서(coroutine과 연관이 있는 듯 하다) chunk_size라는 변수도 선언해 준 것으로 보인다. 그리고 기본값으로 ASGI의 경우는 HTTP 요청만 실행하는 것으로 보였다. 이유가 왜인지는 모르겠다. 

 

 궁금한 점

1. 왜 ASGIHandler는 HTTP 요청만 서빙할 수 있도록 해 두었을까

2. asyncio라는 모듈은 장고뿐만 아니라 파이썬 내에서 사용되는 것으로 보이는데 이 모듈은 어떤 역할을 하는지도 알아보자.

 

 오늘 배운 것

오늘은 드디어 알림 개발을 완료했다(버그가 있으면 수정해야 하니 일단은 1차 완료이다). 프론트에서 알림 코드가 해당된 API를 호출했을 때 오류가 없는 것을 확인하였다. 

 

이제는 다음 태스크인 '비동기 뷰 변환'을 해볼 차례이다. 이는 여러 뷰들 중에서 openAI API를 사용하는 뷰가 있는데, 해당 뷰에 한해서는 응답을 비동기로 처리해주면 되는 태스크이다. 

 

그런데 사실 비동기 뷰라는 개념을 확실히는 모른다. 지금까지 개발한 뷰는 모두 동기 뷰였고, 요청이 오면 그걸 다 처리할 때까지 기다렸다가 응답을 리턴했다. 그렇다면 비동기 뷰는 뭘까. 동기 뷰의 반대니까 요청이 왔어도 응답을 리턴하지 않고 필요한 작업이 다 되면 최종 응답을 리턴하는 식일까? 라는 의문이 들었다. 

 

비동기 뷰에 대해 정리한 블로그를 보고 감을 잡을 수 있게 되었다. 위에서 생각한 작업이 맞았다. 동기 작업의 단점은 오래 걸리는 태스크가 있을 때 그 태스크의 수행을 기다리느라 다른 작업들을 하지 못한다는 점이다. 그러니 오래 걸리는 태스크가 있으면 그게 다 될 때까지 둔 다음에, 그 사이에 들어오는 다른 요청을 처리할 수 있다. 

 

참고로 비동기 뷰뿐만 아니라 쿼리셋에서도 비동기를 처리할 수 있다고 한다. 가령 'objects.get'으로 시작하던 기존 쿼리 대신에 'objects.async_get'을 사용하면, DB에서 데이터를 가져올 때까지 서버가 동기 방식으로 기다리지 않는다. 대신 비동기는 동기와 달리 실행 흐름이 한 줄기가 아니므로 불필요하게 남발하는 것은 실행 플로우나 디버깅을 복잡하게 만들 수 있다. 

 

어쨌든 이제 비동기 뷰를 만들 필요성에 대해서 다시 납득했으니 만들어 보자. 만드는 법은 매우 간단했다. 공식문서를 봤더니 함수형 뷰의 경우는 기존의 'def' 대신 'async def'으로 만들어주면 되었고, 클래스형 뷰의 경우는 개별 메소드의 앞에 async를 붙여주면 되었다. 비동기 뷰는 함수를 리턴하는 동기 뷰와 달리 coroutine을 리턴한다고 나와있다. 

 

이 coroutine은 저번에 '실행을 중단하거나 다시 재개할 수 있는 컴퓨터 프로그램의 구성 요소'라고 잠깐 언급했었는데 사실 나도 와닿지 않는다. 이게 도대체 뭘까. 이걸 알아야 비동기 뷰들과 동기 뷰들이 어떻게 하나의 서버 안에서 호출되고 동작하는지를 이해할 수 있을 것 같아서 찾아보았다. 

 

친절한 블로그 글의 설명과 이미지를 가져오자면 coroutine은 함수와 비슷하다. 다만 함수는 대개 실행 흐름을 통째로 가져가서 처음부터 끝까지 한 번에 실행된 다음 결과를 반환하는 반면 coroutine은 실행 중간중간 실행 흐름을 자신을 호출했던 기존 caller에게 다시 반환한다. 

 

이게 가능하려면 중간에 coroutine이 자신의 실행권을 내려놓겠다는 신호를 줘야 하는데, 파이썬에서는 yield, 자바스크립트에서는 async/await 등의 키워드가 그 역할을 한다. 그리고 실행권을 내려놓은 시점의 위치나 메모리 상태 등을 기억한다면, 다시 실행권을 가져왔을 때 멈춘 지점부터 실행할 수 있다. 

 

그리고 공식문서를 보면서 새로 안 사실인데, 비동기 뷰가 섞여 있을 때 서버의 성능을 최적화하려면 동기 환경만 지원하는 미들웨어가 없어야 한다고 나와있었다. 왜냐하면 그런 미들웨어가 하나라도 있으면 장고는 요청 하나당 하나의 스레드를 자동으로 할당해 버리기 때문에, 비동기 뷰의 이점을 누릴 수 없다는 거였다. 

 

미들웨어는 기본값으로는 동기 환경만 지원하도록 되어 있다. 이를 동기와 비동기 환경을 모두 지원하도록 만들어주면 된다고 한다. 그렇다면 우선 비동기 뷰를 만든 다음에 이 작업도 같이 해 보자. 

 

현재 API에서는 투두를 LLM을 통해 하위 투두로 나눠주는 API에서만 외부 openAI API를 사용하고 있다. 이 앞에 'async' 키워드를 붙여줬다. 공식문서를 잘 읽어보니 해줘야 할 후속 작업들이 많았다. 우선 이렇게만 써 놓으면 장고는 여전히 WSGI 기반(한 번에 하나의 요청만을 처리)에서 동작한다. 

 

이것 자체의 문제는 없으나, 그러면 롱 폴링이나 슬로우 스트리밍 등의 ASGI 기반에서 동작하는 것들을 하지 못한다. 만약 이 작업들을 하고 싶다면 장고가 ASGI를 사용하도록 배포해야 한단다. 이 작업을 해 주면 되겠다. 

 

궁금한 점

1. 동기 뷰를 사용할 때와 비동기 뷰를 사용할 때의 장고의 동작 방식은 똑같을까? 아니면 비동기 뷰와 동기 뷰를 같이 사용하게 되면 장고 뷰 로직의 동작 방식이 바뀔지 궁금하다. 

2. 비동기 쿼리셋에서 나온 objects.get과 objects.async_get의 동작 방식의 차이가 궁금하다. 

3. 왜 ASGI 기반의 서버로 바꿔줘야 롱 폴링, 슬로우 스트리밍 등을 사용할 수 있는 걸까? 대강은 알겠는데 이를 스스로 설명하지는 못하는 것 같다. 

4. 파이썬의 yield 키워드는 어떻게 동작할까?

5. 장고에서 마이그레이션이나 크론 잡들도 당연하지만 동기적으로 실행되고 있었다..! 이 작업들은 Celery를 사용하면 비동기적으로 처리할 수 있다고 한다. 이번 이슈가 끝나면 이것도 도입해 봐야겠다. 

 

 오늘 배운 것

오늘은 원래는 알람 이슈를 개발해보려고 했다. 그러나 개발 서버에서 모종의 이유로 오류가 나고 있었다. 오류를 보니 미들웨어 단에서 에러가 나는 것으로 보였다. 

 

구체적인 로그를 보니 'rest_framework'와 'JWTAuthentication'이 로그에 보였다. 아마도 관련 authentication backend 또는 middleware에서 나는 오류일 것이라고 추측했다. 로그를 자세히 보니 예상대로 JwtAuthentication에서 나는 오류였다. 정확히는 이를 상속받아 직접 만든 CustomJwtAuthentication에서 나는 오류였다. 

 

원인은 예외 케이스 처리를 해주지 않아서 생긴 오류였다. 기존 코드와 수정된 코드는 다음과 같다. 

# 기존 코드
class CustomJWTAuthentication(JWTAuthentication):
    def authenticate(self, request):
        raw_token = self.get_raw_token(self.get_header(request))
        if raw_token is None:
            return None
# 수정된 코드
class CustomJWTAuthentication(JWTAuthentication):
    def authenticate(self, request):
        header = self.get_header(request)
        if header is None:
            return None
        raw_token = self.get_raw_token(header)
        if raw_token is None:
            return None

 

self.get_header()에서 header 값이 None이 나오는 경우에 대해서 예외 케이스를 처리해주지 않은 것이 오류의 원인이었다. 이 부분을 해결해 주었더니 문제 페이지가 잘 나왔다. 


이제 어제 막혔던 알림 기능을 마저 개발해 보자. 어제의 결론은 미들웨어를 써서 문제를 해결하는 것이었는데, 막상 멘토님들께 이를 공유드리니 굳이 미들웨어를 사용할 필요는 없다는 피드백을 주셨다. 미들웨어는 모든 요청에서 공통으로 사용되는 로직, 가령 로깅이나 보안, 인증 관련해서 사용하는 것이 더 일반적이기 때문이었다. 

 

그래서 우선은 뷰 로직 맨 끝에다가 FCM 함수 호출로직을 추가하는 것으로 해 보았다. 우선 다음과 같이 공통 알림 로직을 만들어 주었다. 

def send_push_notification_device(token, target_user, title, body):
    target_device = FCMDevice.objects.filter(user=target_user).exclude(registration_id=token)
    if target_device.exists():
        target_device = target_device.first()
        try:
            target_device.send_message(
                messaging.Message(
                    notification=messaging.Notification(
                        title=title,
                        body=body,
                    ),
                )
            )
        except Exception:
            pass

 

그리고 필요한 views 파일에서 해당 로직을 호출해 주는 식으로 변경하였다. 

 

 오늘 배운 것

동기 미들웨어를 통해서 특정 API 요청이 들어왔을 때만 FCM 알림을 보내보도록 하겠다. 다음과 같은 방식으로 동기 미들웨어를 만든 뒤, 해당 미들웨어를 settings.py의 MIDDLEWARE 리스트 변수에 추가해 주었다. 

class FCMAlarmMiddleware:

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

    def startswith_fcm_alarm_paths(self, path):
        for p in FCM_ALARM_PATHS:
            if path.startswith(p):
                return True
        return False

    def __call__(self, request):

        if request.method in FCM_ALARM_METHODS and self.starts_with_fcm_alarm_paths(request.path):
            fcm_token = request.auth.token
            other_device = FCMDevice.objects.filter(user=request.user).exclude(registration_id=fcm_token)

            if other_device.exists():
                device_id = other_device.first().registration_id
                
                if request.path.startsWith(FCM_ALARM_PATH_TODO):
                    send_push_notification(device_id, "Todo", "")
                elif request.path.startsWith(FCM_ALARM_PATH_SUBTODO):
                    send_push_notification(device_id, "Subtodo", "")
                elif request.path.startsWith(FCM_ALARM_PATH_CATEGORY):
                    send_push_notification(device_id, "Category", "")

 

이제 확인차 서버가 잘 실행되는지를 보려고 하는데, 서버 자체는 잘 실행되는데 다른 에러가 났다. MIDDLEWARE의 값으로 주어진 다른 allauth 미들웨어에서 나는 오류였다. 현재는 allauth를 안 사용하고 있었기 때문에 해당 미들웨어를 지우고 싶었는데, 그러려고 하니 또 다른 에러가 났다. 

 

그래서 INSTALLED_APPS에서 allauth를 제거하고 다시 시도해봤다. 그랬더니 allauth 관련 에러는 나오지 않았다. 문제는 또 다른 XFrameOptionsMiddleware에서 또 에러가 났다. 해당 미들웨어는 어떤 미들웨어인지 모르기 때문에, 어떤 일을 하는지 알아보고 지워주는 것이 맞겠다. 

 

그리고 중간에 pytest도 실행시켜봤는데 다른 테스트들이 죄다 fail이 나기 시작했다. 원인은 위에서 작성한 FCMAlarmMiddleware에서는 request.auth.token이라는 값을 필요로 하는데, 이 값이 테스트에서 사용되는 WSGIRequest의 속성에는 없기 때문이다. 그런데 생각해보니, 기존에 작성된 테스트들에서는 FCM 알림이 보내지는 것까지를 테스트할 필요가 없었다. 그러므로 해당 미들웨어는 테스트 때는 우회를 해도 된다고 판단했다. 

 

그러면 현재 할 일은 'pytest', 'python manage.py runserver' 커맨드를 입력했을 때 기본 URL이 오류 없이 동작하는 것이다. 이를 위해서는 두 가지를 해결해야 한다.

 

1. XFrameOptionsMiddleware 알아보고 불필요하다면 지우기

2. 테스트 환경에서만 FCMAlarmMiddleware 우회하기

 

2번이 더 간단해서 먼저 해보자면, pytest에서 자동으로 사용되도록 어떤 fixture를 하나 만들어두고 그 fixture에서 사용하고자 하는 미들웨어를 설정값으로 넣어주면 되었다. pytest 공식문서를 참고해보니, conftest.py라는 파일을 디렉토리 안에 만들면 해당 및 하위 디렉토리의 테스트들에서 해당 파일에 있는 fixture 등을 사용할 수 있다고 한다. 

 

알고보니 이전에 만들어 둔 conftest.py 파일이 있어서 해당 파일 안에서 바로 작업하기로 했다. TEST_MIDDLEWARE는 MIDDLEWARE에서 특정 불필요한 미들웨어들만 뺀 변수이다. 

@pytest.fixture(autouse=True)
def skip_fcm_middleware():
    from django.conf import settings

    settings.MIDDLEWARE = settings.TEST_MIDDLEWARE

 

그랬더니 1개의 testcase만 fail하고 나머지는 다 성공하였다. 

 

fail한 경우는 fcm 알람 테스트였다. 이전에 테스트를 했을 때는 성공으로 나오던 알람이 잘 가지 않아서 Fail이 난 경우였다. 코드를 보니 테스트 코드에서는 별도의 테스트 문자열이 fcm 토큰값이라고 가정하고 이를 넣어주고 있었는데, 실제 FCMDevice를 조회해 보니 해당하는 fcm 토큰값을 갖고 있는 객체가 없었어서 에러가 난 것이었다. 

 

그렇다면 별도의 mock 객체를 만들어서 FCMDevice에 값을 넣어준 후, 해당 객체의 fcm 토큰으로 이를 테스트해봐야 되겠다. 

 

궁금한 점

1. 테스트 때 사용되는 WSGIRequest는 구체적으론 무엇이며, 일반 request와는 어떻게 다를까?

2. 함수 안에서 패키지나 모듈을 import 하는 것과 밖에서 전역으로 import 하는 것은 어떤 차이가 있을까?

3. mock 객체의 개념이 잘 이해가 안 된다...

 

+ Recent posts