프로젝트의 구조를 모놀리식 아키텍처(Monolithic Architecture)로 정했기 때문에, 다른 팀원들이 구현한 프로젝트 위에 채팅 기능을 추가했다. 가장 까다로웠던 부분은 Spring Security가 적용된 프로젝트 위에서 기능 개발을 했던 것이다. 비슷한 구조로 개발한 자료를 찾지 못해 꽤 오랜 시간 삽질을 했다. 이번 글은 내가 삽질했던 모든 내용을 다 담았다.
Spring boot에서 Websocket과 STOMP를 사용하기 위해서는 아래의 의존성을 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-websocket'
WebSockConfig.java
@Configuration
@EnableWebSocketMessageBroker // Stomp를 사용하기 위한 에노테이션
@RequiredArgsConstructor
public class WebSockConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/sub"); // 메세지를 구독하는 요청 설정
config.setApplicationDestinationPrefixes("/pub"); // 메세지를 발행하는 요청 설정
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-stomp").setAllowedOrigins("*")
.withSockJS(); // sock.js를 통하여 낮은 버전의 브라우저에서도 websocket이 동작할수 있게 설정
registry.addEndpoint("/ws-stomp").setAllowedOrigins("*"); // api 통신 시, withSockJS() 설정을 빼야됨
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompHandler);
}
}
@EnableWebSocketMessageBroker
WebSocket 메시지 브로커 기능을 활성화하는 어노테이션이다. 이를 통해 WebSocket 메시지 핸들링과 메시지 브로커 구성이 가능해진다.
configureMessageBroker()
메시지 브로커 구성을 설정하는 메서드이다. enableSimpleBroker() 메서드를 사용하여 /sub 프리픽스를 가진 주제(destination)로 메시지를 구독할 수 있도록 설정하고, setApplicationDestinationPrefixes() 메서드를 사용하여 /pub 프리픽스를 가진 주제(destination)로 메시지를 발행할 수 있도록 설정한다.
registerStompEndpoints()
STOMP 프로토콜을 사용하는 WebSocket 엔드포인트를 등록하는 메서드이다. /ws-stomp 경로로 WebSocket 연결을 가능하게 하고, setAllowedOrigins("*")로 모든 오리진(Origin)에서의 접근을 허용한다. withSockJS()를 사용하여 모든 브라우저에서 WebSocket 기능을 사용할 수 있다.
WebSocket 엔드포인트로 등록한 경로는 반드시 Spring Security 설정에서 접근 가능하도록 등록해야 한다.
참고로 웹이 아닌 앱을 통한 채팅 기능을 구현할 때 withSockJS()를 사용하면 동작하지 않는다.
configureClientInboundChannel()
클라이언트의 인바운드 채널에 대한 설정을 구성하는 메서드이다. interceptors() 메서드를 사용하여 stompHandler를 등록하여 클라이언트의 WebSocket 연결 이전에 처리 작업을 수행할 수 있도록 한다. 올바른 권한이 있는 사용자인지 확인하는 로직을 추가하기 위해서 stompHandler를 등록했다.
StompHandler.java
@Slf4j
@RequiredArgsConstructor
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class StompHandler implements ChannelInterceptor {
private final JwtUtil jwtUtil;
// websocket을 통해 들어온 요청이 처리 되기전 실행됨
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
// websocket 연결시 헤더의 jwt token 유효성 검증
if (StompCommand.CONNECT == accessor.getCommand()) {
String authorization = jwtUtil.extractJwt(accessor.getFirstNativeHeader("Authorization"));
jwtUtil.parseClaims(authorization);
}
return message;
}
}
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
스프링의 빈 순서를 지정하는 애노테이션으로, StompHandler의 우선순위를 설정한다. Ordered.HIGHEST_PRECEDENCE는 가장 높은 우선순위를 나타내며, + 99를 추가하여 더 높은 우선순위를 부여한다.
preSend()
ChannelInterceptor 인터페이스의 메서드로, WebSocket을 통해 들어온 요청이 처리되기 전에 실행된다. 이 메서드는 수신된 메시지를 가로채고 처리하기 전에 사전 처리 작업을 수행할 수 있다.
StompHeaderAccessor
메시지의 Stomp 헤더를 쉽게 접근하기 위한 유틸리티 클래스이다.
StompHandler는 WebSocket을 통해 들어온 요청의 Stomp 헤더를 가로채어 JWT 토큰의 유효성을 검증하는 역할을 수행한다. preSend 메서드는 각 프로젝트 환경에 맞춰 변경하거나 추가적인 로직을 구현해야 한다.
ChatMessage.java
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
@Builder
public class ChatMessage {
private String roomId;
private String sender;
private String message;
private String sendingTime;
}
메세지를 주고받기 위한 DTO를 생성한다.
ChatController.java
@RequiredArgsConstructor
@Controller
@Slf4j
public class ChatController {
private final SimpMessageSendingOperations messagingTemplate;
private final JwtUtil jwtUtil;
@CrossOrigin
@MessageMapping("/chat/message") //websocket "/pub/chat/message"로 들어오는 메시지 처리
public void message(ChatMessage message, @Header("Authorization") String Authorization) {
String authorization = jwtUtil.extractJwt(Authorization);
Object memberId = jwtUtil.parseClaims(authorization).get("memberId");
message.setSender((String) memberId);
messagingTemplate.convertAndSend("/sub/chat/room/" + message.getRoomId(), message); // /sub/chat/room/{roomId} - 구독
}
}
message 메서드는 /pub/chat/message로 들어오는 메세지를 처리한다. 메세지를 보낸 사용자의 아이디를 message 객체에 저장하고, 채팅방 id를 확인하여 메세지를 전달한다.
Postman은 STOMP 테스트를 지원하지 않는다. 따라서 apic을 사용하여 STOMP를 테스트 했다. apic의 문제점은 STOMP connect 할 땐 header에 token 넣지만, message를 send 할 땐 header에 값을 추가할 수 없다. 지금까지 구현한 로직은 메세지를 보낼 때 마다 메세지 헤더에 있는 token이 유효한지 확인하기 때문에 apic으로 테스트하기엔 적합하지 않다. 따라서 다른 테스트 툴을 찾았다. 해당 툴은 header에 값을 추가하여 보낼 수 있어 성공적으로 테스트할 수 있었다.
우선 STOMP connect를 하기 위해 URL을 작성한다. ULR은 ws://ip주소:포트/엔드 포인트
형식으로 작성한다. 추가적으로 STOMP connect header 필드를 채워야 한다. 정상적인 값을 입력하고 connect를 했을 때 위와 같이 나오면 성공적으로 연결한 것이다.
STOMP subscribe destination에는 messagingTemplate.convertAndSend에 작성한 경로, 그리고 채팅방 순으로 입력한다. Subscribe를 눌렀을 때 아래와 같이 나오면 성공적으로 구독한 것이다. 테스트에서는 1번 채팅방을 구독했다.
다음은 메세지를 발행하기 위해 새로운 브라운저를 열고 STOMP connect를 진행한다. STOMP send header는 connect header와 동일한 값을 넣는다. STOMP send destination에는 configureMessageBroker()에서 설정한 setApplicationDestinationPrefixes와 @MessageMapping에 설정한 경로를 입력한다. 마지막으로 Message Content 필드에 전달할 메세지 내용 작성하고 Send 한다.
채팅방을 구독했던 브라우저 화면으로 돌아와서 확인하면 정상적으로 메세지를 전달 받은 것을 확인할 수 있다.
저도 공부하는 입장이라 부족할 수 있습니다. 따라서 조언과 피드백은 항상 열려있습니다!
https://www.daddyprogrammer.org/post/4077/spring-websocket-chatting/
https://brunch.co.kr/@springboot/695
안녕하세요! 혹시 가능하다면 관련 코드 볼 수 있는 깃허브 주소가 있을까요..?