죽은 편지함 — 실패한 이벤트는 어디로 가나

2025-10-10

이벤트로 서비스를 잇고 나면 곧 이런 질문에 부딪힙니다. 소비자가 처리에 실패하면 그 이벤트는 어떻게 되지?

지금까지 우리 코드의 답은 단순했습니다 — Nak, 즉 "다시 보내줘". 그리고 그게 전부였습니다. 만들어 놓고 보니 이게 마음에 걸렸습니다. 영원히 실패하는 메시지가 들어오면? 브로커는 그 메시지를 계속 다시 보내고, 소비자는 계속 실패하고, 그 사이 뒤의 멀쩡한 이벤트들은 뒤에서 기다립니다. 하나의 독성 메시지(poison message) 가 소비자를 통째로 마비시킵니다.

💻 이 편의 코드는 github.com/kahnco/go-ddd-shoppart-22 태그에 있습니다.

실패에는 두 종류가 있습니다

무한 재전송이 왜 나쁜지는, 실패를 두 가지로 나눠 보면 분명해집니다.

  • 일시적(transient) 실패: DB 가 잠깐 끊겼다, 하류 서비스가 순간 과부하였다. → 재시도하면 성공 합니다. 이건 재전송이 옳습니다.
  • 영구적(permanent) 실패: 이벤트 데이터가 깨졌다, 처리 코드에 버그가 있다. → 몇 번을 다시 보내도 실패 합니다. 무한 재시도는 소비자만 마비시킵니다.

무한 Nak 은 이 둘을 구분하지 못합니다. 그래서 두 축이 필요합니다 — 일시적 실패를 위한 재시도, 그리고 영구적 실패를 위한 포기(格리).

축 1 — 지수 백오프로 재시도

먼저 재시도입니다. 다만 즉시 재시도하면 안 됩니다. 하류가 과부하로 넘어졌는데 곧바로 또 때리면 회복을 방해합니다. 그래서 지수 백오프 — 재시도 간격을 점점 벌립니다(1초 → 2초 → 5초 → 10초).

JetStream 은 이걸 소비자 설정으로 지원합니다. MaxDeliver(최대 시도 횟수)와 BackOff(재전송 간격)를 주면, 브로커가 알아서 그 스케줄대로 다시 보냅니다.

_, err := b.js.QueueSubscribe(subject, group, handleMsg,
	nats.Durable(durableName(group, subject)),
	nats.ManualAck(),
	nats.MaxDeliver(b.retry.maxDeliver),   // 예: 5회까지
	nats.BackOff(b.retry.backoff),         // 1s, 2s, 5s, 10s
)

일시적 장애는 대개 이 안에서 흡수됩니다. DB 가 3초 끊겼다가 살아나면, 두 번째나 세 번째 재시도에서 조용히 성공합니다.

재시도는 중복을 부릅니다. 같은 이벤트가 두 번, 세 번 배달되죠. 그래서 소비자는 멱등 해야 합니다 — 같은 이벤트를 두 번 처리해도 결과가 같아야 합니다. 다행히 이건 4편(아웃박스와 멱등성)에서 이미 봉투 ID 로 갖춰 뒀습니다. 재시도와 멱등성은 한 쌍입니다.

축 2 — 다 쓰면 죽은 편지함으로

재시도를 다 썼는데도 실패한다면, 그건 영구적 실패로 봐야 합니다. 여기서 선택지는 셋입니다.

  1. 계속 재시도 → 소비자 마비(지금까지의 문제).
  2. 그냥 버림(Ack) → 조용히 데이터 유실. 최악입니다.
  3. 죽은 편지함(DLQ)으로 격리 → 잃지도, 막히지도 않음. ✅

세 번째를 택합니다. 마지막 시도까지 실패하면, 그 메시지를 dlq.<원래-subject> 로 보내 별도 스트림에 보관하고, 원본은 Ack 해서 재전송 고리를 끊습니다.

err := handler(env)
if err == nil {
	m.Ack()
	return
}
attempt := deliveryCount(m) // 지금 몇 번째 전달인가
if attempt < b.retry.maxDeliver {
	m.Nak() // 아직 여유 있음 → 재시도(백오프 스케줄대로)
	return
}
// 마지막 시도까지 실패 = 독성 메시지. DLQ 로 보내고 Ack.
if derr := b.publishDeadLetter(subject, group, attempt, err, env); derr != nil {
	m.Nak() // DLQ 발행이 실패하면 Ack 하지 않는다 — 잃느니 다시 시도한다
	return
}
m.Ack()

여기서 순서가 중요합니다. DLQ 발행이 성공한 뒤에만 원본을 Ack 합니다. DLQ 발행이 실패했는데 Ack 해 버리면 메시지가 증발합니다. 그래서 발행 실패 시엔 Nak 으로 되돌려, 다음 기회에 다시 시도합니다. "잃느니 다시" 가 원칙입니다.

죽은 편지에는 부검 기록을 남깁니다

DLQ 에 원본만 던져 두면, 나중에 열어 봐도 "왜 죽었는지" 를 모릅니다. 그래서 봉투에 부검 기록 을 덧붙입니다.

type DeadLetter struct {
	Subject  string   `json:"subject"`  // 원래 어디로 갔던 이벤트인가
	Group    string   `json:"group"`    // 어느 소비자가 실패했나
	Attempts int      `json:"attempts"` // 몇 번 시도했나
	Error    string   `json:"error"`    // 마지막 실패 원인
	Event    Envelope `json:"event"`    // 원본 봉투(그대로 재투입 가능)
}

이 다섯 조각이면 사후에 원인을 추적하고, 고친 뒤 원래 자리로 되돌릴(replay) 수 있습니다.

func (b *Bus) Redeliver(dl DeadLetter) error {
	return b.Publish(dl.Subject, dl.Event) // 원래 subject 로 다시 발행 → 정상 흐름 재진입
}

조용히 쌓이면 "다 잘됨" 으로 착각합니다

DLQ 의 함정은, 아무도 안 보면 아무 일도 없는 것처럼 보인다 는 겁니다. 실패가 조용히 격리되니까요. 그래서 죽은 편지함을 지켜보는 눈 이 있어야 합니다. readmodel 서비스에 DLQ 모니터를 붙여, 죽은 편지가 생길 때마다 경고 로그를 남깁니다.

bus.SubscribeDLQ("readmodeldlq", func(dl eventbus.DeadLetter) error {
	logger.Warn("죽은 편지(DLQ) — 처리 포기된 이벤트",
		"subject", dl.Subject, "attempts", dl.Attempts, "error", dl.Error)
	return nil
})

운영에서는 여기에 알림(Slack·PagerDuty)대시보드 를 겁니다. "DLQ 유입률" 은 시스템 건강의 핵심 지표라, 다음 편(메트릭·SLO)에서 이런 걸 그래프로 세웁니다.

정직하게 — 공짜는 없습니다

  • 순서가 흔들립니다. 한 메시지가 재시도로 뒤처지는 동안 뒤 메시지가 먼저 처리됩니다. 우리 쇼핑몰은 이벤트마다 멱등하고 순서 의존이 약해 괜찮지만, 순서가 중요하면 키별 파티셔닝이나 단일 소비자로 좁혀야 합니다.
  • 형식이 깨진 메시지는 재시도 대상이 아닙니다. JSON 파싱조차 안 되는 건 몇 번을 보내도 똑같으니, 재시도·DLQ 를 거치지 않고 즉시 Term(버림) 합니다 — 이건 원래부터 그랬습니다.
  • DLQ 도 방치하면 무덤이 됩니다. 유입 알림과 정기적인 재처리·정리가 없으면, 조용히 유실과 다를 바 없어집니다.
  • 재시도 횟수·간격은 하류 특성에 맞춰야 합니다. 빨리 회복되는 의존성엔 짧게, 느린 외부 결제 게이트웨이엔 길게.

정리 — 실패를 설계하기

  • 이벤트 처리 실패를 일시적영구적 으로 나눠 다룹니다.
  • 일시적 실패는 지수 백오프 재시도(MaxDeliver + BackOff)로 흡수합니다 — 모든 기존 소비자가 코드 한 줄 안 바꾸고 이걸 얻었습니다.
  • 영구적 실패는 죽은 편지함(DLQ) 으로 격리합니다 — 잃지도, 막히지도 않게. 부검 기록을 남기고, Redeliver 로 되돌립니다.
  • DLQ 는 지켜봐야 합니다. 조용히 쌓이는 실패는 실패가 아닌 척하니까요.

핵심은 이겁니다 — 실패는 예외가 아니라 설계 대상 입니다. "언젠가 실패한다" 를 전제로 재시도와 격리를 미리 심어 두면, 독성 메시지 하나에 시스템 전체가 흔들리지 않습니다.

이번 편 전체 코드는 리포의 part-22 태그에 있습니다.