지난 글에서는 서버를 처음으로 직접 올리고(3단계), 그 절차를
deploy.sh로 정리했다. 이번 글은 4단계, 도메인과 HTTPS를 다룬다. 서버는 이미 공인 IP(탄력적 IP)로 접속되는데도 도메인을 쓰는 이유를 먼저 정리하고, 이어서 도메인 연결, Nginx 설치, 인증서 발급, 자동 갱신 설정, 검증까지 우리가 진행한 순서 그대로 남긴다. 마지막에는 검증 중 예상이 빗나가 설정을 하나 더 넣게 된 과정과, 이 설정을 저장소에 둔 방법도 기록한다. 나중에 서버를 다시 만들거나 정식 도메인으로 옮길 때 그대로 따라 할 수 있게 명령어와 각 명령의 의미, 확인 기준을 자세히 적었다.
이 글(과 저장소)은 공개라서 도메인과 IP는
<도메인>,<탄력적 IP>자리표시자로 적는다. 실제 값은 저장소 밖에서 관리한다.
3단계까지 끝난 시점에 서버는 이렇게 동작했다.
127.0.0.1:8080으로만 열려 있다. 인터넷에서 앱에 직접 닿을 수 없다.| # | 확인할 것 | 성공 기준 |
|---|---|---|
| 1 | 도메인이 서버(탄력적 IP)를 가리킨다 | nslookup <도메인>이 <탄력적 IP>를 돌려준다 |
| 2 | Nginx가 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)
"탄력적 IP로 이미 접속이 되는데 굳이 도메인까지 필요한가?"가 이번 단계의 첫 질문이었다. 결론은 IP만으로도 연결은 되지만, HTTPS와 운영을 함께 놓고 보면 도메인이 사실상 필수라는 것이다.
HTTPS 인증서는 "이 이름(도메인)의 주인이 이 키를 가졌다"는 증명이다. 브라우저와 클라이언트는 접속한 주소가 인증서에 적힌 이름과 같은지 검사한다.
| 상황 | IP로 직접 접속 | 도메인으로 접속 |
|---|---|---|
| 서버를 새로 만들어 교체한다 | 새 IP가 되므로 모든 클라이언트를 다시 배포해야 한다 | DNS의 A 레코드만 새 IP로 바꾸면 된다 |
| 탄력적 IP를 반납하고 다시 받는다 | 같은 IP를 받는다는 보장이 없다 | A 레코드만 고친다 |
| 정식 도메인을 구매해 옮긴다 | (해당 없음) | 같은 IP에 새 이름을 연결하고 전환 기간에 둘 다 서비스한다 |
게임 클라이언트는 앱스토어 심사 등으로 배포 주기가 느리다. 서버 주소가 클라이언트에 박혀 있으면 서버 쪽 변경이 곧 클라이언트 재배포가 된다. 도메인은 그 사이에 한 겹을 두어 서버를 바꿀 자유를 준다. (이 프로젝트는 클라이언트의 서버 주소를 설정값으로 두고 있어 도메인 교체도 설정 변경만으로 된다.)
HTTPS는 접속 시작 때 클라이언트가 SNI(어느 이름으로 접속하려는지)를 보내고, 서버는 그에 맞는 인증서를 고른다. 이름이 곧 구분자다. 나중에 같은 서버에 다른 서비스(관리 화면 등)를 붙일 때도 이름으로 나눈다. IP만 쓰면 이 구분이 없다.
도메인이 있어도 DNS의 A 레코드는 고정된 IP를 가리켜야 한다. 자동 할당 퍼블릭 IP는 인스턴스를 중지했다 시작하면 바뀌므로, 도메인과 탄력적 IP는 함께 쓴다. 역할은 다르다.
| 구성 요소 | 역할 |
|---|---|
| 탄력적 IP | 서버가 항상 같은 번지에 있게 한다 |
| 도메인 | 그 번지에 사람과 클라이언트가 읽을 수 있는 이름과 인증서를 붙인다 |
| 항목 | 상태 |
|---|---|
| EC2 | blackhole-app(Amazon Linux 2023, 접속은 SSM Session Manager, SSH 없음) |
| 앱 컨테이너 | 127.0.0.1:8080에서 실행 중(deploy.sh로 배포) |
| 보안그룹 | blackhole-sg-app이 80, 443을 전체에 허용. 22는 닫혀 있다 |
| 탄력적 IP | EC2에 연결됨 |
임시 무료 도메인으로 DuckDNS 서브도메인을 썼다. 계정 가입과 로그인은 직접 진행했다.
current ip에 탄력적 IP를 입력하고 저장(update)한다.nslookup <도메인>
응답의 주소가 <탄력적 IP>이면 DNS 연결이 끝난 것이다. 전파가 덜 됐다면 몇 분 뒤 다시 확인한다.
current ip를 한 번 적어 두면 그 값을 유지한다. 서버가 탄력적 IP라 바뀔 일이 없어서 IP 갱신용 스크립트는 만들지 않았다.처음에는 인증서 만료 알림 메일을 받으려고 이메일을 등록할 생각이었다. 그런데 확인해 보니 Let's Encrypt가 2025-06-04에 만료 알림 메일 발송을 종료했다. 이메일은 이제 선택 사항이라 이번에는 등록하지 않았다(--register-unsafely-without-email).
그 대신 생기는 성질을 알아 두어야 한다. 갱신이 실패해도 알림이 오지 않는다. 그래서 만료일을 직접 확인하는 습관이 필요하고, 7단계(운영 점검)에서 만료 알람을 추가할 계획이다.
모든 명령은 EC2에서 SSM Session Manager 터미널로 실행했다. 이 절차는 저장소의 deploy/nginx/README.md에도 같은 순서로 있다. 순서를 지킨다. 사이트 설정과 인증서 발급 뒤에 하드닝을 적용한다.
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 install | Nginx(웹서버/리버스 프록시), certbot(인증서 발급 도구), nginx 플러그인(설정 파일을 자동으로 고쳐 줌)을 설치한다. Amazon Linux 2023 저장소에 certbot이 있어 dnf로 충분하다 |
certbot plugins | nginx가 목록에 보이면 플러그인이 설치된 것이다 |
git clone --depth 1 | 설정 파일만 필요하므로 최신 커밋 하나만 받는다. 공개 저장소라 인증이 필요 없다 |
git clone에 --branch <브랜치 이름>을 붙인다.저장소의 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, 앱 연결이 모두 정상이다 |
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-run | Let'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 | 서비스 약관 동의(명령줄에서 대화를 건너뛰기 위함) |
--redirect | HTTP 요청을 HTTPS로 되돌려 보내는 설정을 추가한다 |
실제 발급이 하는 일(HTTP-01 방식).
/.well-known/acme-challenge/…)에 이 토큰을 놓아 보라"고 답한다./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을 먼저 하고 거부되면 정식 도메인 구매를 앞당기기로 했다. 이번에는 거부되지 않았다.
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)으로 바꿔 로그인과 게임 진행이 되는지 확인했다. 동작했다.
필수는 아니지만 공개 서버로서 확인할 만한 항목을 점검하고 설정을 더했다.
내 PC에서 확인한 결과다.
/actuator/env, /actuator/info도 토큰 없이는 401이다(내부 설정 노출 없음).Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options 헤더는 앱(Spring Security)이 보낸다. 그래서 Nginx에 HSTS 등을 또 넣으면 헤더가 중복된다. Nginx 쪽에는 추가하지 않았다.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은 거부된다.
00-hardening.confserver_tokens off;
기본 응답 헤더는 Server: nginx/x.y.z처럼 버전을 알려 준다. 버전을 알면 그 버전의 알려진 취약점을 노리기 쉽다. server_tokens off;는 Server: nginx만 남긴다. /etc/nginx/conf.d/에 두면 http 영역에 포함되어 모든 서버 블록에 적용된다.
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 on | SNI가 없거나 우리 도메인이 아닌 연결은 인증서도 보내지 않고 TLS 핸드셰이크 단계에서 거부한다. 정상 클라이언트(브라우저, Unity)는 SNI를 보내므로 영향이 없다 |
return 444 | 아래 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로 막는다.
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 포트)에 영향을 주지 않는지 확인한다 |
rm -rf ~/repo
설정은 /etc/nginx/로 복사되었으므로 서버에는 저장소 복사본을 남기지 않는다.
| 명령(PowerShell) | 기대 결과 |
|---|---|
curl.exe -I http://<도메인>/actuator/health | 301, Location: https://<도메인>/… |
curl.exe -s https://<도메인>/actuator/health | "status":"UP" |
curl.exe -s -o NUL -w "%{http_code}" https://<도메인>/api/v1/me | 401(토큰 없음) |
curl.exe -sI https://<도메인>/actuator/health | Server: 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) |
끝으로 클라이언트로 로그인과 게임 진행을 확인한다. 이 모두를 통과했다.
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
서버에만 있는 설정은 서버가 사라지면 함께 사라진다. 그래서 deploy/nginx/에 다음을 두었다.
| 파일 | 내용 |
|---|---|
blackhole.conf | certbot이 고치기 전 모양의 사이트 설정(도메인은 <도메인>) |
00-hardening.conf | server_tokens off; |
01-default-reject.conf | 기본 서버 거부 설정 |
README.md | 설치부터 인증서, 하드닝, 검증, 되돌리기, 도메인 교체까지의 절차 |
서버에서 certbot이 수정한 최종 파일을 그대로 복사하지 않은 이유는 인증서 경로가 들어가 도메인에 묶이기 때문이다. 처음 모양과 certbot 명령을 두면 서버를 다시 만들 때 같은 결과를 재현할 수 있다. 공개 저장소라서 도메인과 IP는 자리표시자로만 적는다.
식별 정보가 저장소에 섞이지 않도록 3단계 직후에 만든 식별 정보 검사(스크립트와 CI)가 이 파일들에도 적용된다.
임시 도메인은 정식 출시에 쓰지 않는다. 팀 논의 후 도메인을 구매하면 전환 기간에 두 도메인을 함께 서비스한 뒤 옮긴다.
blackhole.conf의 server_name에 새 도메인을 추가하고(nginx -t 후 reload), 인증서를 확장한다.sudo certbot --nginx --expand -d <기존 도메인> -d <새 도메인>
--expand가 없으면 확장할지 대화형으로 묻는다.
server_name에서 빼고 인증서를 다시 발급한다.JWT 발급자(app.jwt.issuer)는 URL이 아니라 고정 문자열이라 도메인을 바꿔도 이미 발급된 토큰은 무효화되지 않는다.
Host가 다를 때 어떤 서버 블록이 받는지, 그 블록에 root가 없으면 환영 페이지를 내준다는 것을 실제 요청으로 확인해서 알았다. 머릿속 예측만 믿고 넘어갔다면 기본 페이지가 계속 노출됐을 것이다. 검증은 "기대하는 값"을 적어 두고 실제 값과 비교하는 데 의미가 있다.renew --dry-run을 다시 돌려 갱신 경로를 확인했다.limit_req). 게스트 등록(/api/v1/auth/)은 토큰 없이 DB 행을 만들기 때문에 반복 호출을 막을 필요가 있다. 모바일 통신사의 공유 IP를 고려해 한도는 넉넉하게 잡아야 해서 7단계에서 검토한다.| 항목 | 선택 | 이유 |
|---|---|---|
| 서버 이름 | 도메인(임시: 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) | 서버를 다시 만들 때 재현할 수 있고, 인증서 경로를 저장소에 묶지 않는다 |
deploy/nginx/README.md와 이 글의 순서가 그대로 쓰인다.main 병합 시 GitHub OIDC 역할로 이미지를 빌드해 ECR에 올리고, SSM으로 deploy.sh를 실행해 사람 손 없이 배포되게 만든다.