✔️ 기능 추가로 인한 모듈과 서버 분리

말 그대로 하나의 큰 애플리케이션을 여러 개의 독립적인 모듈로 나눠 관리하는 구조로, 각 모듈은 도메인이나 계층 단위로 역할을 명확히 나누고, 필요한 부분만 서로 의존하도록 구성된다.
멀티모듈의 핵심은 모듈 간 의존성은 단방향을 유지하며, 상위 계층이 하위 계층을 의존하는 구조로, 하위 모듈은 상위 모듈의 구현이나 내부를 알지 못해야 한다.
멀티 모듈에 대한 추가적인 내용은 아래 페이지에 정리해놓았다.
📃
멀티 모듈 구조 내부

[도입 초기 구조 - 2025.08]

모듈 구성도
모듈 구성도
module-api ├── module-infrastructure │ ├── module-domain │ │ └── module-global-utils │ └── module-global-utils ├── module-domain │ └── module-global-utils └── module-global-utils
의존성 관계도
 

[기능 확장 - 2026.01]

이 구조를 바탕으로 개발을 이어가면서, Place 도메인이 서비스 성장에 따라 더 많은 역할과 책임을 가지게 되는 상황이 자연스럽게 발생했다. 이 지점에서 멀티 모듈 구조는 ‘한계’가 아닌, 확장을 수용하기 위한 기반으로 작동했다.
 

📦 Place 도메인의 확장과 실행 단위 분리

Place 도메인은 초기에는 API 모듈 내부 여러 디렉토리 중 하나로 시작했지만, 외부로부터 받아오는 데이터 양이나 트래픽과 요청 수가 지속적으로 증가하였고 그에 따라 코드 로직도 무거워져갔다. 다른 모임이나 설문 쪽 도메인은 가벼운 CRUD API가 대부분이고 코드양과 파일 수도 작았지만, Place는 3000 라인이 넘어갔다.
또한, 추후 Place 측에 LLM 도입과 결과 알림 기능이 추가될 예정이였기에 독립적인 구조 확립의 필요성을 느꼈다. 이러한 변화는 도메인이 잘 분리되어 있었기 때문에 오히려 명확하게 드러났고, 그 결과 실행 단위(WAS)를 분리하는 선택으로 자연스럽게 이어졌다.
 

📦 API 서버 분리 이후 구조

notion image
기존의 단일 module-api 구조를 다음과 같이 확장하였다.
  • module-api-common : API 서버들의 공통 로직(보안, 예외 처리, 공통 설정)
  • module-api-core : 메인 비즈니스 API 서버
  • module-api-place : Place 전용 API 서버
두 서버는 모두 bootJar가 활성화된 독립 실행 애플리케이션이고, 하위 모듈 구조는 전과 같이 단방향 의존을 통해 공유한다.
공통 로직을 global-utils 모듈이 아닌 api-common 모듈을 신설한 이유는 api 관련 코드 및 라이브러리들이 순수 전역 공통 모듈인 global-utils 와 뒤섞이지 않게 하기 위함이다.
 
core-server: mem_limit: 512m mem_reservation: 256m place-server: mem_limit: 1024m mem_reservation: 512m
API 서버 분리 이후에는 Core·Place 서버의 부하 특성과 역할에 따른 독립적인 자원 설정이 가능해졌다.
Place 도메인은 외부 API 연동, 데이터 전처리, 고빈도 조회 로직 등으로 메모리 사용량이 상대적으로 높은 특성을 가지기 때문에 Core 서버보다 넉넉한 메모리를 할당하였다.
이를 통해, 아래와 같은 운영상의 이점을 얻을 수 있었다.
  • Place 서버는 트래픽 증가와 연산 부담에 대응할 수 있는 독립적인 리소스 확보
  • Core 서버는 Place 부하로부터 영향을 받지 않는 안정적인 실행 환경 유지
  • 기능 단위 모듈 분리가 컨테이너 단위의 리소스 분리 및 확장성으로 자연스럽게 확장
 
 

📦 Gateway 레벨에서의 책임 분리

# Place 관련 API location /api/v1/places { proxy_pass http://place-server; } # 그 외 API (Core) location / { proxy_pass http://core-server; }
API 진입 지점인 Nginx Gateway에서 요청 경로 기준으로 서버를 분기하였다. Gateway는 단순히 요청을 중계하는 역할만 수행하며, 요청 URL에 따라 어떤 API 서버가 해당 요청을 처리할지 결정한다.
해당 구조를 통해, 아래와 같은 구조적 특성을 살릴 수 있었다.
  • 클라이언트는 하나의 API 엔드포인트만 인지하면 됨
  • 내부적으로는 도메인 특성에 맞게 서버를 분리하여 운영
  • Place 서버의 트래픽 증가나 장애가 Core 서버에 직접적인 영향을 주지 않도록 격리
 

📦 테스트 구조 역시 구조에 맞는 확장

운영 코드뿐만 아니라 테스트 코드 역시 멀티 모듈 및 API 서버 분리 구조에 맞게 함께 확장하였다.
기존에 module-api 에 있던 테스트 설정들과 헬퍼 클래스들 역시 module-api-common 모듈로 이동하고, Gradle 의 java-test-fixtures 플러그인을 사용하여, Core / Place 양쪽 모듈에서 중복 없는 일관된 테스트 환경을 유지할 수 있었다.
이를 통해 다음과 같은 테스트 공통 요소들을 하나의 위치에서 관리하게 되었다.
  • TestSecurityConfig
  • TestAuthHelper
  • 공통 Mock/헬퍼
 

📦 정리

이번 확장은 구조적인 문제를 해결하기 위한 변경이 아니라, 초기에 도입한 멀티 모듈 구조를 그대로 활용해, 도메인·인프라 계층은 그대로 유지한 채 Core와 무거워진 Place를 독립 실행 서버로 분리한 작업이였다.
또한, API 공통 관심사를 하위 모듈로 분리함으로써, 중복 없이 배포·트래픽·장애 범위를 분리할 수 있었고, 멀티 모듈의 의존성 경계를 초기에 명확히 잡아두었기에 확장에 용이하였다.
향후 규모가 더 커져 서비스 단위 분리가 필요해 MSA 환경으로도 나아가기에 최적의 아키텍처 구조임을 느낄 수 있었다.
 

Domain ↔ Entity 분리를 위한 Mapper 구조 도입

module-domain과 module-infrastructure는 서로 다른 책임과 목적을 가지는 계층이기 때문에, 두 계층을 직접 연결하지 않고 이를 중재하는 어댑터 역할로 Mapper를 도입하였다.
이 구조의 핵심은 도메인 모델이 영속성 기술인 JPA에 오염되지 않도록 보호하기 위함이다.

1. 조회 요청 시 흐름도

notion image
조회 시에는 영속화 책임을 갖는 JpaRepository.findXX()를 통해 Entity를 조회하고, 변환 책임을 갖는 Mapper.toDomain()을 통해 조회된 Entity를 Domain Model로 변환하여 상위 계층(Application/Service Layer)으로 전달한다.
 
 

2. 저장 요청 시 흐름도

notion image
변환 책임을 갖는 Mapper.toEntity()를 통해 Domain 객체를 Entity 구조에 맞게 변환하여 생성하고, 영속화 책임을 갖는 JpaRepository.save() 를 통해 변환된 Entity 를 저장한다.
 

✔ Domain 모델 (module-domain)

  • 순수 Kotlin 기반의 비즈니스 객체
  • JPA 어노테이션 및 영속성 설정 없음
  • 서비스/도메인의 핵심 규칙과 개념을 표현하는 역할
  • 인프라 기술(JPA, SQL, DB 스키마)에 영향을 받지 않아야 함

✔ Entity 모델 (module-infrastructure)

  • DB 테이블과 매핑되는 영속성 모델
  • @Entity, @Table, @ManyToOne 등 JPA 의존
  • 스키마 구조와 쿼리 성능을 고려한 형태로 설계
⇒ 두 모델은 목적 자체가 다르다. 통합할 경우 domain 모듈이 JPA 기술에 종속되고, DB 스키마 변경이 곧바로 도메인 변경으로 전파되며결과적으로 모듈 경계가 무너진다.
 
notion image
notion image
Mapper는 도메인 모델(D)과 엔티티 모델(E)을 서로 변환 보호막처럼 감싸는 어댑터로서, 두 계층을 느슨하게 연결하는 역할을 한다.
  • D: Domain 모델 → 순수 비즈니스 로직, JPA와 무관한 객체
  • E: Entity 모델 → DB 매핑을 위한 JPA 객체
각 도메인 모델마다 DomainMapper를 구현한 별도의 Mapper 클래스(@Component)를 생성하여 사용하였다.
 

Mapper가 수행하는 변환 작업

notion image
notion image
  • Entity → Domain 변환 : toDamain()
    • DB에서 읽어온 MeetingEntity를 순수 비즈니스 객체인 Meeting으로 변환하는 과정
    • DB에서 조회한 직후 호출
  • Domain → Entity 변환 : toEntity()
    • 도메인 객체를 DB에 저장 가능한 JPA 엔티티로 변환
    • DB에서 저장하기 직전에 호출
Entity가 Domain 모듈로 직접 참조하지 않도록, Infrastructure 모듈에서 Mapper를 통해 Domain Model로 변환하여 Domain 모듈로 전달된다.
 

Mapper 도입 효과

  • 도메인 ↔ 인프라 간 의존성 완전 차단
  • 도메인 모델의 기술 독립성 보장
  • DB 스키마 변경이 도메인 로직에 전파되지 않음
  • 모델 구조가 달라도 매핑 전략으로 유연하게 대응 가능
⇒ Mapper는 단순한 변환 유틸이 아닌, 도메인 중심 아키텍처를 유지하기 위한 핵심 요소이다. 이 계층을 명확히 분리함으로써, 두 계층은 서로 간섭 없이 독립적으로 확장이 가능하다.
 

멀티모듈로 체감한 장점

1. 관심사의 분리

모듈을 분리한 가장 큰 이유 중 하나는 각 모듈이 ‘명확한 책임’을 갖도록 하기 위함이다. 도메인 모델과 영속성 구현부의 명확한 분리를 통해 순수한 비즈니스 로직과 기술적 구현체를 독립시켰다.
module-domain : 비즈니스 로직과 기술과 무관한 순수 도메인 모델만 포함
notion image
notion image
 
module-infrastructure : JPA 엔티티와 영속성 구현
notion image
notion image
[장점]
  • 각 모듈이 갖는 책임과 역할이 명확하여 리팩토링, 기능 변경의 영향 범위를 파악하기 용이
  • 역할과 책임에 대한 애매함이 없어짐으로써 어떤 모듈에서 어느정도까지를 개발되야할지 명확
  • 도메인 모델이 JPA에 종속되지 않으므로 순수 비즈니스 로직에 집중 가능
  • 영속성 구현 변경 시 도메인 모델은 전혀 영향을 받지 않음
  • 도메인 모델은 기술 스택과 독립적이므로 기술 변경으로부터 자유로움
 

2. 재사용성 향상

하위 모듈의 재사용성
notion image
notion image
위와 같은 유틸리티들이나 ErrorCode 같이 특정 모듈에 정의된 구현체들이 상위 모듈들에서 사용하는 경우가 많다.
[장점]
  • 공통 코드 중복 제거
  • 한 곳에서 수정하면 모든 모듈에 반영
  • 새로운 모듈 추가 시 기존 유틸리티 재사용 가능
 

3. 병렬 개발에 용이

모듈 간 의존성이 명확하기에, 팀이 동시에 작업해도 충돌할 일이 거의 없었습니다. 프로젝트 규모가 더 커진다면 module-api-meeting, module-api-place 처럼 API 모듈을 기능 단위로 모듈 세분화도 가능한데, 각 담당 기능의 모듈을 수정하면 됩니다.
공통 로직이 필요할 경우에는 도메인이나 인프라 등 코어 모듈을 수정하면 되고, 특정 기능에 한정된 요구라면 해당 서비스 모듈만 손보면 됩니다. 이처럼 서비스별로 역할과 책임을 명확히 분리하여 팀원들은 독립적으로 개발·테스트할 수 있어 충돌이 줄고 협업도 훨씬 수월해졌습니다.
결과적으로 모듈 내부의 응집도는 높아지고 모듈 간 결합도는 낮아지는, 확장성과 유지보수성이 뛰어난 구조를 갖출 수 있었습니다.
 

4. 의존성 관리가 명확

각 모듈은 모듈마다 build.gradle.kts를 가집니다.
module-api : bootJar 활성화 (실행 가능한 JAR) ↓ (의존) module-infrastructure : jar만 활성화 (라이브러리) ↓ (의존) module-domain : jar만 활성화 (라이브러리) ↓ (의존) module-global-utils : jar만 활성화 (라이브러리)
의존성 방향이 단방향이므로 순환 의존성이 발생하지 않는다. 또한, 각 모듈의 빌드 설정도 명확하다.
[장점]
  • 순환 의존성 방지
  • 의존성 변경 영향 범위 파악 용이
  • 빌드 시간 최적화 (변경된 모듈만 재빌드) ⇒ 배포 시간 단축
  • 모듈별 독립적 버전 관리 가능
 

5. 단위 테스트 용이

도메인·인프라·API 테스트가 명확히 분리되기에, 단위 테스트 시 정말 편하고 디버깅에도 용이하였다.
  • domain은 순수 Kotlin → 단위테스트가 매우 빠르고 가벼움(JPA, 스프링 컨텍스트 로딩 없음)
  • infrastructure는 JPA + DB 테스트만 분리할 수 있음 → 쿼리·매핑·트랜잭션 검증에 집중 가능
  • API 테스트는 Service·Repository를 mock으로 대체해 독립적으로 검증 가능
  • 레이어 간 테스트 범위가 충돌하지 않아 전체 테스트 시간이 줄고, 테스트 고립도가 높아 안정성이 크게 향상됨
 
 
 
 

[레퍼런스]
  • 우아한형제들 기술블로그  |우아한형제들 기술블로그 |멀티모듈 설계 이야기 with Spring, Gradle | 우아한형제들 기술블로그