diff --git a/docker-compose.yml b/docker-compose.yml
index 3aff84c..2c265d6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -19,6 +19,8 @@ services:
SPRING_RABBITMQ_PORT: 5672
SPRING_RABBITMQ_USERNAME: notification
SPRING_RABBITMQ_PASSWORD: notification
+ NOTIFICATION_CONSUMER_CONCURRENCY: ${NOTIFICATION_CONSUMER_CONCURRENCY:-1}
+ RABBITMQ_PREFETCH: ${RABBITMQ_PREFETCH:-250}
healthcheck:
test:
diff --git a/docs/04-experiment.md b/docs/04-experiment.md
index 13ca2ad..f088062 100644
--- a/docs/04-experiment.md
+++ b/docs/04-experiment.md
@@ -230,6 +230,40 @@ Push, SMS, Email은 서로 다른 외부 Provider의 장애와 처리량에 독
API는 실제 알림 전송 완료를 기다리지 않고 요청 접수 후 `202 Accepted`를 반환한다.
+### Experiment 3. Consumer Concurrency와 Prefetch 튜닝
+
+RabbitMQ 비동기화 이후 API의 응답 성능은 크게 개선되었지만,
+Producer의 메시지 발행 속도가 Consumer 처리 속도를 초과하면서 Queue Backlog가 지속적으로 증가했다.
+
+초기 Consumer는 채널별로 1개씩 동작했으며,
+Mock Provider가 Delivery당 100ms의 지연을 발생시키므로
+Consumer 하나의 이론적인 최대 처리량은 약 10 msg/s이다.
+```text
+1 Consumer / 0.1초
+≈ 10 msg/s
+```
+
+실제 측정에서도 각 Queue의 각 Ack Rate는 약 9 msg/s로 확인되었다.
+
+이에 Consumer 수를 증가시켜 병렬 처리량을 개선할 수 있는지 확인하기 위해
+Consumer Concurrency를 1, 5, 10으로 변경하여 비교했다.
+```text
+concurrency = 1
+Queue → Consumer 1개
+
+concurrency = 5
+Queue → Consumer 5개
+
+concurrency = 10
+Queue → Consumer 10개
+```
+
+이후 Consumer가 ACK 이전에 미리 전달받을 수 있는 메시지 수인 Prefetch를
+10, 50, 250으로 변경하여 실제 처리량과 Unacked 메시지 수를 비교했다.
+
+Concurrency 실험에서는 Prefetch를 250으로 고정하고 30 VU로 측정했으며,
+Prefetch 실험에서는 Concurrency를 5로 고정하고 50 VU로 측정했다.
+
## 11. 개선 후 결과
### Experiment 1. Transaction Boundary 분리 결과
@@ -296,6 +330,89 @@ Queue에 대량의 메시지가 적체되었다.
+### Experiment 3. Consumer 처리량 튜닝 결과
+
+#### Consumer Concurrency
+
+Prefetch를 250으로 고정하고 30 VU에서 Consumer Concurrency를 변경했다.
+
+| Concurrency | API RPS | Avg | p95 | p99 | Queue당 Ack Rate | Total Backlog |
+|---:|---:|---:|---:|---:|---:|---:|
+| 1 | 463.69 | 64.33ms | 185.95ms | 415.50ms | 약 9 msg/s | 54,615 |
+| 5 | 338.21 | 88.33ms | 227.94ms | 418.88ms | 약 44 msg/s | 36,168 |
+| 10 | 289.62 | 103ms | 307.02ms | 603.43ms | 약 89 msg/s | 15,645 |
+
+Concurrency 증가에 따라 Consumer 처리량은 거의 선형적으로 증가했다.
+
+- Concurrency 1: 약 9 msg/s
+- Concurrency 5: 약 44 msg/s
+- Concurrency 10: 약 89 msg/s
+
+Concurrency 10에서는 Consumer 처리량과 Queue 적체는 크게 개선되었지만,
+CPU 사용량이 최대 약 100%까지 증가하고 HikariCP Connection Pending도 크게 증가했다.
+
+API와 Consumer가 동일한 애플리케이션 인스턴스와 DB Connection Pool을 공유하고 있기 때문에
+과도한 Consumer 병렬 처리가 API 처리 성능에도 영향을 준 것으로 판단했다.
+
+따라서 현재 단일 인스턴스 환경에서는
+Consumer 처리량과 API 자원 경합의 균형을 고려하여 Concurrency 5를 선택했다.
+
+> Total Backlog는 테스트 종료 시점의 모든 Queue의 Ready와 Unacked 메시지를 합산한 값이다.
+
+#### RabbitMQ Prefetch
+
+Consumer Concurrency를 5로 고정하고 50 VU에서 Prefetch를 변경했다.
+
+| Prefetch | API RPS | Avg | p95 | p99 | Queue당 Ack Rate | Queue당 Unacked |
+|---:|---:|---:|---:|---:|---:|---:|
+| 10 | 289.49 | 171.91ms | 473.10ms | 985.53ms | 약 45 msg/s | 50 |
+| 50 | 445.85 | 111.61ms | 276.84ms | 504.00ms | 약 45 msg/s | 250 |
+| 250 | 508.60 | 97.89ms | 256.38ms | 458.10ms | 약 44~45 msg/s | 1,250 |
+
+Prefetch를 증가시켜도 Consumer의 실제 Ack Rate는 약 45 msg/s로 거의 동일했다.
+
+이는 Prefetch가 동시에 실행되는 Consumer 수를 증가시키는 설정이 아니라,
+Consumer가 ACK 이전에 미리 전달받을 수 있는 메시지 수를 조절하는 설정이기 때문이다.
+
+Concurrency 5에서 예상 가능한 최대 Unacked 메시지 수는 다음과 같다.
+
+```text
+Prefetch 10
+5 Consumers × 10
+≈ 50
+
+Prefetch 50
+5 Consumers × 50
+≈ 250
+
+Prefetch 250
+5 Consumers × 250
+≈ 1,250
+```
+
+실제 측정값도 이와 동일하게 나타났다.
+
+Prefetch 250은 Prefetch 50에 비해 Consumer 처리량의 추가 향상이 없었지만,
+Queue 당 Unacked 메시지가 250건에서 1,250건으로 5배 증가했다.
+
+Prefetch가 지나치게 크면 Consumer가 많은 메시지를 선점하여
+Worker 장애 시 재전달해야 할 메시지가 증가하고,
+Worker 간 메시지 분배의 유연성도 낮아질 수 있다.
+
+따라서 처리량과 In-flight 메시지 수의 균형을 고려하여 Prefetch 50을 선택했다.
+
+
Concurrency 1
+
+
+Concurrency 10
+
+
+Prefetch 10
+
+
+Prefetch 250
+
+
## 12. 결과 분석
### 12.1 DB Connection Pool 병목 확인
@@ -352,7 +469,7 @@ Notification API는 여전히 모든 Provider 호출이 끝날 때까지 HTTP
Provider 호출을 HTTP 요청 처리 경로에서 분리하고,
API가 알림 요청을 Queue에 등록한 뒤 즉시 응답하도록 비동기 구조로 변경한다.
-### 12.4 Rabbit 비동기화 효과
+### 12.4 RabbitMQ 비동기화 효과
RabbitMQ 적용 후 HTTP 요청은 Mock Provider의 실행 완료를 기다리지 않고,
Notification 및 Delivery를 저장한 뒤 메시지를 Queue에 발행하고 즉시 응답한다.
@@ -386,8 +503,8 @@ API가 알림의 실제 전송 시간과 독립적으로 요청을 처리할 수
따라서 Producer인 Notification API의 처리량이 Consumer의 처리량보다 높아지면서
Queue Backlog가 지속적으로 증가하는 새로운 병목이 발생했다.
-이는 비동기 시스템에서 API RPS만으로 전체 시스템 처리량을 평가할 수 업으며,
-다음 지표를 함께 측정해야 힘을 보여준다.
+이는 비동기 시스템에서 API RPS만으로 전체 시스템 처리량을 평가할 수 없으며,
+다음 지표를 함께 측정해야 함을 보여준다.
- API 요청 처리량
- Queue Backlog
@@ -398,7 +515,50 @@ Queue Backlog가 지속적으로 증가하는 새로운 병목이 발생했다.
다음 단계에서는 Consumer concurrency와 RabbitMQ prefetch 설정을 분석하고,
Consumer 확장을 통해 Queue 적체를 완화할 수 있는지 실험한다.
+### 12.6 Consumer Concurrency 증가 효과
+
+Consumer Concurrency를 1에서 5로 증가시키자
+채널별 Ack Rate는 약 9 msg/s에서 약 44 msg/s로 약 4.9배 증가했다.
+
+Concurrency 10에서는 약 89 msg/s까지 증가하여
+Mock Provider의 100ms 지연을 고려한 이론적인 처리량 증가와 유사한 결과를 보였다.
+
+그러나 Concurrency가 증가할수록 Consumer가 수행하는 Provider 호출과
+DB 상태 갱신도 동시에 증가했다.
+
+Concurrency 10에서는 CPU 사용량과 HikariCP Connection 대기가 크게 증가했고,
+API RPS 역시 463.69에서 289.62로 감소했다.
+
+이를 통해 단순히 Consumer 수를 증가시키는 것만으로는
+전체 시스템의 처리량을 무한히 확장할 수 없음을 확인했다.
+
+현재 구조에서는 Notification API와 Consumer가 동일한 프로세스 및
+DB Connection Pool을 공유하기 때문에 Consumer의 부하가 API 처리에도 영향을 준다.
+
+따라서 더 높은 Consumer 처리량이 필요한 경우에는 Concurrency를 계속 증가시키기보다
+API Server와 Worker를 분리하고 채널별 Worker를 독립적으로 수평 확장하는 구조가 적합하다.
+
+### 12.7 Prefetch 조정 결과
+
+Prefetch를 10, 50, 250으로 변경해도
+Consumer의 Ack Rate는 약 45 msg/s로 유사하게 나타났다.
+
+따라서 현재 환경에서는 Prefetch 10 이상에서
+Consumer가 처리할 메시지를 충분히 확보하고 있었으며,
+Prefetch 증가가 실제 Provider 처리량 증가로 이어지지는 않았다.
+
+반면 Queue당 Unacked 메시지는
+50건, 250건, 1,250건으로 Prefetch 값에 비례하여 증가했다.
+
+Prefetch를 과도하게 증가시키는 것은 처리량 개선 없이
+In-flight 메시지 수만 증가시킬 수 있으므로,
+현재 환경에서는 Prefetch 50을 처리량과 메시지 선점량 사이의 균형점으로 선택했다.
+단, Prefetch 테스트에서 API RPS에는 실행별 차이가 관찰되었다.
+Consumer의 실제 Ack Rate는 동일했기 때문에 이를 Prefetch에 따른 직접적인
+Consumer 처리량 향상으로 해석하지 않았으며,
+동일 프로세스의 CPU 및 DB Connection Pool 자원 경합과
+로컬 테스트 환경의 변동이 영향을 주었을 가능성이 있다.
## 13. Platform Thread와 Virtual Thread 비교
diff --git a/docs/images/concurrency-1.png b/docs/images/concurrency-1.png
new file mode 100644
index 0000000..8aef207
Binary files /dev/null and b/docs/images/concurrency-1.png differ
diff --git a/docs/images/concurrency-10.png b/docs/images/concurrency-10.png
new file mode 100644
index 0000000..9c617ab
Binary files /dev/null and b/docs/images/concurrency-10.png differ
diff --git a/docs/images/prefetch-10.png b/docs/images/prefetch-10.png
new file mode 100644
index 0000000..1f53d84
Binary files /dev/null and b/docs/images/prefetch-10.png differ
diff --git a/docs/images/prefetch-250.png b/docs/images/prefetch-250.png
new file mode 100644
index 0000000..37bb912
Binary files /dev/null and b/docs/images/prefetch-250.png differ
diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/EmailDeliveryConsumer.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/EmailDeliveryConsumer.java
index 69c24d1..e62de3f 100644
--- a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/EmailDeliveryConsumer.java
+++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/EmailDeliveryConsumer.java
@@ -16,7 +16,10 @@ public class EmailDeliveryConsumer {
private final EmailProvider emailProvider;
private final NotificationTransactionService transactionService;
- @RabbitListener(queues = RabbitMqConfig.EMAIL_QUEUE)
+ @RabbitListener(
+ queues = RabbitMqConfig.EMAIL_QUEUE,
+ concurrency = "${NOTIFICATION_CONSUMER_CONCURRENCY:1}"
+ )
public void consume(DeliveryMessage message) {
boolean success;
diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/PushDeliveryConsumer.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/PushDeliveryConsumer.java
index 2d67c6e..6f86991 100644
--- a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/PushDeliveryConsumer.java
+++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/PushDeliveryConsumer.java
@@ -16,7 +16,10 @@ public class PushDeliveryConsumer {
private final PushProvider pushProvider;
private final NotificationTransactionService transactionService;
- @RabbitListener(queues = RabbitMqConfig.PUSH_QUEUE)
+ @RabbitListener(
+ queues = RabbitMqConfig.PUSH_QUEUE,
+ concurrency = "${NOTIFICATION_CONSUMER_CONCURRENCY:1}"
+ )
public void consume(DeliveryMessage message) {
boolean success;
diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/SmsDeliveryConsumer.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/SmsDeliveryConsumer.java
index 943dcd0..5269a51 100644
--- a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/SmsDeliveryConsumer.java
+++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/SmsDeliveryConsumer.java
@@ -16,7 +16,10 @@ public class SmsDeliveryConsumer {
private final SmsProvider smsProvider;
private final NotificationTransactionService transactionService;
- @RabbitListener(queues = RabbitMqConfig.SMS_QUEUE)
+ @RabbitListener(
+ queues = RabbitMqConfig.SMS_QUEUE,
+ concurrency = "${NOTIFICATION_CONSUMER_CONCURRENCY:1}"
+ )
public void consume(DeliveryMessage message) {
boolean success;
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 5dedcec..bc9d373 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -24,6 +24,9 @@ spring:
port: ${SPRING_RABBITMQ_PORT:5672}
username: ${SPRING_RABBITMQ_USERNAME:notification}
password: ${SPRING_RABBITMQ_PASSWORD:notification}
+ listener:
+ simple:
+ prefetch: ${RABBITMQ_PREFETCH:250}
management:
endpoints: