HTTP 폴링 vs WebSocket
실시간 통신을 구현하는 방법은 크게 두 가지입니다.
HTTP 폴링 (Polling)
클라이언트가 주기적으로 서버에 요청을 보내 새 데이터를 확인합니다.
// Short Polling
setInterval(() => {
fetch('/api/messages')
.then(res => res.json())
.then(data => updateUI(data));
}, 1000); // 1초마다 요청
단점:
- 불필요한 요청이 많음 (데이터가 없어도 요청)
- 서버 부하 증가 (동시 접속자 1000명 = 초당 1000건 요청)
- 실시간성 낮음 (폴링 간격만큼 지연)
Long Polling
서버가 새 데이터가 있을 때까지 응답을 보류합니다.
function longPoll() {
fetch('/api/messages/wait')
.then(res => res.json())
.then(data => {
updateUI(data);
longPoll(); // 다시 연결
})
.catch(() => {
setTimeout(longPoll, 5000); // 오류 시 재시도
});
}
개선점:
- 불필요한 요청 감소
- 더 나은 실시간성
여전한 문제:
- HTTP 헤더 오버헤드 (매 요청마다 수백 바이트)
- 연결 수립/종료 비용
WebSocket
양방향 전이중(Full-Duplex) 통신을 제공하는 프로토콜입니다.
const ws = new WebSocket('ws://localhost:8080/chat');
ws.onopen = () => {
console.log('연결됨');
ws.send(JSON.stringify({ type: 'join', room: 'general' }));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
updateUI(data);
};
ws.onerror = (error) => {
console.error('오류:', error);
};
ws.onclose = () => {
console.log('연결 종료');
// 재연결 로직
};
장점:
- 낮은 지연 시간 (< 10ms)
- 작은 오버헤드 (프레임 헤더 2-14 바이트)
- 양방향 통신 (서버 → 클라이언트 푸시 가능)
- 하나의 TCP 연결로 지속적 통신
WebSocket 프로토콜 이해
WebSocket은 HTTP 위에서 동작하지만 독립적인 프로토콜입니다.
핸드셰이크 (Handshake)
클라이언트가 HTTP 요청으로 WebSocket 연결을 시작합니다.
GET /chat HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
서버가 응답하면 프로토콜이 WebSocket으로 전환됩니다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
WebSocket 프레임 구조
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
주요 필드:
- FIN: 최종 프레임 여부
- opcode: 프레임 타입 (0x1=텍스트, 0x2=바이너리, 0x8=종료)
- MASK: 페이로드 마스킹 여부 (클라이언트 → 서버는 필수)
- Payload len: 페이로드 길이
Spring Boot WebSocket 설정
Spring Boot는 WebSocket을 쉽게 구현할 수 있는 추상화를 제공합니다.
의존성 추가
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
WebSocket 설정
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(chatHandler(), "/chat")
.setAllowedOrigins("*"); // CORS 설정
}
@Bean
public WebSocketHandler chatHandler() {
return new ChatWebSocketHandler();
}
}
WebSocketHandler 구현
@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {
private static final Set<WebSocketSession> sessions =
Collections.synchronizedSet(new HashSet<>());
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
sessions.add(session);
System.out.println("새 연결: " + session.getId());
// 환영 메시지 전송
session.sendMessage(new TextMessage(
"{\"type\":\"system\",\"message\":\"채팅방에 입장했습니다\"}"
));
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message)
throws Exception {
String payload = message.getPayload();
System.out.println("수신: " + payload);
// 모든 클라이언트에게 브로드캐스트
synchronized (sessions) {
for (WebSocketSession s : sessions) {
if (s.isOpen()) {
s.sendMessage(message);
}
}
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status)
throws Exception {
sessions.remove(session);
System.out.println("연결 종료: " + session.getId() + ", 상태: " + status);
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception)
throws Exception {
System.err.println("전송 오류: " + exception.getMessage());
session.close();
}
}
STOMP 프로토콜
STOMP (Simple Text Oriented Messaging Protocol)는 WebSocket 위에서 동작하는 메시징 프로토콜입니다.
STOMP를 사용하는 이유
WebSocket만으로는 메시지 라우팅, 구독 관리가 어렵습니다. STOMP는 이를 표준화합니다.
STOMP의 장점:
- 메시지 라우팅 (
/topic,/queue,/user) - 구독 관리 (subscribe/unsubscribe)
- ACK/NACK 메커니즘
- 트랜잭션 지원
STOMP 프레임 구조
COMMAND
header1:value1
header2:value2
Body^@
예시: 구독 (SUBSCRIBE)
SUBSCRIBE
id:sub-1
destination:/topic/chat
^@
예시: 메시지 전송 (SEND)
SEND
destination:/app/chat
content-type:application/json
{"message":"Hello World"}^@
예시: 메시지 수신 (MESSAGE)
MESSAGE
destination:/topic/chat
message-id:123
subscription:sub-1
{"message":"Hello World"}^@
Spring Boot STOMP 설정
@EnableWebSocketMessageBroker
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 클라이언트로 메시지를 전달하는 브로커 설정
config.enableSimpleBroker("/topic", "/queue");
// 클라이언트가 메시지를 보낼 목적지 prefix
config.setApplicationDestinationPrefixes("/app");
// 특정 사용자에게 메시지를 보낼 때 사용
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// STOMP 엔드포인트 등록
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS(); // SockJS 폴백 지원
}
}
메시지 컨트롤러
@Controller
public class ChatController {
@MessageMapping("/chat.send") // 클라이언트가 /app/chat.send로 전송
@SendTo("/topic/chat") // 구독자 모두에게 전달
public ChatMessage sendMessage(ChatMessage message) {
message.setTimestamp(System.currentTimeMillis());
return message;
}
@MessageMapping("/chat.join")
@SendTo("/topic/chat")
public ChatMessage joinChat(@Payload ChatMessage message,
SimpMessageHeaderAccessor headerAccessor) {
// 세션에 사용자 이름 저장
headerAccessor.getSessionAttributes().put("username", message.getSender());
message.setType(ChatMessage.MessageType.JOIN);
message.setContent(message.getSender() + "님이 입장했습니다");
return message;
}
}
메시지 모델
public class ChatMessage {
public enum MessageType {
CHAT, JOIN, LEAVE
}
private MessageType type;
private String content;
private String sender;
private long timestamp;
// Getters, Setters
}
연결 이벤트 처리
@Component
public class WebSocketEventListener {
private static final Logger logger = LoggerFactory.getLogger(WebSocketEventListener.class);
@Autowired
private SimpMessageSendingOperations messagingTemplate;
@EventListener
public void handleWebSocketConnectListener(SessionConnectedEvent event) {
logger.info("새로운 WebSocket 연결");
}
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
String username = (String) headerAccessor.getSessionAttributes().get("username");
if (username != null) {
logger.info("사용자 퇴장: " + username);
ChatMessage chatMessage = new ChatMessage();
chatMessage.setType(ChatMessage.MessageType.LEAVE);
chatMessage.setSender(username);
chatMessage.setContent(username + "님이 퇴장했습니다");
messagingTemplate.convertAndSend("/topic/chat", chatMessage);
}
}
}
SimpMessagingTemplate으로 서버에서 메시지 발행
서버 측에서 임의로 메시지를 전송할 수 있습니다.
전체 브로드캐스트
@Service
public class NotificationService {
@Autowired
private SimpMessagingTemplate messagingTemplate;
public void notifyAllUsers(String message) {
messagingTemplate.convertAndSend("/topic/notifications", message);
}
// 스케줄링 예시
@Scheduled(fixedRate = 60000) // 1분마다
public void sendHeartbeat() {
Map<String, Object> heartbeat = Map.of(
"type", "heartbeat",
"timestamp", System.currentTimeMillis()
);
messagingTemplate.convertAndSend("/topic/heartbeat", heartbeat);
}
}
특정 사용자에게 전송
@Service
public class UserNotificationService {
@Autowired
private SimpMessagingTemplate messagingTemplate;
public void notifyUser(String username, String message) {
messagingTemplate.convertAndSendToUser(
username,
"/queue/notifications",
message
);
}
// 사용 예시
public void sendPrivateMessage(String from, String to, String content) {
PrivateMessage msg = new PrivateMessage(from, content, System.currentTimeMillis());
messagingTemplate.convertAndSendToUser(to, "/queue/private", msg);
}
}
조건부 전송
@Service
public class ConditionalBroadcast {
@Autowired
private SimpMessagingTemplate messagingTemplate;
public void notifyActiveUsers(String message) {
// 특정 조건을 만족하는 사용자만 필터링
List<String> activeUsers = getActiveUsers();
for (String user : activeUsers) {
messagingTemplate.convertAndSendToUser(
user,
"/queue/updates",
message
);
}
}
private List<String> getActiveUsers() {
// 활성 사용자 목록 조회 로직
return Arrays.asList("user1", "user2", "user3");
}
}
클라이언트 구현: @stomp/stompjs + SockJS
설치
npm install @stomp/stompjs sockjs-client
React 클라이언트 예시
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
import { useState, useEffect, useRef } from 'react';
function ChatComponent() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [username, setUsername] = useState('');
const [connected, setConnected] = useState(false);
const clientRef = useRef(null);
useEffect(() => {
if (username) {
connect();
}
return () => {
if (clientRef.current) {
clientRef.current.deactivate();
}
};
}, [username]);
const connect = () => {
const client = new Client({
webSocketFactory: () => new SockJS('http://localhost:8080/ws'),
onConnect: () => {
console.log('연결됨');
setConnected(true);
// 채팅방 구독
client.subscribe('/topic/chat', (message) => {
const chatMessage = JSON.parse(message.body);
setMessages(prev => [...prev, chatMessage]);
});
// 입장 메시지 전송
client.publish({
destination: '/app/chat.join',
body: JSON.stringify({
sender: username,
type: 'JOIN'
})
});
},
onStompError: (frame) => {
console.error('STOMP 오류:', frame.headers['message']);
console.error('상세:', frame.body);
},
onDisconnect: () => {
console.log('연결 종료');
setConnected(false);
}
});
clientRef.current = client;
client.activate();
};
const sendMessage = () => {
if (input.trim() && clientRef.current) {
clientRef.current.publish({
destination: '/app/chat.send',
body: JSON.stringify({
sender: username,
content: input,
type: 'CHAT'
})
});
setInput('');
}
};
return (
<div>
{!username ? (
<div>
<input
placeholder="이름 입력"
onKeyPress={(e) => {
if (e.key === 'Enter') setUsername(e.target.value);
}}
/>
</div>
) : (
<div>
<div className="messages">
{messages.map((msg, idx) => (
<div key={idx} className={`message ${msg.type}`}>
<strong>{msg.sender}:</strong> {msg.content}
</div>
))}
</div>
<div className="input-area">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') sendMessage();
}}
disabled={!connected}
/>
<button onClick={sendMessage} disabled={!connected}>
전송
</button>
</div>
</div>
)}
</div>
);
}
export default ChatComponent;
Vanilla JavaScript 클라이언트
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
class WebSocketClient {
constructor(url, username) {
this.url = url;
this.username = username;
this.client = null;
this.subscriptions = new Map();
}
connect() {
this.client = new Client({
webSocketFactory: () => new SockJS(this.url),
reconnectDelay: 5000, // 재연결 대기 시간
heartbeatIncoming: 20000, // 서버로부터 하트비트 기대 주기
heartbeatOutgoing: 20000, // 클라이언트 하트비트 전송 주기
onConnect: (frame) => {
console.log('연결됨:', frame);
this.onConnected();
},
onStompError: (frame) => {
console.error('STOMP 오류:', frame);
},
onWebSocketClose: (event) => {
console.log('WebSocket 종료:', event);
}
});
this.client.activate();
}
onConnected() {
// 채팅 메시지 구독
this.subscribe('/topic/chat', (message) => {
this.handleChatMessage(JSON.parse(message.body));
});
// 개인 알림 구독
this.subscribe(`/user/queue/notifications`, (message) => {
this.handleNotification(JSON.parse(message.body));
});
}
subscribe(destination, callback) {
const subscription = this.client.subscribe(destination, callback);
this.subscriptions.set(destination, subscription);
return subscription;
}
unsubscribe(destination) {
const subscription = this.subscriptions.get(destination);
if (subscription) {
subscription.unsubscribe();
this.subscriptions.delete(destination);
}
}
send(destination, body) {
this.client.publish({
destination,
body: JSON.stringify(body)
});
}
disconnect() {
if (this.client) {
this.client.deactivate();
}
}
handleChatMessage(message) {
console.log('채팅 메시지:', message);
// UI 업데이트 로직
}
handleNotification(notification) {
console.log('알림:', notification);
// 알림 표시 로직
}
}
// 사용 예시
const client = new WebSocketClient('http://localhost:8080/ws', 'user123');
client.connect();
// 메시지 전송
setTimeout(() => {
client.send('/app/chat.send', {
sender: 'user123',
content: 'Hello!',
type: 'CHAT'
});
}, 2000);
SockJS 폴백 메커니즘
SockJS는 WebSocket을 지원하지 않는 환경에서 자동으로 폴백을 제공합니다.
폴백 순서
- WebSocket (최우선)
- HTTP Streaming (xhr-streaming, iframe-eventsource)
- HTTP Long-Polling (xhr-polling, jsonp-polling)
Spring Boot 설정
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS()
.setStreamBytesLimit(512 * 1024) // 스트리밍 제한
.setHttpMessageCacheSize(1000) // 메시지 캐시 크기
.setDisconnectDelay(30 * 1000); // 연결 종료 대기 시간
}
클라이언트 옵션 설정
const client = new Client({
webSocketFactory: () => new SockJS('http://localhost:8080/ws', null, {
transports: ['websocket', 'xhr-streaming', 'xhr-polling'],
timeout: 5000
}),
debug: (str) => {
console.log('SockJS Debug:', str);
}
});
전송 방식 감지
const sockjs = new SockJS('http://localhost:8080/ws');
sockjs.onopen = function() {
console.log('사용된 전송 방식:', sockjs.protocol);
// 출력 예: "websocket", "xhr-streaming", "xhr-polling"
};
하트비트 최적화
하트비트는 연결 상태를 확인하지만 과도하면 네트워크 트래픽을 증가시킵니다.
기본 설정 문제
// 기본 설정 (4초 간격)
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000
트래픽 계산:
- 하트비트 프레임 크기: 약 2 바이트
- 초당 전송량: 2 bytes / 4s = 0.5 bytes/s
- 사용자 1000명 × 0.5 = 500 bytes/s
- 하루: 500 × 86400 = 43.2 MB/day
최적화된 설정
// 최적화 (20초 간격)
heartbeatIncoming: 20000,
heartbeatOutgoing: 20000
개선 효과:
- 트래픽 80% 절감
- 서버 CPU 사용량 감소
- 연결 안정성 유지
서버 측 설정
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue")
.setHeartbeatValue(new long[]{20000, 20000}); // [서버→클라이언트, 클라이언트→서버]
config.setApplicationDestinationPrefixes("/app");
}
}
동적 하트비트 조정
class AdaptiveHeartbeat {
constructor(client) {
this.client = client;
this.currentInterval = 20000;
this.minInterval = 5000;
this.maxInterval = 60000;
}
onConnectionStable() {
// 연결이 안정적이면 간격 증가
this.currentInterval = Math.min(
this.currentInterval * 1.5,
this.maxInterval
);
this.updateHeartbeat();
}
onConnectionUnstable() {
// 연결이 불안정하면 간격 감소
this.currentInterval = Math.max(
this.currentInterval / 2,
this.minInterval
);
this.updateHeartbeat();
}
updateHeartbeat() {
this.client.heartbeatIncoming = this.currentInterval;
this.client.heartbeatOutgoing = this.currentInterval;
console.log('하트비트 간격 조정:', this.currentInterval);
}
}
재연결 전략
네트워크 오류 시 효과적으로 재연결하는 전략이 필요합니다.
Fast Retry + Slow Reconnect
class ReconnectStrategy {
constructor() {
this.fastRetryCount = 0;
this.maxFastRetries = 3;
this.fastRetryDelay = 5000; // 5초
this.slowRetryDelay = 60000; // 60초
}
getDelay() {
if (this.fastRetryCount < this.maxFastRetries) {
return this.fastRetryDelay;
}
return this.slowRetryDelay;
}
onReconnectAttempt() {
this.fastRetryCount++;
}
onConnected() {
this.fastRetryCount = 0; // 성공 시 리셋
}
}
const reconnectStrategy = new ReconnectStrategy();
const client = new Client({
webSocketFactory: () => new SockJS('http://localhost:8080/ws'),
reconnectDelay: 0, // 직접 관리
onConnect: () => {
console.log('연결 성공');
reconnectStrategy.onConnected();
},
onWebSocketClose: () => {
console.log('연결 종료, 재연결 대기 중...');
reconnectStrategy.onReconnectAttempt();
const delay = reconnectStrategy.getDelay();
console.log(`${delay / 1000}초 후 재연결 시도 (${reconnectStrategy.fastRetryCount}/${reconnectStrategy.maxFastRetries})`);
setTimeout(() => {
client.activate();
}, delay);
}
});
지수 백오프
class ExponentialBackoff {
constructor(initialDelay = 1000, maxDelay = 60000, multiplier = 2) {
this.initialDelay = initialDelay;
this.maxDelay = maxDelay;
this.multiplier = multiplier;
this.currentDelay = initialDelay;
}
getDelay() {
const delay = this.currentDelay;
this.currentDelay = Math.min(
this.currentDelay * this.multiplier,
this.maxDelay
);
return delay;
}
reset() {
this.currentDelay = this.initialDelay;
}
}
const backoff = new ExponentialBackoff(1000, 60000, 2);
client.onWebSocketClose = () => {
const delay = backoff.getDelay();
console.log(`${delay}ms 후 재연결 (지수 백오프)`);
setTimeout(() => {
client.activate();
}, delay);
};
client.onConnect = () => {
backoff.reset(); // 성공 시 리셋
};
연결 상태 관리
class ConnectionManager {
constructor(client) {
this.client = client;
this.state = 'disconnected'; // disconnected, connecting, connected
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
}
connect() {
if (this.state === 'connected' || this.state === 'connecting') {
console.log('이미 연결 중 또는 연결됨');
return;
}
this.state = 'connecting';
this.client.activate();
}
onConnected() {
this.state = 'connected';
this.reconnectAttempts = 0;
this.notifyStateChange('connected');
}
onDisconnected() {
this.state = 'disconnected';
this.notifyStateChange('disconnected');
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnect();
} else {
console.error('최대 재연결 시도 횟수 초과');
this.notifyStateChange('failed');
}
}
reconnect() {
this.reconnectAttempts++;
console.log(`재연결 시도 ${this.reconnectAttempts}/${this.maxReconnectAttempts}`);
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 60000);
setTimeout(() => this.connect(), delay);
}
notifyStateChange(state) {
// UI 업데이트 또는 콜백 호출
console.log('연결 상태:', state);
document.dispatchEvent(new CustomEvent('connectionState', { detail: state }));
}
}
const manager = new ConnectionManager(client);
client.onConnect = () => manager.onConnected();
client.onWebSocketClose = () => manager.onDisconnected();
manager.connect();
토픽 구독과 메시지 라우팅
STOMP는 목적지 기반 라우팅을 제공합니다.
토픽 vs 큐
토픽 (/topic): 브로드캐스트 (구독자 모두에게 전달)
@SendTo("/topic/notifications")
public Notification sendNotification(Notification notification) {
return notification;
}
큐 (/queue): Point-to-Point (한 구독자에게만 전달)
@MessageMapping("/task.assign")
@SendToUser("/queue/tasks")
public Task assignTask(Task task) {
return task;
}
다중 구독
// 여러 토픽 구독
const subscriptions = [
client.subscribe('/topic/agent-status', handleAgentStatus),
client.subscribe('/topic/progress', handleProgress),
client.subscribe('/user/queue/alerts', handleAlert)
];
function handleAgentStatus(message) {
const status = JSON.parse(message.body);
console.log('에이전트 상태:', status);
updateAgentStatusUI(status);
}
function handleProgress(message) {
const progress = JSON.parse(message.body);
console.log('진행률:', progress.percentage + '%');
updateProgressBar(progress.percentage);
}
function handleAlert(message) {
const alert = JSON.parse(message.body);
showNotification(alert.title, alert.message);
}
// 구독 해제
function cleanup() {
subscriptions.forEach(sub => sub.unsubscribe());
}
동적 라우팅
@Controller
public class DynamicRoutingController {
@Autowired
private SimpMessagingTemplate messagingTemplate;
@MessageMapping("/data.update")
public void handleDataUpdate(@Payload DataUpdate update,
@Header("target") String target) {
// 헤더 기반 라우팅
String destination = "/topic/" + target;
messagingTemplate.convertAndSend(destination, update);
}
// 조건부 라우팅
@MessageMapping("/message.send")
public void sendMessage(@Payload Message message) {
if (message.isPriority()) {
messagingTemplate.convertAndSend("/topic/priority", message);
} else {
messagingTemplate.convertAndSend("/topic/general", message);
}
}
}
실전 예시: 파일 전송 진행률 실시간 모니터링
파일 업로드 진행률을 WebSocket으로 실시간 모니터링하는 대시보드를 만들어보겠습니다.
백엔드: 파일 업로드 서비스
@Service
public class FileUploadService {
@Autowired
private SimpMessagingTemplate messagingTemplate;
public void uploadFile(String uploadId, MultipartFile file, String userId) {
long totalSize = file.getSize();
long uploadedSize = 0;
try (InputStream is = file.getInputStream()) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
// 파일 처리 로직 (저장 등)
processChunk(buffer, bytesRead);
uploadedSize += bytesRead;
int percentage = (int) ((uploadedSize * 100) / totalSize);
// 진행률 전송
sendProgress(userId, uploadId, percentage, uploadedSize, totalSize);
// 시뮬레이션을 위한 지연 (실제로는 불필요)
Thread.sleep(50);
}
// 완료 알림
sendCompletion(userId, uploadId, file.getOriginalFilename());
} catch (IOException | InterruptedException e) {
sendError(userId, uploadId, e.getMessage());
}
}
private void sendProgress(String userId, String uploadId,
int percentage, long uploaded, long total) {
Map<String, Object> progress = Map.of(
"uploadId", uploadId,
"percentage", percentage,
"uploadedBytes", uploaded,
"totalBytes", total,
"timestamp", System.currentTimeMillis()
);
messagingTemplate.convertAndSendToUser(
userId,
"/queue/upload-progress",
progress
);
}
private void sendCompletion(String userId, String uploadId, String filename) {
Map<String, Object> completion = Map.of(
"uploadId", uploadId,
"filename", filename,
"status", "completed",
"timestamp", System.currentTimeMillis()
);
messagingTemplate.convertAndSendToUser(
userId,
"/queue/upload-complete",
completion
);
}
private void sendError(String userId, String uploadId, String error) {
Map<String, Object> errorMsg = Map.of(
"uploadId", uploadId,
"error", error,
"timestamp", System.currentTimeMillis()
);
messagingTemplate.convertAndSendToUser(
userId,
"/queue/upload-error",
errorMsg
);
}
private void processChunk(byte[] buffer, int length) {
// 실제 파일 저장 로직
}
}
백엔드: 업로드 컨트롤러
@RestController
@RequestMapping("/api/upload")
public class UploadController {
@Autowired
private FileUploadService uploadService;
@PostMapping
public ResponseEntity<Map<String, String>> uploadFile(
@RequestParam("file") MultipartFile file,
@AuthenticationPrincipal User user) {
String uploadId = UUID.randomUUID().toString();
// 비동기 처리
CompletableFuture.runAsync(() -> {
uploadService.uploadFile(uploadId, file, user.getUsername());
});
return ResponseEntity.ok(Map.of(
"uploadId", uploadId,
"message", "Upload started"
));
}
}
프론트엔드: React 대시보드
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
import { useState, useEffect } from 'react';
function FileUploadDashboard() {
const [uploads, setUploads] = useState({});
const [client, setClient] = useState(null);
const [selectedFile, setSelectedFile] = useState(null);
useEffect(() => {
const stompClient = new Client({
webSocketFactory: () => new SockJS('http://localhost:8080/ws'),
onConnect: () => {
console.log('WebSocket 연결됨');
// 진행률 구독
stompClient.subscribe('/user/queue/upload-progress', (message) => {
const progress = JSON.parse(message.body);
updateProgress(progress);
});
// 완료 알림 구독
stompClient.subscribe('/user/queue/upload-complete', (message) => {
const completion = JSON.parse(message.body);
handleCompletion(completion);
});
// 오류 알림 구독
stompClient.subscribe('/user/queue/upload-error', (message) => {
const error = JSON.parse(message.body);
handleError(error);
});
}
});
stompClient.activate();
setClient(stompClient);
return () => {
stompClient.deactivate();
};
}, []);
const updateProgress = (progress) => {
setUploads(prev => ({
...prev,
[progress.uploadId]: {
...prev[progress.uploadId],
percentage: progress.percentage,
uploadedBytes: progress.uploadedBytes,
totalBytes: progress.totalBytes,
status: 'uploading'
}
}));
};
const handleCompletion = (completion) => {
setUploads(prev => ({
...prev,
[completion.uploadId]: {
...prev[completion.uploadId],
filename: completion.filename,
status: 'completed',
percentage: 100
}
}));
};
const handleError = (error) => {
setUploads(prev => ({
...prev,
[error.uploadId]: {
...prev[error.uploadId],
status: 'error',
error: error.error
}
}));
};
const uploadFile = async () => {
if (!selectedFile) return;
const formData = new FormData();
formData.append('file', selectedFile);
try {
const response = await fetch('http://localhost:8080/api/upload', {
method: 'POST',
body: formData,
credentials: 'include'
});
const data = await response.json();
setUploads(prev => ({
...prev,
[data.uploadId]: {
filename: selectedFile.name,
totalBytes: selectedFile.size,
uploadedBytes: 0,
percentage: 0,
status: 'started'
}
}));
} catch (error) {
console.error('업로드 시작 실패:', error);
}
};
return (
<div className="upload-dashboard">
<h2>파일 업로드 대시보드</h2>
<div className="upload-controls">
<input
type="file"
onChange={(e) => setSelectedFile(e.target.files[0])}
/>
<button onClick={uploadFile}>업로드</button>
</div>
<div className="upload-list">
{Object.entries(uploads).map(([uploadId, upload]) => (
<div key={uploadId} className="upload-item">
<div className="filename">{upload.filename}</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${upload.percentage}%` }}
/>
</div>
<div className="upload-stats">
<span>{upload.percentage}%</span>
<span>
{formatBytes(upload.uploadedBytes)} / {formatBytes(upload.totalBytes)}
</span>
<span className={`status ${upload.status}`}>
{upload.status}
</span>
</div>
{upload.error && (
<div className="error">{upload.error}</div>
)}
</div>
))}
</div>
</div>
);
}
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export default FileUploadDashboard;
CSS 스타일
.upload-dashboard {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.upload-controls {
margin-bottom: 20px;
}
.upload-item {
border: 1px solid #ddd;
padding: 15px;
margin-bottom: 10px;
border-radius: 5px;
}
.filename {
font-weight: bold;
margin-bottom: 10px;
}
.progress-bar {
width: 100%;
height: 20px;
background: #f0f0f0;
border-radius: 10px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4CAF50, #45a049);
transition: width 0.3s ease;
}
.upload-stats {
display: flex;
justify-content: space-between;
font-size: 14px;
color: #666;
}
.status {
padding: 2px 8px;
border-radius: 3px;
}
.status.uploading {
background: #2196F3;
color: white;
}
.status.completed {
background: #4CAF50;
color: white;
}
.status.error {
background: #f44336;
color: white;
}
.error {
color: #f44336;
margin-top: 10px;
font-size: 14px;
}
마무리
WebSocket과 STOMP 프로토콜을 활용하면 효율적인 실시간 양방향 통신을 구현할 수 있습니다.
핵심 내용 정리:
- WebSocket은 HTTP 폴링보다 낮은 지연과 오버헤드
- STOMP는 메시지 라우팅과 구독 관리 표준화
- Spring Boot는
@EnableWebSocketMessageBroker로 간편한 설정 - SockJS는 WebSocket 미지원 환경에서 자동 폴백
- 하트비트 최적화로 트래픽 절감
- 재연결 전략으로 안정적인 연결 유지
- 토픽/큐 기반 메시지 라우팅
다음 단계:
- Redis 백엔드로 클러스터 환경 지원
- Spring Security로 WebSocket 인증/인가
- 메시지 압축으로 대역폭 절감
- RabbitMQ/Kafka와 통합
실시간 통신은 현대 웹 애플리케이션의 필수 요소입니다. WebSocket과 STOMP로 사용자 경험을 한 단계 끌어올려보세요.