
SSE에 대해서
SSE로 실시간 진행률 받아오기
최근 진행 중인 프로젝트에서 사용자가 요청한 작업이 완료되기까지 3분에서 5분 이상 걸리는 상황을 마주하게 되었다.
문제는 단순히 시간이 오래 걸린다는 것만이 아니었다.
- 사용자는 작업이 진행 중인지, 멈춘 건지 알 수 없음
- 작업이 진행하다가 배포 서버가 죽거나 하면 더더욱
- 백그라운드에서 복잡한 파이프라인이 돌아가지만 결과만 보여줄 뿐
- "처리 중" 메시지만 3분 동안 보여주는 건 좋은 UX가 아님
특히 우리 서비스의 경우 하나의 요청이 여러 단계의 처리 파이프라인을 거쳐야 했기 때문에, 각 단계별 진행 상황을 보여주는 것이 중요했다.
이미지
예시를 들어보자면,
- 입력 데이터 전처리 (10%)
- 외부 API 호출 및 처리 (40%)
- 결과물 생성 (30%)
- 후처리 및 저장 (20%)
이런 상황에서 "실시간으로 진행률을 보여주려면 어떻게 해야 할까?"라는 고민에서 출발했고, 폴링과 SSE를 비교하게 되었습니다
실시간 데이터 전달을 알아보다보면 자연스럽게 폴링과 SSE를 비교하게 된다. 이번 글에서는 처리 진행률을 받아올 때 왜 SSE를 선택했는지에 대해서 작성해보려고 한다.
SSE란?
SSE는 Server Sent Events의 약자로 서버에서 클라이언트로 단방향으로 실시간 데이터 스트림을 전송하는 기술이다.
HTTP 프로토콜 기반으로 동작하며, 서버에서 지속적으로 데이터를 내려주는 구조다.
언제 필요한가?
보통 폴링, 롱폴링과 같이 지속적으로 새로운 데이터가 필요할 때 (작업 현황 실시간 진행률 같이) 사용된다.
간단하게 폴링, 롱폴링과 비교를 해본다면 아래와 같다.
- 폴링: 양방향으로 유저가 직접 서버에 요청해서 데이터를 받아옴
- 롱폴링: 양방향으로 유저가 직접 서버에 요청하고, 연결을 잠시 유지하면서 데이터를 받아옴
- SSE: 단방향(서버->클라이언트)으로 유저의 최초 연결 후 서버에서 지속적인 데이터 스트림을 받아옴
이미지
| 구분 | 폴링 (Polling) | SSE (Server-Sent Events) |
|---|---|---|
| 통신 방향 | 양방향 (클라이언트 → 서버) | 단방향 (서버 → 클라이언트) |
| 연결 방식 | 매번 새로운 HTTP 요청 | 단일 HTTP 연결 유지 |
| 요청 주체 | 클라이언트가 주기적으로 요청 | 서버가 변경사항 발생 시 푸시 |
| 네트워크 오버헤드 | 높음 (매 요청마다 헤더 전송) | 낮음 (초기 연결 후 데이터만 전송) |
| 실시간성 | 폴링 간격만큼 지연 | 즉시 전송 (지연 거의 없음) |
| 서버 부하 | 높음 (불필요한 요청 다수) | 낮음 (변경 시에만 전송) |
| 재연결 | 매번 연결/해제 | 자동 재연결 지원 |
| 브라우저 지원 | 모든 브라우저 | IE 제외 모든 모던 브라우저 |
| 구현 복잡도 | 낮음 | 중간 |
| 적합한 상황 | 간단한 상태 확인 | 실시간 업데이트, 긴 작업 진행률 |
예시: 3분 작업, 1초 간격 폴링 기준
| 항목 | 폴링 | SSE |
|---|---|---|
| HTTP 요청 수 | 180회 | 1회 (초기 연결) |
| 전송 데이터 | ~90KB (헤더 포함) | ~5KB (데이터만) |
| 업데이트 지연 | 최대 1초 | 즉시 (<50ms) |
왜 SSE인가?
실시간 처리 진행 상황을 보여주는 용도로 폴링 대신 SSE를 사용한 이유는 실시간 업데이트에 더 적합하다고 생각했기 때문이다.
폴링은 클라이언트에서 요청을 해야 응답을 받을 수 있고, 폴링 주기를 짧게 하면 할 수록 성능 부하가 불필요하게 많아지고, 서버에서 처리하고 있는 진행률 자체는 간단한 상태이기 때문에 폴링보다는 SSE가 적합하다고 생각했다.
SSE의 강점으로 생각했던 것은 실제로 데이터의 변경사항이 있을 때만 서버가 선택적으로 데이터를 내려줄 수 있다는 점이었다. 그리고 한번의 연결 후 데이터 스트림을 내려주는 방식이어서 폴링에 비해서 오버헤드가 적다는 장점으로 선택하게 되었다.
SSE 구현
SSE 구현을 위해서는 클라이언트와 백엔드 모두에서 작업이 필요하다.
import { useEffect, useState } from 'react';
interface ProgressData {
progress: number;
message: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
}
export const useTaskProgress = (taskId: string | null) => {
const [progress, setProgress] = useState<ProgressData>({
progress: 0,
message: '',
status: 'pending'
});
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!taskId) return;
const eventSource = new EventSource(
`${import.meta.env.VITE_API_URL}/api/tasks/${taskId}/progress`
);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setProgress(data);
} catch (err) {
console.error('Failed to parse SSE data:', err);
}
};
eventSource.onerror = (err) => {
console.error('SSE error:', err);
setError('연결이 끊어졌습니다');
eventSource.close();
};
// Cleanup
return () => {
eventSource.close();
};
}, [taskId]);
return { progress, error };
};
