메세지 상태 업데이트는 어디서 처리해야 할까?
배경
Push 프로젝트 에서 Sender
가 메시지를 소비한 후, messageStatus
를 변경해야 하는 요구사항이 생김.
이걸 어떤 모듈에서, 어떤 방식으로 처리할지 고민이 많았음.
메세지 재시도 로직이나, 클라이언트가 메세지를 읽었음을 나타내기 위해
status
를 변경해야 하기 때문.
고민한 옵션들
Sender에서 직접 DB에 접근
- 구현은 쉽지만, 서비스 간 DB 접근이 파편화된 모놀리식 구조가 된다고 판단하여 제외.
Sender → Linker로 gRPC/HTTP 요청
- 메시지 상태 변경 책임을 Linker로 위임. 구조가 명확해지고 결합도 낮아짐.
Linker가 상태 변경 메시지 큐를 소비
- 느슨한 결합, 완전 비동기지만 메시지 순서나 장애 처리 등 복잡도 향상
1번을 제외한 2번과 3번중에서 많은 고민이 있었다
3번을 적용하게 될 경우 linker
는 메세지를 publish하는 주체이면서도 메세지 상태변경 큐를 소비하는 소비자가 되는 셈인데 이러한 구조가 당시에 는 어색하다고만 느껴져서
최종적으로는 2번의 방식을 채택하였다
하지만 2번의 방법도 문제는 있었다
문제 발생: 하나의 요청당 하나의 디비 쿼리?
2번 방식으로 구현했을 때, 다음과 같은 문제가 보였음:
- 메시지를 1만 명에게 보낼 경우,
Sender → Linker
로 1만 건의 요청 발생 Linker
는 이 요청마다 1만 건의 UPDATE 쿼리를 DB에 수행- 결국 과도한 DB I/O가 발생하고, 병목 가능성 존재
이 문제는 3번 방식에서도 동일하게 발생할 수 있는 구조적 한계라고 판단
개선: Linker
내부 큐 + 배치 처리 도입
Sender
는 기존처럼 gRPC로 상태 변경 요청을 보냄Linker
는 해당 요청을 내부 in-memory 큐에 적재- 별도의 워커(goroutine)가 주기적으로 큐를 확인하여 Bulk Update 쿼리 수행
업데이트하려고 하는 상태가 같은 메세지 아이디들 끼리 묶어 bulk Update
하는 방식으로 개선을 하였다.
-- X
UPDATE messages SET status = 'sent', updated_at = NOW() WHERE id = 65;
UPDATE messages SET status = 'sent', updated_at = NOW() WHERE id = 66;
-- O
UPDATE messages SET status = 'sent', updated_at = NOW() WHERE id = 65 or id = 66;
회고
단순한 구조에서 시작했지만, 해당 서비스가 많은 트래픽을 감당하려면 어떤 구조가 되어야 할까 에서 시작된 고민이었다
글을 적다보니 메세지를 소비하는 sender
에서도 마찬가지로 내부 큐를 통해 linker
에게 여러번 요청을 보내는 것이 아닌, 변경하려고 하는 상태가 같은 messageId들을 배열형태로 담아 보내는 방법도 괜찮을 것 같다는 생각이 들었다
많은 서비스 기업들의 아키텍처 설명 영상이나 자료를 보면, 보통은 여러 서비스가 이벤트를 소비하며 느슨한 결합을 유지하는 부분에 초점을 맞추지 만, 그 이벤트에 대한 상태값 변경을 어떻게 처리하는지에 대해서는 깊이 언급하는 경우가 드물어서 어떻게 처리하는지 계속 찾아보려고했던 것 같다.