안녕하세요. 죽을 죄를 지은 백엔드 개발자 최혜미입니다.
이번 글은 회사 말아먹을 뻔한 장애 회고를 가지고 왔습니다.
기존 프로젝트에서 다른 프로젝트로 넘어가고 평화롭게 개발을 하고 있던 어느날, 이전 프로젝트에서 정산을 했는데 저희 DB 어디에도 존재하지 않는 상품이 청구서에 찍혀 있는 걸 발견했거든요...😢
한 건이 아니라 5월에 47건, 6월에 127건. 두 달 동안 총 174건의 "유령 주문"이 외부 공급사 청구서에 쌓여 있었습니다.
범인을 추적해 보니, 놀랍게도 4개월 전 DB 장애가 생겨 해결하고자 제가 열심히 고쳐둔 코드였습니다. 그 수정은 분명히 옳은 방향이었는데, 딱 하나가 빠져 있었어요. 이 글은 그 "딱 하나"에 대한 이야기입니다.
이 글은 실제 서비스에서 발생했던 문제를 다루지만 본문의 코드와 쿼리는 실제 코드가 노출되지 않도록 구조만 유사하게 재작성한 것입니다.
저희 서비스는 여행용 eSIM을 판매합니다. 사용자가 발급 버튼을 누르면 서버가 외부 eSIM 공급사에 주문을 넣고, 발급된 QR 프로필을 받아 DB에 저장한 뒤 사용자에게 보여줍니다.
사용자 → [우리 서버] → 외부서버 주문 생성 API → 주문번호 획득
→ 외부서버 주문 조회 API → QR 프로필 획득
→ DB 저장 → 사용자에게 QR 노출
포인트는 외부서버 주문 조회가 즉시 안 된다는 점입니다.
주문을 넣고 나서 QR이 실제로 발급되기까지 수 초에서, 공급사가 부하 상태일 때는 수 분까지 걸립니다.
그래서 중간에 폴링(재시도 조회) 구간이 존재합니다.
판매 채널은 세 개입니다. 이 셋이 각각 언제 어떤 코드 경로를 타는지가 이 사건의 복선입니다.
이 이야기를 시작하려면 4개월 전 큰 장애가 나왔을 때로 돌아가야합니다🫠
원래 발급 로직은 이렇게 생겼었습니다.
// 실제 코드를 단순화한 의사코드입니다
@Transactional // 클래스 레벨
public class OrderService {
private static final Duration TIMEOUT = Duration.ofHours(1);
public ... issueEsim(Long itemId) {
// 한 트랜잭션 안에서:
// 1. 주문 상품 조회
// 2. client.placeOrder(...).block(1시간) ← 외부서버에 주문 생성
// 3. fetchAndSaveProfile(...) ← 내부에서 조회 폴링 + 저장
}
}
주문 생성 → 조회 폴링 → 저장이 전부 하나의 트랜잭션, 즉 하나의 DB 커넥션 위에서 돌았습니다.
타임아웃은 무려 1시간. 평소엔 외부서버가 빨라서 티가 안 났는데, 3월에 외부서버 장애가 터지자 본색이 드러났습니다.
발급 요청들이 외부서버 응답을 기다리며 커넥션을 20분 넘게 물고 늘어졌고, 그 결과 HikariCP 풀이 고갈됐습니다.
여기서 무서운 건 피해 범위입니다.
커넥션 풀은 서비스 전체가 공유하는 자원이라 eSIM 발급과 아무 상관없는 로그인, 주문 조회, 결제까지 전부 커넥션을 못 얻어 죽었습니다. 외부 API 하나의 장애가 우리 서비스 DB 전면 장애로 증폭된 거죠.
그래서 트랜잭션을 분리했습니다.
[TX 1] 사전 검증 (환불/기발급 체크, readOnly)
[TX 2] 선점 마킹 (중복 진입 차단용 atomic update)
[ 없음 ] UCL 주문 생성 (placeOrder, 타임아웃 1시간 → 3분)
[ 없음 ] UCL 주문 조회 폴링 (Propagation.NOT_SUPPORTED)
[TX 3] 외부서버에서 조회된 주문 정보 저장 (REQUIRES_NEW)
외부 API를 기다리는 구간에서는 커넥션을 아예 잡지 않도록 만들었습니다.
DB 커넥션은 저장하는 찰나에만 점유합니다. 선점 마킹은 이런 쿼리로 동시에 두 요청이 들어와도 한 명만 통과합니다.
(실제 쿼리와 유사하게 재작성한 쿼리입니다.)
update order_item
set issue_started_at = :now
where id = :id
and issued_at is null
and issue_started_at is null -- 이 조건 덕에 1인자만 update count 1
배포 후 같은 규모의 UCL 장애가 와도 DB는 멀쩡했습니다.
수정 이후 3월, 4월 두 달간 아무 문제가 없었고, 저는 이 건을 "잘 끝난 개선"으로 기억 속에 접어뒀습니다.
7월 초 정산.
외부 공급사가 청구한 내역과 저희 주문건을 비교하는데 저희 DB 테이블 어디에도 등록되지 않은 상품이 청구되고 있었습니다...
우리가 사용자에게 준 적 없는 eSIM 값이 돈으로 나가고 있었던 겁니다.
로그를 눈 빠지게 들여다보며 복원한 실제 시나리오는 이렇습니다.
외부 공급사 서버에 주문 생성 → 주문번호 #1 획득
주문번호 #1로 조회 → 재시도 → 재시도 → ... 실패 (QR이 아직 안 나옴)
→ 사용자에게 "발급 실패" 안내
(사용자가 다시 발급 버튼 클릭)
UCL에 주문 생성 → 주문번호 #2 획득 ← ★ 여기서 중복
주문번호 #2로 조회 → 성공 → #2만 DB에 저장
주문번호 #1은? 로그에만 존재. DB엔 없음. 외부 공급사 청구서엔 있음.
코드로 짚어보면 실패 경로가 이렇게 흘러갑니다.
// 발급 마무리 단계 — 조회 실패 시 (의사코드)
OrderDetail orderDetail = fetchOrderWithRetry(externalOrderId);
if (orderDetail == null) {
releaseIssuingMark(item.getId()); // 선점 해제
return error(HttpStatus.BAD_GATEWAY, "공급사 지연으로 발급에 실패했어요.");
// externalOrderId(#1)는 여기서 그냥 버려진다. 로그에만 남고.
}
실패 시 선점 마커를 풀어주는 건 의도된 설계였습니다. 당시 코드 주석에도 이렇게 적혀 있어요.
"실패한 경우, 선점 플래그를 null로 돌려주어야 사용자가 다시 눌렀을 때 API를 호출함"
로컬 관점에서는 완벽하게 맞는 말입니다.
문제는 그 재호출이 외부 세계에 무엇을 만드는지를 안 봤다는 겁니다.
선점 로직은 "같은 순간의 동시 진입"만 막습니다. "실패 후 순차 재시도"는 일부러 열어뒀고요.
그런데 재시도를 열어줄 때 이전 시도에서 외부 공급사 서버에 주문이 이미 생성됐는지를 확인할 방법 자체가 없었습니다. placeOrder가 돌려준 주문번호를 DB 어디에도 저장하지 않았으니까요.
한 문장으로 요약하면:
외부 시스템에 이미 커밋된 부수효과를, 로컬 상태 리셋으로 없었던 일 취급했다.
DB 트랜잭션은 롤백하면 정말 없었던 일이 됩니다. 하지만 외부 API 호출은 롤백이 없습니다.
@Transactional을 쪼개는 순간 롤백이 커버하지 않는 구간의 실패 처리는 전부 제 몫이 되는 건데, 저는 그 구간에서 "선점 해제"라는 로컬 정리만 하고 "외부 공급사에 이미 들어간 주문"이라는 외부 정리를 빼먹은 겁니다.😱😱
분산 시스템 교과서가 말하는 보상 트랜잭션 이 정확히 이 자리에 있었어야 했습니다.
여기가 이 회고에서 제일 재밌는 부분입니다.
처음엔 저도 "예전엔 트랜잭션이 하나라서 롤백되니까 괜찮았나?"라고 생각했는데 아닙니다...
방금 말했듯 외부 API는 롤백이 안 되니까 단일 트랜잭션이었어도 조회 실패 후 재시도하면 중복 주문은 똑같이 생겼어야 합니다.
‼️진짜 이유는 타임아웃 1시간입니다‼️
옛날 코드는 외부 공급사 서버가 아무리 느려도 사실상 응답이 올 때까지 기다렸습니다.
"실패로 끝나는 요청" 자체가 거의 없었으니, 사용자가 재시도할 일도 없었던 거예요.
대신 그 무한한 인내심이 DB 커넥션을 인질로 잡았고 그게 3월의 히카리풀 고갈로 나타난 거고요..
바꿔 말하면 "보상 처리가 빠져 있다"는 공백은 단일 트랜잭션 시절에도 이미 있었습니다.
다만 조회가 성공할 때까지 하염없이 기다려주는 구조 덕에 실패 자체가 거의 안 생겨서 제가 이 부분을 놓치고 있어도 중복 주문으로 드러날 기회가 없었을 뿐이에요.
그러다 옳은 방향의 리팩토링이 "빠른 실패"를 도입하는 순간, 숨어 있던 공백이 수면 위로 올라온 겁니다.
그러니까 3월의 수정은 장애를 없앤 게 아니라 장애 모드를 옮긴 것이었습니다.
| 단일 트랜잭션 (분리 전) | 트랜잭션 분리 (분리 후) | |
|---|---|---|
| 외부서버 지연 시 | 응답 올 때까지 대기 | 짧게 기다리고 빠른 실패 |
| 대가 | 커넥션 인질 → 풀 고갈 → DB 전면 장애 | 재시도 허용 → 중복 주문 (정합성 훼손) |
| 피해 가시성 | 즉시/ 전사가 안다 | 조용히 누적/ 정산 때 안다 |
가용성 장애를 정합성 장애로 바꾼 셈인데 트레이드 자체는 옳았다고 지금도 생각합니다.
DB 전면 장애보다는 백 배 낫죠.
다만 트레이드를 했으면 새로 열린 실패 모드에 값을 지불해야 했습니다.
그 값이 보상 로직이었고, 저는 외상으로 두 달을 지낸 겁니다. 이자가 174건 붙어서 돌아왔네요ㅎㅎ
구조적 결함은 3월부터 존재했는데 왜 두 달 동안 조용하다가 5월애 터졌을까요???
채널별 재시도 코드를 보면 답이 나옵니다.
| 채널 | 조회 재시도 방식 | 총 대기 시간 |
|---|---|---|
| 자사 앱 | 초기 4초 + 4초 고정 간격 × 5회 | 약 20초 |
| 신규 웹 채널 | 자사 앱과 같은 서비스 재사용 | 약 20초 |
| 오픈마켓 | 초기 5초 + 지수 백오프 5→10→20→40초(캡) × 8회 | 3분 20초 |
오픈마켓 경로는 3월에 이미 지수 백오프로 개선돼서 3분 20초를 기다립니다.
외부서버가 느린 날에도 대부분 이 안에 QR이 나와요.
그런데 자사 앱 경로는 4초 고정 간격 5번, 겨우 20초 기다리고 포기합니다.
같은 회사 코드인데 재시도 정책이 10배 차이 나는 평행 복제 코드의 비대칭이 방치돼 있던 거죠...
그리고 5월 말 부터 새로 연 웹 판매 채널에서 eSIM 주문이 유입되기 시작했습니다.
새 채널인데 구현은 기존 자사 앱의 20초짜리 폴링 서비스를 그대로 재사용했습니다.
트래픽은 늘고, 외부서버는 여전히 가끔 느리고, 20초는 짧고, 실패 안내를 받은 사용자는 웹에서 버튼을 다시 누르고… 중복 주문 공장이 가동된 겁니다.
교훈 하나가 여기서 나옵니다..
‼️도메인이 채널별로 평행 복제돼 있으면, 한쪽에 들어간 개선(백오프)은 반드시 반대쪽에도 들어가야 합니다!‼️
신규 채널은 항상 "낡은 쪽"을 재사용하는 경향이 있거든요. 제일 손이 덜 가는 코드가 제일 먼저 복사되니까요.
기술적 원인보다 더 오래 곱씹게 되는 건 이겁니다.
이 장애는 이미 모니터링이 잡고 있었습니다.
두 달간 슬랙 경고 알림이 7번 울렸습니다.
조회 최종 실패가 로깅되고 있었고, 알림 봇은 정직하게 일했어요.
그런데 그 알림은 한 번도 행동으로 이어지지 못했습니다.
누군가 게을렀던 게 아니라 알림이 행동으로 이어질 수 없는 구조였던 건데요.
이유를 뜯어보면 세 겹입니다.
조회 실패 로그는 "사용자가 재시도하면 해결되는 에러"용으로 알림 피로를 줄이려고 만든 낮은 등급으로 분류돼 있었습니다.
그런데 이 케이스는 정확히 반대였죠.
사용자가 재시도할 때마다 돈이 나가는 에러였으니까요.
기술적 심각도(그냥 조회 실패잖아?)로 등급을 매겼지 "이 실패의 다음 단계에서 무슨 일이 벌어지는가"로 매기지 않았던 겁니다.
문제가 생긴 외부 공급사 서버는 원래 장애가 잦은 서버였습니다.
외부 장애 알림이 일상이 되면 "또 그거네"라는 반응은 누구에게나 생깁니다.
그 상태에서 7번의 알림은 다른 외부 서버 알림 사이에 묻혔습니다...
같은 원인(외부 공급서 서버 장애)에서 오는 알림이라도 결과가 다르면 — 단순 지연과 정합성 훼손 — 채널이든 등급이든 분리해서 울렸어야 합니다.
늑대가 진짜 왔을 때 "늑대다!"가 통하려면요.
공교롭게도 신규 채널 트래픽이 유입되기 시작한 바로 그 5월 말 부터 저는 다른 프로젝트로 잠깐 넘어가 있었습니다.
이 도메인의 발급 플로우를 만들고, 저 알림이 어떤 맥락에서 왜 심어졌는지 아는 사람이 저뿐이였고요.
나름대로 인수인계도 했지만, 지금 생각하면 턱없이 부족했습니다.
슬랙에 "이 알림이 울리면 발급이 진행 중일 가능성이 있다"고 단 한 줄 남기고 갔거든요...
그런데 슬랙 메시지는 그 순간에는 읽혀도, 두 달 뒤 알림이 울리는 순간에는 수백 개의 다른 메시지 아래로 스크롤되어 사라진 뒤였습니다.
"공급사 QR 정보 확보 최종 실패"라는 메시지에서 "이거 지금 UCL에 고아 주문이 쌓이고 있다는 뜻"을 읽어내려면 발급 플로우 전체가 머릿속에 있어야 하는데, 그 해석은 알림 옆이 아니라 흘러가버린 대화 속에 있었던 거죠.
6월 내내 모니터링은 돌고 경고는 떴어도, 그 알림은 사실상 해석자가 없는 알림이 되어 있었습니다
‼️ 여기서 얻은 인사이트를 저는 이렇게 정리했습니다
모니터링 커버리지와 모니터링 오너십은 별개의 자원이다.
알림은 "받는 사람"이 아니라 "해석할 수 있는 사람"이 있어야 알림입니다.
해석자가 없는 알림은 커버리지 지표상으로는 존재하지만 실질적으로는 꺼져 있는 것과 같습니다.
심지어 더 나쁠 수도 있어요. "모니터링 하고 있다"는 안심을 주니까....
그래서 사람이 도메인을 옮길 때는 코드 인수인계만이 아니라 알림 인수인계가 필요합니다.
그것도 흘러가는 채팅 메시지가 아니라 알림이 울렸을 때 바로 찾아볼 수 있는 자리에요
"이 알림이 울리면 → 이게 의심된다 → 여길 봐라"라는 런북 한 줄을 알림 메시지 자체나 레포 문서에 붙여두면, 두 달 뒤 알림이 울렸을 때 흘러간 대화 대신 문서가 대답해 줍니다.
그게 없으면 알림 등급을 아무리 잘 매겨도 오너가 자리를 비운 순간 시스템의 눈은 감깁니다.
(이 사건 이후 도메인 인수인계 문서와 장애 분석 문서를 레포에 남기는 것부터 시작했습니다. 사람의 기억은 휘발되지만 문서는 남으니까요🥲🥲)
수정은 일부러 두 단계로 나눴습니다.
이건 근본 해결이 아니라 실패 빈도 자체를 낮추는 지혈입니다.
재시도가 4분이면 UCL이 웬만큼 느려도 그 안에 QR이 나오니, "실패 → 재주문" 루프에 진입하는 건수가 급감합니다.
나눈 이유는 단순합니다.
2차는 없던 DB 컬럼을 추가하고 발급 플로우의 분기를 바꾸는 작업이라 잘못되면 이번엔 "발급이 아예 안 되는" 장애가 됩니다. (장애 조치라고 서두르다가 2차 장애를 내는 게 최악이니까요...)
참고로 "뒤늦게 온 외부 공급사 웹훅(콜백)에서 프로필을 저장하면 되지 않나?"도 검토했는데 보류했습니다.
콜백이 도착한 시점엔 사용자가 이미 "발급 실패" 안내를 본 뒤라, 뒤에서 조용히 저장하면 사용자 인지와 서버 상태가 어긋나고, 사용자가 재시도 중이면 콜백 저장과 재시도 플로우가 경합합니다
그래서 콜백 저장 대신 "재시도를 안전하게(기존 주문 재조회로)" 만드는 쪽을 택했습니다
멱등성은 받는 쪽(콜백)이 아니라 쏘는 쪽(재시도) 에서 확보하는 게 이 구조에선 더 단순했어요
이번 사건이 남긴 문장들입니다.
돌이켜보면 3월의 저는 "DB를 살리는 것"에만 집중했고, 그건 성공했습니다.
다만 시스템은 눌린 풍선 같아서 한쪽을 누르면 다른 쪽이 부풀어 오르더라고요....
부풀어 오를 자리를 미리 보는 눈은 이런 수업료를 내고서야 조금씩 생기는 것 같습니다.
다행이 사용안한 주문 건은 환불해주는 걸로 마무리가 되었습니다 🥲
당시에는 식은땀이 흐르고, 비싼 수업료를 지불했지만 같은 유형의 실수는 이제 코드 리뷰에서 냄새로 잡아낼 수 있게 됐으니 남는 장사라고 정신승리 중입니다ㅎㅎ(사실 안괜찮음)
긴 글 읽어주셔서 감사합니다.
비슷한 구조(외부 API + 트랜잭션 분리)를 운영하고 계신 분이라면 오늘 퇴근 전에 딱 하나만 확인해 보세요.
"외부 호출이 성공하고 그다음 단계가 실패하면 그 성공은 어디에 기록되나요?"
답이 "로그요"라면… 이 글이 도움이 되셨길 바랍니다 🙇