STOMP란?
Simple Text Oriented Messaging Protocol의 약자로 메시지를 전송하기 위한 프로토콜이다.
메시지 브로커와 publisher - subscriber 방식을 사용하고, 메시지 브로커는 발행자가 전송하는 메시지를 구독자들에게 전송한다.
왜 STOMP를 사용 했는가?
Spring WebSocket은 단순히 양방향 연결 채널일 뿐, 메시지를 어디로, 누가, 무엇을 보내는지 구조적으로 정의되어있지 않다. 하지만 STOMP를 WebSocket위에서 동작시키면 채널 구독, 메시지 목적지(채팅방) 구분, 구독 관리 등 실시간 채팅에 맞는 다양한 기능을 사용 가능하다.
따라서 이번에 구현한 채팅은 WebSocket 위에서 STOMP를 사용하여 채팅방 생성, 채팅방 구독, 전송, 읽음 처리를 구현했다.
의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-websocket'
<StompHandler>
인증된 사용자만 Websocket 실시간 통신이 가능하도록 하기 위해서 Websocket 연결 요청 시 JWT토큰을 검증하고, 정상 사용자 정보를 세션에 주입해주는 보안 처리를 담당한다.
@Component
@RequiredArgsConstructor
@Slf4j
public class StompHandler implements ChannelInterceptor {
private final JwtTokenProvider jwtTokenProvider;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String authHeader = accessor.getFirstNativeHeader("Authorization");
log.info("WebSocket connect Authorization header: {}", authHeader);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new BaseException(ErrorStatus.UNAUTHORIZED_EMPTY_TOKEN.getHttpStatus(), "헤더 없음");
}
String token = authHeader.substring(7);
if (!jwtTokenProvider.validateToken(token)) {
throw new BaseException(ErrorStatus.UNAUTHORIZED_INVALID_TOKEN.getHttpStatus(), "토큰 무효");
}
String email = jwtTokenProvider.getEmail(token);
StompPrincipal principal = new StompPrincipal(email);
//setUser에 principal만 설정
accessor.setUser(principal);
//세션에도 저장
Map<String, Object> sessionAttributes = accessor.getSessionAttributes();
if (sessionAttributes == null) {
sessionAttributes = new HashMap<>();
accessor.setSessionAttributes(sessionAttributes);
}
sessionAttributes.put("user", principal);
}
return message;
}
}
<WebSocketConfig>
STOMP + Spring WebSocket 기반 실시간 채팅을 가능하게 해주는 설정 클래스이다.
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler;
private final JwtTokenProvider jwtTokenProvider;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// socketJs 클라이언트가 WebSocket 핸드셰이크를 하기 위해 연결할 endpoint를 지정할 수 있다.
registry.addEndpoint("/chat/inbox")
.setAllowedOriginPatterns("*") // cors 허용을 위해 꼭 설정해주어야 한다.
.withSockJS(); //웹소켓을 지원하지 않는 브라우저는 sockJS를 사용하도록 한다.
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 메모리 기반 메시지 브로커가 해당 api를 구독하고 있는 클라이언트에게 메시지를 전달한다.
// to subscriber
registry.enableSimpleBroker("/sub");
// 클라이언트로부터 메시지를 받을 api의 prefix를 설정한다.
// publish
registry.setApplicationDestinationPrefixes("/pub");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompHandler);
}
}
<ChatMessageController>
WebSocket 메시지를 처리하는 STOMP 기반 컨트롤러이다.
이 컨트롤러에서는 GetMapping이나 PostMapping이 아닌 MessageMapping을 사용했다.
MessageMapping이란 WebSocket/STOMP에서 클라이언트가 서버로 메시지를 보낼 때 사용하는 경로를 처리하는 어노테이션이다. 이 어노테이션을 넣고 그에 맞는 서버 로직을 정의하면 알맞게 메시지 처리가 가능해진다.
@Controller
@RequiredArgsConstructor
@Slf4j
public class ChatMessageController {
private final ChatMessageService chatMessageService;
private final SimpMessagingTemplate messagingTemplate;
private final MessageReadStatusRepository messageReadStatusRepository;
private final ChatMessageRepository chatMessageRepository;
@MessageMapping("/message")
public void sendMessage(@Payload ChatMessageDto message, Message<?> rawMessage, Principal principal) {
if (principal == null) {
// fallback: 세션에서 user 꺼내기
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(rawMessage);
Map<String, Object> sessionAttributes = accessor.getSessionAttributes();
if (sessionAttributes != null) {
Object sessionUser = sessionAttributes.get("user");
if (sessionUser instanceof Principal p) {
principal = p;
}
}
}
if (principal == null) {
log.warn("메시지 발신자 정보 없음. Principal이 null임");
return;
}
String senderEmail = principal.getName();
message.setSender(senderEmail);
ChatMessage saved = chatMessageService.createChatMessage(message);
messagingTemplate.convertAndSend(
"/sub/channel/" + message.getRoomId(),
ChatMessageDto.fromEntity(saved, true)
);
}
@MessageMapping("/read")
public void markAsRead(@Payload Long messageId, Message<?> rawMessage) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(rawMessage);
Principal principal = accessor.getUser();
if (principal == null) {
Map<String, Object> sessionAttributes = accessor.getSessionAttributes();
if (sessionAttributes != null) {
Object sessionUser = sessionAttributes.get("user");
if (sessionUser instanceof Principal p) {
principal = p;
}
}
}
if (principal == null) {
log.warn("읽음 처리 실패: principal이 null입니다.");
return;
}
String readerEmail = principal.getName();
// messageId로 메시지 조회해서 roomId 추출
ChatMessage message = chatMessageRepository.findById(messageId)
.orElseThrow(() -> new RuntimeException("메시지를 찾을 수 없습니다."));
String roomId = message.getRoomId();
// roomId 포함해서 안 읽은 메시지 가져오기
List<MessageReadStatus> unreadStatuses =
messageReadStatusRepository.findAllUnreadByReaderEmailAndRoomIdBeforeMessageId(readerEmail, roomId, messageId);
if (unreadStatuses.isEmpty()) {
log.info("읽을 메시지가 없습니다.");
return;
}
for (MessageReadStatus status : unreadStatuses) {
status.setRead(true);
}
messageReadStatusRepository.saveAll(unreadStatuses);
messagingTemplate.convertAndSend(
"/sub/channel/" + roomId + "/read-status",
new ReadStatusMessage(readerEmail, unreadStatuses.stream()
.map(status -> status.getMessage().getId())
.toList())
);
log.info("총 {}개의 메시지를 읽음 처리했습니다.", unreadStatuses.size());
}
}
메시지 전송 과정
- 클라이언트에서 /pub/message로 메시지 전송
- JSON 형태로 ChatMessageDto에 들어옴
- 사용자 인증( principal == null이면 sessionAttributes에서 user를 꺼냄)
- 보낸 사람 이메일 추출
- 메시지 DB에 저장
- 구독자들에게 메시지 전송
메시지 읽음 처리 과정
- 클라이언트가 /pub/read로 메시지 ID를 보냄
- Principal 인증
- 메시지를 DB에서 조회
- roomId 추출
- 해당 채팅방의 이전에 안읽은 roomId를 모두 가져오기
- 읽음 처리
- 해당 채팅방 구독자에게 읽음 상태 전송
GitHub - 9oormthonUNIV-Halpme/Backend: 충북대학교 구름톤 유니브 2조 Halpme 백엔드
충북대학교 구름톤 유니브 2조 Halpme 백엔드. Contribute to 9oormthonUNIV-Halpme/Backend development by creating an account on GitHub.
github.com
'BackEnd > spring' 카테고리의 다른 글
Spring Webocket을 이용한 실시간 채팅 구현(+ JWT 인증) (0) | 2025.05.14 |
---|---|
@Controller와 @RestController의 차이점 (0) | 2025.02.23 |
SpringBean (1) | 2025.02.11 |
회원가입 로그인 구현 (0) | 2024.08.19 |
BeanValidation-Form 전송 객체 분리 (0) | 2024.08.05 |