[서버 개발 일지 #16] 도메인 연결하기

KimGwangmin·2026년 10월 26일

지난 글에서는 서버를 처음으로 직접 올리고(3단계), 그 절차를 deploy.sh로 정리했다. 이번 글은 4단계, 도메인과 HTTPS를 다룬다. 서버는 이미 공인 IP(탄력적 IP)로 접속되는데도 도메인을 쓰는 이유를 먼저 정리하고, 이어서 도메인 연결, Nginx 설치, 인증서 발급, 자동 갱신 설정, 검증까지 우리가 진행한 순서 그대로 남긴다. 마지막에는 검증 중 예상이 빗나가 설정을 하나 더 넣게 된 과정과, 이 설정을 저장소에 둔 방법도 기록한다. 나중에 서버를 다시 만들거나 정식 도메인으로 옮길 때 그대로 따라 할 수 있게 명령어와 각 명령의 의미, 확인 기준을 자세히 적었다.

이 글(과 저장소)은 공개라서 도메인과 IP는 <도메인>, <탄력적 IP> 자리표시자로 적는다. 실제 값은 저장소 밖에서 관리한다.

1. 배경과 목표

지금 상태

3단계까지 끝난 시점에 서버는 이렇게 동작했다.

  • EC2에서 앱 컨테이너가 127.0.0.1:8080으로만 열려 있다. 인터넷에서 앱에 직접 닿을 수 없다.
  • 보안그룹은 80, 443을 전체에 열어 두었지만 그 포트에서 응답하는 프로세스가 아직 없다.
  • 클라이언트(Unity)가 서버에 접속하려면 지금은 방법이 없다.

4단계의 목표

#확인할 것성공 기준
1도메인이 서버(탄력적 IP)를 가리킨다nslookup <도메인>이 <탄력적 IP>를 돌려준다
2Nginx가 443/80에서 받아 앱으로 전달한다curl http://<도메인>/actuator/health가 UP
3신뢰할 수 있는 HTTPS 인증서가 붙는다curl https://<도메인>/actuator/health가 -k 없이 성공
4인증서가 사람 손 없이 갱신된다certbot renew --dry-run 성공, 타이머 활성
5클라이언트가 HTTPS 주소로 동작한다로그인과 게임 진행 확인
6엉뚱한 이름/IP로 온 요청은 앱까지 닿지 않는다핸드셰이크 거부 또는 연결 종료

전체 구조

[클라이언트] ──HTTPS(443)──▶ [EC2: Nginx] ──HTTP(127.0.0.1:8080)──▶ [앱 컨테이너]
                              │  ▲
                 80포트(HTTP) ┘  └─ certbot이 인증서를 발급·갱신(HTTP-01)

2. 공인 IP가 있는데 왜 도메인을 쓰는가

"탄력적 IP로 이미 접속이 되는데 굳이 도메인까지 필요한가?"가 이번 단계의 첫 질문이었다. 결론은 IP만으로도 연결은 되지만, HTTPS와 운영을 함께 놓고 보면 도메인이 사실상 필수라는 것이다.

2.1 인증서는 이름에 발급된다

HTTPS 인증서는 "이 이름(도메인)의 주인이 이 키를 가졌다"는 증명이다. 브라우저와 클라이언트는 접속한 주소가 인증서에 적힌 이름과 같은지 검사한다.

  • 일반적인 무료 인증서 발급 흐름(Let's Encrypt + certbot)은 도메인 이름을 대상으로 한다. IP 주소 인증서는 예외적인 별도 경로라서(유효 기간이 매우 짧고 설정이 다르다) 이번 구성의 자동 갱신 흐름에 맞지 않는다고 판단했다.
  • IP로 접속하면서 HTTPS를 쓰려면 자체 서명 인증서를 쓰게 되는데, 이는 클라이언트가 신뢰하지 않아 경고가 뜨거나 연결이 실패한다. 클라이언트에서 검증을 끄는 우회는 HTTPS를 쓰는 의미를 없앤다(중간자 공격에 무방비).

2.2 IP는 서버의 "자리"가 아니라 "현재 주소"다

상황IP로 직접 접속도메인으로 접속
서버를 새로 만들어 교체한다새 IP가 되므로 모든 클라이언트를 다시 배포해야 한다DNS의 A 레코드만 새 IP로 바꾸면 된다
탄력적 IP를 반납하고 다시 받는다같은 IP를 받는다는 보장이 없다A 레코드만 고친다
정식 도메인을 구매해 옮긴다(해당 없음)같은 IP에 새 이름을 연결하고 전환 기간에 둘 다 서비스한다

게임 클라이언트는 앱스토어 심사 등으로 배포 주기가 느리다. 서버 주소가 클라이언트에 박혀 있으면 서버 쪽 변경이 곧 클라이언트 재배포가 된다. 도메인은 그 사이에 한 겹을 두어 서버를 바꿀 자유를 준다. (이 프로젝트는 클라이언트의 서버 주소를 설정값으로 두고 있어 도메인 교체도 설정 변경만으로 된다.)

2.3 한 IP에 여러 서비스를 올리려면 이름이 필요하다

HTTPS는 접속 시작 때 클라이언트가 SNI(어느 이름으로 접속하려는지)를 보내고, 서버는 그에 맞는 인증서를 고른다. 이름이 곧 구분자다. 나중에 같은 서버에 다른 서비스(관리 화면 등)를 붙일 때도 이름으로 나눈다. IP만 쓰면 이 구분이 없다.

2.4 보안과 운영 면

  • 이름으로 신원을 확인한다. IP는 사용자가 읽고 확인하기 어렵다. 이름과 인증서의 결합이 "내가 접속한 곳이 맞는가"를 보증한다.
  • 스캐너 트래픽 구분. 인터넷의 스캐너는 IP 대역을 훑는다. 서버가 "우리 도메인이 아닌 요청"을 거부하게 만들 수 있으려면 이름이라는 기준이 필요하다(이 글의 7.2).
  • 무료 임시 도메인으로 먼저 검증할 수 있다. 정식 도메인은 구매 비용과 팀 논의가 필요해서, 우선 DuckDNS 같은 무료 서브도메인으로 HTTPS까지 전부 검증하고 나중에 교체하기로 했다.

2.5 그래도 탄력적 IP는 필요하다

도메인이 있어도 DNS의 A 레코드는 고정된 IP를 가리켜야 한다. 자동 할당 퍼블릭 IP는 인스턴스를 중지했다 시작하면 바뀌므로, 도메인과 탄력적 IP는 함께 쓴다. 역할은 다르다.

구성 요소역할
탄력적 IP서버가 항상 같은 번지에 있게 한다
도메인그 번지에 사람과 클라이언트가 읽을 수 있는 이름과 인증서를 붙인다

3. 준비

3.1 전제

항목상태
EC2blackhole-app(Amazon Linux 2023, 접속은 SSM Session Manager, SSH 없음)
앱 컨테이너127.0.0.1:8080에서 실행 중(deploy.sh로 배포)
보안그룹blackhole-sg-app이 80, 443을 전체에 허용. 22는 닫혀 있다
탄력적 IPEC2에 연결됨

3.2 도메인 연결(DuckDNS)

임시 무료 도메인으로 DuckDNS 서브도메인을 썼다. 계정 가입과 로그인은 직접 진행했다.

  1. DuckDNS에 로그인한다.
  2. 서브도메인 이름을 입력해 추가한다. 이름은 추측하기 어렵거나 의미 없는 것보다는 서비스를 알아볼 수 있는 것이 좋지만, 공개 저장소에는 적지 않는다.
  3. 그 서브도메인의 current ip에 탄력적 IP를 입력하고 저장(update)한다.
  4. 내 PC에서 확인한다.
nslookup <도메인>

응답의 주소가 <탄력적 IP>이면 DNS 연결이 끝난 것이다. 전파가 덜 됐다면 몇 분 뒤 다시 확인한다.

  • DuckDNS는 current ip를 한 번 적어 두면 그 값을 유지한다. 서버가 탄력적 IP라 바뀔 일이 없어서 IP 갱신용 스크립트는 만들지 않았다.
  • 무료 도메인은 가용성과 인증서 발급 한도를 보장하지 않는다. 정식 출시에는 쓰지 않는다(9장).

3.3 인증서 알림 이메일은 쓰지 않는다

처음에는 인증서 만료 알림 메일을 받으려고 이메일을 등록할 생각이었다. 그런데 확인해 보니 Let's Encrypt가 2025-06-04에 만료 알림 메일 발송을 종료했다. 이메일은 이제 선택 사항이라 이번에는 등록하지 않았다(--register-unsafely-without-email).

그 대신 생기는 성질을 알아 두어야 한다. 갱신이 실패해도 알림이 오지 않는다. 그래서 만료일을 직접 확인하는 습관이 필요하고, 7단계(운영 점검)에서 만료 알람을 추가할 계획이다.

4. 구성 절차

모든 명령은 EC2에서 SSM Session Manager 터미널로 실행했다. 이 절차는 저장소의 deploy/nginx/README.md에도 같은 순서로 있다. 순서를 지킨다. 사이트 설정과 인증서 발급 뒤에 하드닝을 적용한다.

4.1 설치와 설정 파일 받기

sudo dnf install -y nginx certbot python3-certbot-nginx git
nginx -v && certbot --version && sudo certbot plugins | grep -i nginx
git clone --depth 1 https://github.com/Anyway-WeMadeIt/Feed-A-Blackhole-Server.git ~/repo
명령의미
dnf installNginx(웹서버/리버스 프록시), certbot(인증서 발급 도구), nginx 플러그인(설정 파일을 자동으로 고쳐 줌)을 설치한다. Amazon Linux 2023 저장소에 certbot이 있어 dnf로 충분하다
certbot pluginsnginx가 목록에 보이면 플러그인이 설치된 것이다
git clone --depth 1설정 파일만 필요하므로 최신 커밋 하나만 받는다. 공개 저장소라 인증이 필요 없다
  • 설정 파일이 아직 기본 브랜치에 병합되기 전이면 git clone에 --branch <브랜치 이름>을 붙인다.

4.2 사이트 설정(HTTP)

저장소의 deploy/nginx/blackhole.conf는 도메인 자리에 <도메인>이 들어 있다. 서버에서 실제 도메인으로 바꿔 넣는다.

DOMAIN=<도메인>
sed "s/<도메인>/$DOMAIN/g" ~/repo/deploy/nginx/blackhole.conf | sudo tee /etc/nginx/conf.d/blackhole.conf > /dev/null
sudo nginx -t && sudo systemctl enable --now nginx
curl -s http://$DOMAIN/actuator/health

설정 파일의 내용은 다음과 같다.

server {
    listen 80;
    server_name <도메인>;
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
줄의미
listen 80; server_name ...80 포트로 온 요청 중 이 이름으로 온 것을 이 블록이 받는다
proxy_pass받은 요청을 같은 서버 안의 앱(127.0.0.1:8080)에 넘긴다
Host, X-Forwarded-For, X-Forwarded-Proto앱이 "원래 요청이 어떤 이름, 어떤 클라이언트 IP, 어떤 프로토콜(https)이었는지" 알 수 있게 전달한다. 앱의 server.forward-headers-strategy=framework가 이 헤더를 읽어 복원한다. 앱 포트가 외부에 열려 있지 않다는 전제에서만 안전하다(3단계에서 127.0.0.1에만 바인딩)
명령의미
sed ... \| sudo tee자리표시자를 치환한 내용을 /etc/nginx/conf.d/에 쓴다. 이 폴더의 *.conf는 Nginx가 자동으로 읽는다
nginx -t문법 검사. && 덕분에 실패하면 다음 명령(시작)으로 넘어가지 않는다
systemctl enable --now nginx지금 시작하고(--now) 부팅 때도 자동으로 시작되게 한다(enable)
curl .../actuator/health끝에서 끝까지 확인한다. {"…","status":"UP"}이면 DNS, 보안그룹(80), Nginx, 앱 연결이 모두 정상이다

4.3 인증서 발급과 자동 갱신

sudo certbot --nginx -d $DOMAIN --register-unsafely-without-email --agree-tos --dry-run
sudo certbot --nginx -d $DOMAIN --register-unsafely-without-email --agree-tos --redirect
sudo systemctl enable --now certbot-renew.timer
sudo certbot renew --dry-run
sudo certbot certificates
명령의미
첫 줄 --dry-runLet's Encrypt 스테이징 서버에서 연습한다. 인증서를 저장하지 않고 발급 횟수 한도도 쓰지 않으면서 DNS, 80 포트, 설정이 맞는지 검증한다. 성공해야 다음으로 간다
둘째 줄실제 발급. 아래에서 설명한다
certbot-renew.timer하루에 두 번쯤 깨어나서 만료 30일 이내인 인증서만 갱신하는 systemd 타이머. 켜 두지 않으면 90일 뒤 만료된다
renew --dry-run갱신 경로 전체를 시뮬레이션한다. 발급 때와 같은 방법(HTTP-01)을 다시 쓰므로, 이 검사가 통과하면 실제 갱신도 될 가능성이 높다
certificates발급된 인증서의 도메인과 만료일을 보여 준다

옵션의 의미는 다음과 같다.

옵션의미
--nginx인증서를 받은 뒤 Nginx 설정을 certbot이 직접 고치게 한다
-d $DOMAIN인증서를 받을 이름
--register-unsafely-without-email이메일 없이 계정을 등록한다(3.3 참고)
--agree-tos서비스 약관 동의(명령줄에서 대화를 건너뛰기 위함)
--redirectHTTP 요청을 HTTPS로 되돌려 보내는 설정을 추가한다

실제 발급이 하는 일(HTTP-01 방식).

  1. certbot이 Let's Encrypt에 "이 이름의 인증서를 달라"고 요청한다.
  2. Let's Encrypt가 "이 이름의 주인이 맞다면 80 포트의 약속된 경로(/.well-known/acme-challenge/…)에 이 토큰을 놓아 보라"고 답한다.
  3. certbot이 토큰을 놓고(Nginx가 응답), Let's Encrypt가 인터넷에서 그 주소로 접속해 확인한다.
  4. 확인되면 인증서가 발급되고 /etc/letsencrypt/에 저장된다. certbot이 blackhole.conf에 443 서버 블록, 인증서 경로, HTTP → HTTPS 리다이렉트를 추가한다.

이 방식 때문에 갱신할 때도 80 포트가 열려 있어야 한다. 보안그룹의 80 포트와 Nginx의 HTTP 서버 블록을 닫지 않는다(7장 운영 메모).

결과. 거부 없이 발급됐다. 2026-10-18 발급, 2027-01-16 만료(90일)이고, 타이머는 만료 30일 전부터 갱신을 시도한다.

이름이 duckdns.org 같은 공용 무료 도메인이면 Let's Encrypt의 "등록 도메인당 발급 횟수" 한도를 같은 도메인을 쓰는 다른 사용자와 나눌 수 있다. 공개 접미사 목록(PSL)에 있는지 확인하지 못해서, 반드시 --dry-run을 먼저 하고 거부되면 정식 도메인 구매를 앞당기기로 했다. 이번에는 거부되지 않았다.

4.4 첫 검증(내 PC)

curl.exe -I http://<도메인>/actuator/health
curl.exe -s https://<도메인>/actuator/health
curl.exe -s -o NUL -w "%{http_code}" https://<도메인>/api/v1/me
명령기대 결과의미
첫째301, Location: https://<도메인>/…HTTP가 HTTPS로 리다이렉트된다
둘째{"…","status":"UP"}-k 없이 성공한다 = 인증서가 신뢰할 수 있게 발급·적용됐다
셋째401토큰 없이 보호 경로를 호출하면 앱이 거부한다. 앱까지 도달하고 있다는 증거이기도 하다

그 뒤 클라이언트(Unity)의 서버 주소를 이 도메인(https)으로 바꿔 로그인과 게임 진행이 되는지 확인했다. 동작했다.

5. 추가 점검과 하드닝

필수는 아니지만 공개 서버로서 확인할 만한 항목을 점검하고 설정을 더했다.

5.1 읽기 전용 외부 점검

내 PC에서 확인한 결과다.

  • /actuator/env, /actuator/info도 토큰 없이는 401이다(내부 설정 노출 없음).
  • 인증서는 Let's Encrypt가 발급한 것이고 TLS 1.2, 1.3이 동작한다.
  • 8080, 22 포트는 외부에서 닫혀 있다.
  • Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options 헤더는 앱(Spring Security)이 보낸다. 그래서 Nginx에 HSTS 등을 또 넣으면 헤더가 중복된다. Nginx 쪽에는 추가하지 않았다.
  • TLS 1.0/1.1이 거부되는지는 내 PC의 OpenSSL이 낡은 프로토콜 시도를 막아서 외부에서는 확인하지 못했다. 서버에서 certbot이 쓰는 설정의 ssl_protocols 줄을 보는 방법이 있다.
grep ssl_protocols /etc/letsencrypt/options-ssl-nginx.conf
sudo nginx -T 2>/dev/null | grep -n ssl_protocols

ssl_protocols TLSv1.2 TLSv1.3;처럼 1.2 이상만 나오면 1.0/1.1은 거부된다.

5.2 버전 숨기기: 00-hardening.conf

server_tokens off;

기본 응답 헤더는 Server: nginx/x.y.z처럼 버전을 알려 준다. 버전을 알면 그 버전의 알려진 취약점을 노리기 쉽다. server_tokens off;는 Server: nginx만 남긴다. /etc/nginx/conf.d/에 두면 http 영역에 포함되어 모든 서버 블록에 적용된다.

5.3 엉뚱한 이름·IP의 요청 막기: 01-default-reject.conf

문제. 443 서버 블록이 blackhole.conf 하나뿐이면, 이름이 맞든 틀리든 443으로 온 모든 요청을 이 블록이 받는다(다른 선택지가 없으므로). 즉 스캐너가 IP로 접속하거나 엉뚱한 이름으로 접속해도 Nginx가 앱으로 프록시한다(앱은 401을 돌려주지만, 트래픽이 앱에 닿는다).

해결. "어느 이름에도 맞지 않는 요청"을 받는 기본 서버(default_server)를 따로 두고 거부한다.

server {
    listen 443 ssl default_server;
    ssl_reject_handshake on;
    return 444;
}
줄의미
listen 443 ssl default_server어느 서버 블록에도 맞지 않는 443 연결을 받는 기본 서버
ssl_reject_handshake onSNI가 없거나 우리 도메인이 아닌 연결은 인증서도 보내지 않고 TLS 핸드셰이크 단계에서 거부한다. 정상 클라이언트(브라우저, Unity)는 SNI를 보내므로 영향이 없다
return 444아래 5.4에서 설명한다

5.4 적용 후 검증과 예상 밖의 결과

IP로 직접 접속해 본 결과는 예상대로였다.

curl.exe -k https://<탄력적 IP>/

TLS 핸드셰이크가 실패했다(curl 종료 코드 35). SNI가 없는 IP 접속이 거부된다.

다음은 SNI는 우리 도메인인데 Host 헤더만 다른 요청이다.

curl.exe -sk -o NUL -w "%{http_code}" -H "Host: example.invalid" https://<도메인>

나는 이 요청이 404를 받을 것이라고 예상했다. 그런데 실제 결과는 200이었다.

원인은 이렇다. 핸드셰이크(SNI)는 우리 도메인이라 통과한다. 그러면 Nginx가 요청의 Host를 보고 서버 블록을 다시 고르는데, 맞는 이름이 없으니 기본 서버가 받는다. 그런데 그 기본 서버에 root 지시문이 없어서 Nginx의 기본 문서 폴더(환영 페이지)를 그대로 서비스한 것이다. 앱으로 넘어가지는 않았지만 불필요한 응답을 주고 있었다.

해결로 return 444;를 넣었다. 444는 Nginx 전용 코드로, 응답 없이 연결을 닫는다. 다시 확인했다.

확인결과
HTTP 코드000 (응답이 없다는 뜻)
curl 종료 코드56 (연결이 끊김)

핸드셰이크 단계에서 걸러지는 요청(IP 접속, 다른 이름)과 핸드셰이크는 통과하고 Host만 다른 요청, 두 경로가 모두 앱에 닿지 않게 되었다.

HTTP(80)는 certbot이 만든 리다이렉트 블록이 알 수 없는 Host를 return 404로 막는다.

5.5 하드닝 파일 적용

sudo cp ~/repo/deploy/nginx/00-hardening.conf ~/repo/deploy/nginx/01-default-reject.conf /etc/nginx/conf.d/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot renew --dry-run
명령의미
cp두 파일을 conf.d/에 둔다. Nginx는 이 폴더의 *.conf를 파일 이름 순으로 읽는다. 숫자 접두어(00-, 01-)는 읽는 순서를 눈에 보이게 하려는 것이다
nginx -t && reload문법 검사 후 기존 연결을 끊지 않고 설정을 다시 읽는다(무중단)
renew --dry-run하드닝이 갱신 경로(80 포트)에 영향을 주지 않는지 확인한다

5.6 정리

rm -rf ~/repo

설정은 /etc/nginx/로 복사되었으므로 서버에는 저장소 복사본을 남기지 않는다.

6. 전체 검증(내 PC)

명령(PowerShell)기대 결과
curl.exe -I http://<도메인>/actuator/health301, Location: https://<도메인>/…
curl.exe -s https://<도메인>/actuator/health"status":"UP"
curl.exe -s -o NUL -w "%{http_code}" https://<도메인>/api/v1/me401(토큰 없음)
curl.exe -sI https://<도메인>/actuator/healthServer: nginx(버전 숫자 없음), Strict-Transport-Security(앱이 보냄)
curl.exe -sk -o NUL -w "%{http_code}" -H "Host: example.invalid" https://<도메인>/000(연결을 닫음)
curl.exe -k https://<탄력적 IP>/TLS 핸드셰이크 실패(종료 코드 35)

끝으로 클라이언트로 로그인과 게임 진행을 확인한다. 이 모두를 통과했다.

7. 운영 메모

  • 80 포트와 HTTP 서버 블록을 닫지 않는다. 갱신이 HTTP-01 방식이다.
  • blackhole.conf의 # managed by Certbot 줄은 직접 고치지 않는다. 설정을 바꾼 뒤에는 항상 sudo nginx -t && sudo systemctl reload nginx로 검사하고 반영한다.
  • 만료일은 직접 본다. 갱신 실패 알림이 오지 않는다.
sudo certbot certificates
systemctl list-timers | grep certbot
sudo journalctl -u certbot-renew
  • 로그는 /var/log/nginx/access.log, error.log.
  • 하드닝만 되돌리려면 두 파일을 지우고 다시 반영한다.
sudo rm /etc/nginx/conf.d/00-hardening.conf /etc/nginx/conf.d/01-default-reject.conf
sudo nginx -t && sudo systemctl reload nginx

8. 설정을 저장소에 두기

서버에만 있는 설정은 서버가 사라지면 함께 사라진다. 그래서 deploy/nginx/에 다음을 두었다.

파일내용
blackhole.confcertbot이 고치기 전 모양의 사이트 설정(도메인은 <도메인>)
00-hardening.confserver_tokens off;
01-default-reject.conf기본 서버 거부 설정
README.md설치부터 인증서, 하드닝, 검증, 되돌리기, 도메인 교체까지의 절차

서버에서 certbot이 수정한 최종 파일을 그대로 복사하지 않은 이유는 인증서 경로가 들어가 도메인에 묶이기 때문이다. 처음 모양과 certbot 명령을 두면 서버를 다시 만들 때 같은 결과를 재현할 수 있다. 공개 저장소라서 도메인과 IP는 자리표시자로만 적는다.

식별 정보가 저장소에 섞이지 않도록 3단계 직후에 만든 식별 정보 검사(스크립트와 CI)가 이 파일들에도 적용된다.

9. 정식 도메인으로 바꿀 때

임시 도메인은 정식 출시에 쓰지 않는다. 팀 논의 후 도메인을 구매하면 전환 기간에 두 도메인을 함께 서비스한 뒤 옮긴다.

  1. 새 도메인의 A 레코드를 같은 탄력적 IP로 연결한다.
  2. blackhole.conf의 server_name에 새 도메인을 추가하고(nginx -t 후 reload), 인증서를 확장한다.
sudo certbot --nginx --expand -d <기존 도메인> -d <새 도메인>

--expand가 없으면 확장할지 대화형으로 묻는다.

  1. 클라이언트의 서버 주소를 새 도메인으로 바꿔 배포한다. 구 클라이언트가 사라지면 기존 도메인을 server_name에서 빼고 인증서를 다시 발급한다.

JWT 발급자(app.jwt.issuer)는 URL이 아니라 고정 문자열이라 도메인을 바꿔도 이미 발급된 토큰은 무효화되지 않는다.

10. 작업 중 겪은 일

  • "IP로 접속되니 됐다"와 "HTTPS가 된다"는 다른 이야기다. 서버가 IP로 응답해도 클라이언트가 신뢰하는 인증서가 없으면 운영할 수 없다. 이름이 있어야 인증서도, 교체의 자유도 생긴다는 점을 이 단계의 가장 큰 배움으로 꼽는다.
  • 이메일 알림이 사라졌다. 기억에 의존해 "만료 알림 메일이 온다"고 안내했다가, 확인해 보니 서비스가 종료된 상태였다. 오래된 기억보다 현재 상태를 확인하는 것이 맞았고, 그 결과 "갱신 실패가 조용히 지나간다"는 위험을 알게 되어 만료일 확인 절차와 7단계 알람 계획이 생겼다.
  • 예상한 404가 200이었다. SNI와 Host가 다를 때 어떤 서버 블록이 받는지, 그 블록에 root가 없으면 환영 페이지를 내준다는 것을 실제 요청으로 확인해서 알았다. 머릿속 예측만 믿고 넘어갔다면 기본 페이지가 계속 노출됐을 것이다. 검증은 "기대하는 값"을 적어 두고 실제 값과 비교하는 데 의미가 있다.
  • dry-run을 앞에 둔다. 발급 한도가 있는 공용 도메인에서 설정 실수로 한도를 낭비하면 되돌릴 수 없다. 스테이징 연습이 이를 막는다.
  • 하드닝은 마지막에. 사이트 설정과 인증서가 안정된 뒤에 하드닝을 얹으면, 문제가 생겼을 때 원인이 하드닝인지 기본 구성인지 가려내기 쉽다. 하드닝 후에도 renew --dry-run을 다시 돌려 갱신 경로를 확인했다.

11. 이 설정이 다루지 않는 것

  • 요청 빈도 제한(limit_req). 게스트 등록(/api/v1/auth/)은 토큰 없이 DB 행을 만들기 때문에 반복 호출을 막을 필요가 있다. 모바일 통신사의 공유 IP를 고려해 한도는 넉넉하게 잡아야 해서 7단계에서 검토한다.
  • 로그 전송과 알람, 인증서 만료 알람은 7단계(운영 점검)에서 다룬다.

12. 정리

항목선택이유
서버 이름도메인(임시: DuckDNS) + 탄력적 IP인증서는 이름에 발급되고, 서버를 바꿔도 클라이언트를 다시 배포하지 않게 한다
프록시Nginx(80/443 → 127.0.0.1:8080)앱 포트를 외부에 열지 않고 TLS는 Nginx가 처리한다
인증서Let's Encrypt + certbot(--nginx, HTTP-01)무료, 자동 설정, 자동 갱신
발급 순서--dry-run → 실제 발급 → renew --dry-run한도를 쓰지 않고 먼저 검증한다
이메일등록하지 않음만료 알림 메일이 종료되었다. 만료일은 직접 확인한다
갱신certbot-renew.timer만료 30일 전부터 자동 시도. 80 포트를 닫지 않는다
응답 헤더server_tokens off, HSTS 등은 앱이 담당버전을 숨기고, 보안 헤더 중복을 피한다
알 수 없는 요청default_server + ssl_reject_handshake + return 444스캐너와 엉뚱한 Host 요청이 앱에 닿지 않게 한다
설정 보관deploy/nginx/(certbot 적용 전 모양 + 절차 README)서버를 다시 만들 때 재현할 수 있고, 인증서 경로를 저장소에 묶지 않는다
  • 4단계가 끝나면서 외부에서 HTTPS로 접속하는 서버가 완성되었다. 클라이언트가 이 주소로 로그인과 게임 진행을 마쳤다.
  • 이번 단계도 3단계처럼 절차를 손으로 먼저 겪은 뒤 문서와 저장소에 고정했다. 서버를 처음부터 다시 만들 때 deploy/nginx/README.md와 이 글의 순서가 그대로 쓰인다.
  • 다음 단계(5단계, CI 확장)는 팀이 이미 PR 테스트 워크플로를 갖고 있어 브랜치 보호와 식별 정보 검사 연동 정도가 남는다. 이후 6단계(CD)에서 main 병합 시 GitHub OIDC 역할로 이미지를 빌드해 ECR에 올리고, SSM으로 deploy.sh를 실행해 사람 손 없이 배포되게 만든다.

0개의 댓글