운영 반영 직후 헬스체크는 실패하지만 브라우저에서는 페이지가 열립니다. 응답 코드, 헤더, 리다이렉트, TLS 정보를 한 번에 확인하지 못해 원인 파악이 늦어집니다.
curl은 단순 다운로드 도구가 아니라 HTTP 장애를 분해하는 가장 빠른 현장 도구입니다.
HTTP 테스트와 상태 코드 점검 (curl)
웹 서버 응답이 이상하다, API가 갑자기 500 에러를 반환한다, SSL 인증서가 만료됐다는 알림이 왔다 — 이런 상황에서 브라우저를 열기 전에 CLI에서 즉시 확인할 수 있는 도구가 curl입니다. 이 챕터에서는 HTTP 메서드와 상태 코드 체계를 이해하고, curl의 핵심 옵션을 활용해 서버 상태를 정확하게 진단하는 방법을 배웁니다.
- 1HTTP 메서드(GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS)와 멱등성 개념을 설명할 수 있다
- 2HTTP 상태 코드 체계(2xx 성공, 3xx 리다이렉션, 4xx 클라이언트 오류, 5xx 서버 오류)를 구분할 수 있다
- 3502 / 503 / 504 Bad Gateway 오류를 구분하고 원인을 분석할 수 있다
- 4curl 핵심 옵션(-I, -v, -s, -L, -k, -w, -X, -d, -H)을 실전에서 활용할 수 있다
- 5SSL/TLS 인증서 만료를 점검하고 openssl로 만료일을 확인할 수 있다
- 6서버 상태 모니터링과 CI/CD 배포 헬스체크 자동화 스크립트를 작성할 수 있다
curl --versionopenssl version인터넷 연결이 필요합니다. 오프라인 환경이라면 로컬에서 python3 -m http.server 8000으로 간이 서버를 실행할 수 있습니다.
bc --version || sudo apt-get install -y bcHTTP 메서드와 상태 코드 체계
백엔드 API를 호출했는데 405 오류가 납니다. 또 다른 경우엔 GET으로 데이터를 삭제하는 코드가 있고, POST와 PUT을 혼용해서 클라이언트가 중복 요청을 보내도 서버가 멱등하게 처리되지 않습니다. HTTP 메서드와 상태 코드 체계를 정확히 알지 못하면 API 설계에서 이런 실수가 반복됩니다.
확대
HTTP 메서드 — 행위를 표현하는 동사
HTTP 요청은 어떤 작업을 할지 메서드(동사)로 표현합니다. REST API 설계와 서버 동작을 이해하는 데 필수적입니다.
| 메서드 | 의미 | 멱등성 | 주요 사용 예 |
|---|---|---|---|
| GET | 리소스 조회 | O | 페이지 조회, API 데이터 읽기 |
| POST | 리소스 생성 | X | 회원 가입, 주문 생성 |
| PUT | 리소스 전체 교체 | O | 사용자 정보 전체 수정 |
| PATCH | 리소스 부분 수정 | X | 이메일만 변경 |
| DELETE | 리소스 삭제 | O | 게시글 삭제 |
| HEAD | 헤더만 요청 | O | 파일 크기 확인, 상태 점검 |
| OPTIONS | 지원 메서드 조회 | O | CORS 사전 요청 |
멱등성(Idempotency): 같은 요청을 여러 번 보내도 결과가 동일하면 멱등. GET으로 같은 페이지를 100번 요청해도 서버 상태는 변하지 않습니다.
HTTP 상태 코드 — 서버의 응답 의미
상태 코드는 3자리 숫자로, 첫 번째 자리가 대분류를 나타냅니다.
2xx — 성공
| 코드 | 이름 | 의미 |
|---|---|---|
| 200 | OK | 요청 성공, 응답 바디 포함 |
| 201 | Created | POST로 리소스 생성 성공 |
| 204 | No Content | 성공했지만 반환할 바디 없음 (DELETE 응답 등) |
3xx — 리다이렉션
| 코드 | 이름 | 의미 |
|---|---|---|
| 301 | Moved Permanently | 영구 이동, 북마크 갱신 필요 |
| 302 | Found | 임시 이동 |
| 304 | Not Modified | 캐시 유효, 바디 없음 |
4xx — 클라이언트 오류
| 코드 | 이름 | 의미 |
|---|---|---|
| 400 | Bad Request | 잘못된 요청 형식 |
| 401 | Unauthorized | 인증 필요(미로그인) |
| 403 | Forbidden | 인증은 됐지만 권한 없음 |
| 404 | Not Found | 리소스 없음 |
| 429 | Too Many Requests | 요청 속도 초과(rate limit) |
5xx — 서버 오류
| 코드 | 이름 | 의미 |
|---|---|---|
| 500 | Internal Server Error | 서버 내부 오류(코드 버그 등) |
| 502 | Bad Gateway | 게이트웨이가 업스트림에서 유효하지 않은 응답 수신 |
| 503 | Service Unavailable | 서버 과부하 또는 점검 중 |
| 504 | Gateway Timeout | 게이트웨이가 업스트림 응답 대기 시간 초과 |
502 vs 503 vs 504 구분
현장에서 자주 혼동되는 5xx 코드입니다.
클라이언트 → [Nginx 프록시] → [백엔드 앱 서버]
502: 백엔드가 응답했지만 내용이 이상함 (프로토콜 오류, 잘못된 응답)
503: 백엔드 서버가 완전히 다운됨 (연결 거부)
504: 백엔드 서버가 타임아웃 내에 응답하지 않음
500: 백엔드 서버 자체에서 오류 발생 (코드 예외, DB 오류 등)
확대
URL 하나를 여는 전체 생애주기 — 파싱·DNS·TCP·TLS·HTTP·재사용 8단계
URL 하나를 여는 전체 생애주기 — curl -w의 시계가 어느 단계를 재는가
주소창에 URL을 넣고 Enter를 누르면 페이지가 뜨는 그 1초 안에, 클라이언트는 URL 해석 → DNS → TCP → (HTTPS면) TLS → HTTP 요청 → 서버 처리 → 응답 → 연결 재사용을 순서대로 밟습니다. 이 단계를 알면 curl -w가 찍는 time_namelookup·time_connect·time_appconnect·time_starttransfer가 각각 어느 단계의 시계인지 읽고, "느리다"의 원인 단계를 정확히 짚을 수 있습니다.
[클라이언트] curl https://www.example.com/users?id=1
│
① URL 파싱 scheme(https)·host·port(443)·path·query 분해
│
② DNS 조회 host → IP (stub → 리졸버, 캐시 미스면 위임 체인) [time_namelookup]
│
③ TCP 연결 IP:443로 3-way handshake(SYN/SYN-ACK/ACK) [time_connect]
│
④ TLS 핸드셰이크 (https면) ClientHello…인증서 검증…세션키 합의 [time_appconnect]
│
⑤ HTTP 요청 요청라인(GET /users?id=1) + 헤더(Host·User-Agent·Accept) 송신
│
⑥ 서버 처리 라우팅·앱·DB 후 응답 생성 → 첫 바이트 전송 [time_starttransfer]
│
⑦ 응답 수신 상태코드 + 응답 헤더 + 바디 [time_total]
│
⑧ 연결 재사용 keep-alive면 TCP·TLS 유지 → 다음 요청은 ②③④ 건너뜀
▼
[브라우저] 바디 파싱·렌더 (CSS/JS/이미지마다 요청 반복, 대개 연결 재사용)
각 단계에서 무슨 일이 일어나고, 느리거나 막히면 어디를 보나:
| 단계 | 하는 일 | 느리거나 막히면 |
|---|---|---|
| ① URL 파싱 | scheme·host·port·path·query로 분해 | 스킴 누락·오타 → 요청이 시작조차 안 됨 |
| ② DNS 조회 | 호스트명을 IP로 해석 | time_namelookup이 큼 → 리졸버·캐시 문제 / (6) Could not resolve host |
| ③ TCP 연결 | IP:포트로 3-way handshake | time_connect가 크거나 멈춤 → 포트 차단·방화벽 / (7) Connection refused·타임아웃 |
| ④ TLS 핸드셰이크 | 인증서 검증·세션키 합의(https만) | time_appconnect가 큼 → TLS 협상 / (60) 인증서 에러 |
| ⑤ HTTP 요청 | 메서드·경로·헤더(특히 Host) 송신 | Host가 틀리면 엉뚱한 vhost 응답(404·다른 사이트) |
| ⑥ 서버 처리 | 업스트림·앱·DB 처리 후 첫 바이트 반환 | time_starttransfer만 큼 → 서버 내부(TTFB) 지연, 네트워크 아님 |
| ⑦ 응답 | 상태코드·헤더·바디 반환 | 5xx=서버·업스트림 문제, 4xx=요청(URL·토큰·권한) 문제 |
| ⑧ 연결 재사용 | keep-alive로 TCP·TLS 연결 유지 | 매 요청 새 연결이면 ②③④를 반복 → 지연 누적 |
즉 "200이 빨리 떴다"는 ①~⑦이 모두 성공하고 신속했다는 뜻이고, 느릴 때는 어느 시계가 큰지가 원인 단계를 그대로 가리킵니다 — time_namelookup이 크면 DNS(②), time_connect가 크면 TCP·방화벽(③), time_appconnect가 크면 TLS(④), time_starttransfer와 time_connect의 간격이 크면 서버 처리(⑥)입니다. curl -w로 이 네 구간을 한 줄에 찍어 비교하면 "네트워크가 느린가, 서버가 느린가"가 즉시 갈립니다(바로 아래 실습에서 측정합니다). 그리고 keep-alive(⑧)가 살아 있으면 두 번째 요청부터 ②③④가 통째로 사라져 훨씬 빨라지므로, 첫 요청만 느리고 이후가 빠르다면 정상입니다.
curl 핵심 옵션과 SSL 인증서 점검
배포 직후 API가 정상인지 확인하려 합니다. 브라우저에서는 되는데 서버에서 내부 API 호출이 안 됩니다. curl을 써서 직접 요청을 던져보면 원인이 바로 드러나는 경우가 많습니다. 헤더를 붙이는 방법, SSL 인증서 오류를 우회하는 방법, 응답 코드만 확인하는 방법 — 이 옵션들을 모르면 curl은 그냥 wget 대체 도구로만 씁니다.
curl 기본 동작 원리
curl(Client URL)은 다양한 프로토콜을 지원하는 CLI 데이터 전송 도구입니다. HTTP/HTTPS 외에도 FTP, SMTP, DNS 등을 지원합니다.
# 실습 디렉토리 준비
mkdir -p /tmp/networking/part3/exam_15 && cd /tmp/networking/part3/exam_15
# 기본 GET 요청 — 응답 바디를 표준 출력으로 출력
curl http://example.com
# HTTPS (SSL/TLS 검증 포함)
curl https://example.com
- HTTP 상태코드 — 첫 번째 확인 대상—curl -I 출력 첫 줄 HTTP/1.1 200 OK에서 상태코드를 먼저 확인 — 2xx는 성공, 3xx는 리다이렉트(Location 헤더 추적), 4xx는 클라이언트 오류, 5xx는 서버 오류
- curl -v 출력 읽기 순서—> 로 시작하는 줄은 요청 헤더, < 로 시작하는 줄은 응답 헤더 — * 로 시작하는 줄은 curl 자체 로그(TLS 핸드셰이크, 연결 정보)이므로 응답 분석 시 < 줄만 집중
- Content-Type과 응답 크기 조합 해석—Content-Type: application/json이면 API 응답, text/html이면 웹 페이지 — Content-Length가 0이거나 없으면 빈 응답 바디이므로 오류 메시지가 없더라도 실패일 수 있음
주요 옵션 정리
| 옵션 | 설명 |
|---|---|
-I | HEAD 메서드로 헤더만 수신 |
-v | verbose — 전체 HTTP 흐름 출력 |
-s | silent — 진행 표시줄 숨김 |
-o 파일 | 응답 바디를 파일로 저장 |
-O | URL의 파일명으로 저장 |
-L | 리다이렉트 자동 따라가기 |
-k | SSL 인증서 검증 건너뜀 (비보안) |
-w 형식 | 요청 완료 후 지정 형식으로 정보 출력 |
-H "헤더" | 커스텀 헤더 추가 |
-d "데이터" | POST 바디 데이터 |
-X 메서드 | HTTP 메서드 지정 |
-u 사용자:암호 | Basic 인증 |
--connect-timeout N | 연결 타임아웃(초) |
--max-time N | 전체 요청 타임아웃(초) |
SSL/TLS 인증서 점검
HTTPS 사이트의 인증서 만료일, 발급자, SANs(Subject Alternative Names) 정보를 확인하는 방법입니다.
# 인증서 상세 정보 확인
curl -vI https://example.com 2>&1 | grep -E "expire|issuer|subject|SSL"
# openssl을 이용한 만료일 확인 (더 명확)
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates
# 출력 예
# notBefore=Jan 1 00:00:00 2024 GMT
# notAfter=Jan 1 00:00:00 2025 GMT ← 이 날짜 확인
인증서 만료 N일 전 알림을 자동화하는 확인 방법:
#!/bin/bash
# SSL 만료까지 남은 일수 확인
DOMAIN="example.com"
EXPIRY=$(echo | openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" 2>/dev/null \
| openssl x509 -noout -enddate | cut -d= -f2)
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
echo "${DOMAIN}: 인증서 만료까지 ${DAYS_LEFT}일 남음"
if [ "$DAYS_LEFT" -lt 30 ]; then
echo "경고: 30일 이내 만료 예정!"
fi
mkdir -p /tmp/networking/part3/http-curl && cd /tmp/networking/part3/http-curl
# 로컬 테스트 서버 스크립트 생성
cat > test_server.py << 'EOF'
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/health':
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({'status':'ok'}).encode())
elif self.path == '/api/users':
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
data = [{'id':1,'name':'alice'},{'id':2,'name':'bob'}]
self.wfile.write(json.dumps(data).encode())
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length)
self.send_response(201)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({'created': True, 'data': body.decode()}).encode())
def log_message(self, *args): pass
HTTPServer(('127.0.0.1', 8765), Handler).serve_forever()
EOF
echo "# 테스트 서버 실행 (백그라운드)"
echo "# python3 test_server.py &"
curl -I 헤더 확인
# 기본 헤더 확인 (HEAD 메서드)
curl -I https://www.google.com
# 출력 예시
# HTTP/2 200
# content-type: text/html; charset=UTF-8
# date: Mon, 27 Jan 2025 01:00:00 GMT
# expires: Mon, 27 Jan 2025 01:00:00 GMT
# cache-control: private, max-age=0
# server: gws
# x-xss-protection: 0
# x-frame-options: SAMEORIGIN
확인 포인트:
HTTP/2 200— 프로토콜 버전과 상태 코드content-type— 응답 형식 (text/html, application/json 등)server— 웹 서버 소프트웨어 (Nginx, Apache, gws 등)cache-control— 캐싱 정책
# 리다이렉트를 따라가며 최종 목적지 확인
curl -I -L http://naver.com
# HTTP/1.1 301 Moved Permanently ← 첫 번째 응답
# location: https://www.naver.com/ ← 리다이렉트 대상
# ...
# HTTP/2 200 ← 최종 목적지 응답
상태 코드만 빠르게 확인
# 상태 코드만 숫자로 출력
curl -s -o /dev/null -w "%{http_code}" https://www.google.com
# 출력: 200
# 줄바꿈 추가
curl -s -o /dev/null -w "%{http_code}\n" https://www.google.com
# 여러 사이트 일괄 점검
for url in https://google.com https://naver.com https://example.com; do
code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$url")
echo "$url → $code"
done
응답 시간 측정
# 상세한 타이밍 정보 출력
curl -o /dev/null -s -w "
DNS 조회: %{time_namelookup}s
TCP 연결: %{time_connect}s
TLS 핸드셰이크: %{time_appconnect}s
첫 바이트까지: %{time_starttransfer}s
전체 시간: %{time_total}s
전송 크기: %{size_download} bytes
" https://www.google.com
출력 항목 설명:
time_namelookup: DNS 조회 시간time_connect: TCP 3-way handshake 완료까지time_appconnect: TLS 핸드셰이크 완료까지 (HTTPS)time_starttransfer: 서버가 첫 번째 바이트를 보낼 때까지time_total: 전체 완료 시간
curl -v 출력 구조 이해
curl -v https://httpbin.org/get
출력에서 각 접두사의 의미:
* Trying 54.208.105.16:443... ← DNS/TCP 정보 (* = curl 내부 정보)
* Connected to httpbin.org port 443 ← TCP 연결 성공
* SSL connection using TLSv1.3 ← TLS 버전
* Server certificate: ← 인증서 정보 시작
* subject: CN=httpbin.org
* start date: Jan 1 00:00:00 2024 GMT
* expire date: Jan 1 00:00:00 2025 GMT
* SSL certificate verify ok. ← 인증서 검증 성공
> ← > = 내가 보낸 요청 헤더
> GET /get HTTP/2
> Host: httpbin.org
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/2 200 ← < = 서버의 응답 헤더
< date: Mon, 27 Jan 2025 01:00:00 GMT
< content-type: application/json
< content-length: 305
<
{ ← 응답 바디 (접두사 없음)
"headers": { ... }
}
POST 요청으로 API 테스트
# JSON 바디로 POST 요청
curl -v -X POST https://httpbin.org/post \
-H "Content-Type: application/json" \
-H "Authorization: Bearer mytoken123" \
-d '{"username": "testuser", "action": "login"}'
# Form 데이터로 POST
curl -v -X POST https://httpbin.org/post \
-F "username=testuser" \
-F "password=secret"
# PUT 요청
curl -X PUT https://httpbin.org/put \
-H "Content-Type: application/json" \
-d '{"name": "updated name"}'
# DELETE 요청
curl -X DELETE https://httpbin.org/delete \
-H "Authorization: Bearer mytoken123"
SSL 인증서 오류 디버깅
# 인증서 검증 실패 시 상세 정보
curl -v https://expired.badssl.com/ 2>&1
# 주요 에러 메시지
# * SSL certificate problem: certificate has expired ← 만료
# * SSL certificate problem: self-signed certificate ← 자체 서명
# * SSL certificate problem: unable to get local issuer certificate ← 체인 불완전
# 임시 우회 (운영 환경에서는 절대 사용 금지!)
curl -k https://expired.badssl.com/
# 특정 CA 인증서 지정
curl --cacert /path/to/company-ca.crt https://internal.example.com/
타임아웃 설정
# 연결 타임아웃 3초, 전체 응답 타임아웃 10초
curl --connect-timeout 3 --max-time 10 https://example.com
# 응답 없는 서버 빠르게 탐지
curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 3 --max-time 5 \
http://192.168.1.200/health
# 출력: 000 (연결 실패 시)
기본 헬스체크 스크립트
#!/bin/bash
# /usr/local/bin/http-healthcheck.sh
TARGETS=(
"https://www.example.com|200"
"https://api.example.com/health|200"
"https://www.google.com|200"
)
FAILED=0
for entry in "${TARGETS[@]}"; do
url=$(echo "$entry" | cut -d'|' -f1)
expected=$(echo "$entry" | cut -d'|' -f2)
actual=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 5 --max-time 10 "$url")
if [ "$actual" = "$expected" ]; then
echo "[OK] $url → $actual"
else
echo "[FAIL] $url → 예상: $expected, 실제: $actual"
FAILED=$((FAILED + 1))
fi
done
exit $FAILED
응답 시간 임계값 모니터링
#!/bin/bash
# 응답 시간이 2초를 초과하면 경고
URL="https://api.example.com/v1/status"
THRESHOLD=2.0
TIMING=$(curl -o /dev/null -s -w "%{time_total}" \
--connect-timeout 5 --max-time 10 "$URL")
echo "응답 시간: ${TIMING}s"
# bc로 부동소수점 비교
if (( $(echo "$TIMING > $THRESHOLD" | bc -l) )); then
echo "경고: 응답 시간 임계값(${THRESHOLD}s) 초과!"
exit 1
fi
echo "정상"
SSL 인증서 만료 일괄 점검
#!/bin/bash
# 여러 도메인의 SSL 만료일 일괄 점검
DOMAINS=("www.example.com" "api.example.com" "static.example.com")
WARN_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)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
if [ "$DAYS_LEFT" -lt 0 ]; then
echo "[EXPIRED] $domain: 이미 만료됨"
elif [ "$DAYS_LEFT" -lt "$WARN_DAYS" ]; then
echo "[WARN] $domain: ${DAYS_LEFT}일 후 만료 (${EXPIRY})"
else
echo "[OK] $domain: ${DAYS_LEFT}일 남음"
fi
done
cron에 등록하여 매일 점검:
# crontab -e
0 9 * * * /usr/local/bin/ssl-check.sh | mail -s "SSL 만료 점검 결과" admin@example.com
증상
$ curl -I https://api.example.com/v1/users
HTTP/1.1 502 Bad Gateway
Server: nginx/1.24.0
Date: Mon, 27 Jan 2025 01:00:00 GMT
Content-Type: text/html
원인 분석과 점검 순서
단계 1 — 백엔드 서버 포트 리스닝 확인
# Nginx 프록시가 8080으로 백엔드를 바라본다고 가정
ss -antp | grep 8080
# 출력이 없으면 백엔드 서버가 다운된 것
단계 2 — 백엔드 직접 접근 테스트
# Nginx를 우회하고 백엔드 직접 호출
curl -v http://127.0.0.1:8080/v1/users
# 연결 거부 시
# curl: (7) Failed to connect to 127.0.0.1 port 8080: Connection refused
# → 백엔드 프로세스 다운
# 응답이 오면 Nginx 설정 문제
단계 3 — Nginx 에러 로그 확인
# Nginx 에러 로그에서 upstream 관련 메시지 확인
tail -50 /var/log/nginx/error.log | grep upstream
# 예시 에러 메시지
# *1 connect() failed (111: Connection refused) while connecting to upstream
# → 백엔드 서비스 다운
# *1 upstream timed out (110: Connection timed out) while reading response header
# → 백엔드 응답 타임아웃
단계 4 — 백엔드 서비스 재시작
이 명령은 실행 중인 서비스 상태를 바꿔 순간적인 중단이나 설정 반영 실패를 만들 수 있습니다. 운영 트래픽 영향과 재시작 후 확인 명령을 먼저 준비하세요.
# 애플리케이션 서비스 상태 확인 및 재시작
systemctl status myapp
systemctl restart myapp
# Docker 컨테이너인 경우
docker ps | grep myapp
docker restart myapp-container
502 유형별 Nginx 설정 해결책
# proxy_connect_timeout: 백엔드 연결 타임아웃 (기본 60s)
# proxy_read_timeout: 응답 대기 타임아웃 (기본 60s)
# 느린 API의 경우 늘려줄 수 있음
upstream backend {
server 127.0.0.1:8080;
keepalive 32;
}
server {
location /api/ {
proxy_pass http://backend;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
}
증상
$ curl https://api.example.com/health
curl: (60) SSL certificate problem: certificate has expired
More details here: https://curl.se/docs/sslcerts.html
curl failed to verify the legitimacy of the server and therefore
could not establish a secure connection to it.
브라우저에서는 "NET::ERR_CERT_DATE_INVALID" 오류로 표시됩니다.
즉각적인 현황 파악
# 1. 만료일 확인
echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null \
| openssl x509 -noout -dates
# notAfter=Dec 31 23:59:59 2024 GMT ← 만료됨
# 2. 발급자 확인 (Let's Encrypt인지, 상용 CA인지)
echo | openssl s_client -connect api.example.com:443 2>/dev/null \
| openssl x509 -noout -issuer -subject
# 3. 임시 우회로 서비스 동작 확인 (장애 범위 파악)
curl -k https://api.example.com/health
# 200이면 인증서만 문제, 서비스는 살아있음
인증서 종류별 갱신 방법
Let's Encrypt (Certbot)
이 명령은 실행 중인 서비스 상태를 바꿔 순간적인 중단이나 설정 반영 실패를 만들 수 있습니다. 운영 트래픽 영향과 재시작 후 확인 명령을 먼저 준비하세요.
# 상태 확인
certbot certificates
# 수동 갱신
certbot renew --cert-name api.example.com
# 강제 갱신 (만료 전이라도)
certbot renew --force-renewal --cert-name api.example.com
# Nginx 리로드
systemctl reload nginx
상용 CA (DigiCert, Sectigo 등)
- CA 포털에서 새 인증서 신청/다운로드
- 기존 인증서 파일 교체:
로컬 터미널
cp new-cert.pem /etc/nginx/ssl/api.example.com.crt cp new-key.pem /etc/nginx/ssl/api.example.com.key nginx -t && systemctl reload nginx
재발 방지 — 자동 갱신 설정
이 명령은 실행 중인 서비스 상태를 바꿔 순간적인 중단이나 설정 반영 실패를 만들 수 있습니다. 운영 트래픽 영향과 재시작 후 확인 명령을 먼저 준비하세요.
# Certbot 자동 갱신 타이머 확인
systemctl status certbot.timer
# 없으면 cron으로 설정
# crontab -e
0 3 * * * certbot renew --quiet && systemctl reload nginx
심화 — 같은 IP 뒤에 여러 사이트가 산다
심화: DNS·SNI·Host 헤더 — 누가 응답할지 정하는 3인조
curl로 IP는 분명히 맞게 찾아갔는데 엉뚱한 인증서나 다른 사이트의 응답을 받는 일이 있습니다. 원인은 하나의 IP 뒤에 여러 가상 호스트(vhost)가 살고, 그중 누가 답할지를 세 값이 단계별로 나눠 정하기 때문입니다. 이 셋의 역할을 구분하지 못하면 curl 테스트가 '되는데도' 실제 서비스는 실패하는 착시에 빠집니다.
- DNS는 어느 IP로 갈지만 정한다: A/AAAA 레코드는 목적지 IP를 알려줄 뿐, 그 IP에서 어떤 사이트가 응답할지는 관여하지 않습니다.
- SNI는 어느 인증서를 줄지 정한다: TLS 핸드셰이크의 ClientHello에 담기는 SNI(Server Name Indication)가 서버에게 어느 도메인의 인증서를 달라고 알립니다. SNI가 틀리면 다른 사이트의 인증서를 받거나 기본 vhost로 떨어집니다.
- Host 헤더는 어느 vhost가 본문을 처리할지 정한다: 암호화가 끝난 뒤 전달되는 HTTP Host 헤더로 실제 응답 vhost가 갈립니다. 인증서 선택보다 한 박자 늦습니다.
- 테스트 핀 고정의 함정:
curl --resolve 도메인:443:IP는 DNS만 바꿔치기해 SNI·Host를 그대로 둔 채 특정 서버로 정확히 핀 고정합니다. 반면-H "Host: ..."만 바꾸면 SNI는 원래 값이라 인증서·vhost가 어긋날 수 있습니다.
그래서 '공유 IP·CDN·SNI 라우팅 로드밸런서' 뒤를 디버깅할 땐, IP뿐 아니라 SNI와 Host를 각각 무엇으로 보냈는지까지 봐야 합니다.
상황
블루-그린 배포로 새 서버를 띄우고 curl --resolve로 그 서버에 핀 고정해 테스트하면 200 정상입니다. 그런데 실제 도메인으로 접속하면 404가 나거나 옛 사이트가 응답합니다. 테스트는 통과했는데 실사용은 실패하는 상황이라 원인을 못 잡습니다.
원인
공유 IP 뒤 로드밸런서가 SNI를 기준으로 vhost를 고르는 구조였습니다. 테스트할 때 -H "Host: ..."만 바꾸고 SNI는 원래 값을 보냈거나, DNS 전환이 아직 옛 IP를 가리키고 있었습니다. Host만 맞춘 요청이 우연히 통과해 '된다'고 착각하게 만든 것입니다.
진단
# curl -v로 실제 받은 인증서와 보낸 SNI 확인
curl -v --resolve api.example.com:443:203.0.113.10 https://api.example.com/health 2>&1 \
| grep -E 'subject:|SSL connection|ALPN'
# SNI별로 어떤 인증서가 오는지 비교
echo | openssl s_client -servername api.example.com -connect 203.0.113.10:443 2>/dev/null \
| openssl x509 -noout -subject
# 실제 DNS가 새 IP로 전환됐는지
dig +short api.example.com
해결
테스트는 항상 실제 SNI(=도메인)로 핀 고정합니다 — curl --resolve 도메인:443:새IP를 쓰고 -H "Host: ..."만 바꾸는 방식은 피합니다. DNS 전환이 미완이면 TTL을 낮추고 전파를 확인하며, 로드밸런서가 SNI 라우팅이면 새 도메인의 SNI·인증서를 vhost에 등록해야 실제 트래픽이 새 서버로 갑니다.
실무에서 curl이 필수인 이유
curl은 브라우저 없이 HTTP 통신의 모든 계층을 CLI에서 직접 제어하고 검사할 수 있는 도구입니다. GUI가 없는 서버 환경, 자동화 스크립트, CI/CD 파이프라인에서 없어서는 안 됩니다.
개발자 관점
# API 개발 중 빠른 테스트
curl -X POST http://localhost:8080/api/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"1234"}'
# 응답 헤더에서 토큰 추출
TOKEN=$(curl -s -X POST http://localhost:8080/api/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"1234"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# 추출한 토큰으로 인증 API 테스트
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/users/me
인프라/운영 관점
# 로드밸런서 뒤의 각 백엔드 서버가 올바른 응답을 하는지 확인
# (Server 헤더나 X-Served-By 헤더로 어느 백엔드가 응답했는지 파악)
for i in {1..10}; do
curl -s -o /dev/null -w "%{http_code} - %header{x-served-by}\n" \
https://api.example.com/health
done
# Prometheus/Grafana 없는 환경에서 수동 모니터링
watch -n 5 'curl -s -o /dev/null -w "Status: %{http_code} | Time: %{time_total}s\n" \
https://api.example.com/health'
CI/CD 파이프라인에서의 활용
# 배포 후 헬스체크 (배포 스크립트에 포함)
deploy_app() {
# ... 배포 로직 ...
# 배포 후 최대 60초간 헬스체크 재시도
for i in {1..12}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 3 --max-time 5 \
https://api.example.com/health)
if [ "$STATUS" = "200" ]; then
echo "배포 성공: 서비스 정상 응답"
return 0
fi
echo "시도 $i/12: 상태 코드 $STATUS, 5초 후 재시도..."
sleep 5
done
echo "배포 실패: 헬스체크 타임아웃"
return 1
}
자격증 및 면접 포인트
- HTTP 상태 코드 외우기: 200/201/204/301/302/400/401/403/404/429/500/502/503/504는 반드시 설명할 수 있어야 합니다.
- curl -v 출력 해석: TLS 핸드셰이크, 요청/응답 헤더 흐름을 설명할 수 있어야 합니다.
- 502 vs 503 차이: Nginx/Apache 프록시 구성 면접에서 자주 나오는 질문입니다.
- SSL 갱신 자동화: Let's Encrypt + Certbot 자동 갱신 설정은 실무 기초 역량입니다.
핵심 명령어 정리
| 명령 | 설명 |
|---|---|
curl -I URL | HEAD 요청으로 헤더만 확인 |
curl -v URL | 전체 HTTP 흐름 verbose 출력 |
curl -s -o /dev/null -w "%{http_code}" URL | 상태 코드만 출력 |
curl -L URL | 리다이렉트 자동 추적 |
curl -o 파일 URL | 응답을 파일로 저장 |
curl -k URL | SSL 검증 건너뜀 (임시 테스트용) |
curl -X POST -d '...' -H 'Content-Type: application/json' URL | JSON POST 요청 |
curl --connect-timeout 3 --max-time 10 URL | 타임아웃 설정 |
정리
- HTTP 메서드(GET/POST/PUT/DELETE)는 행위를, 상태 코드는 결과를 표현합니다.
curl -I로 빠르게 헤더와 상태 코드를 확인하고,curl -v로 전체 흐름을 디버깅합니다.curl -s -o /dev/null -w "%{http_code}"는 자동화 스크립트에서 상태 코드만 추출하는 표준 패턴입니다.- SSL 인증서 만료 전 자동 알림과 certbot 자동 갱신을 설정하면 장애를 예방할 수 있습니다.
- 502는 게이트웨이의 업스트림 문제, 503은 서비스 완전 다운, 504는 타임아웃으로 구분합니다.
관련 모듈로 더 깊이:
- HTTPS/TLS 작동 원리와 curl SSL 에러 장애 디버깅 —
curl -k로 넘긴 SSL 검증과 인증서 에러의 진짜 원리 - Connection Timeout과 Read Timeout 장애 실전 디버깅 — curl 타이밍으로 connection/read 타임아웃을 구분하는 법
- telnet과 nc(netcat) 명령어로 L4 포트 통신 상태 점검 — curl 이전에 포트 자체가 열렸는지 확인하는 법
다음 모듈에서는 HTTPS/TLS 작동 원리와 인증서 에러, 443 포트 장애를 디버깅하는 방법을 다룹니다.