본문 바로가기
Spring

[Spring] WebSocket & STOMP 채팅 기능 구현하기 (1)

by 감자b 2025. 2. 25.

 

 

WebSocket & STOMP의 개념

Polling, Long Polling, SSE(Server Sent Event)HTTP의 이해HTTP란?클라이언트와 서버가 서로 데이터를 주고받기 위해 사용되는 통신 규약으로 다음과 같은 데이터 타입을 전송할 수 있다.HTML, TEXT이미지, 음성,

hbb-devlog.tistory.com

 

저번 글에서 WebSocket, STOMP의 개념에 대해서 간략히 살펴보았다.

이번 글에서는 해당 개념을 토대로 채팅 기능을 간단하게 구현해보려고 한다.

 

1. 스프링 부트는 공식적으로 WebSocket, STOMP를 지원하는데 이를 사용하기 위해 의존성을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-websocket'

 

 

2.  웹소켓을 활성화시키기 위한 WebSocketConfig 작성

@RequiredArgsConstructor
@Configuration
@EnableWebSocketMessageBroker //WebSocket 서버를 활성화하고, 메시지 브로커를 사용할 수 있도록 한다.
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		// 스프링의 인메모리 기반 메시지 브로커를 사용한다는 설정
		// 클라이언트가 메시지를 구독할 때 사용할 접두사를 /subscribe 설정
		registry.enableSimpleBroker("/subscribe");

		// 클라이언트가 메시지를 서버로 보낼 때 사용할 접두사를 /publish 설정
		registry.setApplicationDestinationPrefixes("/publish");
        
		// 특정 사용자에게 메시지를 보낼 때 사용할 접두사를 /user 설정
		registry.setUserDestinationPrefix("/user");
	}

	/**
	 * 오프닝 핸드셰이크 과정에서 사용할 엔드포인트 지정
	 * ws://localhost:8080/ws-connect // 만약 https를 사용한다면 wss
	 */
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/ws-connect")
			.setAllowedOrigins("http://localhost:5173")); // CORS 정책으로, 해당 주소에서의 접근만 허용
	}

}

 

클라이언트에서는 아래와 같이 연결할 수 있다.

import { Client } from "@stomp/stompjs";

const Component = () => {
	const brokerUrl = "ws://localhost:8080/ws-connect";

	const client = new Client({
	  brokerURL: brokerUrl,
	  reconnectDelay: 5000,
	});
}

 

참고로 위 코드의 경우 인메모리 환경의 메시지 브로커를 사용하므로 인스턴스가 여러 대인 경우 문제가 발생할 수 있다.

이 때는 외부 메세징 서버를 따로 두고 그 안에 rabbitMQ, kafka와 같은 메시지 브로커를 설치해서 각 WAS에 전달할 수 있도록 한다.

 

 

3. 컨트롤러 구현

@RequiredArgsConstructor
@Controller
public class ChatController {

	private final ChatMessageService chatMessageService;
    
	@MessageMapping("/chat.send.{teamId}") //Config에서 지정한 Prefix(/publish) + /chat.send.{teamId}를 포함한 경로로 메시지가 오면 해당 메서드가 호출
	@SendTo("/subscribe/teams.{teamId}") // 해당 구독 경로를 구독한 사람들에게 Return 값을 메시지를 전송
	public ChatResponse.ChatResponseDTO sendMessage(
			@DestinationVariable Long teamId, // STOMP 메시지 경로에서 동적으로 추출된 값을 메서드의 인자로 전달하는 어노테이션으로 @PathVariable과 유사
			@Valid @Payload ChatRequest.CreateMessageDTO request) { // 클라이언트에서 보낸 메시지 내용을 ChatRequest.CreateMessageDTO 객체로 매핑하고 검증
		return chatMessageService.sendMessage(teamId, request.getMessage());
	}
}

 

클라이언트는 다음과 같이 위 메서드에 메시지를 보내고 구독한 경로로 전달받을 수 있다.

client.subscribe(`/subscribe/teams.${teamId}`, (message) => {
	const response = JSON.parse(message.body);
	setMessages((prevMessages) => [
		...prevMessages,
		{ ...response, isMine: false },
	]);
});


client.publish({
	destination: `/publish/chat.send.${teamId}`,
	body: JSON.stringify(messagePayload),
});

 

4. 웹 소켓에서 예외 처리 구현

컨트롤러에서 Request를 받을 때 Bean Validation을 통해 DTO를 검증하고 있다.

여기서 message의 값이 500자가 넘어간다면 MethodArgumentNotValidException가 발생한다. 

public class ChatRequest {

    @Getter
    public static class CreateMessageDTO {
        @NotBlank(message = "메시지는 비어있을 수 없습니다.")
        @Size(max = 500, message = "메시지는 500자를 넘어갈 수 없습니다.")
        String message;
    }
}

 

그렇다면 웹소켓 통신에서 이를 처리하려면 어떻게 해야할까?

컨트롤러 하위의 예외를 전역적으로 처리하는 @ControllerAdvice 클래스에 @ExceptionHandler 대신 @MessageExceptionHandler를 통해 해당 예외를 지정해주면 된다.

그리고 @SendToUser를 통해 메시지를 보낸 사람에게 예외 메시지가 전송되도록 한다.

참고로 MethodArgumentNotValidException은  org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException를 import 해주어야 한다.

다른 패키지의 예외를 지정해서 1시간 넘게 헤맸다.

@RequiredArgsConstructor
@Slf4j
@ControllerAdvice
public class WebSocketExceptionAdvice {

    @MessageExceptionHandler(MethodArgumentNotValidException.class)
    @SendToUser("/subscribe/errors") // 특정 유저에게 보내므로 config에서 지정한 /user + /subscribe/errors 를 경로를 구독한 사람에게 메시지가 전송된다.
    public ApiResponse<Object> handleValidationException(MethodArgumentNotValidException ex) {
        ErrorReasonDTO e = ErrorStatus._BAD_REQUEST.getReasonHttpStatus();
        return ApiResponse.onFailure(
                e.getCode(),
                ex.getBindingResult().getFieldErrors().get(0).getDefaultMessage(),
                null
        );
    }
}

 

클라이언트는 다음과 같이 에러가 발생했을 때 이를 구독하여 알림을 받는다.

client.subscribe("/user/subscribe/errors", (message) => {
	const errorResponse = JSON.parse(message.body);
	console.log("errorResponse", errorResponse);
	alert(errorResponse.message); // 오류 메시지를 사용자에게 알림
});

 

이렇게 하여 간단하게 웹소켓을 통해 통신하는 방법에 대해서 알아보았다.

다음 시간에는 스프링 시큐리티와 연동하여 인증된 사용자에게 메시지를 전송할 수 있도록 설정을 추가해 보아야겠다.