WebSocket
네트워크의 선택기준
- ✅ 📌 "어떤 데이터인가?" → 데이터 유형에 맞는 최적의 프로토콜을 선택할 수 있음.
- ✅ 📌 "어떤 방향으로 가는가?" → 단방향 vs 양방향에 따라 불필요한 기술 도입을 피할 수 있음.
- ✅ 📌 "속도와 효율성이 중요한가?" → 과도한 리소스 사용을 막고 최적의 성능을 유지할 수 있음.
웹소켓의 특징
1. 양방향 통신 (Full-Duplex)
- 클라이언트와 서버가 동시에 데이터를 주고받을 수 있어 실시간 데이터 처리가 가능 :채팅, 게임, 주식 시세, 실시간 알림에 적합
2. 연결 유지 (Persistent Connection)
- HTTP는 요청마다 새로운 TCP 연결을 생성해야 하지만, 웹소켓은 한 번 연결 후 지속적으로 재사용 가능.
- 기존 HTTP 방식보다 **응답 지연(Latency)**이 적고, 연결 유지 비용이 낮음.
3. 낮은 오버헤드 (Low Latency)
- http 헤더가 없으며 바이너리 데이터 전송가능하므로 인코딩을 위한 추가 오버헤드 절약가능
- 네트워크 오버헤드가 적어 실시간 데이터 교환이 많은 지도 서비스, IoT, 실시간 모니터링 시스템에도 활용 가능.
웹소켓의 동작 방식
웹소켓은 단순한 TCP 연결이 아니라 TCP 위에서 동작하는 자체적인 "프레임 포맷"을 정의한 프로토콜이야.
1️⃣ 클라이언트가 웹소켓 연결 요청 (HTTP request 사용)
- 클라이언트는 HTTP 요청을 보내면서 웹소켓 업그레이드(Upgrade) 요청을 포함함.
2️⃣ 서버가 핸드셰이크 응답 (HTTP 101 Switching Protocols)
- 서버가 웹소켓을 지원하면 HTTP 응답을 보내며 프로토콜을 웹소켓으로 전환함.
3️⃣ TCP 소켓을 사용하여 웹소켓 연결 유지
- 핸드셰이크가 완료되면 기존의 HTTP 연결이 유지된 상태에서 TCP 소켓을 통해 통신이 진행됨.
- 이후에는 HTTP가 아닌 **웹소켓 프레임(WebSocket Frames)**을 주고받음.
왜 HTTP는 양방향 통신이 안 되고, TCP는 가능한가?
- 브라우저의 HTTP를 핸들링하는 코드가 자신이 요청을 보낸 것에 대한 응답반을 수용하여 요청와 응답을 맵핑하는 방식으로 동작하기 때문
브라우저 내부에서 동작하는 흐름
- 클라이언트가 요청을 보냄 → 브라우저는 내부적으로 요청 ID를 생성함.
- 서버가 응답을 보냄 → 브라우저는 응답을 받아 요청 ID와 매칭함.
- 요청-응답 매칭이 성공하면, onload 핸들러를 실행.
- 만약 서버가 요청과 관련 없는 응답을 보낸다면? → 브라우저가 이를 무시함!
- 즉, 브라우저는 요청을 보냈을 때만 응답을 기다리며, 서버가 먼저 보낸 응답은 받아들이지 않음.
WebSocket이 요청없이 응답을 감지할 수 있는 이유: 이벤트 루프 기반 동작
기존 HTTP 방식은 요청이 끝날 때마다 연결을 닫으므로, 네트워크 부하가 많아지고 응답 속도가 느려짐. 반면, WebSocket은 한 번 연결되면 이벤트 루프(Event Loop)를 사용하여, 요청이 필요할 때만 CPU를 사용하면서도 연결을 계속 유지할 수 있음.
비교 항목 | HTTP (요청-응답 방식, Blocking I/O) | WebSocket (이벤트 루프 기반, Non-Blocking I/O) |
연결 유지 | ❌ 요청할 때마다 새로 연결 | ✅ 한 번 연결되면 지속 유지 |
데이터 전송 방식 | ⛔ 매번 요청해야 응답 가능 | ✅ 언제든 데이터 주고받을 수 있음 (Full-Duplex) |
CPU & 네트워크 부하 | 🔥 요청마다 연결 생성 → 리소스 낭비 | 🟢 이벤트 루프 기반으로 최소 리소스 사용 |
실시간 처리 성능 | ❌ 지속적인 요청 필요 (Polling, Long Polling) | ✅ 실시간 데이터 전송 가능 |
적용 사례 | 일반적인 웹 API (RESTful API) | 실시간 채팅, 게임, 주식 시세, IoT 등 |
📌 즉, WebSocket은 이벤트 루프 덕분에 HTTP 대비 CPU, 네트워크 부하를 크게 줄이면서도 실시간 처리를 할 수 있는 강력한 구조를 가질 수 있어.
서버 웹소켓 관행
(1) WebSocket은 API 서버(Spring 서버)에서 주로 처리
- WebSocket은 "연결" 자체를 관리해야 하므로, HTTP와 밀접. WebSocket도 처음에는 HTTP 요청으로 handshake를 합니다.이 handshake를 처리하려면 HTTP 기반 Web 컨텍스트(Spring MVC 등) 가 필요합니다. 즉, WebSocket은 HTTP 서버에서 초기 연결을 수립하고 소켓을 유지해야 합니다.
- WebSocket은 누구에게 어떤 메시지를 보낼지를 도메인 로직 + 인증 정보 기반으로 판단해야 함. API 서버는 이런 도메인 로직과 보안정보가 모두 모여 있는 중심지입니다
- WebSocket은 처음에는 HTTP 요청으로 시작되기 때문에, 클라이언트가 JWT 토큰 같은 인증 정보를 함께 보낼 수 있습니다.
- 반면, 이벤트 핸들러(예: Kafka Consumer)는 상태를 갖지 않음. Kafka나 Redis 소비자는 이벤트가 오면 그냥 stateless하게 처리하고 끝입니다 "어떤 유저가 현재 접속 중인지" 알 수 없음
웹소켓에 필요한 도메인 정보
도메인 | 도메인 로직 |
알림 | "어떤 알림을 어떤 유저에게 보내야 하나?" |
채팅방 | "어떤 채팅방에 누가 참여하고 있나?" |
군중 위치 | "특정 지역에 사람이 몰리면 관리자에게 알림을 보낸다" |
유저 권한 | "관리자 유저만 특정 WebSocket 메시지를 수신 가능" |
(2) WebSocket URL 설계 – 표준적 관행
- /ws 접두사 붙이기 → WebSocket용 경로임을 명확히 구분
- api는 rest api와 동의어로 사용되기때문에 /api/ws 같은 경로 구조는 혼동을 초래하여 비추천함
- 그 뒤는 도메인/기능에 따라 붙이기 → 도메인에는 종속되지 않음, 기능 기준으로 구성
- /ws/chat : 채팅 기능
- /ws/notify : 알림 push 기능
- /ws/crowd/location : 군중 위치 실시간 전송
서버 웹소켓 구현
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-websocket'
}
연결과정
- /notifications 엔드포인트에서 웹소켓 연결을 처리
- NotificationWebSocketHandler 인스턴스를 생성하고 registery에 등록하여 세션 관리
(1) WebSocket 설정 클래스
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new NotificationWebSocketHandler(), "/notifications").setAllowedOrigins("*");
}
}
@EnableWebSocket
↓
@Import(DelegatingWebSocketConfiguration)
↓
DelegatingWebSocketConfiguration extends WebSocketConfigurationSupport
↓
@Bean webSocketHandlerMapping() 실행
↓
WebSocketHandlerRegistry 생성됨
↓
등록된 모든 WebSocketConfigurer에게 registerWebSocketHandlers(registry) 호출
↓
개발자가 작성한 registry.addHandler() 호출됨
↓
WebSocket 핸들러와 URL, CORS 설정 등 등록 완료
1. @EnableWebSocket
- Spring 전체 컨텍스트를 대상으로 WebSocket 환경을 "활성화"시키는 메커니즘 동작 시작
- 내부적으로 DelegatingWebSocketConfiguration 클래스를 자동 빈 생성하고 그 내부에서 WebSocketHandlerRegistry를 생성함.
2. DelegatingWebSocketConfiguration
- WebSocketConfigurationSupport를 상속받은 Spring 구성 클래스.
- 핵심 역할:
- WebSocketHandlerMapping을 빈으로 등록 (@Bean)
- WebSocketConfigurer 구현체들을 찾아 registerWebSocketHandlers() 메서드를 자동 호출
3. WebSocketConfigurer (사용자 구현)
- 사용자가 직접 구현하는 인터페이스.
- void registerWebSocketHandlers(WebSocketHandlerRegistry registry) : 여기서 웹소켓 핸들러와 경로, CORS 등을 등록하는 역할을함.
- addHandler(handler, path) : WebSocketHandlerRegistry를 이용해서 지정된 핸들러(TextWebSocketHandler)를 지정된 WebSocket endpoint(/notifications)에 바인딩.
- 등록된 핸들러는 클라이언트에서 웹소켓 연결을 요청할 때 실행됨. 즉, 클라이언트가 ws://서버주소/notifications로 연결하면, 이 핸들러가 실행됨.
- .setAllowedOrigins("*") : Cross-Origin 요청 허용. 실무에선 "https://example.com" 등으로 제한 권장
(2) 웹소켓 핸들러 구현 : TextWebSocketHandler 내 주요 클래스 및 메소드
WebSocket 통신에서 주고받는 데이터는 거의 다 JSON 형식의 텍스트이므로 TextWebSocketHandler를 표준적으로 사용한다.
- TextWebSocketHandler → Spring에서 제공하는 웹소켓 핸들러 (텍스트 기반 메시지 처리 가능)
- TextMessage : TextMessage는 Spring WebSocket에서 문자열 기반 메시지를 주고받을 때 사용하는 객체입니다. 대부분의 경우 JSON 문자열을 넣어서 사용합니다.
String json = "{\"type\":\"alarm\", \"message\":\"새 알림이 도착했습니다.\"}";
session.sendMessage(new TextMessage(json));
- WebSocketSession : 클라이언트가 WebSocket으로 서버에 연결하면, 서버는 각 클라이언트마다 WebSocketSession 객체를 생성합니다. 클라이언트나 서버 어느 한 쪽이 연결을 끊을 때까지 유지됨.
- session.getId() : Spring이 연결된 WebSocket 세션마다 자동으로 부여하는 유일한 문자열 ID입니다
- 같은 사용자가 브라우저를 새로고침해도 다른 ID가 부여되므로 사용자 식별에는 적절하지 않고 userId로 관리하는게 일반적
- session.isOpen() : 연결이 아직 살아있는지 확인, 메시지 전송 전에 항상 체크
- session .sendMessage(WebSocketMessage<?> message) : 클라이언트로 메시지 전송, 메시지 push의 핵심
- session.getAttributes() : HandshakeInterceptor에서 세팅한 사용자 정보, 토큰 등 접근, 사용자 인증 후 세션에 저장한 값 읽기
- session. getUri() : 클라이언트가 접속한 URI (쿼리 파라미터 포함) Query로 token, roomId 등 받는 경우
- session.close() : 세션을 수동으로 종료 ; 예외 발생 시 서버에서 강제 종료 가능
- session.getId() : Spring이 연결된 WebSocket 세션마다 자동으로 부여하는 유일한 문자열 ID입니다
public class AuthHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ...) {
String token = request.getHeaders().getFirst("Authorization");
String userId = jwtProvider.extractUserId(token);
attributes.put("userId", userId); // 이후 세션에서 사용 가능
return true;
}
}
사용된 주요 메소드
interface WebSocketHandler
↑
abstract class AbstractWebSocketHandler
↑
class TextWebSocketHandler
@Component
public class WebsocketHandler extends TextWebSocketHandler {
private static final Map<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>();
우리가 구현하는 Websocket Handler는 Spring Boot의 TextWebSocketHandler를 상속받고 TextWebsocketHandler는 아래과 같은 계층구조를 가지고 있으며 내부의 별도의 구현이 없기때문에 필요한 기능을 선택적으로 Overriding해야함.
private final Map<String, WebSocketSession> pendingSessions = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) {
// 아직 userId 모름, 임시 등록
pendingSessions.put(session.getId(), session);
log.info("WebSocket connected. Waiting for INIT. sessionId={}", session.getId());
}
afterConnectionEstablished() : 웹소켓 연결이 열렸을 때 실행
- 클라이언트가 웹소켓에 연결되었을 때 자동으로 호출되는 메서드.
- Session 관리방식은 다양하게 구현할 수 있음.
- 최초 연결시 (ws://localhost:8080/notifications?userId=123)와 같이 param 값으로 식별자를 주면 String query = session.getUri().getQuery()를 활용해서 식별자를 추출하여 관리
- 최초 연결시에 pending session으로 관리하고 handleTextMessage 내에서 initial message가 왔을때 인증, 세션 등 metadata를 채워 넣는 방식
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
JsonNode payload = objectMapper.readTree(message.getPayload());
String type = payload.get("type").asText();
if ("INIT".equals(type)) {
String userId = payload.get("userId").asText();
String device = payload.get("device").asText();
WebSocketClientInfo clientInfo = new WebSocketClientInfo(session, userId, device);
// 등록: userId → clientInfo
clientSessionMap.put(userId, clientInfo);
// 정리: pending 세션에서 제거
pendingSessions.remove(session.getId());
log.info("INIT received. sessionId={}, userId={}, device={}", session.getId(), userId, device);
}
}
handleTextMessage() : 클라이언트가 WebSocket을 통해 텍스트 메시지를 보내면 자동으로 실행
afterConnectionClosed() : 클라이언트가 session을 종료했을때 자동으로 실행되는 콜백 메서드
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
pendingSessions.remove(session.getId());
// clientSessionMap에서 제거 (userId 기반이므로 반복 탐색 필요)
clientSessionMap.entrySet().removeIf(entry -> entry.getValue().getSession().getId().equals(session.getId()));
log.info("WebSocket disconnected. sessionId={}", session.getId());
}
HTTP 헤더로 토큰 전달
서버 측에서는 HandshakeInterceptor를 통해 헤더를 읽을 수 있습니다:
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry
.addHandler(chatHandler, "/ws/chat")
.addInterceptors(new AuthHandshakeInterceptor()) // 인증용 인터셉터
.setAllowedOrigins("*");
}
}
(3) 메세지 발송
특정한 도메인 이벤트나 조건이 발생하면, 서비스 계층에서 WebSocket 세션 맵(sessionMap)을 조회해서, 해당 사용자에게 메시지를 전송하는 구조가 일반적입니다.
흐름 구조 예시
- 어떤 이벤트 발생 : 알림 생성, 군중 밀집도 초과, 새로운 채팅 메시지 도착
- 서비스 로직에서 대상 사용자 식별 : 알림 대상 유저 ID, 채팅방 참가자 목록
- 세션 맵 조회
- 메시지 전송
public void sendNotification(String userId, String message) {
WebSocketSession session = userSessions.get(userId);
if (session != null && session.isOpen()) {
try {
session.sendMessage(new TextMessage(message));
} catch (IOException e) {
log.error("WebSocket 메시지 전송 실패", e);
}
} else {
log.warn("유저 {}는 연결되어 있지 않음", userId);
}
}
sendNotification() (특정 유저에게 실시간 메시지 전송)
- 특정 userId를 가진 사용자에게 실시간으로 메시지를 전송하는 메서드.
- userSessions.get(userId)를 이용하여 해당 유저의 웹소켓 세션을 가져옴.
- 세션이 열려 있다면 메시지를 전송함 (session.sendMessage(new TextMessage(message));).
public void broadCast(String message) {
sessionMap.values().forEach(session -> {
if(session.isOpen()) {
try {
session.sendMessage(new TextMessage(message));
} catch (IOException e) {
throw new RuntimeException("Unable to send message to websocket", e);
}
}else{
log.info("websocket connection closed");
sessionMap.remove(session.getId());
}
});
broadcast는 **현재 연결된 모든 WebSocket 클라이언트(세션)**에게 동시에 같은 메시지를 푸시하는 것을 의미합니다.
일반적으로는 Map을 순회하며 어떤 세션에 어떤 메세지를 보낼지 정한다.
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationWebSocketHandler handler;
public void notifyUser(Long userId, String message) {
try {
handler.sendNotification(String.valueOf(userId), message);
} catch (Exception e) {
log.error("WebSocket 메시지 전송 실패", e);
}
}
}
public void sendNotification(String userId, String message) {
WebSocketSession session = userSessions.get(userId);
if (session != null && session.isOpen()) {
session.sendMessage(new TextMessage(message));
}
}
더 복잡한 구조 예시: 특정 채팅방 참가자 전체에게 브로드캐스트
public void broadcastToRoom(String roomId, String message) {
List<String> userIds = chatService.getUserIdsByRoom(roomId);
for (String userId : userIds) {
WebSocketSession session = sessionMap.get(userId);
if (session != null && session.isOpen()) {
session.sendMessage(new TextMessage(message));
}
}
}
(4) 웹소켓 확장구조
기존에 단순히 session이라는 객체만 Map에 담았다면, WebsocketClientInfo라는 객체를 생성하여 다른 부가적인 메타데이터도 같이 관리하는 형태로 운용할 수 있음.
public class WebSocketClientInfo {
private String userId;
private String role;
private String status; // "active", "paused", 등
private WebSocketSession session;
// 생성자 + Getter/Setter
}
private static final Map<String, WebSocketClientInfo> sessionMap = new ConcurrentHashMap<>();
클라이언트 웹소켓 테스트
// WebSocket 연결
const socket = new WebSocket("ws://localhost:8080/notifications?userId=123");
socket.onopen = function() {
console.log("웹소켓 연결 성공!");
};
socket.onmessage = function(event) {
console.log("서버로부터 메시지 수신: ", event.data);
};
socket.onerror = function(error) {
console.log("웹소켓 에러 발생: ", error);
};
socket.onclose = function() {
console.log("웹소켓 연결 종료");
};
- "ws://localhost:8080/notifications?userId=123" :
- ws://localhost:8080/notifications → 로컬 개발 서버(localhost:8080)의 /notifications 엔드포인트에 연결합니다.
- ?userId=123 → 쿼리 파라미터를 통해 특정 유저의 ID를 서버에 전달합니다. (예: userId=123)
- 웹소켓에서 발생하는 이벤트를 처리하는 이벤트 리스너를 정의합니다.
- onopen - 연결 성공 :서버와 웹소켓 연결이 성공적으로 이루어졌을 때 실행됩니다.
- onmessage - 서버에서 메시지 수신 : event.data를 통해 서버가 보낸 데이터를 확인할 수 있습니다.
- onerror - 에러 발생 시 : 네트워크 문제 또는 서버의 예기치 않은 종료 등으로 인해 발생할 수 있습니다.