채팅이 안 됐다. 정확히는 메시지를 입력창에 쳐도 아무 데도 안 갔다. 근데 방 목록이랑 과거 메시지는 멀쩡하게 보였다. 이 "절반만 되는" 증상이 원인을 가리키고 있었다.
이 글은 nginx 리버스 프록시가 WebSocket 핸드셰이크를 막고 있던 사고의 추적 기록이다.
방 목록 표시 ✅
과거 메시지 표시 ✅
실시간 메시지 전송 ❌
실시간 메시지 수신 ❌
방 목록과 과거 메시지는 일반 REST API로 가져온다. 이건 됐다. 그런데 실시간 채팅은 STOMP over WebSocket으로 돈다. 이게 안 됐다.
REST와 WebSocket의 차이가 핵심 단서였다. 둘 다 같은 백엔드, 같은 nginx를 거치는데 한쪽만 막혔다면 문제는 프로토콜 차이에 있다.
진단 — 핸드셰이크를 직접 시도
브라우저 콘솔만 봐서는 "연결 실패"라는 모호한 메시지뿐이었다. 그래서 서버에 WebSocket 핸드셰이크를 직접 쏴봤다
HTTP/1.1 400
Server: nginx/1.24.0 (Ubuntu)
...
Can "Upgrade" only to "WebSocket".
101 Switching Protocols가 나와야 정상인데 400이 떨어졌다. 그리고 에러 메시지가 백엔드 애플리케이션 것이었다. nginx까지는 요청이 도달했고, 백엔드까지도 갔는데, 백엔드가 "이건 WebSocket 업그레이드 요청이 아닌데?"라며 거부한 거다.
원인 — nginx가 헤더를 무시
WebSocket 연결은 일반 HTTP 요청으로 시작해서 프로토콜을 업그레이드하는 방식이다. 이 업그레이드는 두 헤더로 신호한다.
Upgrade: websocket
Connection: upgrade
클라이언트는 이 헤더를 보냈다. 그런데 nginx가 백엔드로 프록시하면서 이 두 헤더를 기본값대로 떨어뜨렸다. nginx의 기본 동작은 hop-by-hop 헤더인 Connection과 그에 연결된 Upgrade를 다음 홉으로 전달하지 않는 것이다.
결국 백엔드는 업그레이드 신호가 빠진 평범한 HTTP 요청을 받았고, "WebSocket 핸드셰이크인 줄 알았는데 아니네"라며 400을 반환했다.
소켓이 안 붙으니 구독도 전송도 불가능했다. 입력창에 친 메시지가 어디론가 사라진 게 아니라, 애초에 나갈 통로가 없었다.
해결 — 두 군데 수정
1. nginx.conf에 map 디렉티브
http {} 블록에 업그레이드 헤더를 매핑하는 변수를 추가했다.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Upgrade 헤더가 있으면 Connection: upgrade로, 없으면 Connection: close로 매핑한다. WebSocket이 아닌 일반 요청에는 영향을 주지 않으면서 업그레이드 요청만 살린다
2. WebSocket 전용 location
api.refitspace.art 서버 블록에 STOMP 엔드포인트용 location을 추가했다.
location /ws-stomp {
proxy_pass http://refit_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
핵심은 세 가지다. proxy_http_version 1.1은 WebSocket이 HTTP/1.1을 요구하기 때문에 필수다. nginx의 기본 프록시 버전은 1.0이라 업그레이드 자체가 안 된다. Upgrade와 Connection 헤더 전달은 위에서 정의한 map 변수를 사용한다. proxy_read_timeout 3600s는 유휴 WebSocket 연결이 기본 60초 만에 끊기는 걸 막는다.
적용
sudo nginx -t && sudo systemctl reload nginx
검증
같은 curl을 다시 쏴봤다.
HTTP/1.1 101
Connection: upgrade
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
400이 101 Switching Protocols로 바뀌었다. 프론트에서 채팅을 다시 열자 메시지가 실시간으로 오갔다.
정리하며
이번 사고의 교훈은 두 가지다.
증상의 비대칭이 원인을 가리킨다. "REST는 되는데 WebSocket만 안 된다"는 사실 자체가 진단의 9할이었다. 둘 다 같은 경로를 지나는데 한쪽만 막혔다면, 두 프로토콜이 다르게 취급되는 지점을 찾으면 된다. 그게 nginx의 헤더 처리였다.
모호한 에러는 직접 재현해서 좁힌다. 브라우저 콘솔의 "연결 실패"로는 아무것도 알 수 없었다. curl로 핸드셰이크를 직접 쏴서 400과 백엔드 에러 메시지를 받고 나서야 "nginx는 통과했고 헤더가 빠졌다"는 정확한 그림이 나왔다.
리버스 프록시 뒤에 WebSocket을 두면 거의 항상 이 설정이 필요하다. nginx가 기본적으로 업그레이드 헤더를 전달하지 않기 때문이다. STOMP, Socket.IO, 순수 WebSocket 전부 마찬가지다.
'인프라' 카테고리의 다른 글
| 미니 페일오버 VPC 설계 (0) | 2026.05.22 |
|---|---|
| 백업 시스템 완성하기 — 라이프사이클부터 복구 검증까지 (0) | 2026.05.18 |
| PostgreSQL 자동 백업 시스템 구축기: 홈서버 DB를 S3로 (0) | 2026.05.18 |