본문 바로가기
Framework/Spring Boot

[Spring Boot] WebSocket 실시간 채팅: 양방향 통신의 서막

반응형

우리가 매일 사용하는 카카오톡이나 주식 거래 앱의 호가창은 어떻게 실시간으로 데이터가 번쩍번쩍 업데이트될까요? 초보 개발자들은 프론트엔드에서 setInterval() 함수를 써서 1초마다 백엔드에 "새로운 채팅 온 거 있어?"라고 묻는 폴링(Polling) 방식을 떠올립니다. 하지만 1,000명의 유저가 1초마다 빈 요청(HTTP)을 보내면 서버는 채팅을 하기도 전에 트래픽 폭탄을 맞고 터져버릴 것입니다. 웹의 근간인 HTTP는 본래 '단방향(클라이언트가 물어보면 서버가 대답하고 끊음)' 통신이기 때문입니다.

이 HTTP의 태생적 한계를 부수고, "클라이언트와 서버가 한 번 손을 잡으면(Handshake) 연결을 끊지 않고, 서버가 원할 때 언제든 클라이언트에게 데이터를 쏴줄 수 있는 고속도로"를 뚫어주는 혁명적인 프로토콜이 바로 WebSocket(웹소켓)입니다.

이번 [Spring Boot 실무 완벽 가이드] 시리즈에서는 "현대 실시간 서비스의 심장!" Spring Boot WebSocket의 기초 원리와 순수 웹소켓을 활용한 실시간 채팅 서버 구현에 대하여 다뤄보겠습니다.

🤝 1. 웹소켓 핸드셰이크(Handshake): HTTP의 화려한 변신

웹소켓은 완전히 새로운 프로토콜(ws://)이지만, 방화벽(80, 443 포트)을 무사히 통과하기 위해 영리한 꼼수를 씁니다. 처음 만날 때는 평범한 HTTP 요청인 척하며 서버에 접근합니다. 헤더에 Connection: UpgradeUpgrade: websocket이라는 비밀 암호를 적어서 보내면, 서버가 "오케이, 지금부터 HTTP 버리고 웹소켓 고속도로로 갈아타자!" (101 Switching Protocols)라고 응답하며 둘 사이의 영구적인 파이프가 뚫리게 됩니다. 이 과정을 핸드셰이크라고 부릅니다.

WebSocket 개념도

⚙️ 2. Spring Boot 순수 WebSocket 세팅하기

스프링 부트에서 웹소켓을 여는 것은 너무나도 쉽습니다. spring-boot-starter-websocket 의존성을 추가하고, 문지기(Handler)와 주소(Endpoint)를 설정해주면 됩니다.

// 1. WebSocket Handler: 실제 메시지가 도착했을 때 처리하는 로직
@Component
@Slf4j
public class ChatWebSocketHandler extends TextWebSocketHandler {

    // 접속한 클라이언트(브라우저)들의 세션을 모아두는 리스트 (단톡방 역할)
    private static final List<WebSocketSession> sessions = new ArrayList<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        sessions.add(session); // 누군가 접속하면 리스트에 추가
        log.info("새로운 클라이언트 접속! 세션 ID: {}", session.getId());
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload(); // 클라이언트가 보낸 텍스트 메시지
        log.info("수신된 메시지: {}", payload);
        
        // 브로드캐스팅(Broadcasting): 리스트에 있는 '모든' 사람에게 메시지를 똑같이 뿌려줍니다!
        for (WebSocketSession s : sessions) {
            s.sendMessage(new TextMessage("누군가의 말: " + payload));
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        sessions.remove(session); // 접속을 끊으면 리스트에서 제거
        log.info("클라이언트 접속 해제: {}", session.getId());
    }
}
// 2. WebSocket Config: "/ws/chat" 주소로 들어오면 위 핸들러로 연결해 주는 설정
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final ChatWebSocketHandler chatWebSocketHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(chatWebSocketHandler, "/ws/chat").setAllowedOrigins("*");
    }
}

💀 3. 순수 WebSocket의 치명적인 한계

와! 이제 채팅이 잘 됩니다. 그런데 방금 짠 코드를 실무에 배포하면 어떤 일이 벌어질까요?

  1. "A번 방에만 메시지 보내줘"가 안 됩니다: 위 코드는 그냥 List에 무식하게 다 때려 넣었기 때문에, 카카오톡처럼 단톡방을 1,000개로 쪼개려면 개발자가 직접 Map 자료구조로 Map<String, List<Session>> 처럼 방 관리 로직을 하드코딩해야 합니다.
  2. 메시지 규격(Format)이 없습니다: 그냥 단순한 String(문자열)만 날아다닙니다. "이게 입장 메시지인지, 채팅인지, 퇴장인지" 구분하려면 JSON을 뜯어서 if(type.equals("ENTER")) 같은 노가다를 해야 합니다.
  3. 서버가 2대면 채팅이 안 됩니다 (가장 치명적): 만약 서버를 2대로 늘렸을 때, 철수는 1번 서버에 접속하고 영희는 2번 서버에 접속했다면? 1번 서버의 sessions 리스트에는 철수만 있으므로 철수가 아무리 떠들어도 영희에게 전달되지 않습니다!

🎯 4. 마무리 및 다음 단계

지금까지 HTTP의 무거운 족쇄를 끊어내고 서버와 클라이언트 간의 실시간 양방향 고속도로를 개통하는 순수 WebSocket의 원리와 구현, 그리고 그 한계점에 대해 상세히 다루어 보았습니다. 웹소켓은 고속도로(전송 계층)일 뿐, 그 위로 어떤 자동차(메시지)가 다녀야 하는지 규칙은 없습니다.

방금 본 치명적인 문제점들을 해결하려면 어떻게 해야 할까요? 우편 배달부처럼 명확한 '봉투(규격)'와 '수신처(목적지)'를 가진 메시징 프로토콜을 웹소켓 위에 얹어야 합니다. 나아가 10대의 서버가 켜져 있어도 1번 서버의 채팅이 10번 서버까지 날아가게 해주는 거대한 '메시지 브로커(Message Broker)'가 필요합니다. 이어지는 32단계 포스팅에서는 "실무형 엔터프라이즈 채팅 아키텍처의 완성!" STOMP 프로토콜의 웅장한 라우팅 마법과 외부 브로커인 RabbitMQ 연동 실무 가이드에 대해 아주 뼈 때리게 파헤쳐 보겠습니다!

반응형