브라우저와 curl은 되는데 파이썬만 실패하는 이유
파이썬 개발 가이드 7편입니다. 이번 편은 사내 프록시·폐쇄망 환경에서 가장 자주 보고되는 파이썬 HTTPS 에러를 다룹니다.
전형적인 traceback은 이렇게 생겼습니다.
Traceback (most recent call last):
File ".../urllib3/connectionpool.py", line 715, in urlopen
File ".../urllib3/connectionpool.py", line 1058, in _validate_conn
File ".../urllib3/connection.py", line 419, in connect
File ".../ssl.py", line 517, in wrap_socket
File ".../ssl.py", line 1108, in _create
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
unable to get local issuer certificate (_ssl.c:1000)
During handling of the above exception, another exception occurred:
requests.exceptions.SSLError: HTTPSConnectionPool(host='api.example.com', port=443):
Max retries exceeded with url: /v1/ping
(Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] ...')))이 글의 결론을 한 줄로 먼저 박아두겠습니다.
OS 트러스트 스토어 ≠ certifi 번들. 브라우저와 curl은 OS(윈도우 인증서 저장소, macOS 키체인, 리눅스
/etc/ssl/certs)를 보지만, 파이썬requests는 기본적으로 패키지에 동봉된certifiPEM 파일만 봅니다. 그래서 사내 IT가 루트 CA를 OS에 배포해도 파이썬만 홀로 실패합니다.
Zscaler·Netskope·Palo Alto 같은 SSL 인스펙션 장비가 깔린 회사라면 이 증상은 예외가 아니라 기본값에 가깝습니다. 장비가 TLS를 중간에서 끊고 사내 루트 CA로 재서명하는데, 파이썬은 그 루트 CA를 모르기 때문입니다.
에러 원문 5종 분기표: verify code 숫자가 범인을 가리킨다
에러 메시지 뒤 괄호나 openssl s_client 출력의 Verify return code: NN 숫자가 결정적 단서입니다.
| 에러 원문 | verify code | 실제 원인 | 1순위 조치 | 확인 커맨드 |
|---|---|---|---|---|
unable to get local issuer certificate | 20 | 사내 루트 CA가 certifi에 없음, 또는 서버가 중간(intermediate) 인증서를 안 보냄 | 루트 CA를 파이썬에 태우기(4장) | openssl s_client -connect host:443 -showcerts |
self signed certificate in certificate chain | 19 | 사내 MITM 프록시가 트래픽을 재서명 | 프록시 루트 CA 등록(4장 b/d) | 체인 최상단 Issuer가 Zscaler/Netskope인지 확인 |
certificate has expired | 10 | 서버 인증서 만료, 또는 만료된 크로스서명 경로 선택(DST Root CA X3 계열 잔재), 시스템 시계 오차 | 서버 체인 점검 + date 확인 | openssl s_client ... | openssl x509 -noout -dates |
Hostname mismatch, certificate is not valid for 'x.y.z' | — | SNI 미전송, IP 직접 접속, 와일드카드 깊이(*.a.com은 b.c.a.com 불가) | 도메인으로 접속하거나 SAN 확인 | openssl x509 -noout -text | grep -A1 "Subject Alternative Name" |
macOS에서만 unable to get local issuer certificate | 20 | python.org 설치본에서 Install Certificates.command 미실행 → certifi 링크 부재 | 해당 스크립트 1회 실행(4장 OS별) | python3 -c "import certifi;print(certifi.where())" |
검색용으로 원문도 코드블록으로 남겨둡니다.
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate in certificate chain
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for 'api.example.com'30초 판정 절차: 커맨드 3개로 원인 확정
① 파이썬이 실제로 보는 번들 경로 확인
python -c "import ssl, certifi; print(certifi.where()); print(ssl.OPENSSL_VERSION)"예상 정상 결과:
/usr/lib/python3.11/site-packages/certifi/cacert.pem
OpenSSL 3.0.13 30 Jan 2024- 경로가 출력되는데도 실패한다 → 그 번들에 사내 CA가 없다는 뜻. 4장으로.
ModuleNotFoundError: certifi→pip install certifi또는 가상환경을 잘못 잡은 상태.- 가상환경이 여러 개 섞였다면
python -c "import sys;print(sys.executable)"로 실행 파일을 먼저 고정하세요.
② 서버가 실제로 내려주는 체인 확인
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null \
| grep -E "^(depth|verify|Verify| [0-9] s:| [0-9] i:)"정상(공인 인증서) 케이스:
0 s:CN=api.example.com
i:C=US, O=Let's Encrypt, CN=R11
1 s:C=US, O=Let's Encrypt, CN=R11
i:C=US, O=Internet Security Research Group, CN=ISRG Root X1
Verify return code: 0 (ok)사내 SSL 인스펙션 케이스:
0 s:CN=api.example.com
i:CN=Zscaler Intermediate Root CA (zscaler.net)
1 s:CN=Zscaler Intermediate Root CA (zscaler.net)
i:CN=Zscaler Root CA
Verify return code: 19 (self signed certificate in certificate chain)Issuer(i:)에 회사 이름이나 보안 장비 이름이 보이면 판정 끝입니다. MITM 프록시 환경 → 사내 루트 CA를 파이썬에 태우면 됩니다.
체인이 0번 하나만 나오고 Verify return code: 20이면 서버가 중간 인증서를 누락한 것입니다. 이 경우는 서버 관리자에게 풀체인(fullchain) 배포를 요청하는 게 정공법입니다. 인증서 발급/갱신 쪽 문제라면 certbot 갱신 실패 해결: certificate expired 원인별 트러블슈팅을 함께 보세요.
③ curl과 파이썬 비교로 "분리 구조" 확정
curl -v https://api.example.com/ 2>&1 | grep -E "CAfile|SSL certificate|subject|issuer"
python - <<'PY'
import requests
try:
requests.get("https://api.example.com/", timeout=5)
print("PY OK")
except Exception as e:
print("PY FAIL:", e)
PY| curl | 파이썬 | 판정 |
|---|---|---|
| 성공 | 실패 | OS 저장소에는 CA가 있고 certifi에는 없음 → 4장 (b)(d) 권장 |
| 실패 | 실패 | OS에도 CA가 없음 → 4장 OS별 루트 등록부터 |
| 성공 | 성공 | 코드/프록시 환경변수 문제 → 6장 실패 분기 |
참고로 파이썬의 CA 설정은 자바의 -Djavax.net.ssl.trustStore 계열 옵션과 완전히 별개입니다. 자바 스택에서 같은 증상이 났다면 PKIX path building failed / SunCertPathBuilderException 30분 해결 런북을 참고하세요.
환경별 정공법: 사내 루트 CA를 파이썬에 태우는 4가지 방법
(a) 환경변수 3형제 우선순위
| 변수 | 적용 범위 | 비고 |
|---|---|---|
verify= 인자 | 해당 요청/세션 | 항상 최우선. 코드에 하드코딩되면 환경변수가 무시됨 |
REQUESTS_CA_BUNDLE | requests 계열만 | requests가 직접 읽음. CURL_CA_BUNDLE보다 우선 |
CURL_CA_BUNDLE | curl + requests 폴백 | REQUESTS_CA_BUNDLE이 없을 때 requests가 사용 |
SSL_CERT_FILE / SSL_CERT_DIR | 파이썬 ssl·OpenSSL 전역 | aiohttp, httpx, urllib 등 표준 컨텍스트 전반에 적용 |
정리하면 우선순위는 verify= → REQUESTS_CA_BUNDLE → CURL_CA_BUNDLE → (requests 기본값 certifi) 이고, requests를 쓰지 않는 라이브러리는 SSL_CERT_FILE을 봅니다. 사내 전체에 깔 거라면 SSL_CERT_FILE이 커버리지가 넓습니다.
(b) truststore 모듈 — 2026년 기준 권장 1순위
Python 3.10+에서는 truststore가 OS 트러스트 스토어를 그대로 사용하게 해줍니다. 사내 IT가 이미 OS에 루트 CA를 배포했다면 추가 파일 관리가 필요 없습니다.
pip install truststoreimport truststore
truststore.inject_into_ssl() # 이 이후 생성되는 모든 SSLContext가 OS 저장소 사용
import requests
print(requests.get("https://api.example.com/", timeout=5).status_code)애플리케이션 진입점(main.py 최상단, Django settings.py 등)에서 한 번만 호출하면 됩니다. pip 자체도 최신 버전에서 --use-feature=truststore 계열로 시스템 저장소 사용을 지원하므로, 사용 중인 pip 버전의 공식 문서에서 옵션 지원 여부를 확인하고 적용하세요.
(c) pip-system-certs
pip install pip-system-certs설치만 하면 requests/pip이 시스템 저장소를 쓰도록 패치합니다. 코드 수정이 불가능한 서드파티 CLI 도구에 유용하지만, 임포트 시점에 몽키패치가 일어나므로 동작을 명시적으로 보고 싶은 프로덕션 서비스에는 (b)를 권합니다.
(d) 번들 병합 — certifi 원본을 절대 수정하지 말 것
certifi의 cacert.pem을 직접 편집하면 pip install --upgrade certifi 한 번에 날아갑니다. 별도 병합 파일을 만드세요.
sudo mkdir -p /opt/ca
cat "$(python -m certifi)" /path/to/corp-root.crt > /opt/ca/corp-bundle.pem
# 전역 적용
export SSL_CERT_FILE=/opt/ca/corp-bundle.pem
export REQUESTS_CA_BUNDLE=/opt/ca/corp-bundle.pem
# 검증
python -c "import requests;print(requests.get('https://api.example.com/',timeout=5).status_code)"정상이면 200이 출력됩니다. 여전히 실패하면 병합 파일에 -----BEGIN CERTIFICATE----- 블록이 두 종류 이상 들어갔는지, 그리고 사내 CA가 DER 형식이 아닌지 확인하세요. DER이면 변환이 필요합니다.
openssl x509 -inform der -in corp-root.der -out corp-root.crtOS별 루트 CA 등록
# Ubuntu / Debian
sudo cp corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
sudo update-ca-certificates # "1 added" 출력이 정상
# RHEL / Rocky
sudo cp corp-root.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust extract# macOS: python.org 설치본이라면 먼저 이것
/Applications/Python\ 3.12/Install\ Certificates.command
# 키체인 등록
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain corp-root.crt# Windows (관리자 PowerShell)
certutil -addstore -f "Root" C:\certs\corp-root.crt
Import-Certificate -FilePath C:\certs\corp-root.crt -CertStoreLocation Cert:\LocalMachine\Root4가지 방법 비교
| 방법 | 코드 수정 | 재설치 내성 | 커버리지 | 권장 순위 |
|---|---|---|---|---|
| truststore | 2줄 필요 | 강함(OS 기준) | 표준 ssl 전반 | ★1 |
| OS 루트 등록 + truststore | 없음(조합 시) | 강함 | OS 전체 도구 | ★1 |
번들 병합 + SSL_CERT_FILE | 없음 | 강함(별도 파일) | 파이썬 전반 | ★2 |
| pip-system-certs | 없음 | 보통 | requests/pip | ★3 |
| certifi 직접 수정 | 없음 | 없음 | requests만 | 금지 |
verify=False는 왜 해결이 아닌가 + 파생 케이스
verify=False의 실제 위험
verify=False는 "인증서를 검증하지 않는다"이지 "안전하게 연결한다"가 아닙니다. 카페 와이파이나 외부망에서 같은 코드가 돌면, 누구든 자기 인증서로 중간에 끼어들어 요청 헤더의 API 키와 응답 본문을 그대로 읽을 수 있습니다. 사내 프록시 안에서만 돌 것이라는 가정은 배포 환경이 하나만 바뀌어도 깨집니다.
부득이하게 임시로 써야 한다면 최소한 이렇게 남기세요.
# ⚠️ TODO(2026-08-31 제거): 폐쇄망 스테이징 전용. 운영 반영 금지.
# 사내 루트 CA 배포(INFRA-1234) 완료 후 verify=/opt/ca/corp-bundle.pem 으로 교체
import urllib3, requests
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
requests.get("https://internal.example.local/health", verify=False, timeout=5)경고를 끄는 코드는 "문제를 감춘 표시"입니다. 만료일 주석과 티켓 번호 없이 커밋하지 마세요.
pip install이 같은 에러를 낼 때
pip install requests \
--trusted-host pypi.org \
--trusted-host files.pythonhosted.org \
--trusted-host pypi.python.org영구 설정은 설정 파일에 넣습니다. (리눅스/macOS ~/.config/pip/pip.conf, 윈도우 %APPDATA%\pip\pip.ini)
[global]
cert = /opt/ca/corp-bundle.pem
index-url = https://pypi.org/simple
[install]
trusted-host =
pypi.org
files.pythonhosted.orgcert=(정공법)와 trusted-host(검증 우회)는 성격이 다릅니다. 가능하면 cert=를 쓰세요.
Docker에서만 실패할 때
python:3.12-slim, alpine 계열은 CA 번들 패키지가 빠져 있거나 최소화되어 있어 컨테이너에서만 실패하는 사례가 잦습니다.
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# 사내 루트 CA 반영
COPY corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
RUN update-ca-certificates
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt알파인이라면 RUN apk add --no-cache ca-certificates && update-ca-certificates를 씁니다. 이미지 빌드 단계(pip install)에서 이미 막힌다면 CA 복사를 pip install보다 앞에 배치해야 합니다.
라이브러리별 설정 대응표
| 라이브러리 | 사내 CA 지정 | 검증 끄기(비권장) |
|---|---|---|
| requests | requests.get(url, verify="/opt/ca/corp-bundle.pem") | verify=False |
| httpx | httpx.Client(verify="/opt/ca/corp-bundle.pem") | verify=False |
| aiohttp | ssl=ssl.create_default_context(cafile="/opt/ca/corp-bundle.pem") | ssl=False |
| urllib3 2.x | PoolManager(ca_certs="/opt/ca/corp-bundle.pem") | cert_reqs="CERT_NONE" |
| 표준 urllib | urlopen(url, context=ssl.create_default_context(cafile=...)) | — |
import ssl, aiohttp
ctx = ssl.create_default_context(cafile="/opt/ca/corp-bundle.pem")
async def fetch(url):
async with aiohttp.ClientSession() as s:
async with s.get(url, ssl=ctx) as r:
return await r.text()urllib3 2.x 이후로는 OpenSSL 1.1.1 이상이 요구되고 TLS 정책이 강화됐습니다. 구형 장비에 붙다가 SSLError: [SSL: UNSUPPORTED_PROTOCOL]이나 handshake failure가 나면 CA 문제가 아니라 프로토콜 협상 문제이므로, 이 글의 4장 조치로는 해결되지 않습니다. 서버 측 TLS 버전 상향이 정석입니다.
그래도 안 될 때: 실패 분기 3가지
- 프록시 환경변수 오설정 —
env | grep -i proxy로HTTPS_PROXY,NO_PROXY를 확인하세요. 사내 API가NO_PROXY에 빠져 프록시를 타면서 재서명되는 경우가 흔합니다. 대문자/소문자 변수(https_proxy)가 서로 다르게 설정된 경우도 자주 보고됩니다. - 시스템 시계 오차 — 컨테이너나 VM 시계가 크게 밀리면 유효한 인증서도
certificate has expired(err 10)로 뜹니다.date -u와 실제 UTC를 비교하고 NTP 동기화를 확인하세요. - OpenSSL 3.x의 정책 차이 — 구형 서버와의 legacy renegotiation 차단, SHA-1 서명 거부 등으로 실패할 수 있습니다. 이때 에러 문구가
CERTIFICATE_VERIFY_FAILED가 아니라 다른 코드로 바뀌므로 원문을 다시 읽는 게 먼저입니다.
판정 흐름을 다시 정리하면 이렇습니다.
에러 원문 확인
├ unable to get local issuer (20) ─ openssl s_client 로 체인 확인
│ ├ Issuer가 사내/보안장비 → 루트 CA 등록 (truststore 또는 번들 병합)
│ └ 체인 1개뿐 → 서버에 fullchain 배포 요청
├ self signed in chain (19) ────── 사내 MITM 확정 → 루트 CA 등록
├ certificate has expired (10) ─── date 확인 → 서버 인증서 만료일 확인
└ hostname mismatch ───────────── SAN/SNI/IP 접속 여부 확인Go나 Docker에서 나는 x509: certificate signed by unknown authority, 자바의 PKIX 오류는 원인 구조는 같지만 조치 파일 경로가 전혀 다릅니다. 스택별 글을 따로 참고하세요.
다음 편(파이썬 개발 가이드 8편)에서는 ModuleNotFoundError / ImportError — 파이썬 임포트 경로 문제를 sys.path, 패키지 레이아웃, 가상환경 관점에서 완전 판별하는 법을 다룹니다.
자주 묻는 질문 (FAQ)
Q. curl은 되는데 파이썬만 SSL 오류가 납니다. 왜 그런가요?
A. curl은 OS 트러스트 스토어를, requests는 패키지에 포함된 certifi 번들을 기본으로 사용하기 때문입니다. 사내 IT가 루트 CA를 OS에만 배포하면 파이썬은 그 CA를 모릅니다. truststore.inject_into_ssl()을 쓰거나 SSL_CERT_FILE에 병합 번들 경로를 지정하면 해결됩니다.
Q. REQUESTS_CA_BUNDLE을 설정했는데도 계속 실패합니다.
A. 코드에 verify= 인자가 하드코딩되어 있으면 환경변수보다 우선합니다. 또한 REQUESTS_CA_BUNDLE은 requests 계열에만 적용되므로 aiohttp·httpx·표준 urllib에는 SSL_CERT_FILE을 함께 설정해야 합니다. 파일 권한(다른 사용자로 실행되는 서비스에서 읽기 불가)도 확인하세요.
Q. verify=False로 넘겨도 되나요? A. 검증 자체를 끄는 것이라 중간자 공격에 무방비가 됩니다. 폐쇄망 테스트 등 한시적 용도라면 제거 기한과 티켓 번호를 주석으로 남기고, 운영 코드에는 반드시 사내 루트 CA를 등록하는 정공법을 적용하세요.
AI 도구는 자료 조사와 초안 작성의 보조 수단으로 사용될 수 있습니다. Nodelog는 공개 전 내용과 출처를 검토하고, 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서를 함께 확인하도록 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
댓글
첫 번째 댓글을 남겨보세요.