오늘 배운 것

어제 작업 중이던 이슈를 오늘은 마쳐 보려고 한다. Route53이나 EC2 로드밸런서 설정에서 문제가 있을 것이라고 생각해서, 여러 블로그들을 보면서 설정을 비교하고 조금씩 바꿔보던 작업을 하고 있었다. 멘토님께서는 AWS에서 HTTPS 인증서를 받을 수 있고, 로드밸런서(ALB)에 그렇게 받은 HTTPS 인증서를 추가하면 될 것이라고 말씀해 주셔서 이 부분을 참고해보면 좋겠다. 찾아보니 비슷한 경우의 블로그도 또 발견했다. 

 

내가 궁금했던 부분은, 왜 구매한 도메인 자체는 HTTP 연결만 가능하고, 그 앞에 서브도메인으로 dev나 test를 붙인 개발 서버나 테스트 서버는 HTTPS로 접근이 가능한지였다. 추측으로는 개발과 테스트 서버는 Route53에서 CNAME 타입으로 레코드를 생성하기 때문에 A 타입으로 레코드를 생성하는 프로덕션 서버와는 IP 주소로 연결되는 방식이 다르다고 생각했다. 

 

블로그에서 시도한 방법을 보았을 때, 단계는 크게 6단계로 나뉘는 것으로 보였고 나는 1-5단계는 다 적용이 되었다고 생각했다. 

1. ACM 인증서 생성하기

2. Route53에서 새 호스팅 영역 생성하기

3. 도메인을 구매한 사이트에서 설정한 DNS 레코드 값을 Route53에 설정해 주기

4. ELB 생성하기

5. ELB 로드밸런서의 DNS 주소를 Route53에서 새로 생성한 레코드에 적용하여 ELB와 Route53 연결하기

6. ELB 리스너에서 HTTP 리스너를 HTTPS로 리다이렉션 시키기

 

6단계의 경우, 4단계에서 만든 로드밸런서의 상세 정보를 보면 리스너가 2개 있다. HTTPS 리스너와 HTTP이 리스너이다. 여기서는 HTTPS를 통해서만 로드밸런서에 접근하고 싶기 때문에, HTTP로 접근했을 경우 이를 리디렉션 시켜 주어야겠다. 

 

HTTP 리스너의 편집 버튼을 누르고, 다음과 같이 'URL로 리디렉션'을 클릭해 주자. 

 

이렇게 하고 검색창에 도메인 이름을 그대로 입력해 주었다. 원래는 HTTP로 연결되어서 경고로 안전하지 않은 연결이라고 떴었다. 이번에는 자동으로 HTTPS로 리디렉션이 된다. 문제는 여전히 HTTPS 연결이 허용되지 않는 것으로 보였다. 즉 URL 앞에 HTTP나 HTTPS를 명시하지 않거나, HTTP로 명시해도 HTTPS로 리디렉션은 잘 되는데, 그냥 Route53 호스팅 영역 자체에서 HTTPS 연결을 허용하지 않는 것 같았다. 이 부분이 빠진 것 같았다. 

 

그런데 정확히 내가 어떤 부분을 놓치고 있는 것인지 헷갈려서 정리가 필요했다. 

 

정리하자면 총 네 단계가 있었다. 

1. ACM에서 SSL/TLS 인증서 발급받기

2. ALB에 인증서 적용하기

3. 보안 그룹에 인바운드, 아웃바운드 트래픽이 허용되는지 확인하기

4. Route53에서 도메인 설정하기

 

참고로 이미 ACM에 인증서도 있고, ALB에 인증서도 적용되어 있고, 보안 그룹을 확인해보니 인바운드와 아웃바운드 트래픽도 허용된 상태였다. Route53에서 A 레코드도 ALB의 DNS 주소로 연결되어 있었다. 그래서 이 중에서 뭔가 예상과 다르게 된 부분이 있을 것이라고 추측했다. 

 

2번 과정을 자세히 보니 ALB에 ACM에서 발급받은 인증서를 추가하는 것은 맞는데, 리스너 규칙을 적용해 줘야 한다는 말이 있었다. 찾아보니 'SNI용 SSL 인증서 추가'라는 버튼이 보였다. SNI는 뭔지 몰랐는데 SSL 인증서 추가라는 말이 있어서 눌러주었다. 그리고 ACM에 기존에 등록되어 있는 인증서를 추가해주었다. 

 

그런데 여전히 되지 않는다..! 

 

궁금한 점

1. 왜 문제 해결 전 상황에서 도메인은 HTTP 연결만 되고, 서브도메인은 HTTPS도 가능했을까?

2. Route53에서 CNAME과 A타입 레코드의 차이는 무엇일까?

3. 도메인을 발급받은 사이트(내 경우에는 porkbun)와 Route53 서비스의 역할은 각각 무엇인가?

4. 로드밸런서의 정확한 정의가 뭘까

5. ACM 서비스는 왜 필요하고 무엇인가

6. 로드밸런서에 리스너를 추가한다는 것이 무슨 의미인가?

 

 오늘의 러닝 인증

 

 오늘 배운 것

오늘 처리할 이슈는 SZ-299(서버 안정화 이슈)와 SZ-264, SZ-272(FCM 알람 이슈)이다. 더 급한 것이 서버 안정화 이슈이므로 이것부터 해 보겠다. 서버 안정화에는 여러 이슈가 많은데 이걸 다 하겠다는 의미는 아니었다. 이 중에서도 HTTP를 사용하는 현재 서버가 HTTPS를 사용하도록 하는 것, 그리고 서버에 '/auth/android' API 변경사항이 반영되지 않는 문제가 제일 커서(아마도 무언가가 롤백되어서 반영되지 않는 것 같다), 이 부분을 수정하면 될 것 같다. 

 

우선 관련 포스트를 좀 참고해 보았다. 여기서는 자기가 도메인을 구매한 사이트에 가서 SSL/TLS 인증서를 받으라고 했다. 나는 porkbun이라는 사이트에서 도메인을 구매했으므로, 해당 사이트로 들어갔다. 중간에 porkbun 사이트에서 보안 강화를 위해 지문 등록이랑 2FA(two factor authentication)를 하라고 해서 그것까지 해 줬다. 

 

그리고 다른 블로그를 더 찾아보니, 구체적으로 AWS Route53 서비스를 사용해서 서버를 HTTP에서 HTTPS를 사용하도록 바꿔주는 방법에 대해서 구체적으로 설명하고 있었다. 

 

그런데 내용대로 해 봤는데 안 되는 부분이 있어서, 내가 중간에 어떤 부분을 생략했는지도 모르겠다. 일단은 더 자료를 찾아봐야겠다. 구글링 하면서 다른 블로그도 찾았는데, 이 부분을 반영해 봐야겠다. 

 

 궁금한 점

1. 2FA가 정확히 뭘까?

2. 해당 과정을 잘 정리해 보자...

 

 오늘의 러닝 인증

 

 오늘 배운 것

저번 포스트에서 프론트의 앱 배포를 빨리 실행하기 위해서 에뮬레이터를 새로 설치하고 hypervisor를 깔고... 등등 이런 방법들을 시도했었다. 사실 hypervisor를 까는 것은 너무 간 이야기였다. 그 윈도우 OS에서 또 에뮬레이터를 깔고 실행을 시켜야 했기 때문에, 만약 에러를 재현하는 데 성공해도 그 윈도우의 에뮬레이터 단에서 에러를 디버깅하고 해결해야 했다. 

 

그래서 멘토님께 조언을 구하던 중, 문득 '그렇다면 최소 요구 API 버전을 올리면 되는 거 아닌가?' 라는 생각이 들었다. 현재 최소 요구 버전은 23인데, 우리 팀은 다 33 이상에서만 테스트를 하고 있었다. 그렇다면 32 버전에서의 에러도 어떻게 보면 모를 수밖에 없었던 것이다. 이렇게 하면 해당 안드로이드 버전에서의 오류는 무시가 가능하다. 다만 걱정되는 부분은 일단 버전을 올려버리면 해결되는 것이 맞는지, 그에 대한 단점은 없을지였다. 

 

그런데 정말 다행히, 왜인지는 모르겠지만 우리를 애먹게 하던 안드로이드 버전 오류가 구글 플레이스토어를 다시 보니 해결되어 있었다. 그래서 해당 고민은 더 이상 하지 않아도 되었다. 

 

그리고 프론트 앱 배포에 필요한 테스터 20명도 모집했다. 이제 14일간 비공개 테스트 결과를 기다리면 된다. 


그러면 이제 원래 하던 이슈들을 보면 되는데, 생각해보니 현재 프론트 서버에서 배포 버전에서는 프로덕션 서버에 요청을 보내고 있었는데, 막상 프로덕션 서버에 몇 가지의 문제가 있었다. 

 

첫 번째는 main 브랜치로 코드를 반영할 때 워크플로우가 실패한다는 점, 두 번째는 안드로이드 클라이언트 ID를 요청하는 API가 프로덕션 서버에 반영되지 않았다는 점, 세 번째는 프로덕션 서버에서 HTTPS 대신 HTTP를 사용한다는 점이 문제이다. 마지막으로 네 번째는 이렇게 프로덕션 서버에 어떤 API가 있는지를 /swagger URL을 통해 알 수 있었는데, 그 URL이 프로덕션 서버에는 나오지 않는 문제였다. 

 

우선 첫 번째 문제의 경우 이전에 develop 브랜치에서 ECS에서 사용하는 태스크 정의 파일을 JSON 파일 템플릿에서 동적으로 환경변수를 주입하도록 바꿔 주었는데, 그 부분이 문제인 것 같았다. 정확히는 해당 템플릿에서 ECS 개발 클러스터에 대한 고유한 정보가 포함되어 있어서, 이를 main 브랜치를 통해 프로덕션 서버에서 ECS 태스크를 생성하려고 했을 때 '해당 이름의 컨테이너는 없다'는 에러가 난 것이었다. 

 

그래서 기존에 사용하던 ecs-task-def.json 파일 말고 프로덕션 서버 배포 시 사용하는 템플릿으로 ecs-task-prod-def.json 파일을 하나 더 만들어주었더니 해당 문제는 해결되었다. 

 

이제 두 번째 이슈를 해결해야 했다. 왜 main 브랜치에도 안드로이드 클라이언트 ID값을 반환하는 API가 잘 정의되어 있는데 막상 프로덕션 서버에는 없다고 나오는 것일까? 아마도 깃허브 워크플로우 자체는 정상적으로 실행되었지만 이후에 ECS에 의해 롤백되었을 가능성이 있겠다. AWS ECS의 프로덕션 클러스터의 서비스>이벤트에서 롤백과 관련된 로그를 찾을 수 있었다. 

 

그렇다면 왜 롤백되었을까? 해당 서비스의 태스크로 들어가서 로그를 보자. 정확히는 CloudWatch에서 해당 시각에 실행된 태스크의 로그를 보니 답을 찾을 수 있었다. 단순 typo 때문에 생긴 오류였다. 고치고 다시 깃헙 워크플로우를 실행시켰다. 

 

그리고 서버가 HTTP를 사용하여 통신하는 문제를 고치기 위해서 방법을 찾아봤다. AWS의 ACM이나 CloudFront 같은 새로운 서비스를 사용하라고 나와서 제법 복잡하다고 느껴졌다. ACM은 AWS Certificate Manager의 약자인 듯 해서 여러가지 인증서를 관리하는 서비스라고 이해했고, CloudFront는 '글로벌 콘텐츠 전송 네트워크'라고 나와있어서 사실 무슨 서비스인지 와닿지는 않았다. 

 

 오늘 배운 것

1. 특정 안드로이드 API 버전에서 나는 에러는 어떻게 해결할까?

2. 최소 안드로이드 API 버전을 올리는 것이 '옳은' 선택일까? 다른 장단점은 없을까? 

3. 안드로이드 API에서 타깃 버전과 최소 요구 버전의 차이는 무엇일까?

4. ACM과 CloudFront는 정확히 무슨 역할을 하는 서비스일까? 

 

 오늘의 러닝 인증

컨디션 난조(편두통)로 오늘 인증은 패스하겠습니다ㅜㅜ

 

 오늘 배운 것

오늘은 어제 회고에서 느낀 것처럼 현재 배포에서 막히고 있는 프론트 쪽 이슈를 같이 해결해보려고 한다. 현재 프론트에서 앱 배포를 하다가 막힌 상황이고, 잠시 팀원이 자리를 비운 사이에 조금이라도 일을 진행시키는 것이 오늘과 내일의 목표가 되겠다. 팀원에게 듣기로는 현재 expo를 사용해서, eas build를 production 모드로 진행한 다음 aab(abb?) 파일을 구글 플레이스토어에 업로드하면 된다고 한다.

 

다행히 여러 블로그 글에 관련된 설명이 나와 있었다. 우선 첫 번째로 Keystore를 생성해야 했다. Keystore란 RN에서 안드로이드 용 RN 실행 바이너리 파일을 만드는 데 사용되는 일종의 파일과 관련이 있었다. 생성하는 명령어가 있어서 우선 따라해 보려고 했다.

 

그런데 그 문제는 아닌 듯 하다. 

 

팀원이 말해주기를 이런 과정들은 전부 잘 되고, '스플래시 화면이 두 개 뜨는 문제'와 '특정 안드로이드 버전에서 크래시가 나는 문제'를 해결해야 빌드를 완전히 할 수 있다고 한다. 해당 부분은 구글 플레이 콘솔에서 확인할 수 있었다. 

 

특히 '장애'라고 표시된 에러의 경우 로그는 다음과 같았다. 

Process: com.safezone.onestep
PID: 9158
UID: 10163
Flags: 0x20a8be44
Package: com.safezone.onestep v1 (1.0.1)
Foreground: No
Process-Runtime: 49340
Build: Google/Small Desktop (x86)/SmallDesktop.x86:12/SE2B.220326.027/9241628:userdebug/dev-keys
Loading-Progress: 1.0

java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask@aa88f1f rejected from java.util.concurrent.ScheduledThreadPoolExecutor@65a926c[Terminated, pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 5]
	at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2086)
	at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:848)
	at java.util.concurrent.ScheduledThreadPoolExecutor.delayedExecute(ScheduledThreadPoolExecutor.java:334)
	at java.util.concurrent.ScheduledThreadPoolExecutor.schedule(ScheduledThreadPoolExecutor.java:562)
	at java.util.concurrent.ScheduledThreadPoolExecutor.submit(ScheduledThreadPoolExecutor.java:664)
	at java.util.concurrent.Executors$DelegatedExecutorService.submit(Executors.java:640)
	at io.sentry.android.replay.ScreenshotRecorder.capture$lambda$4$lambda$3(ScreenshotRecorder.kt:125)
	at io.sentry.android.replay.ScreenshotRecorder.$r8$lambda$SsuasLI-_UYp4uMsgoefgMADVBI(Unknown Source:0)
	at io.sentry.android.replay.ScreenshotRecorder$$ExternalSyntheticLambda2.onPixelCopyFinished(Unknown Source:6)
	at android.view.PixelCopy$1.run(PixelCopy.java:191)
	at android.os.Handler.handleCallback(Handler.java:938)
	at android.os.Handler.dispatchMessage(Handler.java:99)
	at androidx.test.espresso.base.Interrogator.loopAndInterrogate(Interrogator.java:10)
	at androidx.test.espresso.base.UiControllerImpl.loopUntil(UiControllerImpl.java:7)
	at androidx.test.espresso.base.UiControllerImpl.loopUntil(UiControllerImpl.java:1)
	at androidx.test.espresso.base.UiControllerImpl.loopMainThreadForAtLeast(UiControllerImpl.java:7)
	at androidx.test.espresso.action.Tap$1.sendTap(Tap.java:4)
	at androidx.test.espresso.action.GeneralClickAction.perform(GeneralClickAction.java:4)
	at androidx.test.espresso.ViewInteraction$SingleExecutionViewAction.perform(ViewInteraction.java:2)
	at androidx.test.espresso.ViewInteraction.doPerform(ViewInteraction.java:23)
	at androidx.test.espresso.ViewInteraction.-$$Nest$mdoPerform(Unknown Source:0)
	at androidx.test.espresso.ViewInteraction$1.call(ViewInteraction.java:6)
	at androidx.test.espresso.ViewInteraction$1.call(ViewInteraction.java:1)
	at java.util.concurrent.FutureTask.run(FutureTask.java:266)
	at android.os.Handler.handleCallback(Handler.java:938)
	at android.os.Handler.dispatchMessage(Handler.java:99)
	at android.os.Looper.loopOnce(Looper.java:201)
	at android.os.Looper.loop(Looper.java:288)
	at android.app.ActivityThread.main(ActivityThread.java:7870)
	at java.lang.reflect.Method.invoke(Native Method)
	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1003)

 


우선 우리가 Expo EAS build의 최신 버전에서 앱을 다운받았을 때는 셋 다 아무런 문제가 없었어서, 특정 버전의 문제일 수도 있다고 생각했다. 그렇다면 어떻게 해결해야 할까? 여기서 사용한 안드로이드 에뮬레이터는 SDK 32 버전, 안드로이드 12 버전이었다(Android 12L, SDK 32). 에러를 재현해서 문제를 알아보려면 해당 안드로이드 에뮬레이터로 한번 실행을 시켜봐야 하겠다. 화면 크기나 다른 건 상관없어도, 안드로이드 버전이 12L, SDK 32이면서 ABI가 x86_64인 에뮬레이터를 만들어 보자.

 

그런데 생각해보면 우리가 다 안드로이드 에뮬레이터 설정을 했을 때 API 33 이상으로 했었고, 그 때 나름의 이유가 있었던 것으로 기억한다. 그 모종의 이유 때문에 에러가 난 것은 아니었을까 싶다. 왜냐하면 에러 로그를 봤을 때 java나 android 관련 에러로 보였기 때문이다. 그렇다면 그걸 우리가 해결할 의무가 있을까? 라는 의문이 들었다. 그러다가 출시 개요의 App Bundle을 봤을 때, API 수준이 23 이상이면 사용 가능한 것으로 되어 있었다. 그렇다면 동영상에서 API 32로 테스트하는 것이 말이 된다. 내가 API 수준과 타겟팅 API에 대해서 혼동했던 것 같다. 

 

아무튼 그래서 안드로이드 에뮬레이터를 받아서 실행을 하려는데 실행 자체가 안 된다. 

 

우선 SDK Manager에서 SDK 32를 설치해 줬는데도 같은 에러가 난다. 

 

오류가 발생하는 원인은 여러 가지일 수 있겠다. 우선 x86_64는 Intel 기반 버전이다. mac OS는 기본 apple silicon이기 때문에 intel 기반의 프로그램을 실행하려면 'Rosetta 2'라는 일종의 번역 레이어가 필요하다고 한다. 그래서 이를 설치해서 x86_64 버전의 에뮬레이터를 실행해 보기로 했다. 우선 Rosetta 2는 설치하였으나, 또 같은 에러가 났다. 알고보니 apple silicon에서는 arm 버전의 에뮬레이터를 사용하는 것이 좋다고 한다. 

 

아무튼, 그래서 SDK 32, Android 12L 버전의 arm 에뮬레이터를 다시 설치해봤다. arm 에뮬레이터를 설치해봤더니 앱 설치 및 로그인 화면까지 잘 떴다. 그런데 이러면 안 되는게, 구글 플레이 콘솔의 동영상에서는 아예 앱이 실행조차 안 되었다. 실행이 된다는 것 자체가 문제 상황과 다르다는 거였다. 

 

그런데 여기서 제시하는 방법은 API 버전을 바꾸라거나, x86_64 대신 arm을 사용하라던가 하는, 지금 현재 우리의 상황에는 도움이 되지 않는 방법들이었다. 우선은 멘토님께 도움을 요청해보고, 추가적인 방법을 찾아봐야겠다. 


우선 우리의 목표는 에러 재현을 하고 그 다음에 에러를 해결하는 것이다. 지금 에러 재현이 안 되고 있으니, intel을 사용하는 환경을 만들어 줘야 하겠다. 가장 먼저 생각난 방법은 hypervisor였다. hypervisor란 일종의 소프트웨어로, '단일 물리적 머신에서 여러 개의 가상 머신을 사용할 수 있도록 해 주는 소프트웨어'라고 한다. 이걸 사용해서 mac에서 window 가상 머신을 실행시켜서, 해당 가상 머신에서 또 에뮬레이터를 띄워 보는 것이다... 쓰다 보니 이게 맞나 싶은데, 윈도우 노트북이 없으니 별 수 없다. 

 

맥 환경에서 사용가능한 오픈소스 소프트웨어인 virtualbox를 사용해보기로 했다. 그런데 공식 홈페이지에서는 arm 버전을 제공하는 것이 없었는데, 구글링해서 찾은 한 블로그로부터 테스트 버전으로 arm 버전의 virtualbox가 있다는 것을 알 수 있었다. 

 

무사히 virtualbox를 설치해 주고, 가상환경을 위해 필요한 윈도우 ISO도 설치해 주었다. 

 

 궁금한 점

1. keystore가 뭘까?

2. intel 기반과 apple silicon 기반이 다르다는 건 알고 있었는데 뭐가 어떻게 다른건지 스스로 잘 모르는 것 같다. 알아보자. 

3. ISO는 무엇인가

 

오늘의 러닝 인증!

 

 오늘 배운 것

어제 알람 기능 로직을 작성하다 엔드투엔드(e2e) 테스트의 필요성을 많이 느꼈고, 그래서 시간이 좀 걸리더라도 e2e 테스트를 도입하는 것이 맞다는 판단이 들었다. 왜냐하면 앞으로 알람 기능 뿐만 아니라 다른 기능(API 테스트는 물론이고, 롱 폴링이나 웹소켓 등을 도입하게 되는 경우)을 사용할 때도 e2e는 유용하게 사용될 것이었기 때문이다.

 

그렇다면 e2e를 도입해 보고, 해당 이슈를 우선 처리한 다음, 도입한 e2e로 알람 기능을 테스트 및 개선해 보자. 

 

RN에서는 JestDetox, Django에서는 기존의 pytest를 그대로 사용한 다음, Detox를 통해 이 둘을 연결할 수 있을 것으로 보였다. 

npm install detox --save-dev
npm install jest --save-dev
detox init

 

detox에서는 jest와 같이 사용하는 것을 지원한다고 했다. 그래서 'detox init'을 통해 생성된 'e2e/jest.config.js'라는 파일에서 관련 설정을 관리하는 것으로 보였다. 해당 프로젝트 가이드를 보고 따라한 뒤, build 명령어를 실행시켜 주었다. 

detox build --configuration android.emu.debug

 

그런데 프로젝트 가이드를 보면서 작성한 DetoxTest.java 파일에서 에러가 났다. 'detox build' 명령어로 테스트를 테스트하려면 더미 테스트 파일이 있어야 한다고 해서 만든 파일인데, 여기서 존재하지 않는 패키지를 import 하려고 시도해서 에러가 난 것 같았다. 일단 가이드를 그대로 따라 해봤는데 'com.wix.detox' 파일을 찾을 수 없다고 하니 두 가지 가능성이 있었다. 우선 Detox나 관련 설정이 제대로 되어있지 않거나, 아니면 내가 읽은 가이드는 React Native, 기본으로는 React Native CLI를 전제로 한 가이드인데 Expo를 사용할 경우 설정이 달라졌을 수도 있다. 

우선은 오류가 났을 때 첫 번째 가능성을 먼저 보기로 했다. 왜냐하면 Detox에 나와있는 'expo를 사용하는 경우'에 대한 가이드를 봤는데 detox를 사용하는 게 아니라 maestro라는 또 다른 툴을 사용하는 것으로 보였다. 나는 일단 detox를 1시간 이상 보기도 했고, 일단은 이 툴을 사용해보고 싶었기 때문에 환경설정이 제대로 안 되어있을 가능성을 보기로 했다. 

 

우선 android>app>build.gradle 파일에서 다음과 같이 설정해 주자. 

androidTestImplementation 'com.wix:detox:+'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test:runner:1.3.0'
androidTestImplementation 'androidx.test:rules:1.3.0'

 

그리고 android>build.gradle 파일에서는 다음과 같이 설정해 주자. 

buildscript {
    dependencies {
        classpath 'com.wix:detox-gradle-plugin:1.22.0' // 최신 버전으로 설정
    }
}

 

이렇게 설정해 주어도 계속 오류가 났다. 위의 buildscript>dependencies>classpath에서 설정한 값과 맞는 파일이나 자료를 찾을 수 없어서 dependencies를 해결할 수 없다는 오류가 나는 것으로 이해했다. 

 

그렇다면 여기서 에러를 계속 디버깅하는 것이 맞을까, 아니면 expo 환경에서 사용할 수 있는 다른 라이브러리나 다른 방법을 찾아보는 게 맞을까? 나는 후자가 맞다고 생각했다.

공식문서를 잘 보자!

해당 가이드를 보니 Expo를 사용하는 경우 Maestro라는 라이브러리, 툴을 사용할 것을 권장하고 있었다. 사실 좀 걸리는 부분이 있는 게, 멘토님께서도 eas build 때문에만 expo를 사용하는 거라면 우리 프로젝트에 꼭 expo가 필요한 것은 아니며, 오히려 이 dependency를 없애는 게 나중에 다른 라이브러리나 툴을 더 쉽게 도입할 수 있고 디버깅도 쉬울 거라고 말해주셨기 때문이다. 지금 그 말을 여실히 체감하고 있다. 

 

maestro를 사용하면 장단점이 있다. 우선 yaml 파일을 통해 테스트 코드를 작성해서 간단하고, expo와 호환된다는 장점이 있다. 그러나 구체적인 테스트 시나리오를 작성할 수 없으며 기능이 제한적이라는 점이 걸렸다. 

 

알고보니 RN에서 expo를 사용해서 detox로 e2e 테스트를 하는 방법이 문서로 정리되어 있지만 deprecated 된 상태였다. 그래서 이것을 사용해야 할지 말지가 심히 고민된다.

 

그런데 생각해보니 테스트 라이브러리를 꼭 지금 정하면 바꿔야 하는 것도 아니었다... 일단은 maestro를 사용하고, 만약 기능이 제한적이라고 느껴지거나 나중에 expo dependency를 제거하게 되면 그때 detox로 바꿔도 될 것 같다. 일단 maestro를 사용해 보자. 

 

그러려면 우선 기존에 detox를 도입하기 위해 코드를 바꿔놓은 부분을 원복 해야했다. 이게 git restore . 를 하면 바로 되는 게 아닌게 /android 폴더는 .gitignore에 있는 상태였기 때문에 일일이 코드를 바꿔줘야 했다...! 다행히 관련 문서가 있었다. 알고보니 명령어 하나로 해결되는 문제였다. 

detox clean-framework-cache

 

npm dependency도 제거해 주었다.

npm uninstall detox-cli --global
npm uninstall detox

이제 maestro를 사용해 보자. 가이드에 따르면 프로젝트 루트 디렉토리에 'maestro' 폴더를 만들면 해당 폴더 아래의 .yaml 파일들을 참조하는 것 같았다. 

 

그런데 maestro를 사용하면 제약이 많았다. 우선 npm으로 설치가 안 되어서 전용 CLI를 사용해야 했고, 'maestro init'을 실행했는데 되지가 않았다. 벌써부터 많은 것들이 되어있지 않다고 느껴져서, 이걸 계속 사용해야 할지 모르겠었다. 그래서 멘토님께 도움을 요청해 보았다. 멘토님께서는 react native testing library를 추천해 주셨다. 이거는 엔드투엔드(e2e) 테스트는 아니지만 통합 테스트 툴이다. 

 

그러다가 멘토님과 현재 개발 진행상황에 있어서 궁금한 점과 조언이 필요한 점에 대해서 멘토링을 했는데, 또 다른 인사이트를 얻을 수 있었다. 해당 부분은 별도의 포스트로 올려보겠다. 결론은 지금 하고 있는 알람 기능이 중요한 게 아니라, 다른 팀원이 하고 있었던 앱 배포에 모든 팀원이 뛰어들어서 한시라도 빨리 배포를 마쳐야 한다는 것이었다. 

 

 궁금한 점

1. graybox testing의 정확한 개념이 무엇인지 궁금하다. 

2. build.gradle 파일이 groovy라는 언어로 되어있는 줄은 알았는데 새삼 이 친구에 대해서 하나도 궁금해하지 않았구나 싶었다. groovy는 주로 어떤 개발에 사용하는 녀석일까?

3. android>build.gradle 파일과 android>app>build.gradle 파일의 설정은 뭐가 다를까? 왜 별도의 build.gradle 파일이 필요할까?

 

 오늘 배운 것

어제에 이어 알림 기능을 개발해 보려고 한다. 그러려면 서버에서는, 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라는 라이브러리가 있단다. 일단 설치를 해 보자. 

pip install fcm_django

 

settings.py의 INSTALLED_APPS에도 다음과 같이 추가해 주었다.

# settings.py

INSTALLED_APPS = [
	# ...
    '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로 적용했다. 

export const useTodosQueryByNotification = (accessToken, userId, onSuccess) => {
  return useQuery({
    queryKey: [TODO_QUERY_KEY],
    queryFn: () => fetcher(accessToken, userId),
    onSuccess: onSuccess,
    keepPreviousData: true,
    staleTime: 0,
    enabled: false,
  });
};

 

다음과 같이 해당 커스텀 훅을 컴포넌트 안에 선언하고, 필요한 정보들을 파라미터로 리턴하도록 설정해 주었다. data의 경우는 fetch를 했을 때 가져오는 데이터, isSuccess는 fetch가 성공적으로 진행되었는지의 여부, 그리고 refetch의 경우 수동으로 데이터를 fetch하고 싶을 때 실행시켜주는 함수였다. 

 

현재 해당 훅은 enabled 값이 false이기 때문에 자동으로 데이터를 fetch하지 않는다. 이 훅을 통해 데이터를 가져오려면 refetch 인자로 받은 notificationRefetch() 함수를 사용해야 한다. 

const {
    data: notificationData,
    isSuccess: isNotificationSuccess,
    refetch: notificationRefetch,
  } = useTodosQueryByNotification(accessToken, userId);

 

메시지를 수신했는지는 계속 확인되어야 하므로 useEffect 훅 안에 해당 코드를 선언하고, 메시지를 받았을 경우, 즉 onMessage 이벤트 핸들러가 트리거되었을 경우에 해당 notificationRefetch() 함수를 실행시켰다. 그리고 이를 통해 데이터가 성공적으로 fetch되면 isNotificationSuccess의 값이 true로 바뀔 것이기 때문에 이 조건에 해당되는 경우 받아온 notificationData의 값을 zustand를 통해 만든 TodoStore를 통해 todos의 값으로 바꿔주었다. 

useEffect(() => {
    const unsubscribe = messaging().onMessage(async remoteMessage => {
      notificationRefetch();
      if (isNotificationSuccess) {
        useTodoStore.setState({ todos: notificationData });
        let filteredTodos = notificationData.filter(
          todo =>
            todo.categoryId === selectedCategory &&
            isTodoIncludedInTodayView(
              todo.startDate,
              todo.endDate,
              selectedDate.format('YYYY-MM-DD'),
            ),
        );
        useTodoStore.setState({ currentTodos: filteredTodos });
      }
    });
    return unsubscribe;
  }, [
    notificationRefetch,
    notificationData,
    isNotificationSuccess,
    selectedDate,
    selectedCategory,
  ]);

 

이렇게 Inbox, Category 데이터에 대해서도 같은 작업을 반복해 주었다. 


이제는 서버 쪽을 개발할 차례이다. 우선 django에서 firebase를 사용해야 하기에 관련 라이브러리를 설치해 주었다. 

$ pip install firebase_admin

 

Firebase 문서에 해당 라이브러리 관련 내용이 나와있어서 참고하면서 작업했다. 

 

그런데 생각해 보면, 클라이언트에서는 서버에서 알림이 오면 그냥 받으면 된다지만 서버는 어떤 클라이언트에게 알림을 보낼지 명시해줘야 했다. 이때 사용하는 것이 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 계정 및 관리 정보가 담긴 파일로, 이를 레포지토리에 노출시킬 수는 없었다. 그렇다고 로컬에서 혼자 관리하면 다른 팀원과 같이 개발하는 데 문제가 생긴다. 

from firebase_admin import credentials, messaging

# Firebase Admin SDK 초기화
cred = credentials.Certificate('path/to/your/firebase-adminsdk.json')

 

소스 코드를 보니 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만 보낼 것이기 때문에 다음과 같이 작성했다. 

def send_push_notification(token, title, body):
    message = messaging.Message(
        notification=messaging.Notification(
            title=title,
            body=body,
        ),
        token=token,
    )

    try:
        messaging.send(message)
    except Exception as e:
        # sentry capture exception
        return {"status": "error"}
    return {"status": "success"}

 

이제 대략적인 코드를 짰으니 코드가 동작하는지를 확인해 보려고 한다. 그런데 테스트를 하면 좋을 것 같았다. 문제는 서버에서 보낸 알림을 클라이언트에서 제대로 받는지를 확인해야 하는데, 이런 양방향 통신이 일어나는 테스트는 해 본 적이 없어서 방법이 생각나질 않았다. 

 

결론은 서버와 클라이언트에서 각각 테스트를 작성하고 확인하면 되었다. 먼저 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'을 생성한 다음, 실제 로직을 수행하여 둘의 결과값을 비교하는 코드이다. 해당 명령어로 테스트를 실행시켜 보았다. 

python -m unittest todos.tests.test_firebase_alarm

 

의도와 달리 '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> 태그와 같은 레벨에 해당 코드를 추가해 주었다. 

<service
    android:name=".java.OneStepFirebaseMessagingService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

 

또한 우리는 안드로이드 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 언어의 차이가 궁금하다. 단순 문법적 차이 말고, 둘은 어떤 차이가 있을까?

 

+ Recent posts