[인프라를 소프트웨어처럼 3/5] 환경은 브랜치에서 태어난다: Environment Variant

기술 블로그

지난 글 끝의 그 사고에서 시작합니다

2화는 한 장면에서 끝났습니다. 환경은 만들기 어려웠고, 지우기는 더 어려웠으며, 그래서 아무도 지우지 않은 채 방치된 환경 하나가 어느 날 공유 dev 전체를 멈춰 세웠습니다. 실제로 그런 일이 한 번이 아니었습니다. 낡은 환경 하나가 매니페스트 정합성 검사를 깨뜨려 배포 파이프라인을 막은 적이 있었고, 또 한번은 오래된 환경 하나 때문에 dev의 앱들이 일제히 재시작 오류를 내며 쓰러졌습니다. 두 경우 모두 해법은 똑같았습니다 — 그 낡은 환경을 지우는 것.

이상한 일이었습니다. 환경 하나가 죽으면 그 환경만 죽어야 정상인데, 죽지 못하고 낡아 가던 환경이 도리어 멀쩡한 공유 환경을 끌고 넘어졌습니다. 문제는 개별 사고가 아니라 그 사고를 낳은 구조였습니다. 우리는 그 구조를 처음부터 다시 보기로 했습니다.


"저 dev 잠깐 점령할게요"가 생기는 순간

구조의 출발점은 단순한 산수입니다. 공유 dev 환경은 하나뿐인데, 거기에 무언가를 통합 검증해야 하는 사람은 여럿입니다. 그래서 채널에는 이런 슬랙이 주기적으로 올라옵니다 — "저 Jenkins dev 잠깐 점령하겠습니다", "브랜치 배포 잠깐 하겠습니다." 한 사람이 dev를 자기 브랜치로 덮어쓰는 동안 나머지는 기다립니다. 환경 점유가 직렬화되고, 그 줄 끝에서 충돌과 대기가 쌓입니다.

이 병목은 시간이 갈수록 심해졌습니다. flex에서 한 사람이 FE·BE·모바일의 경계를 넘나들며 기여하는 일이 늘었고, AI 에이전트를 곁에 두면서 한 번에 여러 레포를 동시에 건드리고 검증하는 속도가 폭증했습니다. 그런데 그 변경을 통합해 보려면 dev 전체에 올려야 했고, dev는 영향 범위가 너무 커서 함부로 점령할 수 없는 자원이었습니다. 결론은 명확했습니다 — 각자 자기만의 격리된 환경이 필요하다.

그런데 격리 환경을 만드는 일이 일곱 단계였습니다. 부트스트래퍼의 values.dev.yaml에 환경을 정의하고, 차트 오버라이드 값을 적고, 앱마다 managed 오버라이드를 더하고(앱이 N개면 N번), dev에 PR을 머지하고, ArgoCD에서 부트스트래퍼를 수동으로 동기화하고, 환경 앱 묶음을 한 번 더 수동 동기화하고, 마지막으로 Jenkins에서 환경을 골라 배포합니다. 라우팅을 붙이려면 gateway와 istio까지 이해해야 했습니다. 설정이 app-of-apps 차트 구조와 여러 values 파일에 흩어져 있으니, 그 적절한 지점을 전부 이해한 사람만 환경 하나를 세울 수 있었습니다.

만들기가 이렇게 어려우면 지우기는 더 어렵습니다. 어디에 무엇을 얼마나 흩뿌렸는지 본인도 헷갈리니, 삭제는 위험한 작업이 되고 다시 만들 엄두가 안 납니다. 게다가 gitflow 구조 위에서 dev에서 파생된 환경 브랜치는 rebase가 사실상 불가능했습니다 — 환경은 만들어 두는 순간부터 낡기 시작했습니다(outdated). 지금 어떤 환경이 살아 있고 누가 쓰는지 한눈에 볼 방법도 없었습니다(가시성 0). 만들기 어렵고, 지우기 어렵고, 낡으면 위험한 — 2화 끝의 그 사고는 이 악순환의 출구였던 셈입니다.

Before — 공유 dev 하나를 두고 점유가 직렬화되고, 격리 환경 하나를 세우는 데 7단계가 필요했으며, 그 비용이 방치를 낳고 방치된 환경이 dev 전체를 마비시켰습니다.

용어 — 이 글의 variant는 그 variant가 아닙니다.
backend 연재 「Variant와 스냅샷 캐시」variant는 테스트 컨테이너 계열을 식별하는 값 객체(테스트 축)였습니다. 이 연재의 Environment Variant는 브랜치로 만드는 격리된 실행 환경(공간 축의 운영 확장)입니다. 이름은 같고 축은 다릅니다.


flex에서 variant는 무엇인가

본격적으로 들어가기 전에 용어를 한 줄로 정리하겠습니다. flex에서 variant는 dev 클러스터 안에, 개발자가 브랜치 하나로 통째로 띄우는 격리된 통합 테스트 환경 한 벌입니다. 내가 바꾼 앱은 flex-dev-{이름}-{앱}으로 복제되고, flex-{이름}.dev.flexis.team이라는 전용 주소로 들어가면 그 환경으로 진입합니다.

두 가지만 기억하면 됩니다. 하나, 항상 떠 있는 그 기준 환경을 우리는 baseline이라 부릅니다. variant는 baseline을 통째로 복제하는 게 아니라, 그 위에 바꾼 앱만 덮는 얇은 층입니다. 둘, variant의 생성과 소멸이 git 브랜치에 묶여 있습니다. 브랜치를 push하면 환경이 태어나고, 지우면 사라집니다. 그래서 개발자는 환경을 "신청"하지 않고 브랜치를 "push"합니다. 이 두 가지가 어떻게 가능한지가 이 글의 나머지입니다.


portal이 환경을 만들면 안 되는 이유

첫 설계안은 직관적이었습니다. 셀프서비스 포털(portal)을 만들고, 사용자가 폼을 채우고 버튼을 누르면 포털이 매니페스트 저장소를 직접 수정(mutation)하게 하자. 포털이 똑똑하게 PR을 만들어 주면, 사용자는 7단계의 디테일을 몰라도 됩니다. 자연스러운 출발점이었습니다.

그런데 이 안을 들여다볼수록 한 가지가 계속 걸렸습니다. 포털이 매니페스트를 고쳐 봐야 그 변경은 결국 PR이고, PR은 리뷰·머지라는 게이트를 거쳐야 합니다. 게이트가 남아 있는 한, 우리는 "환경 만들기"를 진짜로 쉽게 만든 게 아니라 클릭 한 번 뒤에 숨긴 것뿐입니다. 더 나쁜 건, 포털이 똑똑해질수록 매니페스트 구조의 복잡성을 포털 코드가 떠안는다는 점입니다. 차트 구조가 바뀌면 포털도 따라 바뀌어야 하고, 포털 자체가 또 하나의 운영 부담이 됩니다.

그래서 우리는 판단했습니다. "리뷰 게이트가 남으면 더 쉬워지지 않는다." 이 한 문장이 설계를 정반대로 뒤집었습니다.


환경은 브랜치에서 태어난다

채택한 안은 환경의 생명주기를 ArgoCD ApplicationSet에 위임하는 것이었습니다. ApplicationSet의 SCM Provider Generator가 저장소의 브랜치 목록을 직접 감시하다가, 약속된 컨벤션의 브랜치가 나타나면 그에 대응하는 환경 앱 묶음을 자동으로 생성하고, 브랜치가 사라지면 자동으로 정리(prune)합니다. 사용자가 매니페스트의 어느 줄을 어떻게 고쳐야 하는지는 더 이상 알 필요가 없습니다. 규칙은 한 문장으로 줄어듭니다.

dev-variant/{이름} 브랜치를 push하면 그 이름의 환경이 태어나고, 브랜치를 지우면 환경이 사라진다.

핵심은 ApplicationSet에 박은 브랜치 매칭 한 줄입니다. 이 패턴에 걸리는 브랜치마다 ArgoCD가 환경 하나를 통째로 찍어냅니다.

# ApplicationSet — 브랜치가 곧 환경의 단위
generators:
  - scmProvider:
      github: { organization: flex-team, allBranches: true }
      filters:
        - branchMatch: "dev-variant/.*"   # 이 컨벤션의 브랜치마다
template:                                  # 환경 한 벌을 통째로 생성/prune
  metadata: { name: "flex-dev-{{ branch }}-apps" }

효과는 분명했습니다. 일곱 단계가 한 단계가 됐습니다 — 브랜치를 push하거나, 포털 폼에서 한 번 클릭하면 끝입니다(포털 버튼도 사실은 "브랜치를 만들어 달라"는 요청일 뿐입니다). 생성용 PR은 머지하지 않고 Draft로 둡니다. 브랜치가 존재한다는 사실만으로 ArgoCD가 환경을 인식하기 때문입니다. 약 15분이면 환경이 가동됩니다. 포털이 지고 있던 매니페스트 변경 책임의 약 70%가 그대로 사라졌습니다. 그리고 "저 dev 점령하겠습니다"라는 슬랙도 사라졌습니다 — 이제 각자 자기 브랜치에 자유롭게 push하면 그만이니까요.

무엇보다 중요한 건 브랜치의 생명주기가 곧 환경의 생명주기가 됐다는 점입니다. 브랜치를 지우면 네임스페이스도, 라우팅도, 배포된 앱도 함께 회수됩니다. 환경의 진실은 포털 DB가 아니라 git에 있습니다. 지금 어떤 환경이 살아 있는지 알고 싶으면 브랜치 목록을 보면 됩니다. 2화 끝의 사고를 낳았던 "지우기 어렵다"가, 여기서 "브랜치를 지운다"로 접혔습니다.

After — dev-variant/foo 브랜치 push를 ApplicationSet이 감지해 namespace·라우팅·배포까지 환경 한 벌을 만들고, 브랜치를 지우면 그 환경 전체를 회수합니다. push=생성, 삭제=회수.


variant는 base를 덮어쓴다, 그리고 자기 주소를 받는다

오해를 먼저 풀어야 합니다. variant는 dev 환경을 통째로 복제한 것이 아닙니다. dev에는 늘 떠 있는 기준 환경 한 벌, 곧 baseline(flex-dev)이 있고, variant는 그 baseline 위에 내가 건드린 앱만 덮어쓰는 얇은 층입니다. variant를 만들 때 카탈로그에 적는 것도 바꿀 앱의 목록뿐입니다. 그 앱들만 flex-dev-{이름}-{앱}으로 복제되고, 손대지 않은 나머지 앱은 baseline의 것을 그대로 씁니다.

그래서 variant는 가볍습니다. 수십 개의 앱을 매번 복제했다면 환경 하나가 곧 클러스터 하나만큼 무거웠겠지만, 바꾼 앱이 두세 개라면 variant도 그만큼의 파드만 늘어납니다. 가벼우니 여러 개가 동시에 떠 있어도 부담이 적습니다.

variant가 태어나면 전용 주소도 함께 생깁니다. flex-{이름}.dev.flexis.team 형태의 host가 variant마다 붙고, 그 주소로 들어오면 해당 variant로 진입합니다. 개발자는 자기 환경의 URL 하나만 알면 됩니다. 다만 이 주소는 variant 전용 로드밸런서가 새로 생기는 게 아닙니다. 모두가 함께 쓰는 하나의 입구에 host 하나가 더 등록되는 것입니다. 그래서 variant 설정에는 이 host와 라우팅이 반드시 포함돼야 합니다. 빠지면 들어오는 길 자체가 없으니까요.

요청이 실제로 어떤 길을 지나는지 따라가 보겠습니다. 바깥에서 온 요청은 먼저 공유 ALB(외부 로드밸런서)와 Istio ingressgateway(클러스터로 들어오는 입구)를 지납니다. 이 입구는 dev 전체가 하나를 같이 쓰고, 들어온 host(flex-pay-v2.dev…인지 *.dev…인지)로 어느 variant인지만 가른 뒤, 요청을 그 host에 해당하는 variant의 gateway로 넘깁니다. 진짜 분기는 여기서 일어납니다. 각 variant는 자기 gateway를 하나 갖고, 그 gateway가 앱별로 "이 variant에 있는 앱이면 variant pod로, 없으면 baseline pod로" 나눕니다.

variant와 baseline은 같은 ALB·Istio 입구를 host로만 구분합니다(별도 LB가 아닙니다). 그다음 그 variant의 gateway가 앱별로, 바꾼 payroll은 variant pod(flex-dev-pay-v2-payroll)로, 안 바꾼 core·time-tracking은 baseline pod(flex-dev-dev-*)로 보냅니다. 런타임 헤더가 아니라 렌더 시점에 정해진 라우팅입니다.

중요한 건 이 분기가 런타임 마법이 아니라 선언으로 박힌다는 점입니다. host로 어느 variant인지 갈린 뒤, 그 variant의 gateway 설정에 각 앱의 목적지가 이미 적혀 있습니다. "이 variant에 그 앱이 켜져 있으면 variant pod(flex-dev-pay-v2-payroll)로, 아니면 baseline pod(flex-dev-dev-payroll)로." variant를 렌더할 때 같은 차트가 한 번 더 그려지면서 이 경로가 정적으로 결정됩니다. 그래서 같은 요청이라도 바꾼 앱은 사본으로, 나머지는 baseline으로 흩어집니다. 헤더로 판단하거나 variant마다 LB를 따로 두는 게 아니라, 빌드 시점에 이미 정해진 경로입니다.


그래서 트래픽은 이렇게 흐른다

경로를 따라가 보면 차이가 분명해집니다. baseline으로 들어온 요청은(주소가 *.dev.flexis.team) 공유 ALB와 Istio 입구를 지나 baseline의 gateway(flex-dev-dev-gateway)로 가고, 거기서 payroll·core·time-tracking 모두 baseline pod(flex-dev-dev-*)로 향합니다. 평소 공용 dev를 쓰던 것과 똑같습니다.

같은 사람이 payroll만 바꾼 flex-pay-v2.dev.flexis.team으로 들어오면, 경로의 앞부분은 토씨 하나 바뀌지 않습니다. 같은 ALB, 같은 입구를 지나고 host만 다를 뿐입니다. 달라지는 곳은 이 variant의 gateway입니다. pay-v2의 gateway가 내가 바꾼 payroll 요청은 내 사본(flex-dev-pay-v2-payroll)으로, 손대지 않은 core·time-tracking은 baseline pod(flex-dev-dev-*)로 보냅니다. 한 번의 사용 흐름 안에서 일부 요청은 내 코드를, 나머지는 baseline을 탑니다.

그래서 variant는 "또 하나의 통째 dev"가 아니라 baseline 위에 내 변경만 얹은 뷰(view)에 가깝습니다. 트래픽으로 보면 그 성격이 그대로 드러납니다. 입구는 공유, 갈리는 곳은 그 variant의 gateway 한 곳, 나머지는 baseline 그대로입니다. 환경 하나를 띄우는 비용이 "바꾼 앱 몇 개의 파드"로 줄어드는 것도 바로 이 구조 덕분입니다.

variant가 하나가 아니라 여럿이어도 그림은 그대로 확장됩니다. 아래는 baseline 하나 위에 두 팀이 각자 variant(자기 gateway 포함)를 띄운 모습입니다. 한 팀은 payroll을, 다른 팀은 core를 바꿨습니다.

baseline 하나 위에 variant가 여럿 얹혀도 구조는 그대로입니다. variant마다 자기 gateway(flex-dev-pay-v2-gateway, flex-dev-core-x-gateway)를 갖고 자기가 바꾼 pod(payroll, core)만 띄우며, 안 띄운 앱은 세 gateway 모두 공유 baseline pod pool로 보냅니다. 그래서 variant가 여러 개여도 늘어나는 건 "각자 바꾼 pod 몇 개"뿐입니다.


만들기 쉬우면, 지우기도 쉬워야 한다

환경을 쉽게 찍어낼 수 있게 되면 거꾸로 새로운 문제가 생깁니다 — 난립과 방치입니다. 2화 끝의 사고가 알려 준 교훈이 바로 이것이었습니다. 쓰고 잊은 환경이 쌓이면 그게 곧 비용이고 위험입니다. 그래서 회수를 처음부터 설계에 넣었습니다.

한 번은 잠들어 있던(dormant) 환경 열다섯 개를 한꺼번에 걷어낸 적도 있고,30일 동안 손대지 않은 브랜치는 알림으로 회수 대상이 되도록 했습니다. 여기서 "push로 태어나고 삭제로 사라진다"는 규칙의 진가가 드러납니다. 브랜치가 곧 환경이라면, 회수는 특별한 절차가 아니라 브랜치를 지우는 일상입니다. 더 이상 "어디를 얼마나 지워야 하나"를 고민할 필요가 없습니다.

수치로 본 변화 — 생성은 7단계에서 1단계로, 포털의 매니페스트 변경 책임은 약 70% 줄었고, dormant 환경 15개를 한 번에 정리했으며, 30일 방치 브랜치는 회수 대상으로 알립니다.


「Rewrite Host」가 남긴 숙제를 환경 단위로

공간 축을 다룬 backend 연재의 「Rewrite Host」는 노트북에서 서비스 하나를 갈아 끼우는 데까지 갔습니다. 같은 Gateway를 두고 라우팅 타깃 하나(Rewrite Host)만 교체하는 방식이었죠. 우리가 한 일은 그 작업을 환경 전체를 복제하는 운영 레벨로 끌어올린 것입니다. 같은 ApplicationSet 템플릿을 불변으로 두고 교체되는 쪽을 "라우팅 타깃 하나"에서 "브랜치 하나 = 격리 환경 전체"로 넓혔을 뿐, 원리는 똑같습니다.

[코드가 환경을 모르는 구조 5/7화]에서 "첫 진입 요청만"이라는 숙제를 남겼다면, Environment Variant는 그 숙제를 환경 단위에서 매듭짓습니다. 환경을 브랜치로 만들 수 있게 되면 그만큼 더 자주, 더 가볍게 새 환경을 띄워 볼 수 있습니다.


다음 화 ▸ plan은 동작을 모른다 — 환경을 이렇게 브랜치로 찍어내려면, 그 아래 인프라 자체가 테스트 가능하고 재현 가능해야 합니다. terraform plan이 보여 주지 못하는 "동작"을, apply 전에 띄워 보고 검증하는 이야기.

🚀플렉스팀 채용페이지 바로가기

☕flex Private Talk 신청하기

글이 마음에 드셨나요?
공유하기