BackEnd/spring

WebSocket + STOMP로 채팅 구현하기

연향동큰손 2025. 5. 30. 16:16

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());
    }





}

 

메시지 전송 과정

  1. 클라이언트에서 /pub/message로 메시지 전송
  2. JSON 형태로 ChatMessageDto에 들어옴
  3. 사용자 인증( principal == null이면 sessionAttributes에서 user를 꺼냄)
  4. 보낸 사람 이메일 추출
  5. 메시지 DB에 저장
  6. 구독자들에게 메시지 전송

 

메시지 읽음 처리 과정

  1. 클라이언트가 /pub/read로 메시지 ID를 보냄
  2. Principal 인증
  3. 메시지를 DB에서 조회
  4. roomId 추출
  5. 해당 채팅방의 이전에 안읽은 roomId를 모두 가져오기
  6. 읽음 처리
  7. 해당 채팅방 구독자에게 읽음 상태 전송

 

 

GitHub - 9oormthonUNIV-Halpme/Backend: 충북대학교 구름톤 유니브 2조 Halpme 백엔드

충북대학교 구름톤 유니브 2조 Halpme 백엔드. Contribute to 9oormthonUNIV-Halpme/Backend development by creating an account on GitHub.

github.com