웹소켓(WebSocket)과 SSE(Server-Sent Events)

서예림·2025년 8월 27일
post-thumbnail

최근 LLM 서비스 프로젝트를 시작하면서 스트리밍 응답을 구현하기 위한 공부가 필요하게 됐습니다. 그래서 공부하는겸 정리하는겸 작성해보려고 합니다!
대표적으로 ChatGPT나 Claude와 같은 서비스들을 보면 사용자가 질문을 던졌을 때, 답변을 한번에 기다리는게 아니라 사람이 타이핑하듯 실시간으로 답변이 나타나는 것을 볼 수 있습니다.
이러한 스트리밍 응답을 구현하기 위해서는 서버에서 클라이언트로 실시간 데이터를 전송할 수 있는 기술이 필요한데요. 대표적으로 웹 소켓(WebSocket)과 SSE(Server-Sent Events)가 있습니다.

웹 소켓(WebSocket)

웹 소켓은 클라이언트와 서버 간의 양방향 실시간 통신을 가능하게 하는 프로토콜입니다. HTTP와는 다르게 한 번 연결을 해놓으면 클라이언트와 서버가 자유롭게 데이터를 주고받을 수 있습니다.

웹 소켓의 특징

  • 양방향 통신: 클라이언트 <-> 서버 데이터 통신 가능
  • 지속적 연결: 한 번 연결하면 명시적으로 끊을 때 까지 사용 가능
  • 낮은 오버헤드: HTTP 헤더 없이 순수 데이터만 전송
  • 별도 프로토콜: HTTP에서 웹 소켓으로 프로토콜 업데이트

웹 소켓 사용 예시

const socket = new WebSocket("ws://localhost:8080");

socket.onopen = () => {
  console.log("연결됨");
  socket.send("Hello Server!");
};

socket.onmessage = (event) => {
  console.log("서버로 부터:", event.data);
};

socket.onclose = () => {
  console.log("연결 끊김");
  // 재연결 로직을 직접 구현해야 한다.
  setTimeout(() => {
    connectWebSocket();
  }, 1000);
};

socket.onerror = (error) => {
  console.error("WebSocket 에러:", error);
};

SSE(Server-Sent Events)

SSE는 서버에서 클라이언트로 단방향 실시간 데이터 전송을 위한 웹 표준 기술입니다. 일반적인 HTTP 연결을 유지하면서 서버가 클라이언트에게 지속적으로 이벤트를 보낼 수 있습니다.

SSE의 특징

  • 단방향 통신: 서버 -> 클라이언트로만 데이터 전송
  • HTTP 기반: 기존 HTTP 인프라와 완벽 호환
  • 자동 재연결: 브라우저가 자동으로 연결 복구
  • 간단한 구현: 별도 프로토콜 변환 없이 HTTP 스트림 활용

SSE 사용 예시

const eventSource = new EventSource("/api/events");

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log("서버로 부터:", data);
};

eventSource.onerror = (error) => {
  console.error("SSE 에러:", error);
  // 브라우저가 자동으로 재연결 시도
};

// 헤더가 필요한 경우 fetch를 사용
async function connectSSEWithFetch() {
  const response = await fetch("/api/stream", {
    headers: {
      "Authorization": `Bearer ${token}`,
      "Accept": "text/event-stream",
    },
  });
  
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  
  while (true) {
    const {value, done} = await reader.read();
    if (done) {
      break;
    }
    
    const chunk = decoder.decode(value);
    // SSE 데이터 파싱 및 처리
    console.log(chunk);
  }
}

비교 테이블

구분웹 소켓 (WebSocket)SSE (Server-Sent Events)
통신 방향양방향 (클라이언트 <-> 서버)단방향 (서버 -> 클라이언트)
프로토콜WebSocket (ws://, wss://)HTTP/HTTPS
재연결수동 구현 필요브라우저 자동 처리
데이터 형식바이너리/텍스트 자유텍스트(주로 JSON)
브라우저 지원모든 모던 브라우저모든 모던 브라우저
방화벽/프록시차단될 수 있음HTTP이므로 통과 용이
서버 구현복잡 (별도 프로토콜 핸들링 필요)간단 (HTTP 스트림)
HTTP/2 호환성제한적완벽 지원
연결 오버헤드낮음약간 높음 (HTTP 헤더 오버헤드)
구현 복잡도높음낮음
사용 사례채팅, 게임, 협업 도구알림, 라이브 업데이트, LLM 스트리밍

이번 프로젝트에서 SSE를 선택한 이유

1. 단방향 통신

LLM 응답은 본질적으로 서버에서 클라이언트로 흐르는 단방향 데이터입니다. 사용자가 질문을 보내는 것은 별도의 POST 요청으로 처리하고 응답은 SSE로 처리하는 것이 자연스럽다고 생각했습니다.

2. 구현의 단순함

웹소켓은 연결 관리, 재연결, 에러 처리 등을 직접 구현해야 합니다. SSE는 브라우저가 대부분 자동으로 처리해주기 때문에 적절하다고 판단했습니다.

// WebSocket - 복잡한 재연결 로직 필요
function connectWebSocket() {
  const socket = new WebSocket("ws://localhost:8080");
  
  socket.onclose = () => {
    // 재연결 로직 직접 구현
    setTimeout(() => connectWebSocket(), 1000);
  };
}

// SSE - 브라우저가 자동 처리
const eventSource = new EventSource("/api/stream");
// 재연결은 브라우저가 알아서!

3. 기존 인프라와 호환성

많은 사용자 환경에서 웹소켓 연결이 방화벽이나 프록시에 의해 차단되는 경우가 있다고 합니다. 반면 SSE는 일반적인 HTTP 연결이기 때문에 이러한 문제는 상대적으로 적다고 봅니다.

4. HTTP/2의 이점 활용

SSE는 HTTP/2의 멀티플렉싱을 자연스럽게 활용할 수 있어 여러 개의 스트림을 효율적으로 처리할 수 있습니다.

HTTP/2 멀티플렉싱이란? 하나의 연결에서 여러 개의 요청과 응답을 처리할 수 있는 기능입니다. 예를 들어 사용자가 여러개의 질문을 던져도 각각 독립적인 스트림으로 처리되어 서로 블로킹이 되지 않습니다.

🤔 왜 Axios에서는 SSE를 사용할 수 없을까?

SSE에 대해서 자료를 찾아보는 중에,, SSE는 Axios를 쓸 수 없다고 합니다. 저는 주로 Axios로 구현된 환경이 많았기 때문에 왜그럴까 조사를 해보았습니다.
Axios는 전통적인 요청-응답 패턴을 위해 설계된 라이브러리로 응답이 완전히 끝난 후에야 Promise를 resolve 하기 때문입니다.
전체 응답이 끝나야 then이 풀리는 모델이라 중간 중간 들어오는 텍스트 청크를 실시간으로 처리하기 어렵습니다.

// Axios - 모든 데이터를 받은 후에야 처리
const response = await axios.get("/api/data");
console.log(response.data); // 모든 데이터가 도착한 후에야 실행

// SSE - 실시간 데이터 처리 필요
const eventSource = new EventSource("/api/stream");
eventSource.onmessage = (event) => {
  // 데이터 청크가 올때마다 실시간 처리
  console.log("실시간:", event.data);
};

SSE의 표준 클라이언트는 EventSource이고, 헤더가 필요하면 fetch를 사용하면 됩니다. 그래서 저는 로그인 기능도 있고 , 헤더를 보낼 일이 종종 있을 것이기 때문에 fetch를 사용해서 구성할 예정입니다!

SSE의 데이터 형식

SSE는 특정한 텍스트 형식을 사용합니다.

data: 실제 데이터 내용
event: 이벤트 타입 (선택 사항)
id: 이벤트 ID (선택 사항)
retry: 재연결 간격 밀리초 (선택 사항)

각 필드는 개행문자로 구분되고, 메세지는 빈 줄로 끝납니다.

LLM 스트리밍에서의 실제 사용 예시

LLM 스트리밍에서는 대략적으로 이런 형태를 가지게 됩니다.

// OpenAI 스타일
data: {"choices":[{"delta":{"content":"안녕"}}]}

data: {"choices":[{"delta":{"content":"하세요"}}]}

data: {"choices":[{"delta":{"content":"!"}}]}

data: [DONE]

// Anthropic Claude 스타일  
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"안녕"}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"하세요"}}

event: message_stop
data: {"type":"message_stop"}

마크다운을 실시간으로 렌더링하는 방법도 구상이 필요해 보입니다! event는 각 모델에서 내려오는 방법이 다르다고 해서 어떻게 내려오는지 의논이 필요한 단계입니다.

Next.js에서 SSE 구현 계획

Next.js + TypeScript를 이용해서 SSE를 구현하기 위한 대략적인 구상입니다.

서버 사이드 (API Route)

async const function POST(request: NextRequest) {
  const { message } = await request.json();
  
  const headers = new Headers({
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    "Connection": "keep-alive",
  });
  
  const stream = new ReadableStream({
    async start(controller) {
      // LLM API 호출 및 스트림 처리
      // 각 토큰을 SSE 형식으로 전송
    }
  });
  
  return new NextResponse(stream, { headers });
}

클라이언트 사이드

interface ChatMessage {
  id: string;
  role: "user" | "assistant";
  content: string;
  status: "streaming" | "complete" | "error";
  timestamp: Date;
}

export default function ChatInterface() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [isStreaming, setIsStreaming] = useState(false);
  
  const sendMessage = async (content: string) => {
    const userMessage: ChatMessage = {
      id: generateId(),
      role: "user",
      content,
      status: "complete",
      timestamp: new Date(),
    };
    
    setMessages((prev) => [...prev, userMessage]);
    
    const assistantMessageId = generateId();
    const assistantMessage: ChatMesssage = {
      id: assistantMessageId,
      role: "assistant",
      content: "",
      status: "streaming",
      timestamp: new Date(),
    };
    
    setMessages((prev) => [...prev, assistantMessage]);
    setIsStreaming(true);
    
    try {
      const response = await fetch("/api/chat/stream", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Authorization": `Bearer: ${token}`,
        },
        body: JSON.stringify({ message: content }),
      });
      
      const reader = response.body?.getReader();
      const decoder = new TextDecoder();
    } catch {
      // 에러 처리
    }
  }
}

Message의 타입을 지정해서 상태 관리를 할까 고민 중입니다. 스트리밍 상태(streaming, complete, error)를 추가해서 UI에서 로딩이나 에러 상태를 보여줄 수 있을 것 같습니다.

마무리

LLM 스티리밍 서비스에는 웹소켓보다 SSE가 더 적합한 경우가 많습니다. 구현이 간편하고 브라우저와의 호환성이 좋고, LLM의 단방향 응답 특성과도 잘 맞기 때문입니다.
앞으로 실제 구현을 진행하면서 마주친 문제들이나, 고려했던 점... 해결책 등등 다음에 추가로 정리해보겠습니다!

참고

MDN WebSocket API
MDN Server-Sent Events
Understanding Server-Sent Events (SSE) with Node.js
Server-Sent Events 정리 (+사용법)
SSE로 실시간 데이터 전송하기
Next.js API Routes

6개의 댓글

comment-user-thumbnail
2025년 9월 1일

읽으면서 흐름이 매끄럽고, 개념 설명 → 비교 → 선택 이유 → 구현 계획까지 체계적으로 잘 정리되어 있어서 이해하기 쉬웠습니다.
특히 Axios가 SSE에 적합하지 않은 이유까지 짚어준 부분이 신기하고 인사이트 있던거 같았어요!! 좋은글 감사합니다~

답글 달기
comment-user-thumbnail
2025년 9월 3일

SSE를 들어만 봤지 실제 어떻게 동작하고 어떻게 구현되는지 알 수 있어서 좋았습니다!
SSE에서 자주 사용되는 Axios같은 라이브러리가 있지 않을까 궁금해집니다. 혹시 없다면 직접 만들어보시는 것도 좋을 것 같습니다~!!!!

답글 달기
comment-user-thumbnail
2025년 9월 4일

SSE에 특징에 대해 자연스레 알게 되는 글인 것 같습니다! 웹 소켓과의 차이점도 명확하게 알게 됐습니다!
그리고 프로젝트에서 SSE로 선택하게 되는 과정이 흥미로웠습니다!!!

SSE 하니까, 제가 맡았던 프로젝트에서 데이터를 실시간으로 보여줘야 한다는 요구사항이 들어와서, 실시간 이라는 키워드에 꽂혀서 SSE를 도입할 뻔 했던 경험이 문득 떠올랐습니다.
결국에는 사용자의 환경을 고려했을 때 실시간 데이터를 꼭 실시간으로 사용자가 확인할 필요는 없다! 라는 결론이 나서 적당한 refetching 전략으로 문제를 해결했었는데 상황에 맞는 기술 도입의 중요성을 느꼈었는데, 글의 의도와는 다르게 다시 한번 그때의 교훈을 상기할 수 있었습니다..^^

SSE에 대한 아쉬움이 있었는데 다음 포스팅도 기대하겠습니다!

답글 달기
comment-user-thumbnail
2025년 9월 5일

저희 솔루션에서, 대기중인 환자 리스트를 10초에 한번씩 인터벌로 불러오는걸 보고 (...) sse를 도입해야겠다고 생각하고 있었거든요! 도움이 되는 글입니다!

답글 달기
comment-user-thumbnail
2025년 9월 8일

예전에 웹소켓, SSE 차이점에 대해서 면접 질문을 받은 적 있는데, 덕분에 다시 한번 공부하고 갑니다.

답글 달기
comment-user-thumbnail
2025년 9월 16일

저희 쪽에서는 LLM의 데이터를 그냥 보여주고 있었지, 저희가 쓰는 도구처럼 실시간으로 응답이 찍혀나오는걸 구현할 생각은 못했는데 이렇게 하는군요! 잘 읽고 갑니다.

답글 달기