어제에 이어 알림 기능을 개발해 보려고 한다. 그러려면 서버에서는, CRUD를 통해 데이터를 변경시킨 클라이언트와 같은 계정을 공유하는, 모든 디바이스토큰을 가진 기기로 알림을 보내야 한다. 그러니까 이런 식이다.
Device.objects.filter(user_id=user_id)
그런데 생각해보면 만약에 유저 계정 A1에 대해서 디바이스 B1과 B2가 있다고 하자. B1에서 C, U, D를 통해 투두 데이터에 변경이 일어나면 B1에 해당하는 유저 계정인 A1을 가진 디바이스 B1과 B2에게 알림을 보내게 될 것이다. 그런데 생각해보면 B1은 변경을 발생시킨 디바이스 주체이므로 굳이 B1에게 알림을 안 가게 하는 것이 맞는데, B2에게 알림을 보내면서 B1에게도 알림이 가게 된다. 이는 불필요한 알림일 수 있다.
알고보니 request.auth라는 요청의 속성을 통해서 토큰의 커스텀 클레임을 볼 수 있다고 한다. 앞서 이런 상황을 대비하진 않았지만 혹시 몰라서 커스텀 토큰에 device 필드를 추가해 두었었다. 이 토큰값을 잘 받아서 디바이스를 식별할 수 있겠다.
그런데 request.auth가 토큰 클레임이 있는 딕셔너리 형태가 아니라 그냥 raw token으로 나왔다. 물론 별도로 토큰을 decode해서 디바이스 토큰을 꺼내볼 순 있겠지만 매 뷰에서 이걸 반복적으로 할 수는 없으므로, 미들웨어 단에서 처리하거나 simplejwt 라이브러리의 설정, DRF JwtAuthentication의 설정 등을 바꿔서 뷰에서는 request.auth로 클레임 값을 접근할 수 있도록 해야겠다. 이를 위해서 커스텀 인증 클래스 authentication.py를 만들고 기존 DRF의 JwtAuthentication을 상속받은 뒤, authenticate 메소드만 재정의했다.
그리고 알고보니 authenticate 메소드에서 리턴하는 두 번째 값이 뷰에서 접근할 수 있는 request.auth의 값으로 지정된다고 해서, 이 값을 validated_token에서 token_payload로 바꿔주었다.
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework_simplejwt.settings import api_settings
from jwt import DecodeError, ExpiredSignatureError
import jwt
class CustomJWTAuthentication(JWTAuthentication):
def authenticate(self, request):
raw_token = self.get_raw_token(self.get_header(request))
if raw_token is None:
return None
try:
validated_token = self.get_validated_token(raw_token)
token_payload = jwt.decode(
raw_token,
api_settings.SIGNING_KEY,
algorithms=[api_settings.ALGORITHM]
)
request.auth = token_payload
except (InvalidToken, DecodeError, ExpiredSignatureError) as e:
raise InvalidToken(e)
return self.get_user(validated_token), token_payload
그랬더니 request.auth에서 딕셔너리 값으로 토큰 클레임을 잘 볼 수 있었고, 커스텀 필드로 정의한 'device' 필드도 볼 수 있었다.
그러면 이제 해당 디바이스 토큰 값을 가지고 알림 로직을 호출할 수 있다. 그런데 생각해보니 알림 로직을 호출하는 필드도 뷰마다 중복이 많이 될 것 같았다. 이럴거면 응답 관련해서 미들웨어를 써도 되지 않나? 아니면 뷰가 다 동작한 다음에 해당 디바이스 토큰과 뷰의 메소드로(GET일 경우 해당되지 않으므로) FCM 알림을 보내는 로직을 만드는 다른 방법이 있을까?
우선 찾아보니 미들웨어를 쓰는 방법이 있었고, 예상하지 못했던 또 다른 방법은 장고의 signal(시그널)을 쓰는 것이었다. 미들웨어는 이제 API로 들어온 요청에 대한 응답을 할 때 사용하는 방법이고, 시그널은 장고 모델에 대해서 변경 사항이 생길 때 변경사항이 생긴 시점 이전이나 이후에 특정 로직을 수행하도록 해 주는 장고의 기능으로 알고 있다.
공식문서를 참고하니 시그널은 어떤 이벤트가 발생했을 때 그 이벤트의 발생 전이나 후에 실행되는 함수를 연결해주는 기능이다. 클라이언트에서 사용하는 콜백 함수와 유사한 개념으로 보였다.
그렇다면 이런 상황에서 시그널을 사용하는 게 맞을까, 미들웨어를 사용하는 게 맞을까? 시그널을 사용해서 DB 모델에 변경이 생기면 FCM 알림을 보내주는 라이브러리는 없을까 싶었다.
아니나 다를까, 바로 있다. fcm_django라는 라이브러리가 있단다. 일단 설치를 해 보자.
알고보니 해당 라이브러리에서는 FCMDevice라는 모델을 만들어서 DB에 각 유저들의 디바이스와 FCM 관련 정보를 저장하는 용도로 사용하는 것 같았다. 또한 해당 FCMDevice의 장고 매니저 기능을 사용하면 send_message 메소드를 통해 해당 디바이스로 메시지를 보낼 수 있다..! 딱 내가 원하던 거였다.
그런데 생각해보니 기존에 정의한 Device라는 모델에서 이미 유사한 정보를 다루고 있었다. 이럴 경우에는 Device 모델을 제거해야 하겠다.
그런데 로컬에서 무턱대고 RDS에 Device 테이블을 지우는 마이그레이션을 실행해 버리면 에러가 날 게 분명했다. 이미 ECS에서 돌아가고 있는 서버에 있는 코드는 RDS에 Device 테이블이 있다고 생각하고 이를 사용하고 있기 때문이다. 그렇다면 어떻게 해야 할까.
우선은 코드에서 Device 테이블에 대한 dependency를 아예 제거해야 하겠다. 그리고 해당 코드가 develop 브랜치에 머지되고 완전히 ECS 서버에 올라가면 그때 지워야 하겠다. 그런데 또 생각해보니 이미 Dockerfile에서 'python manage.py makemigrations && python manage.py migrate' 명령어를 실행하고 있다. 그러므로 해당 코드가 정상적으로 실행된다면 이미 RemoveModel 마이그레이션이 실행되어 RDS에서 테이블이 잘 삭제되었을 것이다. 결론은 걱정할 필요가 없었다.
그리고 찾아보니 fcm_django 라이브러리에 ViewSet이 있었다. FCMDeviceViewSet은 FCMDevice를 등록하는 데 사용하는 ViewSet이었고, FCMDeviceAuthorizedViewSet은 FCMDevice의 정보를 변경하는 데 사용되는 ViewSet이라고 이해했다. 그렇다면 이 ViewSet들을 사용해서 충분히 커버가 가능하겠다. 그런데 해당 ViewSet을 프론트에서 디바이스 토큰을 등록이나 변경하기 위해 별도로 호출해야 할지, 아니면 구글로그인 API를 호출하면 구글로그인 뷰에서 해당 ViewSet을 호출해야 할지는 잘 모르겠었다.
그런데 항상 흐름은 구글로그인이 성공적으로 완료된 다음에 디바이스 토큰을 조회하는 것이므로, 이 흐름이 변화가 없다면 굳이 프론트에서 호출할 필요가 없다고 판단했다. 그래서 두 번째 방법을 사용하기로 했다.
from fcm_django.api.rest_framework import FCMDeviceCreateOnlyViewSet
response = FCMDeviceCreateOnlyViewSet.as_view(request)
✅ 궁금한 점
1. 어떤 라이브러리를 사용할 때 INSTALLED_APPS에 해당 라이브러리 이름을 넣어야 하는 것과 안 넣어줘도 되는 기준이 궁금하다. 이걸 결정하는 부분의 코드가 있다면 한번 찾아보고 싶다.
2. Mixins와 Viewset을 같이 상속받아서 어떻게 상속받은 별도의 두 클래스가 같이 동작하는지도 궁금하다.
3. FCMDevice에서 objects 매니저를 이용해서 send_message 메소드를 호출하면 FCM 서버를 통해 메시지가 보내진다고 한다. 이 원리도 궁금하다.
어제 언급했던 대로 RN 역시 마찬가지로 안드로이드 API 33버전 이후부터는 사용자에게 명시적으로 알림 기능 허용을 받아내야 앱에서 알림을 보이게 할 수 있었다. 그 방법 중 하나는 RN에서 기본적으로 제공하는 PermissionsAndroid API를 사용하는 것이었고, 다른 방법은 react-native-permissions라는 모듈을 설치하는 것이었다. 사실 모듈을 설치해서 어떻게 구체적으로 작업이 되는 것인지는 잘 모르겠지만, 우선 설치해 주었다.
그리고 디바이스는 사용자가 어떻게 두느냐에 따라서 세 가지의 상태 중 하나에 속한다고 했다.
1. Foreground: 앱이 실행되고 있고, 사용자가 지금 앱 화면을 보고 있을 때
2. Background: 앱이 실행되고 있지만 사용자가 앱 화면을 보고 있지는 않을 때. 흔히 말하는 백그라운드에서 실행될 때.
3. Quit: 앱이 백그라운드에서도 실행되고 있지 않을 때
나는 1번과 2번 상태일 때만 앱이 동작하고 3번 상태일 때는 아예 어떠한 알림이나 로직이 동작하지 않는 줄 알았는데, 뒤의 내용을 읽어보니 그렇지는 않은 모양이다.
그리고 디바이스의 상태와 알림 메시지의 형태에 따라서 어떤 메시지 핸들러가 호출될지도 결정된다고 한다.
메시지의 형태란 노티 메시지가 있는지, 아니면 데이터를 포함하고 있는지에 따라서 달라진다. 기본적으로 노티 메시지가 있을 경우 알림의 우선순위가 높아진다고 한다.
앱이 Foreground에 있을 때는 onMessage라는 핸들러를 이용해서 메시지를 처리하고, Background나 Quit 상태일 때는 setBackgroundMessageHandler 핸들러를 이용해서 처리한다.
또한 앱이 Foreground에 있거나 메시지 내용에 노티 메시지가 없고 단순 데이터만 포함될 때는 화면에 알림을 표시하지 않는다고 한다.
앞서 앱이 Foreground 상태일 때는 onMessage 핸들러를 사용하는데, 해당 메시지 핸들러는 React의 Context(정확히 어떤 Context를 의미하는지는 100% 이해하진 못했다. 그냥 React 내부의 정보나 상태라고만 이해했다)에 접근할 수 있기 때문에 UI나 컴포넌트의 상태를 업데이트 시킬 수 있다고 한다.
반면 디바이스가 Background나 Quit 상태일 때는 onMessage 핸들러 대신 setBackgroundMessageHandler를 사용해야 하며, 코드의 시작점인 index.js(ts) 파일에 해당 핸들러 설정을 해 줘야 하는 것으로 보였다.
그리고 메시지에 notification 설정값이 없이 data 설정값만 포함되는 경우, 안드로이드 및 iOS에서는 해당 메시지의 우선순위를 낮게 간주해서 별도로 앱을 깨워서 핸들러를 실행시키지는 않는다고 한다. 그래서 만약 data 설정값만 메시지에 포함되는데 별도의 핸들러를 통해 로직을 실행시키고 싶다면, 서버에서 알림을 보낼 때 해당 메시지의 priority 값을 high로 설정해서 보내야 하겠다.
일단은 알림 로직을 작성하는 데 필요한 정보는 대강 알았으니 이제부턴 코드를 작성하면 되겠다. 그런데 막히는 부분이 하나 있다. 나는 앱이 Foreground에 있을 때에도, Background에 있을 때에도 모두 notification을 받으면 투두 API를 쏴서 새 데이터를 받아와서 다시 투두 데이터를 받아왔으면 좋겠는데, 그러려면 Background에 있을 때에도 컴포넌트 내부에서 데이터 상태를 업데이트 해 줘야 하겠다.
그런데 문제는 Foreground에서는 React Context에 접근해서 앱이나 컴포넌트의 상태를 업데이트할 수 있는데, Background에서는 React Context에 접근할 수 없어서 해당 작업이 불가능하다는 점이었다.
그런데 생각해보니 프론트의 역할은 notification을 받으면 그냥 react query를 통해 API를 호출해주면 되는 것이었다. 그러면 그냥 Background든 Foreground든 react query로 해당 API를 호출해주면 되겠다. 그런데 또 생각해보니 Background에서 굳이 notification을 받아서 최신의 데이터를 계속 유지할 필요가 있을까? 라는 의문도 들었다.
결국 우선은 Foreground에서만 알림을 처리하고, 후에 AsyncStorage, sqlite에서 데이터를 저장해 놓고 쓸 때 그때 Background에서도 알림을 처리하기로 하였다.
그러려면 기존에 사용하던 react query 관련 훅을 재사용하는게 좋아 보여서, 'useTodosQuery'라는 투두 관련 데이터를 불러오는 훅 코드를 보았다. 그런데 해당 코드에는 'refetchInterval' 값이 설정되어 있어서 주기적으로 투두 값을 업데이트 하고 있었다. 우리는 알람을 한번 보내면 그때 한 번 API를 보내서 투두 데이터를 반영하는 작업만 하고 싶었기에 이 값이 필요없었다. 결론은 useTodosQuery와 똑같은 작업을 하되 refetchInterval이 없는 커스텀 훅을 새로 만들어야 한다.
이때 staleTime의 경우 useQuery로 데이터를 불러오고 일정 시간 내에 useQuery를 통해 또 다른 fetch가 일어난다면 cache 데이터를 사용할지를 지정한다. 해당 fetch 작업은 메인 컴포넌트에서 지속적으로 투두를 fetching하는 것과는 무관하므로, 이 작업에 영향을 주어서는 안 되었다. 따라서 staleTime을 0으로 지정했다. 이는 기존에 사용하고 있던 useTodosQuery도 마찬가지였다. 따라서 두 커스텀 훅 모두 staleTime을 0으로 지정해 주었다.
또한 enabled 값을 해당 useTodosQueryByNotification만 false로 지정해 주었는데(기본값은 true다), 그 이유는 enabled 값을 true로 하면 해당 훅이 처음 선언될 때 무조건 한 번 fetch를 하기 때문이었다. 해당 커스텀 훅은 실제 messaging의 onMessage 이벤트 핸들러에 메시지가 들어왔을 때만 실행되어야 하므로, 이 값을 false로 적용했다.
다음과 같이 해당 커스텀 훅을 컴포넌트 안에 선언하고, 필요한 정보들을 파라미터로 리턴하도록 설정해 주었다. data의 경우는 fetch를 했을 때 가져오는 데이터, isSuccess는 fetch가 성공적으로 진행되었는지의 여부, 그리고 refetch의 경우 수동으로 데이터를 fetch하고 싶을 때 실행시켜주는 함수였다.
현재 해당 훅은 enabled 값이 false이기 때문에 자동으로 데이터를 fetch하지 않는다. 이 훅을 통해 데이터를 가져오려면 refetch 인자로 받은 notificationRefetch() 함수를 사용해야 한다.
메시지를 수신했는지는 계속 확인되어야 하므로 useEffect 훅 안에 해당 코드를 선언하고, 메시지를 받았을 경우, 즉 onMessage 이벤트 핸들러가 트리거되었을 경우에 해당 notificationRefetch() 함수를 실행시켰다. 그리고 이를 통해 데이터가 성공적으로 fetch되면 isNotificationSuccess의 값이 true로 바뀔 것이기 때문에 이 조건에 해당되는 경우 받아온 notificationData의 값을 zustand를 통해 만든 TodoStore를 통해 todos의 값으로 바꿔주었다.
그런데 생각해 보면, 클라이언트에서는 서버에서 알림이 오면 그냥 받으면 된다지만 서버는 어떤 클라이언트에게 알림을 보낼지 명시해줘야 했다. 이때 사용하는 것이 FCM(firebase cloud messaging) 토큰이다. 이는 디바이스 토큰이랑은 다른 개념이라고 한다.
디바이스 토큰과 FCM 토큰 모두 각각의 디바이스를 식별할 수 있다는 공통점이 있다. 하지만 디바이스 토큰은 한번 기기가 바뀌지 않는 한 고유한 반면, FCM 토큰은 상황에 따라(주기적으로 firebase 서버에서 갱신할 수 있다고 한다) 바뀔 수도 있다. 그런데 나는 디바이스 토큰의 값을 다음과 같이 구하고 있었어서, 그러면 내가 디바이스 토큰이라 생각했던 값은 사실 디바이스 토큰이 아니라 FCM 토큰이었던 건가? 라는 의문이 들었다.
import messaging from '@react-native-firebase/messaging';
const token = await messaging().getToken();
알고보니 안드로이드에서는 FCM 토큰과 디바이스 토큰을 같은 것으로 취급한다고 한다. 그래서 내가 얻은 FCM 토큰은 디바이스 토큰으로 동작할 수 있겠다.
이제 코드를 작성해보자. 그런데 또 다른 의문이 들었다.
아래와 같이 코드를 작성하려는데, 해당 Certificate를 작성하려면 firebase 관련 파일이 프로젝트 디렉토리에 있어야 했다. 하지만 해당 파일은 firebase 계정 및 관리 정보가 담긴 파일로, 이를 레포지토리에 노출시킬 수는 없었다. 그렇다고 로컬에서 혼자 관리하면 다른 팀원과 같이 개발하는 데 문제가 생긴다.
소스 코드를 보니 Certificate에 주어지는 값은 파일 경로나 딕셔너리 값만 가능했다. 그렇다면 로컬에 파일을 둘 수 없다면 파일 경로값은 줄 수 없으니, 딕셔너리 값을 넣어줘야겠다. Certificate는 firebase에서 json 파일을 통해 확인된 증명 정보를 넣어주는 객체이다.
아마도 AWS의 Secrets Manager에 해당 값을 딕셔너리로 저장하고, 이를 불러오는 방법을 써야할 것 같다.
다음과 같이 'FIREBASE'라는 변수값에 다운로드 받은 json 파일 값을 넣어주고 이를 파이썬 로컬 서버에서 불러왔다.
import firebase_admin
from firebase_admin import credentials, messaging
from django.conf import settings
firebase_info = eval(settings.SECRETS.get("FIREBASE"))
cred = credentials.Certificate(firebase_info)
firebase_admin.initialize_app(cred)
그리고 다음과 같이 클라이언트의 FCM 토큰, 제목, 본문을 인자로 받아 알림을 보내는 함수도 작성해 주자. 여기서 Message는 FCM 서버를 통해 전송될 수 있는 타입의 객체이고, Notification은 Message 안에 notification 내용(notification 아니면 data 아니면 둘 다가 Message에 포함될 수 있다)을 적을 때 사용하는 객체이다. 여기서는 별도로 데이터는 보내지 않고 notification만 보낼 것이기 때문에 다음과 같이 작성했다.
이제 대략적인 코드를 짰으니 코드가 동작하는지를 확인해 보려고 한다. 그런데 테스트를 하면 좋을 것 같았다. 문제는 서버에서 보낸 알림을 클라이언트에서 제대로 받는지를 확인해야 하는데, 이런 양방향 통신이 일어나는 테스트는 해 본 적이 없어서 방법이 생각나질 않았다.
결론은 서버와 클라이언트에서 각각 테스트를 작성하고 확인하면 되었다. 먼저 django에서는 FCM 알람을 보내는 테스트 코드를 작성한다.
from unittest.mock import patch
from django.test import TestCase
from todos.firebase_messaging import send_push_notification
class FCMNotificationTest(TestCase):
@patch("todos.firebase_messaging.send_push_notification")
def test_send_push_notification(self, mock_send_fcm):
mock_send_fcm.return_value = {"status": "success"}
response = send_push_notification("device_token", "title", "message")
mock_send_fcm.assert_called_once()
self.assertEqual(response["status"], "success")
여기서 뭔가 raw json이나 딕셔너리 객체 값을 그냥 사용하는 것 같아서 이를 개선하고 싶었다... 이전에 회사에서 다른 분들의 코드에서 본 @dataclass decorator가 생각나서 적용해 보았다. 해당 코드를 추가하고 위의 코드를 아래에 정의된 변수값으로 바꿔주었다.
from dataclasses import dataclass
@dataclass
class PushNotificationStatus:
status: str
PUSH_NOTIFICATION_SUCCESS = PushNotificationStatus("success")
PUSH_NOTIFICATION_ERROR = PushNotificationStatus("error")
그런데 문득 내가 dataclass가 뭔지 잘은 모르고 쓰고 있었구나 싶었다. 공식문서를 솔직히 다 읽지는 못했고... 필요한 부분만 읽은 다음에 GPT에게도 요약을 부탁했다.
dataclass는 일종의 decorator로, 클래스를 선언할 때 같이 정의해야 하는 여러 메소드들(__init__, __repr__, __eq__)을 해당 데코레이터를 붙이면 암묵적으로 정의해 주는 기능을 가졌다고 이해했다. 또한 필드를 'fieldname:type'의 형태로 정의하면 이를 통해서 메소드를 생성해 주는 기능도 있다. 또한 상속도 되기 때문에 상속받은 부모 클래스의 필드를 가져와서 메소드를 생성할 수도 있다. 그리고 이를 사용하면 반복적으로 작성해야 하는 메소드가 줄기 때문에 유지보수랑 효율성 면에서 좋다고 한다.
아무튼, 이렇게 작성하고 위의 테스트 코드를 살펴보자. @patch라는 데코레이터를 이용해서 'todos.firebase_messaging.send_push_notification'이라는 실제 객체를 모방하는 mock 객체인 'mock_send_fcm'을 생성한 다음, 실제 로직을 수행하여 둘의 결과값을 비교하는 코드이다. 해당 명령어로 테스트를 실행시켜 보았다.
의도와 달리 'send_push_notification'이 한 번도 호출되지 않아서 오류가 났다. 알고보니 코드에 mock 객체를 호출하는 코드가 없었다. 호출 로직('mock_send_fcm()')을 추가해주고 다시 진행하였다.
이번에는 테스트 로직을 호출한 값이 성공에 해당하는 PUSH_NOTIFICATION_SUCCESS 값과 달라서 오류가 났다. 에러 로그를 찍어보니 실험 용으로 'device_token'이라는 문자열을 FCM 토큰 대용으로 보내고 있었는데 이게 유효하지 않은 FCM 토큰이라서 에러가 난 거였다.
그런데 내가 valid한 FCM 토큰을 어떻게 구할까? 라는 의문이 들었다. 방법은 두 가지가 있었다.
1. 내 안드로이드 에뮬레이터의 FCM 토큰으로 테스트하기
2. 클라이언트와 서버를 잇는 엔드 투 엔드 E2E 테스트 하기
사실 장기적으로 볼 때 확장 가능성 있는 것은 2번이다. 그래서 우선은 1번으로 임의 테스트를 하고, 2번의 방법도 도입하기로 했다. 찾아보니 React Native의 'Jest'와 'Detox', Django의 'test suite'를 같이 연동하면 엔드투엔드 테스트가 가능하다고 한다. 설정이 까다롭다고는 하는데 편한 개발을 위해서는 정말 해 두면 좋은 작업이라고는 생각한다. 이 부분도 이슈에 넣어놓았다.
그리고 토스 슬래시를 신청했다! 혹시나 싶어 시간표 공유 이벤트에도 참여해 보았는데 당첨이 꼭 되었으면 좋겠다..!
✅ 궁금한 점
1. onMessage 메시지 핸들러가 접근할 수 있는 React의 Context는 어떤 의미일까?
2. FCM 토큰은 앱을 지웠다가 다시 까는 경우 등에 값이 바뀔 수도 있다고 한다. 이러한 경우는 어떻게 대처해야 할까?
3. 'python manage.py shell' 명령어는 어떻게 동작할까? 별도의 shell 환경에서 터미널이 열리는게 신기한데 이것도 생각해보면 장고 명령어다. 그 원리가 궁금하다.
4. FCM 서버의 동작 원리가 궁금하다.
5. 사실 decorator에 대해서도 문법만 알지 동작 원리는 잘 모른다. 이것도 알아보자.
6. React Native의 'Detox'와 Django의 'test suite'를 연결하면 엔드투엔드 테스트가 가능하다고 한다. 솔직히 쫌 많이 탐난다... 그동안 서버에다가 클라이언트에서 직접 요청을 날려가면서 테스트한 적이 참 많았기 때문이다. 이 녀석을 내일 간단히 찍먹해 보고, 할 만하다고 생각되면 조금이라도 도입해 보고 싶다.
일단은 어제 관련된 dependency 에러를 해결했으니, 이제 알람 기능을 개발해보려고 한다. 사실 여전히 Api 클래스에서 리프레시 토큰 값이 반영되지 않고 있다... 하지만 일단은 알람 기능을 개발하면서 에러 수정을 병행해야 할 것 같다.
알람 기능은 크게 두 가지로 나뉜다. 서버에서 클라이언트로 알림을 보내는 부분과, 클라이언트에서 해당 알림을 받아서 보여주는 로직이다. 우선은 클라이언트 쪽부터 작업해보자.
알고보니 안드로이드 프로젝트에 FCM(firebase cloud messaging)을 설정하려면 사전 작업이 필요했다. 공식문서에 나와있는 가이드대로 진행해주자.
우선 android>app>src>main>AndroidManifest.xml 파일에 다음과 같이 추가해주었다. 백그라운드에서만 앱의 알림을 수신하려면 이 부분을 스킵해도 되지만, 우리는 포그라운드 알림 기능이 필요할 수도 있기 때문에 이 작업을 해야 했다. 해당 파일의 <activity> 태그와 같은 레벨에 해당 코드를 추가해 주었다.
또한 우리는 안드로이드 13버전 이상을 사용하는데, 문서에 따르면 13버전 이상부터는 안드로이드 앱에 알림을 표시하려면 새로운 권한 설정을 해 줘야 한다고 한다. MainActivity.kt 파일을 찾아 해당 코드를 추가해주었다.
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { isGranted: Boolean ->
if (isGranted) {
// FCM SDK (and your app) can post notifications.
} else {
// TODO: Inform user that that your app will not show notifications.
}
}
private fun askNotificationPermission() {
// This is only necessary for API level >= 33 (TIRAMISU)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
// FCM SDK (and your app) can post notifications.
} else if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) {
// TODO: display an educational UI explaining to the user the features that will be enabled
// by them granting the POST_NOTIFICATION permission. This UI should provide the user
// "OK" and "No thanks" buttons. If the user selects "OK," directly request the permission.
// If the user selects "No thanks," allow the user to continue without notifications.
} else {
// Directly ask for the permission
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
그런데 원본 코드를 자세히 보니 if문을 알아서 채워줘야 하는 형태였다. 해당 부분을 비워두는 것은 메소드를 아예 선언하지 않는 것과 똑같아서 어떤 코드라도 넣긴 해야하는데... 뭐라고 작성해야 할지를 모르겠었다. 그러다 왼쪽 네비게이션 바를 봤는데, 내가 보고 있던 부분은 Java/Kotlin으로 안드로이드 개발을 하는 사람들에게 해당되는 내용 같았다.
혹시나 싶어 'react native fcm'으로 검색해보니 바로 React Native에서 사용할 수 있는 다른 라이브러리가 나왔다. 예전에 google analytics로 로그를 남길 때 사용하던 그 라이브러리였다.
글을 쓰고 있는데 자정이 넘어가고 있다. 위에서 작업한 내용들을 다시 롤백하고, 남은 부분은 내일 다시 해봐야겠다.
✅ 궁금한 점
1. AndroidManifest.xml 파일의 역할은 무엇일까?
2. Kotlin과 Java 언어의 차이가 궁금하다. 단순 문법적 차이 말고, 둘은 어떤 차이가 있을까?
오늘은 프론트와 백엔드에서 알람을 사용할 수 있도록, 특히 투두가 업데이트 되면 서버에서 클라이언트로 데이터를 업데이트 할 것을 백그라운드 알람으로 보낼 수 있도록 하려고 한다.
알림의 사용 용도는 크게 두 가지이다. 첫 번째는 하루에 주기적으로 일의 완성도를 체크하기 위해서 특정 시간에 보내는 알람, 두 번째는 여러 기기 간 동기화를 위해서 서버에서 변경 사항이 생겼을 때 해당 클라이언트로 보내는 알람이다. 두 번째의 경우는 알람이긴 하지만 백그라운드로 보내서 사용자가 직접 보진 못한다.
이를 위해선 백엔드 API에서는 Update, Post 등의 API가 호출되었을 때 변경에 해당되는 클라이언트에게 알림을 보낼 수 있겠다. 그런 식의 로직을 짜면 될 것 같다. 그렇다면 프론트에서는 해당 알림을 받아서 백그라운드 모드일 경우 API를 한번 더 호출해 주거나, 백그라운드 모드가 아닐 경우는 알람을 띄워주면 되겠다.
그런데 그러기 위해서는 우선 지금까지 작업사항을 반영 못 하고 있었던 프론트 관련 이슈인 SZ-215를 먼저 머지해야 했다. 왜냐하면 해당 브랜치에서 로그인이랑 액세스토큰 재발급 이슈를 다루고 있었기 때문이다. 혹시나 싶어서 dev 브랜치에 checkout을 해 봤더니 구글로그인 버튼이 동작하지 않고 있었다. 다른 팀원이 이 이슈를 잘 해결해두었다는 말은 들었는데 dev 브랜치에는 그 사항이 반영되지 않은 것 같았다.
그래서 SZ-215로 다시 가서 앱을 실행시켜 보았다. 분명 잘 해결했다고 생각했는데 여전히 뭔가 이상하다. 우선 투두 뷰 화면으로는 잘 이동을 하는데, 콘솔에는 401 에러도 같이 찍힌다. 구글로그인을 했는데도 말이다. 이 부분이 말끔히 해결되지 않으면 알람을 못 구현한다. 로그인 된 상태의 유저에게만 알람을 줄 수 있으니 테스트할 방법이 없다. 결국 해당 알람 이슈는 SZ-215에 dependency가 걸려버렸다. 그러므로 우선 SZ-215를 먼저 처리하자.
우선 앱을 종료시켰다가 다시 켜 보니 401 에러는 나오지 않았다. 대신에 다른 에러가 떴다.
에러 메시지에서 준 링크를 보니 '다른 컴포넌트를 렌더링하고 있을 때는 컴포넌트를 업데이트할 수 없다'는 에러를 고치기가 까다롭다는 사람들의 깃허브 이슈가 보였다. 해당 링크에서는 해결 방법을 얻지는 못했고, trace 로그 중 어디를 봐야 할지는 알 수 있었다. trace 로그가 정말 길어서 볼 생각을 못 하고 있었는데, 하나씩 읽어보다 보니 내가 작성한 컴포넌트가 눈에 들어왔다.
DailyTodo라는 내가 작성한 컴포넌트 안의 UI Kitten의 ListItem 컴포넌트가 있고, 그 안에 TouchableOpacity 컴포넌트가 있고, 해당 컴포넌트의 accessoryRight으로 준 값에 대해서 문제가 생긴 것 같았다.
즉 accessoryRight 안에 있는 컴포넌트나 뷰는 렌더링 도중에 호출되는데, 이때 상태 변화도 같이 일어나고 있는 것이었다. 그런데 렌더링 과정 도중에는 상태를 변경하면 안 되기 때문에 (당연하다. 화면을 그리고 있는데 중간에 상태가 변하면 안 되겠지) 이러한 오류가 나는 것이었다.
이를 해결하려면 상태를 변경하는 로직이 accessoryRight의 값으로 들어간 컴포넌트 안에 있으면 안 되고, 별도의 이벤트 핸들러나 함수에서 이 작업을 처리하도록 위임해야 한다고 한다.
기존의 코드는 다음과 같았는데, TouchableOpacity에서 onPress 속성값의 함수를 별도로 정의하는 대신 바로 정의해 버려서 함수가 그때마다 렌더링되는 문제가 있나? 싶었다.
해당 부분을 다음과 같이 별도의 함수로 분리해 주었다.
그랬더니 에러가 없어졌다!
그러다가 또 다른 에러를 찾았다...! 정확히는 콘솔에 에러는 안 찍히지만, 앱이 의도한 대로 동작하지 않아서 에러라고 생각했다. 문제 상황은 다음과 같다. 앱 화면이 있고 하위 투두를 생성할 수 있게 되어있는데, 문제는 하위 투두를 생성하려다가 다시 버튼이나 컴포넌트 바깥을 누르면 해당 하위 투두의 TextInput 컴포넌트가 사라져야 한다. 이 부분에서 상태 관리가 제대로 안 되고 있다고 느꼈다.
음 그래도 일단 중요한 것은 앱이 제대로 '콘솔 오류 없이' 실행되는 것이었다... 물론 이것도 당연히 출시 전까지는 고쳐야 할 에러다! 그런데 일단 알람 기능을 먼저 하고 이걸 해야 알람 기능 작업이 안 밀릴 것 같다는 생각이 들었다. 그래서 이 부분은 SZ-215에서 다루지 않고, 별도의 버그 이슈를 파서 처리하려고 한다.
그러면 이제 SZ-215에서 모든 작업들(투두 CRUD, 하위 투두 CRUD)이 잘 되는지 확인해 보면 되겠다. 모든 작업이 순탄하게 된다면 dev 브랜치로 PR을 날릴 수 있었겠지만... 아쉽게도 그러지 못했다.
우선 투두의 CRUD까지는 잘 되었다(이 기준은 콘솔 에러가 나지 않고, 화면에 의도한대로 잘 반영되었을 때 잘 된다고 판단했다). 문제는 하위 투두의 CRUD였다. 앱을 껐다 켜도 정보가 없는 걸 보면 API 요청이 제대로 보내지지 않은 것 같았다.
ECS 콘솔을 확인해보니 400 Bad Request가 뜨고 있었다. 뭔가 요청을 보내고는 있는데 형식이 잘못되어서 제대로 추가가 되지 않는 것 같았다.
그리고 현재 개발서버에서는 액세스토큰의 만료기한을 30분으로 설정해주고 있는데, 밥 먹고 오니까 액세스토큰이 만료되었는지 401 AxiosError가 났다. 원래대로라면 토큰 갱신 API를 자동으로 호출해 주어야 하는데, 이 부분이 아직 제대로 동작하지 않는 것 같다. 즉 현재 고쳐야 할 점은 두 가지다.
1. 하위 투두 API를 호출할 때 나는 400 Bad Request 에러 해결하기
2. 토큰 갱신 API를 자동으로 호출하여 401 AxiosError 해결하기
2번이 완료되어야 1번도 작업할 수 있기 때문에, 2번 -> 1번 순으로 해결해 보자.
현재 2번 로직은 Api라는 싱글톤 패턴 클래스에서 axios 인스턴스를 정의해두고, 해당 axios 인스턴스에 interceptor라는 기능을 추가해서 쓰고 있었다. 이 인터셉터가 제대로 동작을 하는지를 우선 봐야겠다. 다시 axios 공식문서를 봤더니 공식문서에서 제안하는 코드 형식과 현재 GPT의 도움을 받아 작성된 코드의 형식이 조금 달랐다. 공식문서가 더 신뢰성이 있으므로 공식문서대로 바꿔보았다.
1번 에러의 경우, 로그를 찍어보니 문제 상황을 알 수 있었다. 이전에 하위 투두 생성 API의 요청/응답 데이터를 조금 바뀌었던 게 생각났다. 현재는 객체, 딕셔너리 형태로 요청을 보내고 있었는데 지금은 리스트 형태로만 요청을 받도록 되어있었다. 이 부분은 API 수정도 필요하지만, 우선은 객체를 리스트로 한 겹 감싸서 보내는 게 해결이 더 빨라서 임시로 처리해 두었다.
그리고 프론트 앱에서 어떤 API들을 호출하고 있는지 로그를 찍어보았다. 토큰을 인증하는 API가 먼저 실행되는 줄 알았는데 카테고리를 불러오는 API가 먼저 실행되고 있었다. 이 부분은 수정되면 좋을 것 같았다. 아무튼 그 외에는 토큰을 인증하려고 시도하고, 투두와 같은 정보들을 불러오는 것으로 보였다.
문제는 여기서 401 에러가 났는데 토큰 갱신 API 로그는 없었다. 토큰 갱신 API는 request() 메소드를 다시 호출하지 않고, this.axiosInstance()를 호출하여 별도로 처리하기 때문이었다. 그러면 토큰 갱신 API도 잘 호출되고 있다는 것인데, 무슨 문제일까? 토큰 갱신 API에 요청을 보낼 때 body에 보내는 'this.refreshToken'의 값을 찍어보니 이유를 알 수 있었다. 해당 값이 'undefined'로 찍히고 있었다.
코드상에서는 해당 API 인스턴스의 생성자에서 init() 메소드를 실행하고 해당 메소드 안에서 AsyncStorage 안에 있는 accessToken, refreshToken의 값을 넣어주고 있었다. 해당 메소드에서 변수들의 값을 로그로 찍어보았을 때도 토큰 값이 있다고 찍혔었다.
알고보니 init() 메소드는 비동기적으로 실행되기 때문에 해당 메소드에서 refreshToken에 값을 할당하기 전에 이미 axios 인스턴스가 초기화 되었을 수 있다. 하지만 constructor() 생성자 메소드 안에서 바로 await 예약어를 사용할 수는 없기 때문에, then()이나 catch()로 비동기 함수가 반환하는 Promise를 추가로 처리하는 로직을 작성해줘야 하겠다.
사실 이 부분을 작업하다 에러가 났었다. .then()을 통해 로직을 실행해도 여전히 undefined로 값이 찍히는 거였다. 나는 짚이는 부분이 더 이상 없었어서, 혹시 멘토님은 어떤 실마리라도 아실 수 있겠다 싶어 도움을 요청드려 보았다. 알고보니 axios interceptor 로직 내부에서 정상 response 받는 부분을 function() 문법으로 처리했었는데, 화살표 함수로 바꾸니 문제가 풀렸다..! 정말 예상하지 못했고, 사실 왜 풀렸는지 아직 제대로 이해하지 못한 부분이기도 하다.
그래도 멘토님이 감사하게도 바로 에러의 원인을 짐작해주셔서 에러를 고칠 수 있었다. 새삼 대강 보이는 로직만이 전부가 아니고 문법 하나하나도 동작에 영향을 미치는구나 싶었다. JS에 대해서 모르는 게 많다는 걸 오늘도 하나 더 알아가본다...!
function 함수와 화살표 함수는 this 키워드의 동작 방식에서 차이가 있다고 한다. 멘토님도 'this binding 이슈 때문에 화살표함수를 많이 사용한다'고 하셨는데 이 부분과 관련된 내용 같았다. 사실 GPT가 풀어서 설명해준 내용을 100% 이해하진 못했는데, function으로 정의된 함수에서 this를 사용하면, 함수가 호출되는 위치에 따라서 해당 위치가 전역(global)인지, 지역(local)인지 등에 따라 this가 의미하는 대상이 달라질 수 있다고 이해했다. 반면 화살표함수로 정의하면 this는 해당 화살표 함수가 선언된 위치에서의 this와 동일해서, 어떤 위치에서 함수가 호출되건 this가 가리키는 대상은 불변한다고 한다.
✅ 궁금한 점
1. git rebase의 원리가 궁금하다. 이거 예전에도 남겼던 것 같은데 소스 코드를 보자!
2. 컴포넌트에서 setState가 호출될 때 공식문서에서는 'enqueue'라는 표현을 썼다. 컴포넌트들마다 일종의 큐가 있고, setState처럼 컴포넌트의 상태가 변경되어야 할 때 해당 정보가 큐에 들어가서 대기하는 방식으로 구현했나보다 싶어서 신기했다.
3. 화살표 함수와 function()이 그냥 보기에만 다른 게 아니었구나 싶다..! 멘토님 피드백을 보면서 알았다. 다음에 이 내용도 파 보자!
어제 Intellij에서 xml 파일을 작성해 주다가 xml에서 참조하는 dtd 파일의 링크가 유효하지 않아서 오류가 발생했었다. 그래서 우회하는 방법으로 여러 커맨드를 사용해 보니, 꼭 reveng.xml 파일을 사용하지 않아도 장고 모델을 스프링 엔티티로 바꿀 수 있는 것처럼 보였다.
요약하자면 다음과 같다.
1. 장고의 inspectdb 명령어를 이용해서 스키마 안의 테이블들을 전부 장고 모델로 바꾼다.
2. hibernate에서 장고에서 사용하던 DB와 연결한다.
3. hibernate tools를 통해 DB를 참고하여 스프링 엔티티 클래스를 만든다.
4. 장고 모델과 동일하게, 1번을 참고하면서 4번의 스프링 엔티티 클래스를 수정한다.
처음에는 1번 과정이 왜 필요한가 싶었는데, 혹시나 4번 과정에서 생성된 스프링 엔티티 클래스가 잘 생성된 것인지를 직접 비교하면서 판단하기 위해서는 필요할 것 같았다.
1번 과정의 경우, 장고의 inspectdb라는 명령어를 사용하면 장고가 현재 사용하고 있는 DB(스키마)에 있는 모든 테이블들을 장고의 모델 클래스로 옮길 수 있다. 공식문서에도 친절하게 설명이 나와있었다.
그런데 inspectdb 명령어는 원래는 반대로 기존에 다른 프로젝트에서 사용하고 있는 데이터베이스를 장고 WAS에서도 참조하고 싶을 때 사용하는 것으로 보였다. inspectdb 명령어로 생성된 모델은 managed 속성값이 False로, 해당 모델의 CRUD(인스턴스가 아니라, 해당 모델 클래스에 대한 CRUD)가 장고 ORM에서 관리되지 않는다고 한다.
그 외에도 데이터베이스별로 컬럼 타입 등이 조금씩 다를 수 있기 때문에 만약 데이터베이스의 어떤 컬럼 타입을 장고에서 사용하는 컬럼 타입으로 바꿀 수 없는 경우 기본으로 TextField를 할당한다고 되어있었다. 또한 데이터베이스에서 필드 이름으로 파이썬의 예약어(reserved word)를 사용한 경우 뒤에 '_field'를 추가로 붙여준다고 한다.
inspectdb 커맨드의 소스 코드를 좀 보려고 했는데 양이 너무 길어서 다 보진 못했다. 'django.core.management.commands' 안에 있는 파일들이 모두 각각의 장고에서 제공하는 기본 커맨드이고, 모든 파일에서는 전부 BaseCommand라는 기본 클래스를 상속받는다.
BaseCommand는 어떤 클래스도 상속받지 않은 기본 클래스이며, BaseCommand를 상속받은 여러 AppCommand, LabelCommand 등도 있다. AppCommand, LabelCommand 클래스들은 BaseCommand처럼 base.py에 정의된 걸 보니 얘네들을 상속받는 또 다른 커맨드 클래스들이 있는 것 같다.
BaseCommand를 상속받아서 'django-admin'이나 'python manage.py' 뒤에 사용될 수 있는 커맨드를 만들고 싶다면 이 BaseCommand를 상속받으면 된다. 그리고 'handle'이라는 메소드를 오버라이딩해서 해당 커맨드가 어떤 역할을 할지를 정의해주면 된다.
원리도 주석으로 상세히 설명되어 있었는데, 여러 메소드가 연쇄적으로 호출되는 것 같았다. 우선 처음에는 'django-admin'이나 'python manage.py' 명령어를 호출하면 뒤에 붙은 커맨드 클래스의 'run_from_argv' 메소드가 호출된다. 그리고 해당 메소드는 'create_parser' 메소드를 호출해서 해당 커맨드 뒤에 인자로 붙은 것이 있다면 'ArgumentParser' 클래스를 통해 파싱(parsing)을 한다.
그리고 그 파싱된 인자들을 가지고 'execute' 메소드를 실행한다. 'execute' 메소드에서는 'handle' 메소드를 호출하여 메소드의 핵심 로직을 실행시킨다. 만약 두 메소드 안에서 오류가 날 경우 'stderr'를 포함한 오류 메시지가 포함된다고 한다.
아무튼 그래서 inspectdb도 당연히 handle 메소드가 있었다. handle 메소드 안에서는 handle_inspection이라는 또 다른 메소드를 호출하고 있었다.
handle_inspection 메소드는 너무 길어서 자세히 보진 못했다. 그래도 맨 처음에 DB에 커넥션을 맺고, 해당 커넥션을 통해 DB 스키마의 테이블들, 필드 이름들을 전부 가져오는 것으로 보였다. 아무튼 해당 작업을 통해서 DB 스키마에 있는 테이블들을 장고 모델로 바꿔준다는 것을 대략적으로나마 알 수 있었다.
이제 2번 작업으로 넘어가자. 그런데 아까처럼 .dtd 파일에서 오류가 나는 상황이다. 관련 공식문서를 찾아보았지만 현재 Reverse Engineering 기능은 유료인 Ultimate 버전에서만 제공되는 것 같았다. 알고보니 Intellij 무료 버전에는 내장된 데이터베이스 브라우저가 없기 때문에 이런 기능이 기본적으로 제공되지 않는다고 한다.
사실 테이블 몇 개 옮기는 게 그렇게 번거로운 작업은 아닌데, 문제는 장고 모델이 변경될 때마다 계속 그에 맞춰 스프링 엔티티를 변경해 줘야 한다는 거였다. 그래서 JPABuddy라는 툴을 사용하는 방법도 있다고 한다. 무료 버전에서는 기능이 제한적인데, 최대한 무료 기능으로 한번 해결해 보자.
✅ 궁금한 점
1. inspectdb 명령어로 생성된 장고 모델 중에서는 allauth나 simplejwt 같은 외부 라이브러리를 사용하면서 생성된 것들도 있는데, 이것들도 스프링 엔티티로 생성해야 할지 궁금하다. 왜냐하면 스프링에는 장고의 allauth, simplejwt와 똑같은 기능을 하는 라이브러리가 없을 수도 있고, 있더라도 그 라이브러리를 직접 사용해서 쓰는 게 더 맞기 때문이다. 이 부분은 잘 모르겠지만 일단은 이렇게 자체적으로 생성되지 않고 외부 라이브러리를 통해 자동적으로 생성된 모델들은 일단은 스프링 엔티티로 옮기지 말고 진행해야겠다.
2. 'handle_inspection' 메소드에서 yield 문법이 나왔는데 여기에 대해서 잘 모르는 것 같다. 다음에 더 알아보자.
스스로를 LLM이라고 생각해 보자. LLM에 멘토님이 보내주신 프롬프팅 관련 지식들을 학습시킨 다음, 그 지식을 토대로 프롬프팅을 어떻게 할지, 하위 투두의 퀄리티는 어떻게 높일 수 있을지를 생각해 보는 것이다.
우선 문서를 읽기 전에는 프롬프팅을 그냥 잘 하면 되는거 아닌가? 싶었는데 그건 아닌 모양이다. 여러 가지 프롬프팅에 대한 기본적인 지식과 Claude(여기서는 Claude를 다루는 법을 설명하는데 GPT도 엄청 다르지는 않을 것 같다)를 어떻게 다뤄야 하는지에 대한 매뉴얼과 그에 따른 예시들이 설명되어 있었다.
그리고 '프롬프팅'과 '프롬프트 엔지니어링'의 차이도 명확히 알 수 있었다. '프롬프팅'은 말 그대로 LLM에게 질의를 한 번 던지는 것이고, '프롬프트 엔지니어링'은 세밀하게 짜여진 질의를 던지고, 사용자들이 줄 수 있는 광범위한 질의에 대해서 LLM이 어느 정도 범주에 있는 예측 가능한 응답을 하도록 프롬프트를 만드는 것이었다.
뿐만 아니라 프롬프트 엔지니어링은 초기에 완벽한 프롬프트를 짠다고 끝이 아니었다. 애초에 완벽한 프롬프트라는 게 없기도 하거니와, 분명히 예상하지 못한 오류들이 있을 것이기 때문이다. 그래서 우선은 최대한 이상적인 초기 프롬프트를 짜고, 여러 가지 유저의 테스트 답변을 통해 프롬프트가 정상적으로 작동하는지 계속 확인하면서 프롬프트를 개선하는 과정이 프롬프트 엔지니어링이라고 할 수 있겠다.
아무튼, 프롬프트 엔지니어링을 하려면 제대로 된 초기 프롬프트를 짜는 것도 중요하다. 가이드에서 제시한 프롬프트를 잘 짜는 방법들에는 다음과 같은 것들이 있었다:
1. LLM에게 역할 부여하기
2. XML 등의 태그를 사용하여 LLM이 참고할 만한 정보 등을 구조화해서 표현하기
3. Input, Output의 형식과 길이 등에 대해서 구체적인 정보 제시하기
4. LLM이 자신의 사고과정을 직접 표현하도록 하기
5. 여러 예시들을 같이 제공하기(multi-shot prompting 이라고 한다)
즉 앞으로의 작업들은 이렇게 하면 되겠다.
순서
할 일
마감기한
1
위의 1-5번 원칙을 통해서 초기 프롬프트를 작성하기
8/25
2
프롬프트를 테스트할 여러 인풋을 만든 다음, 점수를 매길 기준을 정해서 테스트하기
8/26
3
2번에서의 문제점을 해결하는, 더 개선된 프롬프트 만들기
8/27
그러면 우선은 1번 작업을 해 보자. 참고로 LLM을 API 단에서 사용할 때는 'system' 이라는 파라미터를 사용해서 유저가 원하는 답변의 형식이나 방식 등을 조절하고, 'prompt' 파라미터를 사용해서 그때그때 LLM이 답변해주었으면 하는 것들을 입력하는 것으로 보였다.
시스템 프롬프트에는 다음과 같이 입력해 주었다.
너는 사람들이 계획을 잘 세우도록 도와주는 기획자이자 플래너야. 네가 할 일은 사람들이 너에게 ‘투두(할 일)’을 제시하면 그걸 더 작은 단위인 ‘하위 투두’들로 나눠주는 거야.
이후 일반 프롬프트에는 다음과 같이 입력해 주었다.
'<examples>' 태그에는 투두를 하위 투두로 쪼개주는 예시들이 있어. 이 예시들을 참고해줘.
<examples>
<example>
1. 투두를 하위 투두들로 나누는 데 필요한 정보가 충분한 경우. 이 경우는 바로 해당 투두를 하위 투두로 나눠주면 돼.
<user_prompt>
아스랑 저녁 8시에 만나서 집들이하기
</user_prompt>
<subtodos type=‘answer’>
1. 아스한테 오늘 약속이 맞는지 확인하기
2. 저녁 7시에 아스네 집으로 출발하기
3. 집들이 선물 사 가기
</subtodos>
</example>
<example>
2. 투두를 하위 투두들로 나누는 데 필요한 정보가 불충분한 경우. 이 경우는 유저에게 질문을 해서 추가 정보를 얻어야 해.
<user_prompt>
친구랑 약속
</user_prompt>
<subtodos type=‘question’>
1. 친구와 몇 시에 만나기로 했나요?
2. 친구랑 어디서 만나기로 했나요?
3. 친구랑 만나는 곳은 여기서 얼마나 떨어져 있나요?
</subtodos>
</example>
<example>
3. 투두와 관련된 프롬프트가 아닌 경우. 이 경우는 별도로 하위 투두를 나눠주지 않아.
<user_prompt>
파이썬 스크립트를 만들어줘
</user_prompt>
<subtodos type=‘invalid’>
적합한 투두 형식이 아닙니다. 하위 투두로 나눌 수 없습니다.
</subtodos>
</example>
</examples>
쓰다보니 3번(입력값, 출력값의 형태에 대해서 정보 제공하기)과 4번(LLM이 자신의 사고과정을 직접 표현하도록 하기) 부분은 반영되지 않은 것 같았다. 그리고 예시의 개수가 너무 부족한 것 같았다.
일단은 이 프롬프트를 기준으로 프롬프트를 발전시켜가야 할 것 같다. 우선은 멘토님이 Claude와 달리 GPT는 XML보다 JSON 형식의 입력을 더 잘 인식한다고 하셔서, 그리고 대부분의 응답은 JSON으로 처리하는 게 편하므로 응답의 형태부터 바꿔주자.
[
{
"caseNumber":1,
"caseDescription":"투두를 하위 투두들로 나누는 데 필요한 정보가 충분한 경우",
"caseInstruction":"바로 해당 투두를 하위 투두로 나눠준다",
"userPrompts":[
{
"type":"user",
"content":"아스랑 저녁 8시에 만나서 집들이하기"
}
],
"subtodos":{
"type":"answer",
"content":[
{
"subtodoNumber":1,
"subtodoContent":"아스한테 오늘 약속이 맞는지 확인하기"
},
{
"subtodoNumber":2,
"subtodoContent":"저녁 7시에 아스네 집으로 출발하기"
},
{
"subtodoNumber":3,
"subtodoContent":"집들이 선물 사 가기"
}
]
}
}
]
이전 포스트를 올린 지 1주일이 훌쩍 넘어가고 있어서 이대로 가다간 사이드 프로젝트를 아예 못 이어나가겠다는 위기감이 있었다. 사실 이걸 안 하더라도 기존 프로젝트의 '하위 투두 프롬프팅' 관련 작업이 또 남아서 시간이 넉넉하진 않지만, 중간발표와 디버깅에 많이 순위가 밀려있던 이 녀석을 좀 챙겨줘야 하겠다는 생각이 들었다.
오늘의 목표는 기존 Django WAS에서 사용하고 있는 RDS DB에 영향을 주지 않고 Spring Entity에 똑같은 정보를 가져오는 것이다. (사실 마이그레이션이라는 표현이 맞는지는 잘 모르겠어서 쓰다가 아니다 싶으면 제목을 고쳐봐야겠다. -> 마이그레이션이 맞는 듯 하다.)
나름 잘 설명했다고 생각했는데, 처음에는 녀석이 직접 변환하는 방법을 추천해줬다... 다행히 추가적인 질의로 좋은 방법을 알아낼 수 있었다.
Django model을 Spring entity로 변환하는 방법에는 여러 가지가 있었다. 결론적으로 나는 3번 방법을 선택했다가 리버스 엔지니어링의 장벽을 느끼면 1번으로 돌아오기로 했다.
1. Django model을 SQL script로 변환한 뒤, SQL script를 Spring entity로 변환하기
2. JHipster 라는 툴을 사용하면 SQL 스키마를 Spring entity로 변환할 수 있다고 한다. 다 좋았는데, 변환 파일을 작성할 때 JDL이라는 JHipster 자체 문법을 사용해야 한다고 해서 그 점이 진입장벽이었다.
3. Hibernate에서 리버스 엔지니어링을 사용하면 기존 DB 스키마에서 Spring entity를 자동으로 생성할 수 있다고 한다.
4. Django model 파일을 parsing해서 직접 Java 클래스 파일로 변환하는 스크립트를 짜는 방법도 있는데 너무 번거롭고 굳이 다른 도구가 있는데 사용할 필요가 없다고 느꼈다.
5. Liquibase나 Flyway같은 도구가 있었다. 다 너무 좋은데 유료여서 포기했다.
그런데 잠깐이지만 또 궁금한 게 생겼다. 내가 Hibernate와 리버스 엔지니어링의 정확한 정의를 모르고 있었더라.
Hibernate란?
공식문서를 참고해봤다. Hibernate는 Java에서 사용하는 ORM 프레임워크다. ORM이란 객체(object)와 관계형 데이터베이스(relational DB) 사이를 연결(mapping)해 주는 기법이다. 즉 ORM이 없던 시절에는 DB에서 조회한 값을 별도의 객체로 들고 있지 못했고, 그래서 그 값을 읽거나 쓰는 작업을 하려면 여러 가지를 조심해야 했고, 코드도 많이 작성해야 했다. 특히 두 가지가 불편했다. 첫째로는 SQL을 매번 작성해야 했고, 둘째로는 DB에 코드가 의존하게 된다는 점이었다. 각 DB마다 문법(dialect)이 조금씩은 다르기 때문이었다.
아무튼, 이런 ORM 기법을 Java 언어에서 사용하도록 해 주는 ORM 프레임워크 중 하나가 Hibernate였고, 사실상 대부분의 개발에서 Hibernate를 많이 사용한다고 알고 있다.
리버스 엔지니어링(Reverse Engineering)이란?
그렇다면 리버스 엔지니어링(reverse engineering)은 또 뭘까. 대강 역방향으로 코드를 분석한다는 정도로만 알고 있었는데 세부적인 정의는 좀 더 달랐다. 소스 코드를 보지 않고 컴파일된 프로그램의 동작 방식을 이해하는 것이 리버스 엔지니어링 이었다. 또한 보통은 ORM을 통해 객체 모델을 데이터베이스 스키마로 변환하는데, 이 반대의 작업(지금 내가 하려는 거)도 리버스 엔지니어링에 해당한다. 꼭 '어떤 작업'만 리버스 엔지니어링이 아니라, 보통 순방향으로 이뤄지는 엔지니어링을 역방향으로 진행하는 것이 리버스 엔지니어링에 해당한다고 이해했다.
다시 돌아가 보자!
나는 Hibernate Tools를 사용해서 리버스 엔지니어링을 하려고 한다. 우선 리버스 엔지니어링을 하려면 hibernate를 build.gradle 파일에 dependency로 추가해 주고, 'ORM 매핑에 필요한 기본 정보를 포함한 파일'과 '리버스 엔지니어링에 필요한 파일'을 만들어 줘야 한다.
그런데 코드 에디터(Intellij)에서 에러가 났다. 위의 두 XML 파일에서 참조하고 있는 .dtd 파일의 링크로 들어가 보니 해당 링크나 파일이 유효하지 않은 것 같았다. 찾아보니 DTD 파일이란 XML 문서에서 사용되는 구조와 규칙을 정의하는 문서라고 한다. 즉 XML 파일에서는 이 DTD 파일을 통해 어떤 규칙을 사용해야 할지 참고하려고 했는데 해당 링크가 유효하지 않아서 에러가 난 것으로 보였다.
에러를 해결하고 마저 마이그레이션을 하고 싶었는데 밤이 늦었다... 일단 여기까지 세이브 해 두고 자야겠다!
✅ 궁금한 점
1. 이 스프링 사이드 프로젝트를 진행하는 몇 가지 큰 이유 중 하나는 '똑같이 동작하는 WAS를 다른 프레임워크로 만들어 보면서 많이 배울 것 같아서'와 '스프링으로 만들면 취업에도 도움이 될 것 같아서' 였다. 그런데 막상 내가 '왜'를 놓치고 있다는 생각이 들었다. 단순히 스프링 쓰면 그걸로 끝일까? 물론 취업이나 현실적인 여건을 어느 정도 고려하는 건 당연하고 그럴 수는 있는데, 그냥 막연히 '스프링이니까!' 라고 쓰기엔 좀 2% 부족하다는 느낌이다... 왜 사람들은 스프링을 쓰는지에 대해서도 나만의 답을 내려보자.
2. Hibernate의 구체적인 정의는 뭘까
3. 리버스 엔지니어링의 구체적인 정의는 뭘까
4. Django model이 바뀌면 Spring entity에 자동으로 반영해줄 수는 없을까? 역방향(Spring->Django)은 안 되고, 순방향(Django->Spring)으로만 반영되도록 자동화해주는 뭔가가 분명 있을 텐데 찾아봐야겠다.
5. Hibernate와 JPA의 관계는? JPA는 또 뭐고 Hibernate랑은 어떤 관계가 있나?
develop 브랜치, 즉 개발 환경에 대해서 커맨드가 잘 작동하니, 나머지 환경인 test와 production 환경에 대해서도 해당 커맨드가 동작하도록 yaml 파일만 바꿔주었다.
이제는 어제 잠시 보류했던 SZ-243의 하위이슈 'gunicorn, uvicorn을 이용해서 worker를 2개 이상 띄웠음에도 RPS가 그대로인 문제'를 해결해보려고 한다. 당시 멘토님이 조언을 주셨던 부분은 다음과 같다.
1. debug = True로 임시로 설정하기
2. uvicorn worker를 빼고 gunicorn으로만 명령어 설정하기
3. --log-level debug 부분도 빼기.
왜 이런 피드백을 주셨을까 나름대로 유추해 보자면, 1번의 경우는 debug 모드를 켜야 에러를 잡는 데 더 수월해서였을 것 같다. 3번의 경우는 굳이 세부적인 부분(trace 다음으로 로그를 많이 찍는 게 debug니까)까지 로그를 찍을 필요가 없거나, 그렇게 했을 때 너무 많은 정보들이 로그로 찍혀서 로그를 보기 어렵기 때문일 것이라고 추측했다.
그리고 2번을 잘 모르겠었다. 나는 이전 포스트에서처럼 "gunicorn은 여러 개의 uvicorn 프로세스를 통합해서 관리하도록 도와주는 역할을 한다"고 이해했다. 그러면 uvicorn 없이 gunicorn만 있으면 여러 개의 worker로 요청을 받지 못하는 거 아닌가? 그런데 왜 gunicorn만 사용해 보라고 하시는 건지 단박에 이해가 되지는 않았다.
알고보니 gunicorn은 uvicorn 등의 다른 백엔드(라이브러리)를 통해서 worker를 관리하기도 하지만, 자체적으로도 여러 개의 worker들을 관리할 수 있는 라이브러리였다. 즉 이전 포스트에서 내가 이해했던 내용들 중에는 잘못된 부분이 있어서 헷갈렸던 것 같다.
그리고 GPT 피셜, 위의 2번 조언을 주신 이유는 크게 두 가지라고 한다. 첫 번째는 gunicorn과 uvicorn이 서로 worker들을 관리하려고 해서 중복 관리가 일어나서 그로 인해 오버헤드가 발생할 수 있기 때문이다. 두 번째는 gunicorn과 uvicorn이 모두 worker를 관리하기 때문에 문제가 발생했을 경우 어느 쪽에서 문제가 발생한 건지 파악이 어려울 수 있다고 한다. 이 부분은 실제로 내가 디버깅하면서 어려움을 겪었던 부분이기에 더 공감이 갔다.
그리고 gunicorn 공식문서에 따르면 gunicorn을 단독으로 worker를 관리하는 데 사용하는 경우에 대해서 설명해 주고 있었다. 특히 이 부분이 신기했는데, gunicorn의 구조가 master process가 worker process들을 관리하는 구조라고 한다. 요청들은 worker process에서 전적으로 처리하며, master process는 worker process들을 관리할 뿐 worker process가 어떤 클라이언트에 대해서 어떤 요청을 처리하는지는 전혀 모른다고 한다. 아마 뭔진 모르겠지만 pre-fork worker model과 관련이 있지 않을까 싶다.
그리고 worker들의 타입도 여러 가지라고 한다. 가장 많이 사용되고 우리가 기본값으로 사용하는 것은 'Sync Worker'이다. 이 worker는 한 번에 하나의 요청만 처리하며, http에서 keep-alive 헤더(tcp 헤더였던 것 같은데 헷갈린다)를 통해 keep-alive connection을 유지하려고 해도 요청에 대해 응답을 리턴하는 즉시 해당 요청에 대해서는 커넥션을 끊는다고 한다.
그럼 나는 그냥 기본값인 Sync Worker를 쓰면 되는건가 싶었는데 자기한테 맞는 worker type을 어떻게 고르는지를 알려주는 부분이 또 있었다. 여기에 따르면 long pooling이나 websocket 등을 사용하거나 외부 API로 요청을 보내는 경우, 즉 응답을 받는데 정해진 시간이 아닌 undefined time이 걸릴 수 있는 경우는 async worker를 사용해야 한다고 한다. 현재는 websocket을 사용하지는 않지만 외부 API(openAI)를 사용하고 있어서 async worker를 사용해야 할지 고민이 되었다. 아니면 특정 요청(AI를 사용하는 요청)에 대해서만 async worker를 사용하고 싶은데 그런 건 안되려나?
아무튼 이제 왜인지를 알았으니 피드백을 주신 대로 바꿔보자. 기존에 uvicorn과 gunicorn을 같이 사용하던 명령어를 gunicorn만 사용하도록 바꿔 주면 된다. 그리고 '--log-level debug' 부분도 한번 빼 보자.
그리고 해당 명령어로 로컬에서 잘 실행되었는지도 임시지만 확인해 보았다. 다행히 잘 실행되더라.
워크플로우도 성공적으로 실행되었고, ECS의 태스크도 최신 태스크 정의를 참고하고 있어서 잘 반영된 것 같았다. 그런데 문득, 이렇게 '최신 태스크 정의를 참고하고 있는가'와 '워크플로우가 성공적으로 실행되었는가'를 직접 확인하지 않고도 서버 상태가 잘 반영되었는지를 확인할 수 있는 방법은 없을까 싶었다.
어쨌든 서버가 성공적으로 배포되었으니 다시 locust를 통해 dev 서버에 요청을 날려보았다.
RPS가 48.7이 떴다. (캡처는 못 했지만 50도 넘었었다.)
이전 포스트에서는 RPS의 최대값이 30을 거의 못 넘었는데 50 가까이에 있는 걸 보면 RPS가 약 20정도 증가하고 60% 정도 성능이 향상되었다. 어찌됐건 '엄청난' 성능 향상은 아니지만 '유의미하게' 성능이 향상되긴 했다.
그런데 궁금한 점이 생겼다.
오늘 뵌 멘토님께서는 파이썬으로 WAS를 실행하고, t3.micro와 비슷한 스펙에서 실행된다는 것을 감안하면 RPS가 100이 넘으면 꽤 괜찮은 편이라고 하셨다. 설령 지금 정도의 50 RPS이더라도 현재 우리 서비스를 운영하는 데는 큰 문제는 없을 거라고 하셨다. 왜냐하면 지금 상태로도 1분에 약 3000개가 넘는 요청을 처리할 수 있는 상황이기 때문이다. 그런데 다른 멘토님께서는 예전에 RPS 1000을 넘기는 걸 목표로 해 보라고 하셨었다. 사실 RPS가 고고익선인 건 알고 있는데, 이게 단순히 서버 스펙을 늘려서 RPS를 늘리는 것은 결국 비용을 늘려서 이루어낸 결과이기 때문에(당연히 CPU 코어가 무한 개이면 무한 개의 RPS를 처리할 수 있는 것과 같은 의미) 효율성 있게 튜닝은 한 건 아니라는 생각이 들었다.
암튼 그래서 궁금한 점은, 현재 서버 스펙(확실하지 않지만 t3.micro로 추정)으로는 서버 스펙을 늘리지 않는다고 가정하면 어느 정도의 RPS를 목표로 하면 좋을지가 궁금했다! RPS 1000은 한번 달성해보고 싶긴 한데, 서버 스펙을 늘리지 않고도 가능한 결과일지도 궁금하다. 만약 그렇다면 도전해 보고 싶다.
✅ 궁금한 점
1. gunicorn만으로도 worker들을 관리할 수 있는데 uvicorn과 같은 다른 백엔드를 써서 관리하는 이유가 궁금하다.
2. gunicorn이 pre-fork worker model을 기반으로 한다고 했는데 이 모델이 뭔지도 궁금하다
3. worker type을 고를 때 DDOS 공격에는 async worker가 sync worker보다 덜 취약하다고 해서 이유가 궁금했다.
4. gunicorn에서 특정 URL 요청에서만 async worker를 사용하도록 설정할 수 있는지도 궁금하다.
5. 분명 dev.py(개발환경의 설정파일)에서 debug=True로 설정해 주었는데 '/swagger' URL을 입력하면 API 엔드포인트가 안 보이는 이유가 궁금하다. debug 모드와는 별개의 일인 걸까?
6. '최신 태스크 정의를 참고하고 있는지'와 '워크플로우가 성공적으로 실행되었는지'를 확인하지 않고도 서버가 잘 배포되었는지를 확인하는 방법은 없을까?