들어가며
현대 웹 애플리케이션에서 실시간 통신은 선택이 아닌 필수가 되었습니다. 채팅, 알림, 실시간 대시보드, 주식 시세 등 사용자에게 즉각적인 데이터 전달이 필요한 서비스가 점점 늘어나고 있습니다. 전통적인 HTTP 요청-응답 모델로는 이러한 요구사항을 효율적으로 처리하기 어렵습니다. 이번 글에서는 WebSocket, SSE(Server-Sent Events), gRPC Streaming 세 가지 실시간 통신 기술을 깊이 비교하고, Spring Boot 기반의 실전 구현 예제까지 다루겠습니다.
1. HTTP 폴링의 한계
실시간 통신 기술을 이해하려면 먼저 기존 HTTP 방식의 한계를 알아야 합니다.
Short Polling
클라이언트가 일정 간격으로 서버에 반복 요청하는 방식입니다.
// 클라이언트 측 Short Polling
setInterval(async () => {
const response = await fetch('/api/notifications');
const data = await response.json();
updateUI(data);
}, 3000); // 3초마다 요청
문제점: 불필요한 요청이 대량 발생하고, 요청 간격만큼 데이터 지연이 생깁니다. 서버 리소스 낭비가 심합니다.
Long Polling
서버가 새 데이터가 생길 때까지 응답을 보류하는 방식입니다.
// Spring Controller - Long Polling
@GetMapping("/api/notifications/poll")
public DeferredResult<List<Notification>> pollNotifications() {
DeferredResult<List<Notification>> result = new DeferredResult<>(30000L);
// 타임아웃 시 빈 리스트 반환
result.onTimeout(() -> result.setResult(Collections.emptyList()));
// 새 알림이 오면 결과 설정
notificationService.registerListener(result);
return result;
}
문제점: 여전히 연결-해제 반복으로 오버헤드가 존재하고, 서버 리소스를 장시간 점유합니다.
| 방식 | 지연 시간 | 서버 부하 | 네트워크 효율 |
|---|---|---|---|
| Short Polling | 높음 (간격만큼) | 높음 | 낮음 |
| Long Polling | 낮음 | 중간 | 중간 |
| WebSocket | 매우 낮음 | 낮음 | 높음 |
| SSE | 매우 낮음 | 낮음 | 높음 |
2. WebSocket 동작 원리
WebSocket은 HTTP에서 시작하여 양방향 전이중(Full-Duplex) 통신 채널로 업그레이드되는 프로토콜입니다.
핸드셰이크 과정
클라이언트가 HTTP Upgrade 요청을 보내면, 서버가 101 Switching Protocols로 응답하며 WebSocket 연결이 수립됩니다.
// 1. 클라이언트 요청 (HTTP Upgrade)
GET /ws/chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
// 2. 서버 응답
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
핸드셰이크 이후에는 TCP 연결 위에서 프레임(Frame) 단위로 데이터를 교환합니다. HTTP 헤더 오버헤드 없이 2~14바이트의 프레임 헤더만 추가됩니다.
WebSocket 프레임 구조
- FIN bit: 메시지의 마지막 프레임인지 표시
- Opcode: 텍스트(0x1), 바이너리(0x2), Close(0x8), Ping(0x9), Pong(0xA)
- Payload: 실제 전송 데이터
- Mask: 클라이언트 -> 서버 방향은 마스킹 필수
3. STOMP 프로토콜
WebSocket은 저수준 프로토콜이라 메시지 형식이나 라우팅을 직접 구현해야 합니다. STOMP(Simple Text Oriented Messaging Protocol)는 WebSocket 위에서 동작하는 서브 프로토콜로, 메시지 브로커 패턴을 제공합니다.
STOMP 프레임 구조
COMMAND
header1:value1
header2:value2
Body^@
// 예시: 메시지 전송
SEND
destination:/app/chat.send
content-type:application/json
{"roomId":"room1","sender":"user1","content":"안녕하세요!"}^@
STOMP 주요 명령어
- CONNECT: 서버에 연결
- SUBSCRIBE: 특정 destination 구독
- SEND: destination에 메시지 전송
- UNSUBSCRIBE: 구독 해제
- DISCONNECT: 연결 종료
4. SSE (Server-Sent Events)
SSE는 서버에서 클라이언트로의 단방향 스트리밍을 위한 기술입니다. HTTP 위에서 동작하므로 기존 인프라와 완벽히 호환됩니다.
SSE 동작 원리
// SSE 응답 헤더
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
// SSE 이벤트 형식
id: 1
event: notification
data: {"type":"info","message":"새 알림이 있습니다"}
retry: 3000
id: 2
event: notification
data: {"type":"warning","message":"서버 점검 예정"}
SSE 필드 설명
- id: 이벤트 고유 ID (자동 재연결 시 Last-Event-ID 헤더로 전송)
- event: 이벤트 타입 (클라이언트에서 addEventListener로 수신)
- data: 실제 데이터 (여러 줄 가능)
- retry: 재연결 간격 (밀리초)
Spring Boot SSE 구현
@RestController
@RequestMapping("/api/sse")
public class SseController {
private final CopyOnWriteArrayList<SseEmitter> emitters = new CopyOnWriteArrayList<>();
@GetMapping("/subscribe")
public SseEmitter subscribe() {
SseEmitter emitter = new SseEmitter(60_000L); // 60초 타임아웃
emitters.add(emitter);
emitter.onCompletion(() -> emitters.remove(emitter));
emitter.onTimeout(() -> emitters.remove(emitter));
emitter.onError(e -> emitters.remove(emitter));
return emitter;
}
public void broadcast(String eventName, Object data) {
List<SseEmitter> deadEmitters = new ArrayList<>();
emitters.forEach(emitter -> {
try {
emitter.send(SseEmitter.event()
.name(eventName)
.data(data, MediaType.APPLICATION_JSON));
} catch (IOException e) {
deadEmitters.add(emitter);
}
});
emitters.removeAll(deadEmitters);
}
}
클라이언트 측 SSE 수신
const eventSource = new EventSource('/api/sse/subscribe');
eventSource.addEventListener('notification', (event) => {
const data = JSON.parse(event.data);
console.log('알림:', data.message);
});
eventSource.onerror = (error) => {
console.error('SSE 연결 오류:', error);
// 브라우저가 자동으로 재연결 시도
};
5. gRPC와 Protocol Buffers
gRPC는 Google이 개발한 고성능 RPC 프레임워크로, HTTP/2 기반에서 Protocol Buffers(protobuf)를 사용하여 바이너리 직렬화된 데이터를 교환합니다.
gRPC 스트리밍 유형
- Unary: 1:1 요청-응답 (일반 RPC)
- Server Streaming: 클라이언트 1 요청 -> 서버 N 응답
- Client Streaming: 클라이언트 N 요청 -> 서버 1 응답
- Bidirectional Streaming: 양방향 N:N 스트리밍
Proto 파일 정의
syntax = "proto3";
package chat;
service ChatService {
// Unary
rpc SendMessage(ChatMessage) returns (ChatResponse);
// Server Streaming - 실시간 메시지 수신
rpc SubscribeMessages(SubscribeRequest) returns (stream ChatMessage);
// Bidirectional Streaming - 양방향 채팅
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
message ChatMessage {
string room_id = 1;
string sender = 2;
string content = 3;
int64 timestamp = 4;
}
message ChatResponse {
bool success = 1;
string message_id = 2;
}
message SubscribeRequest {
string room_id = 1;
string user_id = 2;
}
gRPC 서버 구현 (Java)
@GrpcService
public class ChatGrpcService extends ChatServiceGrpc.ChatServiceImplBase {
private final Map<String, Set<StreamObserver<ChatMessage>>> roomSubscribers
= new ConcurrentHashMap<>();
@Override
public void subscribeMessages(
SubscribeRequest request,
StreamObserver<ChatMessage> responseObserver) {
String roomId = request.getRoomId();
roomSubscribers
.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet())
.add(responseObserver);
// 클라이언트 연결 해제 감지
Context.current().addListener(context -> {
roomSubscribers.getOrDefault(roomId, Collections.emptySet())
.remove(responseObserver);
}, MoreExecutors.directExecutor());
}
@Override
public StreamObserver<ChatMessage> chat(
StreamObserver<ChatMessage> responseObserver) {
return new StreamObserver<ChatMessage>() {
@Override
public void onNext(ChatMessage message) {
// 같은 방의 모든 구독자에게 브로드캐스트
Set<StreamObserver<ChatMessage>> subscribers =
roomSubscribers.getOrDefault(
message.getRoomId(), Collections.emptySet());
for (StreamObserver<ChatMessage> subscriber : subscribers) {
subscriber.onNext(message);
}
}
@Override
public void onError(Throwable t) {
// 에러 처리
}
@Override
public void onCompleted() {
responseObserver.onCompleted();
}
};
}
}
6. 세 가지 기술 비교 분석
| 항목 | WebSocket | SSE | gRPC Streaming |
|---|---|---|---|
| 통신 방향 | 양방향 (Full-Duplex) | 단방향 (서버 -> 클라이언트) | 양방향 / 단방향 선택 가능 |
| 프로토콜 | ws:// / wss:// | HTTP/1.1 이상 | HTTP/2 |
| 데이터 형식 | 텍스트 / 바이너리 | 텍스트 (UTF-8) | 바이너리 (protobuf) |
| 자동 재연결 | 직접 구현 | 브라우저 내장 | 직접 구현 |
| 브라우저 지원 | 모든 주요 브라우저 | IE 제외 모든 브라우저 | grpc-web 별도 필요 |
| 프록시/방화벽 | 문제 발생 가능 | HTTP라 호환성 높음 | HTTP/2 필요 |
| 성능 | 높음 | 중간 | 매우 높음 |
| 적합한 사용처 | 채팅, 게임, 공동 편집 | 알림, 실시간 피드, SSR | 마이크로서비스 간 통신 |
선택 기준 가이드
- 채팅, 게임 등 양방향 통신이 필요하면: WebSocket
- 알림, 실시간 피드 등 서버 -> 클라이언트 단방향이면: SSE (가장 간단)
- 마이크로서비스 간 고성능 통신이 필요하면: gRPC
- 브라우저 호환성이 최우선이면: SSE > WebSocket > gRPC
7. Spring Boot WebSocket + STOMP 채팅 구현
실전에서 가장 많이 쓰이는 Spring Boot WebSocket + STOMP 기반 채팅 시스템을 구현해봅시다.
의존성 추가 (build.gradle)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.fasterxml.jackson.core:jackson-databind'
}
WebSocket 설정
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 서버 -> 클라이언트 구독 prefix
registry.enableSimpleBroker("/topic", "/queue");
// 클라이언트 -> 서버 전송 prefix
registry.setApplicationDestinationPrefixes("/app");
// 특정 사용자에게 메시지 보낼 때
registry.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/chat")
.setAllowedOriginPatterns("*")
.withSockJS(); // SockJS 폴백 지원
}
}
메시지 DTO
public record ChatMessageDto(
MessageType type,
String roomId,
String sender,
String content,
LocalDateTime timestamp
) {
public enum MessageType {
ENTER, TALK, LEAVE
}
public static ChatMessageDto enter(String roomId, String sender) {
return new ChatMessageDto(
MessageType.ENTER, roomId, sender,
sender + "님이 입장하였습니다.",
LocalDateTime.now());
}
public static ChatMessageDto leave(String roomId, String sender) {
return new ChatMessageDto(
MessageType.LEAVE, roomId, sender,
sender + "님이 퇴장하였습니다.",
LocalDateTime.now());
}
}
채팅 Controller
@Controller
@RequiredArgsConstructor
public class ChatController {
private final SimpMessagingTemplate messagingTemplate;
private final ChatRoomService chatRoomService;
// 메시지 전송: 클라이언트가 /app/chat.send로 SEND
@MessageMapping("/chat.send")
public void sendMessage(ChatMessageDto message) {
// /topic/chat/{roomId}를 구독하는 모든 클라이언트에게 전송
messagingTemplate.convertAndSend(
"/topic/chat/" + message.roomId(), message);
// 메시지 저장
chatRoomService.saveMessage(message);
}
// 입장 알림
@MessageMapping("/chat.enter")
public void enterRoom(ChatMessageDto message) {
ChatMessageDto enterMessage = ChatMessageDto.enter(
message.roomId(), message.sender());
messagingTemplate.convertAndSend(
"/topic/chat/" + message.roomId(), enterMessage);
}
// 특정 사용자에게 1:1 메시지
@MessageMapping("/chat.private")
public void privateMessage(
ChatMessageDto message,
@Header("targetUser") String targetUser) {
messagingTemplate.convertAndSendToUser(
targetUser, "/queue/private", message);
}
}
이벤트 리스너 (연결/해제 감지)
@Component
@RequiredArgsConstructor
@Slf4j
public class WebSocketEventListener {
private final SimpMessagingTemplate messagingTemplate;
@EventListener
public void handleWebSocketConnect(SessionConnectEvent event) {
StompHeaderAccessor accessor =
StompHeaderAccessor.wrap(event.getMessage());
String sessionId = accessor.getSessionId();
log.info("WebSocket 연결: sessionId={}", sessionId);
}
@EventListener
public void handleWebSocketDisconnect(SessionDisconnectEvent event) {
StompHeaderAccessor accessor =
StompHeaderAccessor.wrap(event.getMessage());
String username = (String) accessor.getSessionAttributes()
.get("username");
String roomId = (String) accessor.getSessionAttributes()
.get("roomId");
if (username != null && roomId != null) {
ChatMessageDto leaveMessage = ChatMessageDto.leave(roomId, username);
messagingTemplate.convertAndSend(
"/topic/chat/" + roomId, leaveMessage);
}
}
}
클라이언트 측 (JavaScript + STOMP)
import SockJS from 'sockjs-client';
import { Client } from '@stomp/stompjs';
const client = new Client({
webSocketFactory: () => new SockJS('/ws/chat'),
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
client.onConnect = (frame) => {
console.log('Connected:', frame);
// 채팅방 구독
client.subscribe('/topic/chat/room1', (message) => {
const chatMessage = JSON.parse(message.body);
displayMessage(chatMessage);
});
// 1:1 메시지 구독
client.subscribe('/user/queue/private', (message) => {
const privateMsg = JSON.parse(message.body);
displayPrivateMessage(privateMsg);
});
// 입장 메시지
client.publish({
destination: '/app/chat.enter',
body: JSON.stringify({
roomId: 'room1',
sender: 'user1'
})
});
};
client.onStompError = (frame) => {
console.error('STOMP Error:', frame.headers['message']);
};
client.activate();
// 메시지 전송 함수
function sendMessage(content) {
client.publish({
destination: '/app/chat.send',
body: JSON.stringify({
type: 'TALK',
roomId: 'room1',
sender: 'user1',
content: content
})
});
}
8. 프로덕션 고려사항
WebSocket 스케일아웃
다중 서버 환경에서 WebSocket 세션은 서버별로 분리됩니다. Redis Pub/Sub이나 외부 메시지 브로커(RabbitMQ, Kafka)를 사용하여 세션을 공유해야 합니다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 외부 브로커(RabbitMQ) 사용
registry.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("rabbitmq-host")
.setRelayPort(61613)
.setClientLogin("guest")
.setClientPasscode("guest");
registry.setApplicationDestinationPrefixes("/app");
}
}
연결 관리 체크리스트
- Heartbeat 설정: 연결 유지 확인 (WebSocket Ping/Pong 또는 STOMP heartbeat)
- 재연결 로직: 네트워크 불안정 대비 Exponential Backoff
- 인증: 핸드셰이크 시 JWT 토큰 검증 (HTTP Interceptor 활용)
- 메시지 큐잉: 오프라인 시 메시지 유실 방지
- Rate Limiting: 악의적 메시지 도배 방지
마치며
실시간 통신 기술은 각각의 장단점이 명확하므로, 요구사항에 맞는 기술을 선택하는 것이 중요합니다. 핵심을 정리하면 다음과 같습니다.
- WebSocket: 양방향 통신이 필요한 채팅, 게임 등에 최적. STOMP 프로토콜을 얹으면 메시지 라우팅이 편리합니다.
- SSE: 서버에서 클라이언트로의 단방향 스트리밍에 최적. HTTP 기반이라 인프라 호환성이 좋고, 브라우저 자동 재연결을 지원합니다.
- gRPC: 마이크로서비스 간 고성능 통신에 최적. protobuf 직렬화로 대역폭 효율이 뛰어납니다.
- 프로덕션 환경에서는 스케일아웃(외부 메시지 브로커), 인증, heartbeat, 재연결 전략을 반드시 고려해야 합니다.
직접 구현해보면서 각 기술의 특성을 체감하는 것이 가장 좋은 학습 방법입니다. Spring Boot의 WebSocket + STOMP 조합으로 시작해보시길 추천합니다.
'CS' 카테고리의 다른 글
| 백엔드 개발자 기술 면접 완벽 대비 - Spring/JPA/DB/인프라 핵심 질문 50선 (0) | 2026.04.14 |
|---|---|
| DNS와 CDN 동작 원리 - 웹 성능 최적화의 기초 (0) | 2026.04.08 |
| OAuth 2.0 / OIDC 완벽 가이드 - 인증 프로토콜의 모든 것 (1) | 2026.04.08 |
| REST API 설계 원칙 - 실무에서 바로 쓰는 베스트 프랙티스 (0) | 2026.03.31 |
| HTTP 완벽 가이드 - 백엔드 개발자가 알아야 할 모든 것 (0) | 2026.03.31 |