FDE 캡스톤: 창고가 같은 주문을 세 번 받았다 · 응답 너머의 업무 상태 · 이론
응답이 사라졌는데 주문은 살아 있다
한 줄 요약
요청 실패와 업무 실패는 같지 않다. 같은 업무 키로 재시도하고, 응답과 원장을 대조해야 중복 예약을 구분할 수 있다.
왜 이게 필요했나
창고 API에 예약을 보냈다. 화면에는 연결 오류가 떴다. 이때 다시 보내면 안전할까? 연결이 예약 저장 전에 끊겼다면 다시 보내야 예약이 생긴다. 반대로 저장은 끝났는데 응답만 사라졌다면, 아무 보호 없이 다시 보내는 순간 두 예약이 생길 수 있다. 클라이언트에서 보이는 오류 문장은 비슷하지만 서버 안의 업무 상태는 정반대다.
이번 가상 창고는 이 차이를 실제로 만든다. ORD-BEFORE의 첫 요청은 저장 전에 503을 반환한다. ORD-AFTER의 첫 요청은 SQLite에 예약을 커밋한 다음 HTTP 응답 없이 연결을 닫는다. 두 상황을 그저 실패 플래그 하나로 처리하면 복구를 잘못 설계하게 된다. 로그를 읽는 것으로 끝내지 않고, 여러분이 만든 프로그램을 두 상황에 직접 통과시키는 이유다.
어떻게 동작하나
전송 API는 POST /reservations다. JSON에는 order_id·sku·quantity만 들어간다. 별도의 Idempotency-Key 헤더로 같은 업무 시도를 식별한다. 이 코스에서는 주문 번호를 키로 사용한다. API와 클라이언트는 다음 세 경우를 함께 약속한다.
| 요청 | 창고의 판단 | 응답 |
| --- | --- | --- |
| 처음 보는 키와 유효한 본문 | 새 예약 저장 | 201, 예약 ID |
| 같은 키와 같은 본문 | 기존 예약 반환 | 200, 기존 예약 ID |
| 같은 키와 달라진 본문 | 기존 업무와 충돌 | 409, 새 예약 없음 |
키를 매번 새로 만들면 재시도가 모두 새로운 업무로 보인다. 메모리 안의 집합에만 처리한 키를 저장해도 프로세스가 재시작하면 기억이 사라진다. 이번 서버는 키와 본문, 예약을 같은 SQLite 트랜잭션에 저장하며 키의 유일성 제약을 사용한다. 애플리케이션의 “먼저 조회하고 없으면 쓰기”만으로는 동시에 들어온 두 요청이 둘 다 빈 상태를 볼 수 있다. 데이터베이스가 최종 중복 판정을 함께 지켜야 한다.
본문 전체의 해시를 키로 쓰면 어떨까? 같은 주문의 수량이 바뀌는 순간 해시도 바뀐다. 서버는 이를 다른 키로 보고 새 예약을 만든다. 이것은 충돌 해결이 아니라 충돌 검사를 피해 간 것이다. 고객이 주문 변경을 허용하려면 변경 API와 버전 규칙을 별도로 합의해야 한다. 이 연습에서는 409를 보류하고 기존 예약을 유지한다.
클라이언트에는 요청별 1초 제한을 두고 503과 연결 오류에만 최대 네 번의 POST 시도를 허용한다. 재시도 사이는 0.1초 기다린다. 409나 계약 오류는 기다려도 요청의 뜻이 달라지지 않으므로 중단한다. 이 값들은 짧은 교육용 검증을 위한 정책이다. 운영에서는 상대편의 제한, 지연 분포, 전체 처리 기한과 부하를 고려해 별도로 정한다. 모든 서비스에 네 번을 권장하는 것이 아니다.
응답을 받았다는 것만으로 confirmed를 기록하지 않는다. GET /orders/<주문 번호>로 업무 결과를 조회하고, 정확히 한 예약인지, SKU와 수량이 맞는지, POST에서 받은 ID가 있다면 조회 ID와 같은지도 본다. 세 예약이 있으면 첫 항목만 고르고 성공이라 쓰면 안 된다. 조회에 실패하면 ID를 한 번 받았더라도 이번 계약의 확인 조건을 충족하지 못했으므로 unconfirmed와 null을 남긴다.
상태 이름도 구분한다. confirmed는 정한 기준으로 결과를 확인했다는 뜻, unconfirmed는 아직 확인하지 못했다는 뜻이다. rejected는 409나 계약 오류로 요청이 거부됐다는 뜻이다. unconfirmed를 “예약 없음”으로 번역하면 다음 담당자가 새 키로 재전송할 위험이 있다. 관찰하지 못한 것은 관찰하지 못했다고 남겨야 한다.
두 번 보냈는데 왜 한 번만 예약됐을까
ORD-AFTER를 시간 순서로 따라가 보자. 첫 POST에서 서버는 키와 본문을 저장하고 예약 ID를 만든다. 그 다음 연결을 닫았으므로 클라이언트에는 ID가 도착하지 않는다. 두 번째 POST는 같은 키와 같은 본문이어서 새 예약을 만들지 않고 기존 ID를 돌려준다. 마지막 GET은 그 주문에 한 예약만 있는지 확인한다. 보고서에는 attempts=2지만 실제 원장의 예약은 한 개다. 두 수치가 다른 것은 오류가 아니라 재시도와 업무 효과를 나누어 세었기 때문이다.
ORD-BEFORE도 POST를 두 번 하지만 첫 요청 때 원장은 비어 있다. 두 번째 요청에서 처음으로 예약이 생긴다. 최종 예약 개수만 보면 두 사례는 같으므로, 오류 주입 시험은 저장 시점도 구분해야 한다. 반면 재시도 키를 바꾸는 오답은 ORD-BEFORE에서는 우연히 잘 돌아갈 수 있어도 ORD-AFTER에서는 두 예약을 만든다. 한 사례의 성공만으로 복구 전략을 검증했다고 하면 안 되는 이유다.
현장에서 만나는 모습
동일 파일 재처리는 예상 밖 사고가 아니라 흔한 업무 흐름이다. 전송 담당자가 바뀌거나 작업이 중단되면 같은 파일이 다시 들어온다. 그래서 검증도 첫 실행만 보지 않는다. 서버 프로세스를 재시작하되 같은 원장을 유지한 뒤 같은 입력을 다시 보내고, 예약 개수뿐 아니라 ID까지 유지되는지 확인한다. “오류 없이 종료했다”보다 업무 불변식을 검사하는 편이 이 질문에 직접 답한다.
이 연습은 단일 창고의 단일 SQLite 원장이다. 분산 트랜잭션이나 여러 창고 간 재고 일치, 키 만료 뒤 재전달을 해결하지 않는다. 이 한계를 인계 문서에 남기는 것도 구현의 일부다.
다음에 확인할 것
다음 퀴즈에서는 응답 유실·재시작·내용 변경을 서로 다른 상황으로 판정한다. 구현할 때도 오류 종류, 실제 시도 횟수, 업무 결과를 별개 변수로 두면 무엇을 알고 무엇을 모르는지 설명하기 쉽다.