2026 . 오은

~~~~~~><>

Next.js Vercel → AWS 마이그레이션

2026.05.28

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 (:latest alias)
  • Cluster는 env별 분리 (lens-dev, lens-prod) - IAM Role naming unique 보장 + 운영 가시화
  • Service desired_count=0 Day 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:GetSecretValue resource = 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-over
  • wait-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. 컷오버 순서 (요약)

  1. dev 환경에서 며칠 운영해 안정성 확인
  2. Vercel Ignored Build Step에 exit 1 적용하여 자동 배포 차단
  3. main에 PR 머지 → Actions가 prod 배포
  4. Manual approval → ECR push + ECS update
  5. aws ecs update-service --desired-count 2로 prod Task 띄움
  6. ALB 직접 도메인으로 헬스체크 (Route 53 ALIAS A 미적용 상태)
  7. 도메인 등록업체 콘솔에서 NS를 Route 53 hosted zone NS로 이전
  8. 전파 확인 (dig NS yourdomain.com @8.8.8.8)
  9. 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 1image_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 native use_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}.md 4종 산출물 보존 → 다음 사이클이 이전 컨텍스트 참조 가능

하네스 제작자님께 감사!