던플 개발기 (4): 배포와 로컬 환경 — ECS, RDS, SQS, 그리고 비용 타협
여기까지 설계, 수집, 장애 대응, 화면을 다뤘습니다. 이번 편은 마지막 조각, 어디에 어떻게 띄우고 어떻게 로컬에서 돌리나입니다.
개인 프로젝트의 인프라에는 회사 프로젝트와 다른 긴장이 하나 있습니다. "제대로 운영 가능한 구조"와 "혼자 감당할 비용" 사이에서 줄을 타야 합니다. 그래서 이 편의 절반은 AWS 위에 어떤 그림을 그렸는지, 나머지 절반은 그 그림을 로컬에서 먼저 돌릴 때 실제로 부딪힌 함정들입니다.
한눈에 보면
- 사용자 트래픽은
Route53 → CloudFront → ALB → ECS Fargate(web/bff)로 흐릅니다. Next.js SSR은 정적 배포가 아니라 컨테이너로 띄웁니다. - 내부는
web → bff(REST) → api(gRPC), 수집은EventBridge → SQS → cron이며 ECS Service Connect로 사설 endpoint를 묶습니다. - 데이터·비밀은 RDS(PostgreSQL)·ElastiCache·SQS·Secrets Manager·ECR·CloudWatch로 나눕니다.
- 개인 프로젝트라 RDS Single-AZ로 시작하고, 실사용자가 생기면 Multi-AZ로 올립니다. 처음부터 풀세팅하지 않습니다.
- IaC는 모노레포 언어와 맞춰 **AWS CDK(TypeScript)**로 갑니다.
- 로컬은 docker compose PostgreSQL + 앱 3개. 실제로는 5432 포트 충돌, 부팅 시점에 필요한
NEOPLE_API_KEY,.env의NODE_ENV함정에서 시간을 썼습니다.
어디에 올리나: AWS 토폴로지
flowchart TD
R53[Route 53<br/>market.dunplay.xyz] --> CF[CloudFront]
CF --> ALB[ALB]
ALB -->|default| WEB[ECS Fargate<br/>market-web SSR]
ALB -->|/api/*| BFF[ECS Fargate<br/>market-bff REST]
WEB -->|SSR REST| BFF
BFF -->|gRPC| API[ECS<br/>market-api]
API --> RDS[(RDS PostgreSQL)]
API --> EC[(ElastiCache)]
EBS[EventBridge Scheduler] --> SQS[SQS + DLQ]
SQS --> CRON[ECS<br/>market-cron]
CRON --> NEO[네오플 Open API]
CRON --> RDS사용자 트래픽과 내부·수집 트래픽을 같은 토폴로지 안에서 분리해 둔 게 설계 편의 구조가 그대로 인프라로 내려온 모습입니다. 내부 서비스끼리는 ECS Service Connect와 Cloud Map namespace로 사설 endpoint를 구성해, 공개 인터넷을 거치지 않고 bff → api를 호출합니다.
왜 정적 호스팅이 아닌가
Next.js 앱이라고 해서 전부 S3에 정적 파일로 올리는 건 아닙니다. 던플마켓의 market-web은 첫 화면(차트·시세·순위)을 **서버에서 렌더(SSR)**하므로, 정적 산출물이 아니라 실행되는 서버가 필요합니다. 그래서 market-web을 Docker 컨테이너로 만들어 ECS Fargate에 띄우고, 앞에 CloudFront를 둡니다.
- 정적 asset(JS·CSS·폰트)은 CloudFront cache를 적극 활용합니다.
- 동적 SSR 응답은 Next.js가 지정한 cache header를 존중합니다.
접근 차단도 단계를 나눴습니다. 운영 hardening 단계에서는 CloudFront VPC origin과 private ALB를 쓰지만, 빠른 MVP에서는 internet-facing ALB로 시작하되 CloudFront custom header와 security group 제한으로 ALB 직접 접근을 막습니다. 필요해지면 올리는 식입니다.
데이터·비밀·큐
각 역할을 전용 관리형 서비스로 나눴습니다.
| 용도 | 서비스 |
|---|---|
| 영속 저장 | RDS for PostgreSQL |
| 캐시 | ElastiCache (Valkey 또는 Redis OSS) |
| 큐 | SQS + dead-letter queue |
| 비밀(API key·DB 자격증명) | AWS Secrets Manager |
| 컨테이너 이미지 | ECR |
| 로그 | CloudWatch Logs |
핵심은 설계 편에서 정한 경계가 인프라에서도 유지된다는 점입니다. market-bff는 DB·Redis·네오플에 직접 닿지 않고, API key와 DB 자격증명은 코드가 아니라 Secrets Manager에만 둡니다.
개인 프로젝트의 비용 타협
여기가 회사 프로젝트와 가장 다른 지점입니다. "이상적인 운영 구성"을 처음부터 다 켜면 사용자가 0명일 때도 매달 비용이 나갑니다. 그래서 의도적으로 단계를 나눴습니다.
- DB: 초기에는 RDS Single-AZ로 비용을 줄입니다. 실제 사용자가 생기고 복구 목표(RTO/RPO)가 정해지면 Multi-AZ로 전환합니다.
- 네트워크: MVP는 internet-facing ALB로 시작하고, custom header + security group으로 직접 접근만 막습니다. 트래픽과 보안 요구가 커지면 private ALB + CloudFront VPC origin으로 hardening합니다.
- 수집: 컴플라이언스 게이트 전까지 실제 반복 수집은 feature flag로 꺼 둡니다. 인프라는 올라가 있어도 외부 호출 비용은 0입니다.
이건 "대충 한다"가 아니라 "필요해질 때 올린다"는 결정을 명시적으로 기록해 둔 것입니다. 언제 무엇을 올릴지(Multi-AZ, private ALB)를 미리 적어 두면, 나중에 그게 게으름이 아니라 계획이었음이 드러납니다.
IaC는 CDK로
인프라는 손으로 콘솔에서 만들지 않고 코드로 둡니다. 도구는 AWS CDK(TypeScript)를 골랐습니다. 이유는 단순합니다. 저장소 전체가 TypeScript strict 모노레포이고, 인프라 정의도 같은 언어·같은 타입 체계 안에 두면 맥락 전환이 줄어듭니다. Terraform이 더 범용적이지만, 이 프로젝트에서는 "한 언어로 끝내는 일관성"이 더 컸습니다.
관측성: 안 보이면 못 고친다
수집기는 외부 의존이 많아 "지금 왜 안 되는지"가 안 보이면 손쓸 수가 없습니다. 그래서 장애 대응 편에서 만든 상태들을 관측 가능하게 내보냅니다.
- 앱 로그는 JSON 구조화 로그 + correlation ID로 남깁니다.
- metric·trace는 OpenTelemetry로 계측하고, 초기 저장은 CloudWatch Logs를 씁니다.
- dashboard에는 수집 backlog age, provider latency·error, rate limit 응답, rollup watermark를 둡니다.
- provider 상태, circuit breaker, probe, DLQ도 dashboard와 alert에 포함합니다.
DNF980 감지, 예정 종료 후에도 지속되는 점검, credential 오류, circuit open, backlog 임계 초과, rollup 실패 같은 건 그냥 로그에 묻어 두지 않고 alert로 끌어올립니다.
로컬에서 먼저 돌린다
AWS에 올리기 전에 전부 로컬에서 돕니다. docker compose로 PostgreSQL을 띄우고 앱 3개를 붙입니다. 환경 변수는 역할이 드러나는 이름을 씁니다(MARKET_BFF_PORT, MARKET_API_GRPC_BIND_URL처럼). 한 명령으로 인프라 기동 + migration + 앱 3개를 띄우는 alias도 뒀습니다.
pnpm dev:up # postgres --wait → migration → web/bff/api 병렬 기동여기까지는 매끄러웠지만, 실제로 시간을 쓴 건 다음 세 가지였습니다.
1. 5432 포트 충돌. 로컬에 이미 PostgreSQL이 5432에 떠 있으면 compose가 바인딩에 실패하거나 앱이 엉뚱한 DB에 붙습니다. docker-compose.yml의 host 포트를 ${POSTGRES_PORT:-5432}로 변수화해 둬서, .env에서 POSTGRES_PORT=5433 한 줄만 바꾸면 host 바인딩과 앱 접속 포트가 함께 따라옵니다. 컨테이너 내부 포트는 항상 5432이고, host에서 접속할 때만 5433을 씁니다.
2. NEOPLE_API_KEY가 부팅 시점에 필요하다. 처음엔 "검색을 실제로 호출할 때만 key가 필요하겠지"라고 생각했는데 아니었습니다. market-api가 부팅하며 DI 컨테이너를 구성하는 단계에서 catalog provider factory가 즉시 설정을 읽고, key가 비어 있으면 거기서 부팅 자체가 실패합니다. 그래서 부팅만 확인할 때는 placeholder key로도 /api/docs와 라우팅을 볼 수 있게 하고, 진짜 key는 실제 네오플 호출(쿼터·비용 발생)을 검증하는 live 단계에서만 씁니다. key 값은 코드·fixture·로그 어디에도 남기지 않고, 에러 메시지조차 변수명만 노출합니다.
3. .env의 NODE_ENV 함정. market-web production build가 /_global-error prerender에서 Cannot read properties of null (reading 'useContext')로 깨진 적이 있습니다. 원인은 로컬 .env에 박혀 있던 NODE_ENV=development였습니다. next build가 이 값을 읽어 production prerender를 망가뜨린 것입니다. .env에서 NODE_ENV 줄을 빼니 해결됐습니다.
이런 걸 자동으로 잡으려고 스모크 스크립트를 3단계로 뒀습니다.
| 티어 | 확인 | 외부 의존 |
|---|---|---|
| config | gRPC bind/url 포트 일치, web/bff 포트 비충돌 | 없음 |
| boot | api gRPC accept, bff /api/docs 200, 라우팅 |
PostgreSQL |
| live | BFF 통한 실제 네오플 검색 | 진짜 key·쿼터 |
config 티어는 인프라 없이도 늘 돌릴 수 있어, env 배선이 어긋났는지 가장 빨리 잡아 줍니다.
정리하면
인프라 편의 결정도 다른 편들과 같은 결을 따랐습니다.
- 사용자 트래픽과 내부·수집 트래픽을 분리한 구조를 그대로 ECS·SQS 토폴로지로 내렸다.
- Next.js SSR은 컨테이너로 띄우고 CloudFront를 앞에 뒀다.
- 개인 프로젝트라 Single-AZ·internet-facing ALB로 시작하되, "언제 무엇을 올릴지"를 명시했다.
- IaC는 CDK로, 관측성은 수집 backlog·provider 상태·DLQ까지 dashboard와 alert에 포함했다.
- AWS 전에 로컬에서 다 돌리고, 포트 충돌·부팅 시점 key·
NODE_ENV함정을 스모크로 잡는다.
이걸로 던플 개발기 다섯 편(설계·수집·장애·화면·인프라)으로 한 바퀴 돌았습니다. 공통 원칙은 처음부터 끝까지 하나였습니다. 지금 필요한 만큼만 만들고, 모르는 것을 아는 척하지 않는다. 구조도, 수집도, 장애 대응도, 화면도, 인프라도 그 한 문장의 변주였습니다.
앞으로는 골드 시세, 길드, 랭킹 같은 미니앱이 이 플랫폼 위에 하나씩 얹힐 예정입니다. 그때마다 이 시리즈에 새 편이 붙을 겁니다.
