아키텍처 가이드 문서는 잊힌다 — flex가 빌드로 구조를 지키는 법

헤드라인

"대용량 트래픽" "복잡한 마이크로서비스" — 개발자 커뮤니티에서 가장 눈길을 끄는 키워드잖아요. 초당 수십만 건 처리하고, 수백 개 서비스를 오케스트레이션하는 이야기. 기술 블로그를 쓸 때도 이 숫자들이 앞에 오면 주목도가 확 달라지고요.

근데 모든 서비스가 그런 문제를 풀고 있진 않아요. flex는 HR SaaS예요. 출퇴근 시간에 트래픽이 몰리긴 하지만, 초당 수십만 건이 들어오는 서비스는 아닙니다. 트래픽이 핵심 도전이 아니라는 뜻이에요.

그러면 뭐가 어려운 건데?

9개 도메인이 서로 데이터를 물고 있다

flex의 제품 라인업을 보면 감이 와요. 인사 정보, 인사 관리, 근무, 휴가, 워크플로우, 급여, 채용, 평가, 목표 관리, 캘린더, 인사이트, 비용 관리. 이 도메인들이 독립적으로 존재하지 않는다는 게 문제예요.

급여를 계산하려면 근무 시간 데이터가 필요하고, 평가를 진행하려면 구성원 정보와 조직 구조를 알아야 하고, 인사이트는 모든 도메인의 데이터를 종합해야 의미 있는 분석을 내놓을 수 있습니다.

여기에 권한의 복잡성이 더해져요. "구성원 A가 급여팀 소속이면서, B부서의 매니저이고, C프로젝트의 열람자일 때, 이 사람이 D의 급여 명세서를 볼 수 있는가?" 이 질문 하나에 답하려면 조직 구성원 여부, 하위조직 관계, 유저 그룹, 오브젝트 간 관계, 2차 인증 여부까지 동원해야 합니다.

트래픽은 서버를 늘리면 돼요. 하지만 9개 이상의 도메인을 개발하는 여러 팀이 같은 규칙 안에서 코드를 짜고, 도메인 간 데이터 정합성을 유지하며, 복잡한 권한 체계를 일관되게 적용하는 건 — 서버를 아무리 늘려도 해결 안 되는 문제예요. 아키텍처로만 풀 수 있습니다.

MSA도 모놀리스도 아닌 길 — Hexagonal Modular Monolith

전통적인 모놀리스는 시작은 빠르지만, 코드가 커지면서 도메인 간 경계가 무너지고, 한 곳을 고치면 다른 곳이 깨지는 상황이 반복돼요. 그래서 많은 팀이 MSA로 넘어가는데, 서비스 간 통신, 분산 트랜잭션, 배포 파이프라인 관리 등 운영 비용이 급격히 올라갑니다. 9개 이상의 도메인이 서로 데이터를 참조해야 하는 HR SaaS에서 모든 도메인을 독립 서비스로 분리하는 건 득보다 실이 컸어요.

flex가 선택한 건 Hexagonal Modular Monolith. 하나의 배포 단위 안에 있지만, 각 도메인은 명확한 모듈 경계를 가집니다. 각 모듈은 헥사고날 아키텍처(Ports & Adapters)를 따르고요. 도메인 로직이 중심에 있고, 외부와의 통신은 Port(인터페이스)와 Adapter(구현체)를 통해서만 이루어져요.

도메인 로직이 데이터베이스나 외부 API 같은 인프라에 직접 의존하지 않는다는 뜻이고, 모듈 간의 결합도 Port를 통해서만 이루어진다는 뜻이에요.

`flex-skeleton`이라는 헥사고날 도메인 모듈 템플릿이 있어서, 새로운 도메인을 시작할 때 이 스켈레톤을 기반으로 모듈을 만들면 Port/Adapter의 디렉토리 구조, 의존성 방향, 계층 분리가 자동으로 잡힙니다. "어떻게 구조를 잡지?" 고민부터 하지 않아도 됩니다.

이 아키텍처의 진짜 위력은 배포 형태를 바꿀 때 드러나요.

단일 런타임 — 모든 도메인이 하나의 JVM

단일 런타임 — 모든 도메인이 같은 프로세스에서 함수 호출로 통신해요. DB는 같은 인스턴스를 공유하되, 도메인별로 논리적 스키마가 격리되어 있습니다.

부분 분리 — Recruiting만 별도 런타임

부분 분리 — Recruiting만 별도 런타임으로 분리했어요. Core HR과 Payroll은 여전히 함수 호출이고, Recruiting과의 통신만 REST/Kafka Adapter로 교체됩니다. Domain + Port 코드는 변경 0줄.

완전 분리 — 각 도메인이 독립 런타임

완전 분리 — 모든 도메인을 독립 런타임으로 분리해도, 바뀌는 건 Adapter뿐이에요. 안쪽의 Domain + Port는 세 그림 모두 동일합니다.

변경 0줄. 이 세 글자가 Hexagonal Architecture의 힘이에요.

문서가 아니라 빌드가 지킨다

Gradle Convention Plugin 구조

구조를 정하는 건 첫 단계일 뿐이에요. 진짜 어려운 건 그 구조를 유지하는 겁니다. 아키텍처 가이드 문서를 아무리 잘 써도, 마감에 쫓기면 사람은 지름길을 택해요. "이번만 이렇게 하자"가 쌓이면, 어느새 가이드 문서는 현실과 동떨어진 이상향이 됩니다. (다들 한 번쯤은 봤을 거예요.)

flex는 이 문제를 Gradle Convention Plugin으로 풀었어요. 하나둘이 아닙니다.

`name-policy-plugin`은 멀티모듈 프로젝트에서 모듈의 group name을 계층 구조에 맞게 자동으로 해결해요. 200개 넘는 모듈이 `{도메인}:{레이어}` 형태로 구성될 때 group:module 충돌을 방지합니다. `module-hierarchy-settings-plugin`은 이 대규모 모듈 선언을 계층적 DSL로 작성할 수 있게 해서, `settings.gradle.kts`가 곧 프로젝트의 아키텍처 지도 역할을 해요.

`build-recipe`는 모듈의 타입(예: `kotlin-boot-mvc-application`, `kotlin-boot-jdbc-repository`)에 따라 표준화된 빌드 설정을 자동 적용합니다. 개별 모듈의 `build.gradle.kts`에서 의존성이나 플러그인을 직접 나열할 필요가 없어요. `gradle.properties`에 `type=kotlin-boot-mvc-application` 한 줄이면 Spring Boot, Web, Security, 테스트 프레임워크 등이 자동 구성됩니다.

`version-management`는 사내·외부 라이브러리 버전을 3단계 우선순위 시스템으로 중앙 관리해요. 개별 모듈에서 버전을 직접 명시하면 오히려 관리에서 벗어나기 때문에, 버전 없이 의존성만 선언하는 게 올바른 사용법이에요. 버전은 플러그인이 결정합니다.

`publish-dependency-validator-plugin`은 한 단계 더 나가요. 외부에 퍼블리시되는 라이브러리 모듈이 내부 전용 모듈에 의존하고 있으면, 빌드를 막아버려요. 이 의존성이 빠져나가면 소비자 프로젝트에서 빌드가 깨지는데, "나중에 알게 되는" 문제를 "지금 막는" 겁니다.

이 플러그인들이 하는 일의 본질은 하나예요. 아키텍처 규칙을 컴파일 타임에 물리적으로 강제하는 것. 허용되지 않은 방향으로 다른 모듈에 의존하려고 하면, 코드가 빌드되지 않습니다. 문서에 "이 방향으로 의존하면 안 됩니다"라고 쓰는 대신, 빌드 자체가 실패해요.

가이드 문서는 잊힐 수 있지만, 빌드가 깨지는 건 무시할 수 없으니까요.

플랫폼 팀이 만든 게 아니다

"별도의 플랫폼 팀이 있었나요?" 아니에요. `flex-skeleton`이나 Gradle Convention Plugin 같은 도구들은 별도 조직이 위에서 내려준 게 아니에요. flex의 엔지니어들이 스쿼드 체제에서 각자의 도메인 목표에 집중하면서도, "이 구조가 다른 팀에도 통하는가"를 함께 고민한 결과물이에요.

반복되는 문제를 보면서 "이건 공통으로 뽑을 수 있겠다"는 판단이 자연스럽게 나왔고, 그 판단을 실행에 옮겼습니다. 누군가 "이렇게 하세요"라고 지시한 게 아니라, 엔지니어들이 "이건 이래야 한다"고 스스로 판단하고 움직인 거예요. 물론 이 진화를 플랫폼 관점에서 이끈 사람은 있었지만, 전체 엔지니어가 함께 채택하고 발전시킨 결과물입니다.

모든 모듈이 같은 구조라는 건, 공통 인프라가 "기대한 대로" 동작한다는 뜻이에요. 이 일관성이 다음 이야기의 전제가 됩니다. 도메인 간 데이터 전달이라는 문제를 어떻게 풀었는지 — 그건 시리즈 다음 편에서 나온다고 해요.

HR SaaS의 진짜 어려운 문제는 트래픽이 아니라 도메인 간 결합이라는 선언. 그리고 그 문제를 문서가 아니라 빌드로 지키겠다는 전략. 이 두 가지가 이 글의 전부예요. 200개 모듈을 같은 규칙 안에 넣는 건, 결국 도구가 아니라 합의의 문제니까요.