한 줄 정의

인프라 코드도 애플리케이션처럼 한 번 빌드해 저장소에 올린 뒤 단계마다 프로모션하며 흘려보내야 하고, 의존하는 프로젝트를 언제 합치느냐가 결합도를 결정합니다.

쉽게 말하면

자동차 조립 라인을 떠올리면 편합니다. 부품을 만들어 번호를 붙여 창고에 넣는 것이 빌드, 검사를 통과한 부품에 “다음 공정 통과” 도장을 찍는 것이 프로모션, 실제 차에 끼우는 것이 적용입니다. 여기서 지켜야 할 규칙은 하나입니다. 창고에 들어간 부품은 다시 깎지 않습니다. 검사받은 것과 장착되는 것이 같아야 검사에 의미가 있습니다.

남는 문제는 엔진·차체·타이어를 언제 합치는가 입니다. 처음부터 한 덩어리로 붙여 만들면 검사할 게 하나뿐이라 편하지만, 타이어 하나 바꾸려고 차를 다시 만들어야 합니다. 부품별로 만들어 검사한 뒤 라인 중간에서 합치면 경계가 남으면서도 같이 출하됩니다. 아예 각자 출하해서 현장에서 조립하면 공장끼리 서로 기다리지 않아도 되지만, 현장에서 안 맞을 위험은 그때 떠안습니다.

이 셋 중 무엇을 고르느냐가 이 장의 핵심입니다. 정답은 없고, 결합도를 어디로 옮길지 를 고르는 문제입니다.

왜 중요한가?

예전 인프라 변경은 하드웨어를 뜯어 고치듯 테스트 없이 프로덕션을 직접 건드리는 일이었습니다. 서버 RAM을 늘리는 변경을 개발 환경에서 똑같이 복제해 볼 방법이 없었으니 어쩔 수 없었습니다.

인프라를 코드로 정의하면 이 변경을 파이프라인에 얹어 프로덕션까지 굴려 볼 수 있습니다. 여기서 얻는 것은 세 가지입니다.

  • 변경 자체의 검증 — 다만 RAM 증설처럼 사소한 변경은 이것만으로는 남는 게 별로 없습니다
  • 변경을 적용하는 프로세스의 검증 — 실제로 값진 쪽입니다. 적용 절차가 깨져 있다는 사실을 프로덕션이 아니라 앞단에서 알게 됩니다
  • 프로덕션 경로의 모든 환경이 같은 방식으로 구성된다는 보장

즉 파이프라인의 목적은 “이 코드가 맞나”보다 “이 코드를 넣는 방법이 맞나”를 반복 검증하는 데 있습니다.

핵심 내용

딜리버리 파이프라인의 네 가지 활동

flowchart LR
  B["빌드"] -->|프로모션| S1["적용"] --> V1["유효성 검사"]
  V1 -->|프로모션| S2["적용"] --> V2["유효성 검사"]
  V2 -->|프로모션| S3["적용"]
  subgraph st["스택 테스트 단계"]
    S1
    V1
  end
  subgraph it["통합 테스트 단계"]
    S2
    V2
  end
  subgraph pr["프로덕션 단계"]
    S3
  end
활동하는 일
빌드코드 버전을 준비해 다른 단계에서 쓸 수 있게 만듦. 소스 코드가 바뀔 때마다 파이프라인에서 딱 한 번
프로모션단계 간에 코드 버전을 이동. “스택 테스트를 통과했으니 통합 테스트 준비 완료”를 표시하는 행위
적용도구를 실행해 인프라 인스턴스에 코드를 반영. 인스턴스는 테스트용일 수도, 프로덕션일 수도 있음
유효성 검사적용 결과를 테스트

빌드 단계가 하는 일은 컴파일 언어의 빌드와 거의 같습니다. 의존성(코드베이스 내부·외부 라이브러리) 검색, 여러 프로젝트가 공유하는 구성값 분석, 템플릿에서 구성 파일 생성 같은 컴파일·변환, 오프라인·온라인 테스트 실행, 그리고 인프라 도구가 적용에 쓰는 형식으로 코드를 정리해 꺼내 쓸 수 있게 만드는 일입니다.

딜리버리 저장소

빌드 단계는 소스 코드 저장소에서 코드를 가져와, 검증된 버전을 딜리버리 저장소 에 퍼블리싱합니다. 소스 저장소는 “변경을 관리하는 곳”, 딜리버리 저장소는 “환경에 넣을 준비가 된 버전을 보관하는 곳”으로 역할이 다릅니다. 하나의 저장소로 두 역할을 겸하는 팀도 있습니다.

딜리버리 저장소에는 프로젝트 코드의 여러 버전이 쌓이고, 프로모션은 그 버전에 표시를 남겨 진행 상황을 드러냅니다. 적용 활동은 소스가 아니라 이 저장소에서 특정 버전을 가져와 SIT나 프로덕션 인스턴스에 넣습니다.

아티팩트로 패키징할 것인가

범용 언어에는 이미 패키지 형식이 있습니다. Ruby의 gems, JavaScript의 NPM, Python의 pip, OS 레벨의 .rpm·.deb·.msi·NuGet 같은 것들입니다. 반면 인프라 도구는 전용 패키지 형식이 있는 경우가 드물어서, 팀이 직접 방법을 정해야 합니다.

  • ZIP이나 tarball로 번들링해 자체 아티팩트를 만듦
  • Chef 쿡북을 서버에서 압축 해제하는 RPM 파일을 생성
  • 스택 도구 실행 파일과 스택 프로젝트 코드를 함께 담은 Docker 이미지

기본 패키지 형식이 없는 도구를 쓰는 팀은 아예 패키징하지 않기도 합니다. 코드를 게시·공유·사용하는 방식과 쓰는 저장소 종류에 따라 갈립니다.

저장소 구현 방식
방식특징프로모션 방법
특수 아티팩트 저장소패키지 형식별 저장소 제품·서비스. Artifactory·Nexus는 여러 형식을 함께 지원하고, ORAS는 Docker 이미지용 저장소를 임의 아티팩트 저장소로 전용태그·레이블(SIT_STAGE=true, Stage=SIT) 또는 단계별 저장소로 복사·이동
도구별 저장소패키지 파일 없이 프로젝트 코드를 서버에 업로드하고 버전을 할당. Chef Server(자체 호스팅), Chef Community Cookbooks·Terraform Registry(공개)도구가 제공하는 버전·환경 개념
범용 파일 스토리지파일 서버·웹 서버·오브젝트 스토리지. 버전 관리 기능이 없어 파일 이름에 버전을 직접 박음(my-stack-v1.3.45.tgz)단계별 폴더로 복사하거나 링크
소스 코드 저장소에서 바로 적용하는 방식

가장 흔한 현실입니다. 소스는 이미 저장소에 있고 인프라 도구 대부분은 코드를 릴리스로 다룰 패키지 형식과 도구 체인이 없으니, 그냥 소스에서 환경에 적용합니다.

그런데 트렁크 코드를 모든 환경에 적용하면 환경별로 다른 버전을 둘 수 없습니다. 그래서 이 방식을 쓰는 팀은 환경별 브랜치를 유지하고 병합으로 프로모션하며, GitOps는 여기에 지속적 동기화를 결합합니다.

브랜치 프로모션의 함정

브랜치를 프로모션 수단으로 쓰면 코드를 변경하는 행위 와 딜리버리하는 행위 의 구분이 흐려집니다. CD의 핵심 원칙은 빌드 단계 이후에는 코드를 절대 변경하지 않는 것(only build packages once)입니다. 팀이 “브랜치에서는 코드를 안 고치겠다”고 약속할 수는 있지만, 지켜지지 않는 경우가 많습니다.

프로젝트 통합 시점

코드베이스의 프로젝트들은 서로 의존합니다. ShopSpinner 예제에서 application-infrastructure-stack은 shared-network-stack이 만든 네트워킹 구조 안에 인프라를 만들고, application-server-image가 빌드한 이미지로 서버를 띄웁니다. 그 이미지는 다시 tomcat-server와 monitor-server 서버 구성을 적용해 만들어집니다.

flowchart TD
  AIS["application-infrastructure-stack<br/>스택"] --> SNS["shared-network-stack<br/>스택"]
  AIS --> ASI["application-server-image<br/>서버 이미지"]
  ASI --> TS["tomcat-server<br/>서버 구성"]
  ASI --> MS["monitor-server<br/>서버 구성"]

누군가 한 프로젝트를 바꾸면 그 프로젝트의 새 버전이 생기고, 이 버전은 언젠가 다른 프로젝트의 버전과 만나야 합니다. 그 만나는 시점이 빌드 타임·딜리버리 타임·어플라이 타임 셋 중 하나입니다.

flowchart LR
  subgraph bt["빌드 타임 — 처음부터 한 덩어리"]
    b1["프로젝트 A"] --> bj(("빌드"))
    b2["프로젝트 B"] --> bj
    bj --> bx["이후 모든 단계를<br/>단일 아티팩트로"]
  end
  subgraph dt["딜리버리 타임 — 중간에서 합류"]
    d1["A"] --> dta["A 테스트"] --> dj(("통합 테스트"))
    d2["B"] --> dtb["B 테스트"] --> dj
    dj --> dx["이후 단계를<br/>함께 진행"]
  end
  subgraph at["어플라이 타임 — 끝까지 따로"]
    a1["A"] --> ad["개발"] --> ap["프로덕션"]
    a2["B"] --> bd["개발"] --> bp["프로덕션"]
    ad -.매번 통합.- bd
    ap -.매번 통합.- bp
  end
빌드 타임 프로젝트 통합

여러 프로젝트에 걸쳐 하나의 빌드를 수행합니다. 구성 프로젝트를 각각 빌드·테스트한 뒤 함께 빌드·테스트하는 형태가 흔하고, 결과물은 단일 아티팩트이거나 그룹으로 버저닝·프로모션·적용되는 아티팩트 집합입니다.

의존성 문제가 가장 이른 시점에 드러나고, 이후 전체 딜리버리 주기 동안 동일한 버전 조합이 유지된다는 게 강점입니다. 프로덕션에 들어가는 것과 테스트한 것이 정확히 같습니다.

대가는 확장성입니다. 프로젝트 수가 늘면 빌드가 느려지고 피드백이 길어지며, 대규모에서는 빌드 오케스트레이션 도구가 필요합니다. Google·Facebook처럼 이 패턴을 쓰는 큰 조직에는 사내 빌드 도구를 유지보수하는 전담 팀이 있고, 여기서 영감받은 Bazel·Buck·Pants·Please 같은 도구들이 나왔습니다. 최적화의 정석은 방향 그래프로 변경된 부분만 빌드·테스트하는 것입니다.

더 조용한 대가는 경계입니다. 프로젝트가 항상 함께 빌드되니 사이의 선이 보이지 않고, 긴밀한 결합이 자라납니다. 작은 변경이 코드베이스 다른 부분에 영향을 주기 시작하면 변경 시간과 위험이 함께 올라갑니다.

딜리버리 타임 프로젝트 통합

각 프로젝트를 개별적으로 빌드·테스트한 뒤 결합합니다. 한 번 결합되면 남은 딜리버리 주기 동안 함께 움직입니다.

통합 전에 따로 테스트한다는 점이 경계를 지켜 줍니다. 책의 예가 이걸 잘 보여 줍니다. application-infrastructure-stack에 방화벽 규칙을 넣으면서 application-server-image의 구성 파일에서 포트 번호를 직접 읽는 코드를 썼더니, 빌드 에이전트에 그 파일이 없어서 스택 테스트 단계가 실패했습니다.

이 실패는 좋은 실패입니다

두 프로젝트가 파일 수준에서 결합됐다는 사실을 파이프라인이 대신 잡아 줬습니다. 포트 번호를 파라미터값으로 바꾸고 나중에 주입하면, 프로젝트 전체에서 남의 파일을 직접 참조하는 코드베이스보다 훨씬 유지보수하기 쉬워집니다.

구현은 여러 프로젝트를 하나로 모으는 팬인 파이프라인 설계입니다. 결합한 코드 집합을 이후 단계에서 적용하는 방법은 셋입니다.

방법내용
단일 아티팩트 번들통합 단계에서 모든 프로젝트 코드를 하나의 아카이브로 묶어 다운스트림으로 프로모션. GitOps라면 연관 프로젝트를 통합 단계 브랜치로 병합한 뒤 다시 다운스트림 브랜치로 병합
디스크립터 파일각 프로젝트의 버전 번호만 담은 파일을 아티팩트로 취급. 적용 단계는 이 목록을 보고 딜리버리 저장소에서 개별 아티팩트를 가져옴
집계 버전 태그통합된 버전 번호로 관련 리소스에 태그를 붙임
descriptor-version: 1.9.1
 
stack-project:
  name: application-infrastructure-stack
  version: 1.2.34
 
server-image-project:
  name: application-server-image
  version: 1.1.1

여러 버전 조합을 계속 해결하고 조정해야 하므로 파이프라인 같은 정교한 딜리버리 구현이 전제되고, 프로젝트 수가 많아지면 확장이 어렵습니다.

어플라이 타임 프로젝트 통합

분리된 딜리버리 또는 분리된 파이프라인 이라고도 합니다. 프로젝트마다 자체 딜리버리 단계를 갖고, 코드가 변경되면 그 프로젝트의 경로에 있는 각 환경에 개별적으로 적용됩니다. 통합은 코드가 적용될 때마다 환경별로 따로 일어나므로, 같은 프로젝트 코드가 환경마다 다른 버전의 업스트림·다운스트림과 만납니다.

각 팀이 다른 팀의 변경에 방해받지 않고 조정 없이 프로덕션까지 밀어낼 수 있다는 게 이 패턴의 전부입니다. 자율적인 팀 구조, 그리고 수백~수천 명 규모에서 릴리스를 조율하기 어려운 대규모 시스템에 맞습니다.

대신 의존성이 깨지는 위험이 어플라이 타임으로 이동합니다. 파이프라인이 버전 일관성을 보장하지 않으니, 한 프로젝트 변경을 다른 것보다 빨리 푸시하면 프로덕션에서 테스트 환경과 다른 조합이 만들어집니다.

그래서 이 패턴은 인터페이스 관리 비용을 요구합니다.

  • 의존성을 암묵적 파일 참조가 아니라 콘트랙트 로 명시. shared-network-stack은 다른 프로젝트가 쓸 네트워킹 구조의 식별자를 표준화된 방식으로 노출
  • 테스트 픽스처로 개별 스택을 단독 테스트. 애플리케이션 스택 테스트에 실제 네트워크 스택 인스턴스를 쓰지 않으면, 애플리케이션 스택이 네트워크 구현 세부사항을 가정하도록 자라는 위험도 줄어듦
  • 공급자 팀은 콘트랙트 테스트로 식별자 노출을 보증. 이 테스트를 깨는 변경은 “테스트를 고치면 되는 일”이 아니라 다른 프로젝트를 중단시킬 수 있는 일 이라는 걸 알 수 있게 레이블을 명확히 붙임
  • CDC(소비자 주도 콘트랙트) 테스트 — 소비자 프로젝트 팀이 테스트를 작성해 공급자의 파이프라인에서 실행. 공급자 팀이 소비자의 기대를 이해하게 됩니다

래퍼 스크립트라는 그림자 코드베이스

인프라 도구를 실제로 실행하려면 대부분의 팀이 사용자 정의 스크립트를 만듭니다. Make·Rake·Gradle 같은 빌드 도구이거나 Bash·Python·PowerShell입니다. 이 스크립트가 다루는 일은 생각보다 넓습니다.

작업내용
구성구성 파라미터값을 조합하고 값의 계층 구조를 해결
의존성라이브러리·공급자·연관 코드를 확인하고 검색
패키징아티팩트로 묶거나 브랜치를 생성·병합할 때 전달할 코드를 준비
프로모션태그 지정, 아티팩트 이동, 브랜치 생성·병합
오케스트레이션의존성 순서에 맞춰 여러 스택과 인프라 요소를 적용
실행커맨드라인 인수와 구성 파일을 조합해 인프라 도구 실행
테스트테스트 픽스처·데이터 프로비저닝, 결과 수집과 퍼블리싱

문제는 이 코드가 인프라를 정의하는 코드만큼 복잡해진다는 점입니다. 팀이 인프라 코드를 개선하는 시간보다 래퍼 스크립트 버그를 잡는 시간을 더 쓰는 상황 이 흔합니다.

구성값 조합이 복잡도의 진원지

컴포넌트·환경·고객 조합마다 파일 하나를 두는 1레벨 구성값 집합은 파일 수가 폭발하고 값이 중복됩니다. 그래서 공유값을 한곳에 모으고 래퍼 스크립트가 공유 구성과 컴포넌트별 구성에서 값을 읽게 만듭니다. 그러다 “환경 단위로 공유되는 값”이 필요해지면 세 번째 구성 집합이 생기고, 같은 항목이 여러 파일에서 다른 값을 가지면 스크립트가 우선순위 계층으로 해결해야 합니다.

이 계층 구조를 코드로 짜면 코드가 지저분해지고, 새 파라미터를 추가하거나 특정 인스턴스가 어떤 값을 썼는지 추적하고 디버깅하는 일이 어려워집니다. 구성 레지스트리를 쓰면 파일 배열이 레지스트리 하위 트리로 바뀔 뿐 성격은 같습니다. 어느 쪽이든 파라미터값의 원점을 추적하는 일이 골칫거리 라는 사실은 변하지 않습니다.

단순하게 유지하는 규칙

핵심은 혼합과 결합을 다시 들여다보는 것입니다.

규칙내용
생명 주기 분할빌드·프로모션·적용을 한 스크립트로 처리하지 않음. 작업마다 다른 스크립트를 쓰고, 단계 간 정보 전달 지점을 명확히 알아야 함
작업 분류구성값 조합·코드 패키징·도구 실행 같은 작업을 분류하고 통합점을 정의해 느슨하게 결합
프로젝트 분리여러 프로젝트를 조정하는 스크립트와 프로젝트 안에서 작업을 실행하는 스크립트를 분리. 모든 프로젝트는 자기 작업을 단독으로 실행할 수 있어야 함
래퍼 코드 무시스크립트는 자기가 지원하는 프로젝트를 아무것도 몰라야 함. 프로젝트별 하드코딩을 피하면 특정 스택 도구를 쓰는 모든 프로젝트에 같은 래퍼를 쓸 수 있음

결국 래퍼 스크립트를 ‘실제’ 코드처럼 취급하라 는 이야기입니다. shellcheck 같은 도구로 테스트·검증하고, 단일 책임 원칙과 도메인 개념 중심 설계 같은 소프트웨어 설계 규칙을 그대로 적용합니다.

비교 / 트레이드오프

빌드 타임딜리버리 타임어플라이 타임
통합 지점딜리버리 주기 맨 앞, 한 번주기 중간의 팬인 단계, 한 번코드가 적용될 때마다, 환경별로
버전 일관성파이프라인이 보장통합 이후 구간은 보장보장하지 않음
프로젝트 경계함께 빌드되며 흐려짐통합 전 개별 테스트가 강제물리적으로 분리
팀 자율성낮음 — 함께 움직여야 함중간 — 통합 단계에서 만남높음 — 조정 없이 푸시
주된 비용빌드 확장성, 긴밀한 결합여러 버전 조합의 조정 복잡도인터페이스·콘트랙트 설계와 유지보수
위험이 드러나는 시점빌드통합 테스트 단계적용 시점(최악의 경우 프로덕션)
잘 맞는 저장소 전략모노리포멀티 저장소마이크로리포

내 생각

  • “빌드 단계 이후에는 코드를 변경하지 않는다”가 이 장에서 가장 자주 깨지는 원칙입니다. 환경별 브랜치에 hotfix를 직접 커밋하는 순간 파이프라인이 검증한 것과 프로덕션에 있는 것이 달라지고, 그 뒤로는 파이프라인 통과가 아무것도 보증하지 않습니다.
  • 인프라 도구에 패키지 형식이 없다는 게 여전히 아픈 지점입니다. Terraform 모듈을 Registry로 올리든 상태 파일 버전에 의존하든, 애플리케이션의 jar처럼 “이 파일 하나가 그 버전”이라고 못 박기가 어렵습니다. 그래서 Docker 이미지에 도구와 코드를 함께 담는 방식이 실무에서 계속 살아남습니다.
  • 통합 시점 선택은 사실 조직 구조 선택입니다. 팀이 하나면 빌드 타임이 편하고, 플랫폼팀과 서비스팀이 나뉘면 어플라이 타임 외에는 선택지가 없습니다. 조직은 나뉘어 있는데 빌드 타임 통합을 유지하면 릴리스마다 팀 간 대기가 생깁니다.
  • 어플라이 타임을 선택했으면 콘트랙트 테스트가 옵션이 아닙니다. 스택 출력값을 그냥 노출해 두고 “쓰는 쪽이 알아서” 하면, 공급자는 자기가 뭘 깨는지 모른 채 배포하고 사고는 소비자 환경에서 납니다. CDC 테스트를 공급자 파이프라인에 심어 두는 것이 유일하게 작동하는 방법이었습니다.
  • 래퍼 스크립트는 대부분 조직의 진짜 레거시입니다. 아무도 소유하지 않고 테스트도 없는 500줄짜리 deploy.sh가 실제 배포 로직을 다 들고 있는 경우가 흔합니다. 인프라 코드에만 리뷰와 테스트를 걸어 두고 그걸 실행하는 스크립트는 방치하면, 검증 범위 밖에서 사고가 납니다.

관련 개념