mLog 명판이 있는 감정대에서 진열장과 작업대의 서로 다른 방패 모양 인증서를 대조하는 흰색 3D 유령 보석 감정사

NPM 인증서를 갱신했는데 이전 인증서가 보일 때: SNI·CDN·원본 서버 구분

서버·인프라 Nginx Proxy Manager 2026년 10월 11일

Nginx Proxy Manager에서 인증서를 갱신했는데 브라우저에 예전 만료일이 보이면, 먼저 지금 어느 서버의 인증서를 보고 있는지 확인한다. 공개 도메인이 Cloudflare를 거치는지, 원본 NPM에 직접 연결했는지, 여러 서버 중 하나에 접속했는지에 따라 보이는 인증서가 달라질 수 있다.

이 상태에서 인증서를 다시 발급하거나 NPM을 재시작해도 원인을 놓칠 수 있다. 발급된 인증서, Proxy Host에 선택된 인증서, 실제 TLS 연결에서 전달된 인증서를 순서대로 대조하는 편이 낫다.

이 글은 2026-10-11 공식 문서를 확인하고 로컬 TLS 서버로 진단 도구의 동작을 재현한 기록이다. mLog에서 실제 갱신 장애가 발생했다는 의미는 아니다. NPM의 운영 버전, ACME 갱신 작업, Cloudflare 관리 설정은 이번 실험에서 변경하거나 검증하지 않았다.

1. 인증서 오류와 리다이렉트를 먼저 나눈다

브라우저가 인증서 오류로 연결을 중단하는 경우와, HTTPS 페이지에 연결한 뒤 주소가 계속 바뀌는 경우는 확인 순서가 다르다. 인증서가 신뢰되고 도메인도 맞는데 주소가 반복된다면 NPM 무한 리다이렉트 진단으로 이어간다.

이번 글에서 확인할 질문은 세 가지다.

  1. 어떤 주소로 연결했고 어떤 이름의 인증서를 요청했는가?
  2. 그 인증서는 해당 이름을 포함하고, 검사하는 클라이언트가 신뢰하며, 유효 기간 안에 있는가?
  3. 새 인증서를 적용하려던 위치와 지금 관측한 위치가 같은가?

예를 들어 NPM의 원본 인증서를 갱신한 뒤 Cloudflare의 공개 인증서 만료일만 비교하면, 원본 변경을 확인한 것이 아니다. 반대로 원본이 정상이어도 공개 경로가 다른 서버를 가리키면 방문자는 다른 결과를 볼 수 있다. 이 둘은 각각 측정해야 한다.

2. 연결 IP·SNI·HTTP Host는 같은 값이 아니다

한 IP에서 여러 HTTPS 사이트를 제공할 수 있다. 이때 서버는 TLS 연결 시작 단계의 SNI를 이용해 어떤 이름의 인증서를 제공할지 결정할 수 있다. HTTP의 Host 헤더는 TLS 연결 이후의 요청에 들어간다. NGINX HTTPS 공식 설명이 이 차이를 설명한다.

항목 확인하는 대상
연결 IP와 포트 어느 네트워크 종단에 접속하는가
TLS SNI TLS 단계에서 어떤 서버 이름을 요청하는가
인증서 이름 검증 받은 인증서가 요청한 호스트를 보호하는가
HTTP Host TLS 이후 어떤 웹사이트에 HTTP 요청을 보내는가

따라서 https://IP주소/로 접속하면서 Host만 도메인으로 바꾸면 실제 도메인 접속과 같은 검사가 되지 않는다. 아래 예시는 권장 명령이 아니라 흔한 오진 패턴이다.

# 이 명령만으로 blog.example.com의 TLS가 정상이라고 판정하지 않는다.
curl -H 'Host: blog.example.com' https://203.0.113.10/

203.0.113.10은 문서용 예시 주소다. 실제 확인에서는 소유한 서버의 주소로 바꾼다. 알 수 없는 IP를 찾아다니거나 다른 사람의 서버를 검사할 필요는 없다.

3. 공개 도메인에서 실제 인증서를 읽는다

아래는 Ubuntu와 OpenSSL 3.x를 기준으로 한 예시다. openssl version으로 구현·버전을 먼저 확인한다. macOS 기본 명령이 LibreSSL이면 옵션이 다를 수 있다. timeout은 GNU coreutils 명령이므로 없는 환경에서는 실행 환경에 맞는 시간 제한 방법을 사용한다.

domain='blog.example.com'

timeout 15s openssl s_client \
  -connect "${domain}:443" \
  -servername "$domain" \
  -verify_hostname "$domain" \
  -verify_return_error \
  -showcerts </dev/null >tls-session.txt 2>tls-check.txt
result=$?
printf 'openssl_exit=%s\n' "$result"
cat tls-check.txt

-servername은 SNI를 명시하고, -verify_hostname은 받은 인증서의 이름을 검사한다. -verify_return_error는 인증서 검증 오류를 무시하고 계속 진행하지 않도록 한다. 서버가 인증서를 보여줬다는 것만으로 신뢰 검증까지 통과했다고 생각하지 않는다. 옵션의 의미는 OpenSSL s_client 문서를 기준으로 확인했다.

종료 코드가 124면 위 예시의 시간 제한에 걸린 것이고, 다른 실패도 표준 오류 내용을 함께 읽는다. 연결 종료 방식에 따른 오류와 인증서 검증 오류를 구분한다. 출력이 없거나 검증에 실패했다면 먼저 그 원인을 확인한다. 다음 명령은 수집된 첫 번째 인증서의 내용을 살피기 위한 것이다.

awk '/-----BEGIN CERTIFICATE-----/{copy=1} copy{print} /-----END CERTIFICATE-----/{exit}' \
  tls-session.txt >leaf.pem

openssl x509 -in leaf.pem -noout \
  -subject -issuer -serial -dates -fingerprint -sha256 \
  -ext subjectAltName

subjectAltName에서 도메인을, notAfter에서 만료 시점을, SHA-256 지문에서 인증서의 동일성을 비교한다. 발급자 이름이나 파일 수정 시각만 같다고 같은 인증서라고 판단하지 않는다. -showcerts는 서버가 보낸 목록을 보여줄 뿐 검증된 인증서 체인 자체를 보장하지 않는다는 점도 주의한다.

이미 확보한 PEM 파일의 만료 임박 여부만 확인할 때는 다음처럼 실행할 수 있다.

# 604800초 = 7일. 검사 기간 예시이며 갱신 주기를 뜻하지 않는다.
openssl x509 -in leaf.pem -noout -checkend 604800

이 명령은 지정 기간 안의 만료 여부를 검사한다. 종료 코드가 0이 아니면 만료 임박뿐 아니라 파일 읽기·해석 오류도 확인해야 한다. 이름·신뢰 체인 검증을 대신하지 않는다. OpenSSL x509 문서에 출력 및 검사 옵션이 정리되어 있다.

4. 원본 IP를 검사할 때도 도메인 이름을 유지한다

원본 NPM의 IP를 알고 있고 그 경로로 연결할 권한이 있다면, curl --resolve로 연결 대상만 고정할 수 있다. 공개 DNS나 hosts 파일을 바꾸지 않아도 된다.

domain='blog.example.com'
origin_ip='203.0.113.10'

curl --resolve "${domain}:443:${origin_ip}" \
  --connect-timeout 5 --max-time 15 \
  -sS -v -o /dev/null "https://${domain}/"

URL에는 도메인을 그대로 둔다. 이 방식은 URL의 이름을 SNI와 인증서 검증에 사용하면서 연결할 IP를 지정한다. curl의 이름 해석 설명에 Host만 변경하는 방식과의 차이가 나온다.

회사 프록시·VPN·TLS 검사 장비가 개입하면 측정 경로를 별도로 기록한다. HTTPS 프록시가 요청을 대신 전달하는 환경에서는 예상한 직접 연결이 아닐 수 있다. 허용된 직접 연결 환경에서 실행하고 verbose 출력의 연결 대상을 확인한다. 방화벽이나 CDN 전용 접근 제한 때문에 원본 접속이 막혀 있다면 이를 곧바로 인증서 실패라고 해석하지 않는다. 점검을 위해 원본 방화벽을 전체 개방하지도 않는다. 연결 단계가 실패하면 외부 포트와 방화벽 점검부터 확인한다.

IPv4와 IPv6, 여러 원본 서버를 운영한다면 소유한 각 경로를 분리해 비교한다. 한 IP에서 정상이라고 모든 경로가 갱신되었다고 결론내릴 수는 없다. 비교 기록에는 검사 시각, 도메인, 접속 대상, 인증서 지문과 만료일을 남긴다. 요청 헤더나 상세 로그를 공개할 때는 쿠키·토큰을 지운다.

5. Cloudflare 엣지와 원본 인증서는 달라도 된다

Cloudflare 프록시를 사용하는 구성에서는 브라우저와 Cloudflare 사이, Cloudflare와 원본 사이가 별도의 TLS 구간이다. 공개 주소에서 관측한 인증서가 원본 NPM에 설치한 인증서와 다른 것 자체는 장애의 증거가 아니다.

Cloudflare Origin CA 인증서는 원본 구간을 위한 인증서다. 일반 브라우저가 원본에 직접 접근하면 신뢰 오류가 날 수 있다. Cloudflare 공식 문서도 프록시를 끄거나 일시 중지했을 때 이 문제가 생길 수 있다고 설명한다.

따라서 공개 접속은 정상인데 --resolve로 원본에 연결한 검사만 신뢰 오류가 난다면, 어떤 CA를 쓰는지부터 확인한다. 원본 CA가 맞는지 확인된 경우에만 공식 출처의 올바른 CA 파일을 해당 검사에 한정해 --cacert로 지정한다. 서버가 보내 준 인증서를 무조건 신뢰 저장소에 추가하는 방식은 사용하지 않는다.

curl -k로 응답을 받았다고 인증서 문제가 해결된 것도 아니다. 이 옵션은 검증을 건너뛴다. 공개 서비스의 성공 기준은 검증을 유지한 접속 결과여야 한다. curl 인증서 검증 안내를 참고한다.

프록시 경로를 읽는 방법은 실제 IP와 신뢰 프록시 구분과 연결되지만, X-Forwarded-For를 바꾼다고 TLS 인증서 선택이 바뀌지는 않는다.

6. NPM에서는 발급·연결·반영을 나눠 확인한다

NPM은 Let's Encrypt와 사용자 인증서를 관리하는 도구다. 기능 범위는 공식 가이드에서 확인할 수 있다. 화면 이름과 동작은 설치 버전에 따라 달라질 수 있으므로 고정된 메뉴 순서를 외우기보다 아래 대조표를 사용한다.

관측 결과 다음에 확인할 항목
갱신 작업 자체가 실패함 ACME 오류 원문과 사용한 HTTP-01 또는 DNS-01 방식
새 인증서는 존재하지만 원본이 옛 인증서를 반환함 해당 Proxy Host의 인증서 선택, 다른 NPM 인스턴스 접속 여부, 설정 반영 로그
원본은 예상 인증서인데 공개 경로가 다름 CDN TLS 종단, DNS·여러 원본 경로
이름은 맞지만 신뢰 검증 실패 CA·중간 인증서 구성, 클라이언트 신뢰 저장소와 시각
TLS 검증 성공 후 HTTP 502 프록시 업스트림 상태와 Docker 네트워크

NGINX 일반 동작에서 설정 재적용은 새 설정을 검사해 반영하는 과정이다. 공식 제어 문서를 보면 재적용 실패 시 기존 설정으로 계속 동작할 수 있다. 따라서 인증서 파일이 바뀌었다는 사실과 새 연결에 해당 인증서가 제공된다는 사실을 분리해서 확인해야 한다.

NPM이 관리하는 설정 파일을 직접 덮어쓰거나 인증서 볼륨을 삭제하는 방식부터 시도하지 않는다. 변경 전에는 선택된 인증서와 호스트 설정을 기록하고, 실제 버전의 관리 절차로 한 항목씩 수정한다. 전체 재시작은 다른 호스트까지 영향을 줄 수 있으므로 원인을 좁힌 뒤 결정한다.

TLS까지 정상인데 502가 남았다면 NPM 502와 Docker 네트워크 진단으로 이어간다. 인증서를 반복해서 발급받는 것으로 업스트림 장애가 해결되지는 않는다.

7. 갱신 실패라면 검증 방식부터 확인한다

새 인증서가 아예 발급되지 않은 경우는 적용 확인과 다른 문제다. Let's Encrypt의 챌린지 설명을 기준으로 사용 중인 방식을 확인한다.

  • HTTP-01: 80번 포트의 검증 경로에 외부 검증 요청이 도달하는지 확인한다. 여러 웹서버가 있다면 필요한 응답이 각 경로에서 제공되는지 살핀다.
  • DNS-01: _acme-challenge TXT 레코드, 실제 권한 있는 DNS, API 자격 증명 범위와 전파 상태를 확인한다. 와일드카드 발급도 이 방식으로 처리할 수 있다.

443 페이지가 잘 열리는 것만으로 HTTP-01 검증 경로까지 정상이라고 보지 않는다. DNS-01에서는 웹 포트 상태만 고쳐도 DNS 인증 문제가 해결되지 않는다. API 토큰을 채팅·블로그·공개 로그에 복사하지 말고 필요한 범위로 제한한다.

인터넷 글의 갱신 주기나 특정 Certbot 명령을 현재 NPM 버전에 그대로 대입하지 않는다. 설치 버전의 갱신 로그를 읽고 실패한 단계에 맞춰 조치한다. 이 글에서는 운영 서버에서 강제 갱신을 실행하지 않았다.

8. 로컬 TLS 실험에서 확인한 범위

Python 3.12.14, OpenSSL 3.0.13, curl 8.5.0 환경에서 임시 인증서 두 개와 127.0.0.1 HTTPS 서버를 만들었다. 서버는 site.test SNI가 들어오면 해당 인증서를 선택하고, 그 외에는 테스트용 기본 인증서를 선택하도록 구성했다. 운영 인증서나 시스템 CA 저장소를 변경하지 않았다.

검사 실제 결과
--resolve와 테스트 CA, 올바른 도메인 curl 종료 0, HTTP 200
IP URL에 Host 헤더만 추가 curl 종료 60
테스트 CA를 지정하지 않은 자체 서명 인증서 curl 종료 60
신뢰 CA는 맞지만 요청 호스트가 다름 curl 종료 60
OpenSSL에 올바른 SNI·검증 호스트 지정 종료 0
올바른 SNI에 잘못된 검증 호스트 지정 종료 1, 이름 불일치 확인
SNI 없이 테스트 기본 인증서의 이름 검증 종료 0
1일 인증서를 2일 검사 구간으로 checkend 실행 종료 1

8개 검사가 의도한 결과와 일치했다. 종료 60 하나만으로 신뢰 문제와 이름 불일치를 구분할 수 없다는 점도 확인했다. 오류 설명을 같이 읽어야 한다. SNI가 없는 모든 운영 서버가 기본 인증서를 준다는 의미는 아니다. 이 실험 서버를 그렇게 구성했으며, 다른 서버는 연결을 거절할 수도 있다.

첨부 코드 tls-sni-local-test.py는 Python 3, OpenSSL 3, curl이 있는 로컬 환경에서 아래처럼 실행한다. 외부 접속 없이 임시 파일을 생성하고 루프백 포트 하나를 열며, 종료 시 임시 인증서를 제거한다.

python3 tls-sni-local-test.py

이 결과는 TLS 진단 옵션의 재현 결과다. 실제 NPM 갱신, Let's Encrypt 검증, Cloudflare 배포, IPv6 경로, 만료된 운영 인증서 복구를 통합 시험한 것은 아니다.

변경 후 확인할 기준

수정한 뒤에는 처음과 같은 도메인·접속 경로에서 다시 검사한다. 의도한 이름이 포함되어 있고, 유효 기간과 신뢰 검증이 정상이며, 해당 종단에서 예상한 인증서가 제공되는지 확인한다. 공개 경로와 원본 경로가 있다면 각각의 기대값으로 판단한다.

인증서 검증 이후에는 공개 페이지와 로그인 기능까지 확인한다. 여기서 HTTP 리다이렉트나 502가 보이면 다음 단계의 문제로 나눈다. 새 인증서를 발급했다는 기록보다, 방문자가 사용하는 경로에서 검증을 유지한 채 정상 접속되는지가 최종 확인 기준이다.

태그

mLog

웹 개발과 서버 운영 과정에서 마주친 문제와 해결 과정을 기록합니다. 직접 확인한 설정과 실행 결과를 함께 정리해, 비슷한 문제를 겪는 분들이 참고할 수 있도록 합니다.