오늘 배운 것

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

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

 

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

 

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

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 객체의 개념이 잘 이해가 안 된다...

 

 오늘 배운 것

어제 개발하던 알림 기능을 마저 개발해보려고 한다. 어제 job과 관련된 함수는 작성해 두었으니 이제 settings.py 파일에서 CRONJOBS 변수로 해당 함수를 등록하면 된다. 

 

crontab schedule에 관한 문서를 참고해서 아침 알림은 매일 오전 8시, 오후 알림은 오후 2시, 저녁 알림은 오후 8시에 가도록 설정해 주었다. 

# settings.py

CRONJOBS = [
    ('0 8 * * *', 'todos.jobs.send_morning_alarm'),
    ('0 14 * * *', 'todos.jobs.send_afternoon_alarm'),
    ('0 20 * * *', 'todos.jobs.send_evening_alarm'),
]

 

이제 두 번째 알림 케이스를 작업하면 된다. 투두 CRUD 중 C, U, D API가 호출되는 경우, 해당 API를 호출한 유저가 해당 디바이스 말고 또 다른 디바이스를 갖고 있을 경우, 해당 디바이스에 다시 Read API를 호출하라는 백그라운드 알림을 보내주는 작업이다. 

 

이 로직은 Todo, SubTodo, Category 모델의 Create, Update, Delete API의 끝에 들어가는 작업이기에, 해당 뷰 함수에 반복적으로 로직을 붙이는 것은 좋지 못하다고 판단했다. 그래서 생각해 본 방법으로는 미들웨어와 시그널이 있었다. 

 

내가 이해한 시그널은 가령 DB 특정 모델의 변화를 주목하고 있다가 변화가 일어났을 때 FCM으로 해당 유저에게 노티를 주는 방식이었다. 그래서 공식문서를 조금 읽어보던 중 signal은 implicit function call로 여러 쪼개진 로직에서 같은 이벤트를 주목할 때는 효과적일 수 있지만 그만큼 디버깅을 어렵게 할 수 있다는 문구를 보았다. 

 

그런데 지금 생각해보면 middleware의 사용 목적은 요청이나 응답의 값 등을 적절히 변형하는 용도인데, 이는 FCM 알림처럼 관련 모델 값이 변경되었을 때 노티를 받는 목적과는 맞지 않는다는 생각이 들었다. 

 

그래서 디버깅을 어렵게 할 수 있다는 가능성이 있지만 일단은 signal을 사용해보기로 했다. 그게 사용 목적에 더 맞고, 시그널이 DB를 보고 있으면 데이터가 변경될 때마다 알림이 가니 목적에는 가장 부합하게 구현할 수 있겠다는 생각이 들었다. 그리고 내가 사용하려는 모델이 변경되면 그에 맞게 노티를 주는 시그널은 알고보니 장고에 기본으로 구현되어 있어서, built-in signal을 사용하면 되었다. 

 

그런데 여기서 또 다른 해답을 얻었다. 공식문서에서는 정 필요한 경우에만 signal을 사용하고 아니면 다른 방법을 찾아보라고 하고 있었다. 새삼 signal이 코드를 디버깅하기 어려울 수 있다는 단점이 생각났다. 여기서는 model signal을 사용하는 대신 model manager를 사용해서 비슷한 작업을 처리하는 방법을 추천해주고 있었다. 

 

그래서 다시 signal 대신 manager를 사용하기로 했다. Todo, SubTodo, Category 모델의 manager에서 save 또는 create, update, delete 메소드만 변형하면 되겠다고 생각했다. models.Model의 save 메소드를 override해서 save 메소드 호출 후에 fcm 알림 로직을 추가해주는 방법을 생각했다. 그런데 이렇게 되면 fcm 알림 코드가 또 save 메소드 안에 종속되지 않나... 라는 생각이 들었다. fcm 알림은 명백히 save 메소드가 호출된 다음에 실행되어야 한다. 그러므로 signal의 목적에 부합한다. 

 

여러 고민을 하게 되었는데, 디버깅의 단점이 있지만 일단은 signal을 사용하기로 했다. 다행히 built-in signal에 post-save라는 기본 signal이 제공되어 있었다. 어떻게 사용하는지 잘 와닿지 않아서 'how to use post_save signals in django'로 검색했더니 나온 덕에 해답을 얻을 수 있었다. 

 

그런데 이렇게 했더니 또 다른 문제가 있었다. model이나 signal에서는 request.user이나 request.auth의 값을 갖고 있지 않다. 그렇기 때문에 이 요청이 어떤 디바이스로부터 온 것인지 알 수 있는 방법이 없다. 다음은 임시로 작성한 코드인데, signal은 파라미터로 request를 받지 않고 있다. 

from django.db.models.signals import post_save
from django.dispatch import receiver
from todos.models import *

@receiver(post_save, sender=Todo)
def update_todo_alarm(sender, instance, **kwargs):
    devices = instance.user.device__set.all()

 

즉 내가 원하는 것은 request.auth (또는 request.user)에 접근할 수 있으면서, signal과 비슷한 방법을 통해 이를 수행하는 것이었다. 찾아본 글에 의하면 signal은 순수 model 기능을 이용하기 때문에 불가능하다고 한다. 즉 request.auth에 접근하려면 순수 model 단에서는 불가능하고, 이는 manager도 마찬가지겠다. 

 

그렇다면 위에서 고려했던 다른 옵션인 미들웨어를 살펴봐야 하겠다. 다만 고려할 점은 요청은 그대로 끝나고 요청과 관계 없이, 즉 비동기로 미들웨어를 실행하는 게 맞아 보였다. 알림이 보내질 때까지 응답을 늦출 필요는 없기 때문이다. 

 

그러므로 비동기 미들웨어를 구현해보자. 공식문서를 보니 장고에서는 비동기 미들웨어도 지원하긴 했다. 다만 장고의 기본값은 동기 미들웨어만 지원하는 것이므로, boolean 변수나 decorator 등을 사용해서 이 기본값을 바꿔주는 과정이 필요했다. 공식문서의 예제를 가져와 보니 coroutine function (iscoroutinefunction) 이라는 말이 있는데 이 부분도 자세히는 몰라서 찾아보았다. 

from asgiref.sync import iscoroutinefunction
from django.utils.decorators import sync_and_async_middleware


@sync_and_async_middleware
def simple_middleware(get_response):
    # One-time configuration and initialization goes here.
    if iscoroutinefunction(get_response):

        async def middleware(request):
            # Do something here!
            response = await get_response(request)
            return response

    else:

        def middleware(request):
            # Do something here!
            response = get_response(request)
            return response

    return middleware

 

찾아보니 coroutine function이란 멀티태스킹처럼 실행을 미룰 수 있는 함수이며, exception이나 iterator를 구현할 때 유용하게 사용된다고 한다. 

 

그런데 이 비동기 미들웨어는 현재의 내가 이해하기에는 조금 복잡하고, 무엇보다 API를 보낼 때 FCM 알림을 보내는 것이 그렇게 큰 부담은 아니었다. 빨리 구현하는 것이 중요하니 우선은 기존처럼 동기 방식의 미들웨어로 작업하고, 시간이 될 때 다시 개선하는 것이 맞다고 판단했다. 

 

 궁금한 점

1. explicit function call과 implicit function call의 차이는 무엇일까

2. signal과 middleware가 같은 목적에서 사용할 수 있는 다른 방법이라는 생각이 들었다. 둘의 개념적 차이는 조금 알겠는데, 그러면 어떨 때 signal을 사용하고 어떨 때 middleware를 사용해야 할까? 내가 이해한 바로는 요청이나 응답에 관련된 작업은 middleware에서 하고, 흩어져 있는 코드들에 대해서 알림을 줄 때는 signal을 사용하라고 이해했다. 

3. coroutine function은 exception이나 iterator를 구현할 때 어떻게 사용될 수 있을까?

4. 비동기 미들웨어는 어떤 식으로 동작할까? 미들웨어가 연쇄적으로 얽혀 있으면 비동기 미들웨어를 사용하게 되면 미들웨어의 순서가 보장되지 않을 것 같다. 장고에서는 어떻게 이 문제를 해결할까?

 

+ Recent posts