실시간 매칭 구조 리팩터링: 낙관적 락에서 MQ로 전환하기까지
문제 상황
- 두 명 이상이 동시에 입장할 때, 세션 정원을 충족해도 입장이 가능하다고 판단하여 정원보다 많은 사용자가 한 세션에 매칭되는 문제
- 두 명 이상이 동시에 입장할 때, 새로운 세션을 생성하는 과정이 중복으로 실행되며 세션이 두 개 만들어지고, 먼저 생성된 세션에는 사용자가 정상적으로 매칭되지 않는 문제
낙관적 락
처음에는 Redis의 WATCH 기능을 이용해 입장·퇴장 시점의 데이터를 스냅샷처럼 보호하고, 변경 충돌이 발생하면 재시도하는 낙관적 락 방식을 선택했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
for (let i = 0; i < 3; i++) {
this.redis.watch([participantsKey, memberKey]);
const result = this.redis.multi()
.hAdd(participants, memberId)
.hSet(memberKey, name, '익명')
...
.exec(); // 호출 시 자동으로 unwatch
// 실패 시 재시도
if (result == null) {
continue;
}
}
하지만 실제 운영 시 다음과 같은 문제가 있었다.
감시해야 할 키가 많다.
- 회원 정보 키
- 세션 참여자 목록 키
- 세션 상태 키
- 세션 포인터 키
이처럼 여러 키를 동시에 감시하면 누구 하나만 갱신해도 전체 트랜잭션이 실패하기 때문에 동시 입장 상황에서 재시도가 빈번했다.
충돌 재시도는 순서를 보장하지 않는다.
WATCH는 충돌 여부만 알려줄 뿐 순서를 보장하지 않는다.
- 퇴장 이벤트
- 입장 이벤트
이 두 이벤트가 거의 동시에 발생하면 퇴장이 먼저 와야 하는 상황에서 입장이 먼저 처리되는 반전 현상이 발생할 수 있었다.
유지보수 난이도가 높다.
감시 대상 키가 많아지고, 재시도 로직도 고려해야 하며, 입장/퇴장/재입장의 흐름까지 세밀하게 관리해야 한다. 장기적으로 복잡한 상태 머신이 되어 실수 발생 가능성이 커질 것으로 판단했다.
이런 이유로 보다 안전하고 예측 가능한 구조가 필요했고, 결과적으로 이벤트를 직렬화하여 처리할 수 있는 대기열을 고려하게 되었다.
대기열 적용
문제의 본질은 “동시에 여러 이벤트가 들어오고, 이를 Redis 상태 조회와 함께 처리한다”는 점이었다. 동시성을 자연스럽게 해결하려면, 이벤트를 한 줄로 세워 순서대로 처리하는 구조가 필요했고, 이에 따라 Redis Message Queue를 도입했다.
우선 입장/퇴장 이벤트를 큐에 push하도록 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async pushEnterQueue(data: { memberId: number; socketId: string }) {
const json = JSON.stringify({
type: BlindDateQueue.ENTER,
timestamp: Date.now(),
...data,
});
await this.redisClient.lPush('blinddate:queue', json);
await this.redisClient.publish('blinddate:queue:signal', 'new');
}
async pushLeaveQueue(data: { memberId: number; socketId: string }) {
const json = JSON.stringify({
type: BlindDateQueue.LEAVE,
timestamp: Date.now(),
...data,
});
await this.redisClient.lPush('blinddate:queue', json);
await this.redisClient.publish('blinddate:queue:signal', 'new');
}
초기 구현: polling 기반 처리
음에는 단순한 while 루프를 이용해 큐를 계속 확인하는 방식으로 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
private async start() {
console.log('[QueueConsumer] started');
while (true) {
const job = await this.redisClient.rPop('blinddate:queue');
if (!job) {
await setTimeout(2000);
continue;
}
await this.process(JSON.parse(job) as JobType);
}
}
큐가 비어 있으면 2초 쉬었다가 다시 확인하는 방식이다.
스레드 점유 문제
하지만 무한 루프는 메인 스레드를 점유하는 문제로 이어졌다.
HTTP 요청 처리 지연 및 Socket.IO 이벤트 수신이 누락되었다.
이 구조는 단일 서버 환경에서는 괜찮아 보이지만 Node.js의 이벤트 루프 특성과 맞지 않아 전체 서버 응답성이 떨어지는 문제가 있었다.
Pub/Sub 구조로 전환
이 문제를 해결하기 위해 Message Queue에 이벤트가 들어왔을 때만 처리하는 이벤트 기반 구조로 변경했다.
큐에 작업을 넣을 때 publish를 함께 실행하고,
subscriber는 해당 신호를 받았을 때만 큐를 확인하도록 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private async start() {
console.log('[QueueConsumer] started');
const subscriber = this.redisClient.duplicate();
await subscriber.connect();
await subscriber.subscribe('blinddate:queue:signal', async () => {
while (true) {
const job = await this.redisClient.rPop('blinddate:queue');
if (!job) {
// 더 이상 처리할 게 없으면 중단
break;
}
await this.process(JSON.parse(job) as JobType);
}
});
}
적용 후 아래와 같이 문제들이 해결되었다.
- 불필요한 루프 제거
- 메인 스레드 점유 문제 해소
- HTTP 요청과 Socket.IO 이벤트 정상 처리
- 모든 이벤트가 순서대로 안전하게 처리됨
결론
락을 사용해서 해결해보려고 했지만, 복잡도를 봤을 때 문제 해결 후에도 문제가 될 수 있다고 생각되었다.
결과적으로 대기열을 사용해 안정적으로 사용자 경험을 만들어 내는 것이 중요하다고 판단했다.