Project/phonebid

SSE 기반 실시간 알림 구현기 : 기술 선택부터 프로덕션 안정화까지

쉬지마 이굥진 2026. 4. 26. 21:52

필자는 현재 스마트폰 역경매 플랫폼 bidr(비더)를 개발 중이다. (5월 런칭 목표)

 

bidr에서는 입찰 도착, 최저가 갱신, 계약 체결 등 실시간으로 사용자에게 전달해야 하는 이벤트가 많다. 구매자 입장에서는 내 견적에 새로운 입찰이 들어왔을 때 빠르게 인지해야 하고, 판매자 입장에서는 내가 제시한 입찰이 선택됐을 때 즉각적으로 알아야 이후 계약 흐름이 자연스럽게 이어진다.

 

이번 포스팅에서는 이 실시간 알림을 어떤 방식으로 구현할지 선택하는 과정부터, 실제 구현 후 프로덕션 환경에서 안정적으로 동작하도록 개선한 과정까지 정리해보려 한다.

 

기술적 의사결정 — 폴링, WebSocket, SSE

실시간 알림을 구현하는 방법은 크게 세 가지다. 폴링, 웹소켓, SSE.

 

1. 폴링 (Polling)

클라이언트가 주기적으로 서버에 "새 알림 있어요?" 하고 요청을 보내는 방식이다.

클라이언트: 새 알림 있어요? (3초마다 반복)
서버: 없어요 / 있어요

있어요?있어요?

 

구현이 가장 단순하지만 문제가 명확하다. 알림이 없어도 요청이 계속 발생하고, 폴링 간격만큼 알림이 늦게 도착한다. 사용자가 많아질수록 서버에 불필요한 요청이 폭발적으로 늘어난다.

 

2. WebSocket

양방향 통신을 지원하는 프로토콜이다. 연결을 한 번 맺으면 서버와 클라이언트가 자유롭게 데이터를 주고받을 수 있다.

 

bidr는 이미 채팅 기능에 WebSocket(STOMP)을 사용하고 있다. 그래서 원래 알림도 그냥 WebSocket으로 구현하려고 했었다. 하지만 고민 결과 결국 WebSocket은 채팅에만 사용하기로 했다.

이유인 즉:

알림의 특성:
  서버 → 클라이언트: 입찰 도착, 최저가 갱신, 계약 체결 ...
  클라이언트 → 서버: (없음)

 

알림은 서버에서 클라이언트 방향으로만 흐른다. 양방향 통신이 필요 없는데 WebSocket을 쓰면 그만큼 리소스가 낭비된다. 거기에 더해 채팅 서버의 WebSocket 세션과 알림용 세션을 모두 관리해야 하고, 채팅 쪽에 장애가 나면 알림까지 같이 마비될 수 있는 결합도 문제도 있었다.

 

3. SSE (Server-Sent Events)

서버에서 클라이언트로만 데이터를 보내는 단방향 HTTP 기반 기술이다.

클라이언트: 연결 한 번 맺음 (GET /api/v1/notifications/stream)
서버: 이벤트 있을 때마다 밀어줌 → 알림 도착!

 

우리 서비스에 웹소켓 대비 SSE가 더 적합하다고 판단한 이유는 아래 표와 같다.

항목 WebSocket SSE
통신 방향 양방향 단방향 (서버 → 클라이언트)
프로토콜 ws:// HTTP
자동 재연결 직접 구현 브라우저 기본 지원
서버 리소스 높음 낮음
구현 복잡도 높음 낮음

 

특히 bidr는 모바일 사용자가 많을 것으로 예상되는데, 지하철처럼 네트워크가 자주 끊기는 환경에서 SSE의 자동 재연결 기능이 알림 유실을 줄여주는 큰 이점이 됐다.

폴링 vs SSE vs 웹소켓

 


 

기본 구현

Spring Boot에서 SSE를 구현하는 건 비교적 간단하다. 구현은 가볍게 언급하겠다.

// SSE 연결 엔드포인트
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<SseEmitter> streamNotifications(
        @AuthenticationPrincipal UserDetailsImpl userDetails) {

    UUID userId = userDetails.getUser().getId();
    SseEmitter emitter = sseEmitterManager.createConnection(userId);

    return ResponseEntity.ok()
            .header("X-Accel-Buffering", "no")  // Nginx 버퍼링 방지
            .header("Cache-Control", "no-cache")
            .header("Connection", "keep-alive")
            .body(emitter);
}

 

클라이언트가 /api/v1/notifications/stream에 GET 요청을 보내면 SseEmitter가 반환되고, 이후 서버는 이 emitter를 통해 이벤트를 밀어줄 수 있다.

 

알림을 보낼 때는 이렇게 한다.

// SSE로 알림 발송
SseEmitter emitter = sseEmitterManager.getConnection(userId);

NotificationResponseDto dto = NotificationResponseDto.from(notification);
String jsonData = objectMapper.writeValueAsString(dto);

emitter.send(SseEmitter.event()
        .name("notification")
        .data(jsonData));

 

흐름 자체는 단순하다. 문제는 이걸 프로덕션에서 안정적으로 운영하는 것이었다.


문제 1. 유령 연결이 쌓인다 — Zombie Connection

SSE의 특성상 클라이언트가 브라우저 탭을 닫거나 네트워크가 끊겨도 서버는 이를 즉시 알 수 없다. 데이터를 보내려고 할 때 실패해야 비로소 연결이 끊겼다는 걸 알게 된다.

결과적으로 이미 죽은 연결이 서버 메모리에 계속 남아있는 유령 연결(a.k.a Zombie Connection) 문제가 생긴다.

시간 흐름
────────────────────────────────────────────────────
t=0    사용자 A 연결
t=1    사용자 B 연결
t=2    사용자 A 브라우저 닫음 (서버는 모름)
t=3    사용자 C 연결
...
t=100  연결 수 누적 → 메모리 낭비 📈
────────────────────────────────────────────────────

 

좀비 커넥션 문제를 어떻게 해결할 수 있을까?

 

해결 — 주기적 Heartbeat

모든 활성 연결에 주기적으로 ping을 보내면 된다. 전송이 실패하면 그 연결은 죽은 것으로 판단하고 즉시 제거하는 Heartbeat 방식을 쓰면 된다.

그래서 처음엔 이렇게 구현했다.

// 처음 구현 — 연결마다 전용 스레드로 heartbeat
private void startHeartbeat(UUID userId, SseEmitter emitter) {
    new Thread(() -> {          // ← 연결마다 새 Thread 생성 ⚠️
        try {
            long interval = sseEmitterManager.getHeartbeatInterval();
            while (sseEmitterManager.isConnected(userId)) {
                Thread.sleep(interval); // ← 블로킹 대기
                emitter.send(SseEmitter.event()
                        .name("heartbeat")
                        .data("ping"));
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }, "sse-heartbeat-" + userId).start();
}

 

zombie connection 문제는 해결했지만, 이 구현 자체에 새로운 문제가 있었다. 기본 구현을 마친 후 PR을 올렸는데, CodeRabbit이 아래와 같은 문제를 짚어준 것.

"현재 방식은 사용자 수가 늘면 heartbeat 스레드가 선형 증가합니다. TaskScheduler / ScheduledExecutorService 기반의 공유 스케줄러로 전환해 연결별 작업만 등록/해제하는 구조가 필요합니다."

 

이 구조의 문제는 직관적이다.

연결마다 new Thread()를 만들고, 그 스레드는 Thread.sleep()으로 블로킹 대기를 하면서 heartbeat 간격을 맞추는 구조였기 때문에 이런 리뷰를 받았다..!

연결 수 증가 시나리오
──────────────────────────────────────────────────────
연결 10개   → "sse-heartbeat-xxx" 스레드 10개 (각각 sleep 중)
연결 100개  → "sse-heartbeat-xxx" 스레드 100개  ⚠️
연결 1000개 → "sse-heartbeat-xxx" 스레드 1000개 💀 스레드 풀 고갈
──────────────────────────────────────────────────────

 

zombie connection 잡다가 스레드 폭증이라는 새로운 문제가 생겼다. 끄응

 

다음 해결 — 공유 스케줄러로 전체 연결 순회

스레드 폭증 문제 해결의 핵심 아이디어는 단순하다. 연결별로 스케줄러를 만드는 게 아니라, 하나의 스케줄러가 주기적으로 모든 연결을 순회하며 heartbeat를 보내면 된다.

개선 후
──────────────────────────────────────────────────────
공유 스케줄러 1개 → 30초마다 전체 연결 순회 → ping 전송
연결이 10개든 1000개든 스케줄러는 1개 ✅
Thread.sleep()으로 블로킹하는 스레드도 사라짐 ✅
──────────────────────────────────────────────────────

 

@Scheduled를 활용해 단일 스케줄러가 전체 연결을 순회하도록 하면 될 것 같다고 생각했다.

// 개선 후 — 공유 스케줄러로 전체 연결 순회
@Scheduled(fixedDelayString = "${sse.heartbeat.interval:30000}", initialDelay = 30000)
public void sendHeartbeat() {
    if (!heartbeatEnabled) return;

    Iterator<Map.Entry<UUID, ConnectionInfo>> iterator = connections.entrySet().iterator();

    while (iterator.hasNext()) {
        Map.Entry<UUID, ConnectionInfo> entry = iterator.next();
        UUID userId = entry.getKey();
        ConnectionInfo info = entry.getValue();

        try {
            info.emitter.send(SseEmitter.event()
                    .name("ping")
                    .data("heartbeat"));
        } catch (IOException e) {
            // 전송 실패 = 죽은 연결 → 즉시 제거
            log.warn("SSE Heartbeat 전송 실패, 연결 제거: userId={}", userId);
            info.emitter.completeWithError(e);
            iterator.remove();
        }
    }
}

 

30초마다 ping을 보내고, 실패한 연결은 그 자리에서 제거한다.

💡왜 forEach 가 아닌 Iterator 를 썼음?
ConcurrentHashMap의 forEach는 순회 중에 요소를 삭제해도 예외가 발생하지는 않는다. 하지만 forEach 람다 내부에서
map.remove(key)를 직접 호출하면, 순회 로직과 삭제 로직이 겉돌아 일관성 없는 동작이 발생할 수 있다.

반면 Iterator의 remove()를 사용하면 현재 순회 중인 요소를 컨테이너에서 안전하게 제거 할 수 있어, 순회 중 삭제가 필요한 로직에서는 이 방식이 가장 확실하고 정석적인 방법이라고 판단했다.

 

Cleanup 스케줄러도 함께

Heartbeat만으로는 부족하다. Heartbeat는 ping 전송 실패를 감지해서 죽은 연결을 제거하는 능동적 감지 방식이다. 그런데 콜백이 어떤 이유로 누락되면 타임아웃이 지났는데도 연결이 남아있을 수 있다. 이를 대비해 주기적으로 생성 시간을 체크해서 타임아웃된 연결을 강제 정리하는 Cleanup 스케줄러도 함께 구현했다.

@Scheduled(fixedDelayString = "${sse.cleanup.interval:600000}", initialDelay = 600000)
public void cleanupStaleConnections() {
    Instant now = Instant.now();
    long timeoutMillis = sseTimeout.toMillis();

    Iterator<Map.Entry<UUID, ConnectionInfo>> iterator = connections.entrySet().iterator();
    while (iterator.hasNext()) {
        Map.Entry<UUID, ConnectionInfo> entry = iterator.next();
        long elapsedMillis = Duration.between(entry.getValue().createdAt, now).toMillis();

        if (elapsedMillis > timeoutMillis) {
            entry.getValue().emitter.complete();
            iterator.remove();
        }
    }
}

 

Heartbeat가 능동적 감지라면, Cleanup은 주기적 강제 정리라는 말!! 두 가지를 함께 쓰면 유령 연결이 쌓이는 걸 효과적으로 방지할 수 있다.


문제 2. 연결 직후 Nginx가 자꾸 503을 뱉어요...

처음 배포했을 때 이상한 현상이 있었다. 로컬에서는 잘 되는데 Nginx를 거치면 연결 직후 503 에러가 간헐적으로 났다.

처음엔 설정 문제인가 싶어 Nginx 설정을 들여다봤는데,, 원인은 Nginx의 동작 방식 자체에 있었음

 

Nginx는 기본적으로 서버 응답을 버퍼에 쌓아두었다가 한꺼번에 클라이언트에 전달하려고 한다. SSE는 데이터를 실시간으로 조금씩 계속 보내야 하는데, 연결 직후 아무 데이터도 없으면 Nginx 입장에서는 '응답이 없네'하고 타임아웃으로 판단해버려서 503을 반환하는 것이었다.

연결 직후 데이터 없음 시나리오:
──────────────────────────────────────────────────────
클라이언트 → Nginx → Spring: SSE 연결 요청
Spring: SseEmitter 생성 완료, 대기 중...
Nginx: "응답이 없네? 타임아웃!" → 503 반환 💀
클라이언트: 연결 실패 → 자동 재연결 시도 → 또 503...
──────────────────────────────────────────────────────

 

SSE 특성상 처음 연결하고 나서 실제 알림이 오기 전까지는 서버에서 아무것도 안 보내는 게 자연스럽다. 그런데 그 자연스러운 공백이 인프라 계층에서 문제가 됐다.

 

해결 1  — 연결 직후 즉시 Handshake 이벤트 전송

연결이 됐으면 즉시 뭔가를 보내서 Nginx에게 나 살아있어!!!를 증명하자.

public SseEmitter createConnection(UUID userId) {
    SseEmitter emitter = new SseEmitter(sseTimeout.toMillis());

    // ... 콜백 등록 ...

    connections.compute(userId, (key, existing) -> {
        if (existing != null) existing.emitter.complete();
        return new ConnectionInfo(emitter);
    });

    // ✅ 연결 직후 즉시 확인 이벤트 전송 (Handshake)
    try {
        emitter.send(SseEmitter.event()
                .name("connected")
                .data("SSE 연결이 성공적으로 확립되었습니다."));
    } catch (IOException e) {
        // Handshake 실패 시 즉시 연결 제거 + 재연결 유도
        removeConnection(userId);
        throw new RuntimeException("SSE 연결 확립에 실패했습니다. 재연결을 시도해주세요.", e);
    }

    return emitter;
}

 

이 Handshake 이벤트는 두 가지 역할을 한다.

  • nginx에게 '응답 왔어, 타임아웃 아님' 신호 전달 → 503 방지
  • 클라이언트에게 '연결 성공' 즉시 확인 → UX 개선

클라이언트 입장에서도 나쁘지 않다. 연결 요청을 보내고 나서 connected 이벤트가 오면 '아, 연결됐구나'를 즉시 확인할 수 있으니까.

 

해결 2 — Nginx 설정 맞춤 조정

Handshake만으론 부족하다. Nginx가 SSE 트래픽을 제대로 처리하려면 설정도 맞춰줘야 한다.

location = /api/notifications/stream {
    proxy_pass http://backend:8080/api/notifications/stream;
    proxy_http_version 1.1;

    proxy_buffering off;           # SSE 핵심: 버퍼링 비활성화
    proxy_cache off;
    add_header X-Accel-Buffering no;

    proxy_read_timeout 1800s;      # 30분 연결 유지
    proxy_send_timeout 1800s;
    proxy_set_header Connection ''; # keep-alive
}

 

설정해둔 게 많은데 이런 설정들을 왜 맞추었냐면:

  • proxy_buffering off : 데이터를 버퍼에 쌓지 말고 바로 전달 (이게 없으면 SSE 메시지가 실시간 전달 안 됨)
  • X-Accel-Buffering no : 일부 Nginx 설정에서 추가로 버퍼링 비활성화
  • proxy_read_timeout 1800s : 30분 동안 데이터 없어도 연결 유지 (기본값 60초면 heartbeat 보내기도 전에 끊김)
  • Connection ' ' : HTTP/1.1 keep-alive 활성화

특히 proxy_read_timeout 기본값이 60초라는 걸 주의하면 좋을 것 같다. Heartbeat를 30초마다 보내도록 설정해뒀는데, 타임아웃이 60초면 간발의 차로 끊기는 상황이 생길 수 있다. SSE 연결 타임아웃(30분)과 맞춰서 넉넉하게 1800초로 설정해줬다.


문제 3. 동시 재연결 시 race condition 문제 — 원자적 연산

사용자가 탭을 새로고침하거나 네트워크가 불안정해서 SSE가 끊기면 브라우저는 자동으로 재연결을 시도한다. 이건 SSE의 기본 동작이라 좋은 기능이지만, 기존 연결과 새 연결이 거의 동시에 처리되는 상황이 생길 수 있다.

 

처음 구현 시의 코드를 보면 문제가 보인다.

// 개선 전 — race condition 발생 가능
SseEmitter existing = emitters.remove(userId); 	// ← (A) 기존 연결 제거
if (existing != null) {
    existing.complete();             
}
emitters.put(userId, newEmitter);   		  // ← (B) 새 연결 등록
// (A)와 (B) 사이에 다른 스레드가 끼어들 수 있음 ⚠️

 

(멀티스레드 환경)  remove()와 put() 사이의 미세한 시간차에 다른 스레드가 끼어들면 연결이 누락되거나 중복 저장될 수 있었다.

Race condition 시나리오
──────────────────────────────────────────────────────
Thread 1: remove(userId) 완료 → 기존 연결 제거됨
Thread 2: remove(userId) 시도 → 이미 없음 (null)
Thread 1: put(userId, emitter1) 등록
Thread 2: put(userId, emitter2) 등록  ← emitter1 덮어씌워짐! 💀

결과: emitter1은 map에서 사라졌지만 complete() 미호출
      → 클라이언트에게 종료 신호 없이 연결이 공중에 붕 뜸
──────────────────────────────────────────────────────

 

현실적으로 이 타이밍이 맞아떨어지는 확률이 낮다 보니 로컬 테스트에서는 안 잡힌다. 하지만 트래픽이 늘어나거나 네트워크가 불안정해서 재연결이 잦아지면 드물게 발생할 수 있는 버그다. "재현 안 되는데 간헐적으로 알림이 안 온다"는 유형의 버그가 (뭔지아시죠) 바로 이것임!

 

해결 — ConcurrentHashMap.compute()로 원자적 처리

ConcurrentHashMap.compute()는 키에 대한 연산 전체를 단일 원자적 연산으로 처리해서 다른 스레드가 중간에 끼어들 수 없다.

// 개선 후 — 원자적 연산
connections.compute(userId, (key, existing) -> {
    if (existing != null) {
        existing.emitter.complete(); // 기존 연결 종료
    }
    return new ConnectionInfo(emitter); // 새 연결 등록
    // 제거와 등록이 단일 원자적 연산으로 처리됨 ✅
});

 

코드 리팩토링 후 아까와 동일 시나리오로 상황을 비교해보자.

개선 후 동일 시나리오
──────────────────────────────────────────────────────
Thread 1: compute(userId, ...) 진입 → 해당 사용자에 대한 원자적 연산 시작
Thread 2: compute(userId, ...) 진입 시도 → Thread 1이 끝날 때까지 대기
Thread 1: 기존 연결 종료 + 새 연결 등록 완료 → 연결 갱신 완료
Thread 2: Thread 1이 등록한 연결 종료 + 자신의 연결 최종 등록

결과: 어떤 순서로 오든 마지막 연결만 남고, 이전 연결은 반드시 정리됨 ✅
──────────────────────────────────────────────────────
💡 ConcurrentHashMap이 스레드 안전한데 왜 race condition이 생길까?
ConcurrentHashMap 자체는 put()과 remove() 각각에 대해 thread-safe하다. 하지만 'remove 한 다음에 put'이라는 복합 연산에 대해서는 thread-safe를 보장하지 않는다. compute()를 쓰면 이 복합 연산 전체를 하나의 원자적 연산으로 묶을 수 있다.

이 개선 덕분에 사용자가 탭을 빠르게 새로고침하거나 네트워크가 불안정한 환경에서도 연결이 꼬이지 않고 항상 정확히 하나의 연결만 유지된다.

 

(tmi: ConcurrentHashMap을 전에 자바 병렬 프로그래밍 책 보면서 스터디로 경험했었었는데 이렇게 써먹을 수 있게 되어서 너무 기뻤다!)


문제 4. 서버 재시작 시 연결이 정리되지 않는다 — Graceful Shutdown

코드래빗의 두 번째 지적은 이랬다.

"현재는 IOException만 연결을 정리하고, 그 외 실패는 false 만 반환합니다.
IllegalStateException 같은 종료성 예외를 분리해 removeConnection() 을 호출해 주세요."
// 개선 전 — Exception으로 광범위하게 잡고 removeConnection() 미호출
} catch (Exception e) {
    log.error("SSE 알림 발송 중 예상치 못한 에러: userId={}, notificationId={}",
             userId, notification.getId(), e);
    return false;
    // ← removeConnection() 호출 없음!
    // IllegalStateException 발생 시 죽은 연결이 map에 그대로 남음 ⚠️
}

문제가 된 코드는 이런 코드였는데.. Exception으로 광범위하게 잡으면서 removeConnection()을 호출하지 않는 구조였다. SSE 연결이 끊겼을 때 발생하는 예외는 IOException만이 아니다.

 

✔️ SSE 연결 종료 시 발생 가능한 예외들

  • IOException : 네트워크 I/O 오류 (연결 끊김, 전송 실패)
  • IllegalStateException : emitter가 이미 완료/만료된 상태에서 send() 호출
  • RuntimeException : 그 외 예상치 못한 런타임 오류

IllegalStateException은 emitter가 이미 완료되거나 만료된 상태에서 send()를 호출할 때 발생한다. 이걸 잡지 않으면 이미 죽은 연결인데 removeConnection()이 호출되지 않아 connections 맵에 계속 남아있게 된다.

단순히 예외를 추가로 잡는 게 아니라, 예외 성격에 따라 처리 방식을 다르게 해야 한다는 게 핵심이다.

예외 성격별 처리 방식
──────────────────────────────────────────────────────
IOException           → 종료성 예외   → removeConnection() 후 조용히 처리
IllegalStateException → 종료성 예외   → removeConnection() 후 조용히 처리
RuntimeException      → 비종료성 예외 → removeConnection() 후 예외 재던짐
──────────────────────────────────────────────────────
// 개선 후 — 예외 성격별 분리 처리
} catch (IllegalStateException e) {
    log.error("SSE 연결 상태 에러: userId={}, notificationId={}",
             userId, notification.getId(), e);
    sseEmitterManager.removeConnection(userId);
    return false;
} catch (IOException e) {
    log.error("SSE 알림 발송 실패 (I/O): userId={}, notificationId={}",
             userId, notification.getId(), e);
    sseEmitterManager.removeConnection(userId);
    return false;
} catch (RuntimeException e) {
    log.error("SSE 알림 발송 중 런타임 에러: userId={}, notificationId={}",
             userId, notification.getId(), e);
    sseEmitterManager.removeConnection(userId);
    throw e; // 상위에서 인지할 수 있도록
}

 

이거 고치면서 깨달은 한 가지 원칙

어떤 상황에서도 끊긴 연결은 반드시 정리되어야 한다.

 

이후 한가지 생각이 더 들었다. 코드리뷰를 통해 예외 발생 시 removeConnection()으로 커넥션을 제거하는 것 까지는 보장했다. 근데 서버가 종료될 때는? 서버가 재시작될 때 열려있는 SSE 연결들은 어떻게 되는거지? 아무 처리도 없으면 클라이언트 쪽에서는 연결이 살아있다고 생각할텐데 서버는 이미 꺼진 상태가 된다.

 

해결 @PreDestroy 로 Graceful Shutdown

다시 정리하면! 서버가 재시작될 때 기존 SSE 연결들이 정리되지 않으면 클라이언트 쪽에서 연결이 끊겼는지 모르는 상태가 된다. 예외 처리에서 세운 원칙을 서버 종료 상황에도 그대로 적용해서, @PreDestroy로 서버 종료 시 모든 연결을 명시적으로 닫도록 했다.

@PreDestroy
public void shutdown() {
    log.info("SSE Emitter Manager 종료 시작: 총 {}개 연결 정리", connections.size());
    connections.forEach((userId, info) -> {
        try {
            info.emitter.complete();
        } catch (Exception e) {
            log.warn("SSE 연결 종료 중 에러: userId={}", userId, e);
        }
    });
    connections.clear();
    log.info("SSE Emitter Manager 종료 완료");
}

 

또한 같은 원칙을 콜백에도 적용해서, 예외가 발생하더라도 removeConnection()이 반드시 실행되도록 모든 콜백을 try-finally로 감쌌다.

 

emitter.onTimeout(() -> {
    try {
        log.debug("SSE 연결 타임아웃: userId={}", userId);
    } finally {
        removeConnection(userId); // 예외 발생해도 반드시 실행
    }
});

 

코드래빗의 지적이 단순히 "이 예외도 잡아라"가 아니라, '어떤 상황에서든 끊긴 연결은 정리되어야 한다'는 설계 원칙을 다시 생각하게 만든 계기가 됐고 그 원칙이 Graceful Shutdown까지 자연스럽게 이어진 셈이 되었다. Graceful Shutdown이라는 단어는 어깨너머로 많이 들어봤던 단어였는데 이렇게 쓰이는 거라고 알게 되어서 좋았고 개발 시야가 이렇게 또 넓어졌다.


최종 구조 요약

SseEmitterManager 구성 요소
──────────────────────────────────────────────────────────────
ConcurrentHashMap<UUID, ConnectionInfo>
    │
    ├── createConnection()     연결 생성 + Handshake 이벤트 전송
    ├── getConnection()        연결 조회 (알림 발송 시 사용)
    ├── removeConnection()     연결 제거
    │
    ├── sendHeartbeat()        [30초마다] 유령 연결 감지 및 제거
    ├── cleanupStaleConnections() [10분마다] 타임아웃 연결 강제 정리
    └── shutdown()             [서버 종료 시] 모든 연결 안전하게 닫음
──────────────────────────────────────────────────────────────

 

한 기능에 문제가 좀 많았는데, (ㅎㅎ..) 각 개선 사항이 해결하는 문제를 정리하면 이렇다.

문제 해결책 효과
유령 연결 누적 Heartbeat + Cleanup 스케줄러 메모리 누수 방지
Nginx 503/504 에러 연결 직후 Handshake 이벤트 전송 인프라 타임아웃 방지
동시 재연결 race condition compute() 원자적 연산 연결 누락/중복 방지
서버 재시작 시 연결 미정리 @PreDestroy Graceful Shutdown 안전한 리소스 정리

 

마무리

처음엔 SSE 연결 하나 만드는 게 뭐가 어렵겠어 했는데, 막상 프로덕션을 고려하니 신경 써야 할 게 생각보다 많았다.

 

SSE 자체는 구현이 단순한 편이다. 컨트롤러에 SseEmitter 반환하고, 알림 발송 시 emitter.send() 호출하면 기본 동작은 된다. 하지만 '동작하는 것'과 '안정적으로 동작하는 것'은 다르다.

유령 연결은 메모리를 조용히 갉아먹고, Nginx는 아무 데이터도 없다고 연결을 끊어버리고, 동시 재연결은 타이밍이 맞으면 연결을 날려버린다고 하고, 서버 재시작은 클라이언트를 계속 기다리게 만들고,, 각각 따로 보면 별것 아닌 것 같은데 하나라도 빠지면 '간헐적으로 알림이 안 온다'는 원인 파악도 어려운 버그가 된다는 거.

이 네 가지를 챙기고 나서야 비로소 프로덕션에 올릴 수 있겠다는 확신이 생겼다.

 

개인적으로 이번 구현에서 가장 인상 깊었던 건 CodeRabbit의 리뷰가 단순히 '이렇게 고쳐라'가 아니라 더 넓은 시야를 열어줬다는 것이다. 예외 처리 지적 하나가 '끊긴 연결은 반드시 정리되어야 한다'는 원칙으로 이어졌고 그 원칙이 자연스럽게 Graceful Shutdown까지 연결됐다. Heartbeat 스레드 지적도 단순한 리소스 절약 차원을 넘어 공유 스케줄러 패턴을 제대로 이해하는 계기가 됐다.

 

특히 Heartbeat와 Cleanup을 함께 쓰는 이중 안전장치, 그리고 compute()로 원자적 연산을 보장하는 패턴은 SSE뿐 아니라 비슷한 커넥션 풀을 직접 관리하는 상황에서도 그대로 적용할 수 있는 방법이라고 생각한다 😊


참고 자료

https://medium.com/@priyasrivastava18official/long-polling-vs-sse-vs-websocket-d8e474940feb