콘텐츠로 이동

Twelve-Factor App

분류: 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 패턴에서는 여기서 만든 교체 가능한 프로세스가 네트워크와 서비스 경계를 넘어설 때 생기는 일관성·장애 문제를 다룬다.


2. 선행 운영 방식의 한계 — Twelve-Factor가 등장한 이유

섹션 제목: “2. 선행 운영 방식의 한계 — Twelve-Factor가 등장한 이유”

전통적인 서버 배포에서는 서버 한 대가 애플리케이션의 일부처럼 취급되기 쉬웠다. 운영자가 서버에 접속해 설정 파일을 고치고, 런타임에 라이브러리를 설치하고, 애플리케이션이 로컬 디스크에 세션과 로그를 남겼다. 그러면 코드 저장소의 같은 커밋도 어느 서버에서 실행하느냐에 따라 다른 프로그램처럼 동작한다.

대표적인 실패 흐름은 다음과 같다.

  1. 개발 환경에는 .env와 로컬 Redis가 있으므로 주문 API가 정상 동작한다.
  2. 운영 배포에는 DATABASE_URL이 빠졌지만 코드가 localhost를 기본값으로 사용한다.
  3. 헬스체크는 DB를 조회하지 않아 컨테이너가 정상으로 판정된다.
  4. 실제 주문 요청에서만 ECONNREFUSED 127.0.0.1:5432가 발생한다.
  5. 새 컨테이너가 추가되어도 인메모리 세션 때문에 요청을 받은 인스턴스마다 로그인 상태가 다르다.
  6. 다음 배포에서는 종료 신호를 처리하지 않아 진행 중인 요청이 502로 끊긴다.

문제는 컨테이너나 클라우드가 없어서가 아니다. 빌드 결과, 설정, 상태, 수명 주기의 소유자가 불명확한 것이 원인이다. Twelve-Factor는 이 소유권을 다음 세 방향으로 정리한다.

  • 재현 가능한 릴리스: 코드, 의존성, 설정, 릴리스의 관계를 명시한다.
  • 교체 가능한 실행 단위: 프로세스 메모리와 로컬 디스크를 영속 상태로 보지 않는다.
  • 실행 환경과의 작은 계약: 포트, 종료 신호, 표준 출력처럼 이식 가능한 경계로 통신한다.

공식 문서는 Heroku에서 수백 개 앱을 직접 배포하고 수십만 개 앱을 관찰한 경험을 배경으로 설명한다. 이 맥락은 원칙의 출발점을 알려 주지만, Heroku 사용법을 외우는 것이 학습 목표는 아니다. 오늘날의 컨테이너 이미지, Kubernetes Pod, Amazon Elastic Container Service(ECS) Task도 같은 소유권 문제를 다른 도구로 구현한다.


열두 항목을 독립 체크리스트로 외우면 서로의 관계가 보이지 않는다. 다음 표는 각 원칙이 어떤 운영 질문에 답하는지 묶은 것이다.

열두 원칙의 역할

I. Codebase

하나의 앱을 추적하는 정본은 무엇인가? 하나의 버전 관리 코드베이스다.

같은 커밋에서 개발·스테이징·운영 배포를 만든다.

II. Dependencies

실행에 필요한 라이브러리는 무엇인가? 코드가 명시적으로 선언하고 격리한다.

잠금 파일과 이미지로 빌드 입력을 재현한다.

III. Config

배포마다 달라지는 값은 어디에 있는가? 코드와 분리해 실행 환경이 주입한다.

DB 주소·자격 증명·호스트명은 릴리스 설정으로 관리한다.

IV. Backing services

DB·캐시·큐는 어떻게 연결되는가? 설정으로 붙이고 떼는 리소스로 취급한다.

코드 수정 없이 리소스 핸들을 바꿔 복구·이관한다.

V. Build, release, run

코드가 실행되기까지 어떤 불변 단계를 거치는가? 빌드·릴리스·실행을 분리한다.

빌드 결과와 설정을 결합한 고유 릴리스를 실행하고 이전 릴리스로 되돌린다.

VI. Processes

요청 사이의 영속 상태는 어디에 있는가? 실행 프로세스 밖의 저장소에 둔다.

세션·업로드·작업 checkpoint를 DB, 객체 저장소, 캐시에 둔다.

VII. Port binding

서비스는 실행 환경에 어떻게 노출되는가? 스스로 포트에 바인딩한다.

라우터나 로드 밸런서가 앱의 리스닝 포트로 트래픽을 전달한다.

VIII. Concurrency

부하를 어떻게 나누는가? 프로세스 유형별 인스턴스 수를 늘린다.

web과 worker를 서로 다른 병목에 맞춰 독립 확장한다.

IX. Disposability

프로세스 교체는 얼마나 안전한가? 빠르게 시작하고 정상 종료한다.

새 요청을 끊고 진행 중인 일을 제한 시간 안에 마친다.

X. Dev/prod parity

개발과 운영의 차이가 버그를 숨기는가? 시간·사람·도구의 차이를 줄인다.

같은 빌드 결과와 같은 종류의 backing service로 자주 배포한다.

XI. Logs

로그의 저장·라우팅 책임은 누구에게 있는가? 앱은 이벤트를 내보내고 환경이 수집한다.

stdout/stderr를 로그 플랫폼으로 전달한다.

XII. Admin processes

마이그레이션 같은 일회성 작업은 무엇을 실행하는가? 같은 릴리스와 설정을 사용한다.

배포된 이미지로 별도 one-off task를 실행하고 결과를 기록한다.

4.1 Codebase와 Dependencies: 실행 입력을 추적 가능하게 만든다

섹션 제목: “4.1 Codebase와 Dependencies: 실행 입력을 추적 가능하게 만든다”

Codebase(코드베이스) 원칙의 “하나”는 회사 전체를 반드시 모노레포로 만들라는 뜻이 아니다. 하나의 배포 가능한 앱에 정본 코드베이스가 하나 있어야 한다는 뜻이다. 같은 코드베이스에서 여러 deploy(실행 환경별 배포)가 나올 수 있지만, 서로 다른 제품을 조건문으로 억지로 합치거나 운영 서버에서만 코드를 고쳐 별도 변종을 만들면 추적 가능성이 깨진다.

Dependencies(의존성) 원칙은 운영 서버에 우연히 설치된 패키지를 믿지 말라는 뜻이다. Node.js라면 package.json이 필요한 패키지를 선언하고 잠금 파일이 해석 결과를 고정한다. 컨테이너 이미지는 OS 패키지와 런타임까지 격리할 수 있다. 다만 잠금 파일만 있다고 완전한 재현성이 생기지는 않는다. 베이스 이미지 태그가 가변이거나 빌드 중 외부 파일을 최신 버전으로 내려받으면 같은 커밋도 다른 결과가 될 수 있다.

실패 신호는 명확하다.

  • 새 개발자나 CI(Continuous Integration, 지속적 통합) 환경에서 설치 결과가 다르다.
  • 운영 서버에 수동 설치한 패키지가 있어야만 앱이 시작된다.
  • latest 이미지 태그가 가리키는 내용이 바뀌어 이전 릴리스를 재실행할 수 없다.
  • 어느 커밋과 의존성으로 현재 프로세스가 만들어졌는지 답할 수 없다.

4.2 Config: 코드와 배포별 값을 분리한다

섹션 제목: “4.2 Config: 코드와 배포별 값을 분리한다”

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(주 애플리케이션 옆에서 비밀 주입·갱신 같은 보조 책임을 수행하는 별도 프로세스 또는 컨테이너)처럼 환경 변수 외 전달 방식도 사용한다. 중요한 의도는 다음 세 가지다.

  1. 비밀과 배포별 값이 코드나 이미지에 포함되지 않는다.
  2. 앱은 주입 경로를 명시적으로 알고 시작 전에 검증한다.
  3. 설정 변경은 어떤 릴리스를 만들었는지 추적할 수 있다.

따라서 환경 변수만 고집해 큰 인증서나 자동 회전 자격 증명을 억지로 넣는 것은 원칙의 문자에는 가깝지만 의도에는 멀 수 있다. 전달 방식보다 코드와 설정의 분리, 최소 권한, 변경 이력을 기준으로 판단한다.

4.3 Backing services: 교체 가능성과 무상태를 연결한다

섹션 제목: “4.3 Backing services: 교체 가능성과 무상태를 연결한다”

Backing service(지원 서비스) 는 애플리케이션이 네트워크로 사용하는 데이터베이스, 캐시, 메시지 큐, 메일 전송 서비스, 객체 저장소 등을 뜻한다. “attached resource(연결된 리소스)“로 취급한다는 말은 모두 같은 제품으로 바꿀 수 있다는 뜻이 아니다. 코드가 prod-db-01 같은 서버 정체성을 하드코딩하지 않고, 설정으로 받은 리소스 핸들에 연결한다는 뜻이다.

예를 들어 읽기 전용 리포트 DB를 교체할 때 연결 문자열만 바꿔도 되는 구조는 교체 가능하다. 그러나 MySQL을 PostgreSQL로 바꾸면 SQL 방언, 격리 수준, 데이터 타입이 달라질 수 있다. Twelve-Factor가 도메인 의미나 데이터 마이그레이션 비용까지 제거해 주지는 않는다. 이 경계는 Clean Architecture의 repository adapter와 함께 볼 때 선명해진다.

선택 기준은 “몇 초 만에 제품을 바꿀 수 있는가”가 아니다.

  • 장애 복구나 환경 복제 시 코드 수정 없이 새 리소스 주소를 주입할 수 있는가?
  • 자격 증명과 네트워크 권한이 리소스별로 분리되는가?
  • 로컬 디스크를 사실상 backing service처럼 쓰면서 수명 주기를 앱 프로세스에 묶지 않았는가?
  • 교체 시 필요한 호환성 검증과 데이터 이관을 별도 문제로 인식하는가?

4.4 Build, Release, Run: 무엇을 되돌리는지 명확히 한다

섹션 제목: “4.4 Build, Release, Run: 무엇을 되돌리는지 명확히 한다”

아티팩트(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(실행): 선택한 릴리스의 프로세스를 시작한다.

Build/Release/Run은 결과물과 책임을 나누는 개념적 분리다. CI/CD(Continuous Integration/Continuous Delivery 또는 Deployment) 파이프라인은 테스트, 이미지 빌드, 릴리스 등록, 배포를 자동화해 이 분리를 구현할 수 있는 메커니즘이다. 따라서 둘은 동의어가 아니다. 수동 절차도 세 결과를 구분할 수 있고, 반대로 자동 파이프라인도 실행 시 패키지를 다시 설치하거나 기존 릴리스를 덮어쓰면 이 원칙을 어길 수 있다.

이 구분이 있으면 코드 변경 없이 설정 C20만 바꿔도 R108이라는 새 릴리스가 된다. 문제가 생기면 “이미지를 다시 빌드해 비슷하게 복원”하는 대신 R107을 다시 실행할 수 있다. 릴리스는 고유 ID를 갖고 변경 불가능한 기록으로 남아야 한다.

반례는 운영 컨테이너 안에서 파일을 직접 수정하는 것이다. 당장은 복구된 것처럼 보여도 수정 내용은 다음 재시작에 사라지고 어느 빌드에도 속하지 않는다. 또 앱 시작 시점에 패키지를 설치하거나 프런트 자산을 컴파일하면 야간 자동 재시작이 빌드 서버 역할까지 떠안는다. 실행 단계는 외부 네트워크와 도구에 덜 의존할수록 예측 가능하다.


5. 교체 가능한 프로세스로 실행하기

섹션 제목: “5. 교체 가능한 프로세스로 실행하기”

5.1 Processes: 무상태는 데이터가 없다는 뜻이 아니다

섹션 제목: “5.1 Processes: 무상태는 데이터가 없다는 뜻이 아니다”

Stateless process(무상태 프로세스) 는 요청 사이에 필요한 영속 상태를 자신의 메모리나 로컬 파일시스템에만 보관하지 않는 프로세스다. 주문을 처리하는 동안 메모리에 지역 변수를 사용하는 것은 괜찮다. 문제는 다음 요청이나 재시작 이후에도 필요한 세션, 업로드 파일, 작업 진행 상태를 그 메모리가 계속 가지고 있을 것이라고 가정하는 것이다.

인메모리 세션을 가진 web 프로세스 A와 B를 생각해 보자.

로그인 요청 -> A 메모리에 session-123 저장
다음 조회 -> B로 라우팅 -> session-123 없음 -> 강제 로그아웃
롤링 배포 -> A 종료 -> session-123 영구 유실

sticky session으로 다음 요청을 A에 고정하면 첫 문제는 가릴 수 있지만 A가 종료될 때의 유실은 해결하지 못한다. Redis 같은 외부 저장소로 세션을 옮기면 A와 B가 같은 상태를 읽고 프로세스 교체와 상태 수명이 분리된다. 대신 네트워크 지연, Redis 장애, 만료 정책이라는 새 비용이 생긴다. “무상태가 항상 더 싸다”가 아니라 프로세스를 잃어도 복구할 수 있는가가 판단 기준이다.

5.2 Port binding: 앱과 라우팅 계층의 최소 계약

섹션 제목: “5.2 Port binding: 앱과 라우팅 계층의 최소 계약”

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에만 바인딩해 외부 라우터가 접근하지 못하거나, 코드에 고정한 포트와 실행 환경이 기대하는 포트가 다른 경우다. 반대로 외부 웹 서버가 앱 코드를 플러그인처럼 주입해야만 실행되는 구조는 앱의 독립 실행·테스트를 어렵게 한다.

5.3 Concurrency: 프로세스 유형별로 병목을 확장한다

섹션 제목: “5.3 Concurrency: 프로세스 유형별로 병목을 확장한다”

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의 용량 계획까지 대신하지 않는다.


6.1 Disposability: 빠른 시작과 정상 종료

섹션 제목: “6.1 Disposability: 빠른 시작과 정상 종료”

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(같은 작업을 여러 번 적용해도 결과가 같음)해야 한다.

6.2 Dev/prod parity: 세 가지 차이를 줄인다

섹션 제목: “6.2 Dev/prod parity: 세 가지 차이를 줄인다”

Dev/prod parity(개발·운영 동등성) 는 개발 환경을 운영과 비트 단위로 복제하라는 뜻이 아니다. 공식 원칙은 세 차이를 줄이는 데 초점을 둔다.

  • 시간 차이: 개발한 변경이 운영에 도달하기까지의 간격을 줄인다.
  • 사람 차이: 개발자와 운영 담당 사이에 배포·관측 지식을 단절시키지 않는다.
  • 도구 차이: 로컬 SQLite, 운영 PostgreSQL처럼 의미가 다른 대체품이 버그를 숨기지 않게 한다.

전통적 앱의 배포 간격을 weeks, Twelve-Factor 앱의 목표를 hours로 설명한 원문은 2010년대 초의 대비다. 오늘날 중요한 판단은 특정 시간 숫자가 아니라 변경 피드백이 짧고 환경 차이가 명시적인가다. 노트북과 운영 클러스터의 크기·고가용성·보안 경계는 당연히 다를 수 있다. 같은 이미지, 같은 데이터베이스 엔진의 호환 버전, 같은 migration을 사용하고 차이를 설정으로 드러내면 된다.

반례는 “Docker를 쓰므로 parity를 달성했다”는 주장이다. 로컬 이미지와 운영 이미지가 다르거나, 로컬은 동기식 fake queue를 쓰고 운영은 중복 전달 가능한 실제 queue를 쓰면 컨테이너 형식이 같아도 실패 의미는 다르다. 모든 관리형 서비스를 로컬에 복제하기 어렵다면 contract test(서비스 경계의 요청·응답 형식과 오류 의미가 합의한 계약을 지키는지 자동 검증하는 테스트)와 짧은 staging 검증으로 차이를 의식적으로 관리한다.


7. 운영 출력을 실행 환경에 맡기기

섹션 제목: “7. 운영 출력을 실행 환경에 맡기기”

7.1 Logs: 앱은 이벤트를 쓰고 환경은 운반한다

섹션 제목: “7.1 Logs: 앱은 이벤트를 쓰고 환경은 운반한다”

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가 연결되는 지점이다.

7.2 Admin processes: 관리 작업도 같은 릴리스의 일부다

섹션 제목: “7.2 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은 같은 릴리스 아티팩트를 사용하되, 동시 실행 제어·재실행 안전성·실패 시 중단 조건을 가진 별도 일회성 단계로 두는 편이 예측 가능하다.


8. 하나의 주문 서비스로 열두 원칙 연결하기

섹션 제목: “8. 하나의 주문 서비스로 열두 원칙 연결하기”

주문 API에 새 할인 규칙을 배포한다고 가정하자.

  1. Codebase: Git commit abc123이 변경의 정본이다.
  2. Dependencies: 잠금 파일과 고정된 베이스 이미지로 B42를 만든다.
  3. Config: 운영 DB 주소와 결제 자격 증명은 이미지 밖의 C19에 있다.
  4. Backing services: 주문 DB와 Redis session store는 리소스 핸들로 연결된다.
  5. Build/Release/Run: B42와 C19를 결합한 R107에 고유 ID를 부여한다.
  6. Admin processes: R107 이미지로 할인 컬럼 migration을 한 번 실행한다.
  7. Processes: web은 세션을 Redis에, worker는 작업 상태를 DB에 둔다.
  8. Port binding: web은 주입받은 포트에 바인딩하고 ALB가 트래픽을 전달한다.
  9. Concurrency: web 8개와 worker 7개를 각 부하에 맞게 실행한다.
  10. Disposability: 새 R107이 준비되면 R106은 새 요청을 끊고 진행 중인 일을 마친다.
  11. Logs: 모든 프로세스가 releaseId=R107을 포함한 이벤트를 stdout으로 보낸다.
  12. Dev/prod parity: staging에서 같은 B42 이미지와 migration을 먼저 검증한다.

여기서 결제 자격 증명 C20이 잘못되어 장애가 나면 이미지를 다시 만들 필요가 없다. 설정을 고쳐 R108을 만들거나 R106/R107 중 검증된 릴리스를 다시 실행한다. 반대로 B42 코드가 문제라면 컨테이너 안에서 수정하지 않고 이전 불변 빌드의 릴리스로 돌아간다. 이 차이를 구분할 수 있다는 것이 Build-Release-Run의 실질적 가치다.


9. 시대를 초월한 의도와 구현 수단 구분하기

섹션 제목: “9. 시대를 초월한 의도와 구현 수단 구분하기”

원문은 2011년에 공개되었고 공식 사이트 표기는 2017년 마지막 업데이트다. 따라서 원칙의 의도와 당시의 구체 처방을 분리해서 읽어야 한다.

원칙의 지속적인 의도원문의 대표 처방현대의 가능한 구현
배포별 설정을 코드와 분리한다환경 변수env, secret volume, secret manager 연동
의존성과 실행 입력을 명시·격리한다언어별 dependency manifestlockfile, digest로 고정한 불변 container image
앱과 라우팅 환경의 계약을 작게 만든다자체 웹 서버의 port bindingcontainer port, serverless runtime adapter
프로세스를 교체 가능하게 만든다stateless processcontainer replica, function instance, batch worker
로그 저장 책임을 앱에서 분리한다unbuffered stdoutstdout collector, OpenTelemetry pipeline
관리 작업도 릴리스와 동기화한다원격 shell의 one-off processECS 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원칙의 공식 개정판이라기보다 빈 영역을 보완하는 확장으로 읽는 편이 안전하다. 어떤 목록을 따랐는지보다 각 원칙이 해결하는 실패 모드를 설명할 수 있는지가 더 중요하다.


  • 서비스 간 데이터 일관성, 네트워크 파티션, 중복 메시지 처리는 분산 시스템 기초와 MSA 패턴의 범위다.
  • 도메인 경계와 비즈니스 불변식은 DDD의 범위다.
  • 설정을 외부로 뺐다고 자동으로 암호화·최소 권한·회전이 구현되지는 않는다.
  • 프로세스가 무상태여도 backing service가 단일 장애점이면 시스템은 가용하지 않다.
  • 로그를 stdout으로 보낸다고 metric, trace, alert가 자동으로 생기지는 않는다.
선택얻는 것새로 부담하는 것
세션을 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와 세션 유실이 이미 사용자에게 보인다면 먼저 검증된 릴리스로 복구하고, 이후 어떤 원칙의 계약이 깨졌는지 분리해 조사한다.


원칙을 설계 판단으로 바꾸기

  • 현재 실행 중인 프로세스가 어느 commit, build, config, release에서 왔는지 추적할 수 있는가?
  • 필수 config가 빠지면 첫 실제 요청이 아니라 프로세스 시작 시 실패하는가?
  • 프로세스 하나를 예고 없이 잃어도 세션·업로드·작업 진행 상태를 복구할 수 있는가?
  • web과 worker의 부하를 각각 관측하고 독립적으로 확장할 수 있는가?
  • 종료 유예 시간이 p95 요청 시간과 최장 동기 작업보다 충분한가? 충분하지 않다면 작업을 분리했는가?
  • 실제 플랫폼에서 readiness 제거, 로드 밸런서 등록 해제, 종료 신호와 유예 시간의 경합을 검증했는가?
  • 로그 수집기와 저장소를 바꿔도 애플리케이션 로직을 수정하지 않아도 되는가?
  • migration과 데이터 보정이 개인 노트북이 아니라 대상 release의 코드와 config로 실행되는가?
  • config를 환경별 거대 번들이 아니라 독립 값과 합리적인 원자적 그룹으로 관리하는가?

13. 선택 부록: 플랫폼별 확인과 복구 명령

섹션 제목: “13. 선택 부록: 플랫폼별 확인과 복구 명령”

다음 명령은 원칙을 이해하기 위한 필수 본문이 아니다. Amazon ECS 또는 Kubernetes 환경에서 Config와 Release 계약이 깨졌는지 확인하고, 사용자 영향이 이미 발생했을 때 복구하는 참고 절차다.

Amazon ECS의 설정 주입과 릴리스 확인
Terminal window
# 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 판단이 필요하다.

검증된 이전 릴리스로 복구
Terminal window
# Amazon ECS: 검증된 이전 task definition revision을 지정한다.
aws ecs update-service --cluster prod --service my-app \
--task-definition my-app:42
aws ecs wait services-stable --cluster prod --services my-app
# Kubernetes: 바로 이전 revision으로 되돌리고 상태를 관찰한다.
kubectl rollout undo deployment/my-app
kubectl rollout status deployment/my-app

복구 뒤에는 target health, 5xx 비율, 세션 유실 여부를 본다. 설정만 바꾼 새 revision과 이전 이미지 릴리스 중 무엇이 더 작은 변경인지 구분한다. 기존 운영 예시의 “대개 1~3분”은 출처 있는 보편값이 아니라 환경별 회고 범위다. 실제 완료 시간은 새 프로세스의 startup·health check와 기존 프로세스의 connection draining 시간으로 판단한다. 5xx가 순간적으로 0%가 되었다는 한 점보다 배포 전후의 오류율과 처리 중 요청 유실을 함께 본다.

실행 중 프로세스의 주입값 확인
Terminal window
# Amazon ECS
aws ecs execute-command --cluster prod --task <task-arn> \
--container my-app --interactive --command "sh -c 'test -n \"\$DATABASE_URL\" && echo set || echo missing'"
# Kubernetes
kubectl exec deploy/my-app -- sh -c 'test -n "$DATABASE_URL" && echo set || echo missing'

비밀값 자체를 터미널이나 로그에 출력하지 않는다. set/missing처럼 존재 여부만 확인하고, 근본 수정은 task definition, Secret, ConfigMap 같은 선언 원본에서 수행한다.



  1. Twelve-Factor는 클라우드 제품 목록이 아니라 배포 가능한 서비스 앱의 운영 계약이다.
  2. Codebase·Dependencies·Config·Build/Release/Run은 실행 결과를 재현하고 되돌릴 수 있게 만든다.
  3. Processes·Port binding·Concurrency·Disposability는 프로세스를 독립적으로 교체하고 확장하게 만든다.
  4. Logs와 Admin processes는 운영 출력과 일회성 작업도 같은 릴리스 수명 주기 안에 둔다.
  5. 환경 변수·stdout·port binding은 구현 수단이며, 지속되는 의도는 분리·추적·교체 가능성이다.