LabHub

블로그

HTTP/HTTPS 트러블슈팅 완벽 가이드 - 실무에서 바로 쓰는 디버깅 기법

한국어English日本語

들어가며

운영 환경에서 HTTP/HTTPS 관련 문제가 발생하면, 단순히 "안 돼요"라는 보고만으로는 원인을 파악하기 어렵다. 네트워크 레이어, TLS 핸드셰이크, 애플리케이션 로직, 로드밸런서 설정 등 여러 계층에서 문제가 발생할 수 있기 때문이다.

이 글에서는 실무에서 자주 만나는 HTTP/HTTPS 문제를 체계적으로 진단하고 해결하는 방법을 다룬다.

1. HTTP 상태 코드 - 디버깅 관점에서의 재해석

1xx (정보 응답)

실무에서 자주 간과되지만 중요한 상태 코드가 있다.

100 Continue    → 클라이언트가 큰 요청 본문을 보내기 전, 서버가 수락 의사를 표시
101 SwitchingWebSocket 업그레이드 시 반드시 확인해야 하는 코드
103 Early Hints → 브라우저 preload 최적화에 활용

2xx (성공)

200 OK          → 가장 기본. 하지만 본문이 비어 있으면 의심해야 한다
201 CreatedPOSTLocation 헤더 확인 필수
204 No ContentDELETE 응답으로 적합. 본문이 있으면 버그
206 PartialRange 요청 시. 대용량 파일 다운로드 디버깅에 핵심

3xx (리다이렉션) - 함정이 많은 영역

301 Moved Permanently  → 브라우저가 캐시함. 잘못 설정하면 복구가 어렵다
302 Found              → 임시 리다이렉션. SEO 관점에서 301과 혼동 주의
303 See OtherPOSTGET으로 리다이렉트할 때 사용
307 Temporary Redirect → 메서드를 유지하는 리다이렉트
308 Permanent Redirect → 메서드를 유지하는 영구 리다이렉트

4xx (클라이언트 에러) - 디버깅 핵심

# 400 Bad Request 디버깅
curl -v -X POST https://api.example.com/users \
  -H "Content-Type: application/json" \
  -d '{"name": "test"' # 잘못된 JSON - 닫는 괄호 누락

# 401 vs 403 차이 이해
# 401: 인증 자체가 안 됨 (토큰 없음/만료)
# 403: 인증은 됐으나 권한이 없음

# 405 Method Not Allowed
curl -v -X PATCH https://api.example.com/users/1
# Allow 헤더에서 허용된 메서드 확인

# 429 Too Many Requests
# Retry-After 헤더와 X-RateLimit-* 헤더 확인
curl -v https://api.example.com/data 2>&1 | grep -i "rate\|retry"

5xx (서버 에러) - 가장 긴급한 상황

500 Internal Server Error → 서버 로그 확인이 최우선
502 Bad Gateway           → 업스트림 서버 응답 불가 (프록시 뒤 서버 문제)
503 Service Unavailable   → 서버 과부하 또는 점검 중
504 Gateway Timeout       → 업스트림 서버 응답 시간 초과

2. TLS/SSL 핸드셰이크 트러블슈팅

TLS 핸드셰이크 과정 이해

Client                                Server
  |                                     |
  |--- ClientHello (지원 암호화, SNI) --->|
  |                                     |
  |<-- ServerHello (선택된 암호화) ------|
  |<-- Certificate (인증서 체인) --------|
  |<-- ServerKeyExchange ---------------|
  |<-- ServerHelloDone -----------------|
  |                                     |
  |--- ClientKeyExchange -------------->|
  |--- ChangeCipherSpec --------------->|
  |--- Finished ----------------------->|
  |                                     |
  |<-- ChangeCipherSpec ----------------|
  |<-- Finished ------------------------|
  |                                     |
  |====== 암호화된 통신 시작 ============|

openssl을 이용한 핸드셰이크 디버깅

# 기본 연결 테스트
openssl s_client -connect example.com:443 -servername example.com

# TLS 버전 명시 테스트
openssl s_client -connect example.com:443 -tls1_2
openssl s_client -connect example.com:443 -tls1_3

# 인증서 체인 전체 출력
openssl s_client -connect example.com:443 -showcerts 2>/dev/null | \
  openssl x509 -noout -subject -issuer -dates

# SNI(Server Name Indication) 문제 디버깅
openssl s_client -connect 1.2.3.4:443 -servername mysite.com

# 특정 암호화 스위트 테스트
openssl s_client -connect example.com:443 -cipher ECDHE-RSA-AES256-GCM-SHA384

자주 발생하는 TLS 에러와 해결법

# 에러: SSL_ERROR_HANDSHAKE_FAILURE
# 원인: 클라이언트와 서버 간 지원하는 암호화 스위트가 없음
# 해결: 서버의 cipher suite 확인
nmap --script ssl-enum-ciphers -p 443 example.com

# 에러: CERTIFICATE_VERIFY_FAILED
# 원인: 인증서 체인 불완전 또는 루트 CA 미신뢰
# 진단:
openssl s_client -connect example.com:443 2>&1 | grep "verify"

# 에러: hostname mismatch
# 원인: 인증서의 CN/SAN이 요청 도메인과 불일치
openssl s_client -connect example.com:443 2>/dev/null | \
  openssl x509 -noout -text | grep -A1 "Subject Alternative Name"

3. 인증서 체인 검증 문제

인증서 체인 구조

Root CA (자체 서명, OS/브라우저에 내장)
  └── Intermediate CA (Root CA가 서명)
        └── Server Certificate (Intermediate CA가 서명)

체인 검증 스크립트

#!/bin/bash
# 인증서 체인 검증 스크립트
DOMAIN=$1

echo "=== 인증서 체인 검증: $DOMAIN ==="

# 서버에서 인증서 체인 가져오기
echo | openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} \
  -showcerts 2>/dev/null | \
  awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/{ print }' > /tmp/chain.pem

# 각 인증서 정보 출력
csplit -f /tmp/cert- -b '%02d.pem' /tmp/chain.pem \
  '/BEGIN CERTIFICATE/' '{*}' 2>/dev/null

for cert in /tmp/cert-*.pem; do
  [ -s "$cert" ] || continue
  echo "--- Certificate: $cert ---"
  openssl x509 -in "$cert" -noout \
    -subject -issuer -dates -fingerprint 2>/dev/null
  echo ""
done

# 체인 검증
openssl verify -verbose -CAfile /etc/ssl/certs/ca-certificates.crt \
  /tmp/chain.pem 2>&1

일반적인 인증서 문제

# 1. 인증서 만료 확인
echo | openssl s_client -connect example.com:443 2>/dev/null | \
  openssl x509 -noout -dates

# 2. 중간 인증서 누락 확인
# SSL Labs 테스트 (가장 확실한 방법)
# https://www.ssllabs.com/ssltest/

# 3. 자체 서명 인증서 사용 시 curl
curl --cacert /path/to/ca.crt https://internal.example.com

# 4. 인증서 갱신 후 적용 확인
echo | openssl s_client -connect example.com:443 2>/dev/null | \
  openssl x509 -noout -serial -fingerprint

4. curl 디버깅 기법

기본 디버깅 옵션

# -v (verbose): 가장 기본적인 디버깅
curl -v https://api.example.com/health

# -vv: 더 상세한 출력
curl -vv https://api.example.com/health

# --trace: 바이트 단위 전체 통신 덤프
curl --trace /tmp/curl-trace.log https://api.example.com/health

# --trace-ascii: 가독성 좋은 ASCII 덤프
curl --trace-ascii /tmp/curl-trace.txt https://api.example.com/health

# --trace-time: 타임스탬프 포함
curl --trace-time --trace-ascii - https://api.example.com/health

고급 디버깅 기법

# DNS 우회 (--resolve): 특정 IP로 요청 보내기
# 배포 전 새 서버 테스트에 유용
curl --resolve api.example.com:443:10.0.1.50 \
  https://api.example.com/health

# 연결 시간 측정
curl -o /dev/null -s -w "\
  DNS Lookup:    %{time_namelookup}s\n\
  TCP Connect:   %{time_connect}s\n\
  TLS Handshake: %{time_appconnect}s\n\
  Start Transfer: %{time_starttransfer}s\n\
  Total Time:    %{time_total}s\n\
  HTTP Code:     %{http_code}\n\
  Download Size: %{size_download} bytes\n" \
  https://api.example.com/health

# 리다이렉트 추적
curl -v -L --max-redirs 10 https://example.com 2>&1 | grep "< Location"

# 특정 HTTP 버전 강제
curl --http1.1 https://api.example.com/health
curl --http2 https://api.example.com/health
curl --http3 https://api.example.com/health

# 프록시 경유 디버깅
curl -v --proxy http://proxy.internal:8080 https://api.example.com/health

# 클라이언트 인증서 사용
curl --cert /path/to/client.crt --key /path/to/client.key \
  https://mtls.example.com/api

curl 실전 디버깅 원라이너

# 연속 요청으로 응답 시간 모니터링
for i in $(seq 1 10); do
  echo -n "요청 $i: "
  curl -o /dev/null -s -w "%{http_code} %{time_total}s" \
    https://api.example.com/health
  echo ""
  sleep 1
done

# 헤더만 빠르게 확인
curl -I https://api.example.com/health

# POST 요청 디버깅
curl -v -X POST https://api.example.com/users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"name": "test", "email": "test@example.com"}' \
  2>&1 | grep -E "^[<>*]"

5. 502/503/504 에러 트러블슈팅

502 Bad Gateway

# 원인: 업스트림 서버가 유효하지 않은 응답을 반환

# 1. 업스트림 서버 직접 확인
curl -v http://upstream-server:8080/health

# 2. Nginx 에러 로그 확인
tail -f /var/log/nginx/error.log | grep "502\|upstream"

# 3. 업스트림 서버 포트 리스닝 확인
ss -tlnp | grep 8080
netstat -tlnp | grep 8080

# 4. Nginx 설정 점검
# proxy_pass에서 업스트림 주소가 올바른지 확인
nginx -T | grep -A5 "upstream"

Nginx 502 해결을 위한 설정 조정:

upstream backend {
    server 10.0.1.10:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

server {
    location /api/ {
        proxy_pass http://backend;
        proxy_next_upstream error timeout http_502 http_503;
        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;

        # 버퍼 크기 조정 (큰 응답 헤더 처리)
        proxy_buffer_size 16k;
        proxy_buffers 4 32k;
        proxy_busy_buffers_size 64k;
    }
}

503 Service Unavailable

# 원인 분석
# 1. 서버 과부하 확인
top -bn1 | head -5
free -m
df -h

# 2. 연결 수 확인
ss -s
ss -tn state established | wc -l

# 3. 프로세스 상태 확인
systemctl status nginx
systemctl status your-app

# 4. 리소스 제한 확인
ulimit -n  # 파일 디스크립터 수
cat /proc/sys/net/core/somaxconn  # 소켓 백로그

504 Gateway Timeout

# 원인: 업스트림 서버 응답 시간 초과

# 1. 타임아웃 단계별 진단
curl -o /dev/null -s -w "connect: %{time_connect}s\nttfb: %{time_starttransfer}s\ntotal: %{time_total}s\n" \
  https://api.example.com/slow-endpoint

# 2. 느린 쿼리 확인 (DB 원인인 경우)
# MySQL
SHOW PROCESSLIST;
# PostgreSQL
SELECT pid, now() - pg_stat_activity.query_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active' AND now() - query_start > interval '5 seconds';

# 3. 타임아웃 설정 조정
# Nginx: proxy_read_timeout 120s;
# HAProxy: timeout server 120s

6. CORS 문제 해결

CORS 작동 원리

브라우저                          서버
  |                                |
  |--- Preflight (OPTIONS) ------->|
  |    Origin: https://app.com     |
  |    Access-Control-Request-Method: POST
  |                                |
  |<-- 200 OK --------------------|
  |    Access-Control-Allow-Origin: https://app.com
  |    Access-Control-Allow-Methods: POST, GET
  |    Access-Control-Allow-Headers: Content-Type
  |    Access-Control-Max-Age: 86400
  |                                |
  |--- 실제 요청 (POST) ----------->|
  |    Origin: https://app.com     |
  |                                |
  |<-- 200 OK --------------------|
  |    Access-Control-Allow-Origin: https://app.com

CORS 디버깅

# Preflight 요청 시뮬레이션
curl -v -X OPTIONS https://api.example.com/data \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization"

# 응답에서 CORS 헤더 확인
curl -v https://api.example.com/data \
  -H "Origin: https://app.example.com" \
  2>&1 | grep -i "access-control"

Nginx에서 CORS 설정

location /api/ {
    # Preflight 요청 처리
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '$http_origin' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Requested-With' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Max-Age' 86400;
        add_header 'Content-Length' 0;
        return 204;
    }

    # 실제 요청에도 CORS 헤더 추가
    add_header 'Access-Control-Allow-Origin' '$http_origin' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;

    proxy_pass http://backend;
}

7. 리다이렉트 루프 디버깅

# 리다이렉트 체인 추적
curl -v -L --max-redirs 20 https://example.com 2>&1 | \
  grep -E "^< (HTTP|Location)" | head -30

# 결과 예시 (무한 루프):
# < HTTP/1.1 301 Moved Permanently
# < Location: https://www.example.com/
# < HTTP/1.1 301 Moved Permanently
# < Location: https://example.com/
# < HTTP/1.1 301 Moved Permanently
# < Location: https://www.example.com/   ← 루프!

# 흔한 원인:
# 1. HTTP → HTTPS 리다이렉트 + HTTPS → HTTP 리다이렉트 충돌
# 2. www ↔ non-www 리다이렉트 충돌
# 3. 로드밸런서의 SSL termination + 앱의 HTTPS 강제

# 해결: X-Forwarded-Proto 헤더 활용
# Nginx에서 백엔드로 프로토콜 정보 전달
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

8. HTTP/2 및 HTTP/3 디버깅

HTTP/2 디버깅

# HTTP/2 지원 확인
curl -v --http2 https://example.com 2>&1 | grep "ALPN"

# HTTP/2 프레임 레벨 디버깅 (nghttp2)
nghttp -v https://example.com

# HTTP/2 멀티플렉싱 확인
nghttp -v -m 10 https://example.com/style.css \
  https://example.com/script.js \
  https://example.com/image.png

# HTTP/2 서버 푸시 확인
nghttp -v --stat https://example.com 2>&1 | grep "push"

# h2c (HTTP/2 Cleartext) 테스트
curl -v --http2-prior-knowledge http://localhost:8080/health

HTTP/3 (QUIC) 디버깅

# HTTP/3 지원 확인
curl --http3 -v https://example.com 2>&1 | head -20

# Alt-Svc 헤더 확인 (HTTP/3 광고)
curl -sI https://example.com | grep -i "alt-svc"
# 예: alt-svc: h3=":443"; ma=86400

# QUIC 연결 디버깅 (quiche 도구)
# 0-RTT 연결 테스트
curl --http3 -v --connect-to example.com:443:server-ip:443 \
  https://example.com

HTTP/2와 HTTP/3의 일반적인 문제

# 1. HTTP/2의 HEAD-OF-LINE blocking 확인
# HTTP/2는 TCP 레이어에서 HOL blocking 발생 가능
# 해결: HTTP/3(QUIC) 사용 검토

# 2. HTTP/2 GOAWAY 프레임 디버깅
nghttp -v https://example.com 2>&1 | grep "GOAWAY"

# 3. HPACK 헤더 압축 문제
# 큰 헤더를 가진 요청에서 HPACK 동적 테이블 크기 확인
nghttp -v --header-table-size=65536 https://example.com

# 4. 스트림 동시성 제한
# SETTINGS_MAX_CONCURRENT_STREAMS 확인
nghttp -v https://example.com 2>&1 | grep "MAX_CONCURRENT"

9. 로드밸런서 및 리버스 프록시 트러블슈팅

헬스체크 실패 디버깅

# 1. 직접 헬스체크 엔드포인트 확인
curl -v http://backend-server:8080/health

# 2. 로드밸런서에서 보는 것과 동일한 조건으로 테스트
curl -v -H "Host: api.example.com" \
  --resolve api.example.com:80:10.0.1.10 \
  http://api.example.com/health

# 3. HAProxy 상태 확인
echo "show stat" | socat stdio /var/run/haproxy/admin.sock | \
  awk -F',' '{print $1, $2, $18, $19}'

# 4. Nginx 업스트림 상태 (nginx-module-vts 사용 시)
curl http://localhost/status/format/json | jq '.upstreamZones'

세션 고정(Sticky Session) 문제

# 쿠키 기반 세션 확인
curl -v -c cookies.txt https://app.example.com/login
curl -v -b cookies.txt https://app.example.com/dashboard

# 같은 백엔드로 라우팅되는지 확인
for i in $(seq 1 5); do
  curl -s -b cookies.txt https://app.example.com/api/server-id
  echo ""
done

X-Forwarded-* 헤더 디버깅

# 프록시 체인 확인
curl -v https://api.example.com/debug-headers 2>&1

# 예상되는 헤더:
# X-Forwarded-For: client-ip, proxy1-ip, proxy2-ip
# X-Forwarded-Proto: https
# X-Forwarded-Host: api.example.com
# X-Real-IP: client-ip

# 프록시가 원본 IP를 올바르게 전달하는지 테스트
curl -H "X-Forwarded-For: 1.2.3.4" https://api.example.com/my-ip

10. 실전 디버깅 시나리오

시나리오 1: 간헐적 502 에러

#!/bin/bash
# 간헐적 502 모니터링 스크립트
URL="https://api.example.com/health"
LOG_FILE="/tmp/502-monitor.log"

echo "=== 502 모니터링 시작: $(date) ===" >> $LOG_FILE

while true; do
  RESPONSE=$(curl -s -o /dev/null -w "%{http_code}|%{time_total}|%{time_connect}|%{time_starttransfer}" $URL)
  HTTP_CODE=$(echo $RESPONSE | cut -d'|' -f1)
  TOTAL_TIME=$(echo $RESPONSE | cut -d'|' -f2)
  CONNECT_TIME=$(echo $RESPONSE | cut -d'|' -f3)
  TTFB=$(echo $RESPONSE | cut -d'|' -f4)

  if [ "$HTTP_CODE" != "200" ]; then
    TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[$TIMESTAMP] HTTP $HTTP_CODE | Total: ${TOTAL_TIME}s | Connect: ${CONNECT_TIME}s | TTFB: ${TTFB}s" >> $LOG_FILE

    # 502 발생 시 상세 정보 수집
    if [ "$HTTP_CODE" = "502" ]; then
      echo "  Detailed trace:" >> $LOG_FILE
      curl -v $URL 2>> $LOG_FILE
      echo "---" >> $LOG_FILE
    fi
  fi

  sleep 5
done

시나리오 2: TLS 인증서 만료 모니터링

#!/bin/bash
# 인증서 만료 모니터링 스크립트
DOMAINS=(
  "api.example.com"
  "app.example.com"
  "admin.example.com"
)
WARNING_DAYS=30

for domain in "${DOMAINS[@]}"; do
  EXPIRY=$(echo | openssl s_client -connect ${domain}:443 -servername ${domain} 2>/dev/null | \
    openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)

  if [ -z "$EXPIRY" ]; then
    echo "[ERROR] $domain: 연결 실패"
    continue
  fi

  EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$EXPIRY" +%s 2>/dev/null)
  NOW_EPOCH=$(date +%s)
  DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))

  if [ $DAYS_LEFT -lt 0 ]; then
    echo "[CRITICAL] $domain: 인증서 만료됨! ($EXPIRY)"
  elif [ $DAYS_LEFT -lt $WARNING_DAYS ]; then
    echo "[WARNING] $domain: ${DAYS_LEFT}일 후 만료 ($EXPIRY)"
  else
    echo "[OK] $domain: ${DAYS_LEFT}일 남음 ($EXPIRY)"
  fi
done

시나리오 3: 전체 HTTP 통신 종합 진단

#!/bin/bash
# HTTP 종합 진단 스크립트
URL=${1:-"https://example.com"}
echo "============================================"
echo "HTTP 종합 진단: $URL"
echo "시간: $(date)"
echo "============================================"

# 1. DNS 확인
echo -e "\n[1] DNS 확인"
DOMAIN=$(echo $URL | awk -F[/:] '{print $4}')
dig +short $DOMAIN A
dig +short $DOMAIN AAAA

# 2. 포트 연결 확인
echo -e "\n[2] TCP 연결 확인"
nc -zv $DOMAIN 443 2>&1

# 3. TLS 정보
echo -e "\n[3] TLS 정보"
echo | openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} 2>/dev/null | \
  grep -E "Protocol|Cipher|Verify"

# 4. 인증서 정보
echo -e "\n[4] 인증서 정보"
echo | openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} 2>/dev/null | \
  openssl x509 -noout -subject -issuer -dates 2>/dev/null

# 5. HTTP 응답
echo -e "\n[5] HTTP 응답"
curl -s -o /dev/null -w "\
  HTTP Code:      %{http_code}\n\
  DNS Lookup:     %{time_namelookup}s\n\
  TCP Connect:    %{time_connect}s\n\
  TLS Handshake:  %{time_appconnect}s\n\
  TTFB:           %{time_starttransfer}s\n\
  Total Time:     %{time_total}s\n\
  Download Size:  %{size_download} bytes\n\
  HTTP Version:   %{http_version}\n\
  Remote IP:      %{remote_ip}\n" $URL

# 6. 응답 헤더
echo -e "\n[6] 주요 응답 헤더"
curl -sI $URL | grep -iE "^(server|x-|content-type|cache-control|strict|location|set-cookie)"

# 7. 보안 헤더 확인
echo -e "\n[7] 보안 헤더 검사"
HEADERS=$(curl -sI $URL)
for header in "Strict-Transport-Security" "X-Content-Type-Options" "X-Frame-Options" "Content-Security-Policy" "X-XSS-Protection"; do
  if echo "$HEADERS" | grep -qi "$header"; then
    echo "  [O] $header"
  else
    echo "  [X] $header (누락)"
  fi
done

echo -e "\n============================================"
echo "진단 완료"
echo "============================================"

11. 상태 코드는 규범 문서로 돌아가서 읽는다

현장에서 도는 상태 코드 해석의 상당수는 관행이지 규범이 아니다. HTTP 시맨틱스는 RFC 9110에 정의되어 있고, 애매한 순간에는 이 문서로 돌아가면 논쟁이 끝난다. 특히 리다이렉트와 4xx 계열은 규범과 관행의 차이가 그대로 버그가 된다.

리다이렉트에서 가장 자주 틀리는 지점은 메서드 보존이다. 302는 사용자 에이전트가 POST를 GET으로 바꿔도 되는 코드이고, 303은 후속 요청에 반드시 GET을 쓰도록 요구한다. 반면 307과 308은 요청 메서드를 바꿔서는 안 된다고 규정한다. 즉 POST를 받는 엔드포인트를 HTTPS로 강제 리다이렉트하면서 301이나 302를 쓰면, 클라이언트에 따라 본문이 사라진 GET이 도착한다. 로그에는 500도 4xx도 남지 않고 그저 "요청이 비어 있다"는 애플리케이션 오류만 남기 때문에 원인을 찾는 데 오래 걸린다. 프로토콜 강제 리다이렉트는 308을, POST 처리 후의 화면 전환은 303을 쓰는 것이 규범에 맞는 선택이다.

401과 403의 구분도 규범이 명확하다. 401은 대상 리소스에 대한 유효한 인증 자격 증명이 없다는 뜻이고, 403은 서버가 요청을 이해했지만 수행을 거부한다는 뜻이며 서버는 그 이유를 설명할 의무가 없다. 여기서 실무적으로 중요한 것은 두 번째 문장이다. 403 응답 본문에 상세한 사유를 넣는 것은 규범이 요구하는 바가 아니고, 오히려 권한 구조를 노출할 수 있다. 반대로 401을 받았는데 재인증 경로가 안내되지 않으면 클라이언트는 무한 재시도에 빠진다.

405는 규범이 서버에 의무를 지우는 드문 코드다. 405를 낼 때 서버는 허용되는 메서드를 나열한 Allow 헤더를 반드시 생성해야 한다. 405를 받았는데 이 헤더가 없다면 그 자체가 서버 측 버그이고, API 게이트웨이가 응답 헤더를 걸러내고 있는지 먼저 의심해야 한다.

# 405 응답에서 Allow 헤더가 실제로 왔는지 확인한다
curl -sD - -o /dev/null -X PATCH https://api.example.com/users/1 | grep -i '^allow:'

429는 RFC 9110에 없다. Too Many Requests는 RFC 6585에서 정의된 코드다. 이 사실이 실무에서 의미가 있는 이유는, 429의 동작 세부가 9110의 다른 코드만큼 일관되게 다뤄지지 않기 때문이다. Retry-After의 해석과 재시도 정책은 서비스마다 다르므로, 클라이언트를 만들 때는 해당 API 문서를 별도로 확인해야 한다. 참고로 Retry-After는 503에 대해 규범이 명시적으로 언급하는 헤더이기도 하다.

마지막으로 421 Misdirected Request는 이름만으로는 무엇인지 알기 어렵지만 HTTP/2 환경에서 실제로 마주치는 코드다. 규범은 이를 대상 URI에 대해 권위 있는 응답을 생성할 수 없는 서버로 요청이 전달된 경우로 정의한다. HTTP/2 클라이언트가 인증서의 SAN이 겹친다는 이유로 서로 다른 호스트의 요청을 하나의 연결에 합치는 커넥션 병합이 원인일 때가 많다. 특정 브라우저에서만 간헐적으로 실패하고 curl로는 재현되지 않는다면 이 경우를 의심한다.

12. 5분 안에 계층을 좁히는 순서

장애 상황에서 필요한 것은 도구 목록이 아니라 순서다. 아래 순서는 위에서 아래로 한 단계씩 배제해 나가도록 짜여 있고, 각 단계는 다음 단계를 볼 필요가 있는지를 결정한다.

# 1) 이름 해석: 클라이언트가 보는 IP가 우리가 아는 IP인가
dig +short api.example.com A

# 2) TCP: 그 IP의 443이 열려 있는가 (연결 시간까지 본다)
curl -o /dev/null -s -w 'connect=%{time_connect}\n' --connect-timeout 3 https://api.example.com/

# 3) TLS: 핸드셰이크가 끝나는가, 어떤 인증서가 오는가
echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null \
  | openssl x509 -noout -subject -dates

# 4) HTTP: 상태 코드와 단계별 시간
curl -o /dev/null -s -w 'code=%{http_code} appconnect=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
  https://api.example.com/health

# 5) 프록시 뒤: 업스트림에 직접 같은 요청을 보낸다
curl -o /dev/null -s -w 'code=%{http_code} ttfb=%{time_starttransfer}\n' \
  -H 'Host: api.example.com' http://10.0.1.10:8080/health

각 단계의 출력을 읽는 법이 핵심이다. 1단계에서 나오는 IP가 예상과 다르면 그 아래는 볼 필요가 없다. DNS 캐시나 잘못된 레코드가 원인이고, 이때 흔한 함정은 클라이언트 쪽 리졸버와 서버 쪽 리졸버가 서로 다른 답을 받는 스플릿 호라이즌 구성이다.

2단계에서 connect 시간이 그대로 타임아웃까지 늘어나면 보안 그룹이나 방화벽이다. 반대로 connect는 즉시 끝나는데 3단계가 멈추면 TLS 계층 문제이고, 여기서는 클라이언트가 제시하는 목록과 서버가 받아들이는 목록이 겹치지 않는 경우가 가장 많다.

4단계에서 appconnect와 ttfb의 간격이 문제의 위치를 알려준다. appconnect까지는 빠른데 ttfb가 길면 서버가 요청을 받아 놓고 처리에 시간을 쓰고 있다는 뜻이므로, 네트워크가 아니라 애플리케이션이나 그 뒤의 데이터베이스를 봐야 한다. 반대로 appconnect 자체가 길면 TLS 핸드셰이크가 느린 것이고, OCSP 조회나 인증서 체인 검증이 원인인 경우가 있다.

5단계는 프록시가 개입하는 구성에서 가장 중요한 한 줄이다. 업스트림에 직접 보낸 요청이 정상이라면 문제는 프록시와 업스트림 사이에 있고, 업스트림에 직접 보내도 같은 오류가 나면 프록시는 무죄다. 이 한 번의 비교로 조사 범위가 절반으로 줄어든다. 이때 Host 헤더를 반드시 원래 값으로 맞춰야 한다. 가상 호스트 기반 라우팅에서는 Host가 다르면 완전히 다른 설정이 적용되어 비교 자체가 무의미해진다.

13. 자주 틀리는 진단과 그 이유

같은 증상에 대해 잘못된 결론으로 가는 길이 몇 개 정해져 있다. 자주 반복되는 것만 모았다.

첫째, curl은 되는데 브라우저는 안 되는 경우를 서버 문제로 보는 것이다. 이 조합은 거의 항상 서버가 아니라 브라우저만 하는 일에서 온다. CORS 프리플라이트, 쿠키의 SameSite 속성, 혼합 콘텐츠 차단, HSTS 캐시, 그리고 앞서 언급한 HTTP/2 커넥션 병합이 후보다. curl에 -H 'Origin: ...'을 붙여 프리플라이트를 재현해 보기 전에 서버 로그를 뒤지는 것은 시간 낭비다.

둘째, 502를 업스트림이 죽은 것으로 단정하는 것이다. 규범이 정의하는 502는 게이트웨이가 상류 서버로부터 유효하지 않은 응답을 받았다는 뜻이지, 상류가 응답하지 않았다는 뜻이 아니다. 응답이 오긴 왔는데 프록시가 파싱하지 못한 경우도 502가 된다. 응답 헤더가 프록시의 버퍼보다 큰 경우가 대표적이고, 이때 업스트림 헬스체크는 계속 정상으로 나온다. 504와 헷갈리지 않는 것도 중요하다. 504는 시간 안에 응답을 받지 못한 경우이므로, 502가 나오는데 타임아웃을 늘리는 것은 아무 효과가 없다.

셋째, 인증서 만료만 보고 넘어가는 것이다. 브라우저는 중간 인증서를 캐시하고 있다가 알아서 채워 넣는 경우가 있어서, 체인이 불완전해도 개발자 PC에서는 정상으로 보인다. 반면 서버 대 서버 호출에서는 그대로 실패한다. 만료일이 남아 있는데 일부 클라이언트만 실패한다면 체인 순서와 중간 인증서 포함 여부를 확인해야 한다.

# 서버가 실제로 내려보내는 인증서 개수와 순서를 본다 (중간 인증서 누락 확인)
echo | openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'

넷째, 간헐적 실패를 평균 지표로 판단하는 것이다. 100번 중 3번 실패하는 문제는 대시보드의 p50에서는 보이지 않는다. 이런 문제는 반복 요청 스크립트로 실패한 요청만 골라 그 순간의 상세 정보를 남겨야 잡힌다. 이 글 앞부분의 모니터링 스크립트가 실패 시에만 상세 트레이스를 기록하도록 되어 있는 이유가 이것이다.

다섯째, 리다이렉트 루프를 애플리케이션에서 찾는 것이다. 루프의 절대다수는 TLS 종료 지점과 애플리케이션의 HTTPS 강제 로직이 서로를 모르는 데서 나온다. 로드밸런서가 TLS를 끊고 평문 HTTP로 전달하면 애플리케이션은 평문 요청을 보고 다시 HTTPS로 리다이렉트한다. X-Forwarded-Proto를 전달하고 애플리케이션이 그 값을 신뢰하도록 설정하는 것이 정답이며, 동시에 그 헤더를 외부에서 위조하지 못하도록 경계에서 덮어쓰는 처리도 함께 필요하다.

14. 이 방법이 통하지 않는 경우

여기서 다룬 기법은 요청 하나를 재현할 수 있을 때 강력하다. 재현이 안 되는 문제에는 다른 접근이 필요하다.

특정 지역이나 특정 통신사에서만 발생하는 문제는 내 터미널에서 curl을 아무리 돌려도 나오지 않는다. 이 경우 필요한 것은 도구가 아니라 관측 지점이고, 여러 위치에서 동일한 요청을 보내는 외부 모니터링이나 사용자 측 RUM 데이터가 있어야 시작할 수 있다.

중간 장비가 트래픽을 변형하는 환경도 마찬가지다. 기업 프록시나 일부 보안 장비는 TLS를 종료하고 다시 맺으면서 헤더를 추가하거나 HTTP 버전을 낮춘다. 이런 환경에서 서버 쪽에서 본 요청은 클라이언트가 보낸 요청과 다르며, 서버 로그만 보고 클라이언트 코드를 고치려 들면 영원히 원인을 못 찾는다.

암호화된 페이로드 자체가 문제인 경우도 범위 밖이다. 상태 코드는 200인데 본문이 잘못되었다면 이 글의 도구들은 아무것도 알려주지 않는다. 이때부터는 애플리케이션 로그와 분산 추적의 영역이다.

마지막으로, 도구를 늘려서 해결되지 않는 종류의 문제가 있다. 재현 조건이 특정 사용자 계정이나 특정 데이터에 묶여 있으면, 네트워크 계층을 아무리 정밀하게 관측해도 답이 나오지 않는다. 이럴 때는 요청 단위 상관관계 ID를 심어 로그를 잇는 편이 훨씬 빠르다.

15. 참고 자료

마치며

HTTP/HTTPS 트러블슈팅은 계층적 접근이 핵심이다. DNS, TCP 연결, TLS 핸드셰이크, HTTP 프로토콜, 애플리케이션 로직 순서로 문제를 좁혀 나가면 대부분의 문제를 효율적으로 해결할 수 있다.

여기서 소개한 도구와 기법을 활용해서 운영 환경에서 발생하는 문제를 체계적으로 진단하는 습관을 기르자. 특히 curl -w 포맷 문자열과 openssl s_client는 반드시 익혀두면 실무에서 큰 도움이 된다.

핵심 정리:

퀴즈

Q1: POST를 받는 엔드포인트를 HTTPS로 강제 리다이렉트할 때 301/302 대신 308을 써야 하는 이유는? RFC 9110에서 302는 사용자 에이전트가 POST를 GET으로 바꿔도 되는 코드이고 308은 요청 메서드를 바꿔서는 안 되는 코드다. 301/302를 쓰면 클라이언트에 따라 본문이 사라진 GET이 도착한다.

Q2: 405 Method Not Allowed 응답에서 반드시 확인해야 하는 헤더는? Allow 헤더다. RFC 9110은 405를 낼 때 서버가 허용 메서드를 나열한 Allow 헤더를 생성해야 한다고 규정한다. 없으면 서버 버그이거나 게이트웨이가 헤더를 걸러낸 것이다.

Q3: 502가 나오는데 타임아웃을 늘려도 효과가 없는 이유는? 502는 게이트웨이가 상류로부터 유효하지 않은 응답을 받은 경우이고, 시간 안에 응답을 받지 못한 경우는 504다. 응답 헤더가 프록시 버퍼보다 큰 경우처럼 파싱 실패도 502가 된다.

Q4: curl은 되는데 브라우저만 실패할 때 먼저 의심할 것은? 브라우저만 하는 일이다. CORS 프리플라이트, 쿠키 SameSite, 혼합 콘텐츠 차단, HSTS 캐시, HTTP/2 커넥션 병합으로 인한 421이 후보다.

Q5: curl -w의 time_appconnect와 time_starttransfer 간격이 넓다는 것은 무엇을 뜻하나? TLS 핸드셰이크까지는 빨랐는데 서버가 요청을 받아 놓고 처리에 시간을 쓰고 있다는 뜻이다. 네트워크가 아니라 애플리케이션이나 그 뒤의 데이터베이스를 봐야 한다.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다