멀티모듈 구조 소화하기

tls·2026년 7월 8일

Tech

목록 보기
8/8

개요

멀티모듈에 대해 학습하고 실제로 프로젝트를 멀티모듈 아키텍처로 구현하며 겪었던 과정을 작성해보려 한다. (멀티모듈 구조 씹어삼키기)

프로젝트의 주문 서버, 상품 서버처럼 나중에 서비스를 쪼갤 가능성을 염두에 두고, 변화가 도메인 단위로 일어난다고 봤기 때문에 아키텍처를 도메인 단위로 나누기는 이미 결정했고, 위 결정을 바탕으로 실제 구조를 고민해보며 팀원들과 함께 아키텍처를 고민했다. 실제로 구조를 짜보니 다짐 하나로는 안 풀리는 지점들이 있었다. 예를 들자면 레이어는 어떻게 할지, 인프라는 어떻게 감출지, 도메인끼리는 어떻게 부를지... 등등이다.

왜 Gradle 멀티 모듈인가

멀티모듈 구조 씹어삼키기에서 모듈 경계를 구현하는 방법이 두 가지라고 정리했다. Gradle로 물리적인 서브프로젝트를 나누는 방식과, Spring Modulith로 논리적인 패키지 경계를 긋는 방식이다.

우리는 Gradle을 골랐다. Spring Modulith도 나쁘지 않은 선택지였지만, 경계가 깨졌을 때 테스트를 돌려야 알 수 있다는 점이 걸렸다. 컴파일이 막아주는 것과 테스트가 잡아주는 것은 다르다. 테스트를 깜빡하고 안 돌리면 그대로 병합될 수 있지만, 컴파일 에러는 그럴 수가 없다. 배포 전에 무조건 걸러지길 원해서 물리적인 쪽을 택했다.

그래서 실제로 어떻게 나눴나

project/
├── common/              // 순수 POJO: 이벤트 데이터 클래스, 예외 계약
├── domain/              // 엔티티, 리포지토리 포트, 도메인 이벤트
├── application/         // UseCase 인터페이스 + Service + Facade
├── infra/
│   ├── mysql/           // JPA 리포지토리 구현
│   └── event-kafka/     // 외부로 나가는 메시지 발행
├── web-common/          // api, admin이 같이 쓰는 예외 핸들러 등 웹 공통 코드
├── api/                 // 실행 모듈 - REST 컨트롤러
├── admin/               // 실행 모듈 - 운영 REST API
└── batch/               // 실행 모듈 - 배치

domain과 application은 도메인끼리 모듈로 쪼개지 않고 패키지로만 나눴는데, infra는 반대로 물리 모듈로 또 쪼갰다. 같은 멀티 모듈 구조 안에서 기준이 다르다.

도메인은 왜 모듈이 아니라 패키지인가

도메인 단위로 나눈다는 건 변화가 도메인 단위로 전파되게 만들겠다는 뜻이지, 지금 당장 물리적으로 쪼개야 한다는 뜻은 아니다.

지금은 서비스를 실제로 분리할 계획이 100% 확정되지도, 그럴 필요를 느끼지도 않았다. 미리 물리 모듈로 쪼개봐도 얻는 건 없고 모듈 개수만 늘어난다. 그래서 물리적으로 나누는 대신 패키지 이름만으로 경계를 대신했다. order, product, user라는 패키지 이름 자체가 이미 경계고, 나중에 정말 서비스를 떼어내야 하는 순간이 오면 그 패키지를 통째로 새 모듈로 옮기면 된다.

레이어는 다르다. common, domain, application, infra, 그리고 실행 모듈(api, admin, batch)이 나뉘는 이유는 지금도 확실하다. 실행 모듈 세 개는 배포되는 시점부터가 다르고 infra는 기술 스택 자체가 도메인과 다르다. 확실한 이유가 지금 있으니까 컴파일러가 막아주는 물리 모듈로 만들었다. 도메인은 확실한 이유가 아직 없으니 이름과 리뷰가 지키는 패키지 정도로만 남겨뒀다.

인프라는 왜 다른 기준으로 쪼갰나

domain과 application은 도메인을 패키지로만 나눴지만 infra는 mysql과 event-kafka처럼 기술 종류별로 물리 모듈을 쪼갰다.

이유는 의존성이다. RDBMS를 쓰는 도메인 모듈에 기능 하나 때문에 Redis 의존성을 얹으면, RDBMS만 필요한 소비자도 Redis 의존성과 커넥션까지 떠안게 되는 문제가 발생할 수 있다. 그래서 인프라는 기술 하나당 모듈 하나로 쪼개서, 필요한 기술만 골라 의존할 수 있게 했다.

그런데 기술별로 모듈을 쪼개는 것과 domain과 application이 그 기술 자체를 모르게 하는 것은 별개의 문제다. 모듈을 쪼개놔도 domain이 infra의 클래스를 직접 참조하면 아무 소용이 없다. 그래서 저장소를 포트로 감싸 domain이 OrderRepository라는 인터페이스를 갖고, infra의 mysql 모듈이 그 인터페이스를 구현하게끔 했다. 따라서 domain은 저장 방식이 mysql인지 다른 무엇인지 모른다.

도메인 간 호출은 Facade와 이벤트로 조율한다

같은 도메인 안에서는 전부 같은 모양으로 시작한다. 컨트롤러가 UseCase 인터페이스에 의존하고, Service가 그 인터페이스를 그대로 구현한다. 컨트롤러는 뒤에 뭐가 있는지 모른다.

문제는 주문처럼 다른 도메인을 참조해야 하는 경우다. 주문을 넣으려면 상품이 진짜 팔리고 있는지, 주문하는 사람이 진짜 있는지 다른 도메인에서 확인이 필요하다. Service가 이를 한다면 다른 도메인 간의 참조가 발생하게 되므로, UseCase를 구현하는 자리에 Service 대신 Facade를 하나 두어 조율은 Facade가 맡고 자기 도메인 안의 진짜 로직은 여전히 Service에 맡겼다.

컨트롤러 → UseCase(Facade가 구현) → Service

그래서 주문 도메인만 이 3층 구조고, 나머지 열두 개 도메인은 여전히 컨트롤러 → UseCase(Service가 구현)인 2층이다. 다른 도메인을 부를 수 있는 자리도 Facade로만 좁혔다. Service는 자기 도메인 밖을 아예 모른다.

이 승격은 컨트롤러 입장에서는 안 보인다. 컨트롤러는 처음부터 UseCase 인터페이스만 보고 있어서, 뒤에서 Service가 Facade로 바뀌어도 고칠 게 없다. 그래서 지금 당장 조율이 필요 없는 열두 개 도메인엔 Facade를 미리 만들어두지 않았고, 필요해지는 순간에 그 도메인만 승격하면 된다.

동기 호출과 별개로, 지금 당장 답이 필요 없거나 실패해도 원래 작업을 취소할 필요가 없는 일은 이벤트로 처리한다. 주문이 끝난 뒤 적립금을 쌓아주는 것 같은 경우다. 발행하는 쪽은 자기 도메인 이벤트를 던지기만 하고, 구독하는 쪽은 그 이벤트를 듣고 알아서 처리한다.

공통 모듈엔 계약만 남긴다

1편에서 공통 모듈은 최소화해야 한다고 정리했다. 하지만 아예 없앨 수는 없었다. 현재 진행하고 있는 프로젝트에서는 예외 처리 하나만 놓고 봐도 common, domain, 실행 모듈이 전부 조금씩 관여한다.

문제는 사용자를 못 찾았을 때 api와 admin은 실행 모듈이 다른데도 똑같이 JSON으로 응답해야 하는데, batch는 반응 자체가 다르다는 거였다. 그래서 예외 하나를 두고 실행 모듈 개수(3개)만큼 반응을 나누는 게 아니라, 반응이 실제로 다른 만큼(웹 응답과 배치 처리, 2개)만 나눴다. (하지만 추후 구현에 따라 달라질 수 있을 듯도 싶다.)

common에는 정말 계약만 남기기로 했다.

// common - 규약만
interface ErrorCode {
    val code: String       // "U001"
    val message: String    // "사용자를 찾을 수 없습니다"
    val status: Int
}

class BusinessException(val errorCode: ErrorCode) : RuntimeException(errorCode.message)

data class ErrorResponse(
    val code: String,
    val message: String,
    val status: Int,
) {
    companion object {
        fun of(errorCode: ErrorCode) = ErrorResponse(errorCode.code, errorCode.message, errorCode.status)
    }
}

common은 spring-web에 의존하지 않는다. status도 HttpStatus가 아니라 그냥 Int로 들고 있다.

실제 값은 각 도메인이 갖는다. user 도메인이면 UserErrorCode라는 enum에 USER_NOT_FOUND("U001", "사용자를 찾을 수 없습니다", 404)처럼 실제 코드, 메시지, 상태를 채운다.

반응은 필요한 만큼만 나눴다. api와 admin은 웹 요청을 받는 실행 모듈이라 예외를 JSON으로 바꾸는 코드를 하나 공유한다. batch는 웹이 아예 없어서 반응을 따로 둔다.

만약 이걸 common에 뒀다면 batch까지 spring-web을 억지로 끌고 왔을 거고 JSON이 필요 없는 배치에 안 맞는 처리가 생겼을 것이다. common은 규약만 갖고, 그 규약을 실제로 어떻게 쓸지는 규약이 필요한 쪽이 각자 정한다.

확실한 것만 모듈로 삼자

같은 이유로 같은 시점에 변하는 코드는 묶고 다른 이유로 변하는 코드는 떼어놓으라는 말을 경계마다 다시 적용한 셈이다.

지금 패키지로만 남겨둔 도메인 경계가 진짜 시험대에 오르는 건, 정말 주문 서버 하나를 따로 떼어내야 하는 순간일 것이다. 그때 지금 남겨둔 패키지 경계가 얼마나 매끄럽게 모듈 경계로 바뀌는지 확인해보고 싶다.

출처/레퍼런스

멀티모듈 구조 씹어삼키기

0개의 댓글