Vercel → AWS 마이그레이션 마스터 플랜
Next.js 앱을 Vercel에서 AWS(ECS Fargate)로 옮기는 작업을,
처음부터 끝까지 어떻게 설계하고 진행했는지의 압축 기록.
다음에 같은 일을 할 때 코드 디테일이 아니라 순서·결정·함정 중심으로 펼쳐보기 위한 노트.
1. 무엇을, 왜
Next.js Vercel 배포시, RDS 와 직접 붙어야 할 경우 any-open을 피할 수 없다.
RDS any-open 하지 않으려면 돈내고 Vercel static ip를 쓰거나 aws 이전이 필요하다.
- 대상: Next.js 16 앱 (App Router, RSC, Better Auth, Prisma 7)
- 출발: Vercel (Edge + Vercel Build, 환경변수 UI 입력)
- 도착: AWS ECS Fargate + ALB + ECR + Route 53 + Secrets Manager/SSM
- DB: 기존 RDS Postgres 재사용 (dev/prod 1개 공유 사용한 상황)
- 트래픽 전환: 도메인 NS 이전을 단 한 번의 컷오버로 - 그 전까지 Vercel은 계속 운영
- 컷오버 후: 1~2주 안정 운행 확인 후 Vercel 프로젝트 정리
2. 목표 아키텍처 한눈에
사용자
│ HTTPS
▼
Route 53 (hosted zone: yourdomain.com)
│ ALIAS A
▼
ALB (per env, TLS 1.3, HSTS, HTTP→HTTPS 301)
│ 443 → TG(3000)
▼
ECS Service (Fargate, env별 cluster, desired_count 운영자 관리)
│ Task = Next.js standalone (Node 22 Alpine)
├──► Secrets Manager : DATABASE_URL, BETTER_AUTH_SECRET, GOOGLE_CLIENT_SECRET
├──► SSM SecureString : GOOGLE_CLIENT_ID
├──► SSM Standard : BETTER_AUTH_URL, ALLOWED_EMAIL_DOMAIN, ...
├──► CloudWatch Logs : /ecs/lens-web-{env}
└──► RDS (5432) : 전용 SG, ECS SG로부터 ingress만
CI/CD (GitHub Actions, OIDC)
- dev push → deploy-dev.yml → ECR push + ECS update
- main push → deploy-prod.yml → manual approval → ECR push + ECS update
3. 사이클 순서와 의존성
F 컨테이너화 ──► (코드를 빌드 가능한 이미지로)
│
A VPC/네트워킹/SG ──► (모든 인프라의 기반)
│
B ECS 클러스터+ECR ──► (이미지 → 실행)
│
C ALB + ACM + Route 53 ──► (트래픽 입구)
│
E Secrets/SSM ──► (Task Def이 마지막에 secrets 매핑)
│
D CI/CD ──► (자동 배포 + image_tag 갱신)
│
H 백로그/잔손질 ──► (운영 안정성)
│
I RDS 전용 SG (사후) ──► (default SG 의존 해소)
순서의 핵심: A 없이 B 없고, B 없이 C 없다.
C까지는 신규 인프라를 짓는 단계라 운영 영향 0.
E는 어디든 끼울 수 있지만 D 전에는 들어가야 자동 배포가 의미 있음.
4. 단계별 핵심 결정
F. 컨테이너화
output: 'standalone'+ 3-stage Dockerfile (deps → builder → runner)- Node 22 + Alpine (musl 호환 라이브러리 주의)
- Prisma 7 driver adapter (
adapter-pg) - binaryTargets/native binary 불필요. Prisma 6 이전 가이드와 다름 - builder stage placeholder env (
CI=1+ dummy DATABASE_URL) — t3-env validation을 빌드 시점에 통과시키기 위한 우회. final stage에는 누출 안 되도록 sanity check - sharp 명시적 dependency + outputFileTracingIncludes (Image Optimization 안정화)
--platform linux/amd64— 로컬 ARM Mac에서 빌드 시 Fargate(x86_64)와 어긋남 회피
A. VPC/네트워킹
- 기존 VPC 재사용, subnet/SG는 신규 생성
- 환경 격리 단위는 prefix(
lens-{env}-*), VPC는 공유. 별도 VPC로 가면 RDS 공유 정책상 VPC Peering 필요 - Subnet CIDR 패턴을 명시적 인덱스로 설정 (dev=120/121/130/131, prod=100/101/110/111) - 기존 subnet과 충돌 회피
- 2-AZ 분산 강제 (ALB 요구)
B. ECS + ECR
- ECR 1개 공유 repository (
lens-web) +image_tag_mutability=MUTABLE(:latestalias) - Cluster는 env별 분리 (
lens-dev,lens-prod) - IAM Role naming unique 보장 + 운영 가시화 - Service
desired_count=0Day 1 - 첫 apply 직후 image_pull_failure로 죽는 걸 회피. 부트스트랩 후 CLI로 N으로 올림 - Task sizing 환경별 차등: dev 256/512, prod 512/1024
- Service
lifecycle.ignore_changes=[desired_count, task_definition]- Actions가 갱신하는 영역은 Terraform이 안 건드림 - Log retention 환경별: dev 7일, prod 30일
C. ALB + ACM + Route 53 + HSTS
- 환경별 ALB + 환경별 ACM cert (인증서 갱신/관리 영향 분리)
- Hosted zone은 단일 (
yourdomain.com). prod env가 관리하고 dev env는data.aws_route53_zone으로 참조. dev 서브도메인 레코드도 같은 zone 안에 - CAA 레코드 필수: 도메인 등록업체에 letsencrypt CAA가 박혀있으면 ACM이 발급 실패. amazon.com/amazontrust.com/awstrust.com/amazonaws.com 4개 추가. → 이건 Vercel 에 Domain 이 묶여 있어서 필요했던 과정. Vercel 이 인증서 넣어주니까.
- HTTP 80 → HTTPS 443 301 redirect
- TLS 1.3 (
ELBSecurityPolicy-TLS13-1-2-2021-06) - HSTS
max-age=31536000; includeSubDomains; preload(ALB Listener 응답 헤더) - prod hosted zone
lifecycle.prevent_destroy=true- 컷오버 후 사라지면 모든 DNS 깨짐 - prod cert validation은 변수 토글 (
enable_cert_validation): NS 미이전 상태에선 ACM polling이 hang하므로 부트스트랩 시 false, 컷오버 시 true
E. Secrets / SSM 환경변수 분류
5가지 카테고리로 명확히 나눔:
| 분류 | 저장소 | 주입 시점 | 예시 |
|---|---|---|---|
| 비민감 빌드타임 | Task Def environment (Terraform 박힘) |
컨테이너 시작 | NODE_ENV, PORT, APP_ENV, LOG_LEVEL |
| 비민감 런타임 | SSM Standard | 컨테이너 시작 (ECS secrets) |
BETTER_AUTH_URL, ALLOWED_EMAIL_DOMAIN |
| 보안 중간 | SSM SecureString | 컨테이너 시작 | GOOGLE_CLIENT_ID |
| 시크릿 (회전 가치) | Secrets Manager | 컨테이너 시작 | DATABASE_URL, BETTER_AUTH_SECRET, GOOGLE_CLIENT_SECRET |
| 클라이언트 번들 | SSM Standard → Docker --build-arg |
빌드 타임 | NEXT_PUBLIC_RUN_MODE |
- 명명: Secrets Manager
lens/{env}/{name}, SSM/lens/{env}/{VAR}- IAM ARN을 env별 narrow하기 좋게 - 실값은 Console 수동: Terraform code/state/tfvars/git/PR 어디에도 0건. ARN만 코드에 등장
- DATABASE_URL placeholder (
postgresql://REPLACE_USER:REPLACE_PASS@host:port/db)를 Terraform이 만들고,lifecycle.ignore_changes=[secret_string]로 Console 입력값 덮어쓰지 않게 - IAM 정책 narrow:
secretsmanager:GetSecretValueresource =secret:lens/{env}/*,ssm:GetParameter*=parameter/lens/{env}/* - KMS Decrypt는 wildcard (Phase 2에서 CMK 분리)
D. CI/CD
- GitHub OIDC - 장기 IAM Access Key 폐지. account 1개당 OIDC Provider 1개라 shared state로 관리
- Workflow 2개 분리 (
deploy-dev.yml/deploy-prod.yml) - 단일 workflow보다 권한 분리 명확 - IAM Role trust policy sub condition:
repo:org/repo:environment:{env}- environment 기반이 branch 기반보다 안전 (PR 합치는 사람이 trust 조건 우회 어려움) - GitHub Environment: prod는 Required reviewers 설정 (manual approval gate)
- Image tag:
{env}-{sha}+{env}-latest(env별 latest 분리로 dev/prod 충돌 방지) aws-actions/amazon-ecs-render-task-definition@v1+ describe + jq strip 7 read-only fields → register-task-definition. secrets/env는 base에서 carry-overwait-for-service-stability=true,wait-for-minutes=10
H. 백로그/잔손질
대형 변경은 아니지만 운영 안정성에 영향:
- README 운영 규약 섹션 (APP_ENV/LOG_LEVEL 값 강제, desired_count는 CLI로)
normalizeRequestId헬퍼 (로그 인젝션 방지)--platform linux/amd64명시- Commit 전 dummy URL grep sanity step
I. RDS 전용 SG 분리 (사후 보강)
- 운영 중 발견: default VPC SG에 RDS + lens dev/prod ECS Fargate inbound rule이 모두 들어가 default가 사실상 RDS 전용으로 굳어가던 상태
lens-rds-sg를 shared Terraform으로 신설, dev/prod env가terraform_remote_state.outputs.rds_security_group_id로 참조- swap 절차: shared apply → Console에서 RDS에 default + 새 SG 양쪽 멤버 attach → dev apply → 검증 → prod apply → 검증 → Console에서 default 제거. 통신 끊김 없이 진행
5. 운영 영향 0 원칙
| 시점 | 운영 영향 |
|---|---|
| F~D 모든 코드/Terraform 작업 | 0 (Vercel은 그대로) |
dev 환경 검증 (dev.yourdomain.com) |
0 (별도 도메인) |
| ECR/ECS/ALB 신규 자원 생성 | 0 (기존 도메인은 Vercel) |
| NS 이전 (Route 53 위임) | 유일한 트래픽 전환 — 1~2시간 내 완료, 롤백은 NS 되돌리기 |
핵심은 컷오버를 마지막 한 번에 몰기. 부분 전환 없음~!
6. 컷오버 순서 (요약)
- dev 환경에서 며칠 운영해 안정성 확인
- Vercel Ignored Build Step에
exit 1적용하여 자동 배포 차단 - main에 PR 머지 → Actions가 prod 배포
- Manual approval → ECR push + ECS update
aws ecs update-service --desired-count 2로 prod Task 띄움- ALB 직접 도메인으로 헬스체크 (Route 53 ALIAS A 미적용 상태)
- 도메인 등록업체 콘솔에서 NS를 Route 53 hosted zone NS로 이전
- 전파 확인 (
dig NS yourdomain.com @8.8.8.8) - 1~2주 안정 운행 후 Vercel 프로젝트 정리
7. 함정 모음 (다음엔 피하기)
| 함정 | 증상 | 해소 |
|---|---|---|
| 부모 zone에 letsencrypt CAA만 있음 | ACM이 CAA_ERROR로 발급 거부 |
amazon.com 등 4개 CAA 추가 |
DATABASE_URL에 ?sslmode=no-verify 누락 |
ECS Task가 P1010 connection refused |
연결 문자열에 ?sslmode=no-verify 추가 |
desired_count=2 Day 1 |
image_pull_failure 5회 후 Task 죽음 | Day 1은 0, 부트스트랩 후 N으로 |
| AWS SSO profile이 다른 계정 | S3 backend 403 (state bucket) | aws sts get-caller-identity로 사전 확인 |
| Vercel root NS 변경 시도 | "Cannot set NS records at the root level" | Vercel은 DNS 호스트, 레지스트라 콘솔(가비아 등)에서 변경 |
lifecycle.ignore_changes=[container_definitions] |
secrets/env 추가가 사일런트 누락 | 머지 직후 terraform apply -replace=module.ecs.aws_ecs_task_definition.app |
| RDS instance 외부 자원이라 Terraform이 SG 멤버십 못 만짐 | SG swap 시 Console 수동 필요 | 양쪽 멤버 일시 유지 → 새것만 남김 |
| Hosted zone NS가 시간차로 이전 | dev/prod cert 발급 타이밍 어긋남 | dev는 prod zone 안에 서브로 넣고 단일 zone 운영 |
ECS Task Def secrets 변경 누락 (재발 가능) |
새 env가 컨테이너에 안 들어감 | 위 -replace 룰 + PR 체크리스트 |
8. 다음에 또 한다면 — 권장 변경
다 잘 됐지만 시간이 더 있었다면 다르게 했을 것들:
- Task Def SSOT를 Terraform으로: 현재는 Actions가 매 배포마다 새 revision register → Terraform이
ignore_changes로 따라감. Actions가 image push만 하고terraform apply -var="image_tag=..."로 deploy하면 SSOT 일원화 +-replace함정 자체가 사라짐. Trade-off: Actions runner에서 terraform 실행 → state lock/권한/시간 증가 - NAT Gateway를 처음부터: Public subnet + assign_public_ip는 비용 낮지만 보안 표면 넓음. NAT + Private subnet 패턴이 일반적 권장
- WAF / Auto Scaling을 D 사이클에 포함: 컷오버 후 별도로 처리하면 운영 부담
- observability 강화: CloudWatch만으론 부족. Datadog/Sentry 같은 외부 도구 초기 통합
9. 도구/버전 선택 (재사용 권장)
- Terraform 1.10+ + AWS provider
~> 6.0+ S3 nativeuse_lockfile=true(DynamoDB lock 테이블 불필요) infra/{shared,modules,envs}구조 — shared는 account-wide 단일 자원(ECR/OIDC/RDS data/RDS SG), envs는 env별 자원- tfvars는
.gitignore(실값 + Account ID 노출 방지),.example만 커밋 - pnpm + corepack + Node 22
- Better Auth + Prisma 7 driver adapter (Edge 호환성/native binary 회피)
- Pino + AsyncLocalStorage로 requestId 전파 (구조화 로그)
10. 의사결정 기록 패턴 (ADR)
- 각 사이클의 결정은 모두
ADR-{사이클}{번호}형식으로 짧게 기록 (예:ADR-C07) - 코드 옆 주석 +
decisions.md색인 +_workspace/{cycle}/02_plan.md상세 - spec과 실제 결정 간 차이도 ADR로 남김 → 감사 추적 가능
- 이 패턴이 컷오버 트러블슈팅에서 "왜 이렇게 만들었지?" 5분 안에 답 가능하게 해줌
11. 4-agent harness가 도움이 된 지점
작업을 researcher → planner → implementer → reviewer 4-agent로 분담했을 때 효과가 컸던 지점:
- Researcher가 라이브러리/AWS 동작 정확히 확인 후 Planner에 넘김 → Implementer가 추측 안 함
- Planner가 ADR 형식으로 결정과 대안 기각 사유 기록 → 나중에 회귀 추적 용이
- Reviewer가 매 사이클 직후 별도 패스로 감사 → 사일런트 회귀 조기 발견 (E의 H1 회귀 등)
- 사이클별
_workspace/{cycle}/{01_research,02_plan,03_implementation_log,04_review}.md4종 산출물 보존 → 다음 사이클이 이전 컨텍스트 참조 가능
하네스 제작자님께 감사!
'TIL' 카테고리의 다른 글
| [260531 TIL] 멀티가능 미니 FPS 만들기 2편(운영) (0) | 2026.05.31 |
|---|---|
| [260531 TIL] 멀티가능 미니 FPS 만들기 1편(기본구현) (0) | 2026.05.31 |
| [260527 TIL] 비개발자에게 Claude로 RDS 조회할 수 있도록(안전히...) (0) | 2026.05.27 |
| [260508 TIL] Personas - 임베딩 검색 구현 - overview (1) | 2026.05.09 |
| [260508 TIL] Personas - 임베딩 검색 구현 - observability (0) | 2026.05.09 |