2026.05.28
Next.js 앱을 Vercel에서 AWS(ECS Fargate)로 옮기는 작업을, 처음부터 끝까지 어떻게 설계하고 진행했는지의 압축 기록. 다음에 같은 일을 할 때 코드 디테일이 아니라 순서·결정·함정 중심으로 펼쳐보기 위한 노트.
Next.js Vercel 배포시, RDS 와 직접 붙어야 할 경우 any-open을 피할 수 없다. RDS any-open 하지 않으려면 돈내고 Vercel static ip를 쓰거나 aws 이전이 필요하다.
사용자
│ 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
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 전에는 들어가야 자동 배포가 의미 있음.
output: 'standalone' + 3-stage Dockerfile (deps → builder → runner)adapter-pg) - binaryTargets/native binary 불필요. Prisma 6 이전 가이드와 다름CI=1 + dummy DATABASE_URL) — t3-env validation을 빌드 시점에 통과시키기 위한 우회. final stage에는 누출 안 되도록 sanity check--platform linux/amd64 — 로컬 ARM Mac에서 빌드 시 Fargate(x86_64)와 어긋남 회피lens-{env}-*), VPC는 공유. 별도 VPC로 가면 RDS 공유 정책상 VPC Peering 필요lens-web) + image_tag_mutability=MUTABLE (:latest alias)lens-dev, lens-prod) - IAM Role naming unique 보장 + 운영 가시화desired_count=0 Day 1 - 첫 apply 직후 image_pull_failure로 죽는 걸 회피. 부트스트랩 후 CLI로 N으로 올림lifecycle.ignore_changes=[desired_count, task_definition] - Actions가 갱신하는 영역은 Terraform이 안 건드림yourdomain.com). prod env가 관리하고 dev env는 data.aws_route53_zone으로 참조. dev 서브도메인 레코드도 같은 zone 안에ELBSecurityPolicy-TLS13-1-2-2021-06)max-age=31536000; includeSubDomains; preload (ALB Listener 응답 헤더)lifecycle.prevent_destroy=true - 컷오버 후 사라지면 모든 DNS 깨짐enable_cert_validation): NS 미이전 상태에선 ACM polling이 hang하므로 부트스트랩 시 false, 컷오버 시 true5가지 카테고리로 명확히 나눔:
| 분류 | 저장소 | 주입 시점 | 예시 |
|---|---|---|---|
| 비민감 빌드타임 | 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 |
lens/{env}/{name}, SSM /lens/{env}/{VAR} - IAM ARN을 env별 narrow하기 좋게postgresql://REPLACE_USER:REPLACE_PASS@host:port/db)를 Terraform이 만들고, lifecycle.ignore_changes=[secret_string]로 Console 입력값 덮어쓰지 않게secretsmanager:GetSecretValue resource = secret:lens/{env}/*, ssm:GetParameter* = parameter/lens/{env}/*deploy-dev.yml / deploy-prod.yml) - 단일 workflow보다 권한 분리 명확repo:org/repo:environment:{env} - environment 기반이 branch 기반보다 안전 (PR 합치는 사람이 trust 조건 우회 어려움){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대형 변경은 아니지만 운영 안정성에 영향:
normalizeRequestId 헬퍼 (로그 인젝션 방지)--platform linux/amd64 명시lens-rds-sg를 shared Terraform으로 신설, dev/prod env가 terraform_remote_state.outputs.rds_security_group_id로 참조| 시점 | 운영 영향 |
|---|---|
| F~D 모든 코드/Terraform 작업 | 0 (Vercel은 그대로) |
dev 환경 검증 (dev.yourdomain.com) | 0 (별도 도메인) |
| ECR/ECS/ALB 신규 자원 생성 | 0 (기존 도메인은 Vercel) |
| NS 이전 (Route 53 위임) | 유일한 트래픽 전환 — 1~2시간 내 완료, 롤백은 NS 되돌리기 |
핵심은 컷오버를 마지막 한 번에 몰기. 부분 전환 없음~!
exit 1 적용하여 자동 배포 차단aws ecs update-service --desired-count 2로 prod Task 띄움dig NS yourdomain.com @8.8.8.8)| 함정 | 증상 | 해소 |
|---|---|---|
| 부모 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 체크리스트 |
다 잘 됐지만 시간이 더 있었다면 다르게 했을 것들:
ignore_changes로 따라감. Actions가 image push만 하고 terraform apply -var="image_tag=..."로 deploy하면 SSOT 일원화 + -replace 함정 자체가 사라짐. Trade-off: Actions runner에서 terraform 실행 → state lock/권한/시간 증가~> 6.0 + S3 native use_lockfile=true (DynamoDB lock 테이블 불필요)infra/{shared,modules,envs} 구조 — shared는 account-wide 단일 자원(ECR/OIDC/RDS data/RDS SG), envs는 env별 자원.gitignore (실값 + Account ID 노출 방지), .example만 커밋ADR-{사이클}{번호} 형식으로 짧게 기록 (예: ADR-C07)decisions.md 색인 + _workspace/{cycle}/02_plan.md 상세작업을 researcher → planner → implementer → reviewer 4-agent로 분담했을 때 효과가 컸던 지점:
_workspace/{cycle}/{01_research,02_plan,03_implementation_log,04_review}.md 4종 산출물 보존 → 다음 사이클이 이전 컨텍스트 참조 가능하네스 제작자님께 감사!