Twelve-Factor App
분류: Layer 9 - 아키텍처 & 설계 패턴
1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”Twelve-Factor App은 클라우드 환경(ECS, K8s, Heroku 등)에서 포터블하고 운영 안전한 분산 서비스 앱을 만들기 위한 12가지 원칙(Codebase, Dependencies, Config, Backing services, Build/Release/Run, Processes, Port binding, Concurrency, Disposability, Dev/prod parity, Logs, Admin processes)으로, Heroku 엔지니어들이 2011년 정립했다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”이 12원칙을 지키지 않으면 클라우드에 올렸을 때 “내 로컬에서는 됐다”가 끝없이 반복된다. 환경변수 누락으로 localhost에 연결을 시도하다 502를 뿌리고, 인메모리 세션 때문에 배포 시 사용자가 강제 로그아웃되며, SIGTERM 핸들러 부재로 진행 중인 요청이 502로 끊긴다. 12-Factor는 ECS·K8s·CI/CD 어디에 올려도 같은 방식으로 동작하는 운영 안전 표준 이다.
선수지식: clean-architecture.md. 인접 도메인: L5 docker-basics, kubernetes-basics, cicd-basics, L3 secrets-management.
선행 운영 방식의 한계에서 12원칙으로
섹션 제목: “선행 운영 방식의 한계에서 12원칙으로”12-Factor가 해결한 문제는 “클라우드가 어렵다”가 아니라, 로컬 개발·수동 배포·서버별 설정 파일·인스턴스 로컬 상태가 서로 다른 운영 계약을 만들던 문제다. 공식 문서는 Heroku 플랫폼에서 직접 배포한 수백 개 앱과 관찰한 수십만 개 앱의 운영 경험을 바탕으로 이 원칙이 정리됐다고 설명한다(출처: https://12factor.net/). 특히 Dev/prod parity에서는 전통적 앱의 배포 간격이 “weeks”였고 12-Factor 앱은 “hours”로 줄이는 것을 목표로 하며, 개발자와 배포 담당자 분리, 로컬 SQLite/운영 MySQL 같은 도구 차이를 장애 원인으로 본다(출처: https://12factor.net/dev-prod-parity).
해결 메커니즘은 Clean Architecture의 의존성 경계와 닮았다. 비즈니스 코드는 특정 서버의 파일 경로, localhost DB, 로컬 세션 메모리, 로그 저장소를 직접 알지 않고, 실행 환경이 주입하는 계약만 의존한다. Config는 배포별 값을 환경변수로 빼고, Processes는 프로세스 메모리를 다음 요청의 저장소로 가정하지 않으며, Logs는 파일 관리 대신 stdout 이벤트 스트림을 내보낸다. 그래서 ECS에서 K8s로 옮길 때 바뀌어야 하는 것은 코드가 아니라 Task Definition, Secret, Service, 로그 라우터 같은 실행 환경의 결선이다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”비유: 레고 블록
섹션 제목: “비유: 레고 블록”12팩터 앱은 마치 레고처럼 설계된 앱이다. 블록 하나가 고장 나도 쉽게 교체할 수 있고, 어디서든 같은 방식으로 조립된다. ECS든 K8s든 어디에 올려도 동일하게 동작해야 한다.
원리: 12가지 원칙
섹션 제목: “원리: 12가지 원칙”| # | 원칙 | 요약 | ECS/K8s 적용 |
|---|---|---|---|
| I | Codebase | 하나의 코드베이스, 여러 환경 배포 | 하나의 Git 리포 → ECR 이미지 → dev/staging/prod |
| II | Dependencies | 의존성을 명시적으로 선언 | package.json + package-lock.json 고정 |
| III | Config | 설정은 환경변수로 분리 | ECS Task Definition env vars / K8s ConfigMap + Secret |
| IV | Backing services | DB, 캐시 등을 교체 가능한 리소스로 취급 | RDS URL을 환경변수로 → 다른 DB로 교체 가능 |
| V | Build, Release, Run | 빌드/릴리즈/실행 단계를 엄격히 분리 | CodePipeline: 빌드(Docker build) → 릴리즈(ECR push) → 실행(ECS deploy) |
| VI | Processes | 앱은 무상태(stateless) 프로세스로 실행 | ECS 컨테이너는 state 없음. 세션은 Redis로 |
| VII | Port binding | 포트 바인딩으로 서비스 노출 | Nest.js app.listen(3000) → ALB → ECS Task |
| VIII | Concurrency | 프로세스 모델로 수평 확장 | ECS desired count 증가, K8s replicas 증가 |
| IX | Disposability | 빠른 시작, 우아한 종료 | SIGTERM 핸들러 구현, graceful shutdown |
| X | Dev/prod parity | 개발/스테이징/운영 환경을 최대한 동일하게 | Docker Compose 로컬 = ECS 프로덕션과 동일 이미지 |
| XI | Logs | 로그를 이벤트 스트림으로 취급 | stdout 출력 → CloudWatch Logs / Datadog |
| XII | Admin processes | 관리 작업은 일회성 프로세스로 실행 | ECS Run Task로 마이그레이션 실행 |
실무에서 특히 중요한 3가지
섹션 제목: “실무에서 특히 중요한 3가지”① Config (III번): 환경변수로 설정 분리
// ❌ 잘못된 방법 - 코드에 직접 설정
// ✅ 올바른 방법 - 환경변수 사용const dbUrl = process.env.DATABASE_URL;
// Nest.js에서 ConfigModule 활용// app.module.tsimport { ConfigModule } from "@nestjs/config";
@Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: ".env", // 로컬 개발용 // 프로덕션에서는 ECS Task Definition이나 K8s Secret이 주입 }), ],})export class AppModule {}ECS Task Definition 환경변수 설정:{ "environment": [ { "name": "DATABASE_URL", "value": "mysql://..." }, { "name": "REDIS_URL", "value": "redis://..." }, { "name": "NODE_ENV", "value": "production" } ], "secrets": [ { "name": "JWT_SECRET", "valueFrom": "arn:aws:secretsmanager:..." } ]}Config의 결정 기준은 “환경변수를 쓰는가”보다 더 엄격하다. 공식 문서는 config 파일이 여러 위치·형식으로 흩어져 관리가 어려워지고, 환경 이름(development, staging, joes-staging)을 묶음으로 늘리는 방식은 deploy가 많아질수록 취약해진다고 설명한다(출처: https://12factor.net/config). 운영에서는 process.env.DB_HOST || 'localhost'처럼 기본값으로 계속 실행하는 코드보다, 필수 환경변수가 없으면 부팅 단계에서 실패하는 코드가 안전하다. 헬스체크가 DB를 보지 않는 서비스는 localhost fallback으로도 200 OK를 반환할 수 있어, 장애가 주문 API 같은 실제 DB 경로에서만 터지는 silent failure가 된다.
# ECS task definition에 필수 env/secret이 들어갔는지 배포 전 확인aws ecs describe-task-definition --task-definition my-app:42 \ --query 'taskDefinition.containerDefinitions[?name==`my-app`].[environment,secrets]'# 예상 출력: DATABASE_URL은 environment 또는 secrets 중 하나에 존재# 비어 있거나 NODE_ENV만 보이면 새 revision 등록 전 배포 중단② Logs (XI번): stdout으로 출력
// ❌ 잘못된 방법 - 파일에 로그 저장import * as fs from "fs";fs.appendFileSync("/var/log/app.log", `[ERROR] ${message}\n`);
// ✅ 올바른 방법 - stdout/stderr로 출력 (CloudWatch, Datadog이 수집)console.log( JSON.stringify({ level: "info", timestamp: new Date().toISOString(), message: "주문 생성 완료", orderId: "123", }),);
// Nest.js에서 커스텀 Loggerimport { Logger } from "@nestjs/common";
const logger = new Logger("OrdersService");logger.log(`주문 생성: ${orderId}`); // stdoutlogger.error(`결제 실패: ${error}`); // stderr로그를 stdout/stderr로 보내는 원칙은 웹 API뿐 아니라 워커와 일회성 관리 작업에도 같은 방식으로 적용된다. 12-Factor 공식 문서는 앱이 로그 파일의 저장·라우팅을 직접 관리하지 않고 실행 환경이 스트림을 수집해야 한다고 설명한다(출처: https://12factor.net/logs). 예를 들어 마이그레이션을 ECS Run Task로 실행했다면 성공/실패 근거는 컨테이너 내부 /var/log/migrate.log가 아니라 CloudWatch Logs의 같은 stream prefix에서 확인되어야 한다. 파일 로그만 남기면 task 종료 후 증거가 사라져 Admin processes(XII)의 “일회성 실행” 원칙과도 충돌한다.
③ Disposability (IX번): Graceful Shutdown
ECS는 배포 시 SIGTERM → 30초 대기 → SIGKILL을 보낸다. 이 30초 안에 진행 중인 요청을 마무리해야 한다.
async function bootstrap() { const app = await NestFactory.create(AppModule);
// Graceful Shutdown 활성화 app.enableShutdownHooks(); // SIGTERM 시 NestJS lifecycle hooks 호출
await app.listen(3000); console.log(`서버 시작: port 3000`);}
// orders.service.ts - OnModuleDestroy 구현import { OnModuleDestroy } from "@nestjs/common";
@Injectable()export class OrdersService implements OnModuleDestroy { async onModuleDestroy() { // SIGTERM 수신 시 실행 console.log("주문 서비스 종료 중... 진행 중인 작업 마무리"); // DB 커넥션 풀 종료, 메시지큐 연결 해제 등 }}ECS 배포 시 동작 (출처: AWS Containers 공식 블로그 "Graceful shutdowns with ECS"):1. ECS → 구 컨테이너에 SIGTERM 전송2. Nest.js의 enableShutdownHooks()가 감지3. onModuleDestroy() 실행 — AWS 권고: "in-flight 요청을 마무리하고 새 incoming 요청은 거부"4. 기본 30초 대기 (stopTimeout 미설정 시 SIGTERM↔SIGKILL 사이 30초 기본 지연)5. 30초(또는 stopTimeout 값) 후 SIGKILL로 강제 종료→ 새 컨테이너는 이미 RUNNING 상태로 트래픽 받고 있음
stopTimeout 기준: ECS StopTask는 기본 30초 후 SIGKILL을 보낸다(출처: https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_StopTask.html). Fargate launch type의 `stopTimeout` 유효 범위는 2~120초이고, 미설정 기본값은 30초다(출처: https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerDefinition.html). EC2 launch type은 task definition의 `stopTimeout` 또는 container agent의 `ECS_CONTAINER_STOP_TIMEOUT`을 보며, 둘 다 없으면 기본 30초다. K8s도 Pod 삭제의 기본 `terminationGracePeriodSeconds`가 30초이고, grace period가 끝나면 남은 프로세스에 SIGKILL을 보낸다(출처: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/).따라서 graceful shutdown은 “최대 120초 안에 끝나는 HTTP 요청”에는 맞지만, 예시 기준으로 결제 승인 후 외부 정산 API를 기다리는 3분짜리 동기 요청이나 10분짜리 리포트 생성에는 맞지 않는다. 그런 작업은 요청 스레드 안에서 버티게 하지 말고 큐 작업으로 넘긴 뒤 작업 id를 반환하거나, 중간 checkpoint를 저장해 재시작 가능하게 만든다. 반대로 팀 자체 측정에서 p95 요청 시간이 800ms이고 DB 트랜잭션 timeout이 5초라면 stopTimeout=30과 ALB deregistration delay를 맞추는 것만으로도 배포 중 502를 줄일 수 있다. 이 판단 기준은 ECS, K8s, Heroku dyno restart 모두에서 같은 “프로세스는 언제든 교체될 수 있다”는 원리로 작동한다.
📖 더 보기: Twelve-Factor App 공식 문서 (한국어) — 12가지 원칙의 원문 설명. 한국어 번역이 제공되어 접근성이 좋다 (입문)
📖 더 보기: Developing Twelve-Factor Apps using Amazon ECS (AWS 공식 블로그) — ECS/Fargate 환경에서 12팩터 원칙을 적용하는 AWS 공식 가이드 (중급)
12팩터 원칙 위반이 가장 자주 발생하는 패턴
섹션 제목: “12팩터 원칙 위반이 가장 자주 발생하는 패턴”현장에서 반복되는 위반 사례 세 가지와 그 결과다.
III (Config) — process.env.DB_HOST || 'localhost' → 프로덕션 ECS에 DATABASE_URL 환경변수가 누락되면 localhost로 연결 시도 → Error: ECONNREFUSED 127.0.0.1:5432 (가장 흔한 배포 직후 장애) → 수정: || 'fallback' 패턴 금지, 환경변수 누락 시 앱 시작 단계에서 즉시 실패
VI (Stateless) — 인메모리 세션 사용 → ECS 롤링 배포로 컨테이너가 교체되면 메모리의 세션 데이터 유실 → 사용자 강제 로그아웃, 장바구니 초기화 등 UX 장애 → 수정: 세션 스토어를 Redis(ElastiCache)로 이관
IX (Disposability) — SIGTERM 핸들러 미구현 → ECS 배포 시 SIGTERM → 즉시 프로세스 종료 → 처리 중인 요청 502로 중단 → 수정: app.enableShutdownHooks() + OnModuleDestroy 구현세 가지 중 하나라도 위반 시 로그가 ECONNREFUSED만 남아 환경변수 누락인지 네트워크 설정인지 구분이 어렵다 (소요 시간은 환경/팀별 편차가 크며, 출처 있는 측정값이 아닌 회고 기반 체감치). 사용자가 이미 5xx를 보고 있거나 세션 유실이 발생했다면 직전 정상 revision으로 먼저 롤백하고 원인 조사는 그 후에 진행한다. 반대로 누락된 환경변수 1개가 원인이고 새 이미지 없이 task definition만 고치면 되며, 현재 target health와 에러율이 안정적이라면 새 revision 등록 후 롤링 배포가 더 작다.
# ECS — 직전 정상 task definition revision으로 롤백aws ecs describe-services --cluster prod --services my-app \ --query 'services[0].deployments[?status==`PRIMARY`].taskDefinition' # 현재 revision 확인aws ecs update-service --cluster prod --service my-app \ --task-definition my-app:42 # 직전 정상 revision 번호 지정aws ecs wait services-stable --cluster prod --services my-app # 완료 대기 (대개 1~3분)# 예상 출력: 새 PRIMARY deployment가 RUNNING으로 전환, ALB target health 5xx 비율 0% 복귀
# Kubernetes — 직전 revision으로 즉시 복귀 (인자 없으면 직전 revision)kubectl rollout undo deployment/my-appkubectl rollout status deployment/my-app
# 환경변수 누락 의심 시 — 컨테이너 진입해 직접 확인aws ecs execute-command --cluster prod --task <task-arn> \ --container my-app --interactive --command "sh -c 'echo \$DATABASE_URL'"# 누락이면 빈 줄, 정상이면 mysql://... 형태
# K8s — 실행 중인 Pod의 주입값 확인kubectl exec deploy/my-app -- printenv DATABASE_URL# 예상 출력: postgresql://... 또는 mysql://...# 빈 줄이면 Secret/ConfigMap 매핑 또는 Deployment envFrom 누락 의심다음 단계: 롤백 완료 후 원인을 특정하면 새 task definition revision을 등록하고 재배포한다. 검증은 ALB target group health, 5xx 비율, 사용자 세션 유실 여부(VI 위반의 경우)를 본다.
4. 자주 헷갈리는 개념 비교
섹션 제목: “4. 자주 헷갈리는 개념 비교”| A | B | 차이점 |
|---|---|---|
| 12-Factor | 15-Factor (Beyond) | 15-Factor(Kevin Hoffman)는 12-Factor 확장. API First, Telemetry, Authentication & Authorization 추가. cloud-native 강조 |
| Stateless app | Stateful app | Stateless는 프로세스에 상태가 없음 . 세션·캐시는 외부(Redis). Stateful은 디스크 의존(DB·메시지큐 인프라 측). 12-Factor 앱은 Stateless가 원칙 |
| SIGTERM | SIGKILL | SIGTERM은 요청 종료 신호 (handler 처리 가능). SIGKILL은 즉시 강제 종료 (handler 불가). ECS는 SIGTERM → grace period → SIGKILL |
| ConfigMap | Secret | ConfigMap은 평문 설정 . Secret은 민감 정보 (base64 encode + RBAC 보호). AWS는 Parameter Store/Secrets Manager로 분리 |
process.env | dotenv/.env | process.env는 OS 레벨 환경변수 . dotenv는 .env 파일을 읽어 process.env에 주입하는 라이브러리 . 프로덕션에서 dotenv 의존은 안티패턴 |
| Build/Release/Run | CI/CD 파이프라인 | Build=코드→빌드 아티팩트, Release=설정+아티팩트 결합, Run=런타임 실행. CI/CD는 이 3단계를 자동화한 구현 수단 |
5. 체크리스트
섹션 제목: “5. 체크리스트”- III(Config) 위반 —
process.env.DB_HOST \|\| 'localhost'패턴이 왜 안티패턴인지 설명할 수 있는가? - VI(Stateless) — 인메모리 세션을 Redis로 옮길 때 무엇이 달라지는지 ECS 롤링 배포 시나리오로 설명할 수 있는가?
- IX(Disposability) — NestJS
app.enableShutdownHooks()+OnModuleDestroy의 동작 단계를 SIGTERM → SIGKILL 흐름으로 설명할 수 있는가? - stopTimeout과 ALB
deregistration_delay의 정합성이 왜 중요한가? - ECS / K8s 둘 다에서 직전 revision으로 롤백하는 명령을 외울 수 있는가?
6. 5줄 요약
섹션 제목: “6. 5줄 요약”- 12-Factor는 cloud-native 운영 표준: 한 앱이 ECS/K8s/Heroku 어디에 올라도 같은 방식으로 동작하게 만드는 12원칙.
- III/VI/IX가 배포 직후 장애의 핵심 축: Config 누락(ECONNREFUSED), 인메모리 세션(롤링 배포 시 강제 로그아웃), SIGTERM 미처리(배포 시 502).
- SIGTERM 30초 grace period: ECS 기본값. NestJS
enableShutdownHooks()+OnModuleDestroy로 in-flight 요청 마무리. \|\| 'fallback'패턴 금지: 환경변수 누락 시 시작 단계에서 즉시 실패 (Fail Fast).- 장애 시 롤백 우선: 원인 분석보다 직전 정상 revision으로 즉시 복귀 (
aws ecs update-service --task-definition my-app:42/kubectl rollout undo).