세차새차 비즈콜 서비스 개발 (2) -RabbitMQ TTL, DLX, Retry 적용
Summary
개요
과거 메세지를 함께 Consume해오는 오류
지난번 RabbitMQ PR을 올린 후 위와 같은 문제가 제의되었다.
위 문제 자체야 간단한 설정 문제였다. 그러나 이 문제가 아니라도 Queue에 메세지가 쌓이는 것과 관련하여 문제가 생길 수 있는 부분이 몇가지 보였다.
따라서 RabbitMQ에 TTL
, DLX
, retry
설정을 추가 하기로 했다.
1. acknowledge-mode 설정
우선적으로 개요에서 설명한 문제를 해결하자.
위 문제는 acknowledge-mode
의 설정과 관련된 문제였다.
listener.simple.acknowledge-mode
none
: 기본값. 들어오는 모든 메세지에 대해 ack를 전송한다.auto
: 리스너가 정상값을 반환하면 ack를 전송하고, 그렇지 않으면 nack를 전송한다.manual
: 리스너의 반환값 여부와 관계없이 직접 ack, nack, reject를 전송한다.
위 이슈에서는 acknowledge-mode
가 manual
로 설정되어있었고, ack나 nack를 전송하는 코드가 따로 작성되어있지 않았기 때문에 모든 메세지가 큐에 ready 상태로 남아있었다. 그래서 이전에 전송되었던 메세지가 사라지지 않고 함께 전송된 것이다.
가능한 queue 메세지 Statue는 이래 두가지이다.
- Ready for delivery (Ready 상태)
- Delivered but not yet acknowledged by consumer (전송은 됐으나 아직 Consumer의 Ark를 받지 못함)
정리하자면
아직 consume되지 못한 메세지:
Ready
상태로 큐에 존재한다.consume되고 Ack를 받은 메세지: 큐에서 제거된다.
consume되고 Ack를 받지 못한 메세지:
unack
상태로 큐에 존재한다.consume되고 Nack(negative-ack)를 받은 메세지: 옵션에 따라 큐에서 제거되거나 다시 requeue된다.
위 문제는 acknowledge-mode
를 none
으로 바꾸는 것으로 해결했다.
2. TTL 설정
메시지를 큐에 보관할 수 있는 기간
TTL 설정을 추가하기로 했다. TTL이 설정된 메세지는 큐에 Ready 상태로 TTL 시간 이상 머물게 되면 자동으로 삭제된다.
TTL은 메세지에 TTL을 설정하는 Per-Message TTL과 큐에 TTL을 설정하는 Per-Queue TTL이 존재하는데 나는 Per-Message TTL을 사용했다. 큐에 TTL을 설정하는 것보다 메세지에 TTL을 설정하는게 더 직관적이고, 유연하게 사용 가능할 것이라 생각했기 때문이다.
현재 작성된 코드는 @RabbitListener
를 통해 listen하고 있는 큐에 메세지가 들어오는 경우 바로 Consume하도록 작성되 있어 사실 서버가 정상적으로 돌아가는 경우 큐에 메세지가 Ready 상태로 남아있는 경우가 없을 거라 생각된다.
그럼에도 TTL을 설정하는 이유는 다음과 같은 상황을 가정한다:
- 특정 인스턴스가 죽어도, RabbitMQ는 살아있기 때문에 RabbitMQ는 계속 Queue로 메세지를 전달하게 된다. 만일 어떤 Queue에 메세지가 1000개 쌓여있는 상태에서 인스턴스가 되살아 난다면 큐에 쌓여있는 메세지들이 한번에 Consume되며 문제가 발생할 수 있다.
- 현재는 consumer와 rabbitMQ가 묶여있지만, 이후 서비스를 확장하여 컨슈머와 rabbitmq가 분리됐을 때 TTL이 필요해지게 되기에 미리 적용하는 것이다.
실제 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//기존 코드
public void sendMessage(CdrMessageDto messageDto) {
try {
**rabbitTemplate.convertAndSend(exchangeName, "", messageDto);**
} catch (Exception exception) {
throw new ApplicationException(ApplicationError.RABBITMQ_CONNECTION_ERROR);
}
}
// 변경된 TTL 설정 코드
public void sendMessage(CdrMessageDto messageDto) {
try {
**rabbitTemplate.convertAndSend(exchangeName, "", messageDto, message -> {
message.getMessageProperties().setExpiration(ttl); //환경변수로 30초 설정
return message;
});**
} catch (Exception exception) {
throw new ApplicationException(ApplicationError.RABBITMQ_CONNECTION_ERROR);
}
}
(테스트를 위해 @RabbitListener
연결을 끊어 메세지가 큐에 Ready 상태로 남아있게 했다.)
실제로 expiration을 30초로 설정한 메세지가 Ready 상태로 queue에 30초 이상 머물자 expired 되어 사라진 것을 확인할 수 있었다.
3. DLX 설정
TTL을 사용해 일정 시간 Ack 되지 않고 큐에서 머문 메세지는 큐에서 삭제되도록 하였다.
그러나 이렇게 메세지를 삭제해버리는 것이 마음에 걸렸다. 뭔가… 보관을 해야 안심이 될거같은 느낌이……
따라서 DLX(Dead Letter Exchager)와 DLQ(Dead Letter Queue)를 생성하기로 했다.
Dead Lettering
대기열의 메시지는 데드 레터링될 수 있다. 즉, 다음 네 가지 이벤트 중 하나가 발생할 때 메시지가 exchange에 republished 된다.
requeue
매개변수가false
로 설정된basic.reject
또는basic.nack
를 사용하여 소비자가 메시지를 negatively acknowledged 했다.- Per-message TTL로 인해 메시지가 만료됐다.
- 큐의 크기 제한을 초과하여 메시지가 삭제됐다.
- 메시지가 delivery-limit보다 더 많이 쿼럼 대기열로 반환됐다.
위 4가지 경우에 속하는 메세지들은 DLX에 publish되어 DLQ에 저장되게 된다.
실제 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@RequiredArgsConstructor
@Configuration
public class RabbitMqConfiguration {
@Value("${rabbitmq.queue.name}")
private String queueName;
@Value("${rabbitmq.queue.dead}")
private String deadLetterQueue;
@Value("${rabbitmq.exchange.name}")
private String exchangeName;
@Value("${rabbitmq.exchange.dead}")
private String deadLetterExchange;
@Bean
public Queue queue() {
return new Queue(queueName) ; // 큐에 DLX를 연결해준다.
}
@Bean
public Queue deadLetterQueue() {
return new Queue(deadLetterQueue);
} //DLQ
@Bean
public FanoutExchange fanoutExchange() {
return new FanoutExchange(exchangeName);
}
@Bean
public FanoutExchange deadLetterExchange() {
return new FanoutExchange(deadLetterExchange);
} //DLX
@Bean
public Binding binding(Queue queue, FanoutExchange fanoutExchange) {
return BindingBuilder.bind(queue).to(fanoutExchange);
}
@Bean
public Binding deadLetterbinding(Queue deadLetterQueue, FanoutExchange deadLetterExchange) {
return BindingBuilder.bind(deadLetterQueue).to(deadLetterExchange);
}
@Bean
public MessageConverter jackson2JsonMessageConverter() {
return new Jackson2JsonMessageConverter();
}
}
Expired로 인한 DLQ 이동
TTL이 30초로 설정된 메세지이다. TTL이 끝날동안 Consume되지 않자 expired됐다.
dead queue를 확인하자 expired된 메세지들이 모두 저장된 것을 확인할 수 있었다.
get message를 통해 dead queue에 쌓인 메세지를 꺼내 확인해보았다. expiration이 30초로 설정된 메세지가 expired 되어 dead queue로 들어왔음을 확인할 수 있었다.
rejected로 인한 DLQ 이동
이번엔 consume 되었으나 Error가 발생하여 dead queue로 옮겨진 경우를 테스트 해보았다.
사진에서는 큐에 아무 메세지도 쌓이지 않았던 것처럼 보이는데 queue에 머문 시간이 너무 짧아서 보이지 않는 것 같다.
이번에도 동일하게 dead queue에서 get message를 통해 메세지를 확인해보면 rejected된 메세지란 걸 확인할 수 있다.
4. Retry 설정
마음에 또 걸리는 부분이 생겼다. 거의 대부분의 상황에서 consumer는 전달받은 메세지를 정상적으로 처리될 것이다.
그러니 첫번째 시도만에 오류가 발생한다고 바로 메세지를 DLX로 보내기 보다는 최소 2~3번 정도는 재시도하길 원했다.
해당 설정은 간단하다. retry 설정을 true로 처리하면 된다.
1
2
3
4
5
6
7
rabbitmq:
listener:
simple:
retry:
enabled: true
initial-interval: 2s
max-attempts: 2
retry
: 소비자가 메시지를 처리하는 도중 예외가 발생했을 때 메시지를 자동으로 재시도한다.
initial-interval
: retry 사이에 주어지는 텀max-attempts
: 최대 재시도 횟수 (기본값 3)
위 설정을 적용하고 일부러 예외를 터뜨렸을 때, 동일한 예외가 2번 터지는 것으로 2번씩 재시도되고 있음을 확인할 수 있었다.
위와 같이 작업하긴 했으나 결과적으로 DLX, DLQ는 사용하지 않기로 했다. 비즈콜 메세지는 영원히 없어지든 말든 별로 상관없는 (안중요한) 메세지기 때문에…
그래도 이번 기회에 DLX를 작업하고 공부할 수 있어서 괜히 작업했다는 생각은 안한다 ^-^ rabbitMQ 공부할때 DLX를 안하면 아쉬웠을 거 같다.