발행일

WebSocket connection to wss failed — 후보 열 개를 놓고 하루 종일 좁힌 기록

WebSocket connection to wss failed — 후보 열 개를 놓고 하루 종일 좁힌 기록

[TripTune](/blog/웹 기술로 만드는 협업형 여행 계획 플랫폼 TripTune 개발)의 일정 채팅은 STOMP over WebSocket으로 만들었습니다. 로컬에서는 잘 됐습니다. 그런데 Netlify에 배포하자 연결이 실패했어요.

WebSocket connection to 'ws://...:8080/ws' failed

이 에러 메시지의 문제는 원인 후보가 너무 많다는 것입니다. 서버가 죽었나, 포트가 막혔나, 엔드포인트가 틀렸나, 인증서 문제인가, CORS인가. 이날 하루에만 STOMP 설정을 바꿔 8번을 다시 배포했습니다. 이 글은 그 후보들을 하나씩 제거한 순서와, 결국 남은 진짜 원인의 기록입니다.


1. 원인 후보 전체 목록

디버깅을 시작할 때 놓고 있던 후보들입니다. 나중에 보니 이 목록을 만든 것 자체가 문제였는데, 그 얘기는 마지막에 합니다.

분류후보확인 방법
서버서버가 안 떠 있음같은 서버의 REST API 호출
서버방화벽/보안 그룹이 포트 차단telnet <host> <port>
서버WebSocket 엔드포인트(/ws) 불일치서버 설정과 대조
서버SSL 인증서 만료·미설정openssl s_client -connect <host>:<port>
클라이언트브로커 URL 오타코드 재확인
클라이언트CORS서버 응답 헤더 확인
환경로컬과 배포 환경의 차이

2. 제거 순서 — 로컬은 되고 배포만 안 된다는 사실이 가장 큰 힌트였다

같은 서버의 REST API는 배포에서도 잘 동작했습니다. 이걸로 "서버 다운, 방화벽, 포트" 계열은 한 번에 지워집니다. 서버까지 길은 뚫려 있으니까요.

엔드포인트와 URL 오타는 로컬이 된다는 사실이 지워줍니다. 같은 코드가 로컬에서 연결에 성공하니 주소 자체는 맞습니다.

남는 건 하나입니다. 로컬과 배포의 차이가 뭔가?

  • 로컬: http://localhost:3000 페이지에서 ws:// 연결
  • 배포: https:// 페이지에서 ws:// 연결

이 시점에 개발자 도구 콘솔을 자세히 보니, 연결 실패 위에 이런 메시지가 있었습니다.

Mixed Content: The page at 'https://…' was loaded over HTTPS,
but attempted to connect to the insecure WebSocket endpoint 'ws://…'.
This request has been blocked; this endpoint must be available over WSS.

https로 로드된 페이지에서 브라우저는 평문 ws:// 연결을 차단합니다. 이미지 같은 passive content는 경고로 끝나기도 하지만, WebSocket은 active content라 무조건 막힙니다. 요청이 서버에 도달조차 하지 않으니 서버 쪽을 아무리 뒤져도 나올 리가 없었어요.

이 차단은 브라우저 스펙(Mixed Content)이라 클라이언트에서 우회할 수 없습니다. 답은 하나, 서버가 wss://를 받게 만드는 것뿐입니다.


3. 해결 — wss 전환과 브로커 URL 분리

서버에 TLS를 얹어 wss://를 받게 하는 작업은 백엔드 담당 팀원과 함께 진행했습니다(서버 쪽 상세는 HTTP/HTTPS 에러 분석에 따로 정리 — 이 삽질 중에 서버 로그에 남은 이상한 16진수 요청의 정체를 거기서 다룹니다).

프런트에서는 브로커 주소를 하드코딩하지 않고 환경 변수로 분리했습니다. 로컬은 ws://, 배포는 wss://를 쓰는데, 이걸 코드 분기로 갖고 있으면 이번 같은 환경 차이 버그가 또 생깁니다.

// Chatting.tsx — 브로커 주소는 환경 변수에서만 온다
const brokerUrl = process.env.NEXT_PUBLIC_BROKER_URL;

const client = new Client({
  brokerURL: brokerUrl,
  connectHeaders: { Authorization: `Bearer ${token}` },
  reconnectDelay: 5000,      // 끊기면 5초 후 재연결
  heartbeatIncoming: 4000,   // 좀비 연결 감지
  heartbeatOutgoing: 4000,
});

.env.local에는 ws://localhost:8080/ws, 배포 환경 변수에는 wss://…/ws. 환경 차이를 코드가 아니라 설정이 흡수하게 됐습니다.


4. 다음에 같은 에러를 만나면 — 확인 순서

이날 배운 것을 순서로 만들면 이렇습니다. 1번이 이날 반나절을 아껴줬을 순서입니다.

  1. 콘솔의 연결 실패 메시지 "위"를 본다. Mixed Content 차단은 실패 로그보다 먼저 찍힌다. 여기서 걸리면 아래는 볼 필요도 없다.
  2. 페이지 프로토콜과 소켓 프로토콜을 대조한다. https 페이지면 wss 강제.
  3. 같은 서버의 REST가 되는지 본다. 되면 서버 다운·방화벽 후보 제거.
  4. 그래도 안 되면 그때 서버 쪽: telnet으로 포트, openssl s_client로 인증서, 서버 로그 순.
# 4번까지 갔을 때의 서버 쪽 확인
telnet <host> <port>                      # 포트가 열려 있는가
openssl s_client -connect <host>:<port>   # TLS 핸드셰이크가 되는가
npx wscat -c wss://<host>/ws              # 브라우저 밖에서 소켓 연결이 되는가

5. 돌아보면

  • "원인 후보 목록"부터 만든 게 실수였습니다. 후보 10개를 병렬로 의심하며 설정을 바꿔 8번 배포했는데, 처음부터 "로컬은 되고 배포만 안 된다"는 차이에 집중했으면 후보는 처음부터 한두 개였어요. 증상의 차이점이 후보 목록보다 강한 필터였습니다.
  • 콘솔은 끝까지 읽어야 합니다. Mixed Content 경고는 처음부터 찍혀 있었습니다. 연결 실패 로그만 보고 서버를 의심하러 간 게 반나절이었어요.
  • 한계 — 이 글의 해결은 "서버가 wss를 받게 한다"까지입니다. 인증서 자동 갱신이나 프록시(Nginx) 구성 같은 운영 단은 당시 다루지 않았고, 이후 서버 쪽 정리는 백엔드 담당의 영역이라 여기 없습니다. 또 reconnectDelay 5초·하트비트 4초는 측정으로 정한 값이 아니라 라이브러리 예제의 관례값을 그대로 쓴 것입니다.