티스토리 뷰
폴링, SSE, WebSocket 중 뭐가 맞을까? 비동기 작업 상태를 실시간으로 보여주는 백엔드 설계 — FastAPI · Spring Boot · Node.js
octo54 2026. 6. 1. 12:26폴링, SSE, WebSocket 중 뭐가 맞을까? 비동기 작업 상태를 실시간으로 보여주는 백엔드 설계 — FastAPI · Spring Boot · Node.js
한 줄 답
작업 상태 조회는 처음엔 폴링으로 시작하는 게 가장 현실적이고, “서버에서 클라이언트로 일방향 업데이트”만 필요하면 SSE, 채팅처럼 양방향 통신이 필요하면 WebSocket이 맞습니다. FastAPI는 EventSourceResponse 기반 SSE와 WebSocket을 공식 문서에 두고 있고, Spring은 SseEmitter와 STOMP over WebSocket을 지원하며, Node.js 쪽은 브라우저 EventSource와 ws 라이브러리 조합이 가장 흔한 선택지입니다. (FastAPI)
이 글에서 바로 정리할 것
- 폴링, SSE, WebSocket 차이
- Job Status API 다음 단계로 무엇을 붙여야 하는지
- FastAPI, Spring Boot, Node.js에서 바로 시작 가능한 최소 코드
- 언제 굳이 WebSocket까지 안 가도 되는지
- 검색에 잘 걸리는 질문형 제목, 첫 문단 정답, 정의 문장, FAQ 구조를 반영한 글 구성 원칙
왜 이 주제가 바로 다음 단계냐
지난 글에서 Job Status API를 만들었으면, 이제 프론트 쪽에서 거의 바로 이런 말이 나옵니다.
“2초마다 폴링하는 건 좀 투박한데요?”
“진행률이 바뀌면 바로 반영되면 좋겠어요.”
“채팅처럼 양방향으로도 갈 수 있나요?”
저도 여기서 예전엔 무조건 WebSocket 쪽으로 생각했어요. 뭔가 더 실시간 같고, 더 멋있어 보였거든요. 근데 막상 서비스 붙여보면, 모든 실시간 요구가 WebSocket까지 필요한 건 아니더라고요. MDN은 EventSource가 서버에서 클라이언트로 지속 연결을 열어 text/event-stream 형식의 이벤트를 받는 API라고 설명하고, 반대로 양방향 통신이 필요하면 WebSocket이 더 맞는다고 안내합니다. (MDN 웹 문서)
먼저 아주 짧게 구분하면
폴링
클라이언트가 일정 주기로 상태 API를 다시 호출합니다.
SSE
서버가 클라이언트에게 일방향으로 계속 이벤트를 밀어줍니다.
WebSocket
클라이언트와 서버가 양방향으로 지속 연결을 유지하며 메시지를 주고받습니다.
FastAPI는 WebSocket을 위한 별도 문서를 두고 있고, SSE는 EventSourceResponse로 스트리밍하는 문서를 제공합니다. Spring도 SseEmitter와 WebSocket/STOMP를 각각 별도 문서로 설명합니다. (FastAPI)
그럼 뭐부터 시작해야 할까
제 기준은 꽤 단순합니다.
- 상태 조회만 하면 된다 → 폴링
- 서버가 완료/진행률을 밀어주면 된다 → SSE
- 사용자 입력이 즉시 서버로도 계속 가야 한다 → WebSocket
이 기준이 좋은 이유는 기술보다 요구사항을 먼저 보게 해주기 때문입니다. MDN도 SSE는 서버에서 클라이언트로의 스트림이고, full-duplex가 필요하면 WebSocket이 더 낫다고 설명합니다. Spring은 SseEmitter를 SSE 용도로, STOMP over WebSocket은 양방향 메시징 용도로 설명합니다. (MDN 웹 문서)
폴링은 언제까지 괜찮을까
솔직히 말하면, 생각보다 오래 괜찮습니다.
- 작업 완료 여부 확인
- 짧은 진행률 표시
- 관리자 페이지 상태 확인
- AI 요약/임베딩 상태 조회
이 정도는 1초~3초 간격 폴링으로도 충분한 경우가 많아요. 구조가 단순하고, 브라우저 호환성도 고민이 적고, 백엔드도 그냥 기존 REST API를 재사용하면 됩니다. 그래서 Job Status API 다음 단계로는 폴링이 여전히 가장 현실적인 출발점입니다. 검색 잘 되는 기술 글도 이런 식으로 “가장 먼저 써볼 기본안”을 명확히 제시하는 게 유리합니다.
다만 이런 경우엔 폴링이 점점 부담스러워집니다.
- 사용자가 많음
- 상태 갱신이 잦음
- 완료 순간을 바로 알려주고 싶음
- 진행률이 자주 바뀜
그때 SSE가 정말 예쁘게 들어옵니다.
SSE는 뭐가 좋은가
SSE는 제가 실무에서 꽤 좋아하는 중간 지점이에요.
왜냐하면 “실시간처럼 보이는데 WebSocket까지는 안 가도 되는” 경우가 정말 많기 때문입니다.
MDN은 EventSource가 HTTP 연결을 열고 서버가 text/event-stream 형식으로 이벤트를 계속 보내는 방식이라고 설명합니다. FastAPI는 yield와 EventSourceResponse로 SSE 스트림을 만들 수 있다고 문서화하고 있고, Starlette도 EventSourceResponse를 SSE 구현용 response class로 소개합니다. Spring MVC는 SseEmitter를 공식적으로 지원합니다. (MDN 웹 문서)
SSE가 잘 맞는 대표 예시는 이렇습니다.
- 비동기 작업 진행률
- AI 답변 생성 상태
- 로그 스트림
- 관리자 대시보드 실시간 수치
- 서버 알림
핵심은 이거예요.
클라이언트가 서버에 계속 말할 필요는 없고, 서버가 알려주기만 하면 되는 상황.
WebSocket은 언제 가야 할까
WebSocket은 확실히 강력합니다.
FastAPI는 WebSocket 엔드포인트와 연결 관리 예제를 제공하고, Spring은 raw WebSocket부터 STOMP까지 지원하며, Node.js에선 ws가 가장 널리 쓰이는 라이브러리 중 하나입니다. (FastAPI)
근데 저는 이걸 꼭 같이 말하고 싶어요.
강력하다고 해서 항상 정답은 아닙니다.
WebSocket이 잘 맞는 상황은 보통 이렇습니다.
- 채팅
- 협업 편집
- 양방향 게임 이벤트
- 브라우저와 서버가 서로 자주 메시지 주고받음
- 클라이언트도 적극적으로 서버에 이벤트를 밀어야 함
즉 “서버가 알려주기만 하면 되는 일”에 WebSocket을 바로 붙이면, 생각보다 과한 경우가 많아요.
그래서 Job Status API 다음 단계는 뭐가 제일 현실적일까
저는 보통 이렇게 추천합니다.
- 먼저 Job Status API + 폴링
- 실시간 체감이 더 필요하면 SSE
- 채팅/양방향 상호작용이 들어오면 WebSocket
이 순서가 좋은 이유는 단순합니다.
- 구현 난이도가 점진적이고
- 디버깅이 쉽고
- 운영 부담이 덜하고
- 요구사항이 커질 때만 복잡도를 올릴 수 있기 때문이에요
1) FastAPI 예제: Job 상태를 SSE로 밀어주기
FastAPI는 SSE 문서에서 yield를 사용하고 response_class=EventSourceResponse를 쓰는 방식을 안내합니다. WebSocket도 별도 문서로 제공해서 둘의 차이를 분명히 배울 수 있습니다. (FastAPI)
설치
pip install fastapi uvicorn
예제 코드
# main.py
import asyncio
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.sse import EventSourceResponse
app = FastAPI()
# 예제용 메모리 상태
JOB_STORE = {
"job-1": {"status": "QUEUED", "progress": 0, "result": None}
}
async def job_progress_stream(job_id: str) -> AsyncGenerator[dict, None]:
while True:
job = JOB_STORE.get(job_id)
if not job:
yield {"event": "error", "data": {"message": "job not found"}}
return
yield {
"event": "job-update",
"data": {
"job_id": job_id,
"status": job["status"],
"progress": job["progress"],
"result": job["result"],
},
}
if job["status"] in ("SUCCEEDED", "FAILED"):
return
await asyncio.sleep(1)
@app.get("/api/jobs/{job_id}/events", response_class=EventSourceResponse)
async def stream_job_events(job_id: str):
return EventSourceResponse(job_progress_stream(job_id))
@app.post("/api/jobs/{job_id}/simulate")
async def simulate_job(job_id: str):
if job_id not in JOB_STORE:
JOB_STORE[job_id] = {"status": "QUEUED", "progress": 0, "result": None}
async def worker():
JOB_STORE[job_id]["status"] = "PROCESSING"
for progress in (20, 40, 60, 80, 100):
JOB_STORE[job_id]["progress"] = progress
await asyncio.sleep(1)
JOB_STORE[job_id]["status"] = "SUCCEEDED"
JOB_STORE[job_id]["result"] = {"summary": "문서 요약 완료"}
asyncio.create_task(worker())
return {"message": "simulation started"}
프론트 예시
<script>
const source = new EventSource("/api/jobs/job-1/events");
source.addEventListener("job-update", (event) => {
const data = JSON.parse(event.data);
console.log("job update:", data);
if (data.status === "SUCCEEDED" || data.status === "FAILED") {
source.close();
}
});
source.addEventListener("error", (event) => {
console.error("stream error", event);
source.close();
});
</script>
FastAPI에서 이 방식이 잘 맞는 이유
FastAPI는 SSE와 WebSocket을 둘 다 지원하지만, 작업 상태 알림처럼 서버 → 클라이언트 일방향 업데이트에는 SSE가 훨씬 단순합니다. 브라우저 쪽도 MDN EventSource API만 쓰면 되니까 프론트 코드가 꽤 가볍습니다. (FastAPI)
2) Spring Boot 예제: SseEmitter로 진행률 보내기
Spring MVC는 SseEmitter를 공식 지원하고, controller에서 이를 반환해 SSE 스트림을 만들 수 있다고 설명합니다. 반면 STOMP over WebSocket은 더 큰 양방향 메시징 구조에 가깝습니다. (Home)
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.3'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
예제 코드
// JobController.java
package com.example.backend.controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@RestController
@RequestMapping("/api/jobs")
public class JobController {
private final Map<String, Map<String, Object>> jobStore = new ConcurrentHashMap<>();
@GetMapping("/{jobId}/events")
public SseEmitter streamJob(@PathVariable String jobId) {
SseEmitter emitter = new SseEmitter(60_000L);
new Thread(() -> {
try {
while (true) {
Map<String, Object> job = jobStore.get(jobId);
if (job == null) {
emitter.send(SseEmitter.event()
.name("error")
.data(Map.of("message", "job not found")));
emitter.complete();
return;
}
emitter.send(SseEmitter.event()
.name("job-update")
.data(job));
String status = (String) job.get("status");
if ("SUCCEEDED".equals(status) || "FAILED".equals(status)) {
emitter.complete();
return;
}
Thread.sleep(1000);
}
} catch (IOException | InterruptedException e) {
emitter.completeWithError(e);
}
}).start();
return emitter;
}
@PostMapping("/{jobId}/simulate")
public Object simulate(@PathVariable String jobId) {
jobStore.put(jobId, new ConcurrentHashMap<>(Map.of(
"jobId", jobId,
"status", "QUEUED",
"progress", 0
)));
new Thread(() -> {
try {
Map<String, Object> job = jobStore.get(jobId);
job.put("status", "PROCESSING");
for (int progress : new int[]{20, 40, 60, 80, 100}) {
job.put("progress", progress);
Thread.sleep(1000);
}
job.put("status", "SUCCEEDED");
job.put("result", Map.of("summary", "문서 요약 완료"));
} catch (InterruptedException ignored) {
}
}).start();
return Map.of("message", "simulation started");
}
}
프론트 예시
<script>
const source = new EventSource("/api/jobs/job-1/events");
source.addEventListener("job-update", (event) => {
const data = JSON.parse(event.data);
console.log(data);
if (data.status === "SUCCEEDED" || data.status === "FAILED") {
source.close();
}
});
</script>
Spring Boot에서 이 방식이 잘 맞는 이유
Spring에서 진행률 알림 수준이면 SseEmitter가 꽤 깔끔합니다. 반대로 채팅, 사용자별 구독, 브로커 기반 push가 커지면 Spring WebSocket/STOMP 쪽으로 가는 게 더 자연스럽습니다. Spring도 STOMP over WebSocket을 별도 기능으로 설명합니다. (Home)
3) Node.js 예제: SSE로 Job 상태 보내기, WebSocket은 어디서 쓰는지
브라우저는 EventSource를 오래 지원하고 있고, Node 서버 쪽은 그냥 HTTP 응답을 text/event-stream으로 열어두면 SSE를 구현할 수 있습니다. WebSocket이 필요하면 ws가 가장 많이 쓰이는 선택지 중 하나입니다. (MDN 웹 문서)
설치
npm install express ws
SSE 예제 코드
// server.js
import express from "express";
const app = express();
const PORT = 3000;
app.use(express.json());
const jobStore = new Map();
app.get("/api/jobs/:jobId/events", (req, res) => {
const { jobId } = req.params;
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const timer = setInterval(() => {
const job = jobStore.get(jobId);
if (!job) {
res.write(`event: error\n`);
res.write(`data: ${JSON.stringify({ message: "job not found" })}\n\n`);
clearInterval(timer);
res.end();
return;
}
res.write(`event: job-update\n`);
res.write(`data: ${JSON.stringify(job)}\n\n`);
if (job.status === "SUCCEEDED" || job.status === "FAILED") {
clearInterval(timer);
res.end();
}
}, 1000);
req.on("close", () => {
clearInterval(timer);
});
});
app.post("/api/jobs/:jobId/simulate", async (req, res) => {
const { jobId } = req.params;
jobStore.set(jobId, {
jobId,
status: "QUEUED",
progress: 0,
result: null,
});
(async () => {
const job = jobStore.get(jobId);
job.status = "PROCESSING";
for (const progress of [20, 40, 60, 80, 100]) {
job.progress = progress;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
job.status = "SUCCEEDED";
job.result = { summary: "문서 요약 완료" };
})();
res.json({ message: "simulation started" });
});
app.listen(PORT, () => {
console.log(`server running on http://localhost:${PORT}`);
});
WebSocket 최소 예제
// websocket-server.js
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (socket) => {
socket.send(JSON.stringify({ message: "connected" }));
socket.on("message", (message) => {
socket.send(JSON.stringify({ echo: message.toString() }));
});
});
Node.js에서 어떻게 고르면 되나
Node.js는 특히 여기서 선택 기준이 중요합니다.
- 작업 상태 push만 필요하다 → SSE
- 채팅/양방향 상호작용이다 → ws
ws는 빠르고 널리 쓰이는 WebSocket 구현체로 소개되고 있고, SSE는 브라우저 기본 EventSource가 단순해서 상태 알림용으로 정말 잘 맞습니다. (GitHub)
폴링 vs SSE vs WebSocket을 정말 현실적으로 고르면
저는 보통 이렇게 정리합니다.
폴링 추천
- 빠르게 붙이고 싶다
- 상태 조회 주기가 1~3초여도 괜찮다
- 작업 수가 많지 않다
- 인프라 복잡도를 늘리고 싶지 않다
SSE 추천
- 완료/진행률을 즉시 알리고 싶다
- 서버 → 클라이언트 일방향이면 충분하다
- Job Status API의 업그레이드가 필요하다
- WebSocket까지는 과하다
WebSocket 추천
- 채팅, 협업, 게임, live control
- 클라이언트도 서버에 자주 이벤트를 밀어야 한다
- 사용자별 구독이나 브로드캐스트가 중요하다
MDN과 Spring 문서를 같이 보면 이 구분이 꽤 명확합니다. SSE는 이벤트 스트림이고, full-duplex는 WebSocket 쪽입니다. (MDN 웹 문서)
자주 발생하는 실수
실수 1. 모든 실시간 요구를 WebSocket으로 시작함
이건 꽤 흔합니다.
근데 상태 알림만 필요하면 SSE가 훨씬 단순한 경우가 많아요. FastAPI와 Spring도 SSE를 따로 지원하는 이유가 있습니다. (FastAPI)
실수 2. SSE인데도 클라이언트 → 서버 상호작용을 계속 넣으려 함
이러면 결국 WebSocket이 더 맞습니다.
실수 3. 폴링이 무조건 나쁘다고 생각함
아니요. 초반엔 제일 현실적입니다. 오히려 가장 빨리 제품화됩니다.
실수 4. 상태 모델 없이 실시간 전송만 붙임
status, progress, result, error_message가 정리돼 있지 않으면 프론트도 운영도 힘들어집니다.
실수 5. 연결 종료 조건을 안 둠
작업이 끝났으면 SSE도 끊고, WebSocket도 적절히 정리하는 흐름이 필요합니다.
FAQ
Q. Job Status API가 이미 있으면 SSE는 꼭 필요할까요?
꼭 그렇진 않습니다. 먼저 폴링으로 충분한지 확인하는 게 보통 더 현실적입니다. 완료 알림을 더 즉시 보여주고 싶을 때 SSE를 붙이면 됩니다.
Q. SSE와 WebSocket 중 어느 쪽이 구현이 더 쉬운가요?
작업 상태 알림처럼 서버 → 클라이언트 일방향이면 보통 SSE가 더 쉽습니다. WebSocket은 양방향이 필요한 순간 강해집니다. (MDN 웹 문서)
Q. FastAPI에서 SSE는 공식 지원인가요?
FastAPI 문서에는 fastapi.sse.EventSourceResponse를 사용하는 SSE 문서가 있고, Starlette 응답 문서에도 SSE용 EventSourceResponse가 소개되어 있습니다. (FastAPI)
Q. Spring Boot에서 채팅까지 가려면 SseEmitter로 충분한가요?
보통은 아닙니다. 채팅처럼 양방향 구조가 커지면 Spring WebSocket/STOMP가 더 자연스럽습니다. (Home)
Q. Node.js에서 WebSocket은 어떤 라이브러리로 시작하는 게 무난한가요?
ws가 가장 흔하고 검증된 선택지 중 하나입니다. 공식 저장소도 빠르고 널리 쓰이는 WebSocket 구현체라고 소개합니다. (GitHub)
핵심 요약
- 폴링은 가장 단순한 시작점입니다.
- SSE는 작업 상태 알림처럼 서버 → 클라이언트 일방향 실시간에 잘 맞습니다.
- WebSocket은 채팅처럼 양방향 상호작용이 필요할 때 가는 게 맞습니다.
- Job Status API 다음 단계로는 보통 SSE가 가장 현실적인 업그레이드입니다.
- 검색에 잘 걸리는 개발 글은 질문형 제목, 첫 문단 정답, 중간 정의 문장, FAQ, 핵심 요약 구조가 강합니다.
출처
- FastAPI 공식 문서 — Server-Sent Events. (FastAPI)
- FastAPI 공식 문서 — WebSockets. (FastAPI)
- Starlette 공식 문서 — EventSourceResponse. (starlette.io)
- Spring Framework 공식 문서 — SseEmitter / async requests. (Home)
- Spring Framework 공식 문서 — STOMP over WebSocket. (Home)
- MDN — EventSource API. (MDN 웹 문서)
- MDN — SSE vs WebSocket 방향성 설명. (MDN 웹 문서)
- ws 공식 저장소. (GitHub)
- 글 구성 참고: 업로드하신 “AI 검색에 잘 걸리는 글” 가이드.
백엔드개발, FastAPI, SpringBoot, Nodejs, Express, 폴링, SSE, WebSocket, JobStatusAPI, 백엔드시리즈
'study > 백엔드' 카테고리의 다른 글
- Total
- Today
- Yesterday
- DevOps
- 주니어개발자
- SpringBoot
- LangChain
- 웹개발
- node.js
- Next.js
- 딥러닝
- llm
- flax
- 백엔드개발
- Prisma
- nodejs
- fastapi
- JAX
- Express
- 쿠버네티스
- 생성형AI
- kotlin
- rag
- NestJS
- 개발블로그
- PostgreSQL
- nextJS
- REACT
- Python
- CI/CD
- JWT
- SEO최적화
- seo 최적화 10개
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
