금요일 낮 12시쯤에 카톡이 왔다. 기 개발사로부터 파일을 인계받아 이관한 고객사 사이트 두 곳에서 블로그·고객후기 등 전부 사라졌다는 신고였다. 화면은 그대로라고 했다
(혹시) DB가 날아간 걸 먼저 의심했다. 그게 아니었다. 원인은 25일 전에 만료된 SSL 인증서였고 데이터는 한 건도 유실되지 않았다. 데이터 생존 확정까지 7분, 메인 사이트 복구까지 1시간 57분, 두 사이트 완전 복구까지 4시간 20분이 걸렸다. 정작 고쳐야 했던 건 인증서가 아니라, 인계 문서에 “VPS SSL은 완전 자동”이라고 적혀 있던 갱신 구조였다
껍데기가 남았다는 말이 단서였다
신고 내용을 확인했다. 물어본 건 세 개다
| 질문 | 답 |
|---|---|
| 게시판·등록한 정보만 사라졌나 | 그렇다 |
| 화면 껍데기는 그대로인가 | 그렇다 |
| 인계 직후에는 정상이었나 | 전부 정상이었다 |
DB가 통째로 날아갔다면 화면이 깨지거나 500이 떠야 한다. 레이아웃은 온전한데 목록만 0건이라는 진술은, 화면과 데이터가 물리적으로 다른 서버에 있다는 뜻이었다. 구조가 나뉘어 있었다
그려 보니 실제로 그랬다
[방문자 브라우저]
│
├─ 화면(HTML/CSS/JS) ──→ 카페24 웹호스팅
│ www.example.com
│ example2.com
│
└─ 데이터(API 호출) ────→ 카페24 VPS
api.example.com (NestJS + PostgreSQL)
adm.example.com (관리자 페이지)
프론트엔드는 Vite로 빌드한 React SPA를 카페24 웹호스팅에 정적 배포한 형태였다. 배포된 번들을 열어 보면 API 주소가 하드코딩되어 있다
// www.example.com/assets/index-[hash].js const API_BASE = "https://api.example.com/api/v1"
여기서 하나가 걸렸다. 보조 사이트의 번들에도 같은 주소가 들어 있었다. 자기 도메인이 멀쩡해도 메인 API가 죽으면 보조 사이트까지 함께 빈 껍데기가 된다는 뜻이다. 사이트가 두 개인데 데이터 출처는 하나였다
메인 사이트는 200을 돌려주고 있었다
두 도메인의 응답부터 확인했다
curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/
# → 200 정상
curl -s -o /dev/null -w "%{http_code}\n" https://example2.com/
# → curl: (60) SSL certificate problem: certificate has expired
보조 사이트는 인증서 만료가 즉시 드러났다. 메인 사이트는 200이었다. 여기서 “보조만 인증서 문제”로 결론 내릴 뻔했다. 그런데 신고는 두 사이트 모두였고, 메인 사이트의 200은 카페24가 서빙하는 껍데기가 정상이라는 사실만 증명한다. 데이터 계층은 아직 확인한 게 없었다
번들에서 뽑아 둔 주소를 그대로 때렸다
curl -s https://api.example.com/ # → curl: (60) SSL certificate problem: certificate has expired
여기가 원인이었다. 화면을 주는 서버의 인증서는 유효하고, 데이터를 주는 서버의 인증서만 만료된 상태였다
| 도메인 | 발급자 | 유효기간 | 상태 |
|---|---|---|---|
| api.example.com (+adm) | Let’s Encrypt R12 | 2026-04-28 ~ 2026-07-27 | 만료 (25일 경과) |
| example2.com (+www) | Let’s Encrypt R12 | 2026-04-28 ~ 2026-07-27 | 만료 |
| www.example.com | Sectigo DV R36 | 2026-05-19 ~ 2026-12-03 | 유효 |
증상이 “사라진 것처럼” 보인 이유도 이 표에서 나온다. 브라우저는 만료된 인증서로 가는 fetch/XHR 요청을 경고창도 띄우지 않고 조용히 차단한다. 페이지 진입 시 인증서 경고 화면을 보여 주는 문서 요청과 달리, 백그라운드 API 요청은 아무 흔적 없이 죽는다. 껍데기는 Sectigo 인증서로 정상 로드되고, 목록만 0건이 된다. 신고 문장과 정확히 일치한다
데이터가 살아 있는지를 먼저 확인했다
원인이 잡혔지만 조치보다 먼저 한 일이 있다. 데이터 생존 확인이다
클라이언트가 제일 불안해하는 건 원인이 아니다. 몇 년 쌓은 자료가 날아갔는지다. 인증서 검증만 우회하면 서버 자체는 접근할 수 있다
# -k 는 인증서 검증만 건너뛴다. 서버·DB 상태는 그대로 확인된다. for ep in members cases blogs reviews news company; do curl -sk "https://api.example.com/api/v1/$ep" | head -c 200 done
여섯 개 엔드포인트가 모두 200과 실제 데이터를 돌려줬다. 데이터는 100% 온전했다
이 확인을 먼저 한 게 이번 대응에서 가장 잘한 판단이었다. “데이터가 사라진 게 아니라 인증서 때문에 못 가져오는 상태”라고 1시쯤에 확정할 수 있었다. 원인 조사에 한 시간을 먼저 썼다면 그 한 시간 동안 클라이언트는 자료가 날아갔다고 믿고 있었을 것이다
타이머는 정상이었다. 하루 두 번 실패하면서
인증서가 만료된 건 결과다. 왜 자동 갱신이 안 됐는지가 진짜 원인이다
운영 중인 고객사 서버라서 진단 단계에서는 조회 명령만 실행했다
timedatectl # 시각 정상, NTP active df -h / # 36% 사용, 여유 있음 certbot certificates # 3건 전부 EXPIRED systemctl is-enabled certbot.timer # enabled systemctl is-active certbot.timer # active
enabled와 active가 떴다. 타이머가 안 돌아서 갱신이 안 됐다는 가설이 여기서 깨졌다. 타이머는 두 달 내내 하루 두 번 정상적으로 기동하고 있었다. 매번 실패했을 뿐이다
디스크도, 시각도, 타이머도 정상인데 인증서만 만료됐다는 조합에서 남는 건 갱신 설정 자체였다. renewal 설정 파일을 열었다
# /etc/letsencrypt/renewal/api.example.com.conf authenticator = dns-cloudflare dns_cloudflare_credentials = /etc/letsencrypt/cloudflare.ini
로그에 두 달간 같은 문장이 쌓여 있었다
PluginError: Unable to determine zone_id for api.example.com using zone names: ['api.example.com', 'example.com', 'com']. Please confirm that the domain name has been entered correctly and is already associated with the supplied Cloudflare account.
certbot은 Cloudflare API로 DNS-01 인증을 하도록 설정돼 있었다. 그런데 zone을 찾지 못한다고 한다. 실제 네임서버를 조회했다
dig +short NS example.com # → ns1.cafe24.com, ns2.cafe24.com, ns1.cafe24.co.kr, ns2.cafe24.co.kr
Cloudflare가 아니었다. certbot은 존재하지 않는 zone을 찾으러 가고 있었다
“완전 자동”은 개발사 계정에 종속된 자동이었다
기 개발사가 자사 Cloudflare 계정에 도메인 zone을 올려 두고 DNS-01로 인증서를 발급한 뒤 서버를 이관했다. /etc/letsencrypt/cloudflare.ini에 들어 있는 API 토큰은 개발사 것이다. 이관과 함께 그 계정 접근이 끊기면서, 갱신은 100% 실패하는 상태로 넘어왔다
2026-04-28 개발사 Cloudflare 계정으로 발급 (유효기간 90일)
2026-06-27 만료 30일 전, 자동 갱신 시도 시작 → 실패
~ 하루 두 번씩 두 달, 100회가 넘게 실패 (로그에 전량 기록)
2026-07-27 인증서 만료 → 동적 콘텐츠 전면 소실
2026-08-21 신고 접수
인계받은 운영인계서.html에는 이렇게 적혀 있었다
SSL 인증서 — 외부 인증서 (Let's Encrypt 90일) VPS SSL은 완전 자동. 만료 30일 전 자동으로 갱신 + Nginx reload. SSL (api/adm) — 90일 — 만료일 2026-07-27 — VPS 자동 갱신
만료일까지 정확히 적혀 있었다. 그런데 “완전 자동”은 사실이 아니었다. 개발사 계정 의존성이 제거되지 않은 채 넘어왔고, 첫 갱신 주기에서 반드시 터지게 되어 있었다
이관 시점에 certbot renew --dry-run 한 번만 돌려봤다면 그 자리에서 발각됐을 문제다. 나는 그 문서를 읽고 SSL은 확인 대상이 아니라고 판단했다. 접속정보 문서에는 VPS·DB·FTP·JWT 키·지도 API 키까지 정리돼 있었는데, 정작 인증서 갱신에 쓰이던 Cloudflare 계정은 어디에도 언급이 없었다. 없는 항목을 확인할 방법은 없다. 그래서 검수 기준을 항목 단위에서 동작 단위로 바꿔야 했다
–force-renewal을 타이핑하다 지웠다
원인이 나왔으니 복구다. 처음 떠올린 건 갱신을 한 번 강제로 돌려 보는 것이었다. 명령을 절반쯤 치고 지웠다
Let’s Encrypt는 동일 도메인 세트에 주간 발급 한도를 둔다. 한도를 소진하면 복구 자체가 일주일 막힌다. 지금까지의 실패가 Cloudflare zone 조회 단계, 즉 서버 내부에서 끝났는지 확인이 필요했다. 로그를 다시 읽어 발급 요청이 Let’s Encrypt까지 가지 않았음을 확인하고서야 다음으로 넘어갔다. 이 서버는 이미 두 달간 100회 넘게 실패한 이력이 있었다
방법 선택도 남아 있었다. 개발사에 Cloudflare 계정 협조를 요청하는 게 가장 빠를 수 있었지만, 회신 시점을 내가 통제할 수 없고 성공해도 다음 90일 뒤 같은 자리로 돌아온다. DNS-01을 유지하면서 카페24 DNS에 TXT 레코드를 수동으로 넣는 방법도 있었다. 이건 90일마다 사람이 붙어야 한다. 이번 장애의 원인이 “아무도 손대지 않는 사이 조용히 실패한 것”인데, 사람이 붙어야 하는 구조를 답으로 둘 수는 없었다
남은 건 자체 서버 HTTP 인증(HTTP-01, webroot)이었다. 외부 계정 의존이 사라진다. 전환이 가능한지 외부에서 먼저 검증했다
curl -s -o /dev/null -w "%{http_code}\n" \
http://api.example.com/.well-known/acme-challenge/probe
# → 404
404가 반가운 응답이었다. 이 서버는 http 요청을 전부 https로 301 리다이렉트하는데, ACME 경로만 404를 돌려줬다. 리다이렉트 예외가 이미 설정돼 있다는 뜻이다
# /etc/nginx/sites-available/api.example.com.conf
server {
listen 80;
server_name api.example.com;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
개발사가 HTTP-01용 경로를 미리 열어 둔 상태였다. nginx 설정은 손댈 필요가 없었다.
–cert-name으로 nginx를 손대지 않았다
실행은 백업 → 리허설 → 발급 → 검증 순으로 했다
# 1) 백업 tar czf /root/letsencrypt-backup-$(date +%F).tar.gz /etc/letsencrypt cp -a /etc/nginx /root/nginx-backup-$(date +%F) mkdir -p /var/www/certbot # 2) 리허설 (스테이징 서버 사용, 발급 한도에 영향 없음) certbot certonly \ --webroot -w /var/www/certbot \ --cert-name api.example.com \ -d api.example.com -d adm.example.com \ --deploy-hook "systemctl reload nginx" \ --dry-run # → The dry run was successful. # 3) 실제 발급 (--dry-run 만 제거) # → Successfully received certificate. Expires on 2026-11-19.
옵션마다 이유가 있다
| 옵션 | 이유 |
|---|---|
--cert-name api.example.com | 기존 인증서 이름 유지 → live/ 경로 불변 → nginx 설정 수정 불필요 |
-d api… -d adm… | 기존 인증서의 도메인 2개 유지. adm이 빠지면 관리자 페이지가 막힌다 |
--webroot | Cloudflare 의존 제거. renewal 설정에 영구 저장되어 이후 자동 갱신에도 적용된다 |
--deploy-hook | 갱신 성공 시 nginx 자동 reload. 기존 설정에 누락돼 있던 부분이다 |
certonly | 인증서만 발급하고 nginx 설정은 건드리지 않는다 |
--cert-name이 여기서 제일 중요했다. 이름이 바뀌면 /etc/letsencrypt/live/ 아래 디렉터리 이름이 바뀌고, nginx의 ssl_certificate 경로를 두 파일에서 고쳐야 한다. 운영 중인 사이트에서 인증서 발급과 nginx 설정 변경을 동시에 하는 건 실패 지점을 두 개로 늘리는 일이다
--deploy-hook이 빠져 있었다는 것도 짚어 둘 만하다. 원래 설정대로 갱신이 성공했다 해도, nginx는 새 인증서를 읽지 않고 만료된 인증서를 계속 서빙했을 것이다. 자동 갱신이 애초에 실패했으니 이 결함은 드러날 기회조차 없었다
검증은 외부에서 -k 없이 했다
curl -v https://api.example.com/ 2>&1 | grep -E "expire date|verify" # expire date: Nov 19 04:43:55 2026 GMT # SSL certificate verify ok.
데이터도 다시 셌다
/members 200 (11건) /cases 200 (11건) /blogs 200 (16건) /reviews 200 (26건) /news 200 (18건) /company 200
메인 사이트의 다섯 메뉴가 15시쯤에 전부 복구됐다. 재발 방지 확인은 설정 파일과 리허설 두 개로 했다
grep -E "authenticator|webroot|hook" /etc/letsencrypt/renewal/api.example.com.conf # authenticator = webroot ← Cloudflare 의존 제거됨 # renew_hook = systemctl reload nginx # webroot_path = /var/www/certbot certbot renew --cert-name api.example.com --dry-run # Congratulations, all simulated renewals succeeded
실패가 남아 있으면 성공도 보이지 않는다
여기서 끝낼 수 있었는데 하나가 걸렸다. 두 사이트의 인증서는 카페24가 관리하게 됐으므로, VPS에 남은 renewal 설정 2건은 불필요해졌다. 방치하면 어떻게 되는지 계산해 봤다
certbot renew는 등록된 인증서 전체를 순회한다. api 갱신이 성공해도 나머지 2건이 실패하면 명령 전체가 실패로 끝난다. 종료 코드로 성공을 판단하는 순간, api의 성공은 영구히 실패에 덮인다. 이번 장애가 두 달 방치된 구조를 그대로 남겨 두는 셈이다
nginx가 해당 인증서를 참조하지 않는지 먼저 확인하고 이동시켰다
grep -rhn "ssl_certificate " /etc/nginx/sites-available/ # → api.example.com 경로만 2건 mkdir -p /root/disabled-renewals mv /etc/letsencrypt/renewal/example.com.conf /root/disabled-renewals/ mv /etc/letsencrypt/renewal/example2.com.conf /root/disabled-renewals/ certbot renew --dry-run # Congratulations, all simulated renewals succeeded: # /etc/letsencrypt/live/api.example.com/fullchain.pem (success)
인증서 실물은 live/ 아래 그대로 두고 renewal 설정만 옮겼다. 되돌리려면 파일을 원위치하면 된다. 삭제하지 않은 이유는 하나다. 카페24 발급이 지연되면 이 인증서로 되돌릴 여지를 남겨 둬야 했다
“다음 주 중”이라고 안내했는데 한 시간에 끝났다
보조 사이트는 카페24 웹호스팅이 서빙한다. VPS에서 인증서를 갱신해도 반영되지 않는다. 카페24 자체 SSL로 전환했다. SSL보안 라이트(Sectigo PositiveSSL 계열) 1년 38,500원, 웹호스팅이라 설치비는 없었다
신청 폼 앞에서 시간을 좀 썼다. 조직명 표기를 도메인 whois 등록자 정보에 맞추느라 확인 작업을 했다. 그럴 필요가 없었다. DV 등급 인증서는 조직 정보를 인증서에 담지 않고 검증도 하지 않는다
subject = /CN=example2.com
subject에 O=(조직)이 아예 없다. 실제 심사는 도메인 소유권 확인 하나로 끝난다. OV·EV와 달리 서류 심사가 없으니, 신청 폼의 조직 정보는 인증서에 반영되지 않는다
소유권 확인은 DCV용 CNAME 레코드로 이뤄진다. 네임서버가 카페24라서 자동 등록됐고, 16시경 외부 DNS 조회로 등록을 확인했다. 그리고 17시경 설치가 끝났다
문제는 그 사이에 클라이언트에게 안내한 내용이다. 15시경 신청 직후, 카페24 안내 문구에 있는 “발급 1~2 영업일, 설치 최대 3 영업일”을 그대로 옮겨 “다음 주 중 완료”라고 전달했다. 실제로는 1시간쯤 걸렸다. 안내 문구는 서류 심사가 필요한 상위 등급과 수동 DCV까지 포함한 최대치다. 네임서버가 같은 호스팅사에 있는 DV 인증서는 전 과정이 자동으로 흐른다. 보수적으로 안내한 것 자체는 잘못이 아니지만, 근거 없이 최대치를 옮긴 것과 조건을 따져 보고 최대치를 택한 것은 다르다
카페24 SSL 관리 화면을 열었을 때 하나가 더 나왔다. 두 도메인의 인증서가 모두 보조 사이트 계정에 들어 있었다. 웹호스팅 계정이 도메인별로 나뉘어 있으니 인증서도 각자 계정에 있을 거라고 생각했는데, 메인 사이트 계정에는 아무것도 없었다. 인계 문서에도 이 관계는 적혀 있지 않았다
여기서 알게 된 정책이 하나 더 있다. 카페24는 1년 상품을 199일 제한 때문에 1차·2차로 분할 발급하고, 1차 만료 33일 전에 2차를 자동 재발급한다
1년 계약 ─┬─ 1차 인증서 (약 199일)
│ └─ 만료 33일 전 → 2차 자동 재발급
└─ 2차 인증서 → 계약 만료일까지
이 구조를 몰랐다면 메인 사이트의 12월 3일 만료 표기를 보고 갱신 작업을 준비했을 것이다. 그 날짜는 1차 인증서의 만료일이고 계약 만료일이 아니다. 실제로 사람이 손댈 시점은 2027년 5월이다. 인증서 만료일과 계약 만료일을 같은 것으로 읽으면 없는 일을 만든다
대응 전체는 이렇게 흘렀다.
| 시각 | 사건 |
|---|---|
| 12:46 | 신고 접수 — “화면은 그대로인데 등록한 정보만 사라졌다” |
| 12:53 | -k 로 API 직접 호출 → 데이터 무손실 확정 |
| ~14:00 | renewal conf의 authenticator 와 dig NS 대조 → 원인 확정 |
| 14:43 | webroot 전환 발급 완료, 메인 사이트 복구 |
| 15:47 | 보조 사이트 카페24 SSL 신청 |
| 16:2x | DCV CNAME 자동 등록 확인 |
| 17:0x | 보조 사이트 설치 완료, 두 사이트 정상화 |
목록이 비는 이유가 하나가 아니었다
인증서를 기다리는 동안 별도로 규명한 게 있다. 보조 사이트를 http로 열면 화면은 뜨는데 데이터가 없었다. https는 인증서 만료로 아예 막혀 있으니, 남은 경로가 http인데 그쪽도 비어 있었다
인증서 문제가 아니었다. 백엔드 CORS 허용 목록이 원인이었다
CORS_ORIGIN=https://example.com,https://www.example.com,
https://adm.example.com,https://example2.com,
https://www.example2.com
헤더를 직접 확인했다
Origin: https://example2.com → access-control-allow-origin 헤더 있음 Origin: http://example2.com → 헤더 없음
허용 목록에 https:// 주소만 등록되어 있으니 http Origin은 통과하지 못한다. 브라우저는 응답을 받아도 JS에 넘기지 않는다
처음에는 CORS를 고쳐야 하는 항목으로 적었다. 그게 아니었다. 이 설정은 잘못된 게 아니다. 사이트는 원래 https로 운영되도록 설계됐고 페이지 canonical도 https다. 인증서가 죽어서 방문자가 http로 유입되기 시작하자 조건을 못 맞추게 된 것이다. http://를 허용 목록에 추가하는 건 장애의 증상에 맞춰 정상 설정을 망가뜨리는 일이다
그래서 CORS는 손대지 않았다. 대신 보조 사이트 Origin으로 API를 호출해서, 인증서만 붙으면 즉시 정상 작동한다는 것을 미리 확인했다
Origin: https://example2.com /news 18건 /blogs 16건 /cases 11건 /reviews 26건 /faqs 5건 /members 11건
복구를 마치고 접속 경로를 바꿔 가며 재확인했다. 여기서 예상하지 않은 게 나왔다.
2026-08-21 17:2x 실측 http://www.example.com/ 200 리다이렉트 없음 http://example.com/ 200 리다이렉트 없음 http://example2.com/media/blog 200 리다이렉트 없음 http://www.example2.com/ 200 리다이렉트 없음
메인 사이트도 같은 상태였다. 두 사이트 모두 http로 들어오면 그대로 서빙되고, CORS에 걸려 목록이 빈다. 이번 장애 때문이 아니다. 기 개발사 인계 시점부터 그랬다. 인증서가 멀쩡했던 기간에도 http로 유입된 방문자는 빈 목록을 보고 있었다. 이번 장애로 https까지 막히면서 두 문제가 한 화면에 겹쳐 드러난 것이다
이런 건 장애를 고치는 과정에서만 발견된다. 복구 검증을 “원래대로 돌아왔는지”만 보고 끝냈다면 나오지 않았다. 평상시 https로만 접속하면 몇 년이 지나도 모른다. 리다이렉트 적용 자체는 이번 장애와 원인이 다르므로 별도 사안으로 분리했다
통하지 않는 조건과 한 줄 요약
이번 진단 순서는 화면과 데이터가 분리된 구조라서 유효했다. 단일 서버에서 서버 사이드 렌더링으로 화면과 데이터를 함께 내보내는 구조라면 첫 curl의 200이 곧 정상이고, API 도메인을 따로 뒤질 이유가 없다. 계층을 나눠 점검하라는 게 항상 맞는 조언은 아니다
webroot 전환도 조건부다. 80포트를 외부에 열 수 없는 서버라면 HTTP-01은 쓸 수 없다. 와일드카드 인증서가 필요하면 DNS-01이 유일한 선택이다. 이번 건은 서버가 이미 80포트로 리다이렉트를 서빙하고 있었고 도메인이 2개로 고정되어 있었기 때문에 성립했다. DNS-01 자체가 나쁜 방식이라는 결론으로 읽히면 안 된다. 문제는 그 계정이 우리 것이 아니었다는 점이다
아직 확인되지 않은 것도 하나 남았다. --dry-run은 Let’s Encrypt 스테이징 서버를 쓰고 발급 한도도 타지 않는다. 리허설이 통과했다는 게 실제 갱신이 통과한다는 뜻은 아니다. webroot 방식의 첫 자동 갱신은 만료 30일 전인 10월 20일경에 시도된다. 그날 certbot certificates의 유효기간이 실제로 다음 주기로 밀렸는지 봐야 이 조치가 끝난다
한 줄로 요약하면, 자동화는 “설정되어 있는가”가 아니라 “한 번 실제로 돌려봤는가”로 확인해야 한다
인계 검수 항목을 두 줄 늘렸다. 문서에 “자동”이라고 적힌 것은 그 자리에서 한 번 돌려 본다. “외부 서비스 계정에 의존하는 자동화가 있는지”도 명시적으로 묻는다. 접속정보 목록에 없는 의존성은, 목록을 아무리 꼼꼼히 읽어도 끝까지 발견되지 않기 때문이다
다음에 같은 신고를 받으면 순서를 이렇게 잡을 것이다
1. 증상 확인 — "껍데기는 남고 데이터만 없다" → 화면/데이터 분리 구조를 의심 2. 데이터 생존 — 인증서 검증 우회(-k)로 API 직접 호출, 유실 여부부터 확정 3. 계층 분리 — 프론트 번들에서 API 주소를 추출해 그 도메인을 별도 점검 4. 인증서 — 만료일·발급자·SAN 확인 (curl -v / openssl x509) 5. 자동화 검증 — renewal/*.conf 의 authenticator 와 실제 DNS(NS) 대조 6. 조치 — 백업 → dry-run → 발급 → 외부 검증 → 접속 경로 바꿔 재검증
2번이 1번보다 아래 있는 게 이 순서의 핵심이다. 원인을 몰라도 데이터 생존은 먼저 확정할 수 있다
용어 정리
본문에 나온 용어를 필요한 만큼만 정리했다.
인증서
- SSL/TLS 인증서(Secure Sockets Layer / Transport Layer Security) — 서버가 자기 도메인의 소유자임을 증명하는 파일이다. 유효기간이 있고, 지나면 브라우저가 그 서버로 가는 연결을 거부한다. 규격은 오래전에 TLS로 넘어갔지만 관행상 SSL이라 부른다.
- Let’s Encrypt — 무료 인증서를 발급해 주는 인증 기관(CA, Certificate Authority)이다. 유효기간이 90일로 짧아서 자동 갱신을 전제로 쓴다.
- Sectigo — 상용 인증 기관이다. 카페24 같은 호스팅사가 재판매하는 SSL 상품이 이 계열이다.
- DV · OV · EV(Domain Validation · Organization Validation · Extended Validation) — 인증서 검증 등급이다. DV는 도메인 소유만 확인하고, OV·EV는 조직 실체까지 서류로 심사한다.
subject·CN(Common Name) — 인증서에 담긴 대상 정보와 그 안의 도메인 항목이다. DV 인증서는CN만 있고 조직 항목(O, Organization)이 없다.- SAN(Subject Alternative Name) — 하나의 인증서가 커버하는 도메인 목록이다.
api와adm을 한 인증서로 쓰면 둘 다 이 목록에 들어간다. fullchain.pem— 서버 인증서와 중간 인증서를 이어 붙인 파일이다. nginx가 이 경로를 읽는다.- 발급 한도(rate limit) — Let’s Encrypt가 동일 도메인 세트에 주 단위로 두는 발급 횟수 제한이다. 소진하면 다음 주까지 발급이 막힌다.
도메인 소유 증명
- ACME(Automatic Certificate Management Environment) — 인증서 발급을 자동화하는 프로토콜이다. 발급 기관이 “이 도메인이 정말 신청자 것인지” 확인하는 절차를 규정한다.
- DNS-01 — DNS(Domain Name System)에 지정된 레코드를 넣어 도메인 소유를 증명하는 방식이다. 뒤의
01은 ACME 규격이 정한 챌린지 유형 이름이다. DNS를 API로 조작할 권한이 있어야 성립한다. - HTTP-01 · webroot — 서버의 특정 경로에 파일을 놓아 소유를 증명하는 방식과, 그 파일을 놓을 디렉터리를 지정하는 certbot 옵션이다. 80포트가 외부에 열려 있어야 한다.
.well-known/acme-challenge/— HTTP-01 인증 파일이 놓이는 고정 경로다. https 리다이렉트를 걸어 둔 서버라면 이 경로만 예외로 빼 둬야 한다.- DCV(Domain Control Validation) — 인증 기관이 도메인 소유를 확인하는 절차다. 상용 인증서는 보통 지정된 CNAME(Canonical Name) 레코드로 확인한다.
- zone ·
zone_id— DNS 서비스에서 한 도메인의 레코드 묶음과 그 식별자다. 그 서비스에 도메인이 등록돼 있지 않으면 조회 자체가 실패한다. - 네임서버(NS, Name Server) — 그 도메인의 DNS 레코드를 실제로 관리하는 서버다. 설정 파일에 적힌 DNS 서비스와 여기가 다르면 DNS-01은 성립하지 않는다.
dig +short NS(dig = Domain Information Groper) — 도메인의 네임서버를 조회하는 명령이다. 문서에 적힌 값이 아니라 실제 값을 확인할 때 쓴다.- 와일드카드 인증서 —
*.example.com처럼 하위 도메인 전체를 커버하는 인증서다. HTTP-01으로는 발급할 수 없고 DNS-01만 가능하다.
certbot 운영
- certbot — Let’s Encrypt 인증서를 발급·갱신하는 공식 클라이언트다.
certonly— 인증서만 발급하고 웹서버 설정은 건드리지 않는 모드다.certbot renew— 등록된 인증서 전체를 순회하며 만료가 가까운 것을 갱신한다. 하나라도 실패하면 명령 전체가 실패로 끝난다.--dry-run— Let’s Encrypt 스테이징 서버로 갱신을 리허설한다. 실제 인증서는 발급되지 않고 발급 한도도 소모하지 않는다.--force-renewal— 만료가 남아 있어도 갱신을 강제한다. 반복하면 발급 한도를 빠르게 소진한다.--cert-name— 인증서 묶음의 이름이다. 이 이름이/etc/letsencrypt/live/<이름>/경로가 되므로, 유지하면 웹서버 설정을 고치지 않아도 된다.renewal/*.conf·authenticator— 인증서별 갱신 설정 파일과 그 안의 인증 방식 항목이다. 발급 때 쓴 옵션이 여기 저장돼 이후 자동 갱신에 그대로 쓰인다.--deploy-hook·renew_hook— 갱신 성공 직후 실행할 명령을 지정하는 옵션과, 설정 파일에 저장된 그 값이다. 없으면 새 인증서가 발급돼도 웹서버는 옛 인증서를 계속 서빙한다.certbot.timer— certbot 갱신을 주기적으로 실행하는 systemd 타이머다. 기본적으로 하루 두 번 기동한다.systemctl is-enabled·is-active— 유닛이 부팅 시 켜지도록 등록됐는지, 지금 동작 중인지를 각각 확인하는 명령이다. 둘 다 정상이어도 실행 결과가 성공이라는 뜻은 아니다.
사이트 구조와 브라우저 동작
- VPS(Virtual Private Server) — 물리 서버를 나눠 쓰는 가상 서버다. 이 사이트의 API와 DB가 여기 올라가 있고, 웹호스팅과 달리 서버 안을 직접 손댈 수 있다.
- React SPA(Single Page Application) · Vite — 화면 전환을 브라우저에서 처리하는 단일 페이지 앱과 그 빌드 도구다. 빌드 결과물이 정적 파일이라 일반 웹호스팅에 그대로 올릴 수 있다.
- 번들 — 빌드로 하나로 묶인 JS 파일이다. 빌드 시점에 박힌 API(Application Programming Interface) 주소가 그 안에 남아 있어 배포본만 열어도 확인된다.
- NestJS — Node.js 백엔드 프레임워크다. 이 서버에서는 3000번 포트로 떠 있다.
- nginx 리버스 프록시 — 외부 요청을 받아 뒤쪽 애플리케이션으로 넘겨 주는 웹서버 역할이다. SSL 인증서를 들고 있는 쪽도 여기다.
- 301 리다이렉트 — 요청한 주소가 다른 주소로 영구 이전됐다고 알리는 응답이다. http로 들어온 요청을 https로 넘길 때 쓴다.
fetch· XHR(XMLHttpRequest) — 브라우저가 화면을 그린 뒤 데이터를 따로 받아 오는 요청이다. 인증서가 만료되면 이 요청은 경고 없이 차단된다.- CORS(Cross-Origin Resource Sharing) — 다른 출처의 스크립트가 이 API를 호출할 수 있는지 서버가 정하는 규칙이다.
- Origin — 스킴·도메인·포트를 합친 출처 식별자다.
http://와https://는 도메인이 같아도 다른 Origin이다. access-control-allow-origin— 서버가 그 Origin의 호출을 허용한다고 알리는 응답 헤더다. 이 헤더가 없으면 브라우저는 응답을 받아도 JS에 넘기지 않는다.- canonical — 이 페이지의 대표 주소를 선언하는 태그다. https로 선언돼 있으면 검색 유입도 https로 들어온다.
curl -k— 인증서 검증을 건너뛰고 접속하는 옵션이다. 인증서가 만료된 서버의 내용물을 확인할 때 쓴다.curl -v— 접속 과정을 자세히 출력하는 옵션이다. 인증서 만료일과 검증 결과가 여기 나온다.