[실시간 채팅] 풀링 → WebSocket & STOMP

림민지·2025년 9월 10일

Today I Learn

목록 보기
62/62

*본 글은 회사의 코드와 연관이 없으며, 업무 외의 시간에 공부 목적으로 쓴 글입니다

변경한 이유

기존 풀링 방식에서는 5초에 한 번씩 땡겨서 사용했음
→ 실시간으로 채팅이 막 오면 동시성을 보장하기 어렵고
바로바로 채팅이 반영이 안되니까 사용자 사용성이 매우매우 떨어짐
또한 채팅을 이용하는 사람 수와 채팅방이 많아지면 서버에 부하가 매우 많이 걸릴 것이라고 예상함
기능자체는 잘 되지만, 성능면에서 매우 비효율적임

기존 코드 분석 (Polling 방식의 흐름)

  1. 메시지 전송 (POST /api/chat/send): 클라이언트가 메시지를 보내면, 서버는 DB에 저장하고 저장된 메시지 객체를 그대로 클라이언트에게 응답합니다.
  2. 메시지 조회 (GET /api/chat/messages/{courseId}): 클라이언트는 useEffect 안에서 setInterval을 사용해 5초마다 이 API를 호출합니다. 서버는 DB에서 최신 메시지 목록을 조회해 전달합니다.
  3. 결과: 클라이언트는 5초마다 채팅 목록 전체를 새로고침하며 실시간인 것처럼 보이게 합니다.

WebSocket & STOMP 도입의 이점

1. 완벽한 실시간 통신
WebSocket은 서버와 클라이언트 간에 하나의 연결 통로(TCP 연결)를 계속 열어두고, 양방향으로 원할 때 언제든지 데이터를 주고받을 수 있다
서버에 새로운 메시지가 오면, 서버가 즉시 클라이언트로 메시지를 밀어주는 방식이므로 실시간 반영이 가능!

2. 서버 부하 감소
한번 연결을 맺으면 불필요한 요청을 반복하지 않는다
-> 폴링 방식에 비해 서버 자원 낭비를 크게 줄일 수 있음

3. 메시징 프로토콜(STOMP)
WebSocket은 통신 통로일 뿐, 그 안에서 어떤 형식으로 데이터를 주고받을지에 대한 규칙은 없다
STOMP를 함께 사용하면 메시지의 구독-발행 모델을 쉽게 구현할 수 있음

구현

0. 환경

  • 스프링 시큐리티
  • jdk 21

1. 의존성 추가 & 웹소켓 설정파일 생성 (WebsocketConfig)

implementation 'org.springframework.boot:spring-boot-starter-websocket'
  • /topic
    메시지 구독을 위한 경로
    서버가 클라이언트들에게 정보를 뿌릴 때(Broadcasting) 사용
  • /app: 클라이언트가 서버로 메시지를 발행할 때 사용하는 경로
    /app/chat/send
  • /ws-chat
    클라이언트가 맨 처음 웹소켓 연결을 시도할 주소
// src/main/java/com/yourproject/config/WebSocketConfig.java
package com.yourproject.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker // STOMP 사용을 위한 어노테이션
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 1. 구독자에게 메시지를 보낼 때 사용할 경로의 prefix 설정
        // "/topic"으로 시작하는 경로를 구독하는 클라이언트에게 메시지를 보냄
        registry.enableSimpleBroker("/topic");

        // 2. 클라이언트가 서버로 메시지를 보낼 때 사용할 경로의 prefix 설정
        // "/app"으로 시작하는 경로로 메시지를 보내면 @MessageMapping이 붙은 메소드로 라우팅됨
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 3. 클라이언트가 웹소켓에 연결하기 위한 최초의 경로(endpoint) 설정
        // 예: const client = new Stomp.Client({ brokerURL: 'ws://localhost:8080/ws-chat' });
        registry.addEndpoint("/ws-chat")
                .setAllowedOriginPatterns("*") // CORS 문제 해결을 위해 Origin 패턴 설정
                .withSockJS(); // SockJS 사용 설정 (웹소켓 미지원 브라우저 호환)
    }
}

2. 컨트롤러 구성

스프링 시큐리티를 사용하고 있으므로 토큰에서 역할과 이름을 파싱해와야함.
→ 인증객체에서 유저 정보를 가져와서 덮어쓰기

// ChatSocketController.java 

@Controller
@RequiredArgsConstructor
public class ChatSocketController {
    private final ChatMessageRepository chatMessageRepository;

    // 1. 핵심 비즈니스 로직
    @MessageMapping("/chat/send/{courseId}")
    @SendTo("/topic/chat/room/{courseId}")
    public ChatMessage sendMessage(
            @DestinationVariable Long courseId,
            @Payload ChatMessage chatMessage,
            Authentication auth
    ) {
        String username = auth.getName();
        String role = auth.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .findFirst()
                .orElse("ROLE_STUDENT")
                .replace("ROLE_", "");
        chatMessage.setUsername(username);
        chatMessage.setRole(role);
        chatMessage.setCourseId(courseId);

        return chatMessageRepository.save(chatMessage);
    }

    // 2. 예외 처리 전담 메소드
    @MessageExceptionHandler
    @SendToUser("/topic/errors")
    public String handleException(Throwable exception) {
        // 여기서 예외를 로깅하거나, 클라이언트에게 보낼 에러 메시지를 가공
        System.err.println("An error occurred: " + exception.getMessage());
        return "오류가 발생했습니다: " + exception.getMessage();
    }
}
profile
@lim_128

0개의 댓글