대기열2026년 7월 11일4분 읽기

대기열 서버를 3대로 늘렸더니 입장 인원이 3배가 됐다

티켓팅 대기열을 다중화하자 slot 5짜리 입장에 15명이 들어왔다. 원인은 3대가 각자 admit을 돌린 것. SET NX 리더 선출로 한 주기에 한 대만 입장시키게 만들고, activeCount ≤ slot 불변식으로 검증한 과정.

#대기열#리더선출#SET NX#Redis#동시성#MSA#티켓팅

대기열 서버를 3대로 늘렸더니 입장 인원이 3배가 됐다

대기열 구조부터

티켓팅을 소재로 고동시성 시스템을 밑바닥부터 만들어보는 학습 프로젝트를 하고 있다. 대기열은 직접 만든 Redis(MyRedis)의 sorted set 위에 올라가 있다. 유저가 오면 대기열에 넣고, 주기마다 앞에서 slot 수만큼 꺼내(zpopmin) 입장시키고, 지금 입장해 있는 인원을 activeCount로 센다.

queue-service 한 대일 때는 아무 문제가 없었다. 문제는 이걸 여러 대로 늘리면서 시작됐다.

3대가 각자 문을 열었다

가용성 때문에 queue-service를 3대로 다중화했다. 그러자 입장 로직이 이렇게 돌았다.

다중화 사고 장면. score 순으로 정렬된 대기열에서 인스턴스 A, B, C가 각자 slot 5를 읽고 zpopmin(5)으로 5명씩 뽑아간다. 서로의 존재를 모르는 세 대가 각자 문을 열어 입장 인원이 15명, slot의 3배가 된다.

세 대가 전부 "내가 입장시켜야지" 하고 각자 admit 주기를 돌린 거다. 인스턴스마다 자기 기준으로는 정당하게 slot 5를 지켰는데, 합치면 15명이다. activeCount도 같이 폭주했다. 대기열의 존재 이유가 "동시 입장 인원 제한"인데, 서버를 늘리는 순간 그 제한이 서버 수에 비례해 뚫렸다.

admit이라는 작업 자체가 "전체에서 딱 한 번"이어야 하는 일인데, 인스턴스들은 서로의 존재를 모른다. 누군가 한 명만 문지기를 해야 했다.

문지기 뽑기: SET NX 리더 선출

그래서 리더 선출을 넣었다. 거창한 합의 알고리즘(Raft 같은)까지 갈 것 없이, Redis 계열 저장소가 있으면 제일 값싼 방법이 있다. SET NX(없을 때만 set)의 원자성을 이용하는 거다.

text
매 주기마다:
  SET queue:leader "instance-A" NX EX <주기+여유>

  → 성공한 인스턴스: 이번 주기의 리더. admit 실행.
  → 실패한 인스턴스: 리더가 이미 있음. 이번 주기는 아무것도 안 함.

SET NX는 키가 없을 때만 성공하고, 그 확인과 쓰기가 한 번의 원자적 연산이다. 세 대가 동시에 도전해도 딱 한 대만 성공한다. 성공한 그 한 대만 이번 주기에 zpopmin을 돌리고, 나머지는 다음 주기를 기다린다. TTL을 걸어두니 리더가 죽어도 키가 만료되면서 다음 주기에 다른 인스턴스가 자리를 이어받는다.

SET NX 리더 선출 두 주기. 주기 1에서 A, B, C가 동시에 SET queue:leader NX EX에 도전해 A만 성공해 리더가 되고 A만 zpopmin(5)를 실행한다. 주기 2에서 A가 다운되면 TTL 만료로 키가 소멸하고 B가 도전에 성공해 리더를 인계받는다.

이걸로 "한 주기에 문지기 한 명"이 보장된다. 서버는 3대지만 admit은 한 곳에서만 돈다.

검증: activeCount ≤ slot 불변식

고쳤다고 믿는 것과 검증하는 건 다른 얘기라, 정합성을 불변식 하나로 정의했다.

어느 시점에 관측해도 activeCount ≤ slot이어야 한다.

리더 선출이 없던 시절엔 이게 3배로 깨졌으니, 검증 스크립트는 단순하게 만들었다. 유저 20명을 한꺼번에 대기열에 넣고, admit 주기가 몇 번 돌 시간(6초)을 기다린 뒤 activeCount를 읽는다. slot 이하면 성공, 넘으면 실패다. 리더 선출을 넣기 전엔 이 스크립트가 바로 깨지고, 넣은 뒤엔 통과한다.

전제가 하나 숨어 있다: SET NX는 정말 원자적인가

여기서 발을 헛디딜 뻔한 지점이 하나 있다. 이 리더 선출 전체가 "SET NX가 원자적"이라는 전제 위에 서 있는데, 그 원자성은 공짜가 아니라 MyRedis 커맨드 처리가 단일 스레드라서 나오는 성질이다.

진짜 Redis가 명령을 한 줄로 세워 처리하듯, MyRedis도 워커 1개가 커맨드를 순서대로 집행해야 "키 확인 → 쓰기" 사이에 아무도 못 끼어든다. 만약 성능 욕심에 커맨드 워커를 여러 개로 늘리면, 두 인스턴스의 SET NX가 동시에 "키 없음"을 보고 둘 다 성공할 수 있다. 그 순간 리더가 두 명이 되고, 위의 15명 사태가 그대로 돌아온다. 분산 락을 밑에서 받치는 건 결국 저장소의 실행 모델이라는 걸, 직접 만든 저장소라서 더 선명하게 볼 수 있었다.

단일 스레드 전제. MyRedis 워커가 1개면 커맨드 큐의 SET NX(A)와 SET NX(B)가 순서대로 집행돼 A는 성공, B는 키 있음 실패로 리더가 1명이다. 워커가 2개면 둘 다 키 없음을 보고 동시에 성공해 리더가 2명이 되고, 15명 사고가 재현된다.

남은 문제: 나갔다는 말 없이 나간 사람들

리더 선출로 "들어오는 쪽"은 잡혔는데, "나가는 쪽"에 문제가 남아 있다. 입장한 유저가 결제를 하다 말고 브라우저를 닫으면 release가 호출되지 않는다. 그러면 그 유저 몫의 admittedKeyactiveCount가 영영 남는다. 자리가 안 비니 뒷사람이 못 들어오는 stale 문제다.

지금 잡은 방향은 두 가지다. 입장 키에 결제 제한시간만큼 TTL을 걸어 자연 만료시키는 것, 그리고 activeCount를 단순 카운터가 아니라 만료 시각을 score로 갖는 admitted sorted set으로 바꿔서 admit 직전에 ZREMRANGEBYSCORE로 만료 유저를 자동 회수하는 것. 이 부분은 아직 작업 중이라, 마무리되면 따로 정리하겠다.

부하는 아직 못 쟀다

k6로 시나리오는 짜뒀다. 경쟁 좌석, 대량 진입, 폴링 부하 같은 케이스별로 나눠서, "동시 요청에서 정확히 slot 수만큼만 성공하는가"를 정합성 중심으로 측정할 계획이다. RPS 같은 수치는 실측이 나오면 여기 채우고, 그 전까지 이 글의 주장은 "정합성 불변식이 지켜진다"까지다.

마치며

서버를 늘리는 건 버튼 하나인데, 그 순간 "한 번만 일어나야 하는 일"들이 전부 N번 일어날 후보가 된다. 이번 건 admit이 그랬고, 고치는 데 필요한 건 대단한 게 아니라 원자적 연산 하나(SET NX)와 그걸 받쳐주는 실행 모델에 대한 이해였다. slot 5에 15명이 들어오던 대기열은 이제 몇 대를 띄워도 5명만 들여보낸다.

#대기열#리더선출#SET NX#Redis#동시성#MSA#티켓팅

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js