개요

WebSocket은 HTTP와 달리 양방향 통신을 지원하는 프로토콜로, 실시간 채팅, 알림, 협업 도구 등에 필수적입니다. Spring Framework는 STOMP(Simple Text Oriented Messaging Protocol)를 통해 WebSocket 위에서 메시징 패턴을 구현할 수 있도록 지원합니다. 이 문서에서는 Spring Boot 3.5 기반 실시간 채팅 시스템 구축 방법을 다룹니다.

WebSocket 설정

먼저 Spring WebSocket 의존성을 추가하고 STOMP 엔드포인트를 설정합니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 클라이언트가 구독할 prefix
        registry.enableSimpleBroker("/topic", "/queue");
        // 클라이언트가 메시지 전송 시 사용할 prefix
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS(); // SockJS fallback 지원
    }
}

메시지 모델과 컨트롤러

채팅 메시지를 처리할 도메인 모델과 컨트롤러를 작성합니다.

public record ChatMessage(
    String type,      // JOIN, CHAT, LEAVE
    String content,
    String sender,
    LocalDateTime timestamp
) {}

@Controller
public class ChatController {

    @MessageMapping("/chat.sendMessage")
    @SendTo("/topic/public")
    public ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
        return new ChatMessage(
            chatMessage.type(),
            chatMessage.content(),
            chatMessage.sender(),
            LocalDateTime.now()
        );
    }

    @MessageMapping("/chat.addUser")
    @SendTo("/topic/public")
    public ChatMessage addUser(
        @Payload ChatMessage chatMessage,
        SimpMessageHeaderAccessor headerAccessor
    ) {
        // 세션에 사용자명 저장
        headerAccessor.getSessionAttributes()
            .put("username", chatMessage.sender());

        return new ChatMessage(
            "JOIN",
            chatMessage.sender() + "님이 입장했습니다.",
            chatMessage.sender(),
            LocalDateTime.now()
        );
    }
}

이벤트 리스너로 연결 관리

사용자 입장/퇴장 이벤트를 처리하는 리스너를 구현합니다.

@Component
@RequiredArgsConstructor
public class WebSocketEventListener {

    private final SimpMessageSendingOperations messagingTemplate;

    @EventListener
    public void handleWebSocketConnectListener(SessionConnectedEvent event) {
        log.info("새로운 WebSocket 연결: {}", event.getMessage());
    }

    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor =
            StompHeaderAccessor.wrap(event.getMessage());

        String username = (String) headerAccessor
            .getSessionAttributes()
            .get("username");

        if (username != null) {
            ChatMessage chatMessage = new ChatMessage(
                "LEAVE",
                username + "님이 퇴장했습니다.",
                username,
                LocalDateTime.now()
            );

            messagingTemplate.convertAndSend("/topic/public", chatMessage);
        }
    }
}

프론트엔드 연동 (JavaScript)

SockJS와 STOMP.js를 사용한 클라이언트 구현 예제입니다.

// npm install sockjs-client @stomp/stompjs

let stompClient = null;
let username = null;

function connect() {
    username = document.querySelector('#username').value.trim();

    const socket = new SockJS('/ws');
    stompClient = Stomp.over(socket);

    stompClient.connect({}, onConnected, onError);
}

function onConnected() {
    // 채팅방 구독
    stompClient.subscribe('/topic/public', onMessageReceived);

    // 입장 알림
    stompClient.send("/app/chat.addUser",
        {},
        JSON.stringify({ sender: username, type: 'JOIN' })
    );
}

function sendMessage() {
    const messageContent = document.querySelector('#message').value.trim();

    if (messageContent && stompClient) {
        const chatMessage = {
            sender: username,
            content: messageContent,
            type: 'CHAT'
        };

        stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(chatMessage));
    }
}

function onMessageReceived(payload) {
    const message = JSON.parse(payload.body);

    const messageElement = document.createElement('li');
    if (message.type === 'JOIN') {
        messageElement.classList.add('event-message');
        messageElement.textContent = message.content;
    } else if (message.type === 'LEAVE') {
        messageElement.classList.add('event-message');
        messageElement.textContent = message.content;
    } else {
        messageElement.classList.add('chat-message');
        messageElement.textContent = message.sender + ': ' + message.content;
    }

    document.querySelector('#messageArea').appendChild(messageElement);
}

활용 팁

  • 보안 강화: Spring Security와 통합하여 JWT 기반 WebSocket 인증을 구현할 수 있습니다.
  • 메시지 영속화: MongoDB나 Redis를 연동해 채팅 히스토리를 저장하고 재접속 시 로드할 수 있습니다.
  • 스케일 아웃: 여러 서버 인스턴스 환경에서는 Redis Pub/Sub 또는 RabbitMQ를 External Broker로 사용해야 합니다.
  • 개인 메시지: /queue/private-{userId} 형태로 구독하여 1:1 DM 기능을 구현할 수 있습니다.
  • 읽음 확인: 메시지마다 고유 ID를 부여하고, 클라이언트에서 읽음 ACK를 전송하는 방식으로 구현 가능합니다.

마무리

Spring WebSocket과 STOMP는 복잡한 WebSocket 프로토콜을 추상화하여 선언적 방식으로 실시간 통신을 구현할 수 있게 합니다. 채팅뿐만 아니라 실시간 알림, 협업 편집기, 게임 서버 등 다양한 실시간 애플리케이션에 활용할 수 있습니다. 프로덕션 환경에서는 반드시 Redis나 RabbitMQ 같은 외부 메시지 브로커를 사용하여 수평 확장을 고려해야 합니다.