I. Codebase
하나의 앱을 추적하는 정본은 무엇인가? 하나의 버전 관리 코드베이스다.
같은 커밋에서 개발·스테이징·운영 배포를 만든다.분류: Layer 9 - 아키텍처 & 설계 패턴
Twelve-Factor App은 서비스형 소프트웨어(Software as a Service, SaaS)를 어느 실행 환경에서도 반복해서 빌드·배포·교체·확장할 수 있게 만드는 12가지 설계 원칙이다. 핵심은 특정 클라우드 제품을 쓰는 데 있지 않다. 코드와 배포별 설정을 분리하고, 한 번 만든 릴리스를 변경하지 않으며, 실행 프로세스를 교체 가능한 단위로 만들고, 로그 수집 같은 운영 기능을 실행 환경에 맡기는 데 있다.
이 원칙은 애플리케이션 내부의 비즈니스 규칙을 보호하는 Clean Architecture와 범위가 다르다. Clean Architecture가 “정책 코드가 어떤 기술을 import하는가”를 묻는다면, Twelve-Factor는 “같은 코드가 다른 배포 환경에서 어떻게 구성되고 실행되는가”를 묻는다.
관련 읽기: Clean Architecture는 애플리케이션 내부의 의존성 방향을 다루며, 배포·실행 경계를 다루는 Twelve-Factor와 비교해 볼 수 있다. 컨테이너와 배포 구현 예시가 낯설다면 Secrets Management, Docker Basics, CI/CD Basics, Kubernetes Basics, CloudWatch Basics, Redis를 활용한 복원력 패턴을 선택 관련 읽기로 참고할 수 있다. 이 문서의 ECS와 NestJS 코드는 원칙을 구체화하는 선택적 구현 예시이지 필수 선수지식이 아니다. 다음 문서인 분산 시스템 기초와 MSA 패턴에서는 여기서 만든 교체 가능한 프로세스가 네트워크와 서비스 경계를 넘어설 때 생기는 일관성·장애 문제를 다룬다.
전통적인 서버 배포에서는 서버 한 대가 애플리케이션의 일부처럼 취급되기 쉬웠다. 운영자가 서버에 접속해 설정 파일을 고치고, 런타임에 라이브러리를 설치하고, 애플리케이션이 로컬 디스크에 세션과 로그를 남겼다. 그러면 코드 저장소의 같은 커밋도 어느 서버에서 실행하느냐에 따라 다른 프로그램처럼 동작한다.
대표적인 실패 흐름은 다음과 같다.
.env와 로컬 Redis가 있으므로 주문 API가 정상 동작한다.DATABASE_URL이 빠졌지만 코드가 localhost를 기본값으로 사용한다.ECONNREFUSED 127.0.0.1:5432가 발생한다.문제는 컨테이너나 클라우드가 없어서가 아니다. 빌드 결과, 설정, 상태, 수명 주기의 소유자가 불명확한 것이 원인이다. Twelve-Factor는 이 소유권을 다음 세 방향으로 정리한다.
공식 문서는 Heroku에서 수백 개 앱을 직접 배포하고 수십만 개 앱을 관찰한 경험을 배경으로 설명한다. 이 맥락은 원칙의 출발점을 알려 주지만, Heroku 사용법을 외우는 것이 학습 목표는 아니다. 오늘날의 컨테이너 이미지, Kubernetes Pod, Amazon Elastic Container Service(ECS) Task도 같은 소유권 문제를 다른 도구로 구현한다.
열두 항목을 독립 체크리스트로 외우면 서로의 관계가 보이지 않는다. 다음 표는 각 원칙이 어떤 운영 질문에 답하는지 묶은 것이다.
하나의 앱을 추적하는 정본은 무엇인가? 하나의 버전 관리 코드베이스다.
같은 커밋에서 개발·스테이징·운영 배포를 만든다.실행에 필요한 라이브러리는 무엇인가? 코드가 명시적으로 선언하고 격리한다.
잠금 파일과 이미지로 빌드 입력을 재현한다.배포마다 달라지는 값은 어디에 있는가? 코드와 분리해 실행 환경이 주입한다.
DB 주소·자격 증명·호스트명은 릴리스 설정으로 관리한다.DB·캐시·큐는 어떻게 연결되는가? 설정으로 붙이고 떼는 리소스로 취급한다.
코드 수정 없이 리소스 핸들을 바꿔 복구·이관한다.코드가 실행되기까지 어떤 불변 단계를 거치는가? 빌드·릴리스·실행을 분리한다.
빌드 결과와 설정을 결합한 고유 릴리스를 실행하고 이전 릴리스로 되돌린다.요청 사이의 영속 상태는 어디에 있는가? 실행 프로세스 밖의 저장소에 둔다.
세션·업로드·작업 checkpoint를 DB, 객체 저장소, 캐시에 둔다.서비스는 실행 환경에 어떻게 노출되는가? 스스로 포트에 바인딩한다.
라우터나 로드 밸런서가 앱의 리스닝 포트로 트래픽을 전달한다.부하를 어떻게 나누는가? 프로세스 유형별 인스턴스 수를 늘린다.
web과 worker를 서로 다른 병목에 맞춰 독립 확장한다.프로세스 교체는 얼마나 안전한가? 빠르게 시작하고 정상 종료한다.
새 요청을 끊고 진행 중인 일을 제한 시간 안에 마친다.개발과 운영의 차이가 버그를 숨기는가? 시간·사람·도구의 차이를 줄인다.
같은 빌드 결과와 같은 종류의 backing service로 자주 배포한다.로그의 저장·라우팅 책임은 누구에게 있는가? 앱은 이벤트를 내보내고 환경이 수집한다.
stdout/stderr를 로그 플랫폼으로 전달한다.마이그레이션 같은 일회성 작업은 무엇을 실행하는가? 같은 릴리스와 설정을 사용한다.
배포된 이미지로 별도 one-off task를 실행하고 결과를 기록한다.Codebase(코드베이스) 원칙의 “하나”는 회사 전체를 반드시 모노레포로 만들라는 뜻이 아니다. 하나의 배포 가능한 앱에 정본 코드베이스가 하나 있어야 한다는 뜻이다. 같은 코드베이스에서 여러 deploy(실행 환경별 배포)가 나올 수 있지만, 서로 다른 제품을 조건문으로 억지로 합치거나 운영 서버에서만 코드를 고쳐 별도 변종을 만들면 추적 가능성이 깨진다.
Dependencies(의존성) 원칙은 운영 서버에 우연히 설치된 패키지를 믿지 말라는 뜻이다.
Node.js라면 package.json이 필요한 패키지를 선언하고 잠금 파일이 해석 결과를 고정한다.
컨테이너 이미지는 OS 패키지와 런타임까지 격리할 수 있다. 다만 잠금 파일만 있다고 완전한
재현성이 생기지는 않는다. 베이스 이미지 태그가 가변이거나 빌드 중 외부 파일을 최신 버전으로
내려받으면 같은 커밋도 다른 결과가 될 수 있다.
실패 신호는 명확하다.
latest 이미지 태그가 가리키는 내용이 바뀌어 이전 릴리스를 재실행할 수 없다.Config(설정) 는 배포마다 달라질 수 있는 값이다. 데이터베이스 주소, 외부 서비스 자격 증명, 공개 호스트명은 config다. 반면 라우팅 구조나 모듈 결선처럼 애플리케이션 자체의 동작을 정의하는 코드는 config가 아니다. 모든 상수를 환경 변수로 옮기는 것이 목표가 아니다.
Config의 또 다른 핵심은 직교성(orthogonality), 즉 각 값을 다른 값과 독립적으로 바꾸고
배포할 수 있는 성질이다. development, staging, production, joes-staging처럼 이름 붙인
환경별 설정 묶음 하나를 선택하는 방식은 배포가 늘수록 조합이 폭증하고 예외가 묶음 전체의
복제로 번진다. 예를 들어 결제 API 주소만 다른 임시 검증 배포를 만들기 위해 staging-v2라는
새 묶음을 복사하면, DB 주소와 로그 레벨까지 어느 묶음이 정본인지 추적하기 어려워진다.
대신 DATABASE_URL, PAYMENT_API_URL, LOG_LEVEL을 각각 독립 값으로 관리하고 배포가 필요한
값을 명시적으로 조합한다.
그렇다고 모든 문자열을 무조건 한 칸씩 쪼개라는 뜻은 아니다. 함께 바뀌어야만 유효한 인증서와
개인 키, 하나의 연결 문자열을 이루는 DB 접속 정보, 동일한 소유자·접근 권한·회전 주기를 가진
secret 문서는 원자적 단위로 묶을 수 있다. 합리적인 그룹 경계는 development 같은 포괄적 환경
이름이 아니라 함께 변경되어야 하는 불변식과 같은 보안·수명 주기다. 그룹 안에서도 필요한
키와 형식은 시작 시 검증하고, 무관한 설정까지 함께 배포하게 만드는 거대 번들은 피한다.
function requiredEnv(name: string): string { const value = process.env[name];
if (typeof value !== "string" || value.trim() === "") { throw new Error(`필수 환경변수 ${name}가 설정되지 않았습니다`); }
return value.trim();}
function parseDatabaseUrl(value: unknown): string { if (typeof value !== "string" || value.trim() === "") { throw new Error("DATABASE_URL is required"); }
const normalized = value.trim(); let parsed: URL;
try { parsed = new URL(normalized); } catch { // 오류에 원문을 넣으면 사용자명과 비밀번호가 로그에 노출될 수 있다. throw new Error("DATABASE_URL must be a valid database URL"); }
const supportedProtocols = new Set(["postgres:", "postgresql:", "mysql:"]); if (!supportedProtocols.has(parsed.protocol) || parsed.hostname === "") { throw new Error("DATABASE_URL must use a supported protocol and hostname"); }
return normalized;}
const databaseUrl = parseDatabaseUrl(requiredEnv("DATABASE_URL"));이 예제의 핵심은 process.env 자체가 아니라 누락을 시작 시점에 드러내는 것이다.
process.env.DB_HOST || "localhost"는 로컬 개발에는 편하지만 운영 누락을 정상 부팅으로
바꾼다. 헬스체크가 DB 경로를 검사하지 않으면 배포는 성공해 보이고 실제 요청에서만 실패한다.
NestJS에서는 같은 검증을 ConfigModule의 부팅 과정에 넣을 수 있다.
import { Module } from "@nestjs/common";import { ConfigModule } from "@nestjs/config";
@Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, validate(config: Record<string, unknown>) { config.DATABASE_URL = parseDatabaseUrl(config.DATABASE_URL);
return config; }, }), ],})export class AppModule {}로컬에서 .env 파일을 읽더라도 운영에서는 ECS Task Definition이나 Kubernetes Secret이
같은 설정 계약을 주입할 수 있다. 중요한 것은 어느 주입 방식을 쓰든 애플리케이션이 요구하는
키와 형식을 시작 시점에 검증하는 것이다.
.env는 파일 내용을 process.env로 옮기는 개발 편의 수단이지 별도의 설정 모델이 아니다.
운영에서 .env를 쓸 수 없다는 절대 규칙보다, 파일이 이미지나 저장소에 섞이지 않고 접근 권한과
회전 정책이 관리되는지를 본다. Kubernetes에서는 일반 설정에 ConfigMap, 민감 값에 Secret을
구분해 쓸 수 있지만 Secret의 base64 표현은 암호화가 아니다. 접근 제어와 저장 시 암호화를
별도로 설정해야 한다.
원래 Twelve-Factor는 환경 변수(environment variable)를 구체적 해법으로 권한다. 현대 플랫폼에서는 암호화된 secret volume, 파일 마운트, secret manager sidecar(주 애플리케이션 옆에서 비밀 주입·갱신 같은 보조 책임을 수행하는 별도 프로세스 또는 컨테이너)처럼 환경 변수 외 전달 방식도 사용한다. 중요한 의도는 다음 세 가지다.
따라서 환경 변수만 고집해 큰 인증서나 자동 회전 자격 증명을 억지로 넣는 것은 원칙의 문자에는 가깝지만 의도에는 멀 수 있다. 전달 방식보다 코드와 설정의 분리, 최소 권한, 변경 이력을 기준으로 판단한다.
Backing service(지원 서비스) 는 애플리케이션이 네트워크로 사용하는 데이터베이스,
캐시, 메시지 큐, 메일 전송 서비스, 객체 저장소 등을 뜻한다. “attached resource(연결된 리소스)“로
취급한다는 말은 모두 같은 제품으로 바꿀 수 있다는 뜻이 아니다. 코드가 prod-db-01 같은
서버 정체성을 하드코딩하지 않고, 설정으로 받은 리소스 핸들에 연결한다는 뜻이다.
예를 들어 읽기 전용 리포트 DB를 교체할 때 연결 문자열만 바꿔도 되는 구조는 교체 가능하다. 그러나 MySQL을 PostgreSQL로 바꾸면 SQL 방언, 격리 수준, 데이터 타입이 달라질 수 있다. Twelve-Factor가 도메인 의미나 데이터 마이그레이션 비용까지 제거해 주지는 않는다. 이 경계는 Clean Architecture의 repository adapter와 함께 볼 때 선명해진다.
선택 기준은 “몇 초 만에 제품을 바꿀 수 있는가”가 아니다.
아티팩트(artifact, 빌드 산출물) 는 소스 코드와 고정된 빌드 입력으로 만든 실행 가능한 결과물이다. 컨테이너 이미지, 컴파일된 바이너리, 배포용 패키지가 예이며, 같은 아티팩트를 환경별 설정과 결합해 여러 릴리스로 승격할 수 있어야 한다.
이미지 digest(다이제스트) 는 컨테이너 이미지 내용으로 계산한 sha256:... 식별자다.
내용이 바뀌면 digest도 바뀌므로, 같은 이름이 다른 이미지를 가리킬 수 있는 latest 같은 태그보다
어느 빌드 결과를 실행했는지 정확히 고정하고 확인하는 데 적합하다.
세 단계는 다음처럼 구분한다.
Git commit abc123 │ ├─ Build B42: 소스 + 고정 의존성 -> image digest sha256:7f... │ ├─ Release R107: B42 + prod config version C19 │ └─ Run: R107로 web 3개, worker 5개 실행Build/Release/Run은 결과물과 책임을 나누는 개념적 분리다. CI/CD(Continuous Integration/Continuous Delivery 또는 Deployment) 파이프라인은 테스트, 이미지 빌드, 릴리스 등록, 배포를 자동화해 이 분리를 구현할 수 있는 메커니즘이다. 따라서 둘은 동의어가 아니다. 수동 절차도 세 결과를 구분할 수 있고, 반대로 자동 파이프라인도 실행 시 패키지를 다시 설치하거나 기존 릴리스를 덮어쓰면 이 원칙을 어길 수 있다.
이 구분이 있으면 코드 변경 없이 설정 C20만 바꿔도 R108이라는 새 릴리스가 된다. 문제가 생기면 “이미지를 다시 빌드해 비슷하게 복원”하는 대신 R107을 다시 실행할 수 있다. 릴리스는 고유 ID를 갖고 변경 불가능한 기록으로 남아야 한다.
반례는 운영 컨테이너 안에서 파일을 직접 수정하는 것이다. 당장은 복구된 것처럼 보여도 수정 내용은 다음 재시작에 사라지고 어느 빌드에도 속하지 않는다. 또 앱 시작 시점에 패키지를 설치하거나 프런트 자산을 컴파일하면 야간 자동 재시작이 빌드 서버 역할까지 떠안는다. 실행 단계는 외부 네트워크와 도구에 덜 의존할수록 예측 가능하다.
Stateless process(무상태 프로세스) 는 요청 사이에 필요한 영속 상태를 자신의 메모리나 로컬 파일시스템에만 보관하지 않는 프로세스다. 주문을 처리하는 동안 메모리에 지역 변수를 사용하는 것은 괜찮다. 문제는 다음 요청이나 재시작 이후에도 필요한 세션, 업로드 파일, 작업 진행 상태를 그 메모리가 계속 가지고 있을 것이라고 가정하는 것이다.
인메모리 세션을 가진 web 프로세스 A와 B를 생각해 보자.
로그인 요청 -> A 메모리에 session-123 저장다음 조회 -> B로 라우팅 -> session-123 없음 -> 강제 로그아웃롤링 배포 -> A 종료 -> session-123 영구 유실sticky session으로 다음 요청을 A에 고정하면 첫 문제는 가릴 수 있지만 A가 종료될 때의 유실은 해결하지 못한다. Redis 같은 외부 저장소로 세션을 옮기면 A와 B가 같은 상태를 읽고 프로세스 교체와 상태 수명이 분리된다. 대신 네트워크 지연, Redis 장애, 만료 정책이라는 새 비용이 생긴다. “무상태가 항상 더 싸다”가 아니라 프로세스를 잃어도 복구할 수 있는가가 판단 기준이다.
Port binding(포트 바인딩) 은 앱이 자체 서버 프로세스로 특정 포트에 리스닝하고,
실행 환경은 그 포트로 트래픽을 전달한다는 계약이다. NestJS의 app.listen(port)가 앱의
책임이고, Amazon Application Load Balancer(ALB)나 Kubernetes Service가 외부 주소를
그 포트에 연결하는 것은 환경의 책임이다.
const port = Number(requiredEnv("PORT"));await app.listen(port, "0.0.0.0");이 원칙은 모든 워크로드가 HTTP 포트를 열어야 한다는 뜻이 아니다. 큐 메시지만 소비하는 worker, 예약된 batch, 일회성 migration은 수신 포트가 없어도 된다. 또한 TLS(Transport Layer Security) 종료, 인증, rate limit을 반드시 앱 안에서 해야 한다는 뜻도 아니다. 앱이 제공하는 서비스와 라우팅 계층의 책임을 작은 계약으로 분리하는 것이 핵심이다.
실패 신호는 앱이 컨테이너 안에서 127.0.0.1에만 바인딩해 외부 라우터가 접근하지 못하거나,
코드에 고정한 포트와 실행 환경이 기대하는 포트가 다른 경우다. 반대로 외부 웹 서버가 앱 코드를
플러그인처럼 주입해야만 실행되는 구조는 앱의 독립 실행·테스트를 어렵게 한다.
Concurrency(동시성) 원칙은 한 거대한 프로세스에 모든 역할을 넣기보다 web, worker, scheduler처럼 작업 성격별 process type(프로세스 유형)을 정의하고 각 수를 독립 조절하라는 뜻이다. 컨테이너 오케스트레이터의 replica(레플리카) 는 같은 릴리스와 프로세스 유형을 실행하는 복제 인스턴스 하나이며, replica 수를 조절하는 것은 이 의도를 구현하는 한 방식이다.
작은 용량 계산을 해 보자.
| 프로세스 유형 | 관측 부하 | 인스턴스 하나의 안전 처리량 | 최소 계산 | 운영 선택 |
|---|---|---|---|---|
| web | 초당 240 요청 | 초당 40 요청 | 6개 | 여유 포함 8개 |
| worker | 분당 600 작업 | 분당 90 작업 | 6.7개 | 올림하여 7개 이상 |
web의 최소값은 240 / 40 = 6이다. 여기서는 정상 처리에 필요한 N=6에 N+2 headroom을 둔다고
가정해 8개를 선택했다. 한 replica가 장애로 빠지고 다른 하나가 롤링 배포 중 준비되지 않아도 남은
6개가 초당 240 요청을 감당한다는 가정이다. 정상 시 총 안전 처리량은 초당 320 요청이라 약 33%의
여유가 있지만, 갑작스러운 부하 증가까지 자동으로 보장하는 숫자는 아니다. 실제 선택에서는 장애
동시성, 배포 전략, 트래픽 변동 폭을 관측해 headroom 가정을 다시 세워야 한다.
web 요청이 두 배가 되어도 worker backlog(처리되지 않고 쌓인 작업량)가 안정적이면 web만 늘리면 된다. 반대로 요청 수는 같지만 queue backlog가 증가하면 worker를 늘린다. 이 분리가 없으면 web 트래픽 때문에 전체 앱을 복제하면서 불필요한 scheduler도 여러 개 실행하거나, worker 병목을 web replica로 해결하려 한다.
다만 replica 수만 늘린다고 선형 확장되지는 않는다. DB connection pool(DB 연결을 미리 열어 재사용하는 묶음)이 프로세스당 20개라면 web을 8개에서 40개로 늘릴 때 최대 연결은 160개에서 800개로 증가한다. DB 한도가 500개라면 애플리케이션 CPU보다 DB 연결이 먼저 병목이 된다. 동시성 원칙은 상태 분리와 역할 분리를 제공하지만, 공유 backing service의 용량 계획까지 대신하지 않는다.
Disposability(폐기 가능성) 는 프로세스를 언제든 시작하거나 중단해도 시스템이 회복 가능해야 한다는 뜻이다. 빠른 시작은 장애 대체와 scale-out 시간을 줄이고, graceful shutdown(정상 종료)은 새 작업을 받지 않으면서 진행 중인 작업을 마칠 기회를 준다.
readiness(준비 상태) 는 프로세스가 지금 새 트래픽을 받아도 되는지를 실행 환경에 알리는 신호다. 프로세스가 살아 있다는 뜻과는 다르며, 종료를 시작할 때 준비 상태를 먼저 내려 라우팅 대상에서 제외하는 데 사용한다.
정상 종료의 설계 목표는 다음과 같다. 이것은 모든 플랫폼이 보장하는 보편적 이벤트 순서가 아니라, 종료 뒤에도 새 작업이 유입되지 않고 이미 받은 작업에는 마무리 기회가 생기게 하려는 목표 상태다.
목표 상태 readiness에서 제거되어 로드 밸런서의 새 트래픽 전달이 멈춘다.목표 상태 프로세스가 SIGTERM(종료 요청 신호)을 인지하고 새 작업 접수를 멈춘다.목표 상태 in-flight 요청을 마치고 queue polling, DB·메시지 큐 연결을 정리한다.목표 상태 유예 시간이 끝나기 전에 종료하며, 넘기면 플랫폼이 강제 종료할 수 있다.실제로는 readiness 제거, 로드 밸런서 deregistration(대상 등록 해제), 종료 신호가 서로 비동기적으로 진행되어 경합할 수 있고 플랫폼·컨트롤러·배포 전략에 따라 순서도 달라진다. SIGTERM을 먼저 받은 뒤에도 이미 전달 중인 요청이 도착할 수 있고, readiness가 내려가도 로드 밸런서의 연결 draining 동안 기존 연결이 남을 수 있다. 따라서 문서 속 순서를 가정하지 말고 실제 readiness 제거 시점, 로드 밸런서 등록 해제와 연결 draining 동작, 프로세스에 전달되는 종료 신호, 유예 시간 설정이 배포 환경에서 어떻게 맞물리는지 관찰하고 검증해야 한다.
SIGTERM은 애플리케이션이 처리할 수 있지만 SIGKILL은 처리할 수 없다. Amazon ECS의 기본 종료
대기 시간은 30초이며, Fargate는 서버 인스턴스를 직접 관리하지 않고 ECS task에 필요한
컴퓨팅 자원을 실행하는 AWS의 관리형 실행 방식이다. Fargate의 stopTimeout은 2~120초 범위다.
Kubernetes Pod의 기본 terminationGracePeriodSeconds도 30초다. 이 숫자는 영원한 표준이 아니라
현재 플랫폼 기본값이므로 실제 환경 설정을 확인해야 한다.
async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableShutdownHooks(); await app.listen(3000);}
@Injectable()export class OrdersService implements OnModuleDestroy, OnApplicationShutdown { async onModuleDestroy() { await this.queueConsumer.stopPolling(); }
async onApplicationShutdown() { await this.database.close(); }}NestJS의 문서상 종료 순서는 onModuleDestroy() → beforeApplicationShutdown() → HTTP 연결
종료 → onApplicationShutdown()이다. 따라서 앞선 hook은 queue polling과 새 작업 접수만 멈추고,
진행 중인 HTTP 요청이 사용할 DB 연결은 HTTP drain 뒤의 onApplicationShutdown()에서 닫는다.
다만 NestJS 버전, HTTP adapter, DB 라이브러리의 자체 종료 hook에 따라 실제 동작이 달라질 수
있으므로, 대상 조합에서 hook 호출 순서와 진행 중인 요청 완료 여부를 통합 테스트로 검증해야 한다.
이 코드는 출발점일 뿐이다. readiness가 실제 트래픽 대상 제거와 연결되지 않았거나, 로드 밸런서가 계속 새 요청을 보내거나, 종료 hook이 새 작업 접수를 막지 않거나, Node.js 프로세스가 컨테이너의 PID 1(컨테이너 내부에서 처음 시작되어 종료 신호를 직접 받는 프로세스) 이 아니고 앞단 프로세스가 신호를 전달하지 않으면 hook만 있어도 요청은 끊긴다. 정상 종료는 readiness 제거, 로드 밸런서 등록 해제, 애플리케이션 drain, 종료 신호, 플랫폼 유예 시간의 합동 계약이다.
팀 측정에서 요청 시간의 p95(요청 95%가 이 값 이하로 끝나는 백분위)가 800ms이고 DB transaction timeout이 5초라면 30초 유예 시간은 대부분의 HTTP 요청을 마치기에 충분하다. 이때 ALB deregistration delay, readiness 반영 지연, 종료 신호와 앱 drain의 실제 겹침을 측정해 유예 시간 안에 들어오는지 확인해야 한다. 반면 외부 정산 API를 기다리는 3분 동기 요청이나 10분 리포트 생성은 Fargate 최대 120초 안에도 안전하게 끝나지 않는다. 이런 작업은 HTTP 요청 안에서 버티기보다 queue로 넘겨 작업 ID를 반환하고, 재시도 가능하거나 checkpoint에서 재개할 수 있게 만든다.
worker가 메시지를 처리하다 종료될 때는 ack(처리 완료 확인)를 너무 일찍 보내지 않아야 한다. 먼저 ack한 뒤 강제 종료되면 작업이 유실된다. 완료 후 ack하면 강제 종료 뒤 메시지가 다시 전달될 수 있으므로 handler는 idempotent(같은 작업을 여러 번 적용해도 결과가 같음)해야 한다.
Dev/prod parity(개발·운영 동등성) 는 개발 환경을 운영과 비트 단위로 복제하라는 뜻이 아니다. 공식 원칙은 세 차이를 줄이는 데 초점을 둔다.
전통적 앱의 배포 간격을 weeks, Twelve-Factor 앱의 목표를 hours로 설명한 원문은 2010년대 초의 대비다. 오늘날 중요한 판단은 특정 시간 숫자가 아니라 변경 피드백이 짧고 환경 차이가 명시적인가다. 노트북과 운영 클러스터의 크기·고가용성·보안 경계는 당연히 다를 수 있다. 같은 이미지, 같은 데이터베이스 엔진의 호환 버전, 같은 migration을 사용하고 차이를 설정으로 드러내면 된다.
반례는 “Docker를 쓰므로 parity를 달성했다”는 주장이다. 로컬 이미지와 운영 이미지가 다르거나, 로컬은 동기식 fake queue를 쓰고 운영은 중복 전달 가능한 실제 queue를 쓰면 컨테이너 형식이 같아도 실패 의미는 다르다. 모든 관리형 서비스를 로컬에 복제하기 어렵다면 contract test(서비스 경계의 요청·응답 형식과 오류 의미가 합의한 계약을 지키는지 자동 검증하는 테스트)와 짧은 staging 검증으로 차이를 의식적으로 관리한다.
Logs as event streams(이벤트 스트림으로서의 로그) 원칙은 애플리케이션이 로그 파일의 회전, 보관, 전송을 직접 관리하지 말고 실행 중 발생한 이벤트를 출력하라는 뜻이다.
// 로컬 파일에 직접 저장하면 프로세스와 로그 수명이 결합된다.import * as fs from "node:fs";import { Logger } from "@nestjs/common";
fs.appendFileSync("/var/log/app.log", `[ERROR] ${message}\n`);
// 구조화 이벤트를 stdout/stderr로 보낸다.console.log( JSON.stringify({ level: "info", timestamp: new Date().toISOString(), message: "주문 생성 완료", orderId: "123", releaseId: "R107", }),);
// NestJS Logger도 기본적으로 같은 프로세스 출력 스트림을 사용한다.const logger = new Logger("OrdersService");logger.log(`주문 생성: ${orderId}`);컨테이너가 종료되면 로컬 파일도 사라질 수 있지만 stdout은 ECS log driver나 Kubernetes의 수집 계층이 CloudWatch Logs, Datadog 같은 목적지로 전달할 수 있다. web, worker, 일회성 migration이 같은 방식으로 이벤트를 내보내면 프로세스 유형이 달라도 한 릴리스의 실행 근거를 모을 수 있다.
그러나 stdout만 사용하면 관측 가능성(observability)이 완성되는 것은 아니다. 구조화 필드, request ID 또는 trace ID, release ID, 민감 정보 제거, 수집기의 backpressure(생산 속도가 소비 속도를 넘을 때 입력을 늦추거나 버퍼·제한해 과부하를 전파하지 않는 제어)와 보존 정책은 별도 설계가 필요하다. 초당 로그가 많을 때 동기 출력이 event loop를 막거나 수집 비용이 폭증할 수도 있다. 앱과 로그 저장소의 결합을 끊는 원칙이지, 로그 품질과 비용을 무시하라는 원칙은 아니다.
파일 로그만 남긴 일회성 migration이 실패하면 task 종료와 함께 증거가 사라진다. 반대로 중앙
로그에서 releaseId=R107, processType=migration을 찾을 수 있으면 어느 코드와 설정으로
무슨 작업을 했는지 추적할 수 있다. 이는 Logs와 Admin processes가 연결되는 지점이다.
Admin process(관리 프로세스) 는 DB migration, 데이터 보정, 콘솔 검사처럼 평소 계속 실행되지 않는 일회성 작업이다. Twelve-Factor는 이런 작업도 web 프로세스와 같은 코드베이스, 같은 의존성, 같은 설정을 가진 릴리스에서 실행하라고 한다.
나쁜 흐름: 개인 노트북의 최신 main + 개인 환경 변수 -> 운영 DB 직접 수정좋은 흐름: Release R107 이미지 + R107 운영 설정 -> one-off migration task좋은 흐름은 “누가 어느 릴리스의 어떤 명령을 실행했는가”를 남기기 쉽고, 개발자 노트북의 라이브러리 버전 차이를 제거한다. ECS Run Task나 Kubernetes Job은 현대적 구현 예다.
다만 migration을 앱 시작 때마다 자동 실행하는 방식은 Build-Release-Run 경계를 흐릴 수 있다. web replica 20개가 동시에 같은 migration을 시도하거나, 오래 걸리는 DDL(Data Definition Language, 테이블 구조 변경)이 모든 새 인스턴스의 시작을 막을 수 있기 때문이다. migration은 같은 릴리스 아티팩트를 사용하되, 동시 실행 제어·재실행 안전성·실패 시 중단 조건을 가진 별도 일회성 단계로 두는 편이 예측 가능하다.
주문 API에 새 할인 규칙을 배포한다고 가정하자.
abc123이 변경의 정본이다.releaseId=R107을 포함한 이벤트를 stdout으로 보낸다.여기서 결제 자격 증명 C20이 잘못되어 장애가 나면 이미지를 다시 만들 필요가 없다. 설정을 고쳐 R108을 만들거나 R106/R107 중 검증된 릴리스를 다시 실행한다. 반대로 B42 코드가 문제라면 컨테이너 안에서 수정하지 않고 이전 불변 빌드의 릴리스로 돌아간다. 이 차이를 구분할 수 있다는 것이 Build-Release-Run의 실질적 가치다.
원문은 2011년에 공개되었고 공식 사이트 표기는 2017년 마지막 업데이트다. 따라서 원칙의 의도와 당시의 구체 처방을 분리해서 읽어야 한다.
| 원칙의 지속적인 의도 | 원문의 대표 처방 | 현대의 가능한 구현 |
|---|---|---|
| 배포별 설정을 코드와 분리한다 | 환경 변수 | env, secret volume, secret manager 연동 |
| 의존성과 실행 입력을 명시·격리한다 | 언어별 dependency manifest | lockfile, digest로 고정한 불변 container image |
| 앱과 라우팅 환경의 계약을 작게 만든다 | 자체 웹 서버의 port binding | container port, serverless runtime adapter |
| 프로세스를 교체 가능하게 만든다 | stateless process | container replica, function instance, batch worker |
| 로그 저장 책임을 앱에서 분리한다 | unbuffered stdout | stdout collector, OpenTelemetry pipeline |
| 관리 작업도 릴리스와 동기화한다 | 원격 shell의 one-off process | ECS Run Task, Kubernetes Job, 배포 pipeline job |
SBOM(Software Bill of Materials)은 빌드에 포함된 소프트웨어 구성요소 목록이고, 구성요소의 출처와 버전을 추적해 취약점 대응, 감사, 공급망 검토에 쓰는 인벤토리다. SBOM 자체는 의존성 버전을 고정하거나 실행 환경을 격리하지 않는다. 그 역할은 dependency manifest와 lockfile이 의존성 해석 결과를 고정하고, digest로 식별되는 불변 이미지가 런타임 파일과 도구를 묶어 격리하는 것과 구분해야 한다. OpenTelemetry는 로그·메트릭·트레이스를 수집·전달하기 위한 관측 표준이다. 이 도구들은 Twelve-Factor의 필수 목록이 아니라 원칙의 의도를 확장하는 현대적 수단이다.
서버리스 함수처럼 앱이 직접 포트를 열 수 없는 플랫폼도 있다. 이때 port binding 문장을 문자 그대로 적용하면 플랫폼과 충돌한다. 하지만 “애플리케이션 로직이 특정 프런트 웹 서버 설치 상태에 의존하지 않고 명시된 런타임 계약으로 서비스를 노출한다”는 의도는 유지할 수 있다.
15-Factor 또는 Beyond the Twelve-Factor라는 이름의 후속 제안은 API 우선, telemetry(원격 측정), 인증·인가 같은 항목을 추가한다. 이는 원본 12원칙의 공식 개정판이라기보다 빈 영역을 보완하는 확장으로 읽는 편이 안전하다. 어떤 목록을 따랐는지보다 각 원칙이 해결하는 실패 모드를 설명할 수 있는지가 더 중요하다.
| 선택 | 얻는 것 | 새로 부담하는 것 |
|---|---|---|
| 세션을 Redis로 외부화 | 프로세스 교체·수평 확장 | 네트워크 hop, Redis 용량·가용성·만료 관리 |
| 불변 이미지와 고유 릴리스 | 재현·감사·rollback | 이미지 저장, 취약점 갱신, release metadata 관리 |
| web/worker 독립 확장 | 병목별 비용 최적화 | queue, 중복 처리, 프로세스 유형별 관측 |
| 빠른 배포와 dev/prod parity | 짧은 피드백 | 자동 테스트와 배포 자동화 투자 |
| 구조화 stdout 로그 | 중앙 검색과 인스턴스 교체 내성 | 수집 비용, schema, 민감 정보 필터링 |
| one-off migration | 릴리스와 관리 코드의 동기화 | 순서 제어, lock, 재실행 안전성 |
원칙은 도입 여부를 한 번 결정하는 인증 제도가 아니다. 실패 비용이 낮은 단일 내부 도구에서는 외부 세션 저장소나 복잡한 release metadata가 과할 수 있다. 반면 replica가 자주 교체되고 여러 팀이 배포하며 장애 감사가 필요한 서비스에서는 그 비용이 재현성과 복구 속도로 돌아온다.
| 관찰된 현상 | 먼저 의심할 원칙 | 메커니즘 질문 |
|---|---|---|
운영에서만 localhost 연결 실패 | Config, Dev/prod parity | 누락된 설정이 왜 시작 시 실패하지 않았는가? |
| 새 replica나 배포 때 사용자가 로그아웃됨 | Processes | 세션 수명이 프로세스 메모리에 묶였는가? |
| 같은 커밋인데 서버마다 동작이 다름 | Dependencies, Build/Release/Run | 추적되지 않은 패키지·런타임 수정이 있는가? |
배포 중 502 또는 socket hang up이 증가 | Disposability | 트래픽 drain과 SIGTERM 처리가 유예 시간 안에 맞물리는가? |
| web을 늘렸는데 queue backlog가 계속 증가 | Concurrency | 병목인 worker process type을 독립 확장했는가? |
| replica 증가 후 DB 연결 한도 초과 | Concurrency, Backing services | 프로세스당 pool 크기까지 합산했는가? |
| 종료된 migration task의 실패 근거가 없음 | Logs, Admin processes | 일회성 작업도 중앙 이벤트 스트림을 남기는가? |
| rollback하려면 운영 컨테이너에서 파일을 고쳐야 함 | Build/Release/Run | 이전 불변 릴리스를 그대로 재실행할 수 있는가? |
세 신호를 연결해 보자. DATABASE_URL 누락, 인메모리 세션, SIGTERM 미처리가 동시에 있으면
각각 Config, Processes, Disposability 위반이다. 이때 ECONNREFUSED 하나만 보고 네트워크만
조사하면 설정 소유권 문제를 놓친다. 5xx와 세션 유실이 이미 사용자에게 보인다면 먼저 검증된
릴리스로 복구하고, 이후 어떤 원칙의 계약이 깨졌는지 분리해 조사한다.
다음 명령은 원칙을 이해하기 위한 필수 본문이 아니다. Amazon ECS 또는 Kubernetes 환경에서 Config와 Release 계약이 깨졌는지 확인하고, 사용자 영향이 이미 발생했을 때 복구하는 참고 절차다.
# task definition에 필수 env/secret이 선언됐는지 확인한다.aws ecs describe-task-definition --task-definition my-app:42 \ --query 'taskDefinition.containerDefinitions[?name==`my-app`].[environment,secrets]'
# 현재 PRIMARY deployment의 task definition을 확인한다.aws ecs describe-services --cluster prod --services my-app \ --query 'services[0].deployments[?status==`PRIMARY`].taskDefinition'{ "environment": [{ "name": "NODE_ENV", "value": "production" }], "secrets": [ { "name": "DATABASE_URL", "valueFrom": "arn:aws:secretsmanager:region:account:secret:orders-db" } ]}DATABASE_URL이 environment 또는 secrets에 있어야 한다. 값의 존재만으로 권한·네트워크 연결까지
검증되지는 않으므로 시작 시 설정 스키마 검증(필수 키·타입·형식 검사)과 별도 readiness 판단이
필요하다.
# Amazon ECS: 검증된 이전 task definition revision을 지정한다.aws ecs update-service --cluster prod --service my-app \ --task-definition my-app:42aws ecs wait services-stable --cluster prod --services my-app
# Kubernetes: 바로 이전 revision으로 되돌리고 상태를 관찰한다.kubectl rollout undo deployment/my-appkubectl rollout status deployment/my-app복구 뒤에는 target health, 5xx 비율, 세션 유실 여부를 본다. 설정만 바꾼 새 revision과 이전 이미지 릴리스 중 무엇이 더 작은 변경인지 구분한다. 기존 운영 예시의 “대개 1~3분”은 출처 있는 보편값이 아니라 환경별 회고 범위다. 실제 완료 시간은 새 프로세스의 startup·health check와 기존 프로세스의 connection draining 시간으로 판단한다. 5xx가 순간적으로 0%가 되었다는 한 점보다 배포 전후의 오류율과 처리 중 요청 유실을 함께 본다.
# Amazon ECSaws ecs execute-command --cluster prod --task <task-arn> \ --container my-app --interactive --command "sh -c 'test -n \"\$DATABASE_URL\" && echo set || echo missing'"
# Kuberneteskubectl exec deploy/my-app -- sh -c 'test -n "$DATABASE_URL" && echo set || echo missing'비밀값 자체를 터미널이나 로그에 출력하지 않는다. set/missing처럼 존재 여부만 확인하고,
근본 수정은 task definition, Secret, ConfigMap 같은 선언 원본에서 수행한다.