기술

푸시 알림이 미구현인 줄 알았는데 코드는 다 있었다

2026.06.236분 읽기

B2B 멤버십 서비스 중 모바일 앱 두 개를 맡고 있습니다. 하나는 일반 사용자가 쓰는 회원앱, 하나는 현장 운영자가 쓰는 매니저앱입니다. 어느 날 매니저앱에 푸시 알림 기능을 새로 넣어달라는 요청을 받았습니다. 앱을 켜고 알림이 오는지 확인했더니 정말 아무것도 오지 않았습니다. 기능이 없다고 판단하고 새로 만들 준비를 시작했습니다.

그런데 코드를 열어보니 상황이 예상과 달랐습니다. 백엔드에는 알림 테이블 DDL, 엔티티, 리포지토리, 서비스, 컨트롤러가 모두 있었습니다. Flutter 쪽에도 알림 수신 처리와 토큰 등록 로직이 들어가 있었습니다. 미구현이라고 생각한 기능이 코드 레벨에서는 거의 완성돼 있던 것입니다.

여기서 작업의 방향이 바뀌었습니다. 새로 만드는 일이 아니라, 다 있는데 왜 동작하지 않는지를 찾는 일이 됐습니다.


코드가 멀쩡한데 푸시가 안 오는 이유를 찾았다

푸시가 동작하려면 코드 외에 외부 설정이 맞아야 합니다. Firebase 프로젝트와 앱이 올바르게 연결돼 있어야 토큰이 발급되고 메시지가 전달됩니다. 그래서 코드 디버깅 대신 연결 설정부터 봤습니다.

원인은 firebase_options였습니다. 앱이 어떤 Firebase 프로젝트에 붙을지 정의하는 설정 파일인데, 여기에 옛 템플릿 흔적인 다른 프로젝트가 박혀 있었습니다. 즉 코드는 정상이지만 엉뚱한 프로젝트를 향하고 있어서 토큰 발급과 메시지 전달이 처음부터 성립하지 않았습니다. 새로 만들었어도 같은 설정 위에서는 똑같이 안 됐을 상황입니다.

1.png

해결은 코드를 건드리지 않고 설정과 시크릿만으로 진행했습니다. 순서는 아래와 같았습니다.

새 Firebase 프로젝트를 생성한다.

회원앱과 매니저앱을 각각 Android와 iOS로 등록한다.

앱별 설정 파일 네 개를 교체한다.

flutterfire CLI로 연결을 다시 구성한다.

서버에서 쓰는 서비스 계정 키는 .gitignore로 추적에서 제외한다.

테스트용 컨트롤러로 실기기에 직접 발송해본다.

이 과정을 거치자 Android 실기기에서 푸시가 정상적으로 들어왔습니다. 애플리케이션 코드는 한 줄도 고치지 않았습니다. 미구현으로 보이던 기능의 실체는 잘못된 외부 연결 한 군데였습니다.

코드를 다시 만들기 전에, 코드가 바라보는 외부 설정부터 의심한다.


설정을 고치는 김에 갈라진 도메인 패턴이 보였다

토큰 저장 구조를 손보면서 두 앱이 같은 일을 다른 방식으로 하고 있다는 걸 알게 됐습니다. 회원앱은 FCM 토큰을 회원 엔티티의 컬럼에 직접 저장했고, 매니저앱은 별도의 디바이스 테이블에 저장했습니다. 같은 B2B 식사 SaaS 도메인인데 토큰 관리 패턴이 두 갈래로 갈라져 있던 것입니다.

더 눈에 걸린 건 회원앱 쪽에도 별도 디바이스 테이블과 엔티티가 이미 만들어져 있었다는 점입니다. 다만 어느 코드에서도 쓰이지 않는 dead 상태였습니다. 통일하려다 만 흔적입니다. 이미 만들어졌는데 안 쓰이는 테이블은 도메인 패턴이 갈라져 있다는 신호로 읽을 수 있습니다.

지금이 정합을 맞출 기회였습니다. 한쪽이 새로 손질되는 시점은 두 패턴을 합치기에 가장 비용이 낮은 순간이기 때문입니다. 회원앱을 매니저앱과 같은 별도 디바이스 테이블 방식으로 일원화했습니다.

디바이스 등록 로직은 새 서비스로 정리했고, 토큰을 받을 때 네 단계를 거치게 했습니다.

다른 회원이 같은 기기로 로그인한 기록이 있으면 그 등록을 비활성화한다.

본인이 쓰던 다른 기기는 강제 로그아웃 처리한다.

같은 토큰이 중복으로 남아 있으면 비활성화한다.

최종적으로 현재 기기·토큰을 upsert한다.

이 서비스를 부르는 호출처 여섯 곳을 정리하고, 옛 컬럼에 남아 있던 토큰 데이터는 device_uuid를 'legacy-{회원id}' 형태로 표기해 새 테이블로 옮긴 뒤 컬럼을 DROP했습니다. legacy- 접두사를 붙인 이유는, 마이그레이션으로 들어온 데이터인지 정상 등록된 데이터인지 나중에 한눈에 구분하기 위해서입니다. 출처를 표시해두면 문제가 생겼을 때 추적 범위를 좁힐 수 있습니다.

2.png

한 가지 결정은 남겨뒀습니다. 발송 서비스는 한 회원이 여러 기기를 갖는 멀티 디바이스 구조로 두되, 실제 정책은 1기기1토큰으로 유지했습니다. 인프라는 멀티를 감당할 수 있게 만들어두고, 한 기기만 허용하는 건 정책 레이어에서 강제하는 방식입니다. 이렇게 분리해두면 나중에 멀티 디바이스를 허용하기로 비즈니스가 결정해도 인프라를 다시 짤 필요가 없어 전환 비용이 줄어듭니다.


다시 만들기 전에 점검할 것들

이번 작업에서 다음에도 쓸 만한 판단 기준을 정리하면 이렇습니다.

기능이 미구현이라는 보고를 받으면, 진짜 미구현인지부터 검증한다. 코드는 다 있고 설정만 틀린 경우가 흔하다.

외부 SaaS 연동이 안 될 때는 코드 디버깅보다 프로젝트 ID·키가 옛 템플릿 흔적인지를 먼저 본다.

이미 만들어졌는데 안 쓰이는 테이블이 보이면 도메인 패턴이 갈라져 있다는 신호로 읽는다.

도메인 정합은 작업량이 크므로, 한쪽을 새로 손대는 시점에 맞춰 함께 처리한다.

멀티 디바이스 인프라와 1기기1토큰 정책은 분리해서 둔다.

서비스 계정 키 같은 시크릿은 반드시 .gitignore로 제외한다.

마이그레이션 데이터는 legacy- 같은 식별 가능한 표기를 붙이고, 컬럼 DROP 전에 검증 쿼리를 먼저 돌린다.

다만 설정만 틀렸다는 게 항상 정답은 아닙니다. 진짜 미구현인 경우도 많습니다. 핵심은 어느 쪽인지 단정하기 전에 검증 순서를 지키는 것입니다. 도메인 정합도 마찬가지로, 신규 작업 시점이 아니면 손이 많이 가서 굳이 먼저 벌일 일은 아닙니다. 1기기1토큰을 유지할지도 결국 비즈니스가 정할 문제입니다.

결국 이번 일은 코드를 한 줄도 새로 짜지 않고 끝났습니다. 안 되던 기능이 잘못된 연결 하나 때문이었고, 그 연결을 고치는 과정에서 갈라져 있던 도메인 패턴까지 정리됐습니다. 안 되는 기능 앞에서 코드부터 다시 짜려는 손을 잠깐 멈추고, 그 코드가 바라보는 바깥을 먼저 들여다볼 이유는 충분합니다.

#운영의기술#Firebase#FCM#푸시알림#Flutter#설정관리#마이그레이션#도메인설계