결제 알림 재시도 폭주, 에러율만 보다가 놓친 신호는 무엇이었나?
에러율 대시보드는 그 시각 내내 정상이었다. 문제는 에러가 늘어난 게 아니라 재시도 횟수와 p99 지연이 조용히 임계치를 넘어가고 있었다는 것이다. 이 장애로 결제 완료 후 알림 발송이 평균 40분까지 밀렸고, "결제는 됐는데 알림이 안 온다"는 문의가 하루 만에 300건 넘게 쌓였다.
핵심 정리
- 에러율은 정상이어도 타임아웃률·p99 지연·재시도 횟수는 이미 이상 신호를 보내고 있었다.
- 서킷 브레이커가 없어 죽어가는 의존성에 재시도가 계속 몰리는 재시도 폭주(retry storm)가 발생했다.
- 로그·메트릭·트레이스를 같은 식별자로 묶지 못해 원인 특정까지 오래 걸렸다.
- 재발 방지로 타임아웃 예산, 서킷 브레이커, DLQ 메시지 나이 모니터링을 추가했다.
- 목표는 장애를 없애는 것이 아니라 격리하고 빨리 알아채는 것이었다.
당시 구조는 결제 서비스가 알림 서비스를 호출하고, 알림 서비스가 다시 외부 푸시 게이트웨이를 호출하는 3단 체인이었다. 외부 게이트웨이 응답이 평소 80ms에서 어느 순간 2~3초로 늘어났는데, 알림 서비스 쪽 HTTP 클라이언트는 타임아웃이 5초, 재시도가 최대 3회로 설정돼 있었다. 이 조합이 문제의 씨앗이었다.
재시도 로직이 왜 첫 번째 용의자였나?
재시도 로직이 의심된 이유는 단순했다. 알림 발송 실패 로그 자체는 많지 않은데, 알림 서비스의 커넥션 풀이 계속 고갈되고 있었기 때문이다. 게이트웨이 응답이 2~3초로 늘어난 상태에서 요청마다 최대 3회 재시도가 붙으니, 건당 처리 시간이 6초에서 최대 15초까지 늘어났다. 초당 유입되는 알림 요청은 그대로인데 처리 시간이 늘어나니 동시 처리 중인 요청 수가 쌓였고, 결국 커넥션 풀이 바닥났다.
이 구간에서 재시도 횟수 지표를 시계열로 보니 장애 발생 20분 전부터 이미 평소 대비 4~5배로 튀고 있었다. 문제는 이 지표를 그 시점에 누구도 알림으로 받고 있지 않았다는 점이다. 2026년 자료들도 재시도는 백오프·지터·횟수 제한이 붙어야 안전하고, 재시도 횟수와 max-attempt 도달 비율을 별도 지표로 감시해야 한다고 정리한다 Digital Applied. 우리는 재시도는 붙였지만 그 재시도가 얼마나 자주 상한에 도달하는지는 보고 있지 않았다.
서킷 브레이커 부재가 왜 결정타였나?
서킷 브레이커가 없었다는 게 결정타였던 이유는, 죽어가는 의존성을 차단할 장치가 시스템 안에 전혀 없었기 때문이다. 게이트웨이가 느려져도 알림 서비스는 계속 정상 호출인 것처럼 요청을 보냈고, 그 요청들이 커넥션 풀과 스레드를 계속 점유했다. 서킷 브레이커가 있었다면 실패율이 임계치를 넘는 순간 open 상태로 전환해 하위 시스템으로의 호출 자체를 끊고, 쿨다운 이후 half-open에서 시험 호출만 소량 보내 복구 여부를 확인했을 것이다 OneUptime.
타임아웃·재시도·서킷 브레이커는 역할이 분리된 세 장치다. 타임아웃은 느린 호출을 자르고, 재시도는 일시적 오류를 흡수하고, 서킷 브레이커는 고장 전파를 막는다. 이 셋을 같이 두지 않으면 재시도가 오히려 장애를 키운다는 지적은 2025~2026년 자료에서 반복적으로 나온다 Temporal. 우리는 타임아웃과 재시도만 있고 서킷 브레이커가 빠진, 절반짜리 회복 패턴을 쓰고 있었던 셈이다.
로그·메트릭·트레이스를 이었을 때 뭐가 보였나?
셋을 이어서 보니 병목 위치가 명확해졌다. 메트릭에서 알림 서비스의 p99 지연 이상을 먼저 확인했고, 그 시각의 대표 트레이스 몇 건을 열어보니 알림 서비스 자체 처리 시간은 짧고 외부 게이트웨이 호출 구간에서 대부분의 시간이 소요되고 있었다. 그 트레이스의 trace id로 로그를 역추적하자 같은 시간대에 게이트웨이 응답 지연과 재시도 로그가 함께 찍혀 있었다.
이 과정 자체가 장애 회고의 핵심이다. 메트릭은 얼마나 자주·얼마나 오래 일어나는지를 보여주고, 트레이스는 어디서 시간이 새는지를 보여주고, 로그는 그 지점에서 정확히 무슨 일이 있었는지를 보여준다. 세 신호를 분리해서 각자 보는 게 아니라 같은 식별자로 묶어야 원인까지 이어진다는 점은 2026년 관측성 자료들의 공통 전제다 Digital Applied.
장애를 어떻게 재현하고 원인을 확정했나?
재현은 스테이징 환경에서 게이트웨이 목(mock) 응답 지연을 80ms에서 3초로 인위적으로 늘리는 방식으로 진행했다. 부하는 실제 장애 당시 유입량과 비슷하게 초당 요청 수를 맞췄다. 결과는 예상대로였다. 커넥션 풀 사용률이 5분 내로 90%를 넘겼고, 재시도 횟수는 평소 대비 4배 이상으로 튀었으며, 알림 지연 p99는 실제 장애 때와 비슷한 수준까지 벌어졌다. 이 재현 결과와 실제 장애 시점의 메트릭·트레이스·로그를 나란히 놓고 비교했을 때, 원인은 게이트웨이 지연 자체가 아니라 그 지연을 흡수할 장치가 없었던 우리 쪽 구조라는 결론이 나왔다.
무엇을 바꿨고 어떤 지표로 확인했나?
바꾼 것은 세 가지다. 첫째, 알림 서비스에서 게이트웨이 호출 경로에 서킷 브레이커를 붙였다. 실패율이 일정 임계치를 넘으면 open으로 전환해 즉시 폴백 처리(발송 큐 적재 후 나중에 재시도)로 넘기도록 했다. 둘째, 호출 경로별 타임아웃 예산을 나눠 재시도 총 소요 시간이 원래 요청 타임아웃을 넘지 않게 제한했다. 셋째, 발송 실패 메시지는 DLQ로 보내고 DLQ 깊이와 메시지 나이를 별도 대시보드로 감시하기 시작했다. 이런 보호장치를 함께 두는 방식은 2026년 자료에서도 부분 실패 격리의 현장 버전으로 제시된다 Temporal.
확인 지표는 재시도 횟수, 서킷 브레이커 상태 전이 횟수, DLQ 나이 세 가지로 좁혔다. 이후 유사한 지연 상황을 몇 차례 더 겪었지만, 서킷이 열리는 시점에 알림 채널로 즉시 통지가 오면서 대응 시작 시각이 눈에 띄게 앞당겨졌다. 다만 이 변경이 모든 종류의 하위 장애를 막아주는 것은 아니고, 어디까지나 이번에 관찰된 실패 패턴에 대한 대응이라는 점은 분명히 해두고 싶다.
같은 함정을 피하려면 기본값을 뭘로 삼아야 하나?
기본값으로 삼은 건 단순하다. 외부 의존성을 호출하는 모든 경로에는 타임아웃과 서킷 브레이커를 기본으로 붙이고, 재시도는 백오프와 횟수 제한 없이는 허용하지 않는다. 그리고 에러율만이 아니라 재시도 횟수·서킷 상태·DLQ 나이를 알림 규칙에 포함시킨다. 장애는 없앨 수 없다는 전제 위에서, 격리하고 빨리 알아채는 쪽에 설계 비용을 쓰는 것이 이번 회고에서 남은 판단 기준이다 HireNodeJS.
핵심 정리
- 에러율이 정상이어도 재시도 횟수·p99 지연·타임아웃률이 이미 신호를 보낼 수 있다.
- 타임아웃·재시도·서킷 브레이커는 역할이 다르고, 셋을 같이 써야 재시도 폭주를 막는다.
- 로그·메트릭·트레이스는 같은 trace id로 묶여야 원인 추적 시간이 줄어든다.
- 부분 실패 격리는 서킷 브레이커, 타임아웃 예산, DLQ 나이 감시를 함께 두는 것으로 현장화된다.
- 목표는 장애 제거가 아니라 빠른 감지와 전파 차단, 그리고 안전한 복구다.