
이번 글에서는 제가 직접 겪은 경험을 중심으로, 실시간 기능 도입 과정에서 느낀 점과 기술적 고민들, 그리고 실제 코드 예시를 공유드리고자 합니다.
프로젝트의 특성상, 여러 사용자가 동시에 같은 페이지를 열어두고 함께 작업하는 상황이 자주 발생합니다.
이때 각 사용자의 행동이 다른 사용자에게 실시간으로 반영되지 않으면, 불필요한 새로고침이나 혼란이 생기게 됩니다.
기존 구조는 데이터 변경 후 수동 새로고침이 필요한 방식이었기 때문에,
이런 실시간 협업 흐름에는 적합하지 않았습니다.
그래서 이번 기회를 통해 실시간 기능을 처음으로 도입하고,
사용자가 직접 새로고침하지 않아도 서버 상태가 자동으로 반영되는 구조로 개선하게 되었습니다.
SSE (Server-Sent Events) 는
서버가 클라이언트(브라우저)에게 실시간으로 데이터를 푸시(Push) 할 수 있는 기술입니다.
HTTP 프로토콜 위에서 동작하며, 기본적으로 단방향 통신을 지원합니다.
클라이언트가 EventSource를 통해 서버에 연결을 열면,
서버는 필요한 시점마다 event를 전송하여 실시간으로 정보를 전달합니다.
| 구분 | SSE (Server-Sent Events) | WebSocket |
|---|---|---|
| 통신 방식 | 단방향 (서버 ➝ 클라이언트) | 양방향 (서버 ⇄ 클라이언트) |
| 프로토콜 | HTTP 기반 (text/event-stream) | WebSocket 전용 프로토콜 (ws/wss) |
| 연결 관리 | 자동 재연결, 간단한 keep-alive | 수동 핸들링 필요 |
| 사용 난이도 | 상대적으로 단순 | 복잡한 연결 및 핸드셰이크 필요 |
| 전송 포맷 | 일반 텍스트 스트림 | 바이너리 / 텍스트 모두 가능 |
우리 서비스의 실시간 큐 페이지에서는 서버의 상태를 클라이언트가 실시간으로 받아보는 기능이 핵심이었습니다. 이를 위해 다음과 같은 요구사항을 충족해야 했습니다:
SSE를 선택한 이유는 아래와 같습니다:
✅ 단방향 푸시에 최적화되어 있어 구현이 간단하고 유지보수가 쉬움
✅ HTTP 기반이라 방화벽이나 프록시에서 안정적
✅ 브라우저의 EventSource 객체만으로 빠르게 연결 가능, 재연결도 자동 처리됨
✅ Socket 대비 리소스 사용이 적고, 가볍게 실시간 UI 구성 가능
📡 SSE 연결 및 해제 (startSSE, stopSSE)
let eventSource: EventSource | null = null;
export const startSSE = (queueId: string, onMessage: (data) => void) => {
eventSource = new EventSource('/api/queue/stream');
eventSource.onmessage = (event) => {
const parsed = JSON.parse(event.data);
onMessage(parsed);
};
};
export const stopSSE = () => {
eventSource?.close();
eventSource = null;
};
실시간 업데이트를 위해 SSE 연결을 설정하고, 페이지를 벗어나거나 편집 모드일 때는 연결을 해제하도록 하였습니다.
사용자가 현재 보고 있는 페이지 상태와 서버의 상태가 일치하도록 하는 것이 가장 핵심이었습니다.
SSE 이벤트를 그대로 반영하지 않고, 현재 UI 상태와 병합한 뒤 적용하도록 처리하였습니다.
// 병합 함수 만들기
const mergeData = (serverData: any[], uiState: any[]) => {
return serverData.map((item) => {
const local = uiState.find((u) => u.id === item.id);
return {
...item,
checked: local?.checked || false, // UI 선택 상태 유지
};
});
};
편집 중일 때 외부 상태 변화가 UI를 덮어쓰지 않도록, SSE를 끊고, 저장 후 다시 연결하는 흐름을 설계하였습니다.
이 과정에서 사용자 의도를 최우선으로 반영하는 UX를 구성하려 했습니다.
// useQueueEdit.ts
import { useState } from 'react';
import { startSSE, stopSSE } from './sse';
export const useQueueEdit = (queueId: string, getUIState: () => any[], setData: (data: any[]) => void) => {
const [isEditing, setIsEditing] = useState(false);
const startEdit = () => {
stopSSE(); // 실시간 끊기
setIsEditing(true);
};
const saveEdit = async () => {
await saveQueue(); // 저장 API 호출
setIsEditing(false);
startSSE(queueId, setData, getUIState); // 저장 후 실시간 다시 연결
};
return {
isEditing,
startEdit,
saveEdit,
};
};
const saveQueue = async () => {
const res = await fetch('/api/queue/save', { method: 'POST' });
if (!res.ok) throw new Error('저장 실패');
};
큐를 저장하거나, 순서를 변경했을 때, 각 작업의 피드백을 빠르게 받을 수 있도록
toast 메시지를 출력하여 사용자 경험을 높였습니다.
// queueActions.ts
import { toast } from 'sonner';
export const saveQueue = async () => {
try {
await fetch('/api/queue/save', { method: 'POST' });
toast.success('✅ 큐가 성공적으로 저장되었습니다.');
} catch {
toast.error('❌ 저장 중 오류가 발생했습니다.');
}
};
export const reorderQueue = async (ids: string[]) => {
try {
await fetch('/api/queue/reorder', {
method: 'POST',
body: JSON.stringify({ order: ids }),
headers: { 'Content-Type': 'application/json' },
});
toast.success('🔃 순서가 저장되었습니다.');
} catch {
toast.error('💥 순서 저장 중 오류가 발생했습니다.');
}
};
SSE 수신 이벤트가 잦다 보니, 렌더링이 과도하게 일어나는 구간이 있었습니다.
이후 memo, useMemo, React Window 등을 도입하여 리렌더링을 줄였습니다.
초기 로딩 시 데이터 양이 많아 느려지는 현상이 있었고,
이후 서버측 페이징 및 클라이언트 가상 스크롤 전략을 병행하여 해결했습니다.
초기에는 SSE 로직, 테이블 렌더링, 상태 관리가 섞여 있었습니다.
이후 useSSE, useQueueTable, useEditMode 등의 커스텀 훅으로 분리하여
테스트성과 유지보수성을 높였습니다.
실시간 시스템 개발은 상태 동기화, 성능 최적화, 그리고 사용자 경험을 모두 고려해야 하는 작업이었습니다.
이 과정에서 React의 상태 관리와 SSE의 활용에 대해 더 깊이 이해할 수 있었고, 실시간 기능을 구현할 때의 고민과 선택의 중요성을 실감했습니다 !!