이전 글에서는 PostgreSQL 데이터를 새 EC2로 복원하고 기존 S3 객체를 새 AWS 계정의 버킷으로 이전했다.
하지만 S3 버킷이 변경되면 객체 URL도 함께 달라진다. 기존 게시글에는 이전 S3 주소가 다음 위치에 저장돼 있었다.
uploads.urluploads.thumbnail_urlposts.content내부 이미지 주소
데이터베이스에 새 S3 버킷 주소를 직접 저장하면 향후 버킷이나 AWS 계정을 다시 변경할 때 같은 문제가 반복된다.
이번에는 애플리케이션이 S3 버킷 주소에 직접 의존하지 않도록 assets.hahbr88.site라는 이미지 전용 도메인을 도입했다. 이와 함께 백엔드 API 도메인도 기존 EC2에서 새 EC2로 전환했다.
목표 요청 경로
전환 후 요청 경로는 다음과 같다.
| 구분 | 요청 경로 |
|---|---|
| 프론트엔드 | Client → Vercel |
| 이미지 | Client 또는 Next Image → CloudFront → S3 |
| API | Client 또는 Vercel → Nginx → FastAPI |
| 이미지 도메인 | assets.hahbr88.site |
| API 도메인 | api.hahbr88.site |
게시글에는 실제 S3 버킷 주소 대신 다음과 같은 URL을 저장한다.
https://assets.hahbr88.site/images/example.jpeg
이제 S3 버킷이 변경되더라도 CloudFront Origin만 교체하면 동일한 이미지 URL을 유지할 수 있다.
1. S3를 CloudFront 비공개 Origin으로 구성
새 S3 버킷은 인터넷에 직접 공개하지 않았다.
사용자는 CloudFront를 통해서만 이미지에 접근하고, CloudFront가 비공개 S3 객체를 읽도록 구성했다.
CloudFront Origin에는 S3 정적 웹 사이트 엔드포인트가 아닌 일반 S3 버킷 엔드포인트를 지정했다.
hahbr88-blog-uploads.s3.ap-northeast-2.amazonaws.com
Origin Path는 비워 두었다. 이미지가 이미 버킷의 images/와 thumbnails/ 경로에 저장돼 있기 때문이다.
OAC 적용
CloudFront와 S3 사이에는 OAC(Origin Access Control)를 적용했다.
AWS는 기존 OAI보다 OAC 사용을 권장한다. OAC를 사용하면 CloudFront가 S3 요청에 서명하고 버킷 정책을 통해 특정 CloudFront 배포에만 객체 조회 권한을 부여할 수 있다. AWS CloudFront OAC 문서
설정값은 다음과 같다.
| 항목 | 설정 |
|---|---|
| Origin | 일반 S3 버킷 |
| Origin Path | 비움 |
| Private S3 access | 활성화 |
| Origin access | OAC |
| Signing behavior | Sign requests |
| Cache settings | S3 권장 설정 |
| Origin Shield | 비활성화 |
CloudFront가 S3를 조회할 수 있도록 버킷 정책도 함께 적용됐다. 정책은 CloudFront 서비스 Principal에 s3:GetObject를 허용하지만, 조건에는 해당 CloudFront 배포 ARN이 지정된다.
따라서 아무 CloudFront 배포에서나 버킷에 접근할 수 있는 것은 아니다.
2. 이미지 도메인용 ACM 인증서 발급
CloudFront에서 assets.hahbr88.site를 HTTPS로 제공하려면 ACM 인증서가 필요하다.
CloudFront에 연결할 ACM 인증서는 반드시 미국 동부 버지니아 북부 리전인 us-east-1에서 요청해야 한다. S3와 EC2가 서울 리전에 있더라도 이 조건은 동일하다. CloudFront 인증서 요구사항
ACM에서 다음 도메인의 퍼블릭 인증서를 요청했다.
assets.hahbr88.site
검증 방법은 DNS 검증을 선택했다.
도메인의 권한 있는 네임서버는 Route 53이 아닌 가비아였기 때문에 ACM에서 제공한 CNAME 레코드를 가비아 DNS에 직접 등록했다.
확인 결과는 다음과 같았다.
가비아 DNS에 ACM 검증 레코드를 등록한 후 CNAME 응답을 확인했다.
ACM 검증 대상이 반환된 후 인증서 상태가 발급됨으로 변경됐다.
ACM 검증 CNAME은 인증서 자동 갱신에도 사용될 수 있으므로 인증서 발급이 끝난 뒤에도 삭제하지 않았다.

3. CloudFront 배포와 사용자 지정 도메인 연결
CloudFront 배포는 다음과 같이 구성했다.
| 항목 | 설정 |
|---|---|
| Distribution name | hahbr88-blog-assets-prod |
| Origin | 새 계정의 비공개 S3 버킷 |
| Origin access | OAC |
| Alternate domain name | assets.hahbr88.site |
| Custom SSL certificate | 앞에서 발급한 ACM 인증서 |
| Security policy | TLSv1.2_2021 |
| Supported HTTP versions | HTTP/2, HTTP/3 |
| Price class | 북미·유럽·아시아·중동·아프리카 |
| IPv6 | 활성화 |
| WAF | 비활성화 |
| Origin Shield | 비활성화 |
| Anycast static IP | 사용하지 않음 |
| Viewer mTLS | 사용하지 않음 |
WAF는 이미지 조회용 배포이고 현재 트래픽 규모가 크지 않아 적용하지 않았다. 향후 접근 제어나 비정상 트래픽 방어가 필요할 때 추가할 수 있다.
CloudFront 배포가 완료된 후 다음 세 가지를 다시 확인했다.
- Origin이 새 S3 버킷을 가리키는지
- Origin에 OAC가 연결됐는지
- 사용자 지정 도메인과 ACM 인증서가 연결됐는지

4. 가비아 DNS 레코드 구성
CloudFront 배포가 완료되면 다음과 같은 기본 도메인이 생성된다.
xxxxxxxxxxxx.cloudfront.net
가비아 DNS에 assets.hahbr88.site가 이 CloudFront 배포를 가리키도록 CNAME 레코드를 추가했다.
| 유형 | 호스트 | 값 | TTL |
|---|---|---|---|
| CNAME | assets | xxxxxxxxxxxx.cloudfront.net. | 600 |
가비아에서는 CNAME 값이 점으로 끝나야 하므로 CloudFront 도메인 마지막에 .을 붙였다.
DNS 반영 여부는 다음 명령으로 확인했다.
CloudFront 배포 도메인이 반환되면 연결이 완료된 것이다.
이 시점의 주요 DNS 레코드는 다음과 같다.
| 용도 | 유형 | 호스트 | 대상 |
|---|---|---|---|
| 프론트엔드 | CNAME | www | Vercel |
| 백엔드 API | A | api | 새 EC2 공인 IP |
| 이미지 CDN | CNAME | assets | CloudFront |
| ACM 검증 | CNAME | _검증값.assets | ACM 검증 도메인 |

5. CloudFront 이미지 요청 검증
S3에 존재하는 객체 하나를 이용해 이미지 도메인을 검증했다.
실제 응답에서는 다음 항목을 확인할 수 있었다.
주요 헤더의 의미는 다음과 같다.
| 헤더 | 확인 내용 |
|---|---|
HTTP/2 200 | HTTPS 이미지 요청 성공 |
content-type: image/jpeg | 객체의 MIME 타입 정상 |
x-cache | CloudFront를 거친 요청 |
x-amz-cf-pop | 요청을 처리한 CloudFront Edge Location |
alt-svc: h3 | HTTP/3 사용 가능 |
첫 번째 요청에서 Miss from cloudfront가 반환되는 것은 정상이다. 해당 Edge Location에 캐시가 없어 CloudFront가 S3 Origin에서 객체를 조회했다는 의미다.
이 검증이 완료된 이후에만 데이터베이스의 기존 S3 URL을 https://assets.hahbr88.site로 교체했다.
6. Next.js 이미지 도메인 허용
프론트엔드는 Next.js Image 컴포넌트를 사용한다.
Next Image는 설정에 등록되지 않은 외부 이미지 도메인을 최적화하지 않기 때문에 next.config.ts의 remotePatterns에 새 이미지 도메인을 추가했다.
마이그레이션 도중에는 이전 S3 도메인 설정을 즉시 제거하지 않았다. 데이터베이스나 캐시에 이전 URL이 일부 남아 있을 경우 이미지가 깨지는 것을 방지하기 위해 일정 기간 함께 허용했다.
설정 변경 후 빌드를 확인했다.
Vercel 배포가 완료된 후 Next Image 최적화 엔드포인트도 직접 호출했다.
응답에서 HTTP/2 200, content-type: image/jpeg, server: Vercel을 확인했다.
브라우저에서 이미지가 보이는 것만으로는 원본 이미지가 직접 표시된 것인지 Next Image 최적화가 성공한 것인지 구분하기 어렵다. 따라서 /_next/image 경로까지 별도로 확인했다.
7. 새 EC2의 Nginx와 API 사전 검증
이미지 경로와 별개로 api.hahbr88.site도 기존 EC2에서 새 EC2로 전환해야 했다.
새 EC2에서는 FastAPI 컨테이너를 외부에 직접 노출하지 않고 로컬 인터페이스에만 바인딩했다.
외부 요청은 Nginx가 80번과 443번 포트에서 받은 뒤 FastAPI로 전달한다.
DNS를 변경하기 전에는 curl --resolve로 새 EC2를 직접 지정해 검증했다.
응답은 다음과 같았다.
--resolve를 사용하면 실제 DNS를 변경하지 않고도 요청 목적지 IP만 새 EC2로 지정할 수 있다. Host 헤더는 api.hahbr88.site로 유지되므로 Nginx의 가상 호스트 설정까지 함께 검증할 수 있다.
8. DNS 변경 전 HTTPS 검증
DNS가 기존 EC2를 가리키는 상태에서는 새 EC2가 HTTP-01 검증 요청을 받을 수 없기 때문에 같은 도메인의 인증서를 바로 발급하기 어렵다.
이번 이전에서는 DNS 변경 전에 HTTPS까지 검증하기 위해 기존 서버의 인증서와 개인 키를 새 서버로 임시 이전했다.
인증서 개인 키는 민감정보이므로 다음 원칙을 적용했다.
- SSH를 통해서만 전송
- 개인 키 권한을
600으로 제한 - Git과 Docker 이미지에 포함하지 않음
- 로그와 화면 캡처에 내용을 노출하지 않음
- DNS 전환 후 새 EC2에서 인증서를 다시 발급
- 재발급 완료 후 임시 인증서 삭제
더 엄격한 운영 환경이라면 개인 키를 복사하는 대신 DNS-01 검증이나 로드 밸런서를 활용한 전환 방식을 고려하는 것이 적절하다.
Nginx 설정을 반영한 후 문법을 검사했다.
HTTPS도 DNS 변경 전에 검증했다.
응답에서 HTTP/1.1 200 OK와 {"status":"ok"}를 확인했다.
이 과정은 개인 키를 다루므로 별도의 스크린샷을 남기지 않았다. 작업 과정을 재현한 화면보다 민감정보 노출을 막는 것이 우선이라고 판단했다.
9. API DNS 전환
데이터베이스 덤프가 최종본으로 확정된 상태였기 때문에 DNS 전환 시점에는 관리자 쓰기 작업을 중단했다.
DNS 전환 직전에는 다음 항목을 확인했다.
- 새 EC2의 API Health Check 성공
- Nginx를 통한 HTTP·HTTPS 요청 성공
- PostgreSQL 데이터 복원 완료
- 이미지와 썸네일 조회 성공
- 관리자 쓰기 작업 중단
- 기존 EC2와 S3를 롤백용으로 유지
가비아에서 api.hahbr88.site의 A 레코드를 기존 EC2 IP에서 새 EC2 IP로 변경했다.
TTL은 600초로 설정했다.
권한 있는 네임서버와 일반 재귀 DNS 결과를 각각 확인했다.
두 명령 모두 새 EC2 IP를 반환하는 것을 확인한 후 실제 공개 API를 호출했다.
Health Check 외에도 실제 게시글 조회, 이미지 업로드와 썸네일 생성 경로를 확인했다.

10. 새 EC2에서 인증서 재발급
DNS가 새 EC2를 가리키는 것을 확인한 후 Certbot으로 인증서를 다시 발급했다.
Nginx는 Certbot이 관리하는 인증서 경로를 사용하도록 변경됐다.
인증서 자동 갱신도 Dry Run으로 검증했다.
결과는 다음과 같았다.
재발급과 갱신 검증이 완료된 후 임시로 복사했던 인증서와 개인 키를 삭제했다.

11. 최종 서비스 검증
DNS와 인증서 전환 이후 실제 사용자 경로를 기준으로 다음 항목을 확인했다.
www.hahbr88.site프론트엔드 접속- 게시글 목록과 상세 조회
api.hahbr88.siteHTTPS 응답assets.hahbr88.site원본 이미지 조회- Next Image 최적화 응답
- 관리자 로그인
- 게시글 작성과 수정
- 이미지 업로드
- RQ Worker의 썸네일 생성
- 썸네일 정보의 PostgreSQL 반영
Health Check만 성공했다고 해서 전체 마이그레이션이 끝난 것은 아니다.
Health Check는 Nginx와 FastAPI의 연결 상태만 보여준다. PostgreSQL 데이터, S3 권한, Redis Queue, RQ Worker와 Next Image까지 실제 서비스 경로를 검증해야 한다.
12. 롤백 기준
전환 직후에도 기존 EC2와 S3는 바로 삭제하지 않았다.
다음 문제가 발생하면 API A 레코드를 기존 EC2로 되돌릴 수 있도록 유지했다.
- 새 API 시작 실패
- 데이터 누락 또는 스키마 불일치
- 이미지 조회나 업로드 실패
- Redis 또는 RQ 작업 실패
- Nginx와 인증서 오류
- 프론트엔드 CORS 오류
다만 새 서버에서 쓰기 작업을 재개한 뒤 기존 서버로 돌아가면 데이터가 다시 분리될 수 있다.
| 시점 | 롤백 방법 |
|---|---|
| 새 서버 쓰기 재개 전 | DNS를 기존 EC2로 복구 |
| 새 서버 쓰기 재개 후 | 쓰기 중단 후 신규 데이터 재동기화 |
| 기존 인프라 삭제 후 | 단순 DNS 롤백 불가능 |
따라서 DNS를 변경하는 것보다 무손실 롤백이 가능한 시점을 명확하게 정하는 것이 더 중요하다.
마이그레이션 결과
이번 단계에서 다음 작업을 완료했다.
- 새 S3 버킷을 비공개로 유지
- CloudFront OAC를 통한 S3 접근 제한
assets.hahbr88.site이미지 도메인 구성- ACM 인증서와 가비아 DNS 검증
- Next.js 외부 이미지 도메인 등록
- Vercel 이미지 최적화 검증
- 새 EC2의 Nginx HTTP·HTTPS 사전 검증
api.hahbr88.siteDNS 전환- 새 EC2에서 인증서 재발급
- 인증서 자동 갱신 Dry Run 검증
이번 작업에서 가장 중요한 변화는 이미지 URL에서 S3 버킷 이름을 제거한 것이다.
애플리케이션은 이제 실제 저장소 주소가 아니라 assets.hahbr88.site라는 안정적인 인터페이스에 의존한다. 향후 S3 버킷이나 AWS 계정이 다시 변경되더라도 게시글의 이미지 URL을 일괄 수정할 필요가 없다.
다음 글에서는 새 EC2에 GitHub Actions Self-hosted Runner를 등록하고 main 브랜치의 자동 배포를 이전한 과정을 정리한다.
- Self-hosted Runner 등록과 systemd 서비스 구성
- 기존 Runner와 새 Runner의 전환
- Docker Compose 자동 배포 검증
.env의 Docker 이미지 포함 방지- PostgreSQL과 Redis 포트 비공개화
- 기존 EC2 종료 전 최종 점검