어제까지 잘 돌던 수집 스크립트가 오늘 아침에만 CERTIFICATE_VERIFY_FAILED를 뱉고 멈춰 있습니다. 브라우저로 같은 주소를 열어보면 자물쇠 아이콘까지 멀쩡한데, 파이썬에서만 안 됩니다. 이 SSL 인증서 오류는 검색하면 verify=False 한 줄이 제일 먼저 나오고, 실제로 그걸 붙이면 당장은 돌아갑니다. 그래서 대부분 거기서 끝내는데, 그 한 줄이 나중에 어떤 비용으로 돌아오는지는 잘 안 알려져 있습니다. 이번 글에서는 이 오류의 원인을 서버 문제 / 내 컴퓨터 문제 / 사내망 문제 세 가지로 10초 안에 가려내는 방법과, 각각에 맞는 제대로 된 해결책을 정리합니다.
먼저, 이 에러 메시지를 읽는 법
파이썬 requests에서 나는 전형적인 형태는 이렇게 두 겹으로 쌓여 있습니다.
Traceback (most recent call last):
...
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: unable to get local issuer certificate (_ssl.c:1006)
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/items
(Caused by SSLError(SSLCertVerificationError(1, ...)))

아래쪽 requests.exceptions.SSLError는 껍데기입니다. 진짜 원인은 위쪽 ssl.SSLCertVerificationError 뒤에 붙은 짧은 영어 문구 하나에 다 들어 있습니다. 이 문구별로 원인이 완전히 다릅니다.
| 메시지 | 뜻 | 누구 문제인가 |
|---|---|---|
| unable to get local issuer certificate | 인증서를 서명한 상위 기관을 내 쪽에서 못 찾음 | 내 컴퓨터 (가장 흔함) |
| self signed certificate in certificate chain | 체인 중간에 자체 서명 인증서가 끼어 있음 | 사내 프록시/방화벽 |
| certificate has expired | 서버 인증서 유효기간이 지남 | 서버 (내가 못 고침) |
| hostname mismatch | 인증서에 적힌 도메인과 접속 주소가 다름 | 서버 설정 또는 내 주소 오타 |
| certificate is not yet valid | 인증서 시작일이 미래 | 내 PC 시계 오류 |
원인을 10초에 가리는 명령 한 줄
추측하지 말고 서버가 실제로 뭘 보내는지 직접 봅니다. openssl은 macOS·리눅스에 기본 설치돼 있고, 윈도우에서는 Git for Windows를 깔았다면 Git Bash 안에 들어 있습니다.
# 서버가 보내는 인증서 체인을 그대로 확인한다
openssl s_client -connect api.example.com:443 -servername api.example.com
# 출력 맨 아래 한 줄만 보면 된다
# Verify return code: 0 (ok) -> 서버는 정상, 내 쪽 문제
# Verify return code: 20 (unable to get local issuer certificate)
# Verify return code: 10 (certificate has expired) -> 서버 인증서 만료
# Verify return code: 19 (self signed certificate in certificate chain)
# -> 사내 프록시가 가로채는 중

출력이 길지만 맨 마지막 Verify return code 한 줄만 보면 됩니다. 여기서 0 (ok)가 나오는데 내 스크립트만 실패한다면, 서버는 정상이고 파이썬이 보는 인증서 묶음이 문제라는 뜻입니다. 바로 다음 항목으로 갑니다.
브라우저로 열었을 때 멀쩡한 것은 판단 근거가 되지 않습니다. 크롬과 엣지는 운영체제의 인증서 저장소를 쓰지만, 파이썬·Node.js·git은 각자 자기만의 인증서 묶음을 따로 들고 다니기 때문입니다. 이 차이가 “브라우저는 되는데 코드만 안 되는” 상황의 거의 모든 원인입니다.
경우 1 — 내 컴퓨터의 인증서 묶음이 낡았을 때
파이썬의 requests는 운영체제를 보지 않고 certifi 패키지가 들고 있는 cacert.pem 파일 하나만 봅니다. 이 파일은 패키지 버전에 박제돼 있어서, 오래된 가상환경을 그대로 쓰고 있으면 몇 년 전 목록이 남아 있는 경우가 있습니다. 루트 인증 기관이 중간에 교체되면 그날부터 갑자기 실패합니다.
# 파이썬이 어떤 인증서 묶음을 보고 있는지 먼저 확인
python -c "import certifi; print(certifi.where())"
# C:\Users\hong\venv\Lib\site-packages\certifi\cacert.pem
# 오래된 묶음이 원인인 경우가 많다. 갱신한다
python -m pip install --upgrade certifi
대부분 이 한 줄로 끝납니다. macOS에 python.org 설치본을 쓰고 있다면 한 가지가 더 있습니다. 설치 폴더(/Applications/Python 3.x/) 안의 Install Certificates.command를 더블클릭해 한 번 실행해 주세요. macOS용 파이썬은 설치 직후 인증서 묶음이 비어 있어서, 이걸 실행하지 않으면 모든 HTTPS 요청이 같은 오류로 실패합니다. 새 맥에서 파이썬을 깔자마자 이 오류를 만났다면 십중팔구 이 경우입니다.
경우 2 — 회사 네트워크가 통신을 가로채고 있을 때
self signed certificate in certificate chain이 나왔다면 내 컴퓨터 문제가 아닙니다. 회사의 보안 장비(사내 프록시, 방화벽, 백신)가 HTTPS 트래픽을 중간에서 풀어 검사한 뒤 자기 인증서로 다시 서명해서 보내주고 있는 것입니다. 회사가 발급한 노트북에서 흔합니다.
이때 브라우저가 멀쩡한 건, IT팀이 사내 루트 CA를 운영체제 저장소에 미리 설치해 뒀기 때문입니다. 파이썬은 그 저장소를 안 보니까 혼자 실패하는 겁니다. 해결 방향은 두 가지입니다.
방법 A — 운영체제 저장소를 그대로 쓰게 만들기
가장 손이 덜 갑니다. 인증서 파일을 구하러 다닐 필요가 없습니다.
# 운영체제 인증서 저장소를 그대로 쓰게 만든다
# (윈도우 인증서 저장소 / macOS 키체인에 사내 루트 CA가 이미 깔려 있을 때)
# pip install truststore
import truststore
truststore.inject_into_ssl() # requests / urllib3 import 보다 먼저 호출
import requests
r = requests.get("https://api.example.com/v1/items", timeout=10)
print(r.status_code)
truststore는 윈도우 인증서 저장소와 macOS 키체인을 파이썬이 직접 읽도록 연결해 줍니다. 회사 노트북이라면 사내 루트 CA가 이미 그 안에 들어 있으므로 추가 설정 없이 통과합니다. 다만 inject_into_ssl()은 다른 HTTP 라이브러리를 import 하기 전에 호출해야 적용됩니다.
방법 B — 사내 루트 CA 파일을 직접 지정하기
서버나 CI처럼 GUI 인증서 저장소가 없는 환경이면 이쪽입니다. IT 담당자에게 사내 루트 CA의 .pem(또는 .crt) 파일을 요청하거나, 브라우저 주소창의 자물쇠 → 인증서 → 최상위 항목을 Base64 형식으로 내보내면 됩니다.
# 사내 루트 CA를 PEM 파일로 받아뒀다면, 환경변수로 지정하는 게 가장 깔끔하다
# 윈도우 (PowerShell) - 현재 세션에만 적용
$env:REQUESTS_CA_BUNDLE = "C:\certs\corp-root-ca.pem"
$env:SSL_CERT_FILE = "C:\certs\corp-root-ca.pem"
# macOS / 리눅스
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/corp-root-ca.pem
export SSL_CERT_FILE=/etc/ssl/certs/corp-root-ca.pem
# 코드에서 직접 지정해도 된다
# requests.get(url, verify="C:/certs/corp-root-ca.pem")
여기서 자주 막히는 지점이 하나 있습니다. 사내 CA 파일만 지정하면 이번엔 외부 공개 사이트가 안 됩니다. 지정한 파일에 공개 인증 기관 목록이 없기 때문입니다. 둘 다 써야 한다면 두 묶음을 합친 파일을 만들어 씁니다.
# 공개 사이트와 사내 사이트를 한 스크립트에서 모두 호출해야 한다면
# certifi 묶음 + 사내 루트 CA 를 합친 파일을 하나 만들어 쓴다
import shutil, certifi
OUT = "merged-ca.pem"
shutil.copyfile(certifi.where(), OUT)
with open("corp-root-ca.pem", "r", encoding="utf-8") as src, \
open(OUT, "a", encoding="utf-8") as dst:
dst.write("\n")
dst.write(src.read())
print("merged ->", OUT)
# 이 파일 경로를 REQUESTS_CA_BUNDLE 에 지정하면 양쪽 다 통과한다
파이썬 말고 다른 도구에서 날 때
같은 원인인데 도구마다 설정 위치가 전부 다릅니다. 하나를 고쳐도 옆에서 또 나는 이유입니다.
# Node.js 도 시스템 저장소를 안 본다. 별도 환경변수를 쓴다
set NODE_EXTRA_CA_CERTS=C:\certs\corp-root-ca.pem # cmd
$env:NODE_EXTRA_CA_CERTS = "C:\certs\corp-root-ca.pem" # PowerShell
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/corp-root-ca.pem
# npm 은 자체 설정도 따로 본다
npm config set cafile "C:\certs\corp-root-ca.pem"
# git: SSL certificate problem: unable to get local issuer certificate
git config --global http.sslCAInfo "C:/certs/corp-root-ca.pem"
# pip: 설치 때만 막히는 경우
pip config set global.cert "C:/certs/corp-root-ca.pem"
# 확인
git config --global --get http.sslCAInfo
pip config list
| 도구 | 인증서를 어디서 읽나 | 지정 방법 |
|---|---|---|
| Python requests | certifi 패키지의 cacert.pem | REQUESTS_CA_BUNDLE / verify= |
| Python 표준 ssl | certifi 또는 OS 저장소 | SSL_CERT_FILE / truststore |
| Node.js | 내장된 자체 CA 목록 | NODE_EXTRA_CA_CERTS |
| npm | Node CA + npm 자체 설정 | npm config set cafile |
| git | OpenSSL 기본 경로 (윈도우는 동봉본) | http.sslCAInfo |
| pip | certifi | pip config set global.cert |
| curl | OS 저장소 또는 동봉 묶음 | --cacert / CURL_CA_BUNDLE |
환경변수 방식(REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS)이 특히 편한 이유는, 코드를 한 줄도 안 고치고 그 환경에서 돌아가는 모든 스크립트에 한꺼번에 적용되기 때문입니다. 회사 노트북이라면 시스템 환경변수에 영구 등록해 두는 편이 낫습니다.
verify=False가 답이 아닌 이유
검증을 끄면 오류는 사라집니다. 하지만 사라지는 건 오류지 위험이 아닙니다. 인증서 검증은 “지금 내가 대화하는 상대가 진짜 그 서버가 맞는지”를 확인하는 절차이고, 이걸 끄면 통신 내용은 여전히 암호화되지만 누구와 암호화해서 대화하는지를 확인하지 않게 됩니다. 중간에 끼어든 쪽이 있어도 그대로 통과시킵니다.
- API 키, 액세스 토큰, 로그인 정보가 그 요청에 실려 나간다면 그대로 노출될 수 있는 상태가 됩니다.
- 지금은 사내망이라 안전하다고 해도, 같은 스크립트가 나중에 카페 와이파이나 서버로 옮겨가면 방어선이 없습니다.
- 서버 인증서가 진짜로 만료돼 가는 중이어도 알아채지 못합니다. 원래 이 오류가 잡아줬어야 할 신호를 스스로 꺼버린 셈입니다.
InsecureRequestWarning경고가 계속 찍히고, 이걸 지우려고 경고까지 끄면 나중에 아무도 이 설정의 존재를 모르게 됩니다.
꼭 써야 한다면 “내가 만든 테스트 서버에, 로컬에서, 민감정보 없이” 세 조건이 모두 맞을 때만 쓰고, 바로 위 줄에 왜 껐는지와 언제 되돌릴지를 주석으로 남기세요. 운영 코드에 들어간 verify=False는 대부분 “임시로” 넣었다가 잊힌 것들입니다.
그래도 안 될 때 체크리스트
- PC 시계를 확인합니다. 날짜가 어긋나 있으면 유효한 인증서도 “아직 유효하지 않음”으로 판정됩니다. 가상머신이나 오래 절전 상태였던 노트북에서 종종 발생합니다.
- 가상환경을 확인합니다.
certifi를 업그레이드했는데 그대로라면, 터미널의 파이썬과 스크립트를 돌리는 파이썬이 다른 환경일 수 있습니다.python -c "import sys; print(sys.executable)"로 대조하세요. - 환경변수가 실제로 적용됐는지 봅니다. PowerShell에서
$env:로 설정한 값은 그 창에서만 삽니다. 새 터미널이나 작업 스케줄러에서 돌리면 사라집니다. - 인증서 파일 형식을 확인합니다.
REQUESTS_CA_BUNDLE에는 텍스트 형식인 PEM만 들어갑니다. 파일을 열었을 때-----BEGIN CERTIFICATE-----로 시작하지 않으면 DER 형식이므로 변환이 필요합니다. - 중간 인증서 누락을 의심합니다.
openssl s_client출력에서 인증서가 하나만 보이면 서버 쪽이 체인을 덜 보낸 것입니다. 브라우저는 알아서 보완하지만 파이썬은 안 합니다. 이건 서버 관리자에게 알려야 고쳐집니다.
마무리
오늘 할 수 있는 가장 작은 첫걸음은 python -m pip install --upgrade certifi 한 줄입니다. 개인 PC에서 나는 이 오류의 상당수는 여기서 끝나고, 안 끝나면 그때 openssl s_client로 서버 쪽인지 내 쪽인지만 갈라보면 됩니다. 회사 노트북이라면 truststore를 먼저 시도해 보세요. 어느 쪽이든 verify=False보다 손이 더 가지도 않습니다.
다음 글에서는 주기적으로 쌓이는 로그 파일을 자동으로 압축·보관하고 오래된 것부터 지우는 정리 스크립트를 만들어 보겠습니다.