- 발행일
서버 로그에 찍힌 16진수 메서드 이름의 정체 — wss 삽질의 서버 쪽 이야기
서버 로그에 찍힌 16진수 메서드 이름의 정체
wss 연결 실패를 디버깅하던 날, 백엔드 담당 팀원이 서버 로그를 공유해 줬습니다. 프런트 쪽에서는 연결이 그냥 실패했다고만 나오는데, 서버에는 이상한 게 찍혀 있었어요.
o.apache.coyote.http11.Http11Processor : Error parsing HTTP request header
java.lang.IllegalArgumentException: Invalid character found in method name
[0x16, 0x03, 0x01, ...]. HTTP method names must be tokens
HTTP 메서드 이름이라면 GET, POST 같은 문자열이어야 하는데 16진수 바이트가 메서드 자리에 들어와 있습니다. 처음 든 생각은 "외부에서 악성 트래픽이 들어오나?"였습니다. 결론부터 말하면 공격이 아니라 우리 클라이언트가 보낸 TLS 핸드셰이크였습니다.
1. 0x16 0x03 0x01은 TLS의 첫인사다
wss://(또는 https://)로 연결하면 클라이언트는 데이터를 보내기 전에 TLS 핸드셰이크부터 시작합니다. 그 첫 패킷(ClientHello)의 앞부분이 정확히 이 바이트들입니다.
| 바이트 | 의미 |
|---|---|
0x16 | TLS 레코드 타입: Handshake |
0x03 0x01 | 프로토콜 버전 (TLS 1.0 표기 — 호환성 때문에 첫 레코드에 이렇게 적는 관례) |
그런데 받는 쪽 포트는 평문 HTTP만 처리하는 Tomcat이었습니다. Tomcat은 들어온 바이트를 HTTP 요청으로 읽으려 하고, 요청의 첫 토큰(메서드 이름) 자리에서 0x16을 만나 "메서드 이름에 유효하지 않은 문자"라며 죽습니다. 즉 이 로그는:
**"암호화된 인사를 평문만 아는 서버에 건넸다"**는 뜻입니다. 클라이언트 잘못도 서버 잘못도 아니고, 양쪽 프로토콜이 안 맞는다는 신호예요.
이걸 알고 나니 반대 방향의 대응표도 만들 수 있었습니다. 프로토콜 불일치는 어느 쪽이 틀렸느냐에 따라 에러가 다른 곳에 찍힙니다.
| 클라이언트 | 서버 | 증상이 찍히는 곳 |
|---|---|---|
wss:// | HTTP만 지원 | 서버 로그 — 위의 16진수 메서드 에러 |
ws:// | HTTPS 페이지에서 | 브라우저 콘솔 — Mixed Content 차단 (서버엔 아무것도 안 옴) |
ws:// | HTTPS만 지원(리다이렉트) | 클라이언트 — 핸드셰이크 중 연결 종료 |
우리는 이 표의 1행과 2행을 동시에 겪고 있었습니다. https 페이지라 ws://는 브라우저가 막고, 그래서 wss://로 바꾸면 서버가 TLS를 못 받는 상태. 클라이언트만 고쳐서는 풀 수 없는 교착이었고, 서버에 TLS를 얹어야만 끝나는 문제였습니다.
2. 서버 쪽 선택지 — 직접 TLS vs 프록시
서버가 wss://를 받게 하는 방법은 두 갈래였습니다. 서버 작업은 백엔드 담당 팀원 몫이었지만, 어느 쪽으로 갈지는 함께 정리했습니다.
| 방법 | 장점 | 포기하는 것 | 판단 |
|---|---|---|---|
Spring Boot에 직접 TLS (server.ssl.*) | 구성 요소 추가 없음, 설정 몇 줄 | 인증서 갱신 때마다 앱 재배포, 포트별 인증서 관리 | 2인 팀 초기엔 이걸로 |
| Nginx 프록시가 TLS 종료 후 백엔드에 평문 전달 | 인증서 관리 일원화, 앱 무관 갱신 | 구성 요소 하나 추가, 프록시의 WebSocket 업그레이드 설정(Upgrade/Connection 헤더) 필요 | 운영이 길어지면 이쪽이 정석 |
당시엔 빠른 쪽(직접 TLS)을 택했습니다. Spring Boot 기준 설정은 이 정도입니다:
server.port=443
server.ssl.key-store=classpath:keystore.p12
server.ssl.key-store-password=<password>
server.ssl.key-store-type=PKCS12
프록시 방식을 미룬 대가는 예상대로 인증서 갱신 운영으로 돌아왔습니다. 앱에 인증서가 박혀 있으니 갱신이 곧 재배포였어요.
3. 재발 시 판별 절차
같은 계열의 문제를 다시 만나면, 로그가 찍힌 위치부터 봅니다.
- 브라우저 콘솔에 Mixed Content가 있다 → 클라이언트가
ws://를 https 페이지에서 쏘는 중. 서버는 볼 필요 없음. - 서버 로그에 16진수 메서드 에러가 있다 → 클라이언트는 TLS(
wss://)로 말하는데 서버 포트가 평문. 서버에 TLS를 얹거나 프록시 필요. - 양쪽 다 조용한데 연결만 끊긴다 → 그때 중간 장비(프록시의 업그레이드 헤더, 로드밸런서 타임아웃)를 의심.
# 포트가 TLS를 받는지 즉석 확인 — 인증서 체인이 나오면 TLS, 즉시 끊기면 평문
openssl s_client -connect <host>:<port>
4. 돌아보면
- 에러가 찍힌 위치 자체가 정보였습니다. 클라이언트 에러는 "브라우저가 막았다", 서버의 파싱 에러는 "서버까지 왔는데 언어가 다르다". 이 구분만 있었어도 원인 반경이 처음부터 절반이었어요.
- "악성 트래픽인가?"는 편한 가설이라 위험했습니다. 외부 탓 가설은 우리 코드를 안 봐도 되게 해줘서 매력적인데, 16진수 값을 검색해 보는 5분이 가설 하나를 통째로 지웠습니다. 모르는 바이트는 일단 검색.
- 한계 — 이 글의 서버 쪽 서술은 백엔드 담당 팀원의 작업을 옆에서 정리한 것이라, Spring 설정의 세부 시행착오까지는 담지 못했습니다. 또 직접 TLS를 택한 판단은 "2인 팀의 속도" 기준이었고, 인증서 갱신 자동화(certbot 등)를 붙이기 전까지는 갱신이 수동 운영으로 남았습니다.