안녕하세요! 오늘은 메시지 큐에 대해 알아보고자 합니다.
최근 많은 기업들, 혹은 프로젝트들이 MSA를 도입하고 있고, 이로인해 메시지 큐의 사용을 피할 수 없는 상황이 많아지고 있는데요.
이번 포스팅에서는 메시지 큐의 개념과, 이를 사용함에 있어 가장 많은 문제(?)를 일으키는 전달 보장에 대해 이야기해 보겠습니다.
메시지 큐를 사용하는데에는 여러 가지 이유가 있지만, 그중에서 가장 중요한(위대한) 이유는 바로 결합도를 끊어준다는 점이라고 생각합니다.
뭐 당연한 소리라고 받아들이시는분들도 계시겠지만, 이를 처음 접하시는 분들을 위해 약간의 비유를 해보자면 우리 실생활에서의 택배나 배달을 생각하시면 쉽게 이해하실 수 있습니다.
우리가 배민이나 쿠팡이츠에서 배달을 시켜 드실 때, 최근에는 전문 배달 기사분들이 배달을 해주고 계신데요.
하지만 불과 얼마 전만 해도 전문 배달기사라는 직종은 생각조차 못했고, 동네의 중국집들만 음식배달을 하고는 했잖아요?
다시 말해 당시에는 식당 주인이 배달을 위한 모든 준비와 운영을 직접 해야했어요.
필요에 따라 오토바이도 구매해야 하고, 그 오토바이를 운전하고 길도 잘 찾아다니는 배달원도 고용해야 했죠.
이게 말이야 간단하지, 가끔씩 교통사고가 나기도 하고 종종 배달원이 도망가는 경우도 있는데다가 오토바이에 대한 유지관리 비용은 물론, 고객에게 들어온 불평불만까지도 식당에서 모두 처리해야했어요.
단 하나의 '배달' 이라는 기능을 위해 수많은 부수적인 업무들이 생겼던 거죠.
그런데 요즘은?
식당에서는(물론 많은 비용을 지불하고 있지만) 음식을 준비해서 문쪽 테이블에 올려두면 식당과는 전혀 관련이 없는 배달 기사분들이 음식을 가져다가 고객의 주소로 정확하고 빠르게 배달해 주시죠.
자, 이렇게 이야기 하고 보니 아마 메시지 큐를 써보지 않으신 분들이라도 이게 왜 중요한지 이해하셨을 것 같습니다.
여기서 조금만 더 극단적인 예를 들어보자면, 이제는 배달원이 도망가는바람에 주방장이 음식 배달하러 갔다가, 교통사고가 나는바람에 음식점이 문을 닫는 경우는 (대체로) 없어졌다는 이야기죠.
즉, (메시지 큐를 적당히 잘 썼다면)특정 컴포넌트데 문제가 발생해도 전체 시스템에 영향이 가는 경우는 없어졌다는 이야기죠.
(물론 손님없는 식당이 무턱대고 배달 서비스만 비싼곳 여기저기 다 쓰다가 기둥뿌리 뽑히는 안타까운 경우가 생긴것 같기도 합니다)
이외에도 아래와 같은 장점들도 있으나, 분량을 위해 간략하게만 이야기 하고 넘어가겠습니다.
이렇게 메시지 큐를 사용하면 여러 가지 장점이 있지만, 사실 이러한 모든 장점을 얻기 위해서는 분명 비용이 들어갑니다.
단순히 돈을 더내고 덜내고를 넘어서, 관리해야 할 다른 문제들도 생겼습니다.
예를 들어, 이제는 식당 주인은 어떤 배달업체를 선정해야 하는지, 그 업체와의 계약은 어떻게 이루어지는지, 게다가 업체 안에서 우리 식당의 평이 나쁘지는 않은지 등등 알고 관리해야 할 것들이 많아졌습니다.
또한, 갑작스럽게 배달업체의 사정으로 인해 배달이 중지되거나 식당 주인이 처리 할 수 없는 문제가 발생하는 경우도 있겠죠.
이러한 현실처럼, 개발에서 메시지 큐를 도입하는 경우에도 마냥 좋은 일만 생기는 것은 아닙니다.
우리도 역시나 어떤 큐가 현재 상황에 가장 적절할지 고민해야 하고, 문제가 발생했을 때 어떻게 해결할지 고민해야 합니다.
그래서 이번 포스팅에서는 메시지 큐에서 자주 접하게 되는 문제인 전달 보장 수준에 대해 이야기해 보겠습니다.
메시지 큐에서 가장 중요한 문제 중 하나는 메시지가 전달되는 것을 보장하는 것입니다.
우리가 보낸 음식이 사라지면 안되잖아요?
물론 안타깝게도 여기서부터는 음식배달이라는 예시는 더이상 통하지 않겠지만 말이죠.
메시지 큐에서 전달보장(Delivery Guarantee)은 '시스템이 메시지를 얼마나 안정적으로 전달할 수 있는가?'를 의미합니다.
여기서는 크게 세 가지 수준으로 나눌 수 있습니다.
이들을 좀 더 쉽게, 간단하게(막말로)표현하자면 아래와 같습니다.
이들을 엄밀하게 정의하려면 사실 수신확인(ack; acknowledgment)이라는 개념이 필요한데요.
수신확인은 말 그대로 메시지를 받은 컴포넌트가 메시지를 정상적으로 받았다는 것을 확인시켜주는 과정을 의미합니다.(복명복창)
이제 아래에서 이들을 각각 좀 더 자세히 살펴보겠습니다.
⚠️ 메시지 큐에서 수신확인과 처리 완료는 다른 개념입니다.
수신확인은 메시지를 받았다는 것을 확인시켜주는 것일뿐, 처리 완료는 메시지를 정상적으로 처리했다는 것을 의미합니다.
즉, 수신확인이 되었다고 해서 반드시 처리 완료가 된 것은 아닙니다!
정확하게 정의하자면, 최대 한 번 전달의 경우는 ack = 0인 경우입니다.
즉, 메시지 큐는 컨슈머가 메시지를 받았는지 모릅니다. 다만 본인은 메시지를 보냈으니 메시지는 삭제되고, 만약 네트워크상에서 유실되었다면 메시지는 영영 사라지게 됩니다.
우리 생활에서 보자면 뭐 이런 경우가 있겠죠?
(현관에서 나가기 직전에) 나 오늘 친구랑 저녁 먹고 들어올거야!
안에있는 가족이 들었다면 좋겠지만, 사실 못들어도 큰일이 나는건 아니니 상관없습니다.
이를 구현하는 코드는 그만큼 간단하고, 고려할 사항도 적습니다.
전체 코드는 이곳에서 확인하실 수 있습니다.
/**
* 컨슈머가 사용하는 큐 어댑터
*/
export type QueueAdapter = {
// 메시지 처리. 수신확인 없음
sendMessage(message: Message): Promise<void>;
receiveMessage(): Promise<Message | null>;
};
/**
* 큐로부터 메시지를 수신한다.
* 이 과정에서 약 lossRate의 유실 확률을 가진다.
*/
async receiveMessage(lossRate = consumer.lossRate): Promise<Message | null> {
// 수신 소요시간
const receiveTimeAboutMS = this.jitter(consumer.receiveTimeAboutMS);
await new Promise((resolve) => setTimeout(resolve, receiveTimeAboutMS));
const message = await this.adapter.receiveMessage();
if (!message) {
console.log(`[${this.name}] No message received`);
return null;
}
// 유실 확률을 모의합니다. 큐의 구현과는 무관하지만 '네트워크상에서의 유실'을 흉내내기 위한 코드입니다.
if (Math.random() < lossRate) {
console.log(
`[${this.name}] Message loss - ${message?.id} ${message?.content}`
);
this.messageCounter.incrementLostMessages();
return null;
}
return message;
}
결과적으로 이런 방식에서는 메시지 100건을 전송했을 때, 아래와 같은 결과를 얻을 수 있습니다.
[Produced: 100, Consumed: 74, Lost: 22, Failed: 4, Total: 100]
이 경우에는 ack = n인 경우입니다.
즉, 메시지 큐는 컨슈머가 메시지를 받았는지 확인합니다.
만약 일정시간 내에 메시지를 받지 못했다면, 메시지를 다시 보냅니다.
이로인해 자연스럽게 메시지가 중복되어 전달될 수 있습니다.
이것도 실생활에 예를 들자면 뭐..
(카톡으로) 올 때 메로나좀 사와
(인스타 DM으로) 메로나 사오는거 잊어버리지 마
(문자로) 메로나 사오라니까 왜 답이 없냐. 차단했냐?
이런 느낌이 아닐까 싶습니다.
이를 코드로 구현하자면, 확실히 이전보다는 복잡해집니다.
전체 코드는 이곳에서 확인하실 수 있습니다.
/**
* 컨슈머가 사용하는 큐 어댑터
*/
export type QueueAdapter = {
// 메시지 처리
sendMessage(message: Message): Promise<void>;
receiveMessage(): Promise<Message | null>;
// 이전과는 다르게 처리 확인 및 재처리를 위한 메서드가 추가되었습니다.
acknowledgeMessage(message: Message): Promise<void>;
getPendingMessages(): Promise<Message[]>;
claimMessage(message: Message): Promise<void>;
};
실행한 결과는 아래와 같습니다.
다만, 최대 한 번 전달과는 다르게 검증이 조금 필요해서, 콘솔을 GPT에게 좀 부탁해보았습니다.
Redis에 연결되었습니다.
소비자 그룹이 이미 존재합니다.
메시지가 전송되었습니다. 애플리케이션 ID: 3436c530-490f-40af-9245-d032414281b3, Stream ID: 1740667397832-0
[Producer] Produced message: green
...
메시지 확인됨 - 애플리케이션 ID: 8bec6613-eaa2-4bd1-9223-49959d232d3e, Stream ID: 1740667406420-0
[consumer-1] Checking for pending messages...
최종 메시지 처리 상태:
생산: 100, 소비: 112, 유실: 17, 실패: 7
Redis 연결이 종료되었습니다.
===== 메시지 처리 결과 분석 =====
[1] 생성되었지만 소비되지 않은 메시지:
없음 (모든 생성된 메시지가 처리됨)
[2] 소비되었지만 생성되지 않은 메시지 (비정상):
없음 (정상)
[3] 중복 처리된 메시지 (at-least-once 특성):
총 12개 메시지가 중복 처리됨:
- 메시지 ID: 3d2cf458-265b-4c67-9d4d-a0c6bd488721, 처리 횟수: 2회
...
- 메시지 ID: c82e6dc0-deda-4131-bd2c-55b3b8f44aab, 처리 횟수: 2회
[4] 전체 처리 통계:
- 생산된 메시지 수: 100개
- 처리된 고유 메시지 수: 100개
- 중복 처리된 메시지 수: 12개
- 총 처리 횟수: 112회
[5] 최종 검증 결과:
✅ 성공: 모든 메시지가 최소 1회 이상 처리되었습니다.
===============================
Done
여기서는 콘솔에 설명이 매우 잘 나와있으니, 말로는 설명할 필요가 없겠네요.
이 경우에는 ack = 1인 경우입니다.
즉, 메시지 큐는 컨슈머가 메시지를 받았는지 확인합니다.
만약 일정시간 내에 메시지를 받지 못했다면, 메시지를 다시 보냅니다.
하지만 이렇게되면 최소 한 번 전달처럼 중복되어 전달될 수 있는데요,
이를 해결하기 위해 보통 메시지 처리의 멱등성(idempotence)을 보장하는 방식을 사용합니다.
사실 이건 실생활에서 정확한 예시를 들기가 좀 어렵네요.. 혹시 아이디어 있으시면 알려주세요!
전체 코드는 이곳에서 확인하실 수 있습니다.
이번에는 최소 한 번 전달에서 멱등성을 보장하는 방식으로 구현하였고, Consumer
클래스에서 이를 처리하는 부분만 추가되었습니다.
/**
* 컨슈머가 사용하는 큐 어댑터
*/
export type QueueAdapter = {
// 메시지 처리
sendMessage(message: Message): Promise<void>;
receiveMessage(): Promise<Message | null>;
acknowledgeMessage(message: Message): Promise<void>;
getPendingMessages(): Promise<Message[]>;
claimMessage(message: Message): Promise<void>;
// 멱등성 처리를 위한 메서드가 추가되었습니다.
isMessageProcessed(messageId: string): Promise<boolean>;
markMessageAsProcessed(messageId: string): Promise<void>;
};
// 컨슈머에서 메시지를 처리하기 전에 아래와 같이 처리 여부를 확인합니다.
const isProcessed = await this.adapter.isMessageProcessed(message.id);
...
// 메시지가 처리되었다면 처리 기록을 남깁니다.
await this.adapter.markMessageAsProcessed(message.id);
실행한 결과는 아래와 같습니다.
Redis에 연결되었습니다.
소비자 그룹이 이미 존재합니다.
메시지가 전송되었습니다. 애플리케이션 ID: e833a747-04b1-4c20-af19-96875a499722, Stream ID: 1740897918361-0
[Producer] Produced message: green
...
메시지 확인됨 - 애플리케이션 ID: 2477f6eb-cb8b-4b4e-a010-208f57aeb0d6, Stream ID: 1740897925343-0
[Produced: 84, Consumed: 91, Lost: 13, Failed: 8, Total: 112]
[consumer-1] Checking for pending messages...
[consumer-2] Checking for pending messages...
[consumer-2] No pending messages found
[consumer-1] No pending messages found
최종 메시지 처리 상태:
생산: 100, 소비: 113, 유실: 15, 실패: 9
Redis 연결이 종료되었습니다.
===== 메시지 처리 결과 분석 =====
[1] 생성되었지만 소비되지 않은 메시지:
없음 (모든 생성된 메시지가 처리됨)
[2] 소비되었지만 생성되지 않은 메시지 (비정상):
없음 (정상)
[3] 중복 시도되었지만 처리되지 않은 메시지 (exactly-once 특성):
총 13개 메시지가 중복 시도됨:
- 메시지 ID: 7a70e2d9-8d47-43ef-adcf-a127292ad078, 처리 횟수: 2회
...
- 메시지 ID: 1a96b4cb-d467-4234-9069-a51c7eb4d7c1, 처리 횟수: 2회
[4] 전체 처리 통계:
- 생산된 메시지 수: 100개
- 처리된 고유 메시지 수: 100개
- 중복 시도되었지만 처리되지 않은 메시지 수: 13개
[5] 최종 검증 결과:
✅ 성공: 모든 메시지가 정확히 1회 처리되었습니다.
===============================
Done
지금까지 메시지 큐의 개념과 세 가지 전달 보장 수준(최대 한 번, 최소 한 번, 정확히 한 번)에 대해 알아보았는데요.
이제 위에서 알아보았던 세 가지 전달 보장 수준을 표로 간략하게 정리하고 마무리 하겠습니다.
전달 보장 수준 | 설명 | ack | 목적 | 용도 |
---|---|---|---|---|
최대 한 번 전달 | 메시지를 전달하고 수신확인을 하지 않습니다. | 0 | 메시지 유실이 허용되는 경우 | 중요하지 않은 정보 전달 |
최소 한 번 전달 | 메시지를 전달하고 수신확인을 합니다. | n | 중복 처리가 허용되는 경우 | 이메일 등 꼭 필요한 정보 전달 |
정확히 한 번 전달 | 메시지를 전달하고 수신확인을 합니다. | 1 | 메시지 유실이나 중복 처리가 허용되지 않는 경우 | 예금 이체, 거래 등 |
여기까지 읽어주셔서 감사하고, 혹시 잘못된 부분이나 추가할 내용이 있으시다면 댓글로 알려주시면 감사하겠습니다!