📕 목차
1. Introduction
2. Distributed Transaction: Two Phase Commit
3. Outbox Pattern
4. SAGA Pattern
5. 그대는 어떤 선택을 할 것인가
6. 그냥 PostgreSQL 쓰세요
1. Introduce
📌 As-is
어느덧 유일 생성 ID 생성까지 진입한 채팅 서버 개발 포스트.
이제 본격적으로 채팅을 저장하고, 브로커에게 전달하는 과정을 구현하다가 한 가지 문제에 봉착했다.
처음 설계대로라면, 채팅 서버가 Broker에게 Message를 전달하고, 이를 소비하여 키-값 저장소에 저장하는 것이 구현 목표였다.
그러나 만약, Listener에서 키-값 저장소에 메시지를 저장하는 것이 실패한다면?
이렇게 됐을 때, 두 가지 문제가 발생한다.
- 이미 MQ에서 message를 소비했기 때문에, 애플리케이션에서 재시도 로직까지 실패했을 때, 데이터 일관성이 깨질 수 있다. (마지막의 마지막까지 작성하고 깨달은 사실은 이를 방지하기 위해 auto-ack을 false로 바꾸면 된다고 한다!)
- DB에는 데이터가 없는데, Client에게는 메시지가 전달되었을 수 있다.
그렇다면 Broker에 메시지를 전달하기 전에, 키-값 저장소에 메시지를 저장하도록 순서를 역전시킨다면?
- 저장은 성공했으나 메시지 전달에 실패한 경우, Client는 실시간으로 받지 못 했던 데이터를 새로 고침 이후에나 받을 수 있다. (키-값 저장소에서 채팅 이력 조회하는 과정에서)
다른 방법을 고민하다가, 키-값 저장소와 Broker에 메시지 전달하는 메서드를 Tx로 묶는 것도 고려해보고,
Broker에 전달하는 것이 실패하는 부분을 try-catch로 묶은 후에 성공했을 때만 키-값 저장소에 저장되도록 하는 아이디어도 떠올렸으나, 이 또한 데이터 일관성을 보장할 수 없었다.
- Tx로 묶는 경우: Broker에 메시지 전달 후, commit이 실패하면 처음 문제로 돌아간다.
- try-catch로 묶는 경우: Broker에 전달은 성공했으나, 키-값 저장소 저장이 실패하면, 이미 넘어간 Message를 취소할 수 없다.
📌 문제의 원인이 뭘까?
순서를 아무리 뒤집어 봐도 문제를 해결할 방도가 없었다.
그렇다면 이는 "순서"가 문제가 아닌, 다른 관점에서 문제를 재정의 할 필요가 있음을 의미한다.
내가 원하는 목적은 다음과 같았다.
🎯 메시지 저장, 전달 둘 중 하나라도 실패하는 경우, 모두 실패해야 한다.
이는 즉, 원자성(Atomic)을 어떻게 지킬지에 대해 고민하는 것이 옳다고 판단했다.
문제는 이걸 어떻게 원자성을 지킨단 말인가?
클로드에게 물어보니, Two-Phase Commit, Compensating Transaction, Outbox Pattern 등을 알려주는데
이렇게까지 공부만 하고 살아도 모르는 용어만 쭉쭉 튀어나오니 기가 막힐 따름.
그래서 분산 트랜잭션을 포함해서, 여러가지 방법들을 찾아보고 내게 가장 적절한 방법을 사용하고자 포스트를 작성하게 되었다.
근데 얼핏 훑어보니 MSA 키워드가 계속해서 튀어나오던데, MSA 관련 내용은 적당히 후려치면서 진행할 예정이다.
2. Distributed Transaction : Two Phase Commit
📌 분산 트랜잭션
아직 본격적으로 알아보기 전에도 이 방법은 내게 적합하지 않음을 알 수 있었다.
- NoSQL DB와 현대의 메시지 브로커는 분산 트랜잭션을 지원하지 않는다.
- 모놀리식에서 MSA로 변환하는 과정에서 나온 아이디어 같은데, 덕분에 내 서비스 수준에 맞지 않을 정도로 복잡하다.
- 추측컨데, 아이디어는 MSA보다 먼저 나왔을 거 같다.
- 성능이 상당히 느리다고 한다.
그래도 2PC가 뭔지 궁금하니까, 좀 더 자세히 딥 다이브 해보자.
📌 2PC, Two Phase Commit
2PC는 분산되어 있는 DB들 간에 원자적(atomic)인 Tx commit 처리를 위해 사용되는 알고리즘이라고 한다.
모놀리식, Spring을 사용한다면 @Transactional 하나로 끝낼 수 있었겠지만, MSA에선 다르다.
예전에는 여러 서비스, DB, 메시지 브로커에 걸쳐 데이터의 일관성을 유지하기 위해, X/Open DTP(Distributed Transaction Processing) 모델(X/Open XA)를 사용했는데, 이는 사실상 분산 트랜잭션 관리의 표준이다.
XA는 2PC으로 전체 Tx 참여자가 반드시 커밋 아니면 롤백(All or Not)하도록 보장하도록 만든다.
(문제는 이를 위해, XA 호환이 되는 DB, 메시지 브로커, DB 드라이버, 메시징 API, XA 전역 트랜잭션 ID를 전파하는 프로세스 등 별의 별 호환 기술이 쏟아진다.)
이를 핸들링하기 위해서 Coordinator라는 Tx 중앙 관리자라는 개념이 등장하는데,
작업이 모두 끝난 후 실제로 commit을 할 것인지 말 지를 결정하는 별도의 시스템을 필요로 한다.
- 애플리케이션이 분산 트랜잭션을 시작하기 전에, Coordinator에게 전역적으로 고유한 Tx ID를 요청한다.
- 애플리케이션이 각 참여자와 single-node trasaction을 시작하고, (1)에서 받은 Tx ID를 전달한다.
- 애플리케이션이 필요한 작업을 모두 수행하고, Coordinator에게 commit 준비가 되었음을 알린다. (Tx ID를 전달)
- Coordinator가 모든 참여자들에게 Tx ID가 포함된 prepare 요청을 전달한다.
- 이 중 하나라도 실패하거나 타임아웃되면, 모든 참여자들에게 abort 요청을 전달한다.
- 참여자가 Prepare 요청을 받으면, 참여자는 어떤 상황에서든 Tx를 commit할 수 있도록 준비(Tx 데이터를 디스크에 작성, 제약 위반 검증 등)하고 ok 응답을 반환한다.
- Coordinator가 모든 prepare 요청에 대해 ok 응답을 받으면, commit or abort를 결정한다.
- Coordinator는 이 때 내린 결정을 Disk에 존재하는 Coordinator Transaction Log에 기록하여, 혹시나 발생할 충돌에 대비한다. (commit point)
- Coordinator의 결정이 Disk에 write되고, commit or abort 요청이 모든 참여자들에게 전달된다.
- 이 때 요청이 실패하는 경우, Coordinator는 무슨 일이 있더라도 성공할 때까지 retry해야 한다. (한 번 내린 결정에 빠꾸없는 상남자 식 관리 방법)
- 참여자가 중간에 다운되었다면, 회복했을 때라도 Tx가 커밋될 수 있도록 그 결정이 강제되어야 한다.
📌 2PC 문제점
1️⃣ SPoF로 동작하는 Coordinator
- Tx의 commit, abort 여부를 결정하는 coordinator에 장애가 발생하면? 모든 서비스 장애
- 도중에 failure가 발생하면? tx에 대한 commit/abort 여부를 disk에 write해뒀지만, 각 참여자들에게 알려주지 않으면, DB가 무한정 lock을 걸어둔 상태에서 대기해야 함. → Dead Lock
- 바로 위 문제를 해결하기 위해, 각 DB들 간에 정보를 주고 받게 만드는데 이건 또 Race condition 문제가 발생하는 등 여러모로 문제가 많다.
더 자세히 알아볼 이유가 없어서 파고들지 않았다.
2️⃣ 낮은 가용성
- 분산 트랜잭션의 전제는 "참여한 서비스가 모두 가동 중"이어야 한다는 점.
- 에릭 브루어의 CAP 정리에 따르면, 시스템은 일관성(consistency), 가용성(availability), 분할 내성(partition tolerance) 중 두 가지 속성만 가질 수 있다. 즉, 2PC는 일관성과 분할 허용성을 가지므로, 가용성은 떨어진다는 의미.
- 요즘은 일관성과 가용성을 우선시한다.
3️⃣ 더 이상 지원하지 않는 상당수의 현대 기술들
- NoSQL DB와 현재 메시지 브로커(ex. RabbitMQ, Kafka)는 분산 트랜잭션을 지원하지 않는다.
4️⃣ 느린 성능
- 2PC는 단일 DB에 비해, (MySQL 기준) 10배 가량의 성능 차이가 발생한다고 한다. (내가 테스트 해본 게 아니라 확실한 정보는 아님)
3. Outbox Pattern
📌 Inbox & Outbox
Outbox 패턴이니까, Inbox 패턴도 있지 않을까 싶었는데 진짜 있었다.
우선 위 두 가지 패턴도 모두 MSA에서 분산 트랜잭션을 사용하지 않으면서, 메시지 전달의 일관성을 보장하기 위한 전략이다.
이 때, 메시지 전달 전략에는 세 가지 방법이 존재한다.
- At most once (최대 한 번): 메시지를 최대 한 번만 전달
- At least once (최소 한 번): 메시지를 최소 한 번 이상 전달
- Exactly once (정확히 한 번): 메시지를 정확히 한 번만 전달
Inbox, Outbox Pattern 모두 "At least once" 전략을 구사하는 방법에 대해 설명하고 있다.
(메시지가 최소한 한 번은 처리됨을 보장)
예를 들어, Actor가 API-1, API-2, Message Broker 총 3개라고 가정하자.
일반적인 플로우는 다음과 같을 것이다.
- Client의 요청을 API-1이 받는다.
- API-1의 비즈니스 로직을 수행한 결과를 DB에 저장하고, 이벤트를 브로커로 전달한다.
- API-2가 이벤트를 수신하고, 비즈니스 로직을 수행하고 결과를 DB에 저장한다.
여기서 문제는 (1)의 과정에서 API-1의 DB 저장은 성공했으나, 이벤트를 성공적으로 보내지 못 했을 때 발생한다.
API-1의 비즈니스 로직 수행 결과는 DB에 반영되었으나, API-2는 실행조차 하질 못 했고, 이에 대한 문제를 API-1에게 알려서 취소하지도 못 하게 되어, 일관성이 깨지게 된 것이다.
이 문제를 해결하기 위해, 어디선가는 성공적으로 처리될 때까지 재시도하는 로직을 담고 있어야 한다.
그리고 Inbox와 Outbox는 재시도를 위해 Event를 저장하는 위치에 따른 메커니즘 차이라고 이해하면 된다.
1️⃣ Inbox Pattern
💡 수신 메시지(Event)를 로컬(API-2) DB에 저장하고, 성공적으로 처리될 때까지 재시도한다.
- API-1이 비지니스 로직 처리 후, Event를 메시지 브로커에 발행한다. (이 때, API-1의 DB 저장과 메시지 발행은 원자적이어야 한다.)
- API-2가 Event를 수신하여 API-2의 DB에 저장한다.
- 이 때, 이벤트가 저장된 테이블을 INBOX 테이블이라 한다.
- API-2가 이벤트를 주기적으로 확인(ex. N초마다 INBOX 테이블을 검색)하여, 비지니스 로직을 수행한다.
- 처리가 성공하면 API-2는 이벤트 테이블의 메시지를 삭제한다.
2️⃣ Outbox Pattern
💡 발신 메시지(Event)를 로컬(API-1) DB에 저장하고, 성공적으로 처리될 때까지 재시도한다.
- API-1이 요청을 받아서 비지니스 로직을 처리한다.
- Entity와 Event를 API-1의 DB에 하나의 트랜잭션으로 저장한다. (이건 Local ACID Tx이므로, 원자성 보장이 쉽다.)
- 이 때, 이벤트가 저장된 테이블을 OUTBOX 테이블이라고 한다.
- 백그라운드가 됐건 뭐가됐건 주기적으로 OUTBOX 테이블을 읽어, Broker에게 Event를 발행한다.
- Broker에게서 ACK을 수신하면, OUTBOX 테이블에서 해당 Event를 제거한다.
- API-2가 Broker에게서 Event를 받아서 비지니스 로직을 수행한다.
✒️ Inbox와 Outbox의 결합
프로세스를 이해했다면 알 수 있겠지만, 결국 Inbox와 Outbox는 서로 비교 대상이 아니라, 원자성을 보장하기 위해 시도하는 곳이 어디냐에 따라 사용처가 달라질 뿐이다.
Outbox에서는 MQ에 메시지가 전달만 되었다면, 이벤트를 제거해버림으로써 API-2가 성공하든, 실패하든 아무런 관심이 없는 것을 알 수 있다.
그 말은 즉, API-2 비지니스 로직 수행 실패에 대해서는 발신자인 API-1의 책임을 벗어난다는 말이 된다.
따라서, 서비스 전체 Tx를 일관되게 유지하고 싶다면,
API-1에서는 Outbox Pattern을 사용하여 "적어도 한 번은 MQ로 Event가 전달"되도록 해야하며,
API-2에서는 Inbox Pattern을 사용하여 "적어도 한 번은 Event가 처리"되도록 만들어야 한다.
🤔 멱등성(idempotency)을 보장하려면?
Inbox & Outbox 패턴은 "At least one" 전략을 구사하기 때문에, "exactly once"를 보장하지는 않는다.
즉, 동일한 메시지가 중복되어 비지니스 로직으로 처리될 수 있는 우려가 있다.
멱등성, 즉 동일한 입력 값을 반복해도 아무런 부수 효과가 없음을 보장하려면, 중복 메시지를 걸러내는 메시지 핸들러를 구현해야 한다.
Consumer가 메시지를 처리할 때, Tx의 일부로 ID를 DB 테이블에 기록하거나, 애플리케이션 테이블에 메시지 ID를 기록할 수도 있다. (후자의 방식을 사용하게 되면, 애플리케이션 메모리가 점점 커질 우려가 있을 듯. 주기적으로 비워줘야 하지 않을까)
📌 How to transfer a message from DB to Message Broker
내가 구현하려는 것은 Client로 받은 채팅 메시지를 DB에 작성하고, 적어도 한 번은 MQ에 전달됨을 보장하고 싶은 것이다.
따라서 사용하게 된다면, Outbox 패턴을 사용하는 것이 적합하다.
문제는 Outbox 테이블에 저장된 Event를 주기적으로 확인하는 부분을 어떻게 구현할 것인가?
1️⃣ 폴링 발행기 패턴 (Polling Publisher Pattern)
이름부터 알 수 있듯이, 주기적으로 OUTBOX 테이블을 확인하는 전략이다.
Java 기반의 Spring이라면 다음과 같이 작성할 수 있다.
@Async
@Transactional
public CompletableFuture<Void> sendMessage(ChatMessage message) {
String messageId = generateMessageId();
message.setId(messageId);
return CompletableFuture.runAsync(() -> {
keyValueStore.save(messageId, message);
outboxRepository.save(new OutboxEvent(messageId, "chat.room." + message.getRoomId(), message));
});
}
Message를 DB에 저장함과 동시에 Outbox 테이블에 저장한다.
이 프로세스는 선언적 Tx에 의해 ACID가 보장된다.
@Scheduled(fixedDelay = 100) // 100ms마다 실행
@Transactional
public void processOutbox() {
List<OutboxEvent> events = outboxRepository.findUnprocessedEvents(1000); // 한 번에 1000개씩 처리
if (events.isEmpty()) return;
List<CompletableFuture<Void>> futures = events.stream()
.map(event -> CompletableFuture.runAsync(() -> processOutboxEvent(event)))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
private void processOutboxEvent(OutboxEvent event) {
try {
rabbitTemplate.convertAndSend(CHAT_EXCHANGE_NAME, event.getRoutingKey(), event.getMessage());
outboxRepository.delete(event); // 전달에 성공했으므로 삭제. 만약 여기서 실패하면, 중복 메시지가 발생할 수 있음에 주의
} catch (AmqpException e) {
// 로그 기록 및 재시도 큐에 추가
retryQueue.add(event);
}
}
그리고 Scheduler를 사용해서, 주기적으로 OUTBOX의 테이블을 확인하는 전략이다.
다만, 위 방식은 한 번에 두 개 이상의 DB에 Tx를 걸기 어려운 NoSQL을 사용하기엔 적절하지 않을 수 있다.
나는 Redis를 사용하고 있는데, 이런 경우엔 어떻게 해야 할까?
(물론 NoSQL의 Tx를 보장하기 위한 전략을 찾아볼 수도 있다.)
@RedisHash("chatMessages")
public class ChatMessage {
@Id
private String id;
private String roomId;
private String content;
@Indexed
private boolean published = false; // Outbox 상태를 나타내는 필드
private long createdAt;
...
}
내 생각엔 chatMessages 데이터를 저장할 때, published 여부를 판단하는 필드를 추가해주면 되지 않을까 싶다.
혹은, Lua script를 사용해서 원자적으로 할 수도 있고..뭐 여튼 방법은 다양하다.
✒️ Polling의 고질적인 단점
폴링 방식하면 매번 나오는 그 문제. "얼마나 자주 할 것인가?"
ChatMessage는 실시간성이 중요하므로, Scheduler를 무려 100ms마다 실행되도록 만들어놨다. ㅋㅋ
사실 이것도 느린 게, GUID는 10,000개/ms를 생성할 수 있는 퍼포먼스를 만들어놓고, 정작 메시지 발행이 100ms마다 진행된다.
즉, 채팅 메시지가 많이 오고갈 수록 요청이 점차 쌓일 수 있다. (물론 대규모 서비스 한정 ^^)
가장 큰 문제는 100ms마다 Redis에게 쿼리를 마구잡이로 날려대고 있는데, Redis가 아파한다.
우리 Redis를 괴롭히지 말자.
2️⃣ 트랜잭션 로그 테일링 패턴 (Transaction Log Tailing Pattern)
애플리케이션에서 commit된 update는 각 DB의 Transaction 로그 항목(log entry)으로 남는다.
이를 Transaction log miner가 트랜잭션 로그를 읽어, 변경분을 하나씩 메시지 브로커에 발행하는 전략.
Redis에서는 AOF(Append-Only-File)를 활성화하여, 모든 쓰기 로그를 남도록 하여 비슷하게 처리할 수 있지 않을까?
📌 Redisson을 사용하면 되지 않을까?
Poll 방식을 사용해선 도저히 Outbox Pattern을 사용해야겠다는 엄두가 나질 않는다.
채팅 서버에서 Scheduler를 사용해버리면, Outbox Event가 발행됐을 때 자칫 중복 메시지 문제가 발생할 수도 있으므로, 결국 새로운 애플리케이션을 하나 더 띄워야 한다는 이야기가 되는데,
그렇게까지 해서 한다는 게 100ms마다 Redis 조회하기? 말도 안 되는 선택지.
그나마 다른 방법을 구상하자면, 이 마저도 Pub/Sub 방식으로 동작하도록 Redisson을 사용하는 것이다.
이론 상 문제가 되지 않을 것 같지만, 하나 더 알아보려 했던 SAGA 패턴까지만 알아보고 마지막에 구현을 할 지 말지 고민해보자.
4. SAGA Pattern
📌 SAGA
SAGA가 약자인 줄 알고 한참을 찾았는데, 약자가 아니라 Long Lived Transactions를 의미하는 단어라고 한다.
SAGA 또한 분산 트랜잭션 시나리오에서 MSA 간의 데이터 일관성을 관리하는 방법이다.
다만 2PC처럼 Coordinator로 전체 트랜잭션을 관리하지 않고, 각 서비스가 Local Transaction을 가지고 있는 방식으로 구현한다.
정의만 보면 대충 무슨 이야기를 하고 싶은 지 얼추 감만 오고, 정확히 이해가 안 간다. 😇
조금만 더 자세히 알아보자.
SAGA는 메시지 주도(message-driven) 방식의 로컬 트랜잭션을 사용한다. (현재 서비스에서 데이터를 업데이트 하면서, 메시지 또는 이벤트를 발행하여, 다음 단계의 트랜잭션을 호출한다.)
그런데 ACID에서 I(격리성)을 보장하지 않는다는 기이한 특징을 가지고 있다. (사실 어찌보면 당연한 이야기. 로컬 트랜잭션에 대해서만 관심을 갖기 때문)
따라서 도중에 하나의 서비스에서 실패하게 되면, 보상 트랜잭션(Compensating Transaction)을 실행하여, 이미 commit하거나 반영한 이전 Tx 데이터들을 보정하여 데이터 정합성을 유지해야 한다.
💡 SAGA는 분산 컴퓨팅 아키텍처인 Eventual Consistency(최종 일관성)를 바탕으로 둔 로컬 트랜잭션을 연속적으로 업데이트 수행하는 패턴
즉, SAGA의 논리는 "각 단계는 로컬 Tx의 ACID만을 보장하지만, 최종적으로 데이터 무결성을 보장"한다는 것이다.
그리고 이런 SAGA 패턴을 구현하는 데는 두 가지 기법이 존재한다.
- 코레오그래프(Choreography)
- 오케스트레이션(Orchestration)
📌 Choreography
💡 중앙 제어 장치 없이 참여자가 각자 서로 이벤트를 교환하여 수행하자!
- 서비스 바로 하위의 DB 트랜잭션만을 관리하고 종료한 후 완료 이벤트를 발행
- 이어서 수행해야 할 트랜잭션이 존재하면, 해당 애플리케이션으로 완료 이벤트를 발행
- 해당 이벤트를 받은 애플리케이션이 계속 트랜잭션을 이어서 수행
- 마지막에 도달하면, 메인 애플리케이션에 결과를 전달하여 최종적으로 DB에 영속
⏪ Rollback
- 도중에 실패하면, 실패한 서비스에서 이전 서비스들의 트랜잭션 취소를 위한 보상 Event 발행
- 각 서비스에서 Event를 수신하여, Rollback 수행
📌 Orchestration
💡 중앙 제어 장치가 참여자가 해야 할 일을 지시하자!
- 트랜잭션을 수행하는 모든 참여자가 SAGA Manager에게 트랜잭션의 결과를 전달
- 마지막 트랜잭션이 수행되면, SAGA Manager는 전체 트랜잭션을 종료하고 인스턴스 소멸
⏪ Rollback
- SAGA Manager가 보상 트랜잭션을 실행하여, 각 서비스의 정합성 유지
📌 SAGA를 도입할 것인가?
SAGA 패턴을 엄청 대충 쓴 게 티가 났을 거 같은데, 찾아보다가 계속해서 드는 의문이 있었다.
나는...그저 발행할 때, Message를 저장하고 메시지 브로커로 전달하는 스텝의 원자성만 지켜지길 바랬을 뿐이다.
그런데 이건 너무 과하고, 내가 달성하고자 했던 목표의 문제를 과하게 해석하여 해결하는 방법이었다.
내가 MSA 공부하려고 쓴 글은 아니니까 여기서 cut
5. 그대는 어떤 선택을 할 것인가
📌 나는 어떤 전략을 선택해야 할까?
제목이 갑자기 철학적인 거 같은 건 기분 탓이 아니다.
아무리 생각해도, 위 모든 전략들은 내가 사용하기에는 문제를 너무 과대 해석해서 해결하려 하는 행위에 불과했다.
(그래서 공부 다 하고, 새로운 지식을 얻은 즐거움 반, 현타 반이 찾아왔다.)
분산 트랜잭션은 사실상 내가 해결하려는 문제를 해결하기엔 적합하지 않고,
Outbox 패턴은 재밌는 전략이긴 하나, Event를 감지하는 추가 애플리케이션 구현 비용 및 프리티어 EC2의 한정된 자원으로인해 도입할 수 없었다.
그래도 Outbox 패턴을 공부하면서, 내가 확실히 해결해야 할 문제를 다시 재정의할 수 있게 되었다.
📝 채팅 메시지를 저장하는 데 실패하면 예외를 반환하고, 저장에 성공했다면 반드시 메시지 브로커에 메시지를 전달해야 한다. 그러나 이 마저도 수행할 수 없는 경우, 저장한 메시지를 롤백한다.
이렇게 문제를 정의한다면 보다 쉽게 해결할 수 있는 방법들이 있다.
- Sync 재시도 로직
- DB 상태 저장 후, Async 재시도
- Dead Letter Queue로 처리
📌 재시도 로직
@Service
@RequiredArgsConstructor
public class ChatService {
private final ChatRepository chatRepository; // 키-값 저장소로 전달
private final RabbitTemplate rabbitTemplate;
public void sendMessage(ChatMessage message) {
String messageId = UUID.randomUUID().toString();
message.setId(messageId);
// Redis에 저장
if (saveToRedis(message)) { // 성공적으로 저장되면 메시지 브로커에 발행
publishToMessageBroker(message);
} else {
throw new RuntimeException("Failed to save message to Redis");
}
}
private boolean saveToRedis(ChatMessage message) {
boolean result = true;
try {
chatRepository.save(message);
} catch {
result = false;
}
return result;
}
@Retryable(value = Exception.class, maxAttempts = 3, backoff = @Backoff(delay = 1000))
private void publishToMessageBroker(ChatMessage message) {
rabbitTemplate.convertAndSend("chat.exchange", "chat.room." + message.getRoomId(), message);
}
}
코드는 IDE를 안 쓰고, 블로그에 다이렉트로 적는 바람에 틀린 부분이나, 엉성한 부분이 있을 수는 있다.
여튼 이런 키-값 저장소에 저장 자체를 실패하면 예외를 던져버리고, 성공한다면 메시지 브로커로 전달을 수행하게 하면 되지 않을까?
무한번 수행하도록 할 게 아니라면, 최대 시도 횟수를 정해주어야 할 것이다.
이제 문제는 이 마저도 실패한다면 어떻게 대처할 것인가가 관건이다.
📌 Event Sourcing Pattern
메시지의 상태를 CREATED, SENT, FAILED로 구분한다면 어떨까?
@Service
@RequiredArgsConstructor
public class ChatService {
private final RedisTemplate<String, ChatMessageEvent> redisTemplate;
private final RabbitTemplate rabbitTemplate;
private static final long MESSAGE_TTL = 24 * 60 * 60; // 24 hours in seconds
public void sendMessage(ChatMessage message) {
String messageId = UUID.randomUUID().toString();
message.setId(messageId);
if (saveToRedis(message, MessageStatus.CREATED)) {
try {
publishToMessageBroker(message);
updateMessageStatus(messageId, MessageStatus.SENT);
} catch (Exception e) {
updateMessageStatus(messageId, MessageStatus.FAILED);
throw e;
}
} else {
throw new RuntimeException("Failed to save message to Redis");
}
}
private boolean saveToRedis(ChatMessage message, MessageStatus status) {
String key = "chat:message:" + message.getId();
ChatMessageEvent event = new ChatMessageEvent(message, status);
redisTemplate.opsForValue().set(key, event, MESSAGE_TTL, TimeUnit.SECONDS);
return true;
}
private void updateMessageStatus(String messageId, MessageStatus status) {
String key = "chat:message:" + messageId;
ChatMessageEvent event = redisTemplate.opsForValue().get(key);
if (event != null) {
event.setStatus(status);
redisTemplate.opsForValue().set(key, event, MESSAGE_TTL, TimeUnit.SECONDS);
}
}
private void publishToMessageBroker(ChatMessage message) {
rabbitTemplate.convertAndSend("chat.exchange", "chat.queue", message);
}
}
- Event를 생성한 시점에는 CREATED.
- 메시지 브로커에게 정상적으로 전달했다면 SENT.
- retry마저 실패한 경우에는 FAILED
물론 위 로직은 너무 대충 구현한 터라, 이벤트 소싱 패턴이라고 보긴 힘들다.
그리고 key로 메시지를 탐색하고, 상태를 업데이트하는 로직은 원자성을 유지하기 위해 Lua Script를 사용해야 한다.
만약, 이 방법이 Silver bollet이라고 판단했다면, 수정한 코드를 올렸겠지만...이건 또 다른 문제가 발생한다.
정작 메시지 브로커에 전달은 성공해놓고, Message 상태 업데이트에 실패하게 되면 결국 원자성 보장에 실패하는 어처구니 없는 상황이 벌어진다.........🤦♂️
📌 Dead Letter Queue
사실 이거 작성할 때까지만 해도, 실패한 작업은 Dead Letter Queue로 전달해서 마저 처리하면 되지 않을까 싶었다.
그럼에도 실패하면 영구적으로 실패 처리하면 되기 때문
그러나 이 내용을 마저 찾아보면서 완전히 잘못된 접근임을 알게 되었는데, 추가적인 여러 문제점을 같이 적어두었다.
- Dead Letter Queue를 이용한 retry 메커니즘은 오로지 Consumer, 즉 구독 측 재시도를 위함이다.
- 생각해보면 일반적인 Queue 저장도 실패했는데, Dead Letter Queue 전달은 성공한다? 전제부터 이상한 일.
- Dead Letter Queue를 처리하기 위한, 서버 내 구독자 애플리케이션을 추가로 필요로 한다.
- 이미 채팅 서버와 RabbitMQ를 띄우는 것만으로도, 프리티어 서버로 운영 중인 내 입장에선 너무 부담스럽다.
📌 결론
그렇다면 나는 여기서 더 이상 뭘 더 시도해볼 수 있을까...
사실 이제 딱히 내가 아는 지식으론 더 이상 어떻게 해볼 여지가 남지 않았다.
아니, 솔직히 서버 운영비만 어떻게 할 수 있었어도 대안을 구상할 수 있을 거 같은데, 프리티어 서버라는 제약이 너무나도 컸다.
그래서 그냥 키-값 저장소에 저장이 되고, 메시지 브로커에 전달이 실패하면 키-값 저장소의 메시지 이력을 지우는 로직을 추가하는 정도로 만족하기로 했다.
이 방법은 두 가지 문제가 존재한다.
- 키-값 저장소에 저장은 되었으나 메시지는 발행이 되지 않아서, 클라이언트가 새로고침을 하면 못 보던 메시지가 튀어나올 수 있다.
- 브로커 전달 실패 시, 데이터 저장 취소를 위해 삭제 프로세스를 추가하긴 했으나, 이 또한 실패하면 (1)의 문제를 피할 수 없게 된다.
이 정도만 해도, 서비스 운영에 치명적이라고 할 수 있을 정도는 아니지 않을까 🥲
채팅 서버 구축하면서 처음으로 만족스러운 해결책을 내지 못 한 맛본 굴욕감...
나중에라도 대안책이 생각나면, 해당 포스팅을 바로 업데이트할 예정이다.
📌 (추가) 구독자로 관점을 옮기는 방법?
포스팅을 게시하고 집에 와서 다시 한 번 읽어보던 중에, 가장 첫 시작부터 오류가 있음을 찾아냈다. (이 당시엔 몰랐음)
Dead Letter Queue를 사용하지 못 했던 이유는 Redis 저장을 발행 시점에 처리하려 했기 때문인데,
일단 메시지 브로커에 전달하고, Listener 측에서 queue의 메시지를 키-값 저장소에 저장한다는 로직으로 수행한다면
구독자 관점으로 전환하여 수행할 수 있기는 했다.
그러나 이게 올바른 방법인지는 잘 모르겠다.
현재 최악의 상황은 사용자가 새로 고침을 했을 때, 느닷없이 전달받지 못 했던 메시지가 튀어나오는 것이다.
그러나 위 방식에서 최악의 상황은 채팅 도중엔 잘 받았으나, 새로고침을 하면 메시지가 사라진다는 것이다.
굳이 최악의 상황을 골라야 한다면 전자가 더 낫지 않을까..?
아, 자려고 누웠다가 호다닥 일어나서 다시 구상하고 있었는데, 결론은 다시 미궁으로 빠져버렸다.
이거 진짜 해답을 얻고 싶어..
📌 (추가2) 키-값 저장소를 쓰지 않는다면?
나는 멋모르고 rabbitmq-stream 의존성을 주입하긴 했지만, 갑자기 얘가 뭐하는 역할인지 찾아봤었다.
물론 정말 많은 내용들이 있지만, 내 눈에 들어온 것은 "소비자가 큐에서 메시지를 읽어도, 메시지를 지우지 않는다"는 것이었다.
현명한 생각일지는 모르겠으나, 한 가지 방법론 같은 느낌으로 접근해보면 RabbitMQ를 키-쌍 저장소의 역할까지 수행하게 하면 안 되는 걸까?
물론 트랜잭셔널 메시징 처리는 기가막히게 해결할 수는 있게 될 것이다.
그러나 굳이 Cassandra와 같은 NoSQL에 저장하는 이유는 room_id를 활용한 샤딩 전략을 사용하기 위함일 것이다.
이를 통해, 매우 빠르고 정확한 탐색이 가능한 것인데 RabbitMQ는 설계 자체가 키-쌍 저장소 역할이 아니기 때문에 성능 측면에서 뒤떨어지지 않을까? (테스트를 해봐야 알 듯)
또한 메시징과 데이터 저장은 별개의 관심사를 갖는다.
RabbitMQ를 데이터 저장소로 활용한다는 것은 시스템을 목적에 부합하지 않게 다루는 것이므로, 장기적으로 유지 보수에 상당한 어려움이 생길 수도 있다.
5. 그냥 PostgreSQL 쓰세요
📌 PGMO(Postgres Message Queue)
이 글을 작성하고 한 달이나 지난 이후, 아침에 일어나서 씻으면서 구글이 추천해주는 게시물 목록을 읽다가, 눈을 사로잡는 글이 하나 있었다.
여기서도 Outbox 패턴과 트랜잭션 로그 테일링에 간략하게 언급한 후, 우발적 복잡성(accidental complexity)에 대해 이야기 한다.
✒️ 우발적 복잡성
도메인 규칙 자체가 복잡해서 발생하는 본직적 복잡성(essential complexity)과는 달리,
최적화나 통합 등의 이유로 도입한 프레임워크, 데이터베이스들에서 비롯되는 복잡성
즉, 내 분석과 동일하게 위와 같은 해결책은 시스템을 과하게 복잡하게 만든다는 문제를 지적한다.
그런데 재밌는 건, PGMQ(Postgres Message Queue)를 사용하면, 이 문제를 아주 쉽게 해결할 수 있다는 것이다.
(나도 처음 들어본 건데, PostgresSQL을 Messaging Queue로 확장한 것이라고 한다.)
Outbox 테이블과 retry 혹은, 보낸 메시지를 삭제하거나 표시하는 등의 메커니즘이 이미 내장되어 있기 때문에, 이를 손쉽게 해결할 수 있다.
심지어 내가 하고 있던 다른 고민 중, relay 채널이 여러 개인 경우 이를 어떻게 제어할 지에 대한 경쟁 소비자(Competing Consumers) 문제까지 쉽게 해결할 수 있다고 한다.
물론, 이제와서 PostgreSQL로 넘어갈 수 있는 상황이 아니라서 내 프로젝트에 반영은 힘들지만, 정보 전달을 목적으로 추가해두었다.