목차

502 Bad Gateway

이벤트 때 사이트가 터졌습니다.

할 수 있는 게 없어 트래픽이 해소되는 30분동안 502 코드를 지켜보는 것 밖에 할 수 있는게 없었습니다. 무력감이 느껴지더군요.

당시 환경에 제약이 있어 운영하던 사이트의 서버를 확장하거나 솔루션을 도입할 수도 없었습니다. 대기열이 있었다면 터지지 않았을텐데… 하는 아쉬움이 남았습니다.

그럼 만들지 뭐

그래서 하나 만들기로 했습니다.

목표는 크게 2가지로 잡았습니다.

  • 순식간에 쏟아지는 트래픽으로부터 서버를 보호할 것
  • 상용 솔루션보다 비용 효율적일 것

그리고 목표를 잡으니 해결 방식도 점차 좁혀지더군요

  • 순식간에 쏟아지는 트래픽으로부터 서버를 보호할 것
    → AWS에 고성능 대기열 시스템을 구성하자!

  • 상용 솔루션보다 비용 효율적일 것
    → 서버를 껐다 켰다 할 수 있게 만들자!

다음은 설계였습니다. 대기열 이라는 서비스는 어떻게 만들지 몰랐고, 우아한테크, G마켓, 넥슨 컨퍼런스 등 레퍼런스들을 하나하나 찾아가며 공부해나갔습니다. 역시 인터넷엔 같은 고민을 했던 개발자들이 공유한 양질의 컨텐츠가 많았습니다.

그들의 경험을 배우며 사용할 기술과 구현방식을 좁혀나갔습니다.

API 선택

대규모 순차 처리 시스템에는 CPU 집약적인 연산은 없지만 Redis와 통신하면서 짧은 I/O가 매우 많이 발생합니다. 사용자 요청이 짧은 시간 내에 몰리면 쓰레드 점유와 컨텍스트 스위칭 비용이 커집니다.

Java 21의 Virtual Thread도 좋은 선택지가 될 수 있지만, 요청이 가파르게 증가하고 Redis I/O 사용량이 높은 구조에서는 처음부터 논블로킹을 전제로 설계할 수 있는 Spring Webflux가 더 적합하다고 판단했습니다.

다른 옵션으로는 Nodejs + Express의 조합이 있었지만, 빠른 개발을 위해 더 익숙했던 Java를 선택했습니다.

이벤트는 대부분 예고된 시간에 진행됩니다. 따라서 AWS의 Autoscaling으로 구성해서 필요할때만 확장하여 비용을 절약하고자 했습니다. 갑작스런 상황이 발생할 수 있으니, Golden Image를 매 배포시에 굽도록 배포 파이프라인을 만들었습니다.

데이터 저장

오랜 고민 없이 Redis를 선택했습니다.

Redis는 메모리 기반 DB이기 때문에 높은 동시성과 빈번한 순위 변경을 처리하기 좋습니다. 또 Sorted Set은 score 기반 정렬과 rank 조회를 제공하기 때문에 순차처리 문제를 쉽게 해결할 수 있습니다.

내 앞에 몇 명인지, 나는 몇 번째인지 O(log N)으로 조회가 되니 속도도 매우 빠릅니다.

전통적인 DBMS나 Kafka로도 구현은 가능합니다. 다만 DBMS는 Redis에 비해 높은 처리량과 낮은 지연시간을 확보하기 어렵고, Kafka만으로는 사용자의 현재 대기 순서를 실시간으로 조회하기 어렵습니다.

충분한 성능을 확보하기 위해 대기열 API는 Redis에만 의존하도록 구성했습니다. 또한 지연시간을 최소화하기 위해 RDB/AOF를 모두 비활성화했습니다. 대기열 데이터는 일시적인 데이터라 유실이 허용되기 때문입니다. 대신 가용성을 확보하기 위해 3개의 VM에 Master/Replica를 배치했습니다.

서버 한 대가 죽으면 Master와 Replica 하나씩 같이 죽지만 교차로 구성되어 있어 단일 장애는 감당할 수 있습니다. AWS에 구성해서 각 노드를 서로 다른 Availability Zone에 배치한다면 어느정도 장애 내성도 있을것이고, 당시 트래픽 수준과 비용을 고려했을 때 충분히 감당할 만한 Trade-off라고 판단했습니다.

(돈이 많다면 VM 6대를 활용한 Cluster나 마음 편하게 AWS의 ElastiCache를 사용해보고 싶네요.)

아키텍처

그 밖에 아래와 같은 대기열 시스템을 위한 다양한 서비스들을 구성하며 완성된 첫 번째 아키텍쳐입니다.

  • 대기열을 관리할 어드민 시스템
  • 대기열 정보를 저장할 DB
  • CI/CD (Jenkins)

대기 흐름

대기열의 흐름은 단순하게 만들었습니다.

  • 대기열 티켓을 발급
  • 바로 입장할 수 있으면 요청한 화면으로 이동하고, 대기가 필요하면 대기 화면으로 이동
  • 주기적으로 자신의 순번과 입장 가능 여부를 확인
  • 대기가 끝나면 원래 접근하려던 페이지로 이동

사용자가 기다리는 동안 입장 스케줄러는 Redis의 대기열을 일정 간격으로 확인하면서, 서버가 감당 가능한 인원만 ready 상태로 바꿔줍니다. 그리고 대기를 완료한 사용자는 원래 접근하려던 페이지로 이동합니다.

구현 자체는 어렵지 않았습니다…만, 세 가지 고민이 있었습니다.

 1. 대기열 티켓은 어디서 검사하지?
 2. 대기열 티켓은 어디에 저장하지?
 3. 트래픽 조절은 어떻게 하지?

1. 대기열 티켓은 어디서 검사하지?

대기열을 통한 입장은 공정해야 합니다.

대기하고 왔다면 정당하게 입장을 허용하고, 대기한 적이 없다면 대기열의 가장 뒷순번으로 보내야 합니다.

Next.js에서는 진입 경로를 두 가지로 나누어 대기열 검증 로직을 구현했습니다.

 a. 최초 진입 (새로고침, 새 탭 등) → Next.js middleware에서 검사
 b. Router 기반 내부 이동 → 브라우저에서 검사

a. 최초 진입

최초 진입을 middleware에서 검사한 이유는 당시 화면 구조 때문이었습니다.

페이지가 마운트된 뒤 useEffect에서 대기열을 검사하면, 화면 구조로 인해 다른 useEffect가 먼저 실행될 수 있었습니다. 그 안에서 API 요청이 발생한다면 대기열 검사가 끝나기도 전에 서버 부하가 발생합니다. 트래픽이 폭주해도 서버를 안전하게 지켜야 할 대기열이 제 역할을 못하게 됩니다.

최초 진입
  → middleware에서 대기열 검사
    → 입장 가능: 완료 티켓 전달 후 즉시 진입
    → 대기 필요: 대기열 화면으로 redirect

대기가 필요하지 않다면 middleware가 바로 입장 가능한 완료 티켓을 Response Header에 담아 전달하고, 클라이언트는 초기화 과정에서 이를 감지해 진입을 허용합니다. 대기가 필요하다면 대기열 화면으로 redirect합니다.

b. Router 기반 내부 이동

최초 진입 이후 내부 화면에서 이동할 때는 router가 이동 직전에 대기열 검사를 진행합니다.

즉시 진입이 가능하면 가려던 화면으로 이동시키고, 그렇지 않다면 사용자를 대기시킵니다. 대기가 끝나면 callback을 실행해 원래 가려던 화면으로 이동합니다.

내부 이동 시도
→ 브라우저에서 대기열 검사
  → 입장 가능: 원래 화면으로 이동
  → 대기 필요: 대기열 화면 노출

전부 middleware에서 검사하는게 편하지 않을까?

모든 이동을 middleware에서 검사하는 방법도 있었습니다. 하지만 그렇게 구현하면 모든 대기열 검증 요청이 Next.js의 middleware를 거친 뒤 다시 대기열 API로 전달됩니다. 이벤트 트래픽이 몰리는 시점에는 Node에 불필요한 부하가 추가됩니다. 무거운 로직은 대기열 API가 처리하지만 요청 자체를 Node 서버가 중계해야 하니까요.

대기열 티켓을 대입하는 공격이 들어온다면?

대기 티켓 번호는 브라우저 API 응답에 노출되어 있고 어느정도 규칙성을 띄고 있어 Brute-force 공격으로 다른 사용자의 티켓 번호를 예측할 수 있었습니다. 따라서 티켓 번호와 secret을 조합한 Hash를 만들어 서버에서 검증하도록 구현하여 공격자가 다른 사용자의 대기열 티켓을 무작위로 대입하여 남용하지 못하도록 구현했습니다.

2. 대기열 티켓은 어디에 저장하지?

다음으로 정해야 할 것은 티켓을 저장하고 읽는 위치였습니다.
후보는 총 4개였는데요, 결론부터 말씀드리면 React State에 저장하도록 구성했습니다.

  • Cookie
  • localStorage
  • sessionStorage
  • React State

Cookie나 localStorage는 같은 origin을 사용하는 탭 사이에서 공유됩니다.
만약 사용자가 탭을 여러개 열어서 대기열에 진입한다면, 기존에 발급받은 대기열 티켓이 나른 탭의 티켓과 충돌이 날 수 있습니다. 그래서 고려되지 않았습니다.

그런 측면에서 sessionStorage도 고려할 수 있었지만, 브라우저의 이전 세션 복원 기능이 있어 브라우저를 껐다 켰을 때 이전 티켓값이 남아있을 수 있다는 문제가 있었습니다.

대기열 티켓은 화면당 1회성으로 사용되고 폐기되어야 하기 때문에 React State로 값을 관리하는 것으로 결정했습니다.
탭 간에 데이터가 공유되지 않고, 탭이 다시 열려도 페이지를 새로 로드하므로 당시 요구사항과 가장 잘 맞았습니다.

3. 트래픽 조절은 어떻게 하지?

다음 문제는 한 번에 몇 명을 들여보낼 것인가였습니다.

처음에는 Backpressure 개념으로 매초 설정된 인원만큼 사용자를 입장시키는 고정 유입률 방식을 사용했습니다.

매초 N명 입장
  → 이벤트 시작 시점의 순간적인 유입량 제한

아쉬운 점이라면 이 방식은 들어오는 사용자의 수만 조절할 뿐, 이미 화면에 머물고 있는 사용자들이 발생시키는 부하는 계산하지 못한다는 것이었습니다. 사용자가 빠르게 화면을 이탈한다면 매초 일정 인원을 입장시키는 방식만으로 충분합니다. 하지만 사용자의 체류 시간이 길어지면, 초당 입장 인원은 일정하더라도 화면 안의 동시 사용자는 계속 누적됩니다.

대기열로 유입량을 조절했음에도 얼마나 많은 사용자가 화면에 머물고 있는지 알 수 없었기 때문에, 특정 화면에 사용자가 누적되면 다시 부하가 치솟곤 했습니다.

그래서 제어 기준을 "매초 몇 명을 입장시킬 것인가"에서 "현재 몇 명이 화면에 머물고 있는가"로 변경했습니다.

유입량 제어

수용량 제어

화면을 하나의 방, 즉 Room이라고 생각하고 각 Room에 최대 수용 인원을 설정했습니다.

현재 체류 인원이 최대 수용 인원에 도달하면 신규 진입을 차단합니다. 반대로 사용자가 화면을 빠져나가 여유가 생기면, 비어 있는 인원만큼 대기열에서 기다리고 있는 사용자를 입장시킵니다.

이 방법으로 구간별로 얼마나 많은 사용자가 머물고 있는지 눈으로 볼 수 있었고, 특정 화면에서 발생하는 실제 부하를 기준으로 트래픽을 조절할 수 있었습니다.

도입 이후

대기열 도입 이후 수차례 이벤트를 진행하면서 3분 안에 10만명이 대기하게 되는 등 규모 있는 이벤트도 무리 없이 소화했습니다. 엔지니어로 일하면서 구현한 시스템이 잘 작동하는 것을 보는 것이 개인적으로 가장 긴장되고 짜릿한 순간인 것 같습니다.

행사 운영 중 대기 화면

(실제 운영 화면을 참고하여 임의로 재구성된 이미지입니다)

마치며

한 번도 해본 적 없는 일에 도전하는 것은 쉬운 일은 아니었습니다. 솔루션을 구매해서 도입하는 방법으로 운영하는 환경에서, 자체개발을 한다는 것 자체가 상당한 부담이기도 했습니다. 많은 시행착오가 있었지만 이벤트 때 시스템이 버티게 만들자는 목표는 이루었습니다. 그런데 기능만 만들었다고 끝이 아니더군요.

운영해보니 비즈니스 지표를 기록하고 로그 가시성을 확보해서 안정적으로 서비스를 제공하는 것도 정말 중요했습니다. 최대 몇 명이 대기했는지 데이터가 남아있지 않았고, 에러가 발생해도 원인을 확인할 로그가 없었습니다. Redis Cluster 장애를 너무 늦게 인지한 적도 있었습니다.

뒤늦게서야 비즈니스 지표를 InfluxDB에 기록하고, 서버와 인프라 메트릭을 Prometheus와 Grafana로 확인하고, 장애 상황은 Alertmanager로 빠르게 인지할 수 있도록 하는 등 하나하나 보완해 나갔습니다.

그렇게 하나씩 보완한 현재의 아키텍쳐입니다.

아직 할 일은 많습니다. CI/CD 구성을 개선하고, 보안적으로 취약한 부분을 조치해야 합니다. 하나를 해결하니 또 다른 문제가 튀어나오더군요. 기능을 구현하는 것과 지속 가능한 시스템을 만들어 나가는 것은 또 다른 영역의 일인 것 같습니다. 이 시스템을 만든 제가 앞으로도 책임져야 할 일이겠죠.

긴 글 읽어주셔서 감사합니다.