본문 바로가기
Operating System

openssl 명령어로 SSL 인증서 다루기: s_client 검증, PEM/DER/PKCS12 변환, CSR 생성

후아빠·2026년 8월 8일·조회 2

인증서 만료로 서비스가 멈추거나, 톰캣에는 되던 인증서가 nginx에서 안 붙거나, 자바 앱에 넣을 키스토어를 만들어야 하는 상황은 운영하다 보면 주기적으로 돌아온다. 그때마다 웹 콘솔을 열지 않아도 openssl 명령 몇 개면 대부분 현장에서 해결된다. 이번 글은 제가 서버에서 자주 두드리는 순서 그대로 정리했다.

먼저 결론부터 말하면, 원격 인증서 상태는 openssl s_clientopenssl x509 조합으로 확인하고, 포맷 변환은 openssl x509(PEM/DER)와 openssl pkcs12(PKCS12/JKS)로 처리한다. 새 인증서 발급은 openssl req로 CSR을 만들고, 배포 전에는 개인키와 인증서의 modulus 해시가 같은지 반드시 대조한다. 아래에서 이 네 가지를 차례로 살펴본다.

1. 개요: 포맷과 도구부터 정리

SSL/TLS 인증서를 다룰 때 가장 먼저 헷갈리는 건 포맷이다. 같은 인증서라도 담는 그릇이 여러 개다.

  • PEM: Base64로 인코딩한 텍스트. -----BEGIN CERTIFICATE-----로 시작한다. nginx, Apache, HAProxy가 주로 쓴다. 확장자는 .pem, .crt, .cer, .key가 섞여 나온다.
  • DER: 같은 내용을 바이너리로 담은 것. 자바 세계와 윈도우에서 자주 보인다. 확장자는 .der, .cer.
  • PKCS12: 인증서와 개인키, 체인까지 한 파일(.p12, .pfx)에 묶고 암호를 건 컨테이너. 자바 키스토어(JKS)를 대체하는 표준 포맷이기도 하다.

여기서 한 가지 짚고 넘어간다. 예전에는 자바가 독자 포맷인 JKS를 썼지만, 최근 JDK에서는 키스토어 기본 타입이 PKCS12다. 그래서 요즘은 keytool로 JKS를 따로 만들기보다, openssl pkcs12.p12를 만들어 그대로 자바에 붙이는 방식이 깔끔하다.

환경 확인부터 한다. 아래 예시 출력의 버전 번호는 시스템 상태에 따라 다를 수 있다.

$ openssl version
OpenSSL 3.2.2 4 Jun 2024 (Library: OpenSSL 3.2.2 4 Jun 2024)

RHEL 9 호환 배포판이면 OpenSSL 3.x가 기본으로 깔려 있다. 3.x에서는 -nodes 옵션이 -noenc로 바뀌었는데, -nodes도 아직 별칭으로 동작한다. 이 글은 3.x 기준으로 신형 옵션을 쓴다.

2. s_client로 원격 인증서 만료와 체인 검증

openssl s_client는 지정한 서버에 실제로 TLS 핸드셰이크를 걸고, 서버가 내려준 인증서를 그대로 보여주는 도구다. 브라우저가 하는 일을 명령줄에서 재현한다고 보면 된다.

가장 기본은 -connect 호스트:포트다. 여기에 -servername을 붙이는 게 중요하다. 이건 SNI(Server Name Indication), 즉 하나의 IP에 여러 도메인이 붙어 있을 때 "나는 이 도메인에 접속하려 한다"고 알려주는 TLS 확장이다. 이걸 빼면 가상호스트 서버가 엉뚱한 기본 인증서를 내려줘서 진단이 어긋난다.

$ openssl s_client -connect sarc.io:443 -servername sarc.io < /dev/null
CONNECTED(00000003)
depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R11
verify return:1
depth=0 CN = sarc.io
verify return:1
---
Certificate chain
 0 s:CN = sarc.io
   i:C = US, O = Let's Encrypt, CN = R11
 1 i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
---
...
    Verify return code: 0 (ok)

여기서 두 곳만 보면 된다. Verify return code: 0 (ok)면 체인 검증이 통과한 것이고, Certificate chain 목록의 i:(issuer)가 위로 잘 이어지는지 확인한다. 맨 앞에 < /dev/null을 붙인 이유는, 이걸 안 하면 세션이 열린 채 입력을 기다려서 터미널이 멈추기 때문이다. 저도 처음에 이 부분에서 자주 걸렸다.

만료일만 빠르게 보려면 x509로 파이프해서 날짜만 뽑는다.

$ echo | openssl s_client -connect sarc.io:443 -servername sarc.io 2>/dev/null \
  | openssl x509 -noout -dates
notBefore=Jun  1 00:00:00 2026 GMT
notAfter=Aug 30 23:59:59 2026 GMT

-noout은 인증서 본문 출력을 끄고, -dates는 유효기간만 뽑는 옵션이다. 발급자와 주체까지 함께 보려면 -subject -issuer를 더한다.

체인이 불완전할 때 나타나는 증상

흔한 함정 하나. 브라우저에서는 잘 열리는데 자바 클라이언트나 curl에서 검증에 실패하는 경우가 있다. 서버가 중간 인증서(intermediate)를 빼고 리프 인증서만 내려줄 때다. 이럴 때 검증 결과에 다음과 같이 찍힌다.

    Verify return code: 21 (unable to verify the first certificate)

-showcerts를 붙이면 서버가 보낸 인증서 목록 전체가 그대로 출력된다. 여기서 리프 하나만 나오면 체인 누락이 확실하다. 해결은 서버 설정에서 인증서 파일을 리프 다음에 중간 인증서를 이어붙인 fullchain으로 바꿔주는 것이다. Let's Encrypt는 fullchain.pem을 쓰면 이 문제가 안 생긴다.

3. 로컬 인증서 파일 내용 확인

파일로 받은 인증서의 내용을 볼 때는 x509를 쓴다. 전체를 사람이 읽을 수 있게 펼치려면 -text, 요점만 보려면 개별 옵션을 붙인다.

$ openssl x509 -in server.crt -noout -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: ...
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C = US, O = Let's Encrypt, CN = R11
        Validity
            Not Before: Jun  1 00:00:00 2026 GMT
            Not After : Aug 30 23:59:59 2026 GMT
        Subject: CN = sarc.io
        ...
        X509v3 Subject Alternative Name:
            DNS:sarc.io, DNS:www.sarc.io

실무에서 자주 놓치는 곳이 Subject Alternative Name(SAN)이다. 요즘 브라우저는 CN이 아니라 SAN을 보고 도메인을 매칭한다. 접속은 되는데 이름 불일치 에러가 난다면 여기에 해당 도메인이 들어 있는지부터 확인한다.

4. PEM, DER, PKCS12 상호 변환

변환은 세 갈래로 나뉜다. 텍스트(PEM)와 바이너리(DER) 사이, 그리고 개별 파일과 묶음(PKCS12) 사이다.

PEM ↔ DER

-inform은 입력 포맷, -outform은 출력 포맷을 지정한다. 인증서는 x509로 변환한다.

# PEM -> DER
$ openssl x509 -in server.crt -outform DER -out server.der

# DER -> PEM
$ openssl x509 -inform DER -in server.der -out server.pem

개인키를 DER로 바꿔야 한다면 x509가 아니라 pkey(또는 rsa)를 쓴다. 키와 인증서는 다루는 서브명령이 다르다는 점을 기억한다.

$ openssl pkey -in server.key -outform DER -out server.key.der

PEM -> PKCS12 (자바에 붙일 .p12 만들기)

인증서, 개인키, 체인을 한 파일로 묶을 때 pkcs12 -export를 쓴다. 자바 키스토어로 그대로 넣을 파일이 이렇게 만들어진다.

$ openssl pkcs12 -export \
  -inkey server.key \
  -in server.crt \
  -certfile chain.pem \
  -name sarc \
  -out server.p12
Enter Export Password:
Verifying - Enter Export Password:

옵션을 하나씩 보면, -inkey는 개인키, -in은 리프 인증서, -certfile은 중간 인증서 체인이다. -name은 키스토어 안에서 이 항목을 부르는 별칭(alias)으로, 자바에서 keyAlias로 지정할 값이다. 마지막에 묻는 export 비밀번호가 곧 키스토어 비밀번호가 된다.

여기서 한 번 걸렸던 지점이 있다. 스프링 부트 설정에서 key-store-typePKCS12로 맞추지 않으면 로딩에 실패한다. application.yml에서 이렇게 지정한다.

server:
  ssl:
    key-store: classpath:server.p12
    key-store-type: PKCS12
    key-store-password: changeit
    key-alias: sarc

PKCS12 -> PEM (nginx에 붙일 파일 풀기)

반대로 발급기관이나 윈도우에서 .pfx로 받은 걸 nginx용 PEM으로 풀어야 할 때가 있다. 한 번에 다 뽑은 뒤 인증서와 키를 나눠 쓴다.

# 인증서만 (개인키 제외)
$ openssl pkcs12 -in server.p12 -clcerts -nokeys -out server.crt

# 개인키만, 암호 없이 (nginx가 부팅 시 암호를 못 물어보므로)
$ openssl pkcs12 -in server.p12 -nocerts -noenc -out server.key

-nokeys는 키를 빼고 인증서만, -nocerts는 인증서를 빼고 키만 뽑는다. -noenc(구버전 -nodes)는 뽑아낸 개인키에 암호를 걸지 않는 옵션이다. nginx나 톰캣이 자동 기동 때 키 암호를 입력받을 수 없으므로 이 옵션이 필요하다. 다만 암호 없는 키 파일은 권한을 chmod 600으로 조여 두는 게 맞다.

JKS로 굳이 만들어야 하는 레거시 환경이라면, .p12를 만든 뒤 keytool -importkeystore -srckeystore server.p12 -srcstoretype PKCS12 -destkeystore server.jks로 변환하면 된다.

5. CSR 생성과 개인키 매칭 확인

CSR(Certificate Signing Request)은 인증기관에 "이 도메인, 이 공개키로 인증서를 발급해 달라"고 보내는 신청서다. 개인키와 함께 새로 만든다.

$ openssl req -new -newkey rsa:2048 -nodes \
  -keyout sarc.key \
  -out sarc.csr \
  -subj "/C=KR/O=sarc/CN=sarc.io"

-newkey rsa:2048은 2048비트 RSA 개인키를 함께 생성하고, -keyout에 그 키를, -out에 CSR을 저장한다. -nodes는 개인키에 암호를 걸지 않는 옵션이다. -subj는 대화형 질문을 건너뛰고 주체 정보를 한 줄로 넣는 방식인데, 형식은 /type=value/type=value다.

SAN을 넣어야 하면 -addext를 쓴다. 요즘 인증서는 SAN이 사실상 필수다.

$ openssl req -new -newkey rsa:2048 -nodes \
  -keyout sarc.key -out sarc.csr \
  -subj "/C=KR/O=sarc/CN=sarc.io" \
  -addext "subjectAltName=DNS:sarc.io,DNS:www.sarc.io"

만든 CSR 내용은 -noout -text로 확인한다.

$ openssl req -in sarc.csr -noout -text -verify
Certificate Request:
    Data:
        Version: 1 (0x0)
        Subject: C = KR, O = sarc, CN = sarc.io
        ...
        Requested Extensions:
            X509v3 Subject Alternative Name:
                DNS:sarc.io, DNS:www.sarc.io

개인키와 인증서가 짝인지 대조하기

발급받은 인증서를 서버에 넣기 직전에 반드시 하는 확인이 있다. 손에 든 개인키와 새로 받은 인증서가 정말 같은 짝인지 대조하는 일이다. 안 맞으면 웹서버가 기동은 되더라도 핸드셰이크에서 실패한다.

방법은 개인키, 인증서, CSR 각각의 modulus(공개키 계수) 해시를 뽑아서 같은지 보는 것이다. 세 값이 모두 같아야 한 세트다.

$ openssl x509 -in sarc.crt -noout -modulus | openssl md5
MD5(stdin)= 9f2a...c1

$ openssl rsa -in sarc.key -noout -modulus | openssl md5
MD5(stdin)= 9f2a...c1

$ openssl req -in sarc.csr -noout -modulus | openssl md5
MD5(stdin)= 9f2a...c1

세 해시가 일치하면 배포해도 된다. 여기서 인증서와 키의 해시가 다르게 나온다면, 발급 과정에서 CSR이 뒤바뀌었거나 다른 키로 만든 인증서를 받은 것이다. 이 한 줄 대조를 습관으로 두면 배포 후 장애를 크게 줄인다.

6. 정리: 상황별로 무엇을 쓰나

원격 서버 인증서의 만료와 체인은 s_client -connect ... -servername ...x509 -noout -dates를 파이프해서 본다. 포맷은 인증서면 x509, 개인키면 pkey로 PEM과 DER을 오가고, 여러 파일을 한데 묶거나 풀 때는 pkcs12를 쓴다. 자바에 붙일 거면 JKS 대신 PKCS12(.p12)를 바로 만드는 게 요즘 방식이다. 새 인증서는 req로 CSR을 만들고, 배포 전에는 개인키와 인증서의 modulus 해시가 같은지 꼭 대조한다.

자주 묻는 질문

s_client로 접속했는데 명령이 멈추고 프롬프트가 안 돌아온다.

s_client는 핸드셰이크 후 세션을 열어둔 채 표준입력을 기다린다. 명령 앞에 echo를 파이프하거나 뒤에 < /dev/null을 붙여 입력을 즉시 닫으면 결과만 출력하고 빠져나온다.

브라우저에서는 되는데 curl이나 자바에서 인증서 검증에 실패한다.

서버가 중간 인증서를 빠뜨리고 리프만 내려주는 경우가 많다. openssl s_client -showcerts로 서버가 보낸 인증서 목록을 확인하고, 리프 하나만 나온다면 중간 인증서를 이어붙인 fullchain으로 서버 설정을 바꾼다. Verify return code가 21이면 이 증상이다.

JKS와 PKCS12 중 무엇을 만들어야 하나?

최근 JDK는 키스토어 기본 타입이 PKCS12다. 특별히 JKS를 요구하는 레거시가 아니라면 openssl pkcs12로 .p12를 만들어 그대로 붙이는 것이 낫다. JKS가 꼭 필요하면 .p12를 만든 뒤 keytool -importkeystore로 변환한다.

OpenSSL 3.x에서 -nodes 옵션이 없다고 나온다.

OpenSSL 3.x에서는 -nodes가 -noenc로 이름이 바뀌었다. 다만 -nodes도 별칭으로 여전히 동작한다. 개인키를 암호 없이 뽑거나 생성할 때 둘 중 하나를 쓰면 된다.

개인키와 인증서가 서로 맞는 짝인지 어떻게 확인하나?

openssl x509 -noout -modulus | openssl md5로 인증서의 modulus 해시를, openssl rsa -noout -modulus | openssl md5로 개인키의 해시를 뽑아 비교한다. 두 값이 같으면 한 세트다. CSR도 openssl req -noout -modulus로 같은 방식으로 대조할 수 있다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.