첫 incident — 어느 새벽의 30분
새벽 2시 17분 DB 커넥션 풀이 소진되어 결제 요청이 15분간 쌓였다. 롤백으로 4분 만에 복구했지만 진짜 비용은 이후 40분의 사후 검증이었다. 우리가 바꾼 것과 지금 바로 쓸 수 있는 시사점.
새벽 2시 17분, 결제 요청이 쌓이기 시작했다
슬랙 채널에 첫 에러가 찍힌 시각은 2시 17분이었다. 헬스체크 엔드포인트가 연속으로 실패하고 있다는 알람이 울렸고, 당직이던 팀원이 노트북을 열었다. 그 순간 시스템 안에서는 신규 결제 요청이 계속 들어오고 있었지만 처리되지 못한 채 큐에 쌓이고 있었고, DB 커넥션 풀은 이미 100%에 도달한 상태였다. 결제 확인 콜백은 타임아웃으로 떨어지고, 일부 요청은 재시도 로직을 타고 중복 결제 위험 구간으로 진입하고 있었다.
처음 1분은 상황 파악에만 썼다. 서버 프로세스 자체가 죽은 건지, 특정 엔드포인트만 문제인지, 아니면 PG사 API 같은 외부 의존성 문제인지를 순서대로 확인했다. 로그를 열었을 때 원인은 명확했다. 커넥션을 잡은 트랜잭션들이 반환을 하지 않고 있었고, 두 시간 전 배포된 코드 안에 트랜잭션 블록 내부에 외부 API 호출이 들어가 있었다. 그 응답 지연이 트랜잭션 홀드 타임을 늘려 풀 전체를 잠식한 것이었다.
결제·정산 시스템에서 커넥션 풀 소진이 유독 위험한 이유
일반적인 웹 서비스에서 커넥션 풀 소진은 '느린 응답'을 의미한다. 결제와 정산 시스템에서는 이야기가 다르다. 결제 트랜잭션은 멱등성 보장과 상태 일관성이 전제되어야 하기 때문이다. 커넥션을 얻지 못한 요청이 타임아웃으로 실패하면, 클라이언트는 재시도를 하게 된다. 그런데 재시도가 들어왔을 때 원래 트랜잭션이 커밋됐는지 롤백됐는지 불확실하다면 중복 결제나 미처리 결제가 발생할 수 있다. 우리가 운영하는 정산 배치는 전날 자정부터 익일 자정까지 거래를 집계하는데, 이 구간에 상태가 불확실한 트랜잭션이 끼어들면 정산 숫자 자체가 어긋난다.
더 구체적으로 보면, 당시 우리 서비스의 커넥션 풀 크기는 20개였다. 외부 API 평균 응답이 200ms일 때는 문제없이 돌아간다. 그런데 해당 외부 API의 P99 응답이 밤 시간대에 35초까지 치솟는 상황이었고, 그 35초 동안 커넥션 20개가 잠겨 있으면 다른 모든 요청이 대기 상태가 된다. 트래픽이 적은 새벽이라도 자동 정산 배치와 주기적 상태 동기화 잡이 백그라운드에서 돌기 때문에 커넥션 수요는 상시 존재했다. 낮 시간대였다면 15분이 아니라 훨씬 많은 트랜잭션이 회색 지대로 빠졌을 것이다.
롤백 vs 핫픽스: 새벽 2시 19분의 4분짜리 결정
원인을 파악한 뒤 선택지는 두 가지였다. 핫픽스 — 트랜잭션 블록에서 외부 API 호출을 빼내고 즉시 재배포. 롤백 — 두 시간 전 배포를 취소하고 이전 아티팩트로 복구. 각각의 트레이드오프는 분명했다.
핫픽스는 '올바른 코드'를 배포한다는 장점이 있다. 그러나 새벽 2시에 잠에서 깬 지 3분도 안 된 상태에서 코드를 수정하고 리뷰 없이 프로덕션에 올리는 것은 두 번째 장애를 낼 확률이 높다. 변경 라인이 한두 줄이라도 마찬가지다. 반면 롤백은 '이미 검증된 코드'로 되돌리는 것이다. 이전 배포 아티팩트가 남아 있었고, 재배포는 4분 만에 완료됐다. 2시 32분, 헬스체크가 정상으로 돌아왔다. 실제 서비스 중단 시간은 약 15분이었다.
판단 기준은 단순했다. "지금 내가 새 코드를 믿을 수 있는가?" 새벽 2시, 반쯤 잠든 상태에서 그 답은 '아니오'였다. 롤백 후 핫픽스는 다음 날 낮에 코드 리뷰를 거쳐 정상 배포했다. 트랜잭션 블록 바깥으로 외부 호출을 꺼내고, API 응답을 먼저 받은 뒤 트랜잭션 안에서는 DB 쓰기만 하도록 구조를 바꿨다. 그날 낮 배포 후에는 15분간 직접 모니터링했다.
15분이 만든 파장: 복구보다 길었던 사후 처리
15분이라는 시간이 짧게 들릴 수 있다. 하지만 결제 시스템에서 이 숫자는 다르게 읽힌다. 새벽 23시 사이 우리 서비스의 결제 요청은 시간당 약 80120건 수준이다. 15분이면 약 20~30건의 요청이 지연되거나 실패했을 가능성이 생긴다. 그 중 재시도에 성공한 건수, 타임아웃으로 최종 실패한 건수, 그리고 상태가 모호하게 남은 건수를 다음 날 오전에 수작업으로 분류해야 했다. 이 검증에만 40분이 걸렸다. 인시던트 복구 30분보다 사후 처리가 더 길었다.
이 경험은 두 가지를 명확히 해준다. 첫째, 장애 복구 시간보다 불확실 상태 트랜잭션 정리가 실제 비용에서 더 크다. 둘째, 결제 시스템에서 '부분 실패'는 없다. 성공이거나, 실패이거나, 아니면 검증이 필요한 회색 지대이거나 — 그 회색 지대가 팀의 시간을 먹는다. 이후 우리는 멱등성 키 기반의 재시도 로그를 별도 테이블에 쌓아, 장애 직후 자동으로 회색 지대 트랜잭션 목록을 뽑아주는 쿼리를 미리 만들어두었다.
그날 이후 실제로 바꾼 것들
커넥션 풀 80% 경보: 커넥션 사용률이 80%를 넘으면 슬랙 알람이 온다. 100%에서 처음 알게 되면 이미 늦다. 이 경보가 이후 두 번 울렸고, 두 번 모두 선제 조치로 장애로 이어지지 않았다. 단순한 임계값 하나가 대응 시간을 만들어준다.
트랜잭션 안 외부 호출 금지 체크리스트: 코드 리뷰 시 트랜잭션 블록 안에 외부 HTTP 호출, 루프 안 반복 쿼리, 대기 로직이 있는지를 고정 항목으로 확인한다. 패턴이 정해져 있어 발견이 어렵지 않다. 정적 분석 도구로 일부 자동화도 검토 중이다.
롤백·핫픽스 판단 기준 문서화: "최근 배포가 2시간 이내이고 증상이 배포 직후부터 시작됐다면 → 롤백 우선", "배포와 무관한 데이터 이슈라면 → 핫픽스"라는 기준을 슬랙에 고정해뒀다. 장애 순간 판단력은 저하된다. 기준이 문서에 있는 것과 없는 것은 실제로 다르다.
배포 후 15분 모니터링 의무화: 배포 완료 후 15분간 커넥션 풀, 응답 시간, 에러율 세 지표를 확인한다. 주간 배포는 팀 전체가 보고, 야간 긴급 배포는 당직자 혼자라도 반드시 한다. 배포 직후 자리를 뜨는 것은 특히 야간에 위험하다.
지금 바로 쓸 수 있는 시사점
인시던트는 예방이 최선이지만, 막지 못했을 때의 복구 속도가 실제 피해 규모를 결정한다. 그리고 복구 속도는 사전 준비의 수준에서 온다.
커넥션 풀 경보 임계값을 80%로 설정하라. 100%는 이미 장애다. DataDog, Prometheus, 혹은 cron + 슬랙 웹훅이라도 충분하다. 80%에서 울리면 개입할 시간이 생긴다.
트랜잭션 블록 안에 외부 I/O를 두지 말라. 외부 API 호출, 파일 쓰기, 메시지 큐 발행은 트랜잭션 커밋 이후로 빼라. DB 트랜잭션 시간이 외부 서비스 응답 시간에 종속되는 순간, 장애 반경이 그 서비스 전체로 넓어진다.
롤백 절차를 지금 써두어라. 장애가 나고 나서 명령어를 찾는 데 시간을 쓰지 말라. 이전 아티팩트로 재배포하는 명령어를 팀 문서에 복붙 가능한 형태로 적어두어라. 실제 장애 순간에 문서 유무의 차이는 크다.
회색 지대 트랜잭션 탐지 쿼리를 미리 만들어라. 결제 시스템이라면 created_at이 장애 구간이고 status가 pending인 레코드를 뽑는 쿼리 하나가 사후 처리 시간을 절반으로 줄인다. 장애 이후에 만드는 것과 이미 있는 것은 다르다.
배포 직후 15분은 화면에서 눈을 떼지 말라. 정교한 모니터링 대시보드가 없어도 된다. 에러 로그 tail과 헬스체크 엔드포인트를 새로고침하는 것만으로도 초기 이상 신호를 잡을 수 있다.
작은 팀에게 인시던트는 피할 수 없지만, 같은 인시던트를 두 번 겪는 것은 피할 수 있다. 그 차이는 30분 안에 무엇을 결정했는가가 아니라, 그 이후 무엇을 바꿨는가에서 온다.
— by slecs
* 위 링크는 인프런 affiliate 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
* 위 추천 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.