채팅, 알림, 실시간 시세는 어떻게 "새로고침 없이" 화면이 바뀔까요?
이 글은 웹소켓을 처음 접하는 분부터 운영 환경의 확장성과 보안까지 고민하는 분까지 단계별로 읽을 수 있게 구성했습니다.
읽는 방법: 처음이라면 Part 1~3, 실무 경험이 있다면 Part 4~5부터 읽어도 됩니다.
우리가 평소 쓰는 웹(HTTP)은 "요청해야 응답하는" 구조입니다.
| 순서 | 클라이언트 | 서버 |
|---|---|---|
| 1 | "새 메시지 있어요?" | |
| 2 | "없어요" | |
| 3 | "새 메시지 있어요?" | |
| 4 | "있어요! (안녕)" |
항상 클라이언트가 먼저 물어봐야 서버가 대답할 수 있습니다.
서버가 먼저 "새 메시지 왔어요!" 하고 말을 걸 수 없습니다. 식당에 비유하면, 손님이 벨을 누르기 전에는 직원이 절대 먼저 오지 않는 식당입니다.
웹소켓은 한 번 연결을 맺고 그 연결을 계속 유지하면서, 양쪽 모두 언제든 메시지를 보낼 수 있게 한 프로토콜입니다.
| 순서 | 클라이언트 | 서버 |
|---|---|---|
| 1 | 연결 요청 (핸드셰이크) | |
| 2 | 연결 수락 (이후 연결 유지) | |
| 3 | "새 메시지 도착!" (요청 없이 먼저 전송) | |
| 4 | "저도 보낼게요" | |
| 5 | "또 새 메시지!" |
연결이 열려 있는 동안에는 양쪽 모두 언제든 먼저 보낼 수 있습니다.
비유하면 전화 통화입니다. 한 번 연결되면 둘 중 누구든 말할 수 있고, 매번 다시 걸 필요가 없습니다. 반면 HTTP는 편지에 가깝습니다. 보내고 답장을 기다리고, 끝나면 연결도 끝납니다.
| 구분 | HTTP | 웹소켓 |
|---|---|---|
| 연결 | 요청마다 (또는 짧게) | 한 번 맺고 유지 |
| 방향 | 클라이언트 → 서버 시작 | 양방향, 누구든 시작 |
| 서버의 먼저 보내기 | 불가 | 가능 |
| 오버헤드 | 요청마다 헤더 | 연결 후엔 작은 프레임 헤더 |
| 어울리는 곳 | 조회, CRUD, 일반 페이지 | 채팅, 알림, 게임, 시세, 협업 도구 |
가능합니다. 이를 폴링(polling) 이라고 합니다. 1초마다 "새 메시지 있어?"라고 물으면 사용자에겐 거의 실시간처럼 보입니다. 다만 한계가 있습니다.
| 방식 | 설명 | 특징 |
|---|---|---|
| 폴링 | 주기적으로 요청 | 구현 쉬움, 낭비 큼 |
| 롱폴링 | 새 데이터가 생길 때까지 서버가 응답을 보류 | 낭비 감소, 연결 관리 복잡 |
| SSE | HTTP 연결을 유지하며 서버 → 클라 단방향 push | 단순, 텍스트 중심, 단방향 |
| 웹소켓 | 양방향 상시 연결 | 실시간성 최고, 연결 상태 관리 필요 |
웹소켓은 HTTP로 시작해서 웹소켓으로 "업그레이드" 됩니다. 그래서 80/443 포트를 그대로 쓰고, 기존 웹 인프라와 잘 어울립니다.
클라이언트 요청
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
서버 응답
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
상태 코드 101 Switching Protocols 이후부터는 HTTP가 아니라 웹소켓 프레임으로 통신합니다.
ws://(평문), wss://(TLS 암호화)입니다. 운영에서는 반드시 wss:// 를 씁니다.브라우저는 웹소켓 API를 기본 내장하고 있습니다.
const ws = new WebSocket("wss://example.com/chat");
ws.onopen = () => {
console.log("연결됨");
ws.send("안녕하세요");
};
ws.onmessage = (event) => {
console.log("받은 메시지:", event.data); // 요청 없이도 서버가 보내면 호출됨
};
ws.onclose = (event) => {
console.log("연결 종료", event.code, event.reason);
};
ws.onerror = (err) => {
console.error("에러", err);
};
핵심은 onmessage입니다. 서버가 보내는 순간 호출되므로 내가 따로 요청하지 않아도 다른 사람의 메시지를 받을 수 있습니다.
readyState)| 값 | 상수 | 의미 |
|---|---|---|
| 0 | CONNECTING | 연결 중 |
| 1 | OPEN | 통신 가능 |
| 2 | CLOSING | 종료 중 |
| 3 | CLOSED | 종료됨 |
send()는 OPEN 상태에서만 안전합니다. 연결 전에 보내면 에러가 납니다.
예제는 Spring Boot 3.x(Jakarta 기반) 기준입니다.
implementation 'org.springframework.boot:spring-boot-starter-websocket'
Spring에서 웹소켓을 쓰는 방법은 크게 두 가지입니다.
| 방식 | 특징 |
|---|---|
순수 WebSocket (WebSocketHandler) | 날것의 메시지를 직접 처리. 세션 관리·라우팅을 직접 구현 |
| STOMP over WebSocket | 메시징 규약(구독/발행)을 얹어서 @MessageMapping 등으로 편하게 개발 |
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final ChatHandler chatHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(chatHandler, "/ws/chat")
.setAllowedOriginPatterns("https://my-site.com"); // 운영에선 * 지양
}
}
@Component
public class ChatHandler extends TextWebSocketHandler {
// 동시성 문제를 피하려고 thread-safe 컬렉션 사용
private final Set<WebSocketSession> sessions = ConcurrentHashMap.newKeySet();
@Override
public void afterConnectionEstablished(WebSocketSession session) {
sessions.add(session);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
for (WebSocketSession s : sessions) {
if (s.isOpen()) {
s.sendMessage(message); // 접속한 모두에게 브로드캐스트
}
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
sessions.remove(session);
}
}
이 방식에서는 "누가 어느 방에 있는지"를 직접 관리해야 합니다. 방이 많아지면 코드가 빠르게 복잡해집니다.
주의:
WebSocketSession.sendMessage()는 같은 세션에 동시에 여러 스레드가 호출하면 안 됩니다. 필요하면ConcurrentWebSocketSessionDecorator로 감싸세요.
STOMP는 웹소켓 위에서 쓰는 간단한 텍스트 기반 메시징 규약입니다. 핵심 개념은 pub/sub입니다.
/topic/room.1 같은 목적지(destination)를 구독| 순서 | 주체 | 동작 |
|---|---|---|
| 1 | 클라이언트 B | /topic/room.1 구독 (SUBSCRIBE) |
| 2 | 클라이언트 C | /topic/room.1 구독 (SUBSCRIBE) |
| 3 | 클라이언트 A | /app/chat.send로 메시지 전송 (SEND) |
| 4 | 서버 | @MessageMapping("/chat.send") 메서드 실행, convertAndSend로 /topic/room.1에 발행 |
| 5 | 브로커 | /topic/room.1을 구독 중인 모두(B, C, 그리고 구독 중이면 A)에게 MESSAGE 전달 |
설정
@Configuration
@EnableWebSocketMessageBroker
public class StompConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("https://my-site.com")
.withSockJS(); // 필요 시 폴백
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/app"); // 클라 → 서버
registry.enableSimpleBroker("/topic", "/queue"); // 서버 → 클라 (인메모리 브로커)
}
}
컨트롤러
public record ChatMessage(String roomId, String sender, String content) {}
@Controller
@RequiredArgsConstructor
public class ChatController {
private final SimpMessagingTemplate messagingTemplate;
// 클라이언트가 /app/chat.send 로 보내면 호출됨
@MessageMapping("/chat.send")
public void send(ChatMessage message) {
// 1) DB 저장 (재접속 시 내역 조회용)
// 2) 해당 방 구독자에게 전달
messagingTemplate.convertAndSend("/topic/room." + message.roomId(), message);
}
}
@SendTo("/topic/...")를 쓰면 반환값을 자동으로 브로드캐스트할 수도 있지만, 방 ID처럼 동적 목적지는 SimpMessagingTemplate이 더 편합니다.
클라이언트 (stompjs)
import { Client } from "@stomp/stompjs";
const client = new Client({
brokerURL: "wss://example.com/ws",
reconnectDelay: 5000, // 끊기면 5초 후 재연결
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
onConnect: () => {
// 한 번만 구독하면, 이후 메시지는 자동으로 도착
client.subscribe("/topic/room.1", (frame) => {
const msg = JSON.parse(frame.body);
render(msg);
});
},
});
client.activate();
function send(text) {
client.publish({
destination: "/app/chat.send",
body: JSON.stringify({ roomId: "1", sender: "chichi", content: text }),
});
}
withSockJS()를 쓰면 클라이언트도 SockJS 방식으로 연결해야 합니다(webSocketFactory). 요즘은 대부분의 환경이 웹소켓을 지원하므로 순수brokerURL방식도 많이 씁니다.
웹소켓만으로 채팅이 완성되지는 않습니다. 역할을 나누는 것이 일반적입니다.
| 기능 | 수단 |
|---|---|
| 로그인, 방 생성, 방 목록 | REST API |
| 이전 대화 내역 조회 (페이징) | REST API |
| 실시간 메시지 송수신 | WebSocket (STOMP) |
| 메시지 영구 저장 | DB |
| 오프라인 사용자 알림 | 푸시 알림 등 |
접속하면 먼저 REST로 최근 내역을 불러오고, 그 뒤부터 웹소켓으로 새 메시지를 이어서 받는 흐름이 기본입니다.
브라우저 WebSocket API는 커스텀 헤더(Authorization)를 직접 설정할 수 없습니다. 그래서 보통 다음 중 하나를 씁니다.
| 방법 | 설명 | 주의점 |
|---|---|---|
| 쿠키/세션 | 핸드셰이크 시 쿠키가 자동 전송됨 | CSWSH(아래 참고) 방어 필요 |
| 쿼리스트링 토큰 | wss://.../ws?token=... | URL이 로그에 남을 수 있음. 단기 토큰 권장 |
| STOMP CONNECT 헤더 | 연결 후 STOMP 프레임 헤더에 토큰 | 서버에서 ChannelInterceptor로 검증 |
| 티켓 방식 | REST로 1회용 티켓 발급 → 웹소켓 연결 시 제출 | 가장 안전하고 흔한 패턴 |
STOMP에서 인증하는 예시
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");
Principal user = authenticate(token); // 검증 후 Principal 생성
accessor.setUser(user);
}
return message;
}
});
}
그리고 구독 권한도 검사해야 합니다. 로그인했다는 것만으로 /topic/room.999를 마음대로 구독하게 두면 남의 방 메시지를 훔쳐볼 수 있습니다. SUBSCRIBE 명령 시점에도 "이 사용자가 이 방의 멤버인가?"를 확인하세요.
wss:// 사용: 평문 ws://는 도청·변조 위험이 있습니다.setAllowedOriginPatterns를 명시적 도메인으로 제한하세요.모바일 네트워크 전환, 절전, 로드밸런서·프록시의 idle timeout(예: 60초) 등으로 연결은 조용히 죽습니다. 한쪽은 끊긴 줄 모르는 반쯤 열린(half-open) 연결도 생깁니다.
웹소켓(TCP)은 한 연결 안에서는 순서를 보장하지만, 애플리케이션 수준의 신뢰성은 보장하지 않습니다.
| 문제 | 원인 | 대응 |
|---|---|---|
| 유실 | 끊긴 동안 발송된 메시지 | 서버 저장 + 재접속 시 보충 조회 |
| 중복 | 재전송, 재접속 보충과 실시간 수신 겹침 | 메시지 고유 ID로 클라이언트에서 중복 제거 |
| 순서 꼬임 | 다중 서버, 비동기 처리 | 서버가 부여한 시퀀스/타임스탬프 기준 정렬 |
| 전송 확인 | 보냈는지 불확실 | 클라 → 서버 ACK, 클라이언트 임시 ID(낙관적 UI) |
채팅에서 흔한 패턴은 클라이언트가 임시 ID(clientMsgId)를 붙여 보내고, 서버가 저장 후 정식 ID와 함께 되돌려주면 화면의 임시 메시지를 확정 상태로 교체하는 것입니다.
처음부터 타입이 있는 JSON 구조로 설계해 두면 확장이 쉽습니다.
{
"type": "CHAT",
"id": "01JABC...",
"roomId": "1",
"sender": "chichi",
"content": "안녕",
"sentAt": "2026-10-08T12:00:00Z"
}
type으로 CHAT, TYPING, READ, JOIN, LEAVE 등을 구분하면 입력 중 표시, 읽음 처리 같은 기능을 같은 연결 위에 얹을 수 있습니다. 트래픽이 아주 크면 JSON 대신 Protobuf, MessagePack 같은 바이너리 포맷도 고려합니다.
wscat, websocatWebSocketStompClient 로 통합 테스트 작성 가능npx wscat -c wss://example.com/chat
핸드셰이크 이후의 데이터는 프레임(frame) 단위입니다.
프레임은 앞에서부터 아래 필드가 순서대로 이어진 구조입니다.
| 순서 | 필드 | 크기 | 설명 |
|---|---|---|---|
| 1 | FIN | 1비트 | 이 프레임이 메시지의 마지막 조각인지 여부 |
| 2 | RSV1~3 | 각 1비트 | 확장용 예약 비트. 보통 0 (permessage-deflate는 RSV1 사용) |
| 3 | opcode | 4비트 | 프레임 종류 (아래 표 참고) |
| 4 | MASK | 1비트 | payload 마스킹 여부. 클라이언트 → 서버는 항상 1 |
| 5 | Payload len | 7비트 | 데이터 길이. 0~125면 이 값이 곧 길이, 126이면 확장 16비트, 127이면 확장 64비트 사용 |
| 6 | Extended payload length | 없음 / 16비트 / 64비트 | Payload len이 126 또는 127일 때만 존재 |
| 7 | Masking-key | 없음 / 4바이트 | MASK가 1일 때만 존재 |
| 8 | Payload Data | 가변 | 실제 전송 데이터 (마스킹 키로 XOR 처리됨) |
| opcode | 의미 |
|---|---|
0x0 | 연속(continuation) 프레임 |
0x1 | 텍스트(UTF-8) |
0x2 | 바이너리 |
0x8 | 연결 종료(close) |
0x9 | Ping |
0xA | Pong |
Sec-WebSocket-Accept서버는 클라이언트가 보낸 Sec-WebSocket-Key에 고정 GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)를 붙여 SHA-1 해시 후 Base64 인코딩한 값을 돌려줍니다. 이는 "이 서버가 웹소켓 프로토콜을 이해하고 있다" 는 확인용이며, 인증이 아닙니다.
연결 종료 시 코드와 사유를 전달합니다. 디버깅할 때 매우 유용합니다.
| 코드 | 의미 |
|---|---|
| 1000 | 정상 종료 |
| 1001 | 엔드포인트가 떠남 (서버 종료, 페이지 이동) |
| 1006 | 비정상 종료 (프레임 없이 끊김. 네트워크 단절 등, 실제로 전송되지 않는 예약 코드) |
| 1008 | 정책 위반 |
| 1009 | 메시지가 너무 큼 |
| 1011 | 서버 내부 오류 |
1006이 자주 보이면 프록시 타임아웃, 네트워크 문제, 서버 크래시를 의심하세요.
HTTP는 무상태라 서버를 늘리면 로드밸런서가 아무 서버로나 보내면 됩니다. 웹소켓은 연결 자체가 상태(state) 입니다.
| 사용자 | 연결된 서버 | 해당 서버가 가진 것 |
|---|---|---|
| A | 서버 1 | A의 소켓 |
| B | 서버 2 | B의 소켓 |
A가 B에게 메시지를 보내면 서버 1이 받는데, 서버 1에는 B의 소켓이 없어서 전달할 수 없습니다.
그래서 서버가 여러 대일 때는 서버 간 메시지를 중계하는 계층이 필요합니다.
각 서버가 Redis Pub/Sub, Kafka, RabbitMQ 같은 브로커를 구독하고, 메시지를 브로커에 발행하면 모든 서버가 받아 자신에게 연결된 사용자에게 전달합니다.
Spring STOMP에서는 enableSimpleBroker 대신 enableStompBrokerRelay 로 RabbitMQ·ActiveMQ 등 STOMP 지원 브로커에 위임할 수 있습니다.
registry.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("rabbitmq-host")
.setRelayPort(61613)
.setClientLogin("guest")
.setClientPasscode("guest");
Redis Pub/Sub 방식은 구현이 간단하지만 구독자가 없으면 메시지가 사라지고(저장 안 됨) 전달 보장이 없다는 점을 알아야 합니다. 유실이 안 되어야 하면 Redis Streams, Kafka 등을 고려합니다.
같은 사용자·같은 방을 같은 서버로 보내는 방식입니다. 단순하지만 부하 분산이 불균등하고 서버 장애 시 재배치가 어렵습니다. 보통 브로커 방식과 병행합니다.
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s; # 기본 60초면 idle 연결이 끊김
}
ulimit -n, 커널 소켓 설정을 늘려야 수만 연결을 감당합니다.느린 클라이언트(느린 모바일 네트워크)에게 서버가 계속 보내면 서버 쪽 전송 버퍼가 쌓여 메모리가 터집니다.
ConcurrentWebSocketSessionDecorator(session, sendTimeLimit, bufferSizeLimit)WebSocketTransportRegistration의 setSendTimeLimit, setSendBufferSizeLimit확장(extension)으로 메시지 압축을 협상할 수 있습니다. 텍스트 위주 대용량 트래픽에 효과적이지만, CPU와 메모리를 더 쓰고, 압축과 암호화가 만나는 상황에서의 정보 누출(CRIME/BREACH 계열) 이슈도 고려해야 합니다. 소규모 메시지가 대부분이면 이득이 작을 수 있습니다.
Sec-WebSocket-Protocol 헤더로 "이 연결 위에서 어떤 상위 규약을 쓸지"를 협상합니다. STOMP(v12.stomp), MQTT, GraphQL(graphql-transport-ws) 등이 대표적입니다. 즉 STOMP는 웹소켓과 별개의 규약이고, 웹소켓 위에 얹히는 것입니다.
원래 웹소켓은 HTTP/1.1 업그레이드 방식입니다. HTTP/2 위에서는 RFC 8441(Extended CONNECT)로, HTTP/3는 RFC 9220으로 웹소켓을 한 연결에 멀티플렉싱할 수 있습니다. 다만 서버·프록시·브라우저 지원 상황이 달라, 실무에서는 아직 HTTP/1.1 업그레이드가 가장 널리 쓰입니다.
| 기술 | 특징 | 어울리는 곳 |
|---|---|---|
| SSE | 서버 → 클라 단방향, 자동 재연결, HTTP 인프라 친화적 | 알림, 피드, 진행률, LLM 스트리밍 응답 |
| WebTransport | HTTP/3(QUIC) 기반, 다중 스트림, 비신뢰성 데이터그램 | 저지연 게임, 미디어 (아직 발전 중) |
| WebRTC DataChannel | 브라우저 간 P2P | 화상 통화, P2P 데이터 |
| gRPC 스트리밍 | 서버 간 양방향 스트림 | 마이크로서비스 내부 |
| MQTT (over WS) | 경량 pub/sub | IoT |
"서버가 보내기만 하면 된다"면 SSE가 훨씬 단순한 경우가 많습니다. 웹소켓은 양방향이 정말 필요할 때 선택하세요.
| 우리 서비스의 상황 | 추천 기술 |
|---|---|
| 서버가 먼저 보낼 필요가 없다 (조회, CRUD 위주) | 일반 HTTP (REST) |
| 서버가 먼저 보내야 하지만, 서버에서 클라이언트로 가는 단방향이면 충분하다 (알림, 피드, 진행률) | SSE |
| 양방향 통신이 빈번하고, 방/주제 단위로 구독·발행이 필요하다 (채팅방, 협업 도구) | WebSocket + STOMP |
| 양방향 통신이 빈번하지만 구조가 단순하거나 직접 만든 규약을 쓴다 | 순수 WebSocket |
판단 순서는 위에서 아래로, "서버가 먼저 보내야 하나?" → "단방향이면 충분한가?" → "pub/sub 구조가 필요한가?" 입니다.
| 단계 | 목표 | 해볼 것 |
|---|---|---|
| 1 | 개념 이해 | HTTP vs 폴링 vs 웹소켓 비교, 브라우저 콘솔에서 new WebSocket 실습 |
| 2 | 간단한 채팅 | Spring 순수 WebSocket으로 전체 브로드캐스트 |
| 3 | 구조화 | STOMP로 방 단위 구독/발행, DB 저장, 내역 조회 REST |
| 4 | 운영 대비 | 인증·Origin 검증, 하트비트, 재연결, 중복/유실 처리 |
| 5 | 확장 | Redis/RabbitMQ 브로커, 로드밸런서 설정, 부하 테스트 |
웹소켓 = 한 번 연결해 두고, 서버와 클라이언트가 언제든 서로에게 말을 거는 통로.
쓰기는 쉽지만, 운영에서는 끊김·재연결·인증·확장을 함께 설계해야 진짜 서비스가 됩니다.