1. 작업 배경
이 글은 AWS 계정 간 EC2 백엔드 마이그레이션 시리즈의 두 번째 글이다.
1편에서는 기존 서비스 구조를 분석하고 데이터 정합성, 이미지 URL, DNS, CI/CD, 롤백 계획을 기준으로 전체 마이그레이션 전략을 수립했다.
이번 글에서는 실제 상태 데이터를 이전한 과정을 다룬다.
여기서 다룰 이전 대상은 두 가지이다.
| 대상 | 저장된 데이터 |
|---|---|
| PostgreSQL | 게시글, 업로드 이력, 썸네일 URL, Alembic version |
| Amazon S3 | 원본 이미지와 썸네일 객체 |
PostgreSQL과 S3를 각각 백업하고 새 AWS 계정으로 전달한 뒤, 데이터 개수와 용량을 기준으로 검증하려고 했다.
2. 데이터 이전 전략
데이터 이전은 다음 순서로 진행했다.
- 기존 PostgreSQL dump 생성
- dump 파일 해시 기록
- dump 파일을 새 EC2로 전달
- 새 PostgreSQL에 데이터 복원
- 게시글·업로드·Alembic version 검증
- 기존 S3 객체 내려받기
- 새 EC2로 객체 전달
- 새 S3 버킷에 업로드
- 기존·신규 S3의 객체 수와 전체 용량 비교
- 데이터베이스에 저장된 기존 S3 URL 변경
최종 dump가 생성된 이후에는 게시글 작성, 수정, 삭제와 이미지 업로드를 중단했다.
공개 게시글 조회는 계속 허용하되 데이터 변경만 막아 기존 환경과 신규 환경 사이에 추가적인 차이가 발생하지 않도록 했다.
3. PostgreSQL dump 생성
PostgreSQL은 Docker Compose로 실행되고 있었고 데이터는 Docker volume에 저장되어 있었다.
기존 EC2의 PostgreSQL 컨테이너에서 custom format dump를 생성했다.
-Fc 옵션은 PostgreSQL custom format을 의미한다. 일반 SQL 파일과 달리 압축된 형태로 저장되며 pg_restore를 사용해 복원할 수 있다.
생성된 dump 파일은 약 90KB였다.
파일 크기만으로 정상 여부를 판단하지 않고 SHA-256 해시를 함께 기록했다.

dump 파일에는 운영 데이터가 포함되어 있으므로 권한을 600으로 제한했다.
4. dump 파일 전달과 해시 검증
기존 EC2에서 생성한 dump를 먼저 로컬 Mac으로 내려받은 뒤 새 EC2로 전달했다.
전송 후 새 EC2에서 다시 해시를 계산했다.
기존 EC2, 로컬 Mac, 새 EC2에서 계산한 SHA-256 값이 모두 동일한 것을 확인했다.
해시가 동일하다는 것은 파일 전송 과정에서 내용이 변경되거나 손상되지 않았다는 의미다.

5. 새 PostgreSQL 준비
새 EC2에서는 애플리케이션을 바로 실행하지 않고 PostgreSQL과 Redis만 먼저 시작했다.
컨테이너 상태를 확인했다.
PostgreSQL이 healthy 상태가 된 이후 복원을 진행했다.
이 단계에서 PostgreSQL과 Redis는 호스트 포트에 공개하지 않았다. 두 서비스는 Docker 내부 네트워크를 통해서만 FastAPI와 worker가 접근하도록 구성했다.
6. PostgreSQL 데이터 복원
새 PostgreSQL은 빈 데이터베이스였기 때문에 dump를 바로 복원했다.
옵션은 다음 목적으로 사용했다.
| 옵션 | 목적 |
|---|---|
--no-owner | 기존 DB 객체 소유자 정보를 복원하지 않음 |
--no-privileges | 기존 GRANT·REVOKE 권한을 복원하지 않음 |
--exit-on-error | 오류 발생 시 즉시 복원 중단 |
기존 계정과 신규 계정의 PostgreSQL 사용자 구성이 다를 수 있으므로 owner와 privilege 정보를 그대로 복원하지 않았다.
이미 데이터가 들어 있는 DB에 다시 복원해야 한다면 API와 worker를 중지한 뒤 기존 객체를 정리하거나 --clean --if-exists 옵션을 별도로 검토해야 한다.
7. 복원 결과 검증
pg_restore가 오류 없이 끝났다는 사실만으로 복원 성공을 판단하지 않았다.
게시글, 업로드, Alembic version을 직접 조회했다.
확인 결과는 다음과 같았다.
| 검증 대상 | 결과 |
|---|---|
| 전체 게시글 | 32개 |
| 업로드 레코드 | 29개 |
| Alembic version | c5d6c8a9f0b1 |
테이블 목록도 함께 확인했다.
복원된 테이블은 다음과 같았다.
postsuploadsalembic_version
기존 데이터베이스와 개수 및 migration version이 일치하는 것을 확인한 뒤 S3 이전으로 넘어갔다.

8. 기존 S3 상태 확인
기존 S3를 이전하기 전에 버킷 설정과 객체 상태를 먼저 확인했다.
확인한 항목은 다음과 같다.
- Public Access Block
- Bucket Policy
- 서버 측 암호화
- Versioning
- Object Ownership
- 전체 객체 수
- 전체 객체 용량
AWS CLI가 설치되어 있지 않아 AWS CLI v2를 설치한 뒤 EC2 IAM Role을 확인했다.
get-caller-identity 결과를 통해 기존 AWS 계정의 EC2 Role로 요청하고 있는지 확인했다.
스크린샷을 공개할 때는 AWS 계정 ID와 Instance ID를 마스킹해야 한다.
객체 수와 전체 용량은 다음 명령으로 확인했다.
기존 S3 상태는 다음과 같았다.
| 항목 | 결과 |
|---|---|
| 전체 객체 | 58개 |
| 전체 크기 | 47,767,170 bytes |
기존 버킷은 images/와 thumbnails/ prefix의 객체를 공개적으로 읽을 수 있도록 구성되어 있었다.
이번 이전에서는 이 공개 구조를 그대로 복제하지 않고, 새 S3는 비공개로 유지한 뒤 CloudFront를 통해 이미지를 제공하기로 했다.
9. 새 S3와 EC2 IAM Role 구성
새 AWS 계정에 이미지 저장용 S3 버킷을 생성했다.
새 버킷은 다음 원칙으로 구성했다.
| 항목 | 설정 |
|---|---|
| 리전 | 서울, ap-northeast-2 |
| Public Access | 차단 |
| Object Ownership | Bucket owner enforced |
| 암호화 | SSE-S3 |
| 외부 제공 방식 | CloudFront |
| 애플리케이션 접근 | EC2 IAM Role |
FastAPI와 RQ worker에서 AWS Access Key를 사용하지 않기 위해 새 EC2에 IAM Role을 연결했다.
Role에는 새 S3에 필요한 최소 권한만 부여했다.
- 버킷 목록 조회
- 객체 조회
- 객체 업로드
새 EC2에서 Role 적용 상태를 확인했다.
정상적으로 새 AWS 계정의 Role ARN과 버킷 리전이 반환되는 것을 확인했다.
10. S3 객체 전달
두 AWS 계정 사이에 임시 교차 계정 권한을 추가하지 않고 파일 디렉터리를 중간 경로로 사용했다.
기존 EC2에서 S3 객체를 내려받았다.
해당 디렉터리를 로컬 Mac으로 내려받은 뒤 새 EC2로 전달했다.
새 EC2에서 새 S3로 객체를 업로드했다.
파일 규모가 크거나 지속적인 변경이 발생하는 서비스라면 S3 Batch Operations, DataSync 또는 교차 계정 bucket policy를 고려할 수 있다.
이번 데이터는 약 48MB, 58개 객체로 규모가 작았기 때문에 중간 디렉터리 방식으로도 충분했다.
11. S3 객체 검증
새 S3의 객체 수와 전체 크기를 확인했다.
결과는 기존 버킷과 동일했다.
| 비교 대상 | 기존 S3 | 신규 S3 |
|---|---|---|
| 전체 객체 | 58개 | 58개 |
| 전체 크기 | 47,767,170 bytes | 47,767,170 bytes |
CloudFront 구성 이후 실제 이미지 객체에 HTTP 요청을 보내 200 OK와 image/jpeg 응답도 확인했다.

객체 수와 전체 크기가 같다는 사실은 기본적인 누락 여부를 확인하는 데는 유용하지만 암호학적인 무결성 검증은 아니다.
더 엄격한 검증이 필요한 경우 객체 key와 S3 checksum 또는 ETag 목록을 추출해 비교할 수 있다.
12. 기존 S3 절대 URL 문제
데이터베이스 복원과 S3 객체 이전은 완료됐지만 게시글은 여전히 기존 S3 URL을 사용하고 있었다.
확인 대상은 세 곳이었다.
| 대상 | 저장된 값 |
|---|---|
uploads.url | 원본 이미지 URL |
uploads.thumbnail_url | 썸네일 URL |
posts.content | Markdown 본문에 포함된 이미지 URL |
기존 URL이 포함된 레코드 개수를 조회했다.
조회 결과는 다음과 같았다.
| 대상 | 기존 URL 포함 개수 |
|---|---|
| 원본 이미지 URL | 29개 |
| 썸네일 URL | 29개 |
| 게시글 본문 | 20개 |
S3 버킷 주소를 새로운 버킷 주소로 직접 변경하면 다음 버킷 이전 때 같은 문제가 다시 발생한다.
따라서 새 URL은 S3 주소가 아닌 고정 이미지 도메인인 https://assets.hahbr88.site로 변경하기로 했다.
CloudFront와 이미지 도메인이 정상 동작하는 것을 먼저 확인한 후 트랜잭션으로 URL을 변경했다.
변경 결과는 다음과 같았다.
uploads.url: 29개 변경uploads.thumbnail_url: 29개 변경posts.content: 20개 변경
변경 후 기존 S3 URL이 남아 있는 레코드가 0개인지 다시 확인했다.

13. 이전 결과
PostgreSQL과 S3 이전 결과를 정리하면 다음과 같다.
| 검증 대상 | 결과 |
|---|---|
| dump 전송 해시 | 기존·신규 EC2 일치 |
| 전체 게시글 | 32개 |
| 업로드 레코드 | 29개 |
| Alembic version | c5d6c8a9f0b1 |
| S3 객체 | 58개 |
| S3 전체 크기 | 47,767,170 bytes |
| 기존 원본 이미지 URL | 29개 → 0개 |
| 기존 썸네일 URL | 29개 → 0개 |
| 기존 URL 포함 게시글 | 20개 → 0개 |
데이터 이전은 파일을 복사하고 명령이 성공하는 것으로 끝내지 않았다.
파일 해시, 데이터베이스 레코드 수, migration version, S3 객체 수, 전체 크기와 실제 이미지 응답을 각각 확인했다.
14. 작업 중 확인한 점
상태 데이터는 애플리케이션과 별도로 이전해야 한다
Git 저장소에는 애플리케이션 코드와 Compose 설정만 있다.
PostgreSQL volume과 S3 객체는 별도 상태 데이터이므로 각각 독립적인 백업과 검증 절차가 필요하다.
URL도 데이터의 일부다
S3 객체 자체를 모두 옮겨도 DB에 저장된 URL이 기존 버킷을 가리키면 이전이 완료된 것이 아니다.
파일뿐만 아니라 파일을 참조하는 URL도 마이그레이션 범위에 포함해야 한다.
물리적인 저장소 주소를 직접 저장하지 않는 편이 안전하다
S3 버킷 주소 대신 assets.hahbr88.site를 사용하면서 애플리케이션 데이터와 실제 저장소를 분리할 수 있었다.
향후 버킷이나 CDN이 변경되더라도 DNS와 CloudFront origin 변경으로 대응할 수 있다.
검증 기준을 작업 전에 정해야 한다
데이터 이전 이후 무엇을 비교할지 미리 정하지 않으면 정상 여부를 판단하기 어렵다.
이번 작업에서는 해시, 레코드 수, migration version, 객체 수와 전체 크기를 완료 기준으로 사용했다.
15. 마무리
PostgreSQL과 S3 이전을 통해 새 EC2가 기존 서비스와 동일한 상태 데이터를 사용할 수 있게 됐다.
하지만 아직 사용자의 이미지 요청이 새 S3에 직접 연결된 것은 아니었다.
새 S3는 비공개로 유지하고 있었기 때문에 CloudFront, ACM 인증서, assets.hahbr88.site를 구성해야 했다. 또한 Next.js가 새 이미지 도메인을 허용하도록 Vercel 배포 설정도 변경해야 했다.
다음 글에서는 다음 내용을 다룬다.
- 비공개 S3와 CloudFront OAC 연결
- ACM 인증서 DNS 검증
assets.hahbr88.site연결- CloudFront 캐시와 HTTP/3 확인
- Next.js Image Optimizer 설정
- Nginx와 HTTPS 사전 검증
- API DNS 전환 과정