멋사 부트캠프

[멋사 부트캠프] Day03 - WebSocket, redis

sagecode 2025. 6. 20. 09:50

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