Everytalk
에브리톡
Sendbird 과금 부담을 줄이기 위한 Firebase 채팅 전환.


출발점과 담당 범위
업무용 메신저 · 채팅 기반 전환
맡은 범위
Sendbird 채팅을 Firebase 기반으로 옮기기 위해 회원·관리자 React Native 앱, 웹 관리 화면, NestJS API와 Cloud Functions를 함께 수정했습니다. 대화 데이터 이관부터 알림·권한·읽음 상태, 인프라 전환, 스토어 출시와 장애 대응까지 맡았습니다.
시작할 때의 상태
Sendbird 채팅의 과금 부담이 커, Firebase 기반으로 직접 옮기는 것이 프로젝트의 출발점이었습니다.
개발 범위 · 기술 · 협업 자세히 읽기
시작할 때의 상태
Sendbird 채팅의 과금 부담이 커, Firebase 기반으로 직접 옮기는 것이 프로젝트의 출발점이었습니다. 이미 사용자와 대화 데이터가 있는 서비스여서 기존 앱과 업무를 이어가며 바꿔야 했습니다. 채팅은 Firestore·FCM으로 전환하되, 통화는 Sendbird Calls에 남겼습니다.
작업 개요
Sendbird 채팅의 과금 부담을 줄이기 위해 Firebase·Firestore 기반으로 직접 옮기는 작업을 맡았습니다. 외주 개발된 회원앱·관리자앱·웹관리·서버를 인수해 채팅 백엔드를 전환하고 기존 대화 데이터를 이관했습니다. 그 과정에서 푸시·권한·읽음 상태와 배포 구조도 함께 정리했으며, 현재는 운영 안정화를 이어가고 있습니다.
개발·개선 범위
- 회원·관리자앱의 채팅·읽음·알림·계정 전환과 비동기 상태 처리
- 웹관리·공지·참가자 권한과 API·PostgreSQL·Firestore의 데이터 연결
- Sendbird Chat에서 Firestore로 채팅·대화 데이터 이전, FCM·기기 토큰·푸시 미러 개선
- 배포 슬롯 스왑, RDS·Valkey 분리, ALB·ASG 다중 노드 전환
- 앱 빌드·스토어 출시·롤백, 재현 테스트와 운영 기록 정리
- 사용한 기술
- React Native · TypeScript · NestJS · PostgreSQL · TypeORM · Firebase · FCM · Redis · Valkey · BullMQ · Docker · nginx · AWS ALB · ASG · RDS
기존 자산 · 외부 서비스 · AI 활용
기존 앱·업무 모델·데이터를 인수했고, Sendbird 통화와 Firebase·FCM 등 외부 서비스를 활용했습니다. Claude Code·Codex로 코드 조사와 구현·반례 검증을 진행하며 우선순위, 운영 전환과 사용자 확인을 맡았습니다. 앱 전체를 처음부터 만든 프로젝트로 소개하지 않습니다.
제품 화면과 구조 요약
제품 화면과 구조 요약 보기
푸시보다 먼저,
등록 요청의 경로.
- HTTP 요청앱이 보낸 토큰과 기기 정보
- 전역 interceptor
snake_case→camelCase - 입력 정리 · DTO선택 정보를 정리한 뒤 필수값 검증
- 등록 서비스인증 정보의 사용자 ID와 함께 저장
컨트롤러 앞에서 일어나는 변환까지 HTTP 테스트에 연결했습니다.
문제 해결 / 푸시 등록 경로
알림이 안 온다는 신고를, 실제 HTTP 요청 순서로 다시 풀었습니다.
푸시 전송보다 앞에서 막힌 토큰 등록을 찾아, 설치된 앱을 그대로 두고 복구했습니다.
푸시를 보내기 전에, 등록 요청이 막혔습니다
- 앱 요청배포된 설치본토큰 + 선택 기기 정보
- 전역 변환snake_case → camelCase검증보다 먼저 실행
- 라우트 입력 정리검증 앞에 둔 보정기기 정보만 정리
- DTO 검증이전 실패 지점기존 필수값 검증 유지
- 토큰 저장인증된 사용자 소유이후 미러 동기화
전역 변환 → 입력 정리 → DTO를 실제 순서로 연결
별도 확인토큰 등록 성공 ≠ 실기기 수신 성공
문제
전역 필드명 변환 뒤 DTO 검증에서 정상 앱의 토큰 등록 요청이 거절됐습니다.
바꾼 점
등록 라우트의 검증 앞에서 기기 정보를 정리하고, 기존 필수값 검증은 유지했습니다.
확인한 것
실제 HTTP 처리 순서와 저장 서비스 전달을 나눠 시험하고, 실기기 수신은 별도로 확인했습니다.
조사·판단·검증 과정 읽기
마주한 문제
신규 회원의 알림이 오지 않았습니다. 푸시를 보내는 함수만 볼 문제가 아니라, 알림을 받을 기기의 토큰 등록부터 실패하는 상황이었습니다. 이미 배포된 앱을 고치려면 스토어 심사와 사용자 업데이트를 기다려야 했습니다.
확인한 과정
처음에는 앱 버전별 필드명 차이를 의심했지만 후속 검토에서 진단을 정정했습니다. 앱의 snake_case 요청을 서버 전역 interceptor가 camelCase로 바꾼 뒤, DTO 검증이 그 값을 거절하고 있었습니다. DTO 단위 테스트나 컨트롤러 직접 호출은 이 앞단을 지나지 않아 결함을 놓쳤습니다.
선택한 방법
정상 설치본의 요청을 서버에서 받을 수 있게 고쳤습니다. 전역 검증을 느슨하게 만드는 대신 등록 라우트의 검증 앞에서 선택 기기 정보를 정리했습니다. 너무 긴 기기 식별자는 잘라 저장하면 다른 기기와 겹칠 수 있어 버리고, 진단용 앱 버전만 길이를 제한했습니다. 사용자 ID는 요청 body가 아닌 인증 정보에서 가져오도록 유지했습니다.
어떻게 확인했는가
실제 interceptor·ValidationPipe·DTO를 연결한 HTTP 테스트와, 컨트롤러가 정규화한 값을 저장 서비스에 넘기는 테스트를 분리했습니다. 결함을 되돌렸을 때 정상 앱 요청이 다시 실패하는지도 확인했습니다. 당시 복구 기록에서는 토큰 등록·기기 정보 저장과 사용자 수신 확인을 각각 남겼습니다.
단계별 처리와 경계
앱의 등록 요청
필수 토큰과 선택 기기 정보가 서버에 도착합니다.
전역 필드명 변환
snake_case가 camelCase로 바뀐 뒤 다음 단계로 갑니다.
입력 정리·검증
이 라우트의 부가값을 정리하고 기존 필수값 검증을 통과시킵니다.
소유자와 함께 저장
인증한 사용자와 정규화된 메타데이터를 등록 서비스에 전달합니다.
후행 미러 동기화
DB 커밋과 커넥션 반환 뒤 미러를 동기화합니다. 실제 푸시 전달은 별도입니다.
구조도와 처리 경계 자세히 보기
필드명 변환부터 토큰 저장까지, 실제 요청이 지나는 순서
선택 기기 정보가 틀려도 필수 토큰 등록을 살리고, 실제 HTTP에서 바뀐 필드명을 저장 인자까지 연결합니다.
- 처리·명시된 호출
- 비동기·별도 요청
- 데이터 읽기·참조
- 실패·제한 분기
- 어떤 값은 버리고 어떤 값은 자르나
- camelCase는 구버전 별칭이 아니라 전역 변환 뒤의 실제 이름입니다. 잘못된 기기 식별자는 버리고, 진단용 버전 문자열만 자릅니다. 선택 정보가 필수 토큰을 막지 않게 했습니다.
- 파이프라인 시험이 확인한 범위
- ProbeController로 변환·전처리·DTO를 연결했습니다. 실제 인증·PostgreSQL·Firestore는 포함하지 않습니다. 인자 전달과 커넥션 반환은 별도 시험으로 확인했습니다.
- 등록 성공과 알림 도착은 별개
- 미러는 커밋·커넥션 반환 뒤 시도하며 실패해도 PostgreSQL을 되돌리지 않습니다. 자동 복구·실기기 수신은 별개입니다. 연결·트랜잭션 시작은 표시된 try 실패 처리 앞에 있습니다.
이 구조의 한계토큰 저장 성공은 실기기 알림 도착이나 PostgreSQL–Firestore의 원자적 동시 갱신을 뜻하지 않습니다.
단계별 설명 · 구조도를 글로 읽기
등록 요청
인증된 현재 사용자 · 토큰·타입 + 선택정보
- 전역 키 변환 (처리·명시된 호출)
전역 키 변환
snake → camel · 요청 body 교체
- 선택정보 정리 (처리·명시된 호출)
선택정보 정리
미허용 키·형식 제거 · 필수값 검증은 유지
- DTO 검증 (처리·명시된 호출)
DTO 검증
변환·정규화 후 검증 · 위반이면 400
- 등록 인자 결합 (처리·명시된 호출)
- 입력 거절 (실패·제한 분기)
등록 인자 결합
현재 사용자 ID만 사용 · 토큰·타입·기기·버전
- 등록 서비스 (처리·명시된 호출)
등록 서비스
DI 주입 시 아래 경로 · 미주입은 별도 폴백
- 트랜잭션·잠금 (처리·명시된 호출)
트랜잭션·잠금
DB 트랜잭션 시작 · 동일 토큰 등록 직렬화
- 소유자 전환 (처리·명시된 호출)
- DB 작업 실패 (실패·제한 분기)
소유자 전환
타 사용자 행 비활성 · 현재 사용자 행 저장
- 옛 토큰 정리 (처리·명시된 호출)
- DB 작업 실패 (실패·제한 분기)
옛 토큰 정리
활성 행의 판정 결과만 · 비활성으로 갱신
- 커밋·반환 (처리·명시된 호출)
- DB 작업 실패 (실패·제한 분기)
커밋·반환
DB 커밋 성공 · 등록 커넥션 반납
- 후행 미러 (처리·명시된 호출)
- DB 작업 실패 (실패·제한 분기)
후행 미러
DB 현재 소유자 재조회 · 오류는 기록하고 진행
- 저장 결과 반환 (처리·명시된 호출)
저장 결과 반환
등록 결과 반환 · 실기기 수신과는 별개
DB 작업 실패
try 안 실패: rollback · 예외 전파·미러 미실행
입력 거절
필수값·타입·길이 위반 · DTO 단계의 400
구조 탐색과 실제 소스 대조를 바탕으로 편집한 작동도입니다. 런타임 전체의 자동 검증을 뜻하지 않습니다.
문제 해결 / 캐시 수명과 무효화
서버가 늘어나면, 어느 서버의 캐시가 맞는지도 정해야 합니다.
캐시 수명이 늘어나는 문제와, 늦은 조회가 옛 값을 다시 쓰는 문제를 따로 고쳤습니다.
두 캐시, 서로 다른 안전장치
- 조회 시작세대 n
- 무효화 발생세대 n + 1
- 늦게 도착한 n의 결과캐시에 재저장하지 않음
문제
다중 서버에서 로컬 캐시의 만료와 무효화 이후 재저장이 서로 다른 문제를 만들었습니다.
바꾼 점
권한은 공유 캐시의 남은 수명만 이어받고, 앱 버전 설정은 조회 세대를 비교해 재저장을 막습니다.
확인한 것
만료 연장과 조회·무효화 순서의 반례를 시험하고, 실제 Redis의 노드 간 수신을 확인했습니다.
조사·판단·검증 과정 읽기
마주한 문제
관리자가 권한이나 앱 버전 정책을 바꿔도 다른 서버의 로컬 캐시는 이전 값을 가지고 있었습니다. 그렇다고 로컬 캐시를 모두 없애면 Redis 장애 때 인증 요청이 DB 조회로 몰릴 수 있었습니다.
확인한 과정
권한 조회와 앱 버전 설정은 같은 무효화 버스를 쓰지만 값의 원본과 캐시 방식이 달랐습니다. 공유 캐시를 로컬로 가져오며 만료 시간을 다시 시작하는 문제와, 무효화 전에 시작한 설정 조회가 늦게 끝나 옛 값을 다시 저장하는 문제를 따로 재현했습니다.
선택한 방법
권한 캐시는 로컬 L1과 Redis L2를 함께 두고 절대 만료 시각을 전달했습니다. L1으로 가져올 때도 L2의 남은 수명만 사용합니다. 별도로 앱 버전 설정에는 읽기 시작 시점의 세대를 기록해, 조회 중 무효화가 일어났으면 그 결과를 다시 캐시하지 않도록 했습니다. 구독 연결도 명령용 연결과 분리했습니다.
어떻게 확인했는가
만료가 뒤로 늘어나는 조건과 조회·무효화의 완료 순서를 뒤집는 조건을 회귀 테스트에 넣었습니다. 실제 Redis에서는 노드 간 메시지 수신과 일반 명령의 병행을 확인했고, 배포 후에는 구독 상태를 관측했습니다. 설계 의도와 실제 트래픽에서의 캐시 적중은 다른 확인 항목으로 남겼습니다.
단계별 처리와 경계
권한 조회 · L1
유효한 로컬 값이 있으면 반환하고, 없을 때만 다음 저장소를 조회합니다.
공유 값 · L2
Redis 값이 유효하면 원래 만료를 넘기지 않는 수명으로 L1에 올립니다.
캐시 없음 · DB
공유 값도 없거나 읽기에 실패하면 사용자·권한을 DB에서 조회합니다.
결과 반환·보관
DB 결과를 L1에 두고, 공유 캐시 사용 조건이 맞을 때만 L2에도 씁니다.
구조도와 처리 경계 자세히 보기
권한 캐시의 만료와 설정 캐시의 세대를 구분했다
같은 무효화 버스를 쓰더라도 권한 조회의 절대 만료와 앱 버전 설정의 세대 검사는 서로 다른 보장입니다.
- 처리·명시된 호출
- 비동기·별도 요청
- 데이터 읽기·참조
- 실패·제한 분기
판정·계산식
L1.exp = min(now + 5000, L2.exp) gen !== this.gen ⇒ 이번 결과의 캐시 갱신 생략
- 늦은 응답을 다루는 서로 다른 정책
- 권한 L1은 L2의 남은 수명만 이어받습니다. 설정 gen은 읽기 중 무효화가 생겼는지 확인해 재저장만 막습니다. 버스를 수신한 서버는 로컬 캐시만 지우고 재발행하지 않습니다.
- 당시 시험과 운영 관측의 구분
- 9/5 기록에서 만료 연장·읽기 실패·늦은 설정 재저장의 실패 재현과 수정을 확인했습니다. 실제 Redis 버스 시험은 별도였으며, 관측창의 실사용 L2 활동 확인은 대기 상태였습니다.
- 무효화 뒤에도 남을 수 있는 값
- 권한 캐시는 늦은 조회가 옛 값을 다시 채울 수 있습니다. 설정은 gen이 달라지면 캐시에 넣지 않지만, 진행 중이던 요청에는 읽은 값을 반환합니다. 전역 즉시 반영은 아닙니다.
이 구조의 한계두 캐시 모두 전역 즉시 일관성이나 임의 지연·시계 차이까지 포함한 절대 시간 SLA를 보장하지 않습니다.
단계별 설명 · 구조도를 글로 읽기
권한 L1 조회
유효하면 바로 반환 · miss면 L2, off면 DB
- 공유 L2 검사 (처리·명시된 호출)
- 권한자료 반환 (처리·명시된 호출)
- 사용자·권한 DB (처리·명시된 호출)
공유 L2 검사
유효값은 아래로 승격 · 미스·오류는 DB 조회
- 사용자·권한 DB (처리·명시된 호출)
- L1 승격 (처리·명시된 호출)
사용자·권한 DB
권한을 포함해 조회 · 없으면 그대로 반환
- 투영·캐시 저장 (처리·명시된 호출)
- 권한자료 반환 (처리·명시된 호출)
투영·캐시 저장
L1 저장, L2는 비대기 · L2 읽기 실패면 쓰기 X
- 권한자료 반환 (처리·명시된 호출)
권한자료 반환
캐시 또는 DB 결과 · 허용 판정은 소비자 몫
권한 변경 완료
실제 저장 성공 뒤 · 가드 캐시 무효화 호출
- 가드 무효화 (처리·명시된 호출)
가드 무효화
로컬 L1 삭제 · L2 DEL·발행은 비동기
- 무효화 버스 (비동기·별도 요청)
무효화 버스
별도 연결로 구독 · 종류·키별 로컬 삭제
- 권한 L1 조회 (L1 키 삭제)
- 설정 무효화 (비동기·별도 요청)
L1 승격
원래 절대 만료 유지 · TTL을 다시 늘리지 않음
- 권한자료 반환 (처리·명시된 호출)
설정 캐시 조회
유효 캐시는 바로 반환 · miss면 gen 캡처
- 설정 문서 읽기 (처리·명시된 호출)
- 설정 결과 반환 (처리·명시된 호출)
설정 문서 읽기
문서 await · 부재·오류는 빈 설정
- 세대 비교 (처리·명시된 호출)
설정 무효화
set 성공 또는 버스수신 · 캐시 삭제·gen 증가
- 세대 비교 (데이터 읽기·참조)
- 무효화 버스 (비동기·별도 요청)
세대 비교
읽기 전 gen과 비교 · 다르면 캐시 쓰기 생략
- 설정 결과 반환 (처리·명시된 호출)
설정 결과 반환
gen 같을 때만 캐시 · 다르면 이번 값만 반환
구조 탐색과 실제 소스 대조를 바탕으로 편집한 작동도입니다. 런타임 전체의 자동 검증을 뜻하지 않습니다.
문제 해결 / 배포 슬롯 전환
새 버전이 뜨기 전에, 쓰던 서버부터 내리지 않도록.
새 버전을 먼저 준비하고, 이전 서버가 사라진 뒤에도 응답하는지 확인했습니다.
새 서버의 성공은, 이전 서버 없이 확인
- 1 · 준비이전 슬롯요청 처리 중다음 슬롯부팅·상태 확인
- 2 · 프록시 전환이전 슬롯남은 요청 처리다음 슬롯새 요청 수신
- 3 · 드레인·정지이전 슬롯정지 결과 확인다음 슬롯요청 처리 중
- 4 · 단독 검증이전 슬롯이전 슬롯 없음다음 슬롯단독 응답 확인
전환 단계 실패단계에 맞춰 upstream·슬롯·이미지 복구
이전 슬롯 정지 실패완료로 처리하지 않고 수동 확인
문제
쓰던 컨테이너를 먼저 내리면 새 버전이 뜨기까지 요청을 받을 곳이 없었습니다.
바꾼 점
유휴 슬롯 기동, 프록시 전환, 이전 슬롯 정지, 새 슬롯 단독 확인 순서로 배포합니다.
확인한 것
양방향 스왑·이미지 없음·부팅 실패를 시험하고, 실패 단계별 복구와 수동 확인을 구분했습니다.
조사·판단·검증 과정 읽기
마주한 문제
기존 컨테이너를 내리고 새 컨테이너를 올리는 배포 방식에서는 그 사이 요청을 받을 프로세스가 없었습니다. 앱을 쓰고 있는 사용자가 매 배포의 영향을 받는 문제였습니다.
확인한 과정
같은 호스트의 두 슬롯을 번갈아 쓰는 방식부터 적용했습니다. 계획에 있던 헬스 경로가 실제로 없거나 nginx 설정이 중복으로 포함되는 문제를 확인해 수정했습니다. 전환 직후 200 응답만 보면 여전히 살아 있는 구 슬롯의 응답을 새 버전의 성공으로 오인할 수 있다는 점도 검증 기준에 반영했습니다.
선택한 방법
유휴 슬롯을 먼저 띄우고 nginx 설정 검사 후 upstream을 바꾸도록 배포를 구성했습니다. 전환 직후뿐 아니라 구 슬롯을 드레인·정지한 뒤에도 응답을 다시 확인합니다. 실패 단계에 맞춰 upstream·유휴 슬롯·이미지 태그를 복구하고, 구 슬롯 정지가 실패하면 정상 완료 대신 수동 확인 상태를 남깁니다.
어떻게 확인했는가
정방향과 역방향 스왑, 없는 이미지와 부팅 실패를 나누어 시험했습니다. 내부·외부 프로브로 당시 관측창의 응답을 기록하고 실패 시 기존 슬롯 유지와 정리를 확인했습니다. 이후 DB·캐시와 분산 상태를 먼저 정리한 뒤 ALB·ASG 운영으로 전환했으며, 그 단계는 별도로 노드 상태·의존 서비스·배포 이미지 일치를 확인했습니다.
단계별 처리와 경계
유휴 슬롯 기동
기존 슬롯이 요청을 받는 동안 다음 버전을 준비합니다.
설정 확인·스왑
upstream을 바꾸고 nginx 설정 검사 후 reload합니다.
전환 직후 확인
프록시를 거친 응답을 확인하되 이것만으로 전환 완료를 선언하지 않습니다.
이전 슬롯 드레인
진행 중 요청을 기다린 뒤 정지 결과까지 확인합니다.
새 슬롯 단독 검증
이전 슬롯 없이도 응답하는지 확인하고 실패 시 복구 경로로 들어갑니다.
슬롯 전환의 성공과 실패 경로
단일 호스트 시절의 스왑을 따라가며 전환·드레인·복구를 비교합니다.
결과와 현재의 경계
결과, 그리고 지금.
채팅 백엔드를 Firebase 기반으로 전환하고 기존 방·참가자·과거 대화 텍스트를 이관했습니다. 이후 스토어 앱을 다시 배포하지 않고 푸시 등록을 복구했으며, 실제 요청 순서를 재현하는 테스트와 배포 슬롯 스왑·다중 노드 운영 전환을 남겼습니다. 인수한 서비스의 배포·복구·관측 경로를 정리하며 안정화를 이어가고 있습니다.
회사 운영 서비스의 인수·개선 작업입니다. 채팅 이전과 통화 공급자 유지, 서버 배포와 앱 스토어 출시를 구분합니다. 현재 안정화가 진행 중이며, 참가자 데이터의 불일치 관측을 자동 교정 완료로 표현하지 않습니다.
작성 기준: 2026년 9월 작업 기록·소스 대조. 과거 검증 결과를 현재의 모든 동작에 대한 보증으로 사용하지 않습니다.
역할과 협업 이야기하기