idea·blog
웹 도구소켓 응용 5단계 · 1/5

소켓 응용 1/5 · 전달 보장 — 끊겨도 메시지를 잃지 않으려면

ACK가 사라진 요청을 안전하게 재전송하고, 메시지 ID와 중복 제거로 실제 작업은 한 번만 실행되게 만드는 방법을 배웁니다.

2026.07.20작성 idea-blog 운영자· 약 5
소켓 응용 1/5 · 전달 보장 — 끊겨도 메시지를 잃지 않으려면 스크린샷

사용자가 결제 버튼을 눌렀습니다. 서버는 결제를 끝냈지만, 완료 응답이 돌아오기 직전에 와이파이가 끊겼습니다. 화면에는 실패처럼 보입니다. 그렇다면 같은 요청을 다시 보내야 할까요?

이 질문에는 단순한 예·아니요로 답할 수 없습니다.

확인될 때까지 다시 보내되, 서버는 같은 요청을 다시 실행하지 않아야 합니다.

전달 보장 데모에서 먼저 그냥 다시 보내기를 재생하세요. 전송 시도는 두 번이고 실제 결제도 두 번이 됩니다. 그다음 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 같은 장치로 원자적으로 처리해야 합니다.

  1. messageId를 원자적으로 선점하거나 이미 처리되었는지 확인합니다.
  2. 처음 선점한 처리자만 작업을 수행하고 결과를 저장합니다.
  3. 이미 본 ID라면 작업을 반복하지 않고 저장된 결과를 읽습니다.
  4. 어느 경우든 같은 의미의 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 onceACK까지 재시도중복 도착 가능작업 요청, 저장 이벤트
멱등 처리중복 도착을 서버가 흡수부작용을 한 번으로 제한결제, 예약, 작업 실행

‘exactly once’라는 표현은 범위를 확인해야 합니다. 네트워크 패킷이 물리적으로 딱 한 번 움직였다는 뜻보다, 중복 도착을 허용하면서 업무 결과가 한 번만 생기게 했다는 뜻으로 사용되는 경우가 많습니다.

재시도 간격에도 규칙이 필요합니다

ACK가 늦다고 즉시 무한 재전송하면 서버 장애 때 트래픽이 폭발합니다.

  • ACK 제한 시간을 정합니다.
  • 최대 재시도 횟수를 둡니다.
  • 지수 백오프와 지터로 재시도 시점을 분산합니다.
  • 끝내 확인되지 않은 요청은 실패 큐나 사용자 확인 상태로 보냅니다.
  • 서버의 중복 제거 기록은 가능한 재시도 기간보다 오래 보관합니다.

클라이언트 새로고침까지 견뎌야 한다면 메모리 큐만으로는 부족합니다. 보낼 메시지와 마지막 확인 offset을 디스크나 데이터베이스에 저장해야 합니다.

데모에서 꼭 비교할 숫자

  1. 그냥 다시 보내기를 끝까지 실행합니다.
  2. 전송 시도 2회실제 결제 2회를 확인합니다.
  3. ID + ACK 적용으로 바꿉니다.
  4. 이번에는 전송 시도 2회, 실제 결제 1회인지 확인합니다.
  5. 두 시나리오 모두 ACK가 한 번 사라졌다는 조건은 같다는 점을 봅니다.

요약

  • 소켓 쓰기 성공과 상대 애플리케이션의 처리 완료는 다릅니다.
  • 결과를 모를 때는 같은 메시지 ID로 재전송합니다.
  • 서버는 처리한 ID와 결과를 기억해 중복 부작용을 막습니다.
  • ACK, 제한 시간, 재시도, 중복 제거와 저장소는 하나의 설계 묶음입니다.

다음 편에서는 메시지 한두 개가 아니라 큰 파일을 보냅니다. 흐름 제어와 파일 전송에서 수신자가 느릴 때 왜 송신자가 멈춰야 하는지 이어서 확인할 수 있습니다.

자료: Socket.IO Delivery guarantees, RFC 6455 — The WebSocket Protocol


관련 글