개발 slecs

송금 푸시 알림 미수신을 재시도 큐로 해결

목차

연락처 기반 송금 기능에서 푸시 알림이 가끔 빠진다는 신고가 들어오기 시작했다. "가끔"이라고 하지만 송금에서 "돈 받았다"는 알림을 못 받는 건 사용자 입장에서 꽤 불안한 경험이다. 앱을 직접 켜봐야 잔액이 바뀐 걸 확인할 수 있고, 극단적으로는 입금이 됐는지 안 됐는지 모르는 채로 발신자한테 따로 연락하는 상황까지 생긴다. 단순 불편이 아니라 신뢰 문제로 번진다.

로그를 먼저 팠다. 푸시 발송 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

첫 댓글 달아줘.