Spring

[Spring] Spring Boot WebSocket + STOMP 사용해서 단체 채팅 구현하기

경상도상남자 2025. 3. 31. 18:02

WebSocket + STOMP를 이용해서 단체 채팅방을 한번 구현해보자

 

웹 소켓 구현을 위한 클래스들 

1. WebSocketConfig

2. ChatWebSocketHandler

3. WebSocketStompBrokerConfig

4. ChatMessageDto

5. ChatController

 

 

1. WebSocketConfig 클래스

 

-  WebSocket 최초 연결을 위해 구성하는 환경 구성 파일 클래스

 

 

1. @EnableWebSocket

- WebSocket을 활성화하고 @Configuration 어노테이션을 통해 환경파일임을 지정한다.

- 이 어노테이션을 사용하면 Spring 애플리케이션에서 WebSocket 기능을 사용할 수 있다.

 

2. WebSocketConfigurer(interface)

- WebSocketConfigurer 인터페이스로부터 구현체 registerWebSocketHandlers 메서드를 구성한다.

- 이 인터페이스를 구현하면 registerWebSocketHandlers 메서드를 통해 WebSocket 핸들러를 등록할 수 있다.

 

3. registerWebSocketHandlers()

- WebSocketConfigurer 인터페이스로부터 오버라이딩 받은 WebSocket 핸들러를 구성한다.
- 클라이언트에서 /ws-stomp 경로로 WebSocket 연결을 시도하면 ChatWebSocketHandler으로 연결을 처리하게 핸들러를 등록한다.

 

package com.chat.config;

import com.chat.handler.ChatWebSocketHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.*;

/**
 *  Spring에서 WebSocket 구성을 위한 클래스
 */

@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final ChatWebSocketHandler chatWebSocketHandler;

    // WebSocket 연결을 위해서 Handler을 구성
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        System.out.println("[+] 최초 WebSocket 연결을 위한 등록 Handler");
        registry
                // 클라이언트에서 웹 소켓 연결을 위해 "chat-room"라는 앤드포인트로 연결을 시도하면
                // ChatWebSocketHandler 클래스에서 이를 처리한다.
                .addHandler(chatWebSocketHandler, "chat-room")
                .setAllowedOrigins("*"); // 접속 시도하는 모든 도메인 IP에서 webSocket 연경을 허용
    }

}

 


2. ChatWebSocketHandler

- WebSocket 연결 이후 연결을 처리하는 핸들러를 의미

- TextWebSocketHandler를 상속받아서 최초 소켓 세션을 연결하고 소켓/ 전송 오류가 발생했을 때 및 텍스트 기반 메시지를 보내거나 받을 수 있도록 처리가 가능하게 함

 

1. afterConnectionEstablished() : 연결 성공
- WebSocket 협상이 성공적으로 완료되고 WebSocket 연결이 열려 사용할 준비가 된 후 호출됩니다. 성공을 하였을 경우 session 값을 추가합니다.

2. handleTextMessage() : 메시지 전달
- 새로운 WebSocket 메시지가 도착했을 때 호출됩니다.전달 받은 메시지를 순회하면서 메시지를 전송합니다.
- message.getPayload()를 통해 메시지가 전달이 됩니다.

3. afterConnectionClosed() : 소켓 종료 및 전송 오류
- WebSocket 연결이 어느 쪽에서든 종료되거나 전송 오류가 발생한 후 호출됩니다.
- 종료 및 실패하였을 경우 해당 세션을 제거합니다.

 

package com.chat.handler;

import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 텍스트 기반의 WebSocket 메시지를 처리를 수행하는 Handler 입니다.
 */
@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {

    // WebSocket Session들을 관리하는 리스트입니다.
    private static ConcurrentHashMap<String, WebSocketSession> clientSession = new ConcurrentHashMap<>();

    /**
     * [연결 성공] WebSocket 협상이 성공적으로 완료되고 WebSocket 연결이 열려 사용할 준비가 된 후 호출합니다.
     * - 성공을 하였을 경우 session 값을 추가합니다.
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("[*] afterConnectionEstablished ::" + session.getId());
        clientSession.put(session.getId(), session);
    }

    /**
     * [메시지 전달] 새로운 WebSocket 메시지가 도착했을 때 호출됩니다.
     * - 전달 받은 메시지를 순회하면서 메시지를 전송합니다.
     * - message.getPayload()를 통해 메시지가 전달이 됩니다.
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        System.out.println("[*] handleTextMessage :: " + session);
        System.out.println("[*] handleTextMessage :: " + message.getPayload());

        clientSession.forEach((key, value) -> {
            System.out.println("key :: " + key + " value :: " + value);
            if (!key.equals(session.getId())) { // 같은 아이디가 아니면 메시지를 전달합니다.
                try {
                    value.sendMessage(message);
                } catch (IOException e){
                    e.printStackTrace();
                }
            }
        });
    }

    /**
     * [소켓 종료 및 전송 오류] WebSocket 연결이 어느 쪽에서든 종료되거나 전송오류가 발생한 후 호출됨
     * - 종료 및 실패하였을 경우 해당 세션 제거
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        clientSession.remove(session.getId());
        System.out.println("[*] afterConnectionClosed :: " + session.getId());
    }
}

 


3. WebSocketStompBrokerConfig

- WebSocket 및 STOMP 메시징 처리를 구현하는 WebSocketMessageBrokerConfigurer 인터페이스 구현체

- 이를 통해 WebSocket 통신을 위한 다양한 설정을 구성할 수 있다.

 

1. @EnableWebSocketMessageBroker

- WebSocket 메시지 브로커를 활성화합니다. 이 애노테이션을 사용하면, 해당 클래스는 WebSocket 메시징을 위한 설정을 할 수 있습니다.

2. WebSocketMessageBrokerConfigurer

- WebSocket 연결을 처리하는 핸들러를 의미합니다. TextWebSocketHandler를 상속하여 ‘텍스트 기반 메시지’를 보내거나 받을 수 있도록 처리가 가능합니다.

3. configureMessageBroker(MessageBrokerRegistry config)

- 메시지 브로커를 구성하는 메서드로, 메시지 브로커가 특정 목적지로 메시지를 라우팅 하는 방식을 설정합니다.
- enableSimpleBroker() 메서드를 통해 접두사를 지정하여 클라이언트가 접두사로 시작하는 주제를 “구독(Sub)”하여 메시지를 받을 수 있습니다.
- setApplicationDestinationPrefixes() 메서드를 통해 접두사로 시작하는 클라이언트가 서버로 메시지를 “발행(Sub)” 이 접두사를 사용합니다.

4. registerStompEndpoints()

- STOMP(WebSocket 메시지 브로커 프로토콜) 엔드포인트를 등록하는 메서드로, 클라이언트가 WebSocket에 연결할 수 있는 엔드포인트를 정의합니다.

- addEndpoint() : 클라이언트가 WebSocket에 연결하기 위한 엔드포인트를 "/ws-stomp"로 설정합니다.
- setAllowedOrigins() : 클라이언트의 origin을 명시적으로 지정합니다.
- withSockJS() :WebSocket을 지원하지 않는 브라우저에서도 SockJS를 통해 WebSocket 기능을 사용할 수 있게 합니다.

 

package com.chat.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;

/**
 * STOMP를 사용하여 메시지 브로커를 설정
 * WebSocket 메시지 브로커의 설정을 정의하는 메서드를 제공,
 * 이를 통해 메시지 브로커를 구성하고 STOMP 엔트포인트를 등록할 수 있음
 */

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketStompBrokerConfig implements WebSocketMessageBrokerConfigurer {

    /**
     * configureMessageBroker(): 메시지 브로커 옵션을 구성합니다.
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // 구독(sub) : 접두사로 시작하는 메시지를 브로커가 처리하도록 설정
        //            클라이언트는 이 접두사로 시작하는 주제를 구독하여 메시지를 받음
        // 예) 소켓 통신에서 사용자가 특정 메시지를 받기 위해 "/sub" 이라는 prefix 기반 메시지 수신을 위해 Subscribe 함
        config.enableSimpleBroker("/sub");

        // 발행(pub) : 접두사로 시작하는 메시지는 @MessageMapping이 달린 메서드로 라우팅됩니다.
        // 예) 소켓 통신에서 사용자가 특정 메시지를 전송하게 위해 "/pub"라는 prefix 기반 메시지 전송을 위해 Publish 합니다.
        config.setApplicationDestinationPrefixes("/pub");
    }

    /**
     * registerStompEndpoints() : 각각 특정 URL에 매핑되는 STOMP 엔드포인트를 등록하고
     *                            선택적으로 SockJS 플백 옵션을 활성화하고 구성
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // addEndpoint : 클라이언트가 WebSocket에 연결하기 위한 엔드포인트를 "/ws-stomp"로 설정
        // withSockJS : WebSocket을 지원하지 않는 브라우저에서도 SocketJS를 통해 WebSocket 기능을 사용할 수 있게 합니다.

        registry
                // 클라이언트가 WebSocket에 연결하기 위한 엔드포인트를 "/ws-stomp"로 설정합니다.
                .addEndpoint("/ws-stomp")
                // 클라이언트의 origin을 명시적으로 지정
                .setAllowedOrigins("http://localhost:3000","http://localhost:3001")
                // WebSocket을 지원하지 않는 브라우저에서도 SockJS를 통해 WebSocket 기능을 사용
                .withSockJS();
    }

}

 

 

4. ChatMessageDto

- 클라이언트와 데이터를 주고 받기 위해 구성한 DTO

 

import java.time.LocalDateTime;

public record ChatMessageDto(
    int fundingId,
    int senderId,
    String nickname,
    String content,
    String status, // ex: SENT, DELIVERED
    LocalDateTime createdAt
) {}

 


5. ChatController

 

-  STOMP르 사용하여 메시지를 처리하는 컨트롤러

- @MessageMapping를 통해서 메시지를 틀정 경로로 돌아오는 메시즈를 처리

 

발행자(Publisher) 

- 메시지를 전송하는 역할을 수행

- 엔드 포인트 : /pub/chat/{fundingId}

 

구독자(Subscriber)

- 메시지를 수신하는 역할을 수행

- 엔드포인트: /sub/message

 

import com.chat.dto.ChatMessageDto;
import com.chat.repository.ChatMessageRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
public class ChatController {

    // 특정 사용자에게 메시지를 보내는데 사용되는 STOMP을 이용한 템플릿
    private final SimpMessagingTemplate template;
    private final ChatMessageRepository chatMessageRepository;

    //  엔드 포인트로 데이터와 함께 호출하면 "sub/chat/{fundingId}" 를 수신하는 사용자에게 메세지를 전달합니다.
    @MessageMapping("/chat/{fundingId}")
    public ChatMessageDto send2(@DestinationVariable(value = "fundingId") int fundingId, ChatMessageDto chatMessageDto){

        log.info("MessageDto: {}", chatMessageDto);
        template.convertAndSend("/sub/chat/" + fundingId , chatMessageDto);
        return chatMessageDto;
    }

}

 


React 앱 내에서 메시지를 전송

 

소켓 서버 연결

- 엔드포인트 : http://localhost:8087/chat-room

- 소켓 서버에 연결을 하는데 사용이 되는 엔드포인트

 

메시지 전송

- 엔드포인트 : /pub/chat/{fundingId}

- 메시지는 전송하기 위해 사용되는 앤드포인트

 

메시지 수신

- 엔드포인트 : /sub/chat/{fundingId}

- 메시지를 수신하기 위해 사용되는 엔드포인트

 

 

controller에 log로 dto 수신 출력 확인

 


 

 

💡 채팅 시스템의 흐름이 어떻게 될까??

 

전체 적인 흐름도

[Client1]  → ws://localhost:8087/chat-room (SockJS) ----------- WebSocket 연결 완료
   ↓
subscribe("/sub/chat/1")  ← ✅ 먼저 구독
   ↓
send("/pub/chat/1", "안녕") → 서버
   ↓
서버: convertAndSend("/sub/chat/1", "안녕")
   ↓
[Client1] → 메시지 받음  ✅
[Client2] → 메시지 받음  ✅ (구독 중이라면)

 

 

💡 핵심 개념: pub, sub는 prefix이지, 실제 URL은 아니다!!

 

 

👉 prefix는 STOMP 메시지 경로에서 의미적인 역할을 하는 접두어

 

prefix의 역할 

 

/pub : 클라이언트가 메시지를 서버로 보낼 때 사용 (→ @MessageMapping)

 

config.setApplicationDestinationPrefixes("/pub");
  • 이건 “클라이언트가 보내는 메시지 중 /pub로 시작하는 경로는 Spring에서 처리해줄게” 라는 뜻이다.
  • 즉, 클라이언트가 /pub/chat/1으로 보냈으면, Spring은 내부적으로 chat/1을 꺼내서
    → @MessageMapping("/chat/{id}")가 붙은 메서드와 매핑시킴.
@MessageMapping("/chat/{fundingId}")
    public ChatMessageDto send2(@DestinationVariable(value = "fundingId") int fundingId, ChatMessageDto chatMessageDto){

 

 

/sub : 클라이언트가 서버로부터 메시지를 받을 때 사용 (→ convertAndSend)

 

template.convertAndSend("/sub/chat/" + fundingId , chatMessageDto);

 

서버는 이 구독 경로로 메시지를 브로드캐스트 한다.

- /sub/chat/1을 구독하고 있는 모든 유저에게 메시지를 보냄

 

브로드캐스트(Broadcast)

- 하나의 메시지를 여러 명에게 동시에 보내는 것

- 쉽게 말해, '전체 공지' 같은 개념

 

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketStompBrokerConfig implements WebSocketMessageBrokerConfigurer {

    /**
     * configureMessageBroker(): 메시지 브로커 옵션을 구성합니다.
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // 구독(sub) : 접두사로 시작하는 메시지를 브로커가 처리하도록 설정
        //            클라이언트는 이 접두사로 시작하는 주제를 구독하여 메시지를 받음
        // 예) 소켓 통신에서 사용자가 특정 메시지를 받기 위해 "/sub" 이라는 prefix 기반 메시지 수신을 위해 Subscribe 함
        config.enableSimpleBroker("/sub");

        // 발행(pub) : 접두사로 시작하는 메시지는 @MessageMapping이 달린 메서드로 라우팅됩니다.
        // 예) 소켓 통신에서 사용자가 특정 메시지를 전송하게 위해 "/pub"라는 prefix 기반 메시지 전송을 위해 Publish 합니다.
        config.setApplicationDestinationPrefixes("/pub");
    }

 

 

출처

이분을 도움을 많이 받았습니다. 강추드립니다.

https://adjh54.tistory.com/573

 

[Java] Spring Boot WebSocket + STOMP 이해하고 구성하기 -1: 초기 구성 및 간단 소켓 연결

해당 글에서는 Spring Boot 기반 WebSocket에 대해 이해하고 초기 설정 이후 WebScocket에 연결하는 방법에 대해 알아봅니다  1) Spring Boot WebSocket💡 Spring Boot WebSocket- Spring Framework에서 제공하는 기능으로

adjh54.tistory.com

 

'Spring' 카테고리의 다른 글

[Spring] Apache Kafka 이해하기  (0) 2025.04.01
[Spring] Spring Boot WebSocket + STOMP 구성요소  (0) 2025.03.30