RabbitMQ Prefetch와 Concurrency: 처리량을 높이기 전에 확인해야 할 것들
RabbitMQ Consumer의 처리량이 부족할 때 가장 먼저 검토하는 설정은 대체로 다음 두 가지다.
spring.rabbitmq.listener.simple:
concurrency: 10
prefetch: 250
concurrency를 늘리면 여러 Consumer가 메시지를 병렬로 처리하고, prefetch를 높이면 Consumer가 메시지를 미리 가져와 대기 시간을 줄일 수 있다.
따라서 두 값을 높이면 처리량도 자연스럽게 증가할 것처럼 보인다.
하지만 실제 운영에서는 다음 문제가 함께 발생한다.
- 메시지 처리 순서가 달라진다.
- 처리 중 장애가 발생하면 메시지가 다시 전달될 수 있다.
- Consumer별 처리 속도 차이로 메시지가 한쪽에 몰릴 수 있다.
- 너무 많은 메시지가 Consumer 메모리에 대기할 수 있다.
- DB Connection Pool이나 외부 API가 병목이 될 수 있다.
결국 prefetch와 concurrency는 단순한 성능 옵션이 아니다.
두 설정은 RabbitMQ Consumer의 병렬 처리 단위, 미확인 메시지 수, 순서 보장 범위, 장애 발생 시 재처리 범위를 결정하는 핵심 설정이다.
1. Concurrency란 무엇인가
concurrency는 하나의 Listener Container가 동시에 실행할 Consumer 수를 의미한다.
spring:
rabbitmq:
listener:
simple:
concurrency: 10
위 설정에서는 하나의 애플리케이션 인스턴스가 해당 Queue에 대해 10개의 Consumer를 생성한다.
구조를 단순화하면 다음과 같다.
RabbitMQ Queue
│
├── Consumer 1 → Listener Thread 1
├── Consumer 2 → Listener Thread 2
├── Consumer 3 → Listener Thread 3
└── ...
Consumer 10 → Listener Thread 10
RabbitMQ는 같은 우선순위의 여러 활성 Consumer가 존재하면 일반적으로 메시지를 Consumer들에게 분배한다. 따라서 Consumer가 여러 개라면 여러 메시지가 동시에 처리될 수 있다.
예를 들어 메시지가 다음 순서로 Queue에 저장되어 있다고 가정해 보자.
M1 → M2 → M3 → M4
Consumer가 하나라면 일반적으로 다음과 같이 처리된다.
Consumer 1: M1 → M2 → M3 → M4
Consumer가 두 개라면 메시지는 다음과 같이 나뉠 수 있다.
Consumer 1: M1 → M3
Consumer 2: M2 → M4
실제 처리 시간은 메시지마다 다르기 때문에 완료 순서는 다음과 같이 달라질 수 있다.
완료 순서: M2 → M1 → M4 → M3
즉, concurrency > 1이면 Queue에 들어온 순서와 비즈니스 처리가 완료되는 순서는 같다고 보장할 수 없다.
2. Prefetch란 무엇인가
prefetch는 Consumer가 ACK를 보내기 전에 미리 전달받을 수 있는 메시지 수를 제한한다.
RabbitMQ 공식 문서에서는 prefetch를 Consumer가 보유할 수 있는 미확인 상태의 메시지 수, 즉 unacknowledged message의 최대 개수로 설명한다.
spring:
rabbitmq:
listener:
simple:
prefetch: 10
위 설정은 Consumer 하나가 ACK되지 않은 메시지를 최대 10개까지 전달받을 수 있다는 의미다.
RabbitMQ Queue
│
├── Message 1 ┐
├── Message 2 │
├── Message 3 │ Consumer가 미리 확보
└── ... │
Message 10┘
prefetch=1이라면 Consumer가 메시지 하나를 처리하고 ACK를 보낼 때까지 다음 메시지를 전달받지 않는다.
Message 수신
↓
비즈니스 처리
↓
ACK
↓
다음 Message 수신
prefetch=10이라면 Consumer는 메시지 하나를 처리하는 동안 다음 메시지들을 미리 전달받을 수 있다.
Consumer 내부
처리 중: Message 1
대기 중: Message 2 ~ Message 10
네트워크 왕복과 메시지 전달 대기 시간을 줄일 수 있기 때문에 처리 속도가 빠르고 메시지 크기가 작다면 높은 prefetch가 처리량 향상에 도움이 된다.
Spring AMQP의 Simple Listener Container는 일반적인 Consumer의 활용도를 높이기 위해 기본 prefetch 값을 250으로 사용해 왔다. 다만 메시지 처리 시간이 길거나 메시지 크기가 크고, 순서가 중요하거나 부하 분배가 중요한 경우에는 낮은 값을 사용할 것을 공식 문서에서도 권장한다.
3. Concurrency와 Prefetch는 함께 계산해야 한다
두 설정은 독립적으로 보이지만 실제로는 함께 Consumer의 처리 구조를 결정한다.
예를 들어 다음 설정을 사용한다고 가정해 보자.
spring:
rabbitmq:
listener:
simple:
concurrency: 10
prefetch: 250
애플리케이션 인스턴스 하나가 확보할 수 있는 최대 미확인 메시지 수는 대략 다음과 같다.
최대 Unacked 메시지 수
= concurrency × prefetch
= 10 × 250
= 2,500개
애플리케이션을 4개 인스턴스로 운영한다면 전체 Consumer가 가져갈 수 있는 미확인 메시지 수는 최대 다음 수준까지 증가할 수 있다.
4 instances × 10 consumers × 250 prefetch
= 10,000 messages
이는 Queue에 메시지가 10,000개 존재한다는 의미가 아니다.
RabbitMQ Queue의 Ready 상태가 아니라 각 Consumer에 전달되어 아직 ACK되지 않은 Unacked 상태로 존재할 수 있다는 의미다.
따라서 prefetch를 높이면 RabbitMQ 관리 화면에서 Queue의 Ready 메시지는 빠르게 감소하지만, 실제 비즈니스 처리가 끝난 것은 아닐 수 있다.
Ready 감소 ≠ 처리 완료
Unacked 증가 = Consumer가 가져갔지만 아직 처리 완료되지 않음
이 차이를 이해하지 못하면 Queue 적체가 해소된 것처럼 잘못 판단할 수 있다.
4. Prefetch가 처리량을 높이는 이유
Consumer가 메시지 하나를 처리할 때마다 RabbitMQ에서 다음 메시지를 받아야 한다면 다음과 같은 대기 시간이 반복된다.
메시지 수신 → 처리 → ACK → 다음 메시지 전달
처리 시간이 짧을수록 메시지 전달과 ACK 과정에서 발생하는 네트워크 대기 비율이 상대적으로 커진다.
Prefetch를 높이면 Consumer가 다음 메시지를 미리 확보하므로 처리 흐름이 끊기지 않는다.
낮은 prefetch
처리 ─ 대기 ─ 처리 ─ 대기 ─ 처리
높은 prefetch
처리 ─ 처리 ─ 처리 ─ 처리 ─ 처리
특히 다음 조건에서는 prefetch 증가의 효과가 클 수 있다.
- 메시지 크기가 작다.
- 메시지 처리 시간이 짧고 일정하다.
- 네트워크 지연이 존재한다.
- Consumer가 CPU 또는 메모리 내 연산 중심으로 동작한다.
- 메시지 간 순서 의존성이 없다.
반대로 다음 조건에서는 prefetch 증가 효과가 제한적이거나 오히려 위험할 수 있다.
- 메시지 처리 시간이 길다.
- 메시지 크기가 크다.
- 메시지마다 처리 시간 편차가 크다.
- DB Connection이나 외부 API 호출이 병목이다.
- 메시지 순서가 중요하다.
- 장애 발생 시 많은 메시지가 재전달되면 안 된다.
5. Prefetch가 너무 크면 발생하는 문제
5.1 Consumer 간 불균형
Consumer A와 Consumer B가 존재하고 각각 prefetch=100이라고 가정해 보자.
RabbitMQ는 두 Consumer에게 메시지를 미리 전달할 수 있다.
Consumer A: 100개 확보
Consumer B: 100개 확보
그런데 Consumer A가 처리하는 메시지는 모두 단순 조회이고, Consumer B가 처리하는 메시지는 외부 API와 DB Transaction을 포함한다고 가정해 보자.
Consumer A: 메시지당 10ms
Consumer B: 메시지당 2초
Consumer B가 확보한 메시지는 처리되지 못한 채 오랫동안 대기한다.
Consumer A: 빠르게 처리 완료
Consumer B: 100개의 Unacked 메시지 장시간 보유
Queue에 다른 Consumer가 존재하더라도 이미 Consumer B에게 전달된 메시지는 정상적으로 ACK 또는 NACK되기 전까지 다른 Consumer가 처리할 수 없다.
결과적으로 높은 prefetch가 메시지를 느린 Consumer에 묶어두는 현상이 발생한다.
처리 시간이 불규칙한 작업에서는 작은 prefetch가 Consumer 간 부하를 더 공정하게 분배할 수 있다.
5.2 Consumer 메모리 사용량 증가
Consumer에 전달된 메시지는 애플리케이션 내부에 대기하게 된다.
다음 조건이라면 메모리 사용량은 빠르게 증가한다.
평균 메시지 크기: 500KB
concurrency: 10
prefetch: 250
메시지 본문 크기만 단순 계산해도 다음과 같다.
500KB × 10 × 250
= 약 1.25GB
여기에 메시지 객체, Header, 역직렬화 객체, Listener Container 내부 자료구조가 추가된다.
따라서 메시지 크기가 크다면 prefetch를 처리량만 보고 높여서는 안 된다.
5.3 장애 발생 시 재전달 범위 증가
Consumer가 메시지를 전달받았지만 ACK하지 않은 상태에서 Connection이나 Channel이 종료되면 RabbitMQ는 미확인 메시지를 다시 Queue로 돌려보내 다른 Consumer에 재전달할 수 있다. RabbitMQ의 Consumer ACK는 메시지가 수신 또는 처리되었음을 Broker에 알려주는 신뢰성 메커니즘이다.
Consumer가 250개 확보
↓
100개 처리 중 애플리케이션 종료
↓
ACK되지 않은 메시지 재전달 가능
prefetch가 크면 장애 시 재전달될 수 있는 메시지 범위도 커진다.
여기서 중요한 점은 애플리케이션이 DB 처리를 완료했지만 ACK를 보내기 전에 종료되는 경우다.
1. 메시지 수신
2. DB INSERT 완료
3. 애플리케이션 비정상 종료
4. ACK 전송 실패
5. RabbitMQ가 메시지 재전달
6. DB INSERT 재실행
이 구조에서는 동일한 메시지가 두 번 처리될 수 있다.
RabbitMQ Consumer는 일반적으로 exactly-once 처리를 보장하지 않는다. 실무에서는 at-least-once delivery를 전제로 멱등성을 설계해야 한다.
6. ACK 모드를 이해하지 않고 Prefetch를 조정하면 안 된다
Spring AMQP에서 주로 사용하는 ACK 모드는 다음과 같다.
| ACK 모드 | 의미 | 특징 |
|---|---|---|
NONE |
Broker가 전달 즉시 처리된 것으로 간주 | 장애 시 메시지 유실 가능성이 큼 |
AUTO |
Listener가 정상 종료되면 Container가 ACK | Spring AMQP에서 일반적으로 사용 |
MANUAL |
애플리케이션 코드가 직접 ACK/NACK | 세밀한 제어 가능, 구현 복잡도 증가 |
여기서 주의할 점은 Spring AMQP의 AcknowledgeMode.AUTO와 RabbitMQ protocol 수준의 automatic acknowledgement가 같은 의미가 아니라는 것이다.
Spring의 AUTO는 Listener 호출이 정상적으로 완료되면 Container가 메시지를 ACK한다.
@RabbitListener(queues = "order.queue")
public void consume(OrderMessage message) {
orderService.process(message);
// 메서드가 정상 종료되면 Container가 ACK
}
Listener에서 예외가 발생하면 ACK되지 않고 Container의 예외 처리 및 재큐잉 설정에 따라 NACK 또는 reject 처리가 수행될 수 있다.
RabbitMQ protocol 수준에서 ACK를 사용하지 않는 automatic acknowledgement는 메시지가 Consumer에 전달된 즉시 성공한 것으로 간주하므로 Consumer 장애 시 메시지 유실 위험이 있다. 또한 미확인 메시지 제한이 적용되지 않아 Consumer 과부하를 유발할 수 있다. RabbitMQ는 bounded prefetch와 manual acknowledgement 조합을 일반적인 안정적 처리 방식으로 설명한다.
AUTO ACK 설정
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: auto
concurrency: 5
prefetch: 20
@RabbitListener(queues = "payment.queue")
public void consume(PaymentMessage message) {
paymentService.process(message);
}
paymentService.process()가 정상 종료되면 ACK된다.
MANUAL ACK 설정
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: manual
concurrency: 5
prefetch: 20
@RabbitListener(queues = "payment.queue")
public void consume(
PaymentMessage message,
Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag
) throws IOException {
try {
paymentService.process(message);
channel.basicAck(deliveryTag, false);
} catch (RetryableException e) {
channel.basicNack(deliveryTag, false, true);
} catch (Exception e) {
channel.basicNack(deliveryTag, false, false);
}
}
각 인자의 의미는 다음과 같다.
basicAck(deliveryTag, multiple)
basicNack(deliveryTag, multiple, requeue)
multiple=false: 해당 메시지만 처리multiple=true: 해당 delivery tag 이전의 여러 메시지를 일괄 처리requeue=true: Queue에 다시 넣음requeue=false: 폐기하거나 DLX 설정에 따라 DLQ로 이동
RabbitMQ의 basic.reject와 basic.nack는 Consumer가 처리에 실패한 메시지를 재큐잉하거나 폐기하도록 지시하는 Negative Acknowledgement 메커니즘이다.
실무에서는 무조건 requeue=true를 사용하면 안 된다.
복구 불가능한 메시지를 계속 재큐잉하면 다음과 같은 무한 반복이 발생한다.
수신 → 실패 → requeue → 재수신 → 실패 → requeue
따라서 재시도 횟수를 제한하고 최종적으로 DLQ로 보내는 구조가 필요하다.
7. Concurrency를 높이면 순서가 보장되는가
결론부터 말하면 하나의 Queue에 여러 Consumer가 붙으면 전체 처리 순서는 보장되지 않는다.
RabbitMQ Queue는 기본적으로 FIFO 성격을 가지지만 competing consumer, prefetch, requeue, redelivery 등이 개입하면 실제 처리 완료 순서는 달라질 수 있다. RabbitMQ 공식 문서도 FIFO를 설명할 때 이러한 요인을 제외한 조건을 전제로 한다.
예를 들어 항공기 위치 데이터가 다음과 같이 들어온다고 가정해 보자.
Aircraft A
10:00:01 → Position 1
10:00:02 → Position 2
10:00:03 → Position 3
concurrency=3이면 다음과 같이 처리될 수 있다.
Consumer 1 → Position 1, 처리 시간 500ms
Consumer 2 → Position 2, 처리 시간 50ms
Consumer 3 → Position 3, 처리 시간 100ms
처리 완료 순서는 다음과 같아진다.
Position 2 → Position 3 → Position 1
데이터베이스에 단순 UPDATE를 수행한다면 최종 상태가 과거 위치로 되돌아갈 수 있다.
정상 최종 상태: Position 3
실제 최종 상태: Position 1
따라서 데이터 안에 timestamp가 존재한다고 해서 자동으로 순서가 보장되는 것은 아니다.
timestamp는 순서를 판단하기 위한 데이터일 뿐이고, 애플리케이션이 이전 데이터와 비교해서 오래된 메시지를 거부해야 한다.
8. 위치 데이터의 순서 역전을 방지하는 방법
항공기나 드론 위치처럼 Entity별 순서는 중요하지만 전체 Entity 간 순서는 중요하지 않은 데이터가 많다.
이 경우 전체 Queue를 단일 Consumer로 처리하면 순서는 유지하기 쉽지만 처리량이 크게 제한된다.
더 적절한 방법은 Entity 단위 순서 보장이다.
방법 1. DB 조건부 UPDATE
메시지에 aircraftId와 eventTimestamp가 있다고 가정한다.
public record AircraftPositionMessage(
String messageId,
String aircraftId,
double latitude,
double longitude,
Instant eventTimestamp
) {
}
DB UPDATE 시 현재 저장된 timestamp보다 최신인 경우에만 반영한다.
UPDATE aircraft_position
SET latitude = :latitude,
longitude = :longitude,
event_timestamp = :eventTimestamp
WHERE aircraft_id = :aircraftId
AND event_timestamp < :eventTimestamp;
초기 데이터가 없을 수 있다면 PostgreSQL에서는 다음과 같이 구성할 수 있다.
INSERT INTO aircraft_position (
aircraft_id,
latitude,
longitude,
event_timestamp
)
VALUES (
:aircraftId,
:latitude,
:longitude,
:eventTimestamp
)
ON CONFLICT (aircraft_id)
DO UPDATE
SET latitude = EXCLUDED.latitude,
longitude = EXCLUDED.longitude,
event_timestamp = EXCLUDED.event_timestamp
WHERE aircraft_position.event_timestamp < EXCLUDED.event_timestamp;
이 방식은 메시지가 역순으로 처리되더라도 최신 상태가 과거 상태로 덮어써지는 것을 방지한다.
방법 2. Entity별 Queue 분할
aircraftId를 기준으로 Queue를 분할한다.
aircraftId hash % 4
Queue 0
Queue 1
Queue 2
Queue 3
동일한 항공기는 항상 같은 Queue로 보내고 각 Queue를 단일 Consumer로 처리한다.
Aircraft A → Queue 1 → Consumer 1
Aircraft B → Queue 3 → Consumer 3
Aircraft C → Queue 1 → Consumer 1
이 방식은 Entity별 순서를 보장하면서 Queue 간 병렬 처리가 가능하다.
다만 Queue 수를 동적으로 과도하게 늘리지 말고 고정된 Partition Queue를 운용하는 것이 관리 측면에서 유리하다.
방법 3. 메시지 버전 또는 Sequence 사용
발행자가 Entity별 증가하는 sequence를 부여한다.
{
"aircraftId": "A001",
"sequence": 1523,
"eventTimestamp": "2026-07-11T10:00:03Z"
}
Consumer는 마지막 처리 sequence보다 큰 메시지만 반영한다.
if (message.sequence() <= currentSequence) {
return;
}
동일 timestamp가 생성될 수 있거나 발행 시스템 간 시계가 정확히 동기화되지 않는 환경에서는 timestamp만 사용하는 것보다 sequence가 더 명확하다.
여러 Producer가 같은 Entity의 메시지를 발행한다면 단순한 로컬 증가값으로는 부족하다. 이 경우 Entity별 sequence 발급 주체를 단일화하거나 DB version, Redis atomic counter 등의 구조가 필요하다.
9. Concurrency를 높이면 중복이 발생하는가
concurrency 자체가 메시지를 복제하지는 않는다.
정상적인 상황에서 하나의 Queue에 여러 Consumer가 연결되면 하나의 메시지는 그중 하나의 Consumer에게 전달된다.
하지만 다음 상황에서는 동일한 비즈니스 이벤트가 다시 처리될 수 있다.
1. Consumer가 메시지를 수신
2. DB 처리를 완료
3. ACK 전송 전 Consumer 종료
4. RabbitMQ가 메시지를 재전달
5. 다른 Consumer가 동일 메시지를 처리
concurrency가 높을수록 동시에 처리 중인 메시지 수가 많아지고, prefetch가 높을수록 Consumer가 확보한 미확인 메시지 수도 많아진다.
따라서 장애 시 중복 처리의 영향 범위가 커질 가능성이 있다.
핵심은 다음과 같다.
Concurrency가 중복을 만드는 것은 아니다.
ACK 이전 장애와 재전달이 중복 처리 가능성을 만든다.
이 문제는 ACK 설정만으로 완전히 제거할 수 없다.
ACK를 DB 처리 전에 보내면 메시지 유실 위험이 생긴다.
ACK → DB 처리 → 장애
= 메시지 유실
ACK를 DB 처리 후에 보내면 중복 처리 가능성이 생긴다.
DB 처리 → 장애 → ACK 실패 → 재전달
= 중복 처리
따라서 반드시 처리되어야 하는 메시지는 일반적으로 DB 처리 후 ACK + 멱등 처리 구조를 사용한다.
10. 멱등 처리는 선택이 아니라 필수다
각 메시지에 전역적으로 유일한 messageId 또는 uniqueKey를 포함한다.
{
"messageId": "9f72b152-04f3-44f0-a244-71bc95a54b30",
"aircraftId": "A001",
"eventTimestamp": "2026-07-11T10:00:03Z"
}
DB에 처리 이력을 저장한다.
CREATE TABLE processed_message (
message_id VARCHAR(100) PRIMARY KEY,
processed_at TIMESTAMP NOT NULL
);
Consumer는 비즈니스 처리와 처리 이력 저장을 하나의 Transaction으로 묶는다.
@Service
@RequiredArgsConstructor
public class AircraftPositionService {
private final ProcessedMessageRepository processedMessageRepository;
private final AircraftPositionRepository aircraftPositionRepository;
@Transactional
public void process(AircraftPositionMessage message) {
if (processedMessageRepository.existsById(message.messageId())) {
return;
}
aircraftPositionRepository.upsertIfNewer(
message.aircraftId(),
message.latitude(),
message.longitude(),
message.eventTimestamp()
);
processedMessageRepository.save(
new ProcessedMessage(
message.messageId(),
Instant.now()
)
);
}
}
다만 existsById() 후 save() 방식만으로는 여러 Consumer가 동시에 동일 메시지를 처리할 때 경쟁 조건이 발생할 수 있다.
Consumer A: existsById() → false
Consumer B: existsById() → false
Consumer A: 비즈니스 처리
Consumer B: 비즈니스 처리
따라서 최종 방어선은 DB의 Unique Constraint여야 한다.
@Transactional
public void process(AircraftPositionMessage message) {
try {
processedMessageRepository.insert(message.messageId());
} catch (DuplicateKeyException e) {
return;
}
aircraftPositionRepository.upsertIfNewer(
message.aircraftId(),
message.latitude(),
message.longitude(),
message.eventTimestamp()
);
}
보다 명확하게는 다음처럼 조건부 INSERT 결과를 사용할 수 있다.
INSERT INTO processed_message (
message_id,
processed_at
)
VALUES (
:messageId,
CURRENT_TIMESTAMP
)
ON CONFLICT (message_id) DO NOTHING;
반영된 row 수가 0이면 이미 처리한 메시지다.
@Transactional
public void process(AircraftPositionMessage message) {
int inserted = processedMessageRepository.insertIfAbsent(
message.messageId()
);
if (inserted == 0) {
return;
}
aircraftPositionRepository.upsertIfNewer(message);
}
11. Concurrency를 높인다고 처리량이 계속 증가하지는 않는다
Consumer의 병렬 처리 수를 늘리면 일정 구간까지 처리량이 증가한다.
하지만 실제 처리량은 Consumer Thread 수만으로 결정되지 않는다.
RabbitMQ Consumer
↓
애플리케이션 Thread
↓
DB Connection Pool
↓
Database
예를 들어 다음과 같은 설정을 사용한다고 가정해 보자.
spring:
rabbitmq:
listener:
simple:
concurrency: 30
prefetch: 100
datasource:
hikari:
maximum-pool-size: 10
30개의 Consumer가 동시에 DB 작업을 요청하지만 DB Connection은 10개뿐이다.
Consumer Thread 30개
DB Connection 10개
나머지 20개 Thread는 Connection을 기다린다.
이 상태에서 concurrency를 50으로 늘리면 처리량보다 다음 비용이 증가할 수 있다.
- DB Connection 대기 시간
- Thread Context Switching
- 메모리 사용량
- Transaction 경합
- Lock 경합
- 응답 지연
- ACK 지연
따라서 concurrency는 다음 리소스와 함께 결정해야 한다.
Consumer concurrency
≤ DB Connection Pool
≤ DB가 안정적으로 처리 가능한 동시 요청 수
모든 메시지가 DB를 사용한다면 보수적으로 다음과 같이 시작할 수 있다.
Consumer concurrency
≈ Consumer 전용 DB Connection 예산
예를 들어 HikariCP가 30개이고 HTTP 요청용 Connection을 15개 남겨야 한다면 RabbitMQ Consumer concurrency는 10~15 수준에서 시작하는 식이다.
정확한 값은 공식이 아니라 부하 테스트와 운영 지표를 통해 결정해야 한다.
12. 처리량을 추정하는 기준
Consumer 하나의 평균 처리 시간이 T초라면 이상적인 최대 처리량은 다음과 같이 근사할 수 있다.
단일 Consumer 처리량 ≈ 1 / 평균 처리 시간
메시지 하나 처리에 평균 100ms가 걸리면 다음과 같다.
1 / 0.1초 = 초당 약 10건
Consumer가 10개라면 이론적인 처리량은 다음과 같다.
10 consumers × 10 msg/s
= 약 100 msg/s
이를 일반화하면 다음과 같다.
예상 처리량
≈ concurrency / 평균 처리 시간(초)
예를 들어 목표 처리량이 초당 500건이고 평균 처리 시간이 50ms라면 필요한 concurrency는 다음과 같이 추정할 수 있다.
필요 concurrency
≈ 목표 TPS × 평균 처리 시간
≈ 500 × 0.05
≈ 25
하지만 실제 환경에서는 다음 요소를 반영해야 한다.
- 처리 시간의 p95, p99
- DB Connection 대기
- Network I/O
- GC Pause
- Lock 경합
- RabbitMQ 전달 지연
- Retry 비율
- 외부 API Rate Limit
따라서 평균값만 사용하지 말고 여유 계수를 포함해야 한다.
권장 초기값
≈ 목표 TPS × p95 처리 시간 × 안전 계수
안전 계수는 시스템 특성에 따라 1.2~2.0 수준에서 시작해 부하 테스트로 검증할 수 있다.
13. Prefetch 초기값을 정하는 기준
모든 시스템에 적용할 수 있는 하나의 최적 prefetch 값은 없다.
메시지 특성에 따라 다음과 같이 접근할 수 있다.
| 메시지 특성 | 권장 초기 접근 |
|---|---|
| 처리 시간이 길고 편차가 큼 | 1~10 |
| 순서가 매우 중요함 | 1 또는 낮은 값 |
| DB Transaction 중심 | 5~20 |
| 외부 API 호출 중심 | 1~10 |
| 짧은 CPU 연산 중심 | 20~100 이상 검토 |
| 메시지가 매우 큼 | 낮은 값 |
| 메시지가 작고 처리 속도가 빠름 | 높은 값 검토 |
| 장애 시 재처리 비용이 큼 | 낮은 값 |
| Batch ACK를 활용함 | Batch 크기를 고려해 설정 |
실무적으로는 다음 순서로 조정하는 것이 안전하다.
1. concurrency를 리소스 한도 안에서 결정
2. prefetch를 낮은 값으로 시작
3. Consumer idle time과 처리량 확인
4. prefetch를 단계적으로 증가
5. Unacked, 메모리, 처리 지연 관찰
예를 들어 다음과 같이 테스트할 수 있다.
concurrency=10 고정
prefetch=1
prefetch=5
prefetch=10
prefetch=20
prefetch=50
각 단계에서 다음 지표를 비교한다.
- 초당 처리량
- 메시지 처리 p95/p99
- Queue
Ready - Queue
Unacked - Consumer utilization
- 애플리케이션 Heap
- DB Connection active/pending
- 실패율 및 Retry 비율
- 재전달 메시지 수
14. Spring Boot 설정 예시
고정 Consumer 수
spring:
rabbitmq:
listener:
simple:
concurrency: 10
max-concurrency: 10
prefetch: 20
acknowledge-mode: auto
default-requeue-rejected: false
concurrency와 max-concurrency를 동일하게 지정하면 고정된 Consumer 수로 운영할 수 있다.
동적 Consumer 확장
spring:
rabbitmq:
listener:
simple:
concurrency: 5
max-concurrency: 20
prefetch: 20
Spring의 SimpleMessageListenerContainer는 최소 Consumer 수를 유지하다가 부하가 증가하면 설정된 최대 Consumer 수까지 점진적으로 확장할 수 있다.
동적 확장은 트래픽 변동에 대응하기 좋지만 다음을 주의해야 한다.
- Consumer 증가 속도가 순간 트래픽 증가보다 느릴 수 있다.
- Consumer 증가와 함께 DB 동시 요청 수도 증가한다.
- 자동 확장으로 순서 역전 가능성이 더 커질 수 있다.
- Kubernetes Pod Auto Scaling과 Listener 동적 확장이 동시에 작동하면 과도하게 확장될 수 있다.
운영 예측 가능성이 중요하다면 Consumer 수를 고정하고 애플리케이션 인스턴스 단위로 확장하는 방법도 고려할 수 있다.
Listener별 개별 설정
Queue마다 처리 특성이 다르면 전역 설정 하나를 공통 적용하지 않는 것이 좋다.
@Configuration
@RequiredArgsConstructor
public class RabbitListenerConfig {
private final ConnectionFactory connectionFactory;
@Bean
public SimpleRabbitListenerContainerFactory positionListenerFactory() {
SimpleRabbitListenerContainerFactory factory =
new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setConcurrentConsumers(10);
factory.setMaxConcurrentConsumers(10);
factory.setPrefetchCount(50);
factory.setAcknowledgeMode(AcknowledgeMode.AUTO);
return factory;
}
@Bean
public SimpleRabbitListenerContainerFactory paymentListenerFactory() {
SimpleRabbitListenerContainerFactory factory =
new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setConcurrentConsumers(3);
factory.setMaxConcurrentConsumers(3);
factory.setPrefetchCount(5);
factory.setAcknowledgeMode(AcknowledgeMode.AUTO);
return factory;
}
}
@RabbitListener(
queues = "aircraft.position.queue",
containerFactory = "positionListenerFactory"
)
public void consumePosition(AircraftPositionMessage message) {
positionService.process(message);
}
@RabbitListener(
queues = "payment.queue",
containerFactory = "paymentListenerFactory"
)
public void consumePayment(PaymentMessage message) {
paymentService.process(message);
}
위치 데이터는 처리 속도가 빠르므로 높은 concurrency와 prefetch를 적용하고, 결제 데이터는 중복 처리 비용과 외부 시스템 의존성이 크므로 보수적으로 설정할 수 있다.
15. Retry와 DLQ를 함께 설계해야 한다
Consumer 처리 실패를 모두 같은 방식으로 다루면 안 된다.
실패는 크게 두 종류로 나뉜다.
일시적 실패
- DB 순간 장애
- 외부 API Timeout
- Network 오류
- Lock Timeout
영구적 실패
- 잘못된 메시지 포맷
- 필수 값 누락
- 지원하지 않는 이벤트 타입
- 비즈니스 규칙 위반
일시적 실패는 제한된 횟수만 재시도하고, 영구적 실패는 바로 DLQ로 보내는 것이 적절하다.
Main Queue
↓
Consumer 처리 실패
↓
Retry Queue 또는 Application Retry
↓
최대 횟수 초과
↓
DLQ
default-requeue-rejected=true 상태에서 지속적으로 예외가 발생하면 동일 메시지가 반복 소비될 수 있으므로 운영 환경에서는 명시적인 Retry와 DLQ 정책을 구성하는 편이 안전하다.
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: auto
default-requeue-rejected: false
retry:
enabled: true
max-attempts: 3
initial-interval: 1000ms
multiplier: 2
max-interval: 5000ms
다만 Application 내부 Retry는 Consumer Thread를 점유한다.
처리 실패
→ 1초 대기
→ 재시도
→ 2초 대기
→ 재시도
처리 실패가 많으면 concurrency가 충분하더라도 Listener Thread가 Retry 대기로 소진될 수 있다.
긴 Retry 간격이 필요하다면 Retry Queue와 TTL, DLX를 활용해 Consumer Thread를 반환하는 구조가 더 적절하다.
16. 운영에서 반드시 확인할 지표
prefetch와 concurrency 튜닝은 설정 파일만 보고 판단할 수 없다.
RabbitMQ에서는 최소한 다음 지표를 함께 확인해야 한다.
| 지표 | 의미 | 판단 |
|---|---|---|
messages_ready |
아직 Consumer에 전달되지 않은 메시지 | 지속 증가하면 소비 처리량 부족 |
messages_unacknowledged |
Consumer에 전달됐지만 ACK되지 않은 메시지 | 과도하면 처리 지연 또는 높은 prefetch 의심 |
consumer_count |
Queue에 연결된 Consumer 수 | 설정한 concurrency와 일치하는지 확인 |
ack rate |
초당 ACK 수 | 실제 처리 완료 속도 |
deliver rate |
초당 전달 수 | Broker가 Consumer에 전달하는 속도 |
redelivered |
재전달 메시지 | 장애, NACK, Retry 여부 확인 |
| Consumer utilization | Consumer가 메시지를 받을 수 있는 상태 비율 | 낮으면 Consumer 처리 병목 가능 |
RabbitMQ 공식 문서에서도 Consumer와 prefetch 설정이 병목인지 판단하려면 관련 메트릭을 함께 확인할 것을 권장한다.
애플리케이션에서는 다음 지표를 추가로 확인한다.
rabbitmq.listener.processing.duration
rabbitmq.listener.success.count
rabbitmq.listener.failure.count
rabbitmq.listener.duplicate.count
rabbitmq.listener.outdated.count
DB Connection Pool에서는 다음을 본다.
hikaricp.connections.active
hikaricp.connections.idle
hikaricp.connections.pending
hikaricp.connections.timeout
특히 connections.pending이 지속적으로 증가한다면 RabbitMQ Consumer concurrency가 DB 처리 능력을 넘어섰을 가능성이 크다.
17. 상황별 권장 설정
순서가 절대적으로 중요한 경우
concurrency: 1
prefetch: 1
가장 단순하지만 처리량이 제한된다.
처리량이 필요하다면 Entity Key 기반 Queue Partitioning을 적용한다.
순서가 중요하지만 timestamp로 역전 방지가 가능한 경우
concurrency: 5
prefetch: 10
애플리케이션에서 다음 로직을 반드시 적용한다.
eventTimestamp <= storedTimestamp
→ 오래된 메시지로 판단
→ 상태 변경 없이 ACK
timestamp가 동일할 수 있다면 sequence 또는 version을 함께 사용한다.
빠른 단순 이벤트 처리
concurrency: 10
prefetch: 50
처리량 테스트 후 prefetch를 단계적으로 높인다.
메모리와 Unacked 메시지 수를 함께 관찰해야 한다.
결제·정산·상태 변경
concurrency: 3
prefetch: 5
acknowledge-mode: auto
default-requeue-rejected: false
설정보다 다음 구조가 더 중요하다.
Unique Message Key
+ DB Unique Constraint
+ Transaction
+ 제한된 Retry
+ DLQ
+ 운영자 재처리 도구
외부 API 호출 중심
concurrency: 외부 API 허용 동시 호출 수 이하
prefetch: 1~10
외부 API Rate Limit과 Connection Pool을 기준으로 concurrency를 제한한다.
RabbitMQ 적체를 줄이기 위해 Consumer를 무작정 늘리면 외부 시스템 장애를 확대할 수 있다.
18. 실무 튜닝 순서
concurrency=10, prefetch=250처럼 경험적으로 정한 값을 바로 운영에 적용하기보다 다음 순서로 검증하는 것이 좋다.
1단계. 단일 Consumer 기준 성능 측정
concurrency: 1
prefetch: 1
측정 항목:
평균 처리 시간
p95 처리 시간
p99 처리 시간
DB Connection 점유 시간
실패율
2단계. Concurrency 증가
1 → 2 → 5 → 10 → 20
처리량이 더 이상 선형적으로 증가하지 않는 구간을 찾는다.
concurrency 1 → 20 msg/s
concurrency 2 → 39 msg/s
concurrency 5 → 91 msg/s
concurrency 10 → 142 msg/s
concurrency 20 → 147 msg/s
위 결과라면 concurrency 20은 Thread 수만 늘리고 실질적 이득이 거의 없다.
3단계. Prefetch 증가
Concurrency를 고정하고 prefetch를 조정한다.
1 → 5 → 10 → 20 → 50
처리량과 함께 다음 부작용을 확인한다.
Unacked 증가
Heap 증가
Consumer 간 처리량 편차
장애 시 재전달량
메시지 처리 지연
4단계. 장애 테스트
다음 상황을 의도적으로 발생시킨다.
- DB 처리 직후 애플리케이션 강제 종료
- ACK 전 Connection 종료
- Consumer 처리 중 RabbitMQ 재시작
- 메시지 포맷 오류
- DB Timeout
- 외부 API Timeout
- 동일 messageId 중복 발행
- 과거 timestamp 메시지 발행
정상 처리량뿐 아니라 중복, 순서 역전, 재전달이 비즈니스 데이터에 미치는 영향을 검증해야 한다.
19. 핵심 정리
concurrency는 동시에 처리할 Consumer 수다.
concurrency 증가
→ 병렬 처리 증가
→ 처리량 증가 가능
→ 순서 보장 약화
→ DB 및 외부 시스템 부하 증가
prefetch는 Consumer 하나가 ACK 전에 확보할 수 있는 메시지 수다.
prefetch 증가
→ 메시지 전달 대기 감소
→ 처리량 증가 가능
→ Unacked 증가
→ 메모리 증가
→ Consumer 간 불균형 가능
→ 장애 시 재전달 범위 증가
두 설정의 관계는 다음과 같이 이해할 수 있다.
최대 미확인 메시지 수
≈ 애플리케이션 인스턴스 수
× concurrency
× prefetch
가장 중요한 결론은 다음과 같다.
RabbitMQ의 처리량은 concurrency와 prefetch를 높인다고 무한히 증가하지 않는다. Consumer 뒤에 존재하는 DB, 외부 API, Connection Pool의 처리 능력이 실제 상한을 결정한다.
하나의 Queue에 여러 Consumer가 연결되면 메시지의 전체 처리 완료 순서는 보장되지 않는다. 순서가 중요하다면 단일 Consumer, Key 기반 Partitioning, timestamp·sequence 검증 중 하나가 필요하다.
ACK 이전에 장애가 발생하면 메시지는 재전달될 수 있다. 반드시 처리되어야 하는 작업은 중복 전달을 정상적인 상황으로 간주하고 Unique Key와 DB Constraint 기반 멱등 처리를 적용해야 한다.
따라서 RabbitMQ Consumer 튜닝에서 먼저 해야 할 질문은 다음과 같다.
“prefetch와 concurrency를 몇으로 설정할 것인가?”
가 아니라 다음 질문이어야 한다.
“이 메시지는 순서가 중요한가?”
“동일 메시지가 다시 들어와도 안전한가?”
“장애 시 어디까지 재처리될 수 있는가?”
“DB와 외부 시스템은 몇 개의 동시 요청을 감당할 수 있는가?”
이 질문에 대한 답을 먼저 정한 뒤 prefetch와 concurrency를 설정해야 한다.
'나의 주니어 개발 일기 > RabbitMQ' 카테고리의 다른 글
| Quorum Queue란? – 개념과 장단점, RabbitMQ 공식 문서 기준 정리 (0) | 2025.09.05 |
|---|---|
| [ActiveMQ] 미러링 하는 방법 (2) | 2025.07.09 |
| RABBITMQ 에서 Queue에서 Consume 하고 있는 대상을 명확히 식별하고 싶을때 (0) | 2024.03.18 |
| RABBITMQ 심화속성 정보 (0) | 2024.03.06 |
| RABBITMQ HA,클러스터 구성방법 (0) | 2024.03.05 |
