백엔드트러블슈팅

[POP-SPOT] WebSocket STOMP 채팅 붙이다가 만난 에러들

김동현
··4분 읽기

POP-SPOT 메이트 채팅 기능 개발하면서 마주친 WebSocket STOMP 연결 오류, Security 막힘, Vercel 배포 후 CORS 403까지 삽질 기록.

WebSocket 채팅 기능은 이 프로젝트에서 제일 오래 걸린 부분임. 처음엔 단순하게 생각했는데 에러가 연속으로 터짐.

이 글은 만났던 에러 3개를 순서대로 정리한 것임.


시작 전 — WebSocket이 뭔지 모른다면

일반 인터넷 통신(HTTP)은 "요청 → 응답" 의 단방향 구조임.

클라이언트(앱)가 먼저 물어봐야 서버가 답함. 채팅처럼 "상대방이 보낸 메시지를 즉시 받아야 하는" 상황에 어울리지 않음.

WebSocket은 처음 연결 후 양방향 통신이 가능한 방식임.

한번 연결되면 서버도 언제든 클라이언트에게 메시지를 보낼 수 있음.

javascript
일반 HTTP:
사용자 ──요청──▶ 서버
사용자 ◀──응답── 서버

WebSocket:
사용자 ◀──────▶ 서버 (연결 유지, 양방향)

HTTP 폴링(1~2초마다 "새 메시지 있어?" 물어보는 방식)도 있지만

그건 자원 낭비가 심해서 WebSocket을 선택함.


기본 구조

Spring Boot 쪽에 STOMP 프로토콜을 얹어서 사용함.

STOMP가 뭔지 모른다면?

WebSocket 위에서 동작하는 메시지 규칙임. 순수 WebSocket은 "누구한테 보낼지"를 직접 짜야 하는데, STOMP는 채널 구독 방식으로 이걸 자동으로 처리해줌. 카카오톡으로 치면 채팅방 번호에 입장하면 그 방 메시지만 받는 것과 같음.

java
// WebSocketConfig.java — WebSocket 서버 설정
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/sub");            // /sub 경로 = 메시지 받는 곳 (구독)
        config.setApplicationDestinationPrefixes("/pub"); // /pub 경로 = 메시지 보내는 곳 (발행)
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-stomp")             // 연결 주소
            .setAllowedOriginPatterns("*")
            .withSockJS();                            // SockJS: WebSocket 안 될 때 자동 대체
    }
}

프론트(Next.js)에서는 이렇게 연결함.

typescript
const client = new Client({
  webSocketFactory: () => new SockJS(`${API_URL}/ws-stomp`), // 서버에 연결
  onConnect: () => {
    // 채팅방 구독 — 이 방에 메시지 오면 나한테도 알려줘
    client.subscribe(`/sub/chat/room/${roomId}`, (message) => {
      const body = JSON.parse(message.body);
      setMessages(prev => [...prev, body]); // 화면에 메시지 추가
    });
  }
});
client.activate(); // 연결 시작

에러 1 — 연결하자마자 404

증상: WebSocket 연결 시도하면 즉시 404 Not Found

원인: Spring Security가 /ws-stomp 경로를 로그인 없이 접근 못 하도록 막고 있었음.

Security는 기본적으로 모든 요청을 의심함. WebSocket 연결 경로도 예외 없이 차단함.

Security가 뭔지 모른다면?

서버 앞에 서 있는 경비원 같은 것. 기본 설정은 "모르는 사람은 전부 차단". 명시적으로 "이 경로는 통과시켜" 라고 등록해줘야 함.

해결:

java
// SecurityConfig.java
.requestMatchers("/ws-stomp/**").permitAll() // WebSocket 경로는 누구나 접근 가능

추가로 SockJS가 내부적으로 iframe을 사용하는데, Security가 기본으로 iframe도 막고 있어서 이것도 풀어야 했음.

iframe이 뭔지 모른다면?

웹 페이지 안에 또 다른 웹 페이지를 넣는 기술. SockJS가 WebSocket을 흉내내기 위해 내부적으로 사용함.

java
.headers(headers -> headers
    .frameOptions(f -> f.sameOrigin()) // 같은 출처의 iframe은 허용
)

에러 2 — 메시지를 보내도 화면에 안 나옴

증상: 연결은 됐음. 메시지 전송 로그(SEND)도 찍힘. 근데 채팅창에 아무것도 안 나옴.

원인: 서버 컨트롤러에서 DB 저장 중 에러가 발생하고 있었는데, 에러가 조용히 삼켜지면서 메시지를 전체에 뿌리는 @SendTo 단계까지 도달하지 못했던 것.

@SendTo가 뭔지 모른다면?

"이 메시지를 특정 채널 구독자 전원한테 뿌려라"는 명령어. 이게 실행돼야 채팅방 모든 사람 화면에 메시지가 나타남. DB 저장에서 터지면 여기까지 못 옴.

java
@MessageMapping("/chat/message")
@SendTo("/sub/chat/room/{roomId}") // 이 채널 구독자 전원에게 전송
public ChatMessage sendMessage(ChatMessage message) {
    try {
        chatMessageRepository.save(message); // ← 여기서 에러 나고 있었음
        return message;
    } catch (Exception e) {
        // 이 로그 없으면 에러 발생 자체를 모름
        System.out.println("메시지 저장 실패: " + e.getMessage());
        throw e;
    }
}

DB 저장 에러의 원인은 Oracle 시퀀스 문제였는데, 이후 PostgreSQL로 전환하면서 자연히 해결됨.

WebSocket 디버깅 팁: 에러가 조용히 삼켜지는 경우가 많아서 try-catch로 감싸고 로그를 찍어두는 게 필수임. 로그 없으면 에러 발생 여부조차 파악하기 힘듦.


에러 3 — Vercel 배포 후 CORS 403

증상: 로컬에서 다 됐는데 Vercel에 올리자마자 CORS 403 에러.

원인: WebSocket 설정(WebSocketConfig)이랑 Spring Security 설정(SecurityConfig)이 서로 따로 놀고 있었음. WebSocket 쪽에만 CORS를 허용해줬는데, Security가 먼저 요청을 막아버리고 있었던 것.

CORS가 뭔지 모른다면?

popspot.co.kr(프론트)이 api.popspot.co.kr(백엔드)에 요청할 때, 백엔드가 "이 주소에서 오는 요청, 내가 허용한 곳이야?" 를 확인하는 보안 정책. 허용 목록에 없으면 차단됨.

해결 — Security 레벨에서도 CORS 허용:

java
// SecurityConfig.java
@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOriginPatterns(List.of(
        "http://localhost:3000",    // 로컬 개발 주소
        "https://popspot.co.kr"     // 실제 배포 주소
    ));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowCredentials(true);
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}

추가로 SockJS는 연결 전에 "나 들어가도 돼?" 사전 확인 요청(OPTIONS)을 먼저 보내는데, 이것도 통과시켜줘야 함.

java
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()

에러 요약


마무리

WebSocket은 에러가 조용히 사라지는 경우가 많음. 프론트에서 onStompError, onDisconnect 콜백을 반드시 붙여두고, 백엔드에서도 메시지 처리 로직마다 로그를 심어두는 게 핵심임.