Web Api Sse Server Sent Events Eventsource

Published: by Creative Commons Licence

  • Categories:
  • Tags:

r— layout: post date: 2025-11-03 14:39:19 +0900 title: '[Web API] SSE, Server-sent events' categories:

참고 문서

개요

SSE(Server-Sent Events)는 HTTP 연결을 통해 서버가 클라이언트에게 데이터를 실시간으로 단방향 푸시하는 기술이다. 뉴스 피드, 주가 정보, 알림 시스템 등 실시간 업데이트가 필요한 상황에 주로 사용된다.

SSE 통신은 클라이언트의 EventSource 인터페이스를 통해 시작되고, 서버는 연결을 유지한 채 연속적인 스트림으로 응답한다.

ℹ️ SSE는 단방향만 가능하니, 양방향이 필요하면 웹소켓을 쓰자

클라이언트측 구현: EventSource

EventSource API로 이벤트를 수신하도록 구현한다.

⚠️ EventSource는 표준 사양에 따라 HTTP GET 메서드만 지원한다.

new EventSource(url)
new EventSource(url, options)
  • url: 이벤트나 메시지를 제공하는 원격 자원의 위치. 쉽게 말해서 서버의 SSE 엔드포인트 주소다.
  • options:
    • withCredentials: 기본값 false. 동일 출처일 때는 의미 없는 옵션으로, 이 값이 true 교차 출처 요청(CORS)일 때도 자격 증명 정보(쿠키, HTTP 인증 헤더, 클라이언트 SSL 인증서 등)를 서버에 전송한다. 서버도 이에 응답하려면 Access-Control-Allow-Credentials: true 헤더 필요.
const eventSource = new EventSource('/api/v1/sse');

// 이벤트 유형이 없을 때 실행
eventSource.onmessage = (event) => {
  console.log('받은 데이터:', event.data);
};

// 이벤트 유형이 notice일 때 실행
eventSource.addEventListener('notice', (event) => {
  console.log('알림:', event.data);
});

// 이벤트 유형이 tick일 때 실행
eventSource.addEventListener('tick', (event) => {
  console.log('서버 시각:', event.data);
});
// 종료
eventSource.close();

서버측 구현

서버측의 SSE 응답은 Content-Type: text/event-stream, Connection: keep-alive, Cache-Control: no-cache 헤더를 설정해야 한다.

응답 시 데이터는 이런 모양이어야 한다:

id: aweosomeId\n
event: notice\n
data: 깜짝 이벤트 알림!\n\n
  • 각 항목의 구분은 줄 바꿈(\n)으로 하며, 종료 문자는 두 번의 줄 바꿈(\n\n)이다.
  • 콜론 다음에 공백은 없어도 됨
  • id는 필요 없으면 생략할 수 있다.
  • event는 생략하면 eventSource.onmessage() 에서 처리함
  • data는 생략하면 EventSource API의 파서가 메시지 경계를 제대로 인식하지 못한다. 따라서 빈 값이라도 보내는 걸 권장한다.

JavaScript - Node.js - Express

res.write()를 사용하여 응답 스트림을 닫지 않고 데이터를 주기적으로 전송한다:

// Express.js
app.get('/api/sse', (req, res) => {
  // 1. 헤더 설정
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('Cache-Control', 'no-cache');

  // ℹ️ withCredentials: true 일 때 필요한 헤더
  // res.setHeader('Access-Control-Allow-Credentials', 'true');
  
  let counter = 0;
  const intervalId = setInterval(() => {
    counter++;
    const time = new Date().toLocaleTimeString();
    
    /*
    2. SSE 규격 데이터
    */
    const sseData = `id: ${counter}\nevent: tick\ndata: ${time}\n\n`;
    
    // 3. 스트림에 데이터 쓰기
    res.write(sseData);
  }, 1000);

  // 4. 클라이언트 연결 종료 시 정리
  req.on('close', () => {
    clearInterval(intervalId);
    res.end();
  });
});

Java - Spring - WebFlux

Spring WebFlux의 Flux를 사용하여 비동기적으로 이벤트 스트림 처리:

// Spring Boot (WebFlux)
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import reactor.core.publisher.Flux;
import java.time.Duration;

@GetMapping(value = "/api/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamEvents() {
    
    // 1. Content-Type은 produces 속성으로 자동 설정됨 (text/event-stream)
    
    // 2. 1초마다 이벤트를 생성하여 스트리밍
    return Flux.interval(Duration.ofSeconds(1))
               .map(sequence -> ServerSentEvent.<String>builder()
                   // 3. SSE 필드를 Builder로 쉽게 작성
                   .id(String.valueOf(sequence))
                   .event("tick")
                   .data(LocalDateTime.now().toString())
                   .build());
}

Python - Flask

yield를 사용하여 제너레이터를 만들고, Response 객체에 text/event-stream 타입을 지정하여 스트리밍:

# Flask
from flask import Flask, Response
import time

app = Flask(__name__)

# SSE 규격 데이터 형식 함수
def format_sse(data, event=None, id=None, retry=None):
    msg = f'data: {data}\n\n'
    if event:
        msg = f'event: {event}\n{msg}'
    if id:
        msg = f'id: {id}\n{msg}'
    return msg

# SSE 엔드포인트
@app.route('/api/sse')
def stream():
    
    def event_stream():
        count = 0
        while True:
            time.sleep(1) # 1초 대기
            current_time = time.strftime('%H:%M:%S')
            count += 1
            
            # 1. SSE 형식에 맞춰 데이터 생성 후 yield (스트리밍)
            yield format_sse(
                data=f'서버 시간: {current_time}',
                event='tick',
                id=count
            )
            
    # 2. Response 객체로 스트리밍하고 Content-Type 설정
    return Response(
        event_stream(),
        mimetype='text/event-stream'
    )

활용 예: SSE를 활용한 2FA 인증 구현

SSE는 페이지 새로고침이나 풀링 없이 다른 장치(모바일 앱 등)의 2차 인증(2FA) 완료를 감지해 로그인하는 시스템에 활용할 수 있다. 여기서 핵심은 HTTP 연결 응답 객체를 사용자 식별값(로그인 아이디 등)과 매핑하여 서버 메모리로 관리하는 것이다.

💡 핵심 원리: 연결 식별 및 서버 푸시

  1. 연결 식별 및 저장: 아이디와 비번 입력 단계를 통과했다면 클라이언트는 서버와의 SSE 연결을 생성한다. 서버는 이 연결의 HTTP 응답 스트림 객체(res 객체)를 사용자 식별값과 매핑하여 저장소(인스턴스를 저장할 수 있어야 함. 보통은 서버 메모리)에 저장한다. 이 응답 객체는 해당 서버 인스턴스의 메모리에만 존재하므로, 서버를 여러 대(클러스터) 운용할 경우 Redis등을 이용해 메시지를 라우팅해야 한다.
  2. 상태 대기: 서버는 우선 '인증 대기' 상태를 알리는 메시지를 보낸다.
  3. 2단계 인증 진행: 별도의 장치에서 서버에 2단계 인증이 완료되었음을 알린다.
  4. 특정 클라이언트에 푸시: 외부의 2단계 인증이 완료되면, 서버는 저장소에서 해당 인증의 식별값으로 메모리에서 응답 객체를 찾아 '2단계 인증 완료' 메시지를 전송한다.
  5. 연결 종료: 메시지 전송 직후 서버와 클라이언트 모두 연결을 명시적으로 종료하여 리소스 낭비를 방지한다.

💻 코드 요약

아래는 자바스크립트로 만든 실제 작동하는 코드의 일부분이다.

클라이언트

아이디/비밀번호 인증 단계를 통과했다 치고, 서버와의 SSE 연결을 연다. 그 다음 서버의 메시지를 기다리다가 '2단계 인증 완료' 이벤트 수신 시 로그인을 성공 처리하고 연결을 닫는다.

// 클라이언트: 2FA 대기 시작
const eventSource = new EventSource(`/api/v1/sse/login/${userId}`);

// 초기 접속 권한 확인
eventSource.addEventListener('login', event => {
  if (event.data === 'granted') {
    // 2FA 팝업 창 오픈 등
  }
});

// 최종 2FA 성공 메시지 수신
eventSource.addEventListener('2fa-complete', event => {
  console.log("로그인 성공!");
  eventSource.close(); // 연결 명시적 종료
});

서버: SSE 연결 엔드포인트

최초 SSE 연결의 응답 객체를 맵에 저장해 특정 클라이언트와의 통로를 확보한다.

const sseClients = new Map();

app.get('/api/v1/sse/login/:userId', (req, res) => {
  // 필수 헤더 설정
  res.setHeader('Content-Type', 'text/event-stream');
  // ...
  sseClients.set(req.params.userId, res);
  
  // 초기 'granted' 메시지 전송
  const sseData = `event: login\ndata: granted\n\n`;
  res.write(sseData);
  
  req.on('close', () => {
    sseClients.delete(req.params.userId); // 연결 해제 시 반드시 제거
  });
});

서버: 2FA 완료 처리 엔드포인트

저장된 응답 객체를 찾아 메시지를 전송하고 연결을 끊는다.

app.post('/api/v1/sse/2fa/complete/:userId', (req, res) => {
  const userId = req.params.userId;
  const targetClient = sseClients.get(userId);

  if (targetClient) {
    // 특정 클라이언트에게 '2fa-complete' 메시지 푸시
    const sseData = `event: 2fa-complete\ndata:\n\n`;
    targetClient.write(sseData); 
    
    targetClient.end(); // 서버 측에서 연결 종료
    sseClients.delete(userId);
    
    res.status(200).send("OK");
  }
});