SSE 실시간 알림 (1/8)

다음 편: [스프링] 2. SSE 연결 관리와 동시성

웹 서비스에서 "새 댓글이 달렸습니다"라는 알림을 사용자에게 즉시 보여주려면 어떻게 해야 할까? 사용자가 새로고침을 누르기 전에, 서버가 먼저 데이터를 밀어넣는 방법이 필요하다. 이 편에서는 그 방법 중 하나인 SSE의 개념과 동작 원리를 다룬다.

실시간 통신이 필요한 순간

HTTP는 클라이언트가 요청하면 서버가 응답하는 구조다. 서버가 먼저 말을 걸 수는 없다.

그런데 알림, 실시간 피드, 주식 시세 같은 기능은 서버 쪽에서 새 데이터가 생겼을 때 즉시 클라이언트에 전달해야 한다. 클라이언트가 "혹시 새 데이터 있어?"라고 매번 물어보는 건 비효율적이다.

이 문제를 해결하기 위해 여러 기술이 등장했다. 각각의 방식이 어떤 문제를 어떻게 풀었는지 비교해 보자.

sequenceDiagram autonumber participant C as 클라이언트 participant S as 서버 Note over C, S: [Polling] 주기적 확인 rect rgb(245, 245, 245) C->>S: 새 데이터 있나요? S-->>C: 아니요 (200 OK) Note right of C: (3초 대기) C->>S: 새 데이터 있나요? S-->>C: 네, 여기요! (200 OK) end Note over C, S: [Long Polling] 데이터 생길 때까지 대기 rect rgb(240, 245, 255) C->>S: 데이터 생기면 알려주세요 Note right of S: (데이터 생길 때까지 응답 보류) S-->>C: 데이터 발생! (200 OK) end Note over C, S: [SSE] 스트리밍 연결 유지 rect rgb(240, 255, 240) C->>S: 연결 요청 (Accept: text/event-stream) S-->>C: 연결 성공 (200 OK) S->>C: data: 이벤트 1 S->>C: data: 이벤트 2 end

실시간 통신 기술 비교

Polling

가장 단순한 방법이다. 클라이언트가 일정 주기마다 서버에 요청을 보내서 새 데이터가 있는지 확인한다.

setInterval(async () => {
    const response = await fetch('/api/notifications/new');
    const data = await response.json();
    if (data.length > 0) {
        renderNotifications(data);
    }
}, 3000); // 3초마다

간단하지만, 새 데이터가 없어도 요청은 계속 나간다. 서버 입장에서는 "아무것도 없는데요"라는 빈 응답을 끊임없이 보내야 한다. 사용자가 많아지면 이 빈 요청들이 서버에 상당한 부하를 준다.

Long Polling

Polling의 개선 버전이다. 클라이언트가 요청을 보내면, 서버가 새 데이터가 생길 때까지 응답을 보류한다. 데이터가 생기는 순간 응답을 보내고, 클라이언트는 응답을 받자마자 다시 요청을 건다.

빈 응답이 줄어드니 Polling보다 효율적이다. 하지만 매번 새 HTTP 연결을 맺어야 하는 오버헤드가 여전히 있고, 서버에서 대기 중인 연결을 관리하는 것도 부담이다.

SSE

SSE는 Server-Sent Events의 약자다. 클라이언트가 한 번 연결하면, 서버가 그 연결을 열어둔 채 데이터가 생길 때마다 밀어 보내는 방식이다.

핵심 특징을 정리하면:

  • 단방향 스트림 : 서버 → 클라이언트 방향만 가능하다. 클라이언트에서 서버로 데이터를 보내려면 별도의 HTTP 요청을 쓴다.
  • HTTP 기반 : 기존 HTTP 인프라를 그대로 쓸 수 있다. 프록시, 로드밸런서, 방화벽이 별도 설정 없이 통과된다.
  • 자동 재연결 : 연결이 끊기면 브라우저가 자동으로 다시 연결을 시도한다. 개발자가 재연결 로직을 짤 필요가 없다.
  • 텍스트 전용 : 바이너리 데이터는 전송할 수 없다. JSON 문자열이 주로 쓰인다.

WebSocket

WebSocket은 HTTP에서 시작하지만, 핸드셰이크 후 TCP 위에서 양방향 전이중 통신으로 전환된다. 클라이언트와 서버가 자유롭게 메시지를 주고받을 수 있다.

채팅처럼 양방향 통신이 빈번한 경우에 적합하다. 하지만 HTTP와 다른 프로토콜이라서 프록시 설정이나 방화벽 통과에 추가 작업이 필요할 수 있다.

어떤 기술을 선택하는가

graph TD Start([실시간 통신 기술 선택]) --> Bidirectional{양방향 통신이
필요한가?} Bidirectional -- "예 (채팅, 게임)" --> WebSocket[WebSocket] Bidirectional -- "아니오 (알림, 피드)" --> OneWay{서버에서
데이터를 밀어주는가?} OneWay -- "예" --> SSE[SSE] OneWay -- "아니오 (단순 조회)" --> Polling[Polling / Long Polling] style SSE fill:#e1f5fe,stroke:#01579b style WebSocket fill:#fff3e0,stroke:#e65100
기준PollingLong PollingSSEWebSocket
방향클라이언트 → 서버클라이언트 → 서버서버 → 클라이언트양방향
프로토콜HTTPHTTPHTTPWS / TCP
서버 부하높음중간낮음낮음
구현 난이도쉬움보통쉬움높음
자동 재연결직접 구현직접 구현브라우저 내장직접 구현
적합한 케이스레거시 호환제한적 실시간알림, 피드, 시세채팅, 게임

서버에서 클라이언트로 일방적으로 데이터를 보내는 케이스라면 SSE가 가장 적합하다. 알림 시스템이 정확히 이 케이스에 해당한다.

실무에서의 선택 기준

"양방향이 필요한가?"를 먼저 판단한다. 알림, 대시보드 갱신, 실시간 피드처럼 서버 → 클라이언트 단방향이면 SSE로 충분하다. WebSocket은 채팅, 실시간 협업 에디터, 멀티플레이 게임처럼 양쪽이 빈번하게 메시지를 주고받는 경우에 쓴다.

SSE 프로토콜 구조

SSE는 HTTP 응답의 Content-Type을 text/event-stream으로 설정한 뒤, 특정 형식에 맞춰 텍스트를 스트리밍하는 구조다. 별도의 프로토콜 업그레이드 없이 일반 HTTP 위 에서 동작한다.

sequenceDiagram participant C as 클라이언트 participant S as 서버 C->>S: GET /api/sse/connect (Accept: text/event-stream) S-->>C: 200 OK (Content-Type: text/event-stream) Note over C, S: 연결 유지 (Streaming) S->>C: event: user-joined
data: {"name": "Alice"} Note right of S: (빈 줄로 메시지 구분) S->>C: id: 1
data: {"msg": "Hello!"} S->>C: : heartbeat (주기적 연결 확인용 주석)

메시지 형식

서버가 보내는 각 메시지는 네 가지 필드로 구성된다. 각 필드는 한 줄에 하나씩, 필드명: 값 형태로 작성한다. 메시지와 메시지 사이는 빈 줄(\n\n)로 구분한다.

event: notification
id: 42
data: {"title": "새 댓글", "message": "좋은 글이네요!"}
retry: 3000
graph LR subgraph "SSE 메시지 단위" E[event: 이름] --- D[data: 실제 내용] D --- I[id: 식별자] I --- R[retry: 재연결 시간] end R --- Next["\n\n (구분자)"] style E fill:#f9f,stroke:#333 style D fill:#bbf,stroke:#333 style I fill:#dfd,stroke:#333 style R fill:#ffd,stroke:#333

각 필드의 역할은 다음과 같다:

  • event : 이벤트 이름이다. 클라이언트에서 addEventListener('notification', ...)으로 수신한다. 생략하면 message라는 기본 이벤트로 전달된다.
  • data : 실제 전송 데이터다. 여러 줄에 걸쳐 보내려면 data: 줄을 여러 번 쓴다.
  • id : 이벤트 고유 식별자다. 재연결 시 Last-Event-ID 헤더로 서버에 전달된다.
  • retry : 재연결 대기 시간이다. 밀리초 단위로, 브라우저가 이 값을 기억해두고 연결이 끊겼을 때 해당 시간만큼 기다렸다가 재연결한다.

자동 재연결과 Last-Event-ID

SSE의 가장 큰 장점 중 하나는 브라우저가 재연결을 알아서 처리한다는 것이다.

연결이 끊기면 브라우저는 다음 단계를 자동으로 수행한다:

  1. retry에 지정된 시간만큼 대기한다. 기본값은 보통 3초다.
  2. 마지막으로 받은 id 값을 Last-Event-ID 요청 헤더에 담아서 서버에 재연결 요청을 보낸다.
  3. 서버는 이 ID를 보고 "이 클라이언트가 어디까지 받았는지" 판단할 수 있다.

이 메커니즘 덕분에 네트워크가 잠시 불안정해도 클라이언트 코드에서 재연결 로직을 별도로 구현할 필요가 없다. 단, 서버 측에서 Last-Event-ID를 받아서 누락된 이벤 트를 재전송하는 로직은 직접 구현해야 한다. 이 부분은 개념 4편에서 상세히 다룬다.

sequenceDiagram participant C as 클라이언트 participant S as 서버 Note over C, S: 연결 활성 상태 (id: 10까지 수신) C-xS: 연결 끊김 (네트워크 불안정) Note right of C: retry 시간만큼 대기 (기본 3초) C->>S: 재연결 요청
(Last-Event-ID: 10) S-->>C: 연결 성공 S->>C: id: 11 (누락된 데이터 전송)

브라우저의 EventSource API

SSE를 클라이언트에서 사용하려면 EventSource 객체를 쓴다. 브라우저 네이티브 API라서 별도 라이브러리가 필요 없다.

연결과 이벤트 리스닝

const eventSource = new EventSource('/api/sse/connect?userId=user1');

URL에 연결하면 브라우저가 서버와 SSE 연결을 수립한다. 이후 서버가 보내는 이벤트를 addEventListener로 수신한다.

// 커스텀 이벤트 리스닝 (서버에서 event: notification으로 보낸 메시지)
eventSource.addEventListener('notification', (event) => {
    const data = JSON.parse(event.data);
    console.log('알림:', data);
});

// 기본 이벤트 (event 필드가 없는 메시지)
eventSource.onmessage = (event) => {
    console.log('메시지:', event.data);
};

// 에러 처리
eventSource.onerror = (error) => {
    console.error('SSE 에러:', error);
};

서버에서 event: notification으로 보내면 클라이언트에서 addEventListener('notification', ...)으로 받는다. event 필드 없이 보내면 onmessage로 받는다. 커스텀 이벤트 이름을 쓴다면 반드시 addEventListener를 써야 한다는 점을 기억하자.

연결 상태 관리

eventSource.readyState로 현재 연결 상태를 확인할 수 있다.

  • EventSource.CONNECTING (0) : 연결 중이거나 재연결 시도 중
  • EventSource.OPEN (1) : 연결 활성 상태
  • EventSource.CLOSED (2) : 연결 종료
eventSource.close();

close()를 호출하면 연결이 끊기고, 브라우저의 자동 재연결도 중단된다.

EventSource의 제약

EventSource는 GET 요청만 지원한다. 커스텀 헤더도 설정할 수 없다. 인증 토큰을 헤더로 보내야 하는 상황이면 쿼리 파라미터에 담거나, event-source-polyfill이나 fetch 기반의 SSE 클라이언트 라이브러리를 사용해야 한다.

Spring Boot에서의 SseEmitter

Spring Framework는 SseEmitter라는 클래스로 SSE를 지원한다. 컨트롤러에서 SseEmitter 객체를 반환하면 Spring이 응답의 Content-Type을 text/event-stream으로 설정하고, 연결을 열어둔 채 데이터를 스트리밍할 수 있게 해준다.

SseEmitter 기본 구조

@GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter connect() {
    SseEmitter emitter = new SseEmitter(3600000L);

    emitter.send(
            SseEmitter.event()
                    .name("connect")
                    .data("연결 성공")
    );

    return emitter;
}

SseEmitter 객체를 생성할 때 타임아웃을 밀리초 단위로 지정한다. 위 예시는 1시간이다. 이 시간이 지나면 서버가 자동으로 연결을 종료한다.

SseEmitter.event()는 빌더 패턴으로 메시지를 구성한다:

  • .name(String) → SSE의 event 필드
  • .data(Object) → SSE의 data 필드. 객체를 넘기면 JSON으로 직렬화된다.
  • .id(String) → SSE의 id 필드

produces = MediaType.TEXT_EVENT_STREAM_VALUE는 응답의 Content-Type을 text/event-stream으로 명시한다.

타임아웃을 반드시 설정한다

타임아웃을 지정하지 않으면 Spring의 기본값이 적용되는데, 서블릿 컨테이너에 따라 30초~60초 정도다. 실시간 알림에서 30초마다 연결이 끊기고 재연결되면 서버에 불필요한 부하가 생긴다.

생명주기 콜백

SseEmitter는 연결의 생명주기를 관리하기 위한 세 가지 콜백을 제공한다.

emitter.onCompletion(() -> {
    // 연결이 정상 종료되었을 때
});

emitter.onTimeout(() -> {
    // 타임아웃이 발생했을 때
});

emitter.onError((e) -> {
    // 전송 중 에러가 발생했을 때
});

연결이 끊어진 SseEmitter에 메시지를 보내면 IOException이 발생한다. 그래서 연결이 끊긴 emitter는 즉시 정리해야 한다. 이 콜백들이 바로 그 정리 시점을 알려주는 역할을 한다. 개념 2편에서 이 콜백을 활용한 연결 관리 패턴을 다룬다.

stateDiagram-v2 [*] --> Created: SseEmitter 생성 Created --> Open: client.connect() state Open { [*] --> Sending: emitter.send() Sending --> Sending } Open --> Completed: emitter.complete() Open --> Timeout: 타임아웃 발생 Open --> Error: 네트워크 에러 등 Completed --> [*] Timeout --> [*] Error --> [*] note right of Completed: onCompletion 콜백 실행 note right of Timeout: onTimeout 콜백 실행 note right of Error: onError 콜백 실행

자주 하는 실수

SSE를 양방향 통신에 사용

SSE는 서버 → 클라이언트 단방향 전용이다. 클라이언트에서 서버로 데이터를 보내야 한다면 별도의 REST API 호출을 사용해야 한다. 양방향 통신이 필요하면 WebSocket을 고려한다.

[!DANGER] EventSource의 onmessage로 커스텀 이벤트를 받으려 함

서버에서 event: notification처럼 이벤트 이름을 지정하면 onmessage로는 받을 수 없다. 반드시 addEventListener('notification', ...)을 써야 한다. onmessage는 이벤트 이름이 없는 메시지만 받는다.

[!DANGER] CORS 설정 누락

프론트엔드와 백엔드가 다른 도메인에서 동작한다면 SSE 연결에도 CORS 설정이 필요하다. EventSource는 쿠키를 기본으로 보내지 않으므로, withCredentials 옵션이 필요한 경우 폴리필 라이브러리를 검토해야 한다.