대기열 서버를 3대로 늘렸더니 입장 인원이 3배가 됐다
대기열 서버를 3대로 늘렸더니 입장 인원이 3배가 됐다
대기열 구조부터
티켓팅을 소재로 고동시성 시스템을 밑바닥부터 만들어보는 학습 프로젝트를 하고 있다. 대기열은 직접 만든 Redis(MyRedis)의 sorted set 위에 올라가 있다. 유저가 오면 대기열에 넣고, 주기마다 앞에서 slot 수만큼 꺼내(zpopmin) 입장시키고, 지금 입장해 있는 인원을 activeCount로 센다.
queue-service 한 대일 때는 아무 문제가 없었다. 문제는 이걸 여러 대로 늘리면서 시작됐다.
3대가 각자 문을 열었다
가용성 때문에 queue-service를 3대로 다중화했다. 그러자 입장 로직이 이렇게 돌았다.

세 대가 전부 "내가 입장시켜야지" 하고 각자 admit 주기를 돌린 거다. 인스턴스마다 자기 기준으로는 정당하게 slot 5를 지켰는데, 합치면 15명이다. activeCount도 같이 폭주했다. 대기열의 존재 이유가 "동시 입장 인원 제한"인데, 서버를 늘리는 순간 그 제한이 서버 수에 비례해 뚫렸다. 실제로 뒤에서 부하를 줬을 때, slot 100짜리 대기열에 최대 193명까지 들어왔다.
admit이라는 작업 자체가 "전체에서 딱 한 번"이어야 하는 일인데, 인스턴스들은 서로의 존재를 모른다. 처음엔 누군가 한 명만 문지기를 세우면 된다고 생각했다.
첫 시도: SET NX 리더 선출
그래서 리더 선출을 넣었다. 거창한 합의 알고리즘(Raft 같은)까지 갈 것 없이, Redis 계열 저장소가 있으면 제일 값싼 방법이 있다. SET NX(없을 때만 set)의 원자성을 이용하는 거다.
매 주기마다:
SET queue:leader "instance-A" NX EX <주기+여유>
→ 성공한 인스턴스: 이번 주기의 리더. admit 실행.
→ 실패한 인스턴스: 리더가 이미 있음. 이번 주기는 아무것도 안 함.
SET NX는 키가 없을 때만 성공하고, 그 확인과 쓰기가 한 번의 원자적 연산이다. 세 대가 동시에 도전해도 딱 한 대만 성공한다. 성공한 그 한 대만 이번 주기에 zpopmin을 돌리고, 나머지는 다음 주기를 기다린다. TTL을 걸어두니 리더가 죽어도 키가 만료되면서 다음 주기에 다른 인스턴스가 자리를 이어받는다.

이걸로 "한 주기에 문지기 한 명"이 보장된다. 서버는 3대지만 admit은 한 곳에서만 돈다.
이 리더 선출 전체가 "SET NX가 원자적"이라는 전제 위에 서 있는데, 그 원자성은 공짜가 아니라 MyRedis 커맨드 처리가 단일 스레드라서 나오는 성질이다. 진짜 Redis가 명령을 한 줄로 세워 처리하듯, MyRedis도 워커 1개가 커맨드를 순서대로 집행해야 "키 확인 → 쓰기" 사이에 아무도 못 끼어든다. 만약 성능 욕심에 커맨드 워커를 여러 개로 늘리면, 두 인스턴스의 SET NX가 동시에 "키 없음"을 보고 둘 다 성공할 수 있다. 그 순간 리더가 두 명이 되고, 15명 사태가 그대로 돌아온다.

리더를 걷어냈다: 낙관적 락
리더 선출로 정원은 지켜졌는데, 걸리는 게 있었다. 서버를 3대로 늘린 이유가 가용성과 처리 분산인데, admit을 한 대만 돌리면 나머지 두 대는 그 시간에 논다. "한 명만 일하게" 만든 거라, 다중화의 이점을 admit에서는 도로 반납한 셈이다.
그러다 생각을 뒤집었다. 애초에 "누가 admit하냐"를 정할 필요가 없다. 각자 admit하되, 넘치면 되돌리면 된다. ZPOPMIN·ZADD·ZCARD가 전부 원자적이니까.
방식은 이렇다. 큐 맨 앞에서 한 명을 ZPOPMIN으로 꺼내고, 활성 집합에 ZADD로 일단 '선점'한 뒤, ZCARD로 활성 수를 센다. cap을 넘었으면 방금 넣은 걸 도로 빼고(롤백) 그 유저를 큐에 복원한다. 안 넘었으면 확정.
핵심은 ZADD로 먼저 넣고 나서 ZCARD로 확인하는 순서다. 세 인스턴스가 동시에 이걸 해도 저장소가 명령을 한 줄로 세워 처리하니, (cap+1)번째 ZADD 다음의 ZCARD는 반드시 초과를 보고 롤백한다. 리더도 락도 없이, "일단 넣고 넘치면 무른다"는 낙관적 방식만으로 정원이 지켜진다. 리더 선출이 SET NX 원자성에 기댔듯 이건 ZADD/ZCARD 원자성에 기대는데, 둘 다 저장소가 단일 스레드로 명령을 세우기 때문에 성립한다. 결국 같은 뿌리다.

바뀐 건 이거다. admit이 더 이상 "한 대만"이 아니라 세 대가 동시에 돈다. 다중화가 admit에서도 살아났고, 정원은 여전히 안 뚫린다. 문지기를 세웠다가, 문지기가 필요 없다는 걸 알고 걷어낸 거다.
나가는 쪽도 잡았다: TTL 자동 회수
들어오는 쪽은 잡혔는데 나가는 쪽에 문제가 남아 있었다. 입장한 유저가 결제를 하다 말고 브라우저를 닫으면 release가 호출되지 않는다. 그러면 그 유저 몫의 자리가 영영 안 빈다. 자리가 안 비니 뒷사람이 못 들어오는 stale 문제다.
활성 집합을 단순 카운터가 아니라 만료 시각을 score로 갖는 sorted set으로 뒀다. admit 직전에 ZREMRANGEBYSCORE로 만료된(결제시간 초과·이탈) 유저를 먼저 회수하고 나서 새로 들인다. 여기도 문지기 없이, 만료 시각이라는 기준과 원자적 연산 하나로 빈자리가 제때 열린다.

실측
정합성부터 불변식으로 정의했다.
어느 시점에 관측해도 activeCount ≤ slot이어야 한다.
동시성 제어를 끄고(naive) 켜고(낙관적 락) 같은 조건으로 부하를 줬다. 측정은 real Redis 8.6.2 · queue-service 3 인스턴스 · slot=100 · k6 v2.0.0 조건이다.
| 동시성 제어 | 최대 활성 인원 | 결과 |
|---|---|---|
| 없음 (naive) | 193 | +93명 정원 초과 (oversell) |
| 낙관적 락 | 100 | 초과 0 (정확) |
동일 조건에서 제어만 켜고 끈 결과다. 정원 초과 +93 → 0.

단계별 부하(낙관적 락)에서 지연과 에러율은 이렇게 나왔다.
| 부하 (rps) | p50 | p95 | p99 | 에러율 | 정합성 |
|---|---|---|---|---|---|
| 100 | 1.03ms | 2.08ms | 3.15ms | 0% | active ≤ slot |
| 500 | 0.42ms | 0.66ms | 0.96ms | 0% | active ≤ slot |
| 1,000 | 0.35ms | 0.54ms | 1.07ms | 0% | active ≤ slot |
다만 k6 부하생성기가 서버와 같은 머신에서 돌아서, 위 수치는 처리량 천장이 아니라 관측값이다. 서버 처리량 한계는 부하생성기를 분리해 다시 재야 한다.
측정하다 예상 못 한 걸 하나 발견했다. 처음엔 처리량을 늘리려고 slot을 5에서 40으로 키웠는데, admit 주기가 2초면 큐가 계속 적체됐다(15/s). slot 문제인 줄 알았는데 아니었다. admit 주기를 200ms로 줄이니 평형에 도달했다(19/s). 대기열 처리량의 진짜 레버는 한 번에 몇 명 들이느냐(slot)가 아니라 얼마나 자주 들이느냐(admit 주기)였다.
마치며
서버를 늘리는 건 버튼 하나인데, 그 순간 "한 번만 일어나야 하는 일"들이 전부 N번 일어날 후보가 된다. 이번 건 admit이 그랬다. 처음엔 문지기를 한 명 세워서(리더 선출) 막았는데, 알고 보니 원자적 연산의 순서만 잘 잡으면(선점 후 초과 검사) 문지기 없이도 됐다. 오히려 문지기를 걷어내니 세 대가 다 일하게 되면서 다중화가 제 몫을 했다. slot 100에 193명이 들어오던 대기열은, 이제 몇 대를 띄워도 100명만 들여보낸다.