서비스가 성장함에 따라 도메인은 함께 비대해집니다. 초기에는 단순했던 기능들이 점점 얽히기 시작하고, 특정 기능 하나를 수정하기 위해 관련 없는 코드까지 들춰봐야 하는 상황이 자주 발생합니다. 팀원 간의 커밋은 빈번히 충돌하고, 사소한 수정에도 예기치 못한 장애가 발생하면서 점점 ‘건드리면 안 되는 코드’가 늘어나게 됩니다. 새로운 기능을 추가할 때마다 전체 구조를 다시 이해해야 하는 일도 반복됩니다.
이러한 문제는 보통 하나의 도메인이 지나치게 많은 책임을 지고 있을 때 발생합니다. 단순히 코드가 복잡해졌다는 수준을 넘어서, 시스템 전체의 복잡도가 임계점을 넘어섰다는 신호이기도 합니다.
그리고 결국 우리는 이 결론에 도달하게 됩니다.
“도메인을 분리해야 하지 않을까?”
WMS플랫폼팀 역시, WMS라는 거대한 하나의 도메인 안에서 유사한 문제를 마주하게 되었고, 이를 해결하기 위해 도메인 분리 작업을 시작하게 되었습니다.
WMS플랫폼팀은 B마트 서비스의 물류 플랫폼 중에서도 WMS(Warehouse Management System)를 담당하고 있습니다.
WMS는 창고의 상태를 가시화하고, 무엇이, 어디에, 얼마나 있는지를 신뢰할 수 있는 데이터로 관리하는 시스템입니다. 이 목적을 달성하기 위해 다양한 기능과 프로세스를 제공하며, 이러한 기능들은 다음의 세 가지 주요 도메인으로 나눌 수 있습니다.
- 입고
외부에서 창고로 물류가 유입되는 전 과정을 관리하는 도메인입니다.- 입고는 창고 내 재고가 새롭게 생성되는 과정을 담당합니다.
- 일반적으로는 발주를 기반으로 공급업체로부터 물품이 입고되며, 입고 검수 및 적치까지 포함됩니다.
- 재고
창고 내에 현재 보관 중인 물품의 상태와 수량을 관리하는 도메인입니다.- 상품별 수량, 위치, 유효기간, 상태(정상/불량/보류 등) 등의 속성 정보를 관리합니다.
- 다른 모든 도메인(입고, 출고, 반품 등)과의 상호작용이 가장 많은 핵심 도메인입니다.
- 출고
창고에서 외부로 물류가 이탈하는 전 과정을 관리하는 도메인입니다.- 출고는 재고를 감소시키는 트리거 역할을 합니다.
- 고객 주문, 다른 센터 이동, 반품 출고 등 다양한 형태를 가지며 피킹, 패킹, 출하 등 물리적 이동의 준비 과정까지 포함됩니다.
기존에는 위의 3개의 도메인을 WMS 라는 하나의 도메인으로 패키지조차 분리하지 않고 운영하고 있었습니다.
이 상황에서 재고 정확도 향상을 위한 고도화 과제가 생겼습니다.
재고 도메인은 다른 도메인들과 서로 강하게 연결되어 있어, 단순한 수정만으로도 연관된 코드들의 수정 범위가 넓어지고, 사이드 이펙트 발생 가능성이 매우 높았습니다. 또한 변경사항으로 인한 영향 범위 분석과 QA에 필요한 리소스 또한 무시할 수 없었습니다.
이러한 상황 속에서 재고 도메인의 고도화를 안전하게 수행하기 위해서는, 먼저 도메인 분리를 수행하는 것이 더 효과적이라는 판단을 내리게 되었습니다.
이 글에서는 재고 도메인을 분리하고 헥사고널 아키텍처를 적용하는 과정과, 개선 내용을 안전하게 배포한 경험을 소개합니다.
기존의 WMS는 각자 서비스를 제공하는 api, consumer, batch 모듈이 하나의 도메인 모듈에 의존하고 있었습니다. 이 서비스들은 멈출 수 없고, 새 기능들을 계속 추가해야 하는 상황에서 도메인 분리를 시작했습니다.
Step1. 재고 도메인 모듈 만들기
먼저, 신규 모듈(domain-inventory)을 생성하여 재고 도메인에 해당하는 클래스들을 모았습니다. 이 과정에서 다른 도메인과의 결합은 최대한 제거했습니다.
1-1 응집도 올리기
가장 먼저 뿔뿔이 흩어진 재고 도메인을 모으는 작업을 진행했습니다.
재고 도메인에 해당하는 클래스를 모으려면 무엇이 재고 도메인에 해당하는 것인가?를 정의해야 했습니다.
단순히 재고 정보에 해당하는 도메인뿐만 아니라, 재고 이력, 재고 실사 등 재고와 관련된 도메인들을 포함하도록 했습니다.
관련 엔터티를 정의하고 기본 리포지토리와 서비스들을 신규 재고 모듈 하위로 이동시켰습니다.
아직은 기존 모듈(domain-wms)에서는 신규 재고 모듈(domain-inventory)을 의존하고 있는 상태입니다.
1-2 결합을 끊어야 하는 대상을 특정하기
기존 모듈과 신규 재고 모듈의 의존성을 제거하기 위해서 재고 도메인과 결합된 입출고 도메인의 로직을 파악해야 했습니다.
가장 빠른 방법으로 기존 모듈에서 신규 재고 모듈의 의존성을 제거하고 빌드가 실패하는 파일들을 별도 모듈(service-wms)로 옮겼습니다. 이후에는 이 모듈을 결합 모듈이라고 부르겠습니다.
이 결합 모듈에 다음과 같은 클래스를 이동했습니다.
- 입고/출고 도메인과 재고 도메인 사이에 연관관계를 선언한 엔터티
- 입고/출고 도메인과 재고 도메인을 직접 join한 QueryDSL 리포지토리
- 위의 엔터티 혹은 리포지토리를 사용한 서비스
- 입고/출고 도메인과 재고 도메인의 서비스를 조합하여 사용하는 서비스
또, 이 과정에서 도메인에서 공통으로 사용하는 마스터 정보를 모아두는 모듈(domain-master)이 추가되었습니다.
1-3 결합도 낮추기 1 – 엔터티, 리포지토리의 결합 제거
목표는 결합 모듈의 도메인 간 결합 로직을 모두 수정하여 재고 도메인은 신규 재고 모듈로 입고/출고 도메인의 로직은 기존 모듈로 보내는 것이었습니다.
먼저 위에서 언급한 4가지 유형의 클래스 중 입고/출고 도메인과 재고 도메인의 서비스를 조합하여 사용하는 서비스을 제외한 엔터티와 리포지토리 관련하여 아래와 같은 작업을 수행하여 의존이 필요한 최소한의 서비스 파일만 남겼습니다.
- 입고/출고 도메인과 재고 도메인 사이에 연관관계를 선언한 엔터티
- 연관관계 제거
- 입고/출고 도메인과 재고 도메인을 직접 join한 QueryDSL 리포지토리
- join 절을 제거하고 서비스 레이어에서 조합
- 위의 엔터티나 리포지토리를 사용한 서비스
- 위 수정 사항으로 이동 가능한 서비스는 각 도메인에 맞게 이동
- 타 도메인의 서비스와 결합된 메서드를 제외하고는 별도 파일로 분리하여 이동
1-4 결합도 낮추기 2 – 서비스 결합 제거
입고/출고 도메인과 재고 도메인의 서비스를 조합하여 사용하는 서비스의 결합은 쉽게 분리할 수 없었습니다. 예를 들어, 입고 작업이 완료되면 입고 작업의 상태를 변경함과 동시에 재고를 생성하거나 증가시켜야 합니다. 이처럼 입고 작업의 상태를 변경하는 입고 도메인 서비스와 재고를 생성하는 재고 도메인 서비스를 함께 조합하는 오케스트레이션 서비스가 필요했습니다.
이런 오케스트레이션 로직을 그대로 하나의 결합 모듈에 모아둘 수도 있었습니다. 하지만 모든 도메인에 접근할 수 있는 이 모듈은 금세 비대해졌고, 결국 다시 하나의 거대한 모놀리스로 되돌아갈 수 있다는 우려가 생겼습니다.
실제로 과거에도 유사한 구조가 점점 복잡해지며 관리가 어려워졌던 경험이 있었기 때문에, 이번에는 같은 실수를 반복하지 않기로 했습니다.
이에 결합 모듈의 완전한 제거를 목표로 삼고, 모듈 간 통신 방식으로 헥사고널 아키텍처를 도입했습니다. port-adapter 패턴(비즈니스 로직을 중심에 두고, 그 바깥에 입출력 어댑터를 붙여서 언제든 기술 스택을 갈아끼울 수 있게 해주는 구조)을 적용하여, 모듈 간에는 접근 가능한 인터페이스(port)만을 정의하고, 실제 구현(adapter)은 각 도메인 내부에 두는 방식으로 구성했습니다.
시간순으로 구조 변화를 보면 아래와 같습니다.
Step 2. 도메인 모듈 내부의 응집도 높이기
위와 같은 구조 덕분에 입고/출고 도메인과 재고 도메인 간의 직접적인 결합은 끊을 수 있었습니다.
그렇게 도메인 간의 책임은 분리되었지만, 단순히 이동한 재고 도메인 내부 로직은 많이 분산되어 있었고, 이를 사용하는 유즈케이스들 또한 유사한 형태로 반복되고 있었습니다.
이에 재고 도메인 모듈 내부의 응집도를 높이는 리팩토링 작업을 추가로 진행했습니다. 흩어져 있던 흐름을 하나로 정리하고, 중복된 유즈케이스를 정돈하면서 도메인 내부의 복잡도를 줄여나갔습니다.
코어로직의 응집
코어 서비스에서 기본적인 로직을 수행하도록 했습니다. 유즈케이스 인터페이스의 구현체에서는 밸리데이션과 코어 서비스 수행을 위한 DTO 변환, 여러 코어 서비스 조합 등의 역할만 하도록 했습니다.
코어 서비스 외부로 엔터티 반환 금지
코어 서비스의 로직 응집을 해치는 주요 원인으로 엔터티를 외부로 반환하는 방식이 로직 전반에 걸쳐있음을 파악했습니다.
엔터티가 외부로 노출되면, 이를 받은 쪽에서 도메인 로직을 처리하는 일이 발생하기 쉽습니다. 이로 인해 로직이 분산되고, 서비스 간 결합도가 높아지며, 도메인 간 책임 경계도 흐려집니다.
예를 들어 A 서비스의 엔터티를 B 서비스가 받아 사용하면, B는 A의 내부 구조에 의존하게 되고, 이는 변경에 취약한 구조로 이어집니다.
이를 방지하기 위해 다음과 같은 규칙을 도입했습니다.
- 코어 서비스 외부로 엔터티를 직접 반환하지 않는다.
- 다른 코어 서비스의 리포지토리는 직접 사용하지 않는다.
대신, 필요한 데이터는 DTO나 인터페이스를 통해 전달하고, 로직은 각 도메인 내부에서 처리하도록 했습니다.
Step 3. 안전하게 배포하기
도메인 분리는 단순히 클래스를 이동하는 수준이 아니었습니다.
연관 관계 제거, join 절 제거 등 로직 전반에 걸친 구조 변경이 필요했고, 그 영향은 무려 231개의 API에 달했습니다.
특히 재고는 시스템 내에서도 핵심 도메인 중 하나로, 작은 이슈 하나만 발생해도 전반적인 기능에 영향을 줄 수 있었습니다. 따라서 무엇보다 장애 없는 안전한 배포가 최우선 과제였습니다.
하지만 현실적으로 매번 QA 인력을 투입할 수 없는 상황이었기 때문에, 제한된 리소스 안에서 안정성을 확보하기 위한 다양한 전략이 필요했습니다.
이러한 제약 속에서도 안전한 배포를 위해 다음과 같은 시도들을 해나갔습니다.
피처 플래그 사용하기
작은 변경이라도 반드시 피처 플래그(기능의 활성화 여부를 런타임에 제어할 수 있게 해주는 기술)를 적용하는 원칙을 세웠고, 실제로 약 27개의 플래그를 활용해 점진적 배포와 모니터링을 병행했습니다.
특히 피처 플래그는 센터(각 지역의 물류 거점 단위로, 하나의 독립적인 운영 단위처럼 동작합니다) 단위로 제어 가능하도록 설계했습니다.
전체 약 70여 개 센터를 대상으로, 테스트 목적에 따라 피처 플래그 적용 대상을 다르게 설정했습니다.
운영 리스크를 최소화해야 할 경우에는 주문량이 적은 센터부터 순차적으로 적용했고, 반대로 영향도를 빠르게 파악하고자 할 때는 주문량이 많은 센터를 우선 적용 대상으로 삼았습니다.
적용은 10% → 50% → 100%순으로 점차 확대했고, 피처 플래그 조건을 유연하게 설정한 덕분에, 전체 시스템에 영향을 주지 않으면서도 안정적으로 변화된 로직을 운영 환경에 반영할 수 있었습니다.
피처 플래그를 사용함으로써, 롤백 없이 스위치 ON/OFF 만으로 빠르게 대응할 수 있었기에 안정적으로 운영할 수 있었습니다.
또한 플래그를 추가할 때는 제거 시점까지 함께 계획하여, 일정이 지난 플래그는 코드에 남지 않도록 주기적으로 정리했습니다.
작은 단위로 자주 배포하기
영향 범위를 최소화하기 위해 2주의 스프린트 과정 중에서 매주 배포를 진행했고, 배포 주기가 최대 2주를 넘지 않도록 했습니다.
피처 플래그가 있었기 때문에, 코드 리뷰와 개발자 QA가 완료된 기능은 바로 배포에 들어갈 수 있었습니다. 대부분의 기능은 플래그로 감싸져 있었고, 그 영향 범위도 작았기 때문에 이슈 발생 시 원인 파악이 쉬웠고, 사전 대응 방안도 마련해 둘 수 있었습니다.
덕분에 관련된 개발자 외에 다른 팀원들과는 간단한 공유만으로도 충분했고, 배포 리스크를 팀 전체로 확산시키지 않을 수 있었습니다.
테스트 코드 작성하기
안전한 배포를 위해 테스트 코드 작성도 중요한 축으로 삼았습니다.
특히 로직 변경이 많은 작업에서는 핵심 흐름에 대한 유닛 테스트와 통합 테스트를 집중적으로 작성해, 사소한 변경이 예상치 못한 영역에 영향을 주지 않도록 했습니다.
QA 리소스가 제한된 상황에서도, 이런 테스트 코드들은 사전 검증의 중요한 역할을 해주었습니다.
개선 작업과 신규 과제 개발이 동시에 진행되었기 때문에, 팀 내 지속적인 공유와 방향성 점검이 무엇보다 중요했습니다. 아래와 같은 방식으로 진행 상황을 투명하게 공유하고, 모두 같은 목표를 두고 개발할 수 있도록 노력했습니다.
주 1회 개발자 밋업하기
진행 상황은 주 1회 개발자 밋업에서 공유하고, 유의사항이나 고민 사항을 함께 논의하며 의사결정을 해나갔습니다. 결정된 내용은 ADR(Architecture Decision Record) 문서로 남겨두었고, 팀의 규칙이나 방향성이 확정되면 이를 기반으로 코드에 반영했습니다. 이렇게 문서화된 결정 사항들은 이후 개발 과정에서도 일관성을 유지하는 기준점이 되었고, 새로운 팀원이 합류하거나 과거의 결정 배경을 확인할 때도 큰 도움이 되었습니다.
스터디하기
헥사고널 아키텍처 기반의 신규 구조에 대해 단순히 룰만 전달하는 방식은 지양하고, 왜 이런 구조를 택했는지 목적과 맥락을 함께 이해하고 공감하는 것을 중요하게 생각했습니다. 이를 위해 관련 도서를 선정해 주 1회 스터디를 진행했습니다. 정해진 분량을 사전에 읽어오고, 각 챕터별로 인상 깊은 문장이나 우리 팀과의 차이점에 대해 "우리는 어떻게 하면 좋을까?"라는 질문을 중심으로 자연스럽게 토론을 이어갔습니다.
스터디가 일부 인원만의 활동으로 고정되지 않도록, 당일 아침에 사다리 타기로 진행자를 정하고, 서기를 함께 뽑아 스터디 내용을 위키에 기록했습니다. 휴가자도 이후 내용을 확인할 수 있도록 해, 팀 전체가 학습 흐름에서 소외되지 않도록 했습니다.
아크 유닛(ArchUnit) 적용하기
구조적으로 가장 큰 변화는 헥사고널 아키텍처의 도입이었습니다. 아키텍처를 바꾸는 것은 단순한 패키지 정리 이상의 작업이었습니다. 구조적인 변경에 따라 팀 내에서 지켜야 할 명확한 규칙들이 생겼고, 이를 단순히 문서로 공유하는 것만으로는 충분하지 않다고 판단했습니다. 그래서 아크유닛(ArchUnit)을 도입해 코드 수준에서 해당 규칙들을 강제했고, 구조가 다시 흐트러지는 것을 사전에 막고자 했습니다.
현재는 분리된 재고 도메인 안에서 응집된 로직에 집중하며 고도화 작업을 이어가고 있습니다. 동시에, 유사한 방식으로 입고와 출고 도메인도 각각 분리해냈습니다. 이 과정에서 눈에 띄었던 것은 이러한 도메인 분리 과정을 통해 자연스럽게 코드 커버리지가 증가하고, 전체 시스템의 복잡도가 감소하는 효과도 함께 얻을 수 있었다는 점입니다. 책임이 명확해지고 테스트 대상이 좁아지면서, 로직을 더 명확하게 다듬을 수 있었고, 개발과 유지보수 모두에서 이점을 체감할 수 있었습니다.
표: SonarQube의 Activity에서 기간 분석
팀 내 개발자를 대상으로 리팩토링 후 체감되는 영향에 대해 5점 만점의 설문조사를 진행했습니다. 개선된 구조에 대해 적응하는 시간은 개인별로 편차가 있었지만, 전반적으로 아래와 같이 긍정적인 답변을 얻었습니다.
- 변경된 엔터티 구조에 대한 이해도: 평균 4.14점으로 대체로 긍정적
- 리팩토링 효과에 대한 동의 (각 항목 평균 4점 이상)
- 코드 수정/확장 복잡도 감소
- 클래스 간 책임/역할 명확화
- 코드 리뷰 및 테스트 작성/수정 용이
- 개발 효율성 향상
사실 도메인 분리를 하면 모든 것이 더 깔끔해질 줄 알았습니다. 하지만 실제로는 같은 기능을 구현하더라도 더 많은 코드가 필요했고, 작은 결정 하나에도 깊이 고민해야 할 순간들이 많았습니다. 그럼에도 불구하고, 이런 끊임없는 의사결정의 과정을 통해 어떻게 하면 더 안정적으로 배포할 수 있을지, 어떻게 하면 팀원들과 더 잘 소통하고 같은 방향을 바라볼 수 있을지에 대해 많이 배우고 성장할 수 있었습니다.
이 글이 비슷한 고민을 하는 분들께 조금이나마 도움이 되었기를 바랍니다.
더 보기
우아한형제들의 물류플랫폼이 궁금하신가요?
물류플랫폼실 WMS플랫폼팀 출고파트에서 백엔드 개발자로 일하고 있습니다.