[코드가 환경을 모르는 구조 2/7] 배포 코드가 환경을 모르는 구조

기술 블로그
페이스북링크드인트위터

두 벌의 스크립트가 생기는 순간

"dev에 배포하는 스크립트"와 "prod에 배포하는 스크립트"는 한날 한시에 갈라지지 않습니다. 처음엔 클러스터 인증 정보와 환경 변수 몇 개만 다를 뿐입니다. 그런데 서비스가 자라면서 두 파일은 조용히 갈라집니다. dev에만 붙는 디버그 플래그, prod에만 추가되는 리소스 제한, qa에만 켜지는 기능 플래그. 각 환경의 "특수 상황"이 스크립트에 박히는 순간, 두 파일은 사실상 다른 파이프라인입니다.

사고는 여기서 싹틉니다. dev에 새 단계가 추가됐지만 prod에는 빠져, 프로덕션 배포에서 마이그레이션을 놓칩니다. 환경 변수 이름이 dev와 prod에서 미묘하게 어긋나, 로컬에서 되던 것이 prod에서 멈춥니다. 비슷해 보이는 두 파일에서 한 줄의 차이를 찾아내는 일은 리뷰어에게 가혹합니다.

flex는 이 문제를 "배포 코드 자체가 환경을 모르게" 설계하는 방향으로 풀었습니다. 파이프라인은 하나, 템플릿도 하나. 환경에 따라 달라져야 하는 것은 전부 외부에서 값으로 주입하고, 그 값이 어느 층에서 오버라이드 되는 지를 규율로 못박았습니다.


값은 층층이, 템플릿은 하나

flex의 Kubernetes 배포는 Helm 차트 위에 올라갑니다. 중요한 건 차트 자체가 아니라, 거기에 주입하는 값(values)을 세 층으로 쌓는 규율입니다. 각 층은 자기가 무엇을 알고 무엇을 모르는지가 분명합니다.

가장 아래는 기본 층입니다. 앱 목록과 공통 기본값이 여기에 들어가지만, 이 층에서는 어떤 앱도 켜져 있지 않습니다. 모든 앱이 "꺼짐" 상태로 선언되고, 환경을 가리키는 값은 비어 있습니다. 이 파일만 읽어서는 어느 앱이 어느 환경에 배포될지 알 수 없다는 것, 그것이 이 층의 미덕입니다.

그 위에 환경 층을 얹습니다. 환경 층은 "어디서"를 결정합니다. 이 환경이 바라보는 소스 브랜치, 도메인, 실제로 동작해야 할 앱들을 선언합니다. 기본 층에서 꺼져 있던 앱 중 일부가 여기서 비로소 켜집니다. dev 환경이든 prod 환경이든 같은 자리에 같은 모양으로 한 장씩 존재하고, 두 장을 나란히 놓고 diff를 뜨면 환경 사이의 차이가 그대로 드러납니다.

가장 위에는 변종 층이 있습니다. 같은 prod 안에서도 고객 테넌시, 지역, 특수한 배리언트에 따라 값을 한 번 더 조정해야 할 때가 있습니다. 이 층은 꼭 필요할 때에만 얹히고, 전체가 아니라 일부 앱의 몇몇 값만 교체합니다. 없을 수도 있다는 점이 성격을 말해 줍니다 — 변종 층은 예외를 다루지, 기본 질서를 다시 쓰지 않습니다.

세 층이 합쳐져 최종 매니페스트가 나옵니다. 템플릿 자체에는 `dev`도, `prod`도, 테넌트 이름도 등장하지 않습니다. 템플릿은 "어떤 앱이 있고, 어떤 모양의 매니페스트를 찍어내는가"만 말합니다. 환경 이름은 값의 층에서만 결정되고, dev와 prod의 차이는 두 환경 층을 diff 한 번으로 확인합니다.


누가 이 층을 조립하는가

값의 층이 잘 쌓여 있어도, "언제 어떻게 클러스터에 반영할지"는 또 다른 문제입니다. flex는 이 일을 GitOps 방식으로 풉니다.

GitOps의 전제는 간단합니다. 매니페스트를 명령으로 클러스터에 밀어넣지 않고, Git을 보고 클러스터가 스스로 맞춰 간다. 로컬에서 `kubectl apply`를 치는 순간이 없어지고, "지금 클러스터에 무엇이 떠 있어야 하는가"의 답이 Git 한 곳에 모입니다. 사람 손이 배포 경로에서 빠지면, 환경 사이에 생기던 미묘한 표류도 같이 사라집니다.

flex에서는 ArgoCD가 이 역할을 맡습니다. 그 위에 한 겹을 더 얹어, 환경 목록을 선언한 뿌리 하나가 자식들을 펼치는 App-of-Apps 패턴으로 짰습니다. 뿌리는 자기 아래에 어떤 서비스가 어떤 값으로 배포돼야 하는지를 선언한 목록을 바라봅니다. 뿌리가 한 번 동기화되면 자식들이 한꺼번에 클러스터에 등록되고, 각 자식은 다시 자기가 책임질 서비스 하나를 Git에서 찾아와 동기화합니다. 뿌리만 심어 두면 나머지는 클러스터가 알아서 펼쳐 갑니다.

이 구조가 주는 이득은 세 가지입니다.

첫째, 환경 = Git 브랜치라는 단순한 규칙이 성립합니다. dev 클러스터는 dev 브랜치를, prod 클러스터는 prod 브랜치를 봅니다. "배포한다"는 행위가 "Git 히스토리에 커밋이 쌓인다"와 동의어입니다. 누가 언제 무엇을 바꿨고 그 변경이 어느 환경으로 흘러갔는지는 git log에 그대로 남습니다.

둘째, 롤백이 revert 한 번으로 끝납니다. 문제가 난 커밋을 되돌리는 것과 "prod를 이전 상태로 돌린다"가 같은 행위입니다. 별도의 배포 도구를 다시 켜서 이전 버전을 찾아 누를 필요가 없고, 롤백의 흔적도 Git에 남습니다.

셋째, 새 앱을 추가할 때 파이프라인 코드가 바뀌지 않습니다. 뿌리는 선언된 목록을 보고 자식들을 펼치므로, 새 서비스가 하나 생겼다는 건 환경 층 values에 한 줄이 추가되고 해당 환경에서 켜진다는 뜻일 뿐입니다. 그 이상은 없습니다.


이미지도 누군가는 만들어야 한다

여기까지는 "이미 만들어진 이미지를 어떻게 환경별로 풀어 놓느냐"의 이야기였습니다. 그런데 그 이미지를 만들어 내는 파이프라인 — 빌드, 테스트, 태깅, 푸시 — 은 누군가 환경을 알아야 할 것처럼 보입니다. dev 이미지는 dev 레지스트리로, prod 이미지는 prod 레지스트리로 가야 하니까요. "환경을 모른다"는 규율이 가장 쉽게 깨지는 자리가 여기입니다.

이미지 빌드는 선언형 매니페스트 한 장으로 끝나지 않습니다. 테스트가 먼저 돌고, 보안 스캔을 통과해야 하고, 결과에 따라 빌드를 계속할지 말지가 갈립니다. 빌드된 이미지에는 태그를 붙여 레지스트리로 푸시하고, 모노레포라면 변경된 모듈만 골라 빌드하는 조건 분기도 필요합니다. 순차적이고 조건부이며 단계마다 실패 처리를 따로 해야 하는 이 흐름은 명령형 파이프라인이 자연스럽게 받아냅니다.

flex는 이 역할을 이미 Jenkins로 운영 중이었습니다. Jenkins가 이상적이어서 택한 게 아니라, Jenkins 클러스터를 다른 도구로 이주시키는 비용보다 현실의 Jenkins를 같은 규율 아래 길들이는 쪽이 실용적이어서입니다. 질문은 "무엇을 쓸 것인가"에서 "이것도 환경을 모르게 만들 수 있는가"로 옮겨 갔습니다.


Jenkinsfile에도 같은 규율

Jenkinsfile은 파이프라인을 코드로 적은 파일입니다. 중요한 건 dev 이미지 빌드든 prod 이미지 빌드든 같은 파일 하나가 실행된다는 점입니다. Helm 템플릿이 values 층에서 환경을 주입받듯, Jenkinsfile도 환경을 모르고 파라미터로 환경을 받습니다. `env`, `variant`, `product` 같은 값이 바깥에서 들어오고, 파이프라인은 그 값을 받아 자기 할 일만 합니다. 템플릿에 values 층이 얹히는 Helm의 모양이, 파이프라인에 파라미터가 주입되는 Jenkins의 모양으로 반복될 뿐입니다.

규율이 깨지는 순간은 늘 비슷합니다. 누군가 "dev에서만 임시로 한 줄 넣어 두자, 다음 주 안에 정리할게"라고 말합니다. 긴급한 이슈가 걸려 있고, values를 건드리려면 리뷰가 필요하고, 파이프라인에 `if`를 한 줄 박는 쪽이 10분이면 끝납니다. 합리적으로 들립니다. 그렇게 한 줄이 들어갑니다.

문제는 그 한 줄이 혼자 살지 않는다는 점입니다. 다음 달에 비슷한 상황이 오면, 선례가 있으니 누군가 똑같은 방식으로 한 줄을 더 넣습니다. 이어서 qa 분기가 생기고, prod에만 도는 로직이 슬쩍 얹힙니다. flex 초기에도 Jenkinsfile 안에 `if (env == "prod")` 블록이 서너 군데 박혀 있던 시기가 있었습니다. 겉으로는 "하나의 파이프라인"이었지만, 안에는 환경 수만큼의 다른 파이프라인이 한 파일에 엉켜 있던 셈입니다.

그걸 걷어내고 모든 조건을 values로 되돌려 보내는 리팩토링은 한두 시간에 끝나지 않았습니다. 각 분기가 왜 들어왔는지 추적하고, 같은 의미를 values 층으로 옮기고, 테스트 환경에서 파이프라인이 이전과 같이 동작하는지 확인하는 일이 며칠 이어졌습니다. 그 경험이 남긴 교훈은 간단하지만 무거웠습니다 —한 번 뚫린 경계는 금세 다시 자란다. 이후 리뷰에서는 이 질문 하나가 표준이 됐습니다.

"이 조건은 values로 밀어낼 수 있지 않나요?"

이 한 줄이 사람의 판단에 맡겼던 경계를 다시 구조의 경계로 끌고 들어옵니다. flex의 배포 자동화는 그렇게 하나의 금지 조항을 공유합니다 — 배포 코드는 환경 이름을 직접 읽지 않는다. 환경은 바깥에서 주입한다.


얻은 것: Git이 곧 배포 이력

이 구조가 자리잡은 뒤 flex에서 "배포가 꼬였다"는 이야기는 눈에 띄게 줄었습니다. 배포 상태가 사람의 기억이 아니라 Git 히스토리에 적혀 있기 때문입니다.

프로덕션 이슈가 터졌을 때,
첫 번째 질문은 "지금 prod에 뭐가 올라가 있는가"입니다. 이 구조에서는 prod 브랜치의 HEAD 커밋이 답입니다.
두 번째 질문은 "마지막 변경이 무엇이었는가". git log가 답합니다.
세 번째 질문은 "직전 상태로 돌아가려면". revert 한 번으로 끝나고, 나머지는 클러스터가 감지해 자동으로 동기화합니다.

dev에서 마구 실험하고 싶을 때도 같은 이점을 누립니다. dev 브랜치에 커밋을 쌓으면 곧장 반영되고, prod는 건드리지 않습니다. 수동 `kubectl apply`가 사라지면서, "실수로 prod에 dev 매니페스트를 적용했다"는 유형의 사고는 구조적으로 막힙니다.

덤으로 감사(audit)의 품질이 바뀝니다. 누가, 언제, 어떤 서비스의, 어떤 설정을 바꿔서, 어떤 환경에 반영했는가 — 이 다섯 가지 질문의 답이 모두 Git에 적혀 있습니다. 컴플라이언스 요구사항이 붙는 순간, 그동안 쌓아둔 규율이 별도의 감사 로그 시스템을 만드는 수고를 덜어 줍니다.


다시, "코드가 환경을 모르는 구조"

이 편을 한 문장으로 요약하면 이렇습니다. 템플릿은 "앱이 무엇인가"만, 동기화 도구는 "그 앱을 어느 클러스터에 어떤 정책으로 둘 것인가"만, 값의 층은 "그 앱이 이 환경에서는 어떤 값으로 켜지는가"만 말한다. 세 역할이 서로의 영역을 침범하지 않을 때, 배포 코드는 환경을 몰라도 됩니다. 환경을 아는 건 값의 층 하나뿐이고, 그 층들은 서로 위에 얹히며 diff로 비교할 수 있는 형태로 놓입니다. 이미지 빌드 파이프라인도 예외가 아닙니다 — 같은 규율이 Jenkinsfile 한 장에 그대로 적용됩니다.

이 원리는 본편 5화의 원칙, "코드가 환경을 모르는 구조"를 배포 축에 옮긴 것입니다. 같은 원칙이 인프라 코드에서도 성립할까요. Terraform의 HCL은 이 구조를 어디까지 받아내고, 어디서 한계에 부딪치며, flex는 그 한계를 어떻게 Kotlin으로 우회했을까요.

다음 화에서는,
IaC에 헥사고날이 관통하는 순간. spec 모듈을 Port로, 클라우드 모듈을 Adapter로 삼는 Pulumi + Kotlin의 설계를 이야기합니다.

🚀플렉스팀 채용페이지 바로가기☕flex Private Talk 신청하기

글이 마음에 드셨나요?
공유하기
페이스북링크드인트위터
flex가 궁금하다면? 지금 무료로 체험해 보세요
flex가 궁금하다면? 지금 무료체험하기
  • 기술 블로그
    2026. 4. 23
    [코드가 환경을 모르는 구조 1/7] 코드는 무엇을, 환경은 어디서 - 다시 더 깊이
    본편 '미래를 담아낸 뼈대 5화: 코드가 환경을 모르는 구조'에서 독자들이 던진 질문, 그리고 이 시리즈가 답하려는 것
  • 아티클
    2020. 5. 25
    근태관리, 유연근무제, 그리고 코로나 시대
    코로나, 뉴노멀, 유연근무제, 그리고 근태관리