SSL 인증서 오류(CERTIFICATE_VERIFY_FAILED) 해결 총정리 — verify=False가 답이 아닌 이유

어제까지 잘 돌던 수집 스크립트가 오늘 아침에만 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로 만료된 인증서를 가진 사이트에 접속해 CERTIFICATE_VERIFY_FAILED가 발생한 터미널 화면
스택이 길지만 읽어야 할 곳은 맨 끝 한 줄입니다. 괄호 안 ‘certificate has expired’가 실제 원인입니다.

아래쪽 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)
#                                                       -> 사내 프록시가 가로채는 중
openssl s_client 명령으로 서버 인증서를 확인해 Verify return code 10 certificate has expired를 받은 터미널 화면
만료된 인증서를 쓰는 테스트 사이트에 실제로 붙여 본 결과입니다. 맨 아래 Verify return code 한 줄이 원인을 그대로 알려 줍니다.

출력이 길지만 맨 마지막 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 requestscertifi 패키지의 cacert.pemREQUESTS_CA_BUNDLE / verify=
Python 표준 sslcertifi 또는 OS 저장소SSL_CERT_FILE / truststore
Node.js내장된 자체 CA 목록NODE_EXTRA_CA_CERTS
npmNode CA + npm 자체 설정npm config set cafile
gitOpenSSL 기본 경로 (윈도우는 동봉본)http.sslCAInfo
pipcertifipip config set global.cert
curlOS 저장소 또는 동봉 묶음--cacert / CURL_CA_BUNDLE

환경변수 방식(REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS)이 특히 편한 이유는, 코드를 한 줄도 안 고치고 그 환경에서 돌아가는 모든 스크립트에 한꺼번에 적용되기 때문입니다. 회사 노트북이라면 시스템 환경변수에 영구 등록해 두는 편이 낫습니다.

verify=False가 답이 아닌 이유

검증을 끄면 오류는 사라집니다. 하지만 사라지는 건 오류지 위험이 아닙니다. 인증서 검증은 “지금 내가 대화하는 상대가 진짜 그 서버가 맞는지”를 확인하는 절차이고, 이걸 끄면 통신 내용은 여전히 암호화되지만 누구와 암호화해서 대화하는지를 확인하지 않게 됩니다. 중간에 끼어든 쪽이 있어도 그대로 통과시킵니다.

  • API 키, 액세스 토큰, 로그인 정보가 그 요청에 실려 나간다면 그대로 노출될 수 있는 상태가 됩니다.
  • 지금은 사내망이라 안전하다고 해도, 같은 스크립트가 나중에 카페 와이파이나 서버로 옮겨가면 방어선이 없습니다.
  • 서버 인증서가 진짜로 만료돼 가는 중이어도 알아채지 못합니다. 원래 이 오류가 잡아줬어야 할 신호를 스스로 꺼버린 셈입니다.
  • InsecureRequestWarning 경고가 계속 찍히고, 이걸 지우려고 경고까지 끄면 나중에 아무도 이 설정의 존재를 모르게 됩니다.

꼭 써야 한다면 “내가 만든 테스트 서버에, 로컬에서, 민감정보 없이” 세 조건이 모두 맞을 때만 쓰고, 바로 위 줄에 왜 껐는지와 언제 되돌릴지를 주석으로 남기세요. 운영 코드에 들어간 verify=False는 대부분 “임시로” 넣었다가 잊힌 것들입니다.

그래도 안 될 때 체크리스트

  1. PC 시계를 확인합니다. 날짜가 어긋나 있으면 유효한 인증서도 “아직 유효하지 않음”으로 판정됩니다. 가상머신이나 오래 절전 상태였던 노트북에서 종종 발생합니다.
  2. 가상환경을 확인합니다. certifi를 업그레이드했는데 그대로라면, 터미널의 파이썬과 스크립트를 돌리는 파이썬이 다른 환경일 수 있습니다. python -c "import sys; print(sys.executable)"로 대조하세요.
  3. 환경변수가 실제로 적용됐는지 봅니다. PowerShell에서 $env:로 설정한 값은 그 창에서만 삽니다. 새 터미널이나 작업 스케줄러에서 돌리면 사라집니다.
  4. 인증서 파일 형식을 확인합니다. REQUESTS_CA_BUNDLE에는 텍스트 형식인 PEM만 들어갑니다. 파일을 열었을 때 -----BEGIN CERTIFICATE-----로 시작하지 않으면 DER 형식이므로 변환이 필요합니다.
  5. 중간 인증서 누락을 의심합니다. openssl s_client 출력에서 인증서가 하나만 보이면 서버 쪽이 체인을 덜 보낸 것입니다. 브라우저는 알아서 보완하지만 파이썬은 안 합니다. 이건 서버 관리자에게 알려야 고쳐집니다.

마무리

오늘 할 수 있는 가장 작은 첫걸음은 python -m pip install --upgrade certifi 한 줄입니다. 개인 PC에서 나는 이 오류의 상당수는 여기서 끝나고, 안 끝나면 그때 openssl s_client로 서버 쪽인지 내 쪽인지만 갈라보면 됩니다. 회사 노트북이라면 truststore를 먼저 시도해 보세요. 어느 쪽이든 verify=False보다 손이 더 가지도 않습니다.

다음 글에서는 주기적으로 쌓이는 로그 파일을 자동으로 압축·보관하고 오래된 것부터 지우는 정리 스크립트를 만들어 보겠습니다.

댓글 남기기