springboot

대규모 트래픽 경험기 - SSE

2026-02-21
4분 분량
JAVATraffic

3편까지의 한계

3편에서 API를 seat-map(정적 메타)과 seat-status(동적 상태)로 분리하고, DTO 프로젝션으로 조회 비용을 줄였다. 2000명 동시 접속 기준 TPS 51 → 136, p95 36초 → 13초. 처리량 2.7배, Tail Latency 2.6배 개선이었다.

하지만 구조는 여전히 폴링이었다. 그리고 폴링에는 아무리 응답을 최적화해도 해결할 수 없는 근본적인 문제가 있다.


폴링은 왜 구조적으로 불리한가

폴링의 요청량은 간단한 산수로 계산된다.

plain text
총 요청 수 = 동시 접속자 × (체류 시간 / 폴링 주기)

사용자 2,000명이 1초 간격으로 폴링하면서 90초간 체류하면 2,000 × 90 = 180,000건의 요청이 발생한다. 사용자가 20,000명이면 1,800,000건이다.

이 요청들 중 대부분은 의미 없는 요청이다. 좌석 상태가 바뀌는 것은 누군가가 예약에 성공했을 때뿐인데, 예약 50건이 발생하는 동안 18만 건의 조회가 날아간다. 전체 요청의 99.97%가 "변경 없음"이라는 동일한 응답을 받기 위해 서버 자원을 소비하고 있는 것이다.

응답을 아무리 가볍게 만들어도 이 구조에서는 RPS(Requests Per Second)가 사용자 수에 비례해서 선형 증가한다. 서버 성능을 튜닝하는 것은 기울기를 줄일 뿐, 직선 자체를 없애지 못한다.


질문을 바꾸다

여기서 최적화의 방향을 근본적으로 전환했다.

지금까지의 질문은 "조회 API를 어떻게 더 빠르게 만들까?"였다. 캐시를 붙이고, 응답을 줄이고, 프로젝션을 적용했다. 모두 요청이 들어왔을 때 더 빠르게 처리하는 방향이었다.

하지만 진짜 질문은 이것이었다. "왜 클라이언트가 계속 물어봐야 하는가?" 좌석 상태는 이벤트다. 누군가 예약에 성공하면 상태가 바뀌고, 그때 한 번만 알려주면 된다. 변경이 없는 동안에는 아무것도 하지 않아도 된다.

이 관점의 전환이 Pull(폴링) → Push(SSE) 전환의 출발점이었다.


SSE(Server-Sent Events) 선택 이유

실시간 Push 방식으로는 WebSocket과 SSE가 있다. SSE를 선택한 이유는 다음과 같다.

좌석 상태 전파는 서버 → 클라이언트 단방향이다. 클라이언트가 서버에 데이터를 보낼 일이 없다. WebSocket은 양방향 통신을 지원하지만 그만큼 연결 관리가 복잡하고, 프록시/로드밸런서 설정도 까다롭다. SSE는 일반 HTTP 위에서 동작하므로 기존 인프라를 그대로 사용할 수 있고, 연결이 끊어지면 브라우저가 자동으로 재연결을 시도한다.

단방향 이벤트 스트림에는 SSE가 더 적합한 선택이다.


설계: Snapshot + Delta Stream

SSE 도입에서 가장 중요한 설계 결정은 초기 상태를 어떻게 전달하느냐였다.

클라이언트가 SSE에 연결한 시점 이전의 변경 사항은 스트림으로 받을 수 없다. 그렇다고 연결 시점부터의 delta만 보내면 클라이언트는 초기 좌석 상태를 모른다. 따라서 두 단계로 나눴다.

1단계 — Snapshot: 최초 진입 시 전체 좌석 상태를 1회 조회한다. 일반 JSON 응답이다.

2단계 — Delta Stream: 이후에는 SSE 연결을 통해 변경된 좌석만 실시간으로 받는다.

plain text
GET /seat-status/snapshot     → JSON (전체 상태, 1회)
GET /seat-status/stream       → SSE  (변경분만, 지속 연결)

이 구조에서 클라이언트는 snapshot으로 초기 화면을 그린 뒤, stream에서 오는 delta를 로컬 상태에 덮어쓰기만 하면 된다. 좌석 10,000개 중 예약이 발생한 좌석 1개의 상태만 전송되므로, 이벤트 하나의 크기는 수십 바이트에 불과하다.


핵심 구현

SSE 연결 관리

java
private final Map<Long, CopyOnWriteArrayList<SseEmitter>> rooms
    = new ConcurrentHashMap<>();

scheduleId별로 연결된 클라이언트(SseEmitter) 목록을 관리한다. CopyOnWriteArrayList를 사용한 이유는 브로드캐스트(읽기)가 연결/해제(쓰기)보다 압도적으로 빈번하기 때문이다. 쓰기 시 배열 복사 비용이 있지만, 읽기가 대부분인 이 패턴에서는 synchronized 블록보다 처리량이 높다.

브로드캐스트 — 변경된 좌석만 전송

java
public void broadcast(long scheduleId, long eventId, List<SeatStatusRes> changed) {
    List<SseEmitter> emitters = rooms.getOrDefault(
        scheduleId, new CopyOnWriteArrayList<>());

    for (SseEmitter emitter : emitters) {
        try {
            emitter.send(SseEmitter.event()
                    .name("delta")
                    .id(String.valueOf(eventId))
                    .data(changed));
        } catch (Exception e) {
            remove(scheduleId, emitter);
        }
    }
}

event.id()에 순차 증가하는 eventId를 넣는 이유가 있다. SSE 스펙에서 브라우저는 연결이 끊겼다가 재연결할 때 Last-Event-ID 헤더에 마지막으로 받은 id를 보낸다. 서버는 이 값을 확인해서 클라이언트가 놓친 이벤트부터 다시 보내줄 수 있다. 네트워크가 불안정한 환경에서도 이벤트 유실 없이 상태를 동기화할 수 있는 구조다.

트랜잭션 커밋 이후에만 이벤트 발행

java
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onChanged(SeatStatusChangedEvent e) {
    long eventId = seq.incrementAndGet();

    broker.broadcast(
            e.scheduleId(),
            eventId,
            List.of(new SeatStatusRes(e.seatId(), e.status()))
    );
}

@TransactionalEventListener에 AFTER_COMMIT을 지정한 이유는 명확하다. 만약 트랜잭션이 롤백되면 좌석 상태 변경은 실제로 일어나지 않은 것이다. 커밋 전에 SSE를 보내면 클라이언트는 "예약됨"을 표시하지만 DB에는 반영되지 않은, 팬텀 상태가 된다. 커밋이 확정된 후에만 이벤트를 발행해야 DB와 클라이언트 화면의 일관성이 보장된다.


부하 테스트

SSE는 일반 HTTP 요청과 테스트 방식이 다르다. 연결이 끝나지 않고 유지되기 때문에, JMeter에서 두 개의 Thread Group을 구성했다.

  • Thread Group 1: SSE 연결 2,000개를 열고 90초간 유지
  • Thread Group 2: 예약 API를 50회 호출 (좌석 상태 변경 이벤트 발생)

결과

2,000개의 SSE 연결이 유지되는 동안에도 예약 API의 p95가 152ms로 안정적이었다. SSE 연결 자체는 유휴 상태에서 거의 리소스를 소비하지 않기 때문에, 이벤트가 발생하지 않는 동안에는 서버 부하가 사실상 없다.

네트워크 사용량이 ~60KB/s인 것도 주목할 만하다. 폴링 방식에서 2,000명이 1초마다 seat-status를 조회하면 545KB × 2,000 = 약 1,060MB/s의 네트워크 부하가 발생하지만, SSE에서는 변경된 좌석 1건(수십 바이트)만 2,000개 연결에 브로드캐스트하므로 트래픽이 극적으로 줄어든다.


폴링 vs SSE 구조 비교

핵심적인 차이는 비용이 무엇에 비례하느냐다. 폴링은 사용자 수에 비례하고, SSE는 이벤트 발생 빈도에 비례한다. 티켓팅처럼 "많은 사람이 보고 있지만 실제 변경은 드문" 시나리오에서 이 차이는 결정적이다.


운영 관점에서 남은 과제

SSE가 폴링의 구조적 문제를 해결하지만, 새로운 고려 사항이 생긴다.

연결 수 = 메모리. 각 SseEmitter는 커널 수준의 TCP 소켓을 유지한다. 2,000개는 문제가 없지만 20,000개, 200,000개로 늘어나면 파일 디스크립터 한계(ulimit -n)와 메모리를 모니터링해야 한다. slow client가 send 버퍼를 채우면 브로드캐스트 쓰레드가 블로킹될 수 있으므로, 쓰기 타임아웃 설정과 주기적 heartbeat로 죽은 연결을 감지해야 한다.

단일 인스턴스 한계. 현재 eventId는 AtomicLong으로 관리되고, emitter 목록은 인메모리 Map에 저장된다. 서버가 여러 대라면 인스턴스 A에서 발생한 예약 이벤트가 인스턴스 B에 연결된 클라이언트에게 전달되지 않는다. 멀티 인스턴스 환경에서는 Redis Pub/Sub이나 Kafka 같은 외부 이벤트 브로커를 통해 인스턴스 간 이벤트를 공유해야 한다.


여기까지 해결한 것, 아직 해결하지 못한 것

1편부터 4편까지는 "좌석을 조회하는 과정" 을 최적화했다. 부하 테스트로 병목을 찾고, 캐시를 붙이고, 응답 구조를 바꾸고, 트래픽 모델을 전환했다. 그 결과 좌석 상태를 확인하는 경험은 충분히 안정적으로 만들었다.

하지만 티켓팅 시스템에서 진짜 위험한 순간은 조회가 아니라 "좌석을 예약하는 순간" 이다. 좌석 A-12가 비어있는 것을 확인한 10명이 동시에 예약 버튼을 누르면 어떤 일이 벌어질까? 지금 구조에서는 10명 모두 예약에 성공한다. 하나의 좌석에 10장의 티켓이 발행되는 더블 부킹 문제다.

또 하나의 문제가 있다. 좌석 조회 응답 시간은 개선했지만, 2000명이 동시에 서버에 접근하는 것 자체를 제어하고 있지는 않다. SSE로 불필요한 폴링 요청은 줄였지만, 오픈 시간에 한꺼번에 몰려드는 초기 접속 자체는 여전히 서버에 부하를 준다. 서버가 감당할 수 있는 만큼만 사용자를 통과시키고, 나머지는 서버 바깥에서 대기시키는 구조가 필요하다.

다음 편에서는 이 두 가지를 다룬다. Pessimistic Lock으로 동시 예약의 데이터 정합성을 보장하고, Redis 기반 대기열로 서버 유입 자체를 제어한다.

함께 읽으면 좋은 글