1:1 채팅 기능에서 WebSocket STOMP 적용 중 발생한 문제 - fitpassTeam/fitpass GitHub Wiki

1:1 채팅 기능에서 WebSocket + STOMP 적용 중 발생한 문제

문제 상황

FitPass 헬스장 예약 플랫폼에서 사용자와 체육관 운영자 간의 실시간 상담을 위한 1:1 채팅 기능을 구현하는 과정에서 여러 기술적 문제가 발생했습니다. 기존 REST API 방식으로는 즉시 응답이 어려워 WebSocket과 STOMP 프로토콜을 도입하기로 결정했습니다.

특히 **USER(일반 사용자)**와 OWNER(체육관 운영자) 간의 복잡한 역할 구분과 메시지 라우팅에서 예상하지 못한 문제들이 연쇄적으로 발생했습니다.

이 문제를 해결하기 위해 다음과 같은 기술 스택을 설계했습니다:

  • 백엔드: Spring Boot + WebSocket + STOMP
  • 프론트엔드: React + @stomp/stompjs + SockJS
  • 메시지 라우팅: 글로벌 브로드캐스트 + 개별 사용자 전송 하이브리드 방식

하지만 실제 구현 과정에서 WebSocket 연결 실패, 메시지 라우팅 불일치, 복잡한 역할 기반 ID 매핑 등 다양한 문제가 발생했습니다.


문제 분석

1. WebSocket 연결 실패와 CORS 설정 문제

초기 설정에서 CORS 설정이 실제 프론트엔드 도메인과 불일치하는 문제가 발생했습니다:

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/ws")
        .setAllowedOriginPatterns("*")  // 문제 지점
        .withSockJS();
}

브라우저 콘솔에서는 다음과 같은 에러가 지속적으로 발생했습니다:

WebSocket connection to 'ws://localhost:8080/ws' failed: 
Error during WebSocket handshake: Unexpected response code: 404

이는 단순히 CORS 설정만의 문제가 아니라, SockJS 엔드포인트 등록 자체가 실패하는 상황이었습니다. 명확한 원인 파악보다는 임시방편으로 와일드카드 설정을 시도했지만, 실제로는 구체적인 도메인 명시가 필요했습니다.

2. 메시지 라우팅 경로 불일치 문제

서버에서는 @SendTo("/topic/public")로 설정했지만, 클라이언트에서는 잘못된 구독을 시도했습니다:

// ❌ 잘못된 구독 시도
client.subscribe('/topic/chat/' + roomId, (message) => {
    // 메시지를 받을 수 없음
});

// ✅ 올바른 구독 (서버와 일치)
client.subscribe('/topic/public', (message) => {
    const parsedMessage = JSON.parse(message.body);
    setMessages(prev => [...prev, parsedMessage]);
});

이는 STOMP 프로토콜의 pub/sub 메커니즘에 대한 이해 부족으로 인한 문제였습니다. 메시지 전송은 성공하지만 수신이 되지 않는 현상이 발생했습니다.

3. 복잡한 역할 기반 ID 매핑 오류

FitPass의 **일반 사용자(USER)**와 체육관 운영자(OWNER) 간의 채팅에서 ID 매핑이 복잡했습니다:

// 복잡한 ID 매핑 로직
const senderId = userInfo.userRole === 'OWNER' 
  ? (myGym?.id ?? myGym?.gymId ?? chatRoomInfo.gymId)  // 체육관 ID
  : userInfo.id;  // 개인 ID

// 메시지 소유권 판별에서 오류 빈발
const isMine = (msg) => {
  if (userInfo?.userRole === 'OWNER') {
    const myGymId = myGym?.id ?? myGym?.gymId ?? chatRoomInfo?.gymId;
    return msg.senderType === 'GYM' && String(msg.senderId) === String(myGymId);
  }
  // ...
};

체육관 운영자의 개인 ID와 체육관 ID를 혼동하는 문제가 지속적으로 발생했습니다. 특히 React Query를 통한 비동기 데이터 로딩과 WebSocket 연결 타이밍이 일치하지 않아 문제가 배가되었습니다.


해결 방향 및 계획

현재는 일단 정확한 경로 매핑과 역할별 ID 검증 로직을 적용해 기본적인 채팅 기능은 동작하도록 했으나, 근본적인 아키텍처 개선까지는 도달하지 못했습니다.

이에 따라 다음과 같은 방향으로 대응하고자 합니다:

  • WebSocket + STOMP 구조에 대한 근본적인 학습 진행

    • STOMP 프레임 구조와 메시지 플로우 이해
    • Destination 경로 설계 원칙과 패턴
    • 세션 관리와 메모리 누수 방지
  • 실제 운영환경에서의 실시간 통신 설계 고려

    • Redis Pub/Sub을 통한 멀티 서버 환경 대응
    • 복잡한 권한 시스템에서의 메시지 라우팅 명확화
    • React Query와 WebSocket 상태 동기화

회고 및 교훈

이번 경험을 통해 실시간 통신은 단순히 WebSocket을 연결하는 것 이상의 복잡성을 가지고 있다는 점을 깊이 깨달았습니다.

핵심 교훈

  1. 프로토콜 이해의 중요성: STOMP는 구조화된 메시징 프로토콜입니다. @MessageMapping, @SendTo, 클라이언트 구독 경로가 정확히 일치해야 한다는 기본 원리를 간과했습니다.

  2. 복잡성의 분리: FitPass의 USER/OWNER 역할 구분이라는 비즈니스 복잡성과 WebSocket 연결이라는 기술적 복잡성이 결합되면서 문제가 배가되었습니다.

  3. 충분한 디버깅 인프라: 실시간 통신에서는 문제 발생 시점과 원인 파악이 어렵습니다. 처음부터 상세한 로깅 메커니즘을 구축해야 합니다.

앞으로는 실시간 통신의 복잡성을 과소평가하지 않고, 충분한 설계와 테스트를 바탕으로 안정적인 시스템을 구축하려 합니다.

https://sang914.tistory.com/20