← Articles

단일 인스턴스 환경에서 Redis MQ를 걷어내고 단일 큐로 전환하기

이전 글

이전에는 실시간 매칭 구조 리팩터링: 낙관적 락에서 MQ로 전환하기까지에서 다룬 것처럼, Node.js + Redis MQ로 입장/퇴장 이벤트를 큐에 넣고 순서대로 처리하는 구조를 썼다. 당시엔 동시 입장으로 세션 정원이 초과되거나 세션이 중복 생성되는 문제를 락(WATCH) 재시도로 풀려다가, 결국 이벤트를 한 줄로 세우는 대기열 구조로 정리했었다.

이후 이 기능을 Java/Spring으로 다시 구현하면서, 그 대기열 구조를 걷어내고 다시 로컬 락(자바 synchronized / Lock) 기반으로 되돌렸다. 그런데 락 3개로 정리한 뒤에도 비슷한 유형의 버그가 계속 나와서, 결국 그 락들도 전부 걷어내고 단일 스레드 큐 하나로 다시 바꿨다. 이 글은 MQ → 락 → 단일 큐로 이어진 두 번의 전환과, 그 과정에서 실제로 겪은 문제들을 정리한 글이다.

이전 · Redis MQ 서버 Redis 요청마다 네트워크를 2회 이상 왕복 최종 · 단일 큐(로컬) 서버 프로세스(JVM) 안에서 전부 처리 큐에 적재 단일 스레드가 순서대로 처리 네트워크 왕복 없음 — 락도 없이 순서대로 처리
이전(Redis MQ) vs 최종(단일 큐) 입장/퇴장 이벤트 처리 흐름 비교 — 중간에 락 3개를 썼다가 다시 큐로 정리했다

MQ 구조의 숨은 비용

Redis MQ 구조는 "여러 서버 인스턴스가 동시에 떠 있어도 이벤트 순서를 보장한다"는 장점이 있다. 그런데 실제 운영 환경을 다시 보니 이 서비스는 단일 인스턴스로만 떠 있고, 앞으로도 확장할 계획이 없었다.

단일 인스턴스라는 전제에서는 MQ가 해결해주는 문제(여러 프로세스 사이의 순서 보장, 상태 공유)가 애초에 발생하지 않는다. 입장/퇴장 이벤트 하나 처리할 때마다 Redis에 lPush/brPop으로 왕복하는 비용, 큐 컨슈머의 구독/차단 로직, signal 기반 제어 같은 복잡도를 계속 끌고 가는 게 필요 없는 네트워크 홉과 유지보수 비용만 남기는 선택이라고 판단했다.

첫 번째 시도 — 같은 JVM 안에서는 락으로 충분하다

MQ가 실제로 보장해주던 건 "동시에 들어온 이벤트를 한 줄로 세워서 순서대로, 그리고 원자적으로 처리한다"는 것이었다. 단일 인스턴스라면 이 두 가지는 자바의 ConcurrentHashMap과 명시적 락만으로도 충분히 만족시킬 수 있다고 판단해서, 가장 먼저 락 3개로 구조를 정리했다. (다만 이 락 3개는 최종 구조가 아니다 — 뒤에서 다시 걷어낸다.)

  • 세션 배정 시점의 원자성 → 전역 락(matching lock) 하나로 "포인터 확인 → 정원 판단 → 등록"을 하나의 임계구역으로 묶음
  • 회원/세션 단위의 동시 접근 방지 → 키 단위로 나뉜 락(member lock, session lock)
  • 맵 안에서의 조회-반영 원자성 → ConcurrentHashMap#compute, putIfAbsent 같은 원자적 연산

Redis WATCH 시절 겪었던 "감시할 키가 많아서 재시도가 잦다", "충돌 재시도는 순서를 보장하지 않는다" 같은 문제는 같은 프로세스 메모리 안에서 락을 쓰는 순간 아예 발생하지 않는 종류의 문제였다. 그 문제들 자체가 여러 프로세스가 공유 상태를 원격으로 조율해야 해서 생긴 문제였기 때문이다.

무엇이, 어떻게 바뀌었나

이 첫 번째 시도에서 실제로 쓴 락은 3개다. 각각 보호하는 대상이 다르다.

보호 대상없으면 생기는 문제
matching lock
(전역, 1개)
세션 포인터 확인 → 정원 판단 → 참가자 등록 동시 입장 시 정원 초과 배정, 세션 중복 생성
member lock
(회원 ID별)
같은 회원의 재접속·소켓 추가/제거 한 회원이 여러 기기로 동시 접속할 때 소켓 목록이 덮어써짐
session lock
(세션 ID별)
세션 시작·종료·퇴장 처리 같은 세션에 동시 접속·퇴장이 겹치며 상태가 꼬임

이 3개 락은 구조상 서로 겹칠 이유가 없다. 세션 A와 세션 B는 애초에 다른 락 객체를 쓰기 때문에, 세션이 여러 개 동시에 돌아가도 서로 기다릴 필요가 없다.

이 락 3개짜리 구조는 지금은 코드에 남아있지 않다. 그래서 락을 언제 잡고 언제 놓는지 세세하게 그리는 대신, 이 단계에서 고친 문제만 요약해서 남긴다: 참가자 조회~저장을 ConcurrentHashMap#compute 한 번으로 묶어 원자성을 확보했고, 세션별 락이 있는데도 걸려 있던 인스턴스 전역 락을 제거해 무관한 세션끼리 기다리지 않게 했고, socketId → memberId 역인덱스를 추가해 O(n) 조회를 O(1)로 줄였다. 구체적인 실수 하나하나는 아래 🎪 실수 축제에 정리해뒀다.

중간 결론 — MQ보다는 낫지만, 이게 끝은 아니었다

MQ는 여러 인스턴스, 여러 프로세스가 상태를 공유해야 하는 상황에서는 여전히 좋은 선택이다. 하지만 단일 인스턴스로만 운영되고 확장 계획도 없는 서비스에 MQ를 그대로 유지하는 건, 해결하지 않아도 될 문제(다중 프로세스 조율)를 위한 비용을 계속 지불하는 것이었다. 그래서 일단 그 비용을 걷어내고, 락의 범위를 필요한 만큼만 좁히고, 조회-반영 사이의 공백을 원자적 연산으로 없애는 방향으로 구조를 정리했다.

여기서 끝났다면 좋았겠지만, 실제로는 이 락 3개짜리 구조도 최종 답이 아니었다.

두 번째 전환 — 락 3개도 결국 걷어내고 단일 큐로

위 내용대로 락 3개를 정리한 뒤에도, 코드를 다시 들여다볼 때마다 비슷한 유형의 버그가 계속 나왔다. 락 범위가 겹치는 곳, 순서가 반대로 되어 있던 조건문, 정리가 빠진 락 맵, 락은 없는데 필드 하나가 volatile이 아니라 가시성이 안 보장되던 곳까지 — 세다 보니 한 자리 수를 훌쩍 넘겼다.

락 자체가 잘못된 도구는 아니었지만, "이 락이 어디서부터 어디까지를 지키는지, 다른 락과 순서가 안 뒤집히는지를 사람이 매번 정확히 맞춰야 하는 구조"가 계속 새로운 버그를 만들어내고 있다는 게 문제였다. 그래서 락 3개를 전부 없애고, 참가자·세션 상태를 건드리는 처리(입장, 재접속, 세션 시작 판단, 퇴장) 전부를 단일 스레드 큐 하나에서 순서대로 처리하는 구조로 다시 바꿨다. 지금 코드에 실제로 남아 있는 건 이 구조뿐이다.

입장(connect) 퇴장(disconnect) 세션 종료(scheduler) submit(task) 단일 스레드 큐 한 번에 하나씩, FIFO로 처리 참가자 / 세션 상태(Map) 항상 큐 스레드 하나만 접근 → 락 불필요
지금 구조 — 입장·퇴장·세션 종료 모두 같은 큐에 작업을 넣고, 큐를 처리하는 스레드는 항상 하나뿐이다

이러면 애초에 "동시에 두 스레드가 같은 상태를 건드릴 수 있는가"라는 질문 자체가 사라진다. 처리하는 스레드가 하나뿐이니 락으로 지킬 것도 없어지는 셈이다. 세션 A, B가 더 이상 병렬로 처리되지 않는다는 트레이드오프가 있지만, 단일 인스턴스·현재 트래픽 규모에서는 순차 처리로도 충분해서 손해가 없다고 판단했다.

결국 이번 글도 앞선 MQ→락 전환과 같은 결론으로 다시 돌아왔다: 정확성과 유지보수 비용이 성능보다 먼저 고려해야 할 문제였고, 지금 이 서비스 규모에서는 "제일 단순하게 순서를 보장하는 방법"이 곧 최선의 방법이었다.

🎪 실수 축제

부스트캠프에서부터 이어온 습관인데, 내가 한 실수를 스스로 정리해두면 다음에 똑같이 넘어지지 않는다고 믿는다. 그래서 이번에 겪은 실수들도 여기 남겨둔다.

락 맵 정리 누락

회원/세션별 락을 Map<Long, Lock>, Map<String, Lock>으로 관리했는데, 세션 종료 시 참가자·세션 데이터는 초기화하면서 이 락 맵을 비우는 코드가 빠져 있었다. 매칭 라운드가 반복될수록 락 맵이 계속 쌓이는 누수였다.

이미 락이 있는데 안쪽에서 또 잠금

전역 락을 잡은 상태에서 호출하는 메서드에 synchronized가 또 걸려 있는 경우가 있었다. 바깥 락이 이미 원자성을 보장하고 있어서 안쪽 락은 실질적으로 하는 일이 없었고, 오히려 "이 메서드는 혼자서도 안전하다"는 잘못된 신호를 주고 있었다. 실제로는 내부 로직이 조회-반영으로 쪼개진 상태(check-then-act)라 바깥 락 없이 혼자 호출되면 레이스가 그대로 드러나는 상태였다.

이건 getput하던 로직을 ConcurrentHashMap#compute 한 번으로 묶어서 조회와 반영 사이의 공백을 없애는 방식으로 정리했다. 공백이 없어지니 굳이 락을 겹쳐 걸 필요도 없어졌다.

서로 무관한 세션끼리 줄을 서게 만든 전역 락

세션별로 락을 이미 나눠뒀는데, 그 안에서 다시 인스턴스 전역 synchronized를 건 메서드가 있었다. 세션 A와 세션 B는 서로 관계가 없는데도, 두 세션이 동시에 정원을 채우는 순간 전역 락 때문에 한 세션씩 순서를 기다리게 됐다. 세션 단위 락이 이미 필요한 원자성을 다 지켜주고 있었기 때문에, 이 전역 락은 그냥 지웠다.

O(n) 스캔

소켓 ID로 참가자를 찾는 로직이 매번 전체 참가자를 순회하고 있었다. 세션이 끝나 여러 명이 한꺼번에 접속을 끊을 때 체감되는 지연이었다. socketId -> memberId 역인덱스를 하나 추가해서 O(1) 조회로 바꿨다.