WebSocket
웹에서는 보통 HTTP를 활용해 데이터를 주고 받는다. 하지만 실시간 요청을 처리할 때는 HTTP Polling이라는 방식으로 몇초마다 서버에 GET요청을 보내고 새로운 메시지가 있는지 확인하는 방식으로 실시간 요청을 처리했다. 이 방식은 간단하지만
- 클라이언트가 먼저 요청하지 않으면 응답을 받을 수 없다.
- 요청마다 연결을 새로 맺고, 헤더가 매우 무겁다.
- 불필요한 요청이 많아지고, 서버 리소스가 많이 낭비된다.
그래서 이러한 방식은 보완한것이 WebSocket방식(양방향 통신 프로토콜)이다.
HTTP는 기본적으로 Request -> Response이라는 단방향 통신이지만, WebSocket은 한 번 연결하면 서버와 클라이언트가 자유롭게 메시지를 주고받을 수 있다.
실제로 WebSocket은 처음에 HTTP 프로토콜을 통해 "업그레이드 요청"을 보낸 후, 서버가 이를 수락하면 연결 상태가 WebSocket으로 전환된다. 이 과정을 handshake라고 하며, 이후에는 HTTP가 아닌 WebSocket 전용 프레임 프로토콜로 통신이 이뤄진다.
통신 계층
전송 계층 | TCP, UDP |
인터넷 계층 | IP |
링크 계층 | Ethernet 등 |
애플리케이션 계층 | HTTP, WebSocket, FTP, SMTP 등 |
통신 계층에서 WebSocket은 HTTP와 같이 애플리케이션 계층에 속한다. 그래서 WebSocket도 TCP위에서 이루어진다.
WebSocket은 처음부터 WebSocket으로 연결할 수 없다. 브라우저가 서버에 통신을 요청할 때 처음에 HTTP 요청을 먼저 보낸다.
const socket = new WebSocket("ws://localhost:8080/ws-chat");
이 코드가 실행되면 브라우저는 서버로 아래와 같은 HTTP 요청을 보낸다
GET /ws-chat HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ...
http를 먼저 보내서 WebSocket으로 업그레이드 하겠다는 내용이다.
PureWebsocket
WebSocketConfig.java
"/ws-chat" 경로로 http요청을 감지하고 ChatWebSocketHandler 클래스에게 연결을 넘겨준다.
이 부분에서 HTTP -> WebSocket 전환이 결정된다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
서버가 클라이언트에게 이런 응답을 보내면서 이 이후로는 WebSocket 전용 프로토콜로 동작한다.
ChatWebSocketHandler.java
private final Set<WebSocketSession> sessions = Collections.synchronizedSet(new HashSet<>());
private final Map<String, Set<WebSocketSession>> rooms = new ConcurrentHashMap<>();
private final ObjectMapper objectMapper = new ObjectMapper();
- 연결된 사용자를 모두 관리하기한 Session set
- rooms를 통해 사용자 그룹핑
- jsons -> java객체로 바꾸기 위한 ObjectMapper
Spring WebSocket은 클라이언트가 연결을 요청할 때마다 Session을 하나씩 생성한다. 생성된 WebSocketSession을 네가 만든 ChatWebSocketHandler의 afterConnectionEstablished(WebSocketSession session)에 넘겨준다.
//클라이언트가 웹소켓 서버에 접속했을 때 호출
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
super.afterConnectionEstablished(session);
sessions.add(session);
System.out.println("접속된 클라이언트 세션 ID = "+session.getId());
}
이 session은 이미 Spring이 내부적으로 클라이언트 IP, 헤더 등 StandardWebSocketSession이라는 구현체로 연결된 TCP 소켓 기반으로 만들어 놓았다.
WebSocket이 연결된 후 클라이언트는 아래와 같은 양식으로 메시지를 전달한다.
socket.send(JSON.stringify({
roomId: "room1",
from: "sanghwa",
message: "안녕하세요"
}));
//클라이언트가 보낸 메세지를 서버가 받았을 때 호출
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
super.handleTextMessage(session, message);
//json 문자열 -> 자바 객체
ChatMessage chatMessage = objectMapper.readValue(message.getPayload(), ChatMessage.class);
String roomID = chatMessage.getRoomId(); // 클라이언트에게 받은 메세지에서 roomID를 추출
if (!rooms.containsKey(roomID)){ //방을 관리하는 객체에 현재 세션이 들어가는 방이 있는지 확인
rooms.put(roomID, ConcurrentHashMap.newKeySet()); // 없으면 새로운 방을 생성
}
rooms.get(roomID).add(session); // 해당 방에 세션 추가
for (WebSocketSession s : rooms.get(roomID)){
// for (WebSocketSession s : sessions) {
if (s.isOpen()){
// 자바 객체 -> json 문자열
s.sendMessage(new TextMessage(objectMapper.writeValueAsString(chatMessage)));
System.out.println("전송된 메세지 = "+chatMessage.getMessage());
}
}
}
클라이언트가 받은 json 형식의 메시지를 ObjectMapper를 이용해 java 객체(ChatMessage)로 역직렬화한다.
RoomID를 추출해 그 룸에 있는 클라이언트(세션을 통해 확인)들에게 ChatMessage를 모두 json으로 직렬화해서 전송한다.
//클라이언트의 연결이 끊어졌을 때 호출
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
super.afterConnectionClosed(session, status);
sessions.remove(session);
//연결이 해제되면 소속되어있는 방에서 제거
for (Set<WebSocketSession> room : rooms.values()){
room.remove(session);
}
}
WebSocket은 브라우저를 닫거나 네트워크가 끊기면 연결이 종료된다. 이 때, Spring이 자동으로 afterConnectionClosed가 호출된다.
일단 세션목록에서 그 클라이언트를 제거 한 후에, 참여하고 있는 모든 방에서 세션을 제거한다.
StompWebsocket
순수 WebSocket 방식은 실시간 통신이 가능하지만, 다음과 같은 단점이 있다.
- 클라이언트와 서버가 어떤 경로(채팅방, 귓속말 등)로 메시지를 주고받는지 명확한 라우팅 구조가 없다.
- WebSocket은 기본적으로 단일 연결 경로(예: /ws-chat)만 제공하기 때문에 만약 채팅방을 여러개 만들고 싶은 경우, 각 채팅방마다 Map<String, Set<WebSocketSession>> rooms = new ConcurrentHashMap<>(); 이러한 형태로 관리해야 한다.
- 확장성, 유지보수 관리, 메시지 분기 측면에서 매우 복잡하고 문제가 많다.
- 실시간 메시지를 여러 서버 인스턴스에서 처리하려면 Redis나 브로커를 직접 구현해야 한다.
- WebSocket은 메시지를 서버에 보내면 그 서버의 메모리에 세션을 생성한다. 그렇기 때문에 서버 인스턴스 분리시 다른 서버는 그 세션의 존재 유무 및 내용에 대해서 모른다.
이러한 문제점을 해결하기 위해 Spring에서는 STOMP 프로토콜 + 메시지 브로커(SimpleBroker, Redis 등) 조합을 지원한다.
WebSocketConfig.java(Stomp)
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
//Prefix <- 메세지의 목적지를 구분하기 위한 접두어
/** 구독용 Prefix **/
// /topic 일반 채팅을 받을 접두어
// /queue 귓속말을 받을 접두어
/** 서버가 보내는 메시지를 클라이언트가 구독할 때 사용하는 경로 **/
registry.enableSimpleBroker("/topic","/queue"); // 구독용 경로
/** 전송용 Prefix **/
// 클라이언트가 서버에게 메세지를 보낼 접두어
/**클라이언트가 서버에 메시지를 보낼 때 사용하는 경로 접두어 -> @MessageMapping **/
registry.setApplicationDestinationPrefixes("/app"); // 클라이언트 -> 서버
// /user 특정 사용자에게 메세지를 보낼 접두어
/** 서버가 특정 사용자에게 메시지를 보낼 때, 클라이언트가 구독할 경로 접두어 **/
registry.setUserDestinationPrefix("/user"); // 서버 -> 특정 사용자
}
@EnableWebSocketMessageBroker는 STOMP를 사용하는 WebSocket 메시징을 활성화한다.
- /app 접두어로 들어오는 메시지는 컨트롤러(@MessageMapping)로 라우팅됨
- /topic, /queue, /user로 시작하는 경로는 브로커를 통해 클라이언트로 전달됨
방향 | 설명 | 예시 경로 |
클라이언트 → 서버 | 클라이언트가 서버에게 메시지를 보냄 | /app/xxx (→ @MessageMapping) |
서버 → 클라이언트 | 서버가 메시지를 클라이언트에게 보냄 (브로커 경유) | /topic/xxx, /queue/xxx, /user/xxx |
Spring은 내부에 SimpleBroker라는 가벼운 메시지 브로커를 내장하고 있다. 이 Broker가 서버가 메시지를 발행하면, 누가 구독하고 있는지 보고 해당 클라이언트에게 메시지를 전달한다.
- /topic
- 다수에게 메시지를 브로드캐스트할 때 사용
- 예: /topic/room1
- /queue
- 1:1 메시지 전달(큐 기반의 개인 메시지), 주로 귓속말 등에 사용
- 기본적으로 1명의 클라이언트가 받을 것이라 기대하는 메시지
- /user
- 특정 사용자에게 메시지를 전송할 때 사용
- Spring에서 자동으로 세션 ID 또는 사용자 이름 기반으로 라우팅해줌
Spring에서의 구성도
1. 클라이언트 → 서버
stompClient.send("/app/chat.sendMessage", {}, JSON.stringify({
from: "sanghwa",
message: "안녕하세요",
roomId: "room1"
}));
- 클라이언트는 /app/chat.sendMessage처럼 보낸다.
- Spring의 @MessageMapping("chat.sendMessage")이 이를 처리한다.
2. 서버 → 브로커
- 메시지를 처리한 후, 서버는 template.convertAndSend("/topic/room1", message)처럼 브로커에게 메시지를 전달한다.
3. 브로커 → 구독자
- 브로커는 /topic/room1을 구독하고 있던 모든 클라이언트에게 메시지를 전달한다.
ChatController.java
@Controller
@RequiredArgsConstructor
public class ChatController {
//서버가 클라이언트에게 수동으로 메세지를 보낼 수 있도록 하는 클래스
private final SimpMessagingTemplate template;
//동적으로 방 생성 가능
@Value("${PROJECT_NAME:web Server}")
private String instansName;
private final RedisPublisher redisPublisher;
private ObjectMapper objectMapper = new ObjectMapper();
@MessageMapping("/chat.sendMessage")
public void sendmessage(ChatMessage message) throws JsonProcessingException {
message.setMessage(instansName+" "+message.getMessage());
String channel = null;
String msg = null;
if (message.getTo() != null && !message.getTo().isEmpty()) {
// 귓속말
//내 아이디로 귓속말경로를 활성화 함
channel = "private."+message.getRoomId();
msg = objectMapper.writeValueAsString(message);
} else {
// 일반 메시지
channel = "room."+message.getRoomId();
msg = objectMapper.writeValueAsString(message);
}
redisPublisher.publish(channel,msg);
}
@MessageMapping("/chat.sendMessage")
- STOMP 메시지의 목적지를 설정한다.
- 클라이언트가 아래 코드와 같이 메시지를 보낸다.
stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(payload));
@MessageMapping("/chat.sendMessage") ←→ /app/chat.sendMessage
메시지에 있는 내용을 바탕으로 채널을 정하고(룸이 없다면 생성), ChatMessage 객체를 JSON 문자열로 변환하여 Redis에 발행한다.
RedisPublisher
RedisSubscriber
'멋사 부트캠프' 카테고리의 다른 글
[멋사 부트캠프] Day10 - ThreadLocal (0) | 2025.06.30 |
---|---|
[멋사 부트캠프] Day09 - 예외처리 및 로그분석(ELK) (0) | 2025.06.28 |
[멋사 부트캠프] Day08 - OAuth2 소셜 로그인 (0) | 2025.06.27 |
[멋사 부트캠프] Day04 - Gpt API (0) | 2025.06.20 |
[멋사 부트캠프] Day01 - 도커, 컨테이너 기술, 볼륨, 도커파일 (0) | 2025.06.17 |