[미래를 담아낸 뼈대 5/7] 코드가 환경을 모르는 구조

애플리케이션 코드를 넘어서

지난 4화에서 1~3화의 구조가 멀티클라우드, AI, Observability라는 새로운 문제의 출발점을 높여주었다는 이야기를 했습니다. 하지만 그 이야기는 "애플리케이션 코드" 안에 머물러 있었습니다.

flex에서 깎아낸 경계는 애플리케이션 코드에서 끝나지 않습니다. 인프라 코드, 배포 파이프라인, 그리고 개발자의 일상적인 디버깅 도구까지 — 같은 사고방식이 관통하고 있습니다.


코드는 "무엇을", 환경은 "어디서"

Helm + ArgoCD — 배포가 환경을 모른다

flex의 Kubernetes 배포는 Helm 차트 + ArgoCD GitOps로 구성되어 있습니다. 핵심은 3단 오버라이드 구조입니다.

애플리케이션의 기본 설정('values.yaml')이 있고, 그 위에 환경별 설정('values.dev.yaml', 'values.prod.yaml')이 덮어씁니다. 더 특수한 경우에는 배리언트별 설정('values.tt-1.yaml' 등)이 한 번 더 덮어씁니다. 애플리케이션 코드에는 "내가 dev에서 돌아가는지 prod에서 돌아가는지"에 대한 정보가 없습니다. DB 호스트, 외부 서비스 URL, 리소스 크기 — 환경에 따라 달라지는 모든 것은 values 파일이 결정합니다.

ArgoCD는 Git 브랜치와 환경을 매핑합니다. 'dev' 브랜치에 머지하면 dev 클러스터에, 'main' 브랜치에 머지하면 prod 클러스터에 자동으로 배포됩니다. "배포한다"는 행위가 "Git에 머지한다"와 동의어가 됩니다. 누가 언제 무엇을 바꿨는지가 모두 Git 히스토리에 남고, 문제가 생기면 이전 커밋으로 revert하면 ArgoCD가 자동으로 롤백합니다.

Jenkins 파이프라인도 마찬가지입니다. Jenkinsfile은 'env', 'variant' 같은 파라미터를 외부에서 주입받을 뿐, 파이프라인 코드 자체에 환경이 하드코딩되어 있지 않습니다. 같은 파이프라인이 dev 배포에도, prod 배포에도 사용됩니다.

IaC에도 Hexagonal이 관통한다

이 분리가 가장 선명하게 드러나는 곳은 flex의 IaC(Infrastructure as Code)입니다. flex-terraform의 Pulumi 프로젝트는 Kotlin으로 작성된 Gradle 멀티모듈 프로젝트인데, 구조가 백엔드와 놀라울 정도로 닮아 있습니다.

'spec' 모듈에는 'VirtualNetwork', 'KubernetesCluster', 'WorkloadIdentity' 같은 인터페이스가 정의되어 있습니다. 이것이 Port입니다. "네트워크는 VPC ID와 서브넷 목록을 제공해야 한다"는 계약만 있고, 그것이 AWS인지 NCP인지 Azure인지는 모릅니다. 그 아래에 'aws', 'ncp', 'azure' 모듈이 각각의 구현체를 제공합니다. 이것이 Adapter입니다.

새로운 클라우드가 추가될 때 기존 코드에 영향이 없습니다. 인터페이스를 구현하는 새 Adapter 모듈을 만들면 됩니다. 그리고 이 코드는 Kotlin이기 때문에 — 1화에서 이야기한 것과 같은 이유로 — 컴파일러가 설정 오류를 잡아줍니다. HCL로는 'terraform plan'을 돌려야만 알 수 있었던 오류가, 여기서는 빌드 시점에 발견됩니다.

라이프사이클 기반으로 스택도 분리됩니다. 네트워크(변경 주기: 수년), 클러스터(분기), 아이덴티티(주간) — 변경 빈도가 다른 리소스를 하나의 거대한 스택에서 관리하면, 서비스 계정 하나 추가하는데 VPC까지 plan에 포함됩니다. 분리된 스택은 'StackReference'로 연결되는데, 클러스터 코드가 VPC의 출처를 몰라도 됩니다. 'VirtualNetwork' 인터페이스만 알면 됩니다. 백엔드의 Port/Adapter와 정확히 같은 패턴입니다.

같은 원칙, 모든 레이어

이 원칙은 배포 인프라를 넘어 조직 운영의 코드화에도 적용됩니다. GitHub 리포 설정(github-terraform)에서는 리포지토리가 'repositories.yaml'에 선언적으로 정의되고, 타입별 모듈(gitflow, trunk-based 등)이 브랜치 보호 규칙을 적용합니다. Okta SSO 설정(okta-terraform)에서는 Jenkins 모듈 하나가 'domain' 파라미터만 받아서 dev/qa/prod 인스턴스를 찍어냅니다. 모듈은 하나, 환경은 파라미터.

flex의 인프라 전체를 관통하는 설계 원칙은 이것입니다: 코드는 "무엇을" 정의하고, "어디서"는 외부에서 주입한다. Hexagonal Architecture에서 도메인이 인프라를 모르게 하는 것과 같은 사고방식이, 빌드, 배포, 인프라, 심지어 조직의 접근 제어까지 관통하고 있습니다.


전체를 올리지 않고 부분을 검증한다

경계가 명확하면 또 하나의 능력이 생깁니다 — 전체 시스템을 재현하지 않고도 부분을 교체해서 검증할 수 있다는 것. flex에서는 이 능력이 개발자의 일상적인 디버깅 도구로 구현되어 있습니다.

타임머신 — 시간 축을 조작한다

HR SaaS에서 시간은 비즈니스 로직의 핵심 변수입니다. 급여는 월말에 정산되고, 연차는 입사일 기준으로 발생하며, 퇴직금은 퇴사일 기준으로 계산됩니다. "이 사람이 다음 달에 퇴사하면 급여가 어떻게 계산되는가?"를 검증하려면, 실제로 한 달을 기다릴 수는 없습니다.

flex에는 2020년부터 "타임머신"이라는 디버깅 기능이 있습니다. HTTP 요청에 디버그 헤더를 붙이면, 그 요청을 처리하는 서버의 "현재 시간"이 헤더에 지정된 시점으로 바뀝니다. 이게 가능한 이유는 모든 비즈니스 코드가 'Clock' 인터페이스를 통해 현재 시간을 얻기 때문입니다. 'LocalDateTime.now()'를 직접 호출하는 코드가 아니라, 주입된 'Clock'에게 시간을 묻는 코드. 타임머신 모드에서는 이 'Clock'이 디버그 헤더의 시점을 반환하는 구현체로 교체됩니다.

Port/Adapter 패턴의 축소판입니다. "현재 시간을 제공한다"는 Port(Clock 인터페이스)가 있고, 프로덕션에서는 시스템 시계를, 타임머신 모드에서는 헤더 기반 시계를 Adapter로 끼워 넣는 것. 도메인 로직은 어느 쪽이든 신경 쓰지 않습니다.

Rewrite Host — 공간 축을 조작한다

타임머신이 시간을 바꾼다면, Rewrite Host는 공간을 바꿉니다.

개발자가 특정 모듈을 수정하고 있을 때, 전체 시스템을 로컬에 올리는 건 현실적이지 않습니다. 9개 이상의 도메인, Gateway, 인증 서버, DB, Kafka — 전부 로컬에 띄우려면 노트북이 버티지 못합니다. 하지만 dev 환경에서 내가 수정한 코드만 검증하고 싶을 때가 있습니다.

flex의 Rewrite Host는 디버그 헤더 하나로 이 문제를 풀니다. 'flexteam-debug: rewritehost' 헤더를 붙이면, dev 환경의 Gateway가 해당 요청을 개발자의 로컬 서버로 프록시합니다. 나머지 모든 서비스는 dev 환경의 것을 그대로 사용하면서, 내가 수정한 모듈만 로컬에서 돌릴 수 있습니다. 프론트엔드에서도 같은 원리가 적용됩니다 — 'flexteam-debug: rewritehost-mf' 헤더로 특정 마이크로 프론트엔드 앱만 로컬 개발 서버로 라우팅할 수 있습니다.

이것도 결국 같은 패턴입니다. 전체 시스템 중 하나의 Adapter(특정 서비스의 엔드포인트)만 교체하는 것. 나머지 시스템은 건드리지 않고, 내가 보고 싶은 부분만 바꿔 끼웁니다.

같은 사고방식의 세 가지 변주

타임머신은 시간 축을, Rewrite Host는 공간 축을, 그리고 다음 화에서 이야기할 Standalone App은 구조 축을 조작합니다. 세 가지 모두 "전체를 재현하지 않고 부분을 교체해서 검증한다"는 동일한 사고방식입니다. 그리고 이 모든 것이 가능한 이유는, 1화에서 깎아낸 경계 덕분에 교체 가능한 접점이 명확하기 때문입니다.


테스트도 한 컨테이너에서 — Testcontainers 최적화

이터레이션 속도를 이야기하려면, 테스트 인프라를 빠뜨릴 수 없습니다.

flex는 3년 넘게 Testcontainers를 통합 테스트에 사용해왔습니다. MySQL, Elasticsearch, Redis, Kafka — 실제 인프라를 Docker 컨테이너로 띄워서 테스트합니다. 문제는, Spring Boot 테스트에서 '@MockBean'이나 '@SpyBean'을 사용하면 ApplicationContext가 "dirty"되어 새 컨텍스트가 만들어지고, 그때마다 새 컨테이너가 뜬다는 것이었습니다. 멀티모듈 프로젝트에서 수십 개의 테스트 모듈이 각각 컨테이너를 띄우면, CI에서 메모리가 폭발하고 통합 테스트가 10분 이상 걸리는 상황이 벌어졌습니다.

flex는 이 문제를 Gradle BuildService 기반의 자체 플러그인으로 풀었습니다. 핵심 아이디어는 단순합니다 — 하나의 MySQL 컨테이너를 전체 빌드에서 재사용하되, 테스트 모듈별로 논리적 데이터베이스(스키마)를 분리하는 것. Gradle BuildService는 Task 간 상태를 공유하고, 빌드 종료 시 자동으로 정리됩니다. 단일 컨테이너 안에 'database_repo1', 'database_service', 'database_repo2' 같은 데이터베이스를 variant로 생성하고, 각 테스트 모듈이 자기 데이터베이스에만 접근합니다. 스키마 초기화는 Liquibase가 담당합니다.

1화에서 이야기한 "단일 JVM + 논리적 스키마 격리" 패턴이 여기서도 반복됩니다. 프로덕션에서 도메인별 스키마를 분리한 것처럼, 테스트에서도 모듈별 스키마를 분리합니다.
컨테이너는 하나, 격리는 스키마 수준.

결과는 명확했습니다. 통합 테스트 실행 시간이 약 10분에서 2분으로 — 80% 감소.
CI에서 메모리 부족으로 발생하던 스로틀링이 해소되었고, 그만큼 CPU를 더 활용할 수 있게 되었습니다. 더 중요한 건, "테스트가 느리니까 생략하자"는 위험한 신호가 사라졌다는 것입니다. 테스트가 빠르면 사람들은 테스트를 건너뛰지 않습니다.

이 Testcontainers 플러그인도 1화의 Convention Plugin과 같은 사고방식입니다 — 빌드 시스템이 테스트 인프라의 라이프사이클을 관리하고, 개별 개발자는 그 내부를 신경 쓰지 않아도 됩니다.

> 이 내용은 [Testcontainers 최적화: Docker 컨테이너 폭발 해결]에서 더 자세히 다루고 있습니다.


경계가 만드는 이터레이션 속도

이 도구들을 개별적으로 보면 각각 유용한 디버깅 기능입니다. 하지만 전체를 조감하면, 하나의 설계 원칙이 보입니다 — 경계를 명확하게 깎아두면, 그 경계를 따라 교체하고, 주입하고, 우회할 수 있다. 애플리케이션 코드의 Adapter를 교체하든, IaC의 클라우드 Provider를 교체하든, 배포 파이프라인의 환경 values를 교체하든, 디버깅 세션의 시간이나 라우팅을 교체하든 — 모두 같은 동작입니다.

이건 엔지니어의 이터레이션 속도에 직접적인 영향을 줍니다. "전체를 올려야 확인할 수 있다"와 "부분만 바꿔서 확인할 수 있다"의 차이는, 피드백 루프의 길이 차이이고, 결국 하루에 시도할 수 있는 실험의 횟수 차이입니다.

다음 화에서는,
이 구조가 가장 예상 밖의 효과를 발휘하는 지점을 다룹니다. 사람을 위한 규율이 AI 에이전트에도 작동하고, 그 구조가 Code Review의 병목을 어떻게 바꾸는지 살펴보겠습니다.

🚀플렉스팀 채용페이지 바로가기☕flex Private Talk 신청하기
글이 마음에 드셨나요?
공유하기
페이스북링크드인트위터
flex가 궁금하다면? 지금 무료로 체험해 보세요
flex가 궁금하다면? 지금 무료체험하기
  • 기술 블로그
    2026. 3. 30
    [미래를 담아낸 뼈대 4/7] 기반이 열어준 다음 문제
    멀티클라우드 대응, AI 백엔드 통합, Observability 표준화를 가능하게 하는 Hexagonal Architecture 기반 백엔드 설계
  • 아티클
    2020. 5. 25
    근태관리, 유연근무제, 그리고 코로나 시대
    코로나, 뉴노멀, 유연근무제, 그리고 근태관리