SSL 인증서 수동 관리 입문: 발급부터 설치, 갱신까지

SSL 인증서 수동 관리 입문: 발급부터 설치, 갱신까지

요즘은 HTTPS를 켜는 일이 너무 쉬워졌습니다. Cloudflare 뒤에 사이트를 두거나 Caddy로 서버를 띄우면 인증서라는 단어를 한 번도 마주치지 않고 자물쇠가 켜지는데요. 그러다 회사 사내 서버나 공유 호스팅처럼 자동화가 없는 환경을 만나면 갑자기 낯선 단어들이 쏟아집니다. CSR을 만들라고 하고, 파일이 세 개 오고, 중간 인증서를 붙이라고 하고, 만료 30일 전부터만 갱신이 된다고 합니다.

이 낯섦은 능력의 문제가 아니라 세대 차이입니다. Let’s Encrypt 이전 시대에는 인증서가 자동으로 흐르는 인프라가 아니라 사람이 사서 서버에 올리는 물건이었고, 그 시절 절차가 아직 여러 환경에 그대로 남아 있거든요. 이번 글에서는 그 전통적인 인증서 관리 과정을 발급부터 갱신까지 순서대로 따라가 보겠습니다. 개념보다는 실제로 무엇을 입력하고 무엇을 받아서 어디에 놓는지에 집중하려고 합니다.

인증서 한 장에 붙어 있는 다섯 단계

자동화된 환경에서는 한 줄 명령으로 끝나는 일이, 수동 환경에서는 다섯 단계로 나뉩니다.

flowchart TB
    accTitle: 수동 환경에서 SSL 인증서를 관리하는 다섯 단계
    accDescr: 서버에서 키 쌍과 CSR을 만들어 인증 기관에 제출하면 도메인 소유를 검증한 뒤 인증서를 발급하고, 운영자가 받은 파일을 서버에 설치한 다음 만료일을 감시하다가 갱신 시점에 다시 처음으로 돌아간다.

    csr["① 키 쌍과 CSR 생성<br/>서버에서 직접"]
    verify["② 도메인 소유 검증<br/>메일, 파일, TXT 레코드"]
    issue["③ 인증 기관이 발급<br/>파일 여러 개 수령"]
    install["④ 서버에 설치<br/>웹 서버 설정과 재시작"]
    watch["⑤ 만료 감시"]

    csr --> verify --> issue --> install --> watch
    watch -- "만료 30일 전" --> csr

    classDef manual stroke:#7c3aed,stroke-width:2px
    class csr,install manual

눈여겨볼 곳은 마지막 화살표입니다. 이 흐름은 한 번 돌고 끝나는 것이 아니라 만료 때마다 처음으로 돌아옵니다. 누군가 달력을 보고 이 순환을 계속 돌려줘야 하고, 그 사람이 자리를 비우면 아무 일도 일어나지 않습니다. 수동 관리에서 사고가 나는 지점은 대부분 기술이 아니라 여기입니다.

키 쌍과 CSR 만들기

첫 단계는 서버에서 비대칭 암호의 키 쌍을 만드는 일입니다.

개인 키 생성
openssl genrsa -out example.key 2048

이렇게 만든 example.key가 개인 키(private key)입니다. 이 파일은 서버 밖으로 절대 나가면 안 되고, 잃어버리면 인증서를 다시 받아야 합니다.

다음은 인증서 서명 요청(Certificate Signing Request, CSR)입니다. 인증 기관에 제출할 신청서라고 보면 되는데요. 요즘은 도메인을 여러 개 넣는 것이 기본이라 설정 파일을 하나 만들어 쓰는 편이 편합니다.

san.cnf
[req]
distinguished_name = dn
req_extensions     = ext
prompt             = no

[dn]
C  = KR
ST = Seoul
O  = Example Corp
CN = example.com

[ext]
subjectAltName = DNS:example.com, DNS:www.example.com
CSR 생성
openssl req -new -key example.key -out example.csr -config san.cnf

만들어진 CSR은 이렇게 생겼습니다.

example.csr
-----BEGIN CERTIFICATE REQUEST-----
MIICyTCCAbECAQAwSjELMAkGA1UEBhMCS1IxDjAMBgNVBAgMBVNlb3VsMRUwEwYD
VQQKDAxFeGFtcGxlIENvcnAxFDASBgNVBAMMC2V4YW1wbGUuY29tMIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxhoCiMXjkjmSmh0t3eLm8cf9Tahd0/GQ
...
-----END CERTIFICATE REQUEST-----

그냥 보면 알 수 없는 문자열이지만 디코딩하면 내용이 드러납니다.

CSR 내용 확인
openssl req -in example.csr -noout -text
결과
Subject: C=KR, ST=Seoul, O=Example Corp, CN=example.com
RSA Public-Key: (2048 bit)
DNS:example.com, DNS:www.example.com
Signature Algorithm: sha256WithRSAEncryption

여기서 초보자가 가장 많이 걱정하는 부분을 짚고 넘어가겠습니다. CSR에는 개인 키가 들어가지 않습니다. 공개 키와 도메인 정보만 담기고, 개인 키는 서명을 만드는 데만 쓰인 뒤 서버에 남습니다. 직접 확인해 볼 수도 있어요.

CSR에 개인 키가 있는지 확인
grep -c "PRIVATE" example.csr
결과
0

개인 키가 없으니 CSR 파일은 메일로 보내도 되고 웹 폼에 붙여 넣어도 됩니다. CSR이 담고 있는 필드와 PKCS #10 구조가 궁금하다면 TLS 인증서의 구조에서 더 자세히 다루고 있습니다.

도메인이 내 것임을 증명하기

CSR을 제출했다고 바로 인증서가 나오지는 않습니다. CSR의 서명은 “내가 이 개인 키를 갖고 있다”만 증명할 뿐, “이 도메인이 내 것이다”는 전혀 증명하지 못하거든요. CSR에 남의 도메인을 적는 것은 누구나 할 수 있으니까요.

그래서 인증 기관은 도메인 소유를 따로 확인합니다. 전통적인 방식에서는 보통 세 가지 중에서 고르게 됩니다.

메일 검증은 가장 오래된 방식입니다. admin@example.com이나 webmaster@example.com 같은 정해진 주소로 확인 메일을 보내고, 링크를 클릭하면 소유가 인정됩니다. 서버를 건드릴 필요가 없어 공유 호스팅에서 편하지만, 해당 메일 주소를 실제로 받을 수 있어야 합니다.

파일 검증은 인증 기관이 준 파일을 웹 루트의 지정된 경로에 올려두는 방식입니다. FTP만 있어도 되니 접근이 제한된 환경에서 자주 쓰입니다.

DNS 검증은 TXT 레코드를 추가하는 방식입니다. 서버에 손댈 수 없거나 아직 서버가 준비되지 않은 상태에서도 쓸 수 있고, 와일드카드 인증서를 받으려면 이 방식이 필요합니다.

세 방식 모두 사람이 한 번씩 손을 대야 한다는 공통점이 있습니다. Let’s Encrypt와 ACME가 바꾼 지점이 정확히 여기인데요. 같은 검증을 사람 대신 클라이언트 프로그램이 수행하도록 만든 것이 자동화의 출발점이었습니다.

받은 파일 세 개의 정체

검증을 통과하면 인증 기관이 파일을 보내옵니다. 여기서 많은 분들이 한번 당황합니다. 파일이 하나가 아니라 여러 개거든요.

이름은 인증 기관마다 다르지만 역할은 셋으로 나뉩니다.

  • example.crt: 서버 인증서(leaf certificate). 내 도메인 이름이 적힌 본체입니다.
  • example.ca-bundle: 중간 인증서(intermediate certificate). 루트와 내 인증서를 잇는 다리입니다.
  • example.key: 개인 키. 인증 기관이 준 것이 아니라 내가 1단계에서 만들어 갖고 있던 파일입니다.

세 번째가 중요합니다. 인증 기관은 개인 키를 주지 않습니다. 애초에 받은 적이 없으니까요. CSR을 만든 서버에서 개인 키를 잃어버렸다면 받은 인증서는 쓸 수 없고, 처음부터 다시 해야 합니다.

서버에 설치하고 체인 붙이기

파일을 서버에 올리고 웹 서버 설정에서 가리켜 주면 설치가 끝납니다.

nginx 설정
server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/ssl/example_fullchain.crt;
    ssl_certificate_key /etc/ssl/example.key;
}

여기서 초보자가 가장 자주 걸려 넘어지는 함정이 나옵니다. ssl_certificate에 서버 인증서만 넣고 중간 인증서를 빠뜨리는 경우인데요. 내 브라우저에서는 멀쩡해 보이는데 다른 기기나 모바일에서만 오류가 난다면 대부분 이 문제입니다.

왜 그런지 직접 재현해 볼 수 있습니다. 서버 인증서 한 장만 놓고 검증하면 이렇게 됩니다.

서버 인증서만으로 검증
openssl verify leaf.pem
결과
CN = daleseo.com
error 20 at 0 depth lookup:unable to get local issuer certificate
leaf.pem: verification failed: 20 (unable to get local issuer certificate)

중간 인증서를 함께 주면 통과합니다.

중간 인증서를 함께 검증
openssl verify -untrusted chain.pem leaf.pem
결과
leaf.pem: OK

브라우저는 신뢰하는 루트 인증 기관 목록만 갖고 있을 뿐, 중간 인증서까지 들고 있지는 않습니다. 그래서 서버가 중간 인증서를 함께 보내줘야 루트까지 사슬이 이어지는데요. 일부 브라우저는 중간 인증서를 캐시해 두었다가 알아서 메워 주기 때문에, 이미 다른 사이트를 방문한 적 있는 내 브라우저에서만 정상으로 보이는 착시가 생깁니다.

설치할 때는 서버 인증서와 중간 인증서를 순서대로 이어 붙인 파일을 만들어 둡니다.

체인 파일 만들기
cat example.crt example.ca-bundle > example_fullchain.crt

순서가 중요합니다. 서버 인증서가 먼저, 중간 인증서가 뒤입니다. 설치 후에는 브라우저 대신 명령으로 확인하는 편이 정확합니다. 아래부터는 실제로 돌아가는 사이트를 대상으로 잡았으니, 그대로 복사해 실행해 보셔도 됩니다.

서버가 실제로 보내는 체인 확인
echo | openssl s_client -connect daleseo.com:443 -servername daleseo.com -showcerts
결과
 0 s:/CN=daleseo.com
   i:/C=US/O=Google Trust Services/CN=WE1
 1 s:/C=US/O=Google Trust Services/CN=WE1
   i:/C=US/O=Google Trust Services LLC/CN=GTS Root R4

각 줄의 s:는 그 인증서의 주인, i:는 발급자입니다. 0번의 발급자가 1번의 주인과 일치하면서 사슬이 이어지는 모습이 보이는데요. 이 목록이 서버 인증서 한 줄로 끝난다면 중간 인증서가 빠진 것입니다.

apex와 www는 저절로 함께 가지 않습니다

수동 환경에서 정말 흔한 사고가 하나 있습니다. example.com은 자물쇠가 잘 걸려 있는데 www.example.com으로 들어가면 경고가 뜨는 경우입니다. 같은 서버, 같은 IP인데 왜 이런 일이 생길까요?

인증서를 고르는 기준이 IP가 아니라 클라이언트가 말해준 이름이기 때문입니다. TLS 핸드셰이크의 첫 메시지에는 접속하려는 호스트 이름이 평문으로 실려 나가는데, 이것을 서버 이름 표시(Server Name Indication, SNI)라고 합니다. 웹 서버는 이 이름으로 어떤 설정 블록을 쓸지 정하고, 그 블록에 적힌 인증서를 꺼내 줍니다.

설정 블록이 둘로 나뉘어 있으면 인증서도 자연히 둘이 됩니다.

이렇게 나뉘어 있으면
server {
    server_name example.com;
    ssl_certificate /etc/ssl/new_2026.crt;      # 갱신함
}

server {
    server_name www.example.com;
    ssl_certificate /etc/ssl/old_2025.crt;      # 잊고 방치됨
}

인증서 하나에 두 이름을 모두 담을 수는 있습니다. 주체 대체 이름(Subject Alternative Name, SAN) 필드에 도메인을 여러 개 적으면 되고, 앞서 만든 CSR에도 DNS:example.com, DNS:www.example.com 두 줄이 들어 있었죠. 다만 인증서가 두 이름을 담당할 자격이 있다는 것과, 그 파일이 두 블록에 실제로 설치되어 있다는 것은 완전히 다른 이야기입니다.

공유 호스팅에서는 이 함정이 더 잘 숨습니다. 관리 화면이 도메인별로 인증서 설치 슬롯을 따로 두는 경우가 많아서, 인증서 자체는 www를 커버하는데 www 슬롯에는 옛 인증서가 남아 있는 상태가 생기거든요. 설치 후에는 두 이름을 각각 확인하는 습관을 들이는 것이 좋습니다.

두 이름을 각각 확인
for h in example.com www.example.com; do
  echo "--- $h ---"
  echo | openssl s_client -connect "$h:443" -servername "$h" 2>/dev/null \
    | openssl x509 -noout -subject -enddate
done

만료는 조용히 옵니다

인증서가 만료돼도 사이트가 느려지지 않고 에러 로그가 쌓이지도 않습니다. 그냥 어느 날 아침부터 방문자에게 경고 화면이 뜨고, 서버는 아무 일 없다는 듯이 200 OK를 계속 반환합니다. 운영자가 직접 접속해 보지 않으면 몇 달이 지나도 모를 수 있다는 뜻인데요.

그래서 수동 환경에서는 만료 감시를 따로 만들어 둬야 합니다. 가장 간단한 방법은 남은 기간을 보는 것입니다.

만료일 확인
echo | openssl s_client -connect daleseo.com:443 -servername daleseo.com 2>/dev/null \
  | openssl x509 -noout -subject -enddate
결과
subject= /CN=daleseo.com
notAfter=Oct  5 20:12:38 2026 GMT

날짜를 사람이 읽고 판단하는 대신 자동으로 알림을 받고 싶다면 -checkend 옵션이 유용합니다. 지정한 초 안에 만료되면 0이 아닌 종료 코드를 돌려주기 때문에 스크립트로 엮기 좋습니다.

30일 안에 만료되는지 판정
echo | openssl s_client -connect daleseo.com:443 -servername daleseo.com 2>/dev/null > live.pem

if openssl x509 -in live.pem -noout -checkend $((30*86400)); then
  echo "30일 내 만료: 아니요"
else
  echo "30일 내 만료: 예 → 갱신 필요"
fi

이 스크립트를 cron에 걸어 슬랙이나 메일로 알림을 보내도록 하면, 갱신 시점을 사람 기억에 의존하지 않아도 됩니다. 자동 갱신은 못 하더라도 자동 감시는 할 수 있고, 이것만으로도 사고의 대부분이 사라집니다.

갱신과 연장은 다른 말입니다

인증서 만료일이 다가오면 헷갈리는 단어가 두 개 등장합니다.

연장은 상품의 계약 기간을 늘리는 상거래 행위입니다. 많은 인증 기관과 호스팅 업체가 만료 30일 전부터만 연장을 열어 두는데요. 이 기간을 놓쳐 인증서가 완전히 만료되면 연장이 아예 불가능해지고, 신규 신청으로 처음부터 다시 해야 합니다.

갱신은 새 인증서를 발급받아 기존 것을 교체하는 기술적 행위입니다. 기존 인증서의 날짜를 늘려서 다시 저장하는 방법은 없습니다. 유효 기간은 인증 기관의 서명 안에 포함되어 있어서, 날짜를 바꾸려면 서명을 새로 받아야 하거든요. 결국 갱신은 언제나 새 파일을 받아 설치하는 작업인 셈입니다.

만료된 뒤에 연장이 막히는 이유도 여기에 있습니다. 연장이 빠른 이유는 이전에 통과한 도메인 검증 이력을 재사용하기 때문인데, 만료된 건은 그 이력을 쓸 수 없어 절차상 신규와 똑같아집니다. 연장이란 검증 이력이 살아 있는 동안에만 열려 있는 지름길이었던 거죠.

정리하면 이렇습니다. 만료 30일 전부터의 기간 안에서는 클릭 몇 번으로 끝나고, 그 기간을 놓치면 CSR 생성부터 도메인 검증까지 전 과정을 다시 밟아야 합니다. 만료와 폐기(revocation)의 차이가 궁금하다면 TLS 인증서의 구조에서 따로 정리해 두었습니다.

수동 관리의 유효 기간도 끝나갑니다

여기까지 읽으셨다면 이 과정이 만만치 않다는 느낌을 받으셨을 텐데요. 문제는 이 주기가 점점 짧아지고 있다는 점입니다.

CA/브라우저 포럼(CA/Browser Forum)이 인증서 최대 유효 기간을 단계적으로 줄이기로 확정하면서, 2026년 3월부터 200일, 2027년 3월부터 100일, 2029년 3월부터는 47일이 됩니다. 47일짜리 인증서를 손으로 갱신한다면 1년에 여덟 번 이 과정을 반복해야 하는 셈이죠.

한때 인증서는 1년에 한 번 챙기면 되는 일이었습니다. 그때는 달력에 표시해 두고 사람이 처리하는 방식이 그럭저럭 굴러갔는데요. 주기가 47일로 줄어들면 사람의 기억력이나 인수인계로는 감당이 되지 않습니다.

장기적으로는 자동화가 가능한 환경으로 옮기는 것이 정답입니다. 서버에 셸 접근 권한이 있다면 Let’s Encrypt와 ACME 클라이언트로 이 글의 모든 단계를 통째로 없앨 수 있습니다. Caddy처럼 인증서 관리를 내장한 웹 서버를 쓰면 설정에 도메인만 적어도 되고요. Cloudflare 같은 서비스를 앞단에 두는 방법도 있습니다.

다만 공유 호스팅처럼 셸이나 설정 권한이 없는 환경에서는 이런 선택지가 막혀 있습니다. 그럴 때는 최소한 만료 감시라도 자동화해 두세요. 갱신을 자동으로 못 하더라도, 갱신 시점을 놓치지 않는 것만으로 실패의 대부분을 막을 수 있습니다.

마치며

전통적인 인증서 관리는 키 쌍과 CSR을 만들고, 도메인 소유를 증명하고, 받은 파일을 체인까지 맞춰 설치한 뒤, 만료를 감시하다가 때가 되면 다시 처음으로 돌아가는 순환이었습니다. 낯설어 보이는 단어들이 많지만 각 단계가 왜 필요한지 알고 나면 그렇게 복잡한 절차는 아닙니다.

특히 두 가지만 기억해 두시면 좋겠습니다. 하나는 개인 키가 서버를 떠나지 않으므로, 잃어버리면 처음부터 다시 해야 한다는 점입니다. 다른 하나는 인증서가 어떤 이름을 담당할 자격이 있다는 것과 그 파일이 실제로 설치되어 있다는 것이 별개라는 점이고요. 이 두 가지가 수동 환경에서 벌어지는 사고의 대부분을 설명합니다.

지금 운영 중인 사이트가 있다면 이 글의 명령들을 한 번씩 실행해 보시길 권합니다. 체인이 온전한지, apex와 www가 같은 인증서를 쓰고 있는지, 만료까지 며칠 남았는지를 확인하는 데 몇 분이면 충분합니다.

openssl 명령의 전체 옵션은 OpenSSL 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord