NestJS SSE (Server-Sent Events)
실시간으로 서버의 데이터를 클라이언트에 전달해야 하는 상황은 자주 발생한다. 트렌드 피드 갱신, 알림, 주가 업데이트 같은 기능이 대표적이다. 이런 실시간 통신을 구현하는 방법은 크게 세 가지가 있다.
폴링(Polling) — 클라이언트가 일정 간격으로 서버에 요청을 보내서 새 데이터가 있는지 확인한다. 구현은 간단하지만, 데이터가 없어도 계속 요청이 발생하고, 요청 간격에 따라 실시간성과 서버 부하 사이에서 트레이드오프가 생긴다.
WebSocket — 클라이언트와 서버 간에 양방향 연결을 열어놓고 양쪽 모두 자유롭게 데이터를 보낼 수 있다. 채팅처럼 양방향 통신이 필요한 경우에 적합하지만, 연결 관리가 복잡하고 HTTP와 다른 프로토콜을 사용하기 때문에 프록시나 로드밸런서 설정이 까다롭다.
SSE (Server-Sent Events) — 서버에서 클라이언트로의 단방향 스트리밍이다. HTTP 위에서 동작하기 때문에 기존 인프라와 자연스럽게 호환되고, 브라우저가 자동 재연결을 지원한다.
핵심은 이것이다: 서버 → 클라이언트 단방향 데이터 전송만 필요한 경우, SSE가 WebSocket보다 훨씬 간단하고 효율적이다. 클라이언트에서 서버로 데이터를 보낼 일이 없다면 WebSocket을 쓸 이유가 없다.
SSE 프로토콜의 동작 원리
SSE는 일반적인 HTTP 응답과 다르게, 응답을 한 번 보내고 끝내는 것이 아니라 연결을 유지한 채 데이터를 계속 보낸다. 이것이 가능한 이유는 Content-Type: text/event-stream이라는 특별한 MIME 타입 덕분이다.
HTTP 레벨에서의 흐름
GET /api/feed/trend/sse HTTP/1.1
Accept: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: {"message":"현재 트렌드 피드 수신 완료","data":[...]}
data: {"message":"새로운 트렌드 피드 수신 완료","data":[...]}
응답 본문은 data: 접두사가 붙은 텍스트 라인들로 구성된다. 각 이벤트는 빈 줄(\n\n)로 구분된다. 브라우저의 EventSource API가 이 형식을 자동으로 파싱해서 onmessage 콜백에 전달한다.
이벤트 형식
SSE 프로토콜이 지원하는 필드는 네 가지다:
id: 1
event: ranking-update
data: {"trend": [...]}
retry: 5000
| 필드 | 설명 |
|---|---|
data | 이벤트 데이터. 여러 줄이면 data: 라인을 여러 개 쓴다 |
event | 이벤트 타입 이름. 생략하면 message 타입이 됨 |
id | 이벤트 ID. 재연결 시 Last-Event-ID 헤더로 전송됨 |
retry | 재연결 대기 시간(ms). 브라우저 기본값은 보통 3초 |
id 필드가 특히 중요하다. 네트워크가 끊어져서 재연결될 때, 브라우저는 마지막으로 받은 id를 Last-Event-ID 헤더에 담아 보낸다. 서버는 이 값을 확인해서 놓친 이벤트부터 다시 보내줄 수 있다.
SSE vs WebSocket 비교
| 항목 | SSE | WebSocket |
|---|---|---|
| 방향 | 서버 → 클라이언트 (단방향) | 양방향 |
| 프로토콜 | HTTP | ws:// / wss:// |
| 자동 재연결 | 브라우저 내장 | 직접 구현 필요 |
| 데이터 형식 | 텍스트 (UTF-8) | 텍스트 + 바이너리 |
| 프록시/CDN 호환 | 자연스러움 (HTTP) | 설정 필요 (Upgrade) |
| 연결 수 제한 | 도메인당 6개 (HTTP/1.1) | 제한 없음 |
| 구현 복잡도 | 낮음 | 높음 |
HTTP/1.1에서 브라우저는 도메인당 최대 6개의 동시 연결을 허용한다. SSE 연결도 이 제한에 포함되기 때문에, 여러 SSE 엔드포인트를 동시에 연결하면 다른 HTTP 요청이 차단될 수 있다. HTTP/2에서는 하나의 TCP 연결 위에 다중 스트림이 가능하므로 이 문제가 해결된다.
NestJS에서 SSE 구현
NestJS는 @Sse 데코레이터를 제공해서 SSE 엔드포인트를 쉽게 만들 수 있다. 핵심 아이디어는 Observable을 반환하는 것이다. Observable이 값을 emit할 때마다 클라이언트에 SSE 이벤트가 전송된다.
기본 구조
import { Controller, Sse } from '@nestjs/common';
import { Observable } from 'rxjs';
@Controller('feed')
export class FeedController {
@Sse('trend/sse')
readTrendFeedList(): Observable<MessageEvent> {
return new Observable((observer) => {
// observer.next()를 호출할 때마다 클라이언트에 이벤트 전송
observer.next({
data: { message: '초기 데이터', data: [] },
});
});
}
}
@Sse('trend/sse') 데코레이터는 두 가지를 한다:
- 해당 라우트의
Content-Type을text/event-stream으로 설정 - 반환된 Observable을 구독해서 SSE 형식으로 스트리밍
반환 타입 MessageEvent는 NestJS가 정의한 인터페이스로, SSE 프로토콜의 필드와 매핑된다:
interface MessageEvent {
data: string | object; // → data: 필드
id?: string; // → id: 필드
type?: string; // → event: 필드
retry?: number; // → retry: 필드
}
data에 객체를 넘기면 NestJS가 자동으로 JSON.stringify()를 적용한다.
EventEmitter2와 연동
실제 서비스에서는 데이터가 변경되는 시점에 이벤트를 발행하고, SSE 핸들러에서 이 이벤트를 구독하는 패턴이 일반적이다. NestJS의 @nestjs/event-emitter 패키지가 제공하는 EventEmitter2를 사용하면 모듈 간 느슨한 결합을 유지하면서 이벤트를 전달할 수 있다.
import { Controller, Sse } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Observable } from 'rxjs';
@Controller('feed')
export class FeedController {
constructor(
private readonly feedService: FeedService,
private readonly eventService: EventEmitter2,
) {}
@Sse('trend/sse')
readTrendFeedList() {
return new Observable((observer) => {
// 1. 연결 즉시 현재 데이터 전송
this.feedService
.readTrendFeedList()
.then((trendData) => {
observer.next({
data: {
message: '현재 트렌드 피드 수신 완료',
data: trendData,
},
});
})
.catch((err) => observer.error(err));
// 2. 이후 데이터 변경 시마다 전송
const handler = (trendData) => {
observer.next({
data: {
message: '새로운 트렌드 피드 수신 완료',
data: trendData,
},
});
};
this.eventService.on('ranking-update', handler);
// 3. 연결 종료 시 리스너 정리 (중요!)
return () => {
this.eventService.off('ranking-update', handler);
};
});
}
}
이 패턴의 흐름은 이렇다:
- 클라이언트가 SSE 연결을 맺으면 Observable이 생성된다
- 즉시 현재 트렌드 데이터를 조회해서 첫 이벤트로 보낸다
EventEmitter2의ranking-update이벤트를 구독한다- 스케줄러나 다른 서비스에서
ranking-update이벤트를 발행하면, 연결된 모든 클라이언트에게 새 데이터가 전송된다 - 클라이언트가 연결을 끊으면 teardown 함수가 실행되어 이벤트 리스너가 제거된다
여기서 teardown 함수(Observable 생성 함수가 반환하는 함수)가 핵심이다. 이걸 빠뜨리면 클라이언트가 연결을 끊어도 이벤트 리스너가 남아서 메모리 누수가 발생한다. 연결이 많아질수록 누적되어 서버가 느려지다가 결국 OOM으로 죽을 수 있다.
이벤트 발행 측
랭킹 데이터를 주기적으로 갱신하는 스케줄러에서 이벤트를 발행한다:
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class FeedScheduler {
constructor(
private readonly feedService: FeedService,
private readonly eventService: EventEmitter2,
) {}
@Cron(CronExpression.EVERY_30_SECONDS)
async updateTrendFeed() {
const trendData = await this.feedService.calculateTrendRanking();
// 연결된 모든 SSE 클라이언트에게 전달됨
this.eventService.emit('ranking-update', trendData);
}
}
@Cron(CronExpression.EVERY_30_SECONDS)가 30초마다 트렌드 랭킹을 재계산하고, EventEmitter2.emit()으로 이벤트를 발행한다. SSE 핸들러에서 이 이벤트를 구독하고 있으므로, 연결된 모든 클라이언트에게 자동으로 새 데이터가 전송된다.
클라이언트: EventSource API
브라우저에서 SSE를 사용하려면 EventSource API를 쓴다. XMLHttpRequest나 fetch로도 받을 수 있지만, EventSource는 자동 재연결, 이벤트 파싱, Last-Event-ID 전송 등을 내장하고 있어서 직접 구현할 필요가 없다.
기본 사용법
const eventSource = new EventSource('/api/feed/trend/sse');
// 기본 message 이벤트 수신
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data);
};
// 에러 처리 (네트워크 끊김 등)
eventSource.onerror = (error) => {
console.error('SSE 연결 에러:', error);
// 브라우저가 자동으로 재연결을 시도함
};
// 연결 종료
eventSource.close();
EventSource의 readyState 프로퍼티로 현재 연결 상태를 확인할 수 있다:
| 값 | 상수 | 의미 |
|---|---|---|
| 0 | CONNECTING | 연결 중 또는 재연결 중 |
| 1 | OPEN | 연결됨 |
| 2 | CLOSED | 연결 종료됨 (close() 호출 후) |
커스텀 이벤트 타입 수신
서버에서 event: 필드로 이벤트 타입을 지정하면, 클라이언트에서 addEventListener로 해당 타입만 선택적으로 수신할 수 있다:
// 서버에서 { type: 'ranking-update', data: ... } 형태로 보낸 경우
eventSource.addEventListener('ranking-update', (event) => {
const data = JSON.parse(event.data);
handleRankingUpdate(data);
});
eventSource.addEventListener('new-feed', (event) => {
const data = JSON.parse(event.data);
handleNewFeed(data);
});
onmessage는 event: 필드가 없는(=기본 message 타입) 이벤트만 수신한다. 커스텀 타입을 사용하면 하나의 SSE 연결로 여러 종류의 이벤트를 구분해서 처리할 수 있다.
React에서의 활용
React 컴포넌트에서 SSE를 사용할 때는 useEffect 안에서 EventSource를 생성하고, cleanup 함수에서 close()를 호출해야 한다:
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
export const useTrendingPosts = () => {
const queryClient = useQueryClient();
useEffect(() => {
const eventSource = new EventSource(`${BASE_URL}/feed/trend/sse`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// React Query 캐시에 직접 데이터 주입
queryClient.setQueryData(['trending-posts'], data);
} catch (e) {
console.error('SSE 데이터 파싱 에러:', e);
}
};
return () => {
eventSource.close();
};
}, [queryClient]);
};
이 패턴에서 주목할 점은 React Query의 setQueryData와 SSE를 결합하는 방식이다. 일반적으로 React Query는 폴링(refetchInterval)이나 사용자 액션으로 데이터를 갱신하지만, SSE를 통해 서버가 밀어주는 데이터를 setQueryData로 캐시에 직접 넣으면 별도의 HTTP 요청 없이 UI가 즉시 업데이트된다.
queryFn에는 빈 Promise를 넣고 실제 데이터는 SSE로만 받는 구조다. 초기 로드도 SSE 연결 시 서버가 현재 데이터를 즉시 보내주기 때문에 별도의 API 호출이 필요 없다.
연결 관리와 주의사항
연결 유지와 타임아웃
SSE는 장시간 연결을 유지하기 때문에, 프록시나 로드밸런서의 유휴 타임아웃에 걸릴 수 있다. Nginx의 기본 proxy_read_timeout은 60초이므로, 60초 동안 데이터가 없으면 연결이 끊긴다.
해결 방법은 주기적으로 heartbeat(keep-alive) 이벤트를 보내는 것이다:
@Sse('trend/sse')
readTrendFeedList() {
return new Observable((observer) => {
// 30초마다 빈 이벤트 전송 (keep-alive)
const heartbeat = setInterval(() => {
observer.next({ data: '' });
}, 30000);
// 실제 데이터 전송 로직...
return () => {
clearInterval(heartbeat);
};
});
}
또는 Nginx 설정에서 타임아웃을 늘리는 방법도 있다:
location /api/feed/trend/sse {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off; # 버퍼링 비활성화 (중요!)
proxy_cache off;
proxy_read_timeout 86400s; # 24시간
}
proxy_buffering off가 특히 중요하다. Nginx가 응답을 버퍼링하면 SSE 이벤트가 즉시 전달되지 않고 버퍼가 찰 때까지 대기하게 된다.
동시 연결 수 관리
SSE 연결은 서버의 소켓을 하나씩 차지한다. 사용자 수가 많아지면 동시 연결 수가 서버의 파일 디스크립터 제한(ulimit -n)에 도달할 수 있다. 이를 완화하는 방법들:
- HTTP/2 사용: 하나의 TCP 연결 위에 여러 SSE 스트림을 다중화할 수 있다
- 연결 타임아웃 설정: 일정 시간 후 연결을 끊고 재연결하게 유도
- Redis Pub/Sub 중계: 서버 인스턴스가 여러 대일 때, EventEmitter는 프로세스 내부에서만 동작하므로 Redis Pub/Sub으로 인스턴스 간 이벤트를 동기화
RxJS interval을 활용한 주기적 전송
Observable을 직접 생성하는 대신 RxJS 연산자를 조합해서 주기적으로 데이터를 보낼 수도 있다:
import { interval, map, switchMap } from 'rxjs';
@Sse('metrics/sse')
streamMetrics() {
return interval(5000).pipe(
switchMap(() => this.metricsService.getCurrentMetrics()),
map((metrics) => ({
data: { message: '메트릭 갱신', data: metrics },
})),
);
}
interval(5000)이 5초마다 값을 emit하고, switchMap이 그때마다 최신 메트릭을 조회하며, map이 SSE MessageEvent 형태로 변환한다. 이벤트 기반이 아니라 폴링 기반으로 SSE를 사용하는 경우에 깔끔한 패턴이다.
SSE가 적합한 경우와 부적합한 경우
적합한 경우:
- 실시간 랭킹, 트렌드 피드 갱신
- 서버 상태 모니터링 대시보드
- 알림 스트림 (새 댓글, 좋아요 등)
- 장시간 작업의 진행률 표시
- 주가, 환율 같은 실시간 데이터 피드
부적합한 경우:
- 채팅 (양방향 통신 필요 → WebSocket)
- 실시간 게임 (양방향 + 바이너리 데이터 → WebSocket)
- 바이너리 데이터 스트리밍 (SSE는 텍스트만)
- 인증이 필요한 경우 (
EventSource는 커스텀 헤더를 지원하지 않음)
EventSource가 커스텀 헤더를 지원하지 않는 점은 실제로 꽤 번거로운 제약이다. JWT를 Authorization 헤더로 보낼 수 없기 때문에, 쿼리 파라미터로 토큰을 전달하거나 쿠키 기반 인증을 사용해야 한다. 또는 fetch의 ReadableStream을 사용해서 직접 SSE 파싱을 구현하는 방법도 있다:
const response = await fetch('/api/sse', {
headers: { Authorization: `Bearer ${token}` },
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
// SSE 형식 파싱...
}
다만 이 방식을 쓰면 자동 재연결을 직접 구현해야 하므로, 가능하면 쿠키 기반 인증을 쓰는 것이 낫다.
정리
- 서버→클라이언트 단방향 스트리밍만 필요하면 SSE가 WebSocket보다 간단하고, HTTP 위에서 동작하므로 프록시/CDN과 자연스럽게 호환된다
- NestJS의 @Sse + Observable + EventEmitter2 조합으로 이벤트 기반 실시간 전송을 구현하며, teardown 함수로 리스너를 정리해야 메모리 누수를 방지할 수 있다
- HTTP/1.1 도메인당 6개 연결 제한, proxy_buffering off, heartbeat keep-alive 등 인프라 레벨 설정이 안정적인 운영의 핵심이다
관련 문서
- NestJS WebSocket - 양방향 실시간 통신이 필요한 경우
- NestJS Schedule - @Cron 기반 주기적 작업
- RxJS Basics - Observable, pipe, 연산자