SSL/TLS 인증서란 무엇인가: X.509와 신뢰 체인의 원리

브라우저에서 자물쇠 아이콘을 누르면 인증서 발급자와 유효 기간, 도메인 이름 같은 정보가 나타납니다.
서버를 운영하다 보면 fullchain.pem, privkey.pem, .crt, .p12처럼 비슷해 보이는 파일도 한꺼번에 마주치고요.
인증서가 만료됐다는 경고는 익숙하지만, 이 파일이 왜 서버의 신원을 증명하는지는 선뜻 설명하기 어렵습니다.
현대의 전송 계층 보안(Transport Layer Security, TLS) 인증서는 단순한 암호화 키도, 사이트가 안전하다는 보증서도 아닙니다. 누구의 공개 키인지, 어느 이름에 사용할 수 있는지, 누가 그 관계를 확인했는지를 디지털 서명으로 묶은 문서인데요. HTTPS가 자물쇠를 보여주는 이유와 TLS 핸드셰이크가 인증서를 사용하는 위치를 이해했다면, 이제 그 문서 안쪽을 열어볼 차례입니다.
이번 글에서는 보안 소켓 계층(Secure Sockets Layer, SSL) 인증서라는 익숙한 이름부터 X.509 필드와 신뢰 체인, 인증서 서명 요청, 파일 형식까지 차근차근 살펴보겠습니다. 마지막에는 OpenSSL로 실제 서버 인증서를 읽고 흔한 오류를 진단해 보겠습니다.
SSL 인증서일까, TLS 인증서일까
검색창에는 여전히 “SSL 인증서”라는 표현이 훨씬 자주 보입니다. 하지만 SSL은 오래전에 사용이 중단된 프로토콜이고, 현대 웹은 TLS를 사용합니다. 따라서 기술적으로는 TLS 인증서나 서버 인증서라고 부르는 편이 정확합니다.
그렇다고 SSL 인증서가 전혀 다른 파일이라는 뜻은 아닙니다. 대부분의 서비스가 같은 대상을 익숙한 옛 이름으로 부를 뿐인데요. 이 글에서도 검색할 때 자주 만나는 표현을 고려해 제목에는 SSL/TLS를 함께 썼지만, 본문에서는 TLS 인증서라고 부르겠습니다.
웹에서 사용하는 인증서는 대개 X.509 공개 키 인증서입니다. 인터넷에서 쓰는 X.509 인증서와 인증서 폐기 목록의 기본 프로필은 RFC 5280에 정의되어 있습니다.
인증서는 공개 키의 신분증입니다
비대칭키 한 쌍만 만들어서는 그 공개 키가 누구의 것인지 알 수 없습니다. 공격자도 자기 키 쌍을 만든 뒤 “이게 example.com의 공개 키입니다”라고 주장할 수 있기 때문이죠.
TLS 인증서는 공개 키에 신원 정보를 붙이고, 인증 기관(Certificate Authority, CA)이 그 묶음에 디지털 서명을 남긴 문서입니다. 서명에는 인증서의 주요 필드가 모두 포함되므로, 발급 뒤 도메인이나 공개 키, 유효 기간을 몰래 바꾸면 검증에 실패합니다.
flowchart TB
accTitle: TLS 인증서를 구성하고 서명하는 요소
accDescr: 도메인과 주체 정보, 공개 키, 유효 기간과 용도 같은 내용을 인증서 본문에 담고 인증 기관이 자신의 개인 키로 본문에 서명하면 검증 가능한 TLS 인증서가 만들어진다.
body["X.509 인증서 본문<br/>① 도메인과 주체 정보<br/>② 서버 공개 키<br/>③ 유효 기간과 사용 용도"]
caPrivate(["인증 기관의 개인 키"])
signature["인증 기관의 서명"]
certificate(["TLS 인증서"])
body --> signature
caPrivate --> signature
body --> certificate
signature --> certificate
classDef input stroke:#0284c7,stroke-width:2px
classDef privateKey stroke:#7c3aed,stroke-width:2px
classDef signature stroke:#0d9488,stroke-width:3px
classDef output stroke:#16a34a,stroke-width:3px
class body input
class caPrivate privateKey
class signature signature
class certificate output
인증 기관의 서명은 “이 사이트 운영자는 선량하다”는 평가가 아닙니다. 정해진 검증 절차에 따라 이 공개 키와 도메인의 관계를 확인했다는 뜻에 가깝습니다. 피싱 사이트도 자신이 소유한 도메인에는 정상 인증서를 발급받을 수 있는 이유입니다.
X.509 인증서 안에는 무엇이 들어 있을까
X.509 인증서를 사람이 읽을 수 있게 펼치면 필드가 아주 많습니다. 처음부터 전부 외우기보다는 다음 항목의 역할을 잡는 편이 좋습니다.
| 필드 | 의미 |
|---|---|
| 버전(version) | 사용하는 X.509 구조의 버전입니다. 현대 인증서는 보통 v3입니다. |
| 일련번호(serial number) | 발급자가 인증서를 구별하기 위해 부여한 고유 번호입니다. |
| 발급자(issuer) | 인증서에 서명한 인증 기관입니다. |
| 유효 기간(validity) | notBefore부터 notAfter까지 인증서를 사용할 수 있습니다. |
| 주체(subject) | 인증서가 나타내는 조직이나 서버의 이름입니다. |
| 주체 공개 키 정보(Subject Public Key Info, SPKI) | 공개 키와 공개 키 알고리즘이 들어 있습니다. |
| 주체 대체 이름(Subject Alternative Name, SAN) | 인증서를 사용할 수 있는 DNS 이름이나 IP 주소를 나열합니다. |
| 키 용도와 확장 키 용도 | 서명, 키 합의, 서버 인증처럼 허용된 용도를 제한합니다. |
| 기본 제약 조건(Basic Constraints) | 인증 기관용 인증서인지 최종 서버 인증서인지 구분합니다. |
| 서명 알고리즘과 서명값 | 발급자가 어떤 알고리즘으로 본문에 서명했는지 보여줍니다. |
여기서 웹 개발자가 가장 자주 확인하게 되는 필드는 SAN입니다. 브라우저는 주소창의 호스트 이름이 SAN에 들어 있는 DNS 이름과 맞는지 검사합니다.
예전 자료에는 주체의 일반 이름(Common Name, CN)으로 도메인을 확인한다고 적혀 있기도 합니다.
하지만 현재 서버 신원 검증 기준인 RFC 9525는 서비스 신원을 SAN에 표현하도록 요구하며, CN을 서버 신원으로 사용하는 방식을 더는 허용하지 않습니다.
그래서 인증서의 CN=example.com만 보고 도메인 검증이 끝났다고 생각하면 안 됩니다.
인증서 끝의 서명과 TLS 핸드셰이크의 CertificateVerify도 구분해야 합니다.
인증서 서명은 인증 기관이 “이 공개 키는 이 도메인에 연결된다”고 보증한 값입니다.
반면 CertificateVerify는 서버가 인증서의 공개 키와 짝을 이루는 개인 키를 실제로 가지고 있음을 현재 연결에서 증명하는 서명입니다.
인증서와 개인 키는 한 쌍이지만 같은 파일이 아닙니다
TLS 서버에는 인증서와 개인 키가 모두 필요합니다. 인증서는 클라이언트에 공개해도 되는 문서이고, 개인 키는 서버 밖으로 나가면 안 되는 비밀입니다.
flowchart TB
accTitle: 서버 인증서와 개인 키의 역할
accDescr: 서버는 외부로 공개하는 인증서와 내부에만 보관하는 개인 키를 함께 설정하고, 브라우저는 인증서에서 공개 키를 얻어 서버가 개인 키로 만든 서명을 검증한다.
subgraph server["서버"]
direction TB
privateKey(["개인 키<br/>외부 전송 금지"])
certificate["TLS 인증서<br/>공개 키 포함"]
sign["핸드셰이크 내용에 서명"]
privateKey --> sign
end
certificate --> browser["브라우저"]
sign --> browser
browser --> verify{"인증서의 공개 키로<br/>서명이 맞는가?"}
verify -- "예" --> accept(["개인 키 보유 확인"])
verify -- "아니요" --> reject(["연결 중단"])
classDef privateKey stroke:#7c3aed,stroke-width:3px
classDef publicData stroke:#0284c7,stroke-width:2px
classDef decision stroke:#0d9488,stroke-width:3px
classDef success stroke:#16a34a,stroke-width:3px
classDef failure stroke:#e11d48,stroke-width:3px
class privateKey privateKey
class certificate publicData
class verify decision
class accept success
class reject failure
인증서 파일이 공개됐다고 해서 바로 사고는 아닙니다. 브라우저가 접속할 때마다 원래 받는 정보이기 때문입니다. 반대로 개인 키가 유출되면 공격자가 서버인 척 서명할 수 있으므로 해당 인증서를 폐기하고 새 키 쌍으로 다시 발급해야 합니다.
서버 설정에서 인증서와 개인 키가 서로 다른 키 쌍에서 나왔다면 핸드셰이크가 시작되지 않습니다. 두 파일의 이름이 비슷하다는 사실이 아니라, 인증서 안의 공개 키가 개인 키에서 유도되는 공개 키와 같은지를 확인해야 합니다.
브라우저는 신뢰의 사슬을 어떻게 검증할까
서버 인증서에 인증 기관의 서명이 있다고 해서 브라우저가 그 발급자를 무조건 믿지는 않습니다. 브라우저나 운영 체제에는 미리 신뢰하는 루트 인증 기관의 인증서가 들어 있는 신뢰 저장소(trust store)가 있습니다. 서버 인증서에서 이 루트까지 서명을 따라 올라갈 수 있어야 하죠.
서버는 보통 서버 인증서와 필요한 중간 인증서를 보냅니다. 루트 인증서는 클라이언트가 이미 가지고 있으므로 서버가 보내지 않는 것이 일반적입니다. 브라우저는 서버 인증서의 서명을 중간 인증서의 공개 키로 확인하고, 다시 중간 인증서의 서명을 상위 인증서의 공개 키로 검증해 신뢰 기준점(trust anchor)에 도달합니다.
서명만 맞는다고 검증이 끝나는 것은 아닙니다. 실제 판단 과정을 단순화하면 다음과 같습니다.
flowchart TB
accTitle: 브라우저의 서버 인증서 검증 순서
accDescr: 브라우저는 서버 인증서에서 신뢰하는 루트까지 서명 체인을 만들고, 유효 기간과 SAN의 호스트 이름, 키 용도와 제약 조건을 차례로 검사해 모두 통과한 경우에만 연결을 계속한다.
received(["서버 인증서와<br/>중간 인증서 수신"])
checks["① 신뢰 체인<br/>② 유효 기간<br/>③ SAN의 호스트 이름<br/>④ 서버 인증 용도와 제약 조건"]
result{"모든 조건을<br/>충족하는가?"}
accept(["인증서 검증 성공"])
reject(["경고 표시 또는 연결 중단"])
received --> checks --> result
result -- "예" --> accept
result -- "아니요" --> reject
classDef decision stroke:#0d9488,stroke-width:3px
classDef success stroke:#16a34a,stroke-width:3px
classDef failure stroke:#e11d48,stroke-width:3px
class result decision
class accept success
class reject failure
환경과 정책에 따라 인증서 폐기 상태와 인증서 투명성(Certificate Transparency)도 확인할 수 있습니다. 따라서 “서명이 맞다”와 “이 연결에서 사용할 수 있는 인증서다”는 같은 판단이 아닙니다.
자체 서명 인증서(self-signed certificate)가 항상 암호학적으로 잘못된 것도 아닙니다. 문제는 일반 브라우저의 신뢰 저장소에 그 인증서가 없다는 점입니다. 조직 내부에서 자체 루트를 모든 기기에 안전하게 배포했다면 같은 방식으로 신뢰 체인을 만들 수 있지만, 방문자에게 경고를 무시하라고 안내하는 것은 신뢰 검증을 포기하는 행동입니다.
CSR은 인증서가 아니라 발급 신청서입니다
서버 인증서를 발급받을 때 자주 등장하는 파일이 인증서 서명 요청(Certificate Signing Request, CSR)입니다. RFC 2986에 정의된 PKCS #10 요청에는 주체 정보와 공개 키, 요청한 확장 필드, 그리고 요청자가 개인 키를 보유했다는 서명이 들어갑니다.
CSR에는 개인 키가 들어가지 않습니다. 서버에서 키 쌍을 만든 뒤 공개 키와 원하는 도메인을 CSR에 담아 인증 기관으로 보내고, 개인 키는 서버에 남겨두는 것이 핵심입니다. 인증 기관은 요청 내용을 검증한 뒤 실제 인증서에 어떤 확장을 넣을지 결정하므로, CSR에 적었다고 모두 그대로 발급되는 것은 아닙니다.
flowchart TB
accTitle: CSR을 이용해 TLS 인증서를 발급받는 흐름
accDescr: 서버가 키 쌍을 만든 뒤 공개 키와 요청할 도메인을 개인 키로 서명한 CSR에 담아 인증 기관에 보내면, 인증 기관은 도메인 통제권과 CSR 서명을 확인하고 인증 기관의 서명이 붙은 인증서를 발급한다.
keyPair["서버에서 키 쌍 생성"]
privateKey(["개인 키<br/>서버에 보관"])
publicKey["공개 키"]
request["도메인과 요청 확장"]
csr["CSR 생성 및<br/>개인 키로 서명"]
ca["인증 기관<br/>도메인 통제권 확인"]
certificate(["인증 기관이 서명한<br/>TLS 인증서"])
keyPair --> privateKey
keyPair --> publicKey
publicKey --> csr
request --> csr
privateKey --> csr
csr --> ca --> certificate
certificate --> server["서버에 인증서 설치"]
privateKey --> server
classDef privateKey stroke:#7c3aed,stroke-width:3px
classDef request stroke:#0284c7,stroke-width:2px
classDef authority stroke:#0d9488,stroke-width:3px
classDef output stroke:#16a34a,stroke-width:3px
class privateKey privateKey
class publicKey,request,csr request
class ca authority
class certificate output
OpenSSL로 P-256 키와 CSR을 만들려면 다음처럼 실행할 수 있습니다.
openssl genpkey \
-algorithm EC \
-pkeyopt ec_paramgen_curve:P-256 \
-out example.com.key
openssl req \
-new \
-key example.com.key \
-out example.com.csr \
-subj "/CN=example.com" \
-addext "subjectAltName=DNS:example.com,DNS:www.example.com"
만든 CSR의 서명과 주체를 확인해 보겠습니다.
openssl req \
-in example.com.csr \
-noout \
-subject \
-verify
Certificate request self-signature verify OK
subject=CN=example.com
이 성공 메시지는 CSR이 해당 개인 키로 서명됐다는 뜻일 뿐, example.com을 통제한다는 증명은 아닙니다. 도메인 통제권은 인증 기관이 별도의 절차로 확인합니다. Let’s Encrypt와 ACME는 이 검증과 발급, 갱신 과정을 자동화합니다.
PEM과 DER, CRT는 무엇이 다를까
인증서 작업이 어려워 보이는 이유 중 하나는 파일 확장자와 인코딩, 컨테이너 이름이 뒤섞여 있기 때문입니다.
고유 인코딩 규칙(Distinguished Encoding Rules, DER)은 추상 구문 표기법 1(Abstract Syntax Notation One, ASN.1) 구조를 바이너리로 직렬화합니다.
사람이 텍스트 편집기로 열면 읽기 어려운 바이트가 보이죠.
반면 흔히 PEM이라고 부르는 형식은 DER 데이터를 Base64로 바꾸고 BEGIN과 END 경계로 감싼 텍스트 표현입니다.
RFC 7468은 인증서의 텍스트 인코딩을 다음 형태로 정의합니다.
-----BEGIN CERTIFICATE-----
Base64로 인코딩된 DER 데이터
-----END CERTIFICATE-----
.pem, .crt, .cer는 파일 이름의 관례일 뿐, 확장자만 보고 내부가 PEM인지 DER인지 확정할 수 없습니다.
.key도 개인 키라는 힌트일 뿐이며, 텍스트나 바이너리 형식 모두 가능합니다.
파일을 열어 -----BEGIN 경계가 보이면 텍스트 인코딩이고, 그렇지 않다면 file 명령이나 OpenSSL의 -inform DER 옵션으로 확인하는 편이 안전합니다.
PKCS #12는 인증서 하나만 담는 인코딩이 아니라 개인 키와 서버 인증서, 중간 인증서를 함께 묶을 수 있는 컨테이너입니다.
보통 .p12나 .pfx 확장자를 쓰고 암호로 보호해 다른 시스템으로 옮길 때 사용합니다.
| 이름 | 주로 담는 것 | 특징 |
|---|---|---|
| PEM | 인증서, CSR, 공개 키, 개인 키 | Base64 텍스트이며 여러 블록을 이어 붙일 수 있음 |
| DER | 인증서나 키 한 개 | 바이너리 인코딩 |
| CRT와 CER | 주로 인증서 | 내부 인코딩은 확장자만으로 알 수 없음 |
| KEY | 주로 개인 키 | PEM이나 DER일 수 있음 |
| PKCS #12, P12와 PFX | 개인 키, 인증서와 인증서 체인 | 여러 항목을 한 파일로 묶는 컨테이너 |
PEM과 DER 사이를 바꾸거나 PKCS #12 묶음을 만들 때는 다음 명령을 사용할 수 있습니다.
# PEM 인증서를 DER로 변환
openssl x509 -in server.pem -outform DER -out server.der
# DER 인증서를 PEM으로 변환
openssl x509 -in server.der -inform DER -out server.pem
# 개인 키, 서버 인증서, 중간 인증서를 PKCS #12로 묶기
openssl pkcs12 \
-export \
-inkey server.key \
-in server.pem \
-certfile intermediate.pem \
-out server.p12
PKCS #12에 개인 키가 들어갈 수 있다는 점은 꼭 기억해야 합니다. 인증서 파일처럼 공개 저장소에 올리거나 메신저로 전달해서는 안 됩니다.
OpenSSL로 실제 인증서 살펴보기
서버가 보내는 인증서를 직접 확인하려면 openssl s_client와 openssl x509를 연결하면 됩니다.
-servername에는 서버 이름 표시(Server Name Indication, SNI)로 보낼 도메인을 지정합니다.
openssl s_client \
-connect daleseo.com:443 \
-servername daleseo.com </dev/null 2>/dev/null |
openssl x509 \
-noout \
-subject \
-issuer \
-ext subjectAltName
이 글을 작성할 때 실행한 결과는 다음과 같았습니다.
subject=CN=daleseo.com
issuer=C=US, O=Google Trust Services, CN=WE1
X509v3 Subject Alternative Name:
DNS:daleseo.com
subject는 인증서의 주체이고 issuer는 인증서에 서명한 발급자입니다.
실제 호스트 이름 검증에는 마지막의 SAN이 쓰입니다.
서버 인증서를 PEM 파일로 저장하려면 출력 파일을 지정합니다.
openssl s_client \
-connect daleseo.com:443 \
-servername daleseo.com </dev/null 2>/dev/null |
openssl x509 -out server.pem
일련번호와 유효 기간, SHA-256 지문을 보고 싶다면 출력 옵션을 바꿀 수 있습니다.
openssl x509 \
-in server.pem \
-noout \
-serial \
-dates \
-fingerprint \
-sha256
인증서가 특정 호스트 이름과 맞는지도 바로 검사할 수 있습니다.
openssl x509 -in server.pem -noout -checkhost daleseo.com
Hostname daleseo.com does match certificate
인증서 체인과 서버 인증 용도까지 검증하려면 신뢰할 루트와 중간 인증서를 함께 지정합니다.
openssl verify \
-CAfile root.pem \
-untrusted intermediate.pem \
-verify_hostname example.com \
-purpose sslserver \
server.pem
openssl s_client는 진단 도구라 검증 오류가 있어도 연결을 계속할 수 있습니다.
연결 자체를 오류로 끝내고 싶다면 -verify_return_error와 -verify_hostname을 함께 지정해야 합니다.
openssl s_client \
-connect example.com:443 \
-servername example.com \
-verify_hostname example.com \
-verify_return_error </dev/null
자주 만나는 인증서 오류
인증서 문제는 암호 알고리즘보다 파일 조합이나 이름, 시간 때문에 생기는 경우가 많습니다.
- 중간 인증서 누락: 서버가 최종 인증서만 보내면 클라이언트가 신뢰하는 루트까지 체인을 만들지 못할 수 있습니다. 서버 설정에는 보통 이 인증서 뒤에 중간 인증서를 붙인
fullchain.pem을 사용합니다. - SAN 불일치:
example.com인증서가api.example.com까지 자동으로 보호하지는 않습니다. 접속할 모든 이름을 SAN에 넣거나 의도에 맞는 와일드카드 인증서를 사용해야 합니다. - 만료 또는 아직 유효하지 않음: 현재 시각이
notBefore와notAfter사이에 있어야 합니다. 서버와 클라이언트의 시계가 크게 틀어진 경우에도 오류가 날 수 있습니다. - 개인 키 불일치: 인증서 안의 공개 키와 설정한 개인 키가 한 쌍이 아니면 서버가 핸드셰이크 서명을 만들 수 없습니다.
- 개인 키 권한 노출: 웹 서버 프로세스는 키를 읽을 수 있어야 하지만 다른 사용자까지 읽게 열어두면 안 됩니다. 저장소에 커밋하지 않는 것도 기본입니다.
- 자체 서명 인증서 경고 무시: 경고를 무시하면 신뢰할 서버인지 확인하는 단계가 사라집니다. 내부 환경이라면 자체 루트를 안전하게 배포해야 합니다.
- 확장자만 보고 형식 판단:
.crt라고 해서 항상 PEM은 아닙니다. 파싱 오류가 나면 실제 인코딩부터 확인해야 합니다.
인증서와 개인 키가 한 쌍인지 확인할 때는 양쪽에서 공개 키를 DER로 꺼내 같은 해시가 나오는지 비교할 수 있습니다.
openssl x509 -in server.pem -pubkey -noout |
openssl pkey -pubin -outform DER |
openssl sha256
openssl pkey -in server.key -pubout -outform DER |
openssl sha256
두 결과가 다르면 서로 다른 키 쌍의 파일입니다.
만료와 폐기는 다른 문제입니다
인증서의 유효 기간이 끝나면 만료(expiration)됩니다. 서버 운영자는 만료 전에 새 인증서를 발급해 교체해야 하며, 기존 인증서의 날짜를 늘려서 다시 저장하는 것은 불가능합니다. 발급자의 새로운 서명이 필요하기 때문입니다. 자동화 없이 이 교체를 직접 해야 한다면 SSL 인증서 수동 관리에서 발급부터 갱신까지의 절차를 다루고 있습니다.
폐기(revocation)는 아직 유효 기간이 남아 있는 인증서를 더는 신뢰하지 말라고 알리는 절차입니다. 개인 키 유출이나 잘못된 발급이 발견됐을 때 인증 기관은 인증서 폐기 목록(Certificate Revocation List, CRL)이나 온라인 인증서 상태 프로토콜(Online Certificate Status Protocol, OCSP)로 상태를 제공할 수 있습니다. 실제 클라이언트가 실패를 어떻게 처리하는지는 구현과 정책에 따라 달라서, 폐기만 믿기보다는 키를 보호하고 짧은 수명의 인증서를 자동 갱신하는 운영이 중요합니다.
마치며
TLS 인증서는 공개 키와 도메인, 유효 기간과 용도를 인증 기관의 서명으로 묶은 X.509 문서입니다. 브라우저는 서버 인증서 하나만 보는 것이 아니라 신뢰하는 루트까지의 서명 체인, SAN의 호스트 이름, 유효 기간과 사용 목적을 함께 검증합니다.
인증서는 공개해도 되지만 개인 키는 서버에만 남아야 합니다. CSR은 개인 키를 보내지 않고 공개 키와 원하는 도메인을 발급자에게 전달하는 신청서이고, PEM과 DER은 같은 구조를 표현하는 서로 다른 인코딩입니다. 이 구분만 잡아도 인증서 파일과 오류 메시지가 훨씬 덜 낯설게 보일 것입니다.
명령의 전체 옵션은 OpenSSL x509 공식 문서를 참고하세요.
This work is licensed under CC BY 4.0