사용자가 결제 버튼을 눌렀습니다. 서버는 결제를 끝냈지만, 완료 응답이 돌아오기 직전에 와이파이가 끊겼습니다. 화면에는 실패처럼 보입니다. 그렇다면 같은 요청을 다시 보내야 할까요?
이 질문에는 단순한 예·아니요로 답할 수 없습니다.
확인될 때까지 다시 보내되, 서버는 같은 요청을 다시 실행하지 않아야 합니다.
전달 보장 데모에서 먼저 그냥 다시 보내기를 재생하세요. 전송 시도는 두 번이고 실제 결제도 두 번이 됩니다. 그다음 ID + ACK 적용으로 바꾸면 전송은 두 번이어도 실제 결제는 한 번만 일어납니다.
send 성공은 처리 완료가 아닙니다
소켓에 데이터를 썼다고 다음 세 가지가 모두 보장되지는 않습니다.
| 확인한 것 | 알 수 있는 범위 |
|---|---|
send 또는 write 성공 | 로컬 런타임이 데이터를 받아들임 |
| TCP ACK | 상대 TCP 스택이 해당 바이트 범위의 수신을 확인함 |
| 애플리케이션 ACK | 상대 프로그램이 약속한 단계까지 처리함 |
우리가 필요한 것은 마지막 줄입니다. 주문 서버가 order-17을 처리하고 결과를
기록했다는 애플리케이션 수준의 확인 응답을 보내야 합니다. WebSocket은 메시지를
운반하지만 업무 처리가 완료되었다는 의미의 ACK를 자동으로 만들어 주지 않습니다.
Socket.IO도 기본 메시지 도착 보장을 at most once로 설명합니다. 연결이 전송 도중 끊기면 상대가 받았는지 보장할 수 없고, 더 강한 보장은 애플리케이션에서 ACK, 재시도와 저장을 조합해야 합니다.
ACK도 하나의 메시지라서 사라질 수 있습니다
흐름을 시간순으로 보면 문제가 선명합니다.
클라이언트 서버
── PAY order-17 ───▶ 결제 실행 ✓
◀── ACK ── X 연결 끊김
── PAY order-17 ───▶ 다시 도착
클라이언트는 ACK를 못 받았기 때문에 실패라고 단정할 수도, 성공이라고 단정할 수도 없습니다. 이 상태를 결과 불명(unknown outcome)이라고 생각하면 쉽습니다. 재시도는 필요하지만 서버에 보호 장치가 없다면 같은 부작용이 다시 발생합니다.
재시도할 때 ID를 바꾸지 않습니다
첫 요청과 재시도 요청이 같은 작업임을 나타내는 messageId 또는 idempotencyKey를
붙입니다.
{
"type": "PAY",
"messageId": "order-17",
"amount": 24000
}
제한 시간이 지났다고 order-18을 새로 만들면 서버는 새 작업으로 이해합니다.
재시도 횟수와 관계없이 업무 ID는 그대로 유지해야 합니다.
서버는 ID와 결과를 함께 기억합니다
서버 처리 순서는 다음처럼 설계할 수 있습니다. 이때 1번과 2번을 단순히
조회 → 실행으로 분리하면 같은 ID 두 개가 동시에 들어왔을 때 둘 다 처음 본 요청으로
판단할 수 있습니다. ID 선점은 데이터베이스의 unique key 같은 장치로 원자적으로
처리해야 합니다.
messageId를 원자적으로 선점하거나 이미 처리되었는지 확인합니다.- 처음 선점한 처리자만 작업을 수행하고 결과를 저장합니다.
- 이미 본 ID라면 작업을 반복하지 않고 저장된 결과를 읽습니다.
- 어느 경우든 같은 의미의 ACK를 보냅니다.
의사 코드는 짧습니다.
onMessage(message):
claim = resultStore.claimUnique(message.id) // 원자적 선점
if claim.completed:
return ACK(claim.result)
if claim.ownedByAnotherWorker:
return RETRY_LATER
result = runBusinessOperation(
message,
idempotencyKey=message.id
)
resultStore.complete(message.id, result)
return ACK(result)
선점만으로 모든 문제가 끝나는 것은 아닙니다. 업무 처리와 ID 기록 사이에 서버가 죽는 경우도 고려해야 합니다. 같은 데이터베이스에서 끝낼 수 있다면 하나의 트랜잭션으로 묶고, 외부 결제처럼 별도 시스템을 호출한다면 그 시스템에도 같은 멱등성 키를 전달합니다. 필요하면 outbox 패턴을 사용해 작업은 끝났는데 결과 기록만 없는 틈을 없애거나 안전하게 복구할 수 있어야 합니다.
at-most-once와 at-least-once를 쉽게 구분하기
| 방식 | 재시도 | 가능한 결과 | 잘 맞는 예 |
|---|---|---|---|
| at most once | 보통 없음 | 유실될 수 있지만 중복 전송은 줄어듦 | 자주 갱신되는 임시 상태 |
| at least once | ACK까지 재시도 | 중복 도착 가능 | 작업 요청, 저장 이벤트 |
| 멱등 처리 | 중복 도착을 서버가 흡수 | 부작용을 한 번으로 제한 | 결제, 예약, 작업 실행 |
‘exactly once’라는 표현은 범위를 확인해야 합니다. 네트워크 패킷이 물리적으로 딱 한 번 움직였다는 뜻보다, 중복 도착을 허용하면서 업무 결과가 한 번만 생기게 했다는 뜻으로 사용되는 경우가 많습니다.
재시도 간격에도 규칙이 필요합니다
ACK가 늦다고 즉시 무한 재전송하면 서버 장애 때 트래픽이 폭발합니다.
- ACK 제한 시간을 정합니다.
- 최대 재시도 횟수를 둡니다.
- 지수 백오프와 지터로 재시도 시점을 분산합니다.
- 끝내 확인되지 않은 요청은 실패 큐나 사용자 확인 상태로 보냅니다.
- 서버의 중복 제거 기록은 가능한 재시도 기간보다 오래 보관합니다.
클라이언트 새로고침까지 견뎌야 한다면 메모리 큐만으로는 부족합니다. 보낼 메시지와 마지막 확인 offset을 디스크나 데이터베이스에 저장해야 합니다.
데모에서 꼭 비교할 숫자
나쁜 결과와 안전한 결과를 비교하세요
ACK가 사라진 결제 요청을 재생합니다
보는 순서① 보호 없음 선택→② 단계 실행→③ 실행 횟수 비교
- 1요청
- 2실행
- 3재전송
- 4중복
그냥 다시 보내기 시나리오입니다. 실험 시작을 눌러 네 장면을 따라가세요.
화면에서 코드로 옮길 때
이 편의 원리 세 가지
send가 성공해도 상대 프로그램의 처리가 끝났다는 뜻은 아닙니다.
새 ID로 다시 보내면 서버는 새로운 작업으로 이해합니다.
처리한 ID와 결과를 저장해야 같은 요청에 같은 답을 돌려줄 수 있습니다.
구현 체크리스트
최소 네 조각으로 시작하세요.
데모의 시각 요소를 실제 프로토콜 필드와 런타임 동작으로 옮기면 됩니다.
- 1메시지 ID비즈니스 작업마다 고유하고 재시도 중에는 변하지 않는 ID
- 2ACK 제한 시간무한 대기하지 않고 재시도를 시작할 기준 시간
- 3중복 제거 저장소ID, 처리 상태와 이전 결과를 함께 보관
- 4재시도 정책최대 횟수, 지수 백오프와 실패 큐
데모만 따로 보려면 전체 화면으로 열기.
- 그냥 다시 보내기를 끝까지 실행합니다.
전송 시도 2회와실제 결제 2회를 확인합니다.- ID + ACK 적용으로 바꿉니다.
- 이번에는
전송 시도 2회,실제 결제 1회인지 확인합니다. - 두 시나리오 모두 ACK가 한 번 사라졌다는 조건은 같다는 점을 봅니다.
중복 제거 기록은 언제까지 들고 있어야 할까
처리한 메시지 ID 를 기억해 둔다는 원칙은 간단하지만, 실제로 구현할 때 바로 막히는 질문이 하나 있습니다. 그 기록을 언제 지울 것인가.
영원히 들고 있으면 저장소가 무한히 커집니다. 반대로 너무 일찍 지우면 늦게 도착한 재시도가 새 요청으로 처리돼 중복이 그대로 생깁니다. 기록을 지우는 순간이 곧 중복 방지가 풀리는 순간입니다.
기준을 잡는 방법은 보통 이렇습니다. 클라이언트가 재시도를 포기하기까지 걸리는 최대 시간을 먼저 정하고, 기록은 그보다 넉넉히 오래 남깁니다. 재시도가 최대 5 분 동안 이어질 수 있다면 기록은 최소 그 이상 살아 있어야 합니다. 여기에 시계 차이와 네트워크에서 늦게 도착하는 경우를 감안해 여유를 더 둡니다.
서버가 여러 대라면 문제가 하나 더 붙습니다. 재시도가 처음과 다른 서버로 갈 수 있기 때문에, 기록은 서버 각자의 메모리가 아니라 모두가 함께 보는 저장소에 있어야 합니다. 한 대로 돌릴 때는 드러나지 않다가 서버를 늘리는 순간 중복이 생기기 시작하는 문제가 여기서 나옵니다.
따로 떼면 안 되는 다섯 가지
- 소켓 쓰기 성공과 상대 애플리케이션의 처리 완료는 다릅니다.
- 결과를 모를 때는 같은 메시지 ID로 재전송합니다.
- 서버는 처리한 ID와 결과를 기억해 중복 부작용을 막습니다.
- ACK, 제한 시간, 재시도, 중복 제거와 저장소는 하나의 설계 묶음입니다.
다음 편에서는 메시지 한두 개가 아니라 큰 파일을 보냅니다. 흐름 제어와 파일 전송에서 수신자가 느릴 때 왜 송신자가 멈춰야 하는지 이어서 확인할 수 있습니다.