오늘 배운 것

오늘은 드디어 알림 개발을 완료했다(버그가 있으면 수정해야 하니 일단은 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. 비동기 미들웨어는 어떤 식으로 동작할까? 미들웨어가 연쇄적으로 얽혀 있으면 비동기 미들웨어를 사용하게 되면 미들웨어의 순서가 보장되지 않을 것 같다. 장고에서는 어떻게 이 문제를 해결할까?

 

 오늘 배운 것

오늘은 알림 기능 개발을 마저 진행해보려고 한다. 지금까지는 서버에서 알림을 보내면 앱의 화면에 알림이 잘 뜨는지를 확인했고, 이제는 잘 뜨는 것을 확인했으니 목적에 맞게 알람을 보내도록 코드를 작성해주면 되겠다. 

 

목적에 맞게 알람을 보내려면 크게 두 가지의 요구사항을 충족해야 한다.

1. foreground 알림의 경우, 아침(오전 8시), 점심(오후 2시), 저녁(오후 8시) 시간을 기본값으로 하고, 각 시간마다 사용자에게 알림을 보낸다. 

2. background 알림의 경우, 사용자가 CRUD 중 CUD에 해당하는 API를 호출한다면 사용자(User)에 해당하는, 현재 알림을 보낸 디바이스가 아닌 다른 디바이스(Device)가 있는지 확인하고, 있다면 해당 디바이스에게 API를 다시 호출하라는 알림을 보낸다. 

 

우선 첫 번째 경우부터 작업해 보자. 

 

지정된 시간마다 알림을 보내야 하니, 처음에 생각난 방법은 cronjob을 이용해서 서버에서 특정 시간마다 배치 로직을 돌려주는 것이었다. 'django cronjob'으로 검색해 보니 django-crontab이라는 라이브러리가 나왔다. 찾아보니 지속적으로 실행해주었으면 하는 Job에 해당하는 함수를 정의해준 뒤, settings.py에 해당 함수를 cronjob 날짜 표시 규칙에 맞게 정의해주면 끝이어서 간단해 보였다. 

 

우선 todos 앱 내부에 jobs.py라는 cronjob에 쓰일 job 함수들을 정의하는 용도의 파일을 만들어 주었다. 그리고 다음과 같이 send_day_alarm 이라는 공통 로직을 만들어 주었다. 

MORNING_ALARM_TITLE = "오늘의 할 일을 확인해보세요"
AFTERNOON_ALARM_TITLE = "지금 할 일을 확인해보세요"
EVENING_ALARM_TITLE = "오늘의 남은 할 일을 확인해보세요"


def send_morninig_alarm():
    send_day_alarm(MORNING_ALARM_TITLE)


def send_afternoon_alarm():
    send_day_alarm(AFTERNOON_ALARM_TITLE)


def send_evening_alarm():
    send_day_alarm(EVENING_ALARM_TITLE)


def send_day_alarm(alarm_title):
    users_prefetch = Prefetch('user__todo_set', queryset=Todo.objects.filter(is_completed=False))
    devices = FCMDevice.objects.all().select_related('user').prefetch_related(users_prefetch)
    try:
        for device in devices:
            todos_queryset = device.user.todo_set.filter(is_completed=False).values_list("content", flat=True)
            todos_list = "\n".join(todos_queryset)
            device.send_message(
                messaging.Message(
                    notification=messaging.Notification(
                        title=alarm_title,
                        body=todos_list,
                    ),
                )
            )
    except Exception:
        pass

 

FCMDevice는 fcm_django에서 send_message() 메소드를 통해 알람을 편하게 보낼 수 있게 하는 전용 모델이다. 이 FCMDevice와 User는 N:1 관계이고, User와 할 일을 나타내는 Todo 모델은 1:N 관계이다. 그리고 여기서 원하는 것은 FCMDevice를 조회하면서 관련 User와 Todo 데이터 모두를 한 번의 쿼리로 조회하는 것이었다. 

 

이를 위해서 django의 Prefetch를 사용했다. django의 select_related와 prefetch_related 안에 Prefetch() 객체를 넣어서 두 번의 join문을 통해 한 번의 쿼리로 User, Todo 데이터를 가진 FCMDevice 쿼리셋을 만들었다. 

 

궁금한 점

1. INSTALLED_APPS에 'django_crontab'을 넣으면 django에서 이를 어떻게 인식하는 것일까?

2. 'python manage.py crontab add' 명령어를 입력하면 settings.py에 입력한 함수 커맨드가 crontab에서 관리해야 할 job으로 등록되는 것으로 이해했다. 이 과정이 어떻게 일어나는지 궁금하다. 

3. Prefetch를 통해서 데이터를 불러오는 것과 그냥 objects.all()를 통해서 데이터를 불러오는 것은 차이가 있을까? 

4. select_related나 prefetch_related 안에 Prefetch() 객체를 넣으면 django에서 이를 어떻게 인식해서 join문으로 DB에 쿼리를 날리는지 궁금하다. 

 

 오늘 배운 것

오늘은 두 가지의 과제가 있다. 하나는 main workflow를 정상화해서 develop 브랜치의 내용이 main 브랜치로도 반영되도록 모종의 에러를 해결해야 한다. 또 다른 하나는 현재 develop 브랜치에서 settings 파일의 debug 변수를 True로 두고 있음에도 swagger 페이지가 제대로 표시되지 않는 오류(오류인지 정상 작동하는 것인지는 모르겠지만 우리 입장에서는 나타나야 하기 때문에 오류로 간주했다)가 있다. 

 

우선 첫 번째 업무부터 해 보자. main workflow가 과연 모종의 이유로 롤백되고 있는지를 먼저 확인하자. AWS ECS의 클러스터의 서비스 로그를 확인해봤는데 'rolled back'이라는 문구가 눈에 띄었다. 모종의 이유로 ECS가 롤백되고 있었다. 

 

CloudFormation에서 더 구체적인 로그를 보니, 이전에 개발서버에 있었던 거의 똑같은 이슈가 프로덕션 서버에 발생하고 있었다. 디렉토리명이 맞지 않아서 생긴 오류였다.

 

그래서 'onestep_be.settings.prod' 라는 모듈을 main 브랜치에서 사용하고 있는지를 확인해 보았다. 태스크 정의 JSON 파일에서 변수를 동적으로 넣어 주는 코드에서 해당 값을 넣어주고 있었다..!! 이 부분은 develop, main 브랜치 모두에서 수정되지 않은 부분이기 때문에 두 브랜치 모두 바꿔주어야 했다. 

 

해당 코드의 settings를 setting으로 바꿔 주고 다시 커밋을 올렸다. 다시 롤백이 발생하는지를 지켜봐야겠다. 우선 워크플로우는 무사히 잘 실행되었다. 

 

다행히 develop에서 새로 개발된 피쳐들이 프로덕션 서버에도 잘 적용되는 것을 확인할 수 있었다.


다음은 swagger view가 제대로 표시되지 않는 오류를 다뤄 보려고 한다. 찾아보니 이 부분은 크게 두 가지의 원인이 있을 수 있겠다. 첫 번째는 settings 파일의 DEBUG 변수 값에 따라서 swagger view를 표시하지 않도록 설정이 될 수 있다고 한다. 이 부분은 예상했던 부분이었다. 

 

그러나 DEBUG의 값이 False인 프로덕션과는 달리, DEBUG의 값이 True인 개발 서버에서도 swagger view가 표시되지 않으므로 이 문제만은 아닌 것 같았다. 

 

다른 가능성으로는, 현재 문제는 'python manage.py runserver' 명령어를 사용하다가 gunicorn 기반의 명령어를 사용하면서 swagger 페이지가 보이지 않게 된 것이므로 이 부분과 관련이 있을 수 있겠다. 찾아보니 python manage.py runserver로 서버를 띄울 때랑 gunicorn으로 서버를 띄울 때랑 정적 파일을 참조하는 방식이 다르다고 한다. 

 

그렇다면 gunicorn에서 어떻게 로컬 서버에 있는(사실 swagger 관련 파일이라 나는 이 파일들의 존재도 모르긴 했다. 어쨌든!) 정적 파일들을 서빙하게 할까? 집단지성의 힘을 빌리니 간단한 코드 몇 줄을 추가하면 된다고 한다. 

# onestep_be.urls
from django.contrib.staticfiles.urls import staticfiles_urlpatterns

urlpatterns += staticfiles_urlpatterns()

 

 궁금한 점

1. gunicorn은 원래는 어떻게 정적 파일을 서빙했을까

2. staticfiles_urlpatterns는 어떤 역할을 하길래 gunicorn이 python manage.py runserver와 똑같은 정적 파일을 서빙하도록 할 수 있는 걸까

 

 오늘 배운 것

오늘은 별도로 개발을 하지는 않았다. 현실적인 이유로 어쩔 수 없다고 생각하긴 하지만, 명색이 주니어 주니어 개발자인데 이래도 되나 싶어 조금 반성되는 하루였다. 일단 죄책감을 잠시 미뤄두고 내일까지 마감인 공고에 자기소개서를 썼다. 개발 직무가 사실 이력서를 중심으로 보지 자기소개를 써야 하는 기업은 많지 않아서, 간만에 레포트 과제를 하던 본전공 시절로 돌아간 것 같았다.

 

다행히 무사히 자소서를 제출했다. 이제는 다른 pdf 버전 자소서를 한번 더 셀프 첨삭하고, 멘토님들께 피드백을 받은 다음, 이틀 뒤 개발 컨퍼런스의 리쿠르팅 존에 가져가는 것이 목표이다. 

 

그럼에도 불구하고, 며칠간 안 하면 또 감이 떨어지고 개발과 낯을 가리게 되는 걸 알고는 있다. 그래서 내일은 조금이라도 꼭 개발을 해야 하겠다. 

 

그리고 여러 기업에 원서를 넣는 중이긴 한데 공채가 아니라 수시 채용인 곳들은 메일을 준다는 전제 하에 얼마 후에 메일을 주는지도 잘 모르겠다... 일단 체감상 2주 이상 연락이 없으면 알아서 서류 탈락이겠거니 하긴 하는데, 이게 맞으려나 모르겠다. 

취준 기록용으로 노션을 쓰고 있는데 정리가 잘 되어서 너무 잘 쓰고 있다... 노션 짱

 

암튼 내 멘탈도, 팀원들이랑 프로젝트도, 틈틈이 취준하면서 원서 넣는 것도 모두모두 파이팅이다. 

 

 오늘 배운 것

이제는 기존의 알림 코드를 조금 수정해서 어제 찾은 라이브러리를 사용하는 코드를 추가해 주면 된다. 다음과 같은 코드를 추가해 주었더니, 다음과 같은 경고 메시지가 떴다. 

import PushNotification from 'react-native-push-notification';

PushNotification.localNotification({
  title: remoteMessage.notification.title,
  message: remoteMessage.notification.body,
});

 

공식문서의 readME를 읽어보니 channelId 값이 없으면, 즉 channelId 값을 안 주거나 해당 channelId에 해당하는 채널이 없으면 notification이 trigger되지 않는다고 한다. 어쩐지 이렇게 채널 Id같은 추가 정보도 안 받고 알림이 보내지나 싶었다. (다시 생각해보면 FCM 라이브러리에서는 해당 문제가 없었던 이유가, 이미 FCM 토큰으로 특정 디바이스를 타겟팅해서 보내기 때문에 별도의 channel ID가 필요없던 것이라고 추측해본다.) 그리고 여러 가지 이유로 레포가 활발히 업데이트 되지 않는다며 Notifee와 같은 다른 라이브러리를 사용하기를 추천하기도 했다... 

 

사실 새 라이브러리를 배우는 것은 문제가 되지 않았으나, 현재 여러 팀원이 프론트 개발에 참여하고 있었기 때문에 안드로이드와 같은 RN 영역 외의 설정이 복잡하지 않는 라이브러리였으면 좋겠다고 생각했다. 그래서 Notifeereact-native-notifications, 그리고 현재 라이브러리인 react-native-push-notifications의 공식문서를 비교해 보면서 가장 안드로이드 설정이 간단한 라이브러리를 선택하기로 했다. 

 

자세히 보지는 않았지만, Notifee의 설치 및 셋업 가이드를 보니 매우 간단하고, 중간에 /android 폴더를 건드리는 일도 없었다. 그래서 일단은 Notifee 라이브러리를 선택했다. 설치 가이드를 따라서 진행했다. 뒷부분에는 Expo 설치 가이드도 있었는데, 그 부분은 시도해보니 expo에서 지원하는 valid plugin이 아니라는 에러가 나서 이후로 쭉 진행하지는 못했다. 

 

그런데 'npm run android:dev'(팀원이 scripts 값으로 별도로 설정한 명령어다)로 서버 prebuild를 시도하니 에러가 났다. 이 부분은 expo dependency(app.config.js에 있는 plugins 변수의 값)에서 @notifee/react-native 부분을 제거해서 오류를 임시로 해결하였다. 

 

그리고 'rnfirebase' 공식문서를 보는데 팀원이 빼먹은 부분을 알려주었다. 바로 유저의 permission을 받아야 알림을 표시할 수 있었는데, 그렇게 권한을 요청하는 코드를 빼먹었었다. 

import {PermissionsAndroid} from 'react-native';
  
PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);

Allow

 

그런데 권한도 받았는데 알림이 여전히 오지 않았다. 그런데 rnfirebase 공식문서에서는 notification 속성이 메시지에 있고, 앱이 background 상태에 있다면 알림은 표시되는 것이 기본값이라고 말하고 있었다. 그래서 혹시 서버에서 알림을 보내는 코드가 잘못되었을 가능성은 없을까? 라는 의문이 들었다. 다시 fcm_django 공식문서를 봤다. 

 

이상한 점이 하나 있었는데, 나는 기존의 messaging.Message 인스턴스를 별도의 message 변수에 할당해서 사용하고 있었다. 그런데 공식문서에서는 전부 messaging.Message 인스턴스를 FCMDevice의 send_message 안에 직접 선언하고 있었다. 그러니까 이런 식이었다. 나는 아래와 같이 코드를 작성하고 있었다. 

from firebase_admin import credentials, messaging
from fcm_django.models import FCMDevice

def send_push_notification(token, title, body):
    message = messaging.Message(
        notification=messaging.Notification(
            title=title,
            body=body,
        ),
    )
    device = FCMDevice.objects.filter(registration_id=token).first()
    try:
        device.send_message(message)

 

그런데 위의 코드를 아래와 같이 바꿔주었더니 잘 동작하는 게 아닌가. message 변수로 할당된 값을 그대로 device.send_message() 안의 인자 값으로 넣어주었을 뿐인데 알람이 잘 들어왔다. 

def send_push_notification(token, title, body):
    device = FCMDevice.objects.filter(registration_id=token).first()
    try:
        device.send_message(
            messaging.Message(
                notification=messaging.Notification(
                    title=title,
                    body=body,
                ),
            )
        )

좋은데 쫌 어이가 없다

 

 궁금한 점

1. 위와 같은 알람 이슈는 왜 일어난 것일까

 

+ Recent posts