본문 바로가기
Project/mo:rack (익명 커뮤니티)

채팅기능 고도화 (STOMP + JWT 토큰 인증 + 전송시 유저 정보 + sessionId 확인)

by 창브로 2025. 7. 2.

저번에 엄청 간단한 채팅기능을 웹소켓만으로 구현을 했다.

 

클라이언트에서 소켓 연결을 열고, 서버는 연결된 세션을 직접 관리하면서 채팅방별 메시지를 구분하고 전달하는 구조였다.

근데 개발이 진행되면서 몇 가지 문제가 생겼다!

 

1. 채팅방 별 메시지 구분이 번거로웠고 직접 방 ID를 파싱 해서 라우팅해야 했다.

2. 유저에게 특정 메시지를 보내려면 세션 ID와 유저 ID를 수동으로 매핑해서 관리해야 했다.

3. 메시지 형식이 자유로워 정해진 규약 없이 문자열 파싱으로 사용했다.

 

이로 인해 코드가 계속 복잡해지고 유지보수가 힘들 것 같다는 느낌이 들었다!

 

 

검색을 통해 찾아본 결과 STOMP라는 게 있었다.

 

STOMP란?

 

쉽게 설명하면 웹소켓은 실시간 통신을 위한 기본적인 통신 프로토콜이고, STOMP는 그 위에 얹은 메시지 프로토콜이다!

웹소켓이 파이프라면 STOMP는 그 파이프 안에서 편지 형식과 주소 체계를 정해주는 규칙이라고 말할 수 있겠다.

 

내가 사용했던 전에 코드로 예시를 들어주겠다.

 

 

클라이언트에서 이런 식으로 보내주기로 약속을 한다.

" '|'를 통해 나눕시다!"

socket.send("room1|nickname|hello");

 

그럼 백엔드 개발자인 나는

@OnMessage
public void handleMessage(Session session, String message) {
    // 문자열 파싱
    String[] parts = message.split("\\|");
    String roomIdPart = parts[0]; // "roomId:3"
    Long roomId = Long.parseLong(roomIdPart.split(":")[1]);

    // 방 ID에 따라 라우팅
    sendToRoom(roomId, message);
}

 

이런 식으로 파싱 하여 사용해야만 한다.

 

뭔가 유지보수하기 정말 힘들 것 같지 않나요?

 

구분하는 값이 | -> &로 바뀐다던지 아니면 값의 순서가 바뀐다던지 이럴 수 있기 때문입니다.

 

그럼 STOMP로 바꾸면 어떻게 되냐?

클라이언트에선 백엔드에서 지정한 dto로 주면 된다.

stompClient.send("/app/chat/3", {}, JSON.stringify({
  nickname: "changbro",
  message: "안녕!"
}));

 

 

라우팅 경로(/chat/{roomId})로 자동 처리돼서 파싱 없이 깔끔하고 안정적.

@MessageMapping("/chat/{roomId}")
public void message(@DestinationVariable Long roomId, ChatMessageDto dto) {
    messagingTemplate.convertAndSend("/topic/room/" + roomId, dto);
}

 

 

어떤 느낌인지 아시겠죠?

 

 

그럼 이제 제가 채팅기능을 어떻게 구현하고 있는지 보여드리겠습니다.

 

일단 STOMP를 사용하기 위해 STOMP config 먼저 만들어야 합니다.

 

여기엔

 

- 실시간 채팅을 위한 웹소켓 설정

- JWT 인증 기반 연결 관리

- Redis를 활용한 세션 관리

 

의 기능이 들어갑니다

 

 

 

WebSocketStompConfig

@Configuration
@EnableWebSocketMessageBroker // 웹소켓 메시지 브로커 활성화
@Slf4j
@RequiredArgsConstructor
public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {

	private final JwtUtil jwtUtil;
	private final WebSocketSessionService webSocketSessionService; // Redis 세션 관리 서비스

	// 메시지 브로커 설정
	// 클라이언트가 메시지를 구독할 때와 메시지를 보낼 때의 경로를 설정
	@Override
	public void configureMessageBroker(MessageBrokerRegistry config) {
		// 메시지 구독 경로 설정
		config.enableSimpleBroker("/sub");

		// 클라이언트에서 메시지 보낼때 사용하는 경로
		config.setApplicationDestinationPrefixes("/pub");

		log.info("STOMP 메시지 브로커 설정 완료");
		log.info("구독 경로: /sub");
		log.info("메시지 보내는 경로: /pub");
	}

	// 클라이언트가 웹소켓에 연결할 때 사용할 엔드포인트
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/ws") // 엔드포인트 경로
				.setAllowedOriginPatterns("*") // CORS 설정
				.setAllowedOrigins("http://localhost:8080") // 명시적 허용
				.withSockJS(); // SockJS 풀백 옵션

		log.info("STOMP 엔드포인트 설정: /ws");
		log.info("SockJS 사용 설정");
	}


	// 메시지 처리전 인터셉터를 통해 전처리 수행
    // JWT 인증 및 세션 관리
	@Override
	public void configureClientInboundChannel(ChannelRegistration registration) {
		registration.interceptors(new ChannelInterceptor() {
			@Override
			public Message<?> preSend(Message<?> message, MessageChannel channel) {
				StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);

				if (StompCommand.CONNECT.equals(accessor.getCommand())) {
					handleConnect(accessor);
				} else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
					handleDisconnect(accessor);
				}
				return message;
			}
		});
	}

	// 웹소켓 연결 처리 메서드
	private void handleConnect(StompHeaderAccessor accessor) {
		String authHeader = accessor.getFirstNativeHeader("Authorization");

		if (authHeader == null || !authHeader.startsWith("Bearer ")) {
			log.warn("!!!!!!Authorization 헤더가 없거나 형식이 잘못됨!!!!!!");
			throw new MessagingException("JWT 인증 실패: 토큰 누락");
		}

		String token = authHeader.substring(7);

		try {
			if (jwtUtil.validateAccessToken(token)) {

				String sessionId = accessor.getSessionId();
				Long userId = jwtUtil.getUserIdFromAccessToken(token);
				String nickname = jwtUtil.getNicknameFromAccessToken(token);

				webSocketSessionService.saveSession(sessionId, userId, nickname);

				log.info("WebSocket 인증 성공: {}", jwtUtil.getNicknameFromAccessToken(token));
			}
		} catch (JwtException e) {
			log.warn("WebSocket JWT 검증 실패: {}", e.getMessage());
			throw new MessagingException("JWT 인증 실패: 유효하지 않은 토큰");
		}

	}

	// 웹소켓 연결 해제 처리 메서드
	private void handleDisconnect(StompHeaderAccessor accessor) {
		String sessionId = accessor.getSessionId();
		if(sessionId != null) {
			webSocketSessionService.removeSession(sessionId);
			log.info("WebSocket 연결 해제 및 세션 삭제: sessionId={}", sessionId);
		}
	}


}

 

 

여기서 제가 생각하는 특별한 점은 웹소켓을 Connect 할 때 JWT토큰을 검증하고
SessionId를 Key값으로 두고 User정보를 Redis에 담은 것입니다.

JWT + Redis 기반 연결 관리

✅ 연결 인증 흐름 요약

1. 클라이언트는 /ws로 STOMP 연결 요청 + JWT 토큰 정송

2. CONNECT 이벤트에서 JWT 검증 수행

3. 인증 성공 시, Redis에 sessionId로 유저 정보 저장 (sessionId로 사용자 정보 조회 가능)

4. DISCONNECT시 Redis에서 세션 제거 (사용자 정보 삭제)

 

요약하면 이런 식으로 되어있습니다!


가장 중요한 부분이고 레디스 설정 부분은 생략하겠습니다.

 

마지막으로 ChatController와 ChatService 부분을 보여드리면

 

ChatController

@Controller // WebSocket 메시지를 처리하는 컨트롤러
@Slf4j
@RequiredArgsConstructor
public class ChatController {

    private final ChatService chatService;

    // 클라이언트에서 오는 메시지를 받는 경로
    // 실제 요청: "/pub/chatRoom/123" (prefix "/pub"는 config에서 설정)
    @MessageMapping("/chatRoom/{roomId}") 
    
    // 처리된 메시지를 전송할 경로 
    // 해당 채팅방을 구독한 모든 클라이언트에게 브로드캐스트
    @SendTo("/sub/chatRoom/{roomId}") 
    public ChatMessageDto chat(@DestinationVariable Long roomId,
                               @Payload String message,
                               // 웹소켓 세션 정보 접근 객체
                               StompHeaderAccessor headerAccessor) {

	// 현재 웹소켓 세션 ID 추출
        String sessionId = headerAccessor.getSessionId();

        return chatService.saveAndGetChatMessage(roomId, message, sessionId);
    }
}

 

 

 

ChatService

@Service
@RequiredArgsConstructor
@Slf4j
public class ChatService {

    private final ChatMessageRepository chatMessageRepository;
    private final WebSocketSessionService webSocketSessionService;

    public ChatMessageDto saveAndGetChatMessage(Long roomId, String message, String sessionId) {

        // 레디스에서 세션 정보 조회
        WebSocketSessionDto userInfo = webSocketSessionService.getSession(sessionId);

        String senderNickname = null;

        if (userInfo != null) {
            senderNickname = userInfo.getNickname();
            log.info("채팅 메시지 전송: roomId={}, sessionId={}, nickname={}",
                     roomId, sessionId, senderNickname);
        } else {
            log.warn("세션 정보를 찾을 수 없습니다: sessionId={}", sessionId);
        }

        ChatMessage chatMessage = ChatMessage.builder()
                .type(MessageType.CHAT)
                .chatRoomId(roomId)
                .senderNickname(senderNickname)
                .message(message)
                .build();

        ChatMessage savedChatMessage = chatMessageRepository.save(chatMessage);

        return ChatMessageDto.createChatMessage(
                savedChatMessage.getChatRoomId(),
                savedChatMessage.getSenderNickname(),
                savedChatMessage.getMessage()
        );
    }
}

 

 

그냥 간단하게 채팅 내용 저장하고 Dto로 클라이언트에 Return 해주는 구조다.

 

 

이렇게 보여줘도 되는지 안되는지 모르니 테스트 자료까지 포함하겠다.

 

프론트에 약간 무지한 나는 AI들을 활용하여 프론트를 간단하게 구현해보려 했지만

멍청한(?) AI들이 이상하게 구현해 줘 삽질을 엄청하다가 웹소켓 테스트 할 수 있는 사이트를 찾았다.

 

https://jiangxy.github.io/websocket-debug-tool/

 

WebSocket Debug Tool

 

jiangxy.github.io

 

 

만든 사람 대박 나세요.

 

테스트 진행하겠습니다.

 

프로젝트 실행!

 

아까 Config에서 설정한 로그들이 나오는 것을 볼 수 있다.

 

 

내 서버의 주소 + 소켓 연결 주소를 입력하고 Header에 실제 로그인해서 받은 AccessToken을 넣고 연결 시도를 하면

 

 

 

 

로그인한 회원의 정보들이 sessionId와 함께 레디스에 저장됐다고 메시지가 뜬다.

 

이렇게 하면 거짓말일 수도 있으니 직접 Redis를 들어가서 확인해 보겠다.

 

 

 

정확하게 정보가 저장되어 있는 것을 확인할 수 있다.

 

그럼 일단 카리나 유저를 /sub/chatRoom/1 채팅방 1번을 구독하게 해 놓고

(/sub <- 아까 config에서 설정했죠?)

(Controller에서 @SendTo로 /chatRoom/1도 설정했죠?)

 

이창형 유저 또한 채팅방 1번을 구독하게 하여 둘이서 채팅을 해보겠다.

 

카리나가 채팅방 1번에서 얘기를 하는 걸로 시작

 

 

 

send STOMP message

 

"안녕?"이라는 메시지를 전송했다는 것을 알 수 있다.

 

Receive subscribed message from destination /sub/chatRoom/1

 

카리나 유저도 1번 방을 구독하고 있기 때문에 본인이 보낸 메시지를 받는다.

 

 

 

같은 방을 구독하고 있는 이창형 유저는?

 

 

Receive subscribed message from destination /sub/chatRoom/1

 

카리나라는 유저가 "안녕?"이라는 메시지를 보낸 것을 알 수 있다.

 

 

행복하네요

 

 

 

이제 친구일 때 자동으로 채팅방을 만들어주고

해당 각 유저에게 그 방을 구독시켜 주는 것 외에 수많은 기능들을 붙여봐야겠다 ㅋ ㅋ

 

좋은 경험이었습니다~