송금 푸시 알림 미수신을 재시도 큐로 해결
목차
연락처 기반 송금 기능에서 푸시 알림이 가끔 빠진다는 신고가 들어오기 시작했다. "가끔"이라고 하지만 송금에서 "돈 받았다"는 알림을 못 받는 건 사용자 입장에서 꽤 불안한 경험이다. 앱을 직접 켜봐야 잔액이 바뀐 걸 확인할 수 있고, 극단적으로는 입금이 됐는지 안 됐는지 모르는 채로 발신자한테 따로 연락하는 상황까지 생긴다. 단순 불편이 아니라 신뢰 문제로 번진다.
로그를 먼저 팠다. 푸시 발송 API 호출 자체는 정상적으로 이루어지고 있었다. 문제는 그 다음이었다. 실패 응답이 돌아와도 아무것도 하지 않고 종료하는 단발성 구조. 토큰이 만료됐거나 네트워크가 잠깐 끊긴 타이밍에 걸리면 그냥 조용히 실패하고 끝. 받는 쪽은 돈이 들어온 줄도 모르고 나중에 앱 켜서야 알게 되는 흐름이었다.
실패 유형 분류와 대응 전략
실패를 하나로 묶어서 처리하면 안 된다는 게 먼저였다. 재시도로 해결 가능한 케이스와 그렇지 않은 케이스를 섞어서 루프를 돌리면, 어차피 안 되는 요청에 자원을 낭비하고 회수 가능한 케이스의 타이밍도 망가진다.
| 실패 유형 | 체감 비율 | 대응 방향 |
|---|---|---|
| 토큰 만료 | 가장 많음 | 토큰 갱신 후 재시도 |
| 일시적 네트워크 | 중간 | 백오프 후 재시도 |
| 디바이스 미등록 | 적음 | 재시도 의미 없음, 즉시 폐기 |
디바이스 미등록은 재시도해봤자 같은 응답이 온다. FCM 기준으로 보면 UNREGISTERED, InvalidRegistration 같은 에러 코드는 명백한 영구 실패다. 이런 케이스를 큐에 쌓아두면 워커가 계속 의미 없는 요청을 보내게 되고, 그 비용이 쌓인다. 분기는 초기에 잡는 게 맞다.
재시도 큐 설계와 트랜잭션 분리
지수 백오프 + 최대 3회 재시도로 방향을 잡았다. 간격은 1초 → 3초 → 9초. 3회까지 다 실패하면 미발송 큐로 이관하고, 별도 백그라운드 잡이 주기적으로 처리한다. 흐름을 코드 수준으로 표현하면 대략 이렇다.
MAX_RETRY = 3
BACKOFF = [1, 3, 9] # seconds
def send_push_with_retry(token, payload, attempt=0):
try:
response = push_client.send(token, payload)
if response.is_permanent_failure():
# 디바이스 미등록 등 — 재시도 없이 즉시 폐기
log_push_result(token, "PERMANENT_FAIL", attempt)
return
log_push_result(token, "SUCCESS", attempt)
except TransientError:
if attempt < MAX_RETRY:
schedule_retry(token, payload, attempt + 1, delay=BACKOFF[attempt])
else:
enqueue_failed_push(token, payload, attempt)
log_push_result(token, "QUEUED_AFTER_MAX_RETRY", attempt)
실제 구현에는 여기에 토큰 갱신 로직이 붙고, 큐 적재 시 시도 횟수·마지막 에러 코드·페이로드를 같이 넣었다. 워커가 나중에 맥락 없이 재처리하면 왜 실패했는지 추적이 안 되기 때문에 attempt 카운트는 필수였다.
한 가지 함정이 있었다. 처음엔 그냥 try/catch 감싸고 루프 돌리는 구조로 짰는데, 이러면 송금 본 트랜잭션 안에서 외부 API 호출 대기 시간만큼 DB 락이 같이 길어진다. 잔액 갱신처럼 짧게 끊어야 하는 트랜잭션 안에 외부 IO가 끼어드는 건 결제 도메인에서 특히 위험하다. 동시 요청이 몰릴 때 락 대기가 쌓이고, 최악의 경우 타임아웃까지 이어진다.
결국 본 트랜잭션 커밋 후 이벤트를 발행하고, 푸시 발송은 이벤트 핸들러에서 처리하도록 분리했다. 트랜잭션과 외부 IO를 분리하는 건 결제 플랫폼에서 기본 원칙 수준이지만, 이번에 직접 밟으면서 다시 체감했다. 저 함정을 그냥 두고 배포했으면 동시성 높은 시간대에 더 큰 사고로 이어졌을 거다.
배포 이후와 회고
배포 후 며칠 모니터링한 결과:
- 미수신 신고가 거의 사라졌다
- 재시도로 회수되는 비율이 첫 시도 실패 건 중 약 70%
- 큐에 끝까지 남는 진짜 실패는 일 단위 한자리수
70%라는 숫자가 인상적이었다. 첫 시도 실패의 상당수가 일시적 원인이었다는 뜻이다. 기존 단발 호출에서 그 70%가 조용히 누락되고 있었던 거다.
재시도 횟수를 왜 3회로 잡았는지는 명확한 공식이 있는 건 아니다. 너무 적으면 회수율이 낮고, 너무 많으면 결국 도착 불가한 요청에 자원을 쓰게 된다. 1초 → 3초 → 9초 구간이면 총 약 13초 안에 끊기는데, 일시적 네트워크 단절 대부분은 이 범위 안에서 복구된다는 경험적 판단이었다. 서비스 특성에 따라 구간은 달라진다. 인증 코드처럼 즉시성이 중요한 알림이라면 간격을 더 짧게 잡아야 하고, 즉시성이 낮은 메시지라면 늘려도 된다. 무한 재시도는 비용이고, 0회는 UX 사고라서 그 사이 어딘가에 선을 그어야 하는데, 이번엔 3회 + 큐 이관으로 잡았다.
이번 작업을 하면서 푸시는 "API 200 응답 = 성공"이 아니라는 걸 다시 확인했다. HTTP 200은 발송 시도가 접수됐다는 뜻이지, 단말에 실제 도착했다는 보장이 아니다. 이커머스나 송금 알림처럼 사용자가 실제로 봐야 의미 있는 메시지일수록 이 구분이 중요해진다.
다음 단계로 고민하는 건 단말 ack 추적이다. 지금은 발송 시도 결과를 로깅하는 수준인데, 앱 수신 이벤트를 서버로 쏘게 해서 진짜 도착 여부로 성공을 정의하는 구조까지 가면 더 정확해진다. 다만 이건 클라이언트 변경이 따라와야 하고, ack가 오지 않는 케이스(앱이 꺼진 상태 등)의 경계 처리가 복잡해서 아직 확정은 못 했다. 재시도 정책만 제대로 잡아도 미수신 대부분은 막을 수 있다는 게 이번에 확인됐으니, 그다음 개선은 실제 데이터를 더 모아보고 판단할 것 같다.
댓글 0
첫 댓글 달아줘.