대시보드가 늦게 갱신되면 신뢰가 떨어집니다. 이번 글은 페이지 새로고침 없이 상태를 동기화하는 세 가지 전략을 살펴봅니다.
시작 전에 정리하기
- 무엇을 만든다:
/activity화면에서 초기에 데이터를 로드하고, SSE 또는 폴링으로 실시간 동기화를 유지하며, 선택 시 웹소켓으로 확장하는 데모. - 왜 중요한가: 시리즈 후반부 앱은 여러 사용자가 동시에 바라보므로 신뢰할 수 있는 실시간 전략을 선택하고 전환할 수 있어야 합니다.
- 주의할 점: SSE나 웹소켓이 끊기면 반드시 재연결 로직을 넣고, 폴링 간격을 너무 짧게 잡으면 서버 비용이 급증합니다.
이번 글에서 새로 나오는 용어
- 폴링: 일정 간격마다 API를 다시 호출해 최신 데이터를 가져오는 가장 단순한 실시간 전략입니다.
- SSE(Server-Sent Events): 서버가 텍스트 스트림으로 데이터를 밀어 주는 단방향 연결 방식입니다.
- 웹소켓(WebSocket): 서버와 클라이언트가 서로 동시에 메시지를 주고받을 수 있는 양방향 통신 채널입니다.
- 재연결 로직: 네트워크가 끊겼을 때 타이머나 오류 핸들러에서 다시 연결을 시도하는 코드입니다.
개념
폴링(polling)은 일정 간격마다 API를 다시 호출하는 방식입니다. 구현이 단순하지만 불필요한 요청이 생길 수 있습니다. 서버 전송 이벤트(SSE)는 서버가 텍스트 스트림으로 데이터를 밀어 주는 단방향 실시간 통신입니다. 웹소켓(WebSocket)은 양방향 통신으로 사용자가 보낸 이벤트를 다른 사용자에게 즉시 전달할 수 있습니다. 세 방식 모두 연결 해제가 생길 때 재시도 로직이 필요합니다.
D2로 보는 실시간 플로우
👉 미니 요약: 초기 load가 한 번 데이터를 가져오고, 이후에는 SSE가 밀어주거나 폴링이 당겨 옵니다. 더 고급 상황이면 웹소켓으로 양방향 이벤트를 열 수 있지만, 기본은 “load → SSE → 폴링 백업” 순서입니다.
코드
폴링과 SSE를 기본으로 소개하고, 웹소켓은 확장 예제로 정리합니다.
// src/routes/(app)/activity/+page.svelte
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
export let data;
let tasks = structuredClone(data.tasks);
let interval: ReturnType<typeof setInterval> | null = null;
function apply(payload) {
tasks = payload;
}
onMount(() => {
const source = new EventSource('/api/tasks/stream');
source.onmessage = (event) => apply(JSON.parse(event.data));
source.onerror = () => {
source.close();
if (!interval) {
interval = setInterval(async () => {
const res = await fetch('/api/tasks');
apply(await res.json());
}, 5000);
}
};
return () => {
source.close();
if (interval) clearInterval(interval);
};
});
</script>
<ActivityTimeline {tasks} />
<!-- src/routes/(app)/activity/+page.server.ts -->
export const load: PageServerLoad = async ({ fetch, locals }) => {
if (!locals.user) {
throw error(401, '로그인이 필요합니다.');
}
const res = await fetch('/api/tasks');
if (!res.ok) {
throw error(res.status, '초기 데이터를 불러오지 못했습니다.');
}
return { tasks: await res.json() };
};
// src/routes/api/tasks/stream/+server.ts
export const GET = () => {
const stream = new ReadableStream({
start(controller) {
const timer = setInterval(async () => {
const payload = await getTasks();
controller.enqueue(`data: ${JSON.stringify(payload)}\n\n`);
}, 3000);
return () => clearInterval(timer);
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
}
});
};
(선택) 양방향 웹소켓 확장
팀 프로젝트에 꼭 필요하지 않으면 건너뛰어도 됩니다. SSE나 폴링만으로도 대부분의 과제 알림은 충분합니다. 양방향 채널이 필요하다고 판단될 때 아래 코드를 참고하세요.
const socket = io({ path: '/realtime' });
socket.on('task:sync', (task) => {
tasks = tasks.map((t) => (t.id === task.id ? task : t));
});
function toggle(task) {
socket.emit('task:update', { ...task, done: !task.done });
}
💬 댓글
이 글에 대한 의견을 남겨주세요