[ ] 앞전 페이지 내용에 대한 의문을 여기서 보완해주기 여기서 예리하신 분들은 질문하실 겁니다. '인스턴스 3개가 동시에 돌면 순서가 꼬이지 않나요?' 맞습니다. 이게 분산 시스템의 가장 큰 함정이죠.
먼저 독자들에게 왜 문제가 생기는지 그림으로 보여줘야 합니다.
이벤트 ①: "채팅방 입장" (Msg 1)이벤트 ②: "메시지 전송" (Msg 2)이벤트 ① 처리 시작 (조금 느림)이벤트 ② 처리 시작 (빠름)가장 아키텍처적인 해결법입니다. **"같은 유저의 이벤트는 무조건 같은 인스턴스로 보내자"**는 전략입니다. (Kafka의 파티션 키와 비슷한 개념을 NATS로 구현하는 법)
user.event -> user.event.{UserID}user.event.1* 구독 (ID가 1로 시작하는 유저 전담)user.event.2* 구독 (ID가 2로 시작하는 유저 전담)user.event.3* 구독가장 현실적이고 유연한 방법입니다. (아까 우리가 이야기했던 그 주제와 연결됩니다!)
"순서가 섞여서 들어오는 건 어쩔 수 없다. 하지만 DB에 저장할 때 순서가 맞는지 검사하자."
이벤트 ② (버전 2)가 먼저 도착함.이벤트 ① (버전 1)이 도착 → 정상 처리 (버전 0 → 1 업데이트).이벤트 ②가 재전송됨 → 정상 처리 (버전 1 → 2 업데이트).
문제 상황
이벤트 메세지 → nats jetstream → consumer → Database Jobs(INSERT, UPDATE, DELETE …) 의 흐름에서 부하 분산을 위해 2개 이상의 consumer를 배치하는 경우,
이벤트 메세지를 누적해서 얻은 대상의 최종 status와 데이터베이스에 반영된 최종 status 값이 불일치할 수 있는 문제.
문제 상황을 잘 설명하는 사례
채팅 메세지 발생 (a) → 채팅 메세지 내용을 ‘1’ 로 수정 (b) → 채팅 메세지 내용을 ‘123’ 로 수정 (c)
해결 방법
이벤트 메세지에, 직전 이벤트 메세지의 updatedAt, 즉 업데이트 된 타임스탬프 값을 기재한다.
채팅 메세지 발생 (a, updatedAt = ‘’)
→ 채팅 메세지 내용을 ‘1’ 로 수정
(b, updatedAt = ‘20260102111520123’, previousUpdatedAt = ‘’)
→ 채팅 메세지 내용을 ‘123’ 로 수정
(c, updatedAt = ‘20260102111550123’, previousUpdatedAt = ‘20260102111520123’)
이벤트 메세지에, version 넘버링을 한다. (1씩 증가)
채팅 메세지 발생 (a, 버전 0)
→ 채팅 메세지 내용을 ‘1’ 로 수정 (b, 버전 1)
→ 채팅 메세지 내용을 ‘123’ 로 수정 (c, 버전 2)
또 다른 문제(더 구체적인 문제)
사례:
채팅 메세지 테이블과 첨부파일 테이블로 분리가 된 상황을 가정하자.
채팅 메세지에 첨부 파일을 첨부하고 나서, 채팅 메세지를 수정할 때 첨부파일 리스트도 같이 수정을 하는 경우는 어떻게 데이터 정합성을 보장할 수 있을까?
채팅 메세지 + 첨부파일(1,2,3) 작성 (a)
→ 채팅 메세지 내용을 ‘1’ 로 수정 + 첨부파일에서 1번파일 제거 (b)
→ 채팅 메세지 내용을 ‘123’ 로 수정 + 첨부파일에 4번파일 추가 (c)
→ 첨부파일에서 3번 파일 제거(d)
→ 채팅 메세지 내용을 'abderer' 로 수정(e)
기대하는 최종 상태값:
- 채팅 메세지 내용: 'abderer'
- 첨부파일 목록: (2,4)
내가 생각했던 해결책
이벤트 메세지 페이로드에 채팅메세지와 첨부파일을 하나로 묶어준다.
채팅 메세지와 첨부파일 관련 SQL 쿼리를 하나의 트랜잭션으로 묶어준다.
채팅메세지와 첨부파일 테이블 모두 각각 version 칼럼을 배치한다.
하나의 트랜잭션이 성공하면 채팅메세지와 첨부파일 테이블의 버전을 모두 같은 값으로 업데이트한다. 즉 사례에 적용해보면 다음과 같이 풀어볼수 있겠다.
채팅 메세지 + 첨부파일(1,2,3) 작성 (a)
-- 채팅메세지 버전 0, 첨부파일 버전 0
채팅 메세지 내용을 ‘1’ 로 수정 + 첨부파일에서 1번파일 제거 (b)
-- 채팅메세지 버전 1, 첨부파일 버전 1
select * from message where version = 0 (채팅메세지 row = 0개면 돌려보낸다)
채팅 메세지 내용을 ‘123’ 로 수정 + 첨부파일에 4번파일 추가 (c)
-- 채팅메세지 버전 2, 첨부파일 버전 2
select * from message where version = 1 (채팅메세지 row = 0개면 돌려보낸다)
첨부파일에서 3번 파일 제거(d)
-- 채팅메세지 버전 3, 첨부파일 버전 3
(첨부파일 버전이 올라갈때 채팅메세지 버전도 같이 올려준다)
select * from message where version = 2 (채팅메세지 row = 0개면 돌려보낸다)
채팅 메세지 내용을 'abderer' 로 수정(e)
-- 채팅메세지 버전 4, 첨부파일 버전 4
(채팅메세지 버전이 올라갈때 첨부파일 버전도 같이 올려준다)
select * from message where version = 3 (채팅메세지 row = 0개면 돌려보낸다)
(그런데 사장님께선 실무에서 진짜 트랜잭션을 써야할만큼의 데이터 정합이 깨지는 문제는 드물다고 했는데… 이 말은 내가 어떻게 받아들여야 할지 정말모르겠다. 트랜잭션 때문에 데이터베이스에 락이 걸리는 문제를 우려하셨던 걸까. ? 내가 이렇게 이해도가 낮았나ㅠ?)
결론: **동시 수정 확률이 낮으니 트랜잭션 비용보다는 낙관적 락(Optimistic Lock)이나 이벤트 순서 보장으로 가볍게 풀라'는 뜻으로 이해해야 한다.
첨부파일만 지워도 메시지 테이블에 Lock을 걸어야 합니다. 트래픽이 몰리면 DB 성능 저하(Contention)가 발생할 수 있습니다.① Aggregate Root 패턴 (DDD 관점)
채팅 시스템에서 첨부파일은 독자적인 존재가 아니라, 메시지의 **부속품(Value Object)**입니다.
Message가 **Aggregate Root(주인)**이고 Attachment는 그냥 속성입니다.Attachment 테이블을 따로 나누지 않고, Message 테이블의 **attachments 컬럼(JSON)**에 [{"id":1}, {"id":2}] 이렇게 박아버리는 경우가 많습니다.Message 행 하나만 업데이트하면 원자성(Atomicity)이 자연스럽게 보장됩니다.낙관적 락 (Optimistic Locking)만으로 충분