
RabbitMQ를 실제로 쓰다 보면 메시지를 보내는 것보다 더 중요한 게 있다.
바로 실패 처리다.
처음에는 Producer가 메시지를 넣고, Consumer가 가져가서 처리하면 끝이라고 생각했다.
Producer
↓
RabbitMQ
↓
Consumer
그런데 실제 서비스에서는 Consumer가 항상 성공하지 않는다.
DB 저장 중 에러가 날 수 있다.
외부 API가 잠깐 죽어 있을 수 있다.
메시지 포맷이 잘못됐을 수도 있다.
Consumer가 처리 중간에 죽을 수도 있다.
이때 메시지를 어떻게 할지가 중요하다.
RabbitMQ에서 이 부분을 이해하려면 먼저 ack를 알아야 한다.
Ack는 처리 완료 신호다
Consumer가 Queue에서 메시지를 가져갔다고 해서 RabbitMQ가 바로 메시지를 지워버리면 위험하다.
예를 들어 Consumer가 메시지를 받자마자 RabbitMQ에서 메시지가 사라졌다고 해보자.
Consumer가 메시지 수신
↓
RabbitMQ에서 메시지 제거
↓
Consumer 처리 중 장애
↓
메시지 유실
이러면 메시지가 사라진다.
메일 발송, 포인트 지급, 결제 후처리 같은 작업이라면 꽤 위험하다.
그래서 RabbitMQ는 Consumer가 명시적으로 ack를 보낼 수 있게 한다.
Consumer가 메시지 수신
↓
작업 처리
↓
성공하면 ack
↓
RabbitMQ가 메시지 제거
즉 ack는 “이 메시지는 정상 처리했으니 지워도 된다”는 신호다.
Go 코드로 보면 대충 이런 느낌이다.
err := processMessage(msg)
if err != nil {
// 실패 처리
return
}
msg.Ack(false)
처리가 성공했을 때만 ack를 보낸다.
Auto Ack는 조심해야 한다
RabbitMQ Consumer를 만들 때 auto ack를 켤 수 있다.
auto ack를 켜면 Consumer가 메시지를 받는 순간 RabbitMQ는 처리 완료로 본다.
처음에는 편해 보인다.
ack를 직접 안 보내도 되니까 코드가 단순하다.
그런데 문제가 있다.
Consumer가 메시지 수신
↓
RabbitMQ는 처리 완료로 판단
↓
Consumer 처리 중 장애
↓
메시지는 이미 사라짐
그래서 중요한 작업이라면 auto ack는 조심해야 한다.
메일 발송처럼 한 번 실패해도 다시 보내면 되는 작업도 있고,
포인트 지급처럼 중복 처리까지 조심해야 하는 작업도 있다.
이런 작업은 보통 manual ack 방식이 낫다.
메시지 수신
↓
처리 성공
↓
ack
처리가 끝나기 전까지는 RabbitMQ가 메시지를 완전히 지우지 않는다.
실패하면 Nack 또는 Reject
Consumer가 메시지를 처리하다가 실패할 수 있다.
이때 선택지가 있다.
ack : 성공 처리
nack : 실패 처리
reject : 거부 처리
실패했을 때 메시지를 다시 Queue에 넣을 수도 있고, 버릴 수도 있다.
Go 코드로 보면 이런 느낌이다.
err := processMessage(msg)
if err != nil {
msg.Nack(false, true) // requeue = true
return
}
msg.Ack(false)
여기서 requeue = true면 메시지를 다시 Queue에 넣는다.
처리 실패
↓
Nack requeue true
↓
Queue로 다시 들어감
↓
다른 Consumer 또는 같은 Consumer가 다시 처리
처음에는 이게 좋아 보인다.
실패하면 다시 시도하면 되니까.
그런데 항상 좋은 건 아니다.
메시지 자체가 잘못된 경우라면 계속 실패한다.
잘못된 메시지
↓
Consumer 처리 실패
↓
다시 Queue
↓
또 실패
↓
다시 Queue
↓
또 실패
이렇게 되면 무한 재시도에 가까운 상황이 생긴다.
Consumer가 계속 같은 메시지에 붙잡히고, 다른 메시지 처리까지 밀릴 수 있다.
그래서 retry 전략이 필요하다.
재시도는 무조건 하면 안 된다
처리 실패에도 종류가 있다.
잠깐 뒤에 다시 하면 성공할 수 있는 실패가 있다.
- 외부 API 일시 장애
- DB connection 일시 문제
- 네트워크 타임아웃
반대로 다시 해도 계속 실패할 가능성이 높은 실패가 있다.
- 메시지 JSON 포맷 오류
- 필수 필드 누락
- 존재하지 않는 사용자 ID
- 비즈니스 규칙 위반
둘을 똑같이 재시도하면 안 된다.
일시적인 에러는 retry가 도움이 된다.
하지만 메시지 자체가 잘못된 경우는 retry해도 계속 실패한다.
그래서 실패 유형을 나눠야 한다.
일시적 실패 → 재시도
영구적 실패 → DLQ로 이동 또는 로그 남기고 종료
처음에는 실패하면 전부 requeue하면 될 줄 알았다.
그런데 그렇게 하면 poison message가 생긴다.
poison message는 계속 실패하면서 Queue를 괴롭히는 메시지다.
한두 개면 괜찮지만 많아지면 Consumer 처리량이 확 떨어진다.
Dead Letter Queue가 필요한 이유
그래서 DLQ, Dead Letter Queue가 나온다.
DLQ는 정상 Queue에서 처리하지 못한 메시지를 따로 보내는 Queue다.
main_queue
↓
Consumer 처리 실패
↓
retry 초과 또는 reject
↓
dead_letter_queue
DLQ에 들어간 메시지는 바로 사라지지 않는다.
나중에 확인하거나, 수정해서 다시 넣거나, 별도 Consumer로 처리할 수 있다.
이 구조를 두면 실패 메시지가 정상 메시지 흐름을 계속 방해하지 않는다.
정상 메시지 → 계속 처리
문제 메시지 → DLQ로 분리
이게 실무에서 꽤 중요하다.
예를 들어 메일 발송 Queue가 있다고 해보자.
email_queue
여기에 잘못된 이메일 주소나 필수 데이터가 빠진 메시지가 들어오면 계속 실패할 수 있다.
이 메시지를 계속 requeue하면 정상 메일도 밀린다.
DLQ를 두면 문제 메시지만 따로 뺄 수 있다.
email_queue
↓ 실패
email_dlq
이후 운영자는 email_dlq를 보고 어떤 메시지가 실패했는지 확인하면 된다.
Retry Queue를 따로 두는 방식
재시도를 바로 하는 것도 문제다.
외부 API가 장애라면 지금 당장 다시 해도 또 실패할 가능성이 높다.
그럴 때는 잠깐 기다렸다가 다시 시도하는 게 낫다.
그래서 retry queue를 따로 두는 방식도 많이 쓴다.
main_queue
↓ 실패
retry_queue
↓ 일정 시간 대기
main_queue로 다시 전달
흐름은 이런 식이다.
1. Consumer가 main_queue 메시지 처리
2. 실패
3. retry_queue로 이동
4. TTL 동안 대기
5. 다시 main_queue로 이동
6. 재처리
이렇게 하면 실패 메시지가 즉시 다시 들어오지 않는다.
잠깐 시간을 두고 재시도할 수 있다.
다만 여기서도 재시도 횟수를 제한해야 한다.
1차 실패 → 10초 뒤 재시도
2차 실패 → 1분 뒤 재시도
3차 실패 → 5분 뒤 재시도
최종 실패 → DLQ
이런 식으로 설계할 수 있다.
중복 처리도 생각해야 한다
RabbitMQ를 쓰다 보면 메시지가 정확히 한 번만 처리된다고 기대하기 쉽다.
그런데 실제로는 중복 처리를 고려해야 한다.
예를 들어 Consumer가 작업을 성공적으로 끝냈는데, ack를 보내기 전에 죽었다고 해보자.
Consumer 메시지 수신
↓
DB 저장 성공
↓
ack 보내기 전 장애
↓
RabbitMQ는 처리 실패로 판단
↓
메시지 재전달
그러면 같은 메시지가 다시 처리될 수 있다.
그래서 Consumer 작업은 가능하면 idempotent하게 만들어야 한다.
idempotent는 같은 요청을 여러 번 처리해도 결과가 달라지지 않게 만드는 것이다.
예를 들어 포인트 지급 메시지가 있다고 해보자.
{
"event_id": "evt_123",
"user_id": 10,
"point": 1000
}
이 메시지를 처리할 때 단순히 포인트를 더하면 위험하다.
첫 처리: +1000
중복 처리: +1000
결과: +2000
그래서 event_id를 저장해두고 이미 처리한 메시지인지 확인할 수 있다.
event_id가 이미 처리됨
↓
다시 포인트 지급하지 않음
↓
ack
이런 식으로 중복 처리를 막아야 한다.
RabbitMQ를 쓸 때 “메시지는 한 번만 올 거야”라고 생각하면 위험하다.
오히려 “같은 메시지가 다시 올 수도 있다”고 보고 설계하는 게 안전하다.
Prefetch도 같이 보면 좋다
Consumer가 한 번에 너무 많은 메시지를 가져가면 문제가 생길 수 있다.
예를 들어 Consumer 하나가 메시지 1000개를 한 번에 가져갔다고 해보자.
아직 처리도 안 했는데 다른 Consumer는 놀고 있을 수 있다.
그래서 prefetch 값을 조절한다.
Consumer가 한 번에 가져갈 수 있는 미확인 메시지 수 제한
예를 들어 prefetch를 10으로 잡으면 Consumer는 ack 하지 않은 메시지를 최대 10개까지만 들고 있을 수 있다.
prefetch = 10
Consumer는 최대 10개까지만 미처리 상태로 보유
이렇게 하면 Consumer 사이에 작업이 조금 더 고르게 분산된다.
처리 시간이 긴 작업이라면 prefetch 값을 너무 크게 잡지 않는 게 좋다.
반대로 아주 짧은 작업이라면 조금 더 크게 잡을 수도 있다.
정답은 없고, 메시지 처리 시간과 Consumer 개수에 따라 조정해야 한다.
Go에서 Ack 흐름은 이런 느낌
Go에서 RabbitMQ를 쓴다면 흐름은 대략 이렇게 볼 수 있다.
for msg := range messages {
err := processMessage(msg.Body)
if err != nil {
// 재시도할 에러인지 판단 필요
msg.Nack(false, false)
continue
}
msg.Ack(false)
}
여기서 중요한 건 실패했다고 무조건 requeue = true로 보내지 않는 것이다.
msg.Nack(false, true)
이렇게 하면 다시 Queue로 들어간다.
일시적인 실패에는 쓸 수 있지만, 메시지 자체가 잘못된 경우에는 무한 반복이 될 수 있다.
그래서 실제로는 에러 타입을 나눠야 한다.
err := processMessage(msg.Body)
if err != nil {
if isRetryable(err) {
// retry queue로 보내거나 requeue
msg.Nack(false, true)
return
}
// 재시도해도 의미 없는 에러라면 DLQ로 보내도록 reject/nack
msg.Nack(false, false)
return
}
msg.Ack(false)
물론 DLQ로 보내려면 Queue 설정에서 Dead Letter Exchange를 연결해두어야 한다.
정리하면
RabbitMQ에서 메시지 발행은 생각보다 어렵지 않다.
Producer가 메시지를 보내고, Consumer가 가져가면 된다.
하지만 실제 서비스에서는 실패 처리가 더 중요하다.
처리 성공 → ack
처리 실패 → nack 또는 reject
일시적 실패 → retry
반복 실패 → DLQ
중복 가능성 → idempotency 처리
처음에는 RabbitMQ를 단순한 비동기 처리 도구로 봤다.
그런데 써보면 메시지를 “잘 보내는 것”보다 “실패했을 때 안전하게 다루는 것”이 더 중요했다.
특히 auto ack를 켜고 쓰면 편하긴 하지만, 중요한 작업에서는 메시지 유실 위험이 생길 수 있다.
manual ack를 쓰면 코드가 조금 더 길어지지만, 처리 성공 시점과 메시지 삭제 시점을 직접 제어할 수 있다.
DLQ도 처음에는 조금 과하게 느껴질 수 있다.
하지만 운영하다 보면 실패 메시지를 따로 모아두는 게 얼마나 편한지 알게 된다.
정상 메시지는 계속 흐르게 두고, 문제 메시지는 따로 빼서 확인한다.
이 구조가 있어야 Consumer가 특정 메시지 하나에 계속 붙잡히지 않는다.
RabbitMQ 3편까지 정리하면 흐름은 이렇게 볼 수 있다.
1탄: RabbitMQ를 왜 쓰는지
2탄: Exchange와 Queue로 메시지가 어떻게 이동하는지
3탄: 실패한 메시지를 어떻게 처리하는지
RabbitMQ는 비동기 처리, 서비스 분리, 실패 처리, 재시도, 운영 모니터링까지 같이 봐야 제대로 쓸 수 있다.
처음부터 모든 걸 완벽하게 설계하기는 어렵다.
그래도 ack, retry, DLQ만 제대로 이해해도 RabbitMQ를 훨씬 안전하게 다룰 수 있다.
'Infra' 카테고리의 다른 글
| RabbitMQ를 이해해보자, Exchange와 Queue가 헷갈렸던 이유 (2) (0) | 2026.05.09 |
|---|---|
| RabbitMQ를 이해해보자, 메시지 큐를 왜 쓰는 걸까? (1) (0) | 2026.05.09 |
| HAProxy로 무중단 배포를 이해해보기 (0) | 2026.05.08 |
| 리눅스 netstat과 ss 차이 (0) | 2026.05.06 |
| Redis Sentinel 정리, Failover와 Redis Cluster 차이 이해하기 (2) (0) | 2026.05.04 |