앞서 멀티 모듈의 목적과 설계 원칙을 정리했습니다.
그러나, 이론적 개념만으로는 멀티 모듈이 어떻게 적용되는지 이해하기 어렵습니다.
아래에서는 구현 예시, Gradle 설정, 모듈 간 코드 흐름, 그리고 실제 서비스에서 어떻게 적용되는지를 단계별로 정리하였습니다.
아래 구조는 가장 일반적인 멀티 모듈 구성입니다.
project
├── api
├── application
├── domain
├── infrastructure
└── common
각 모듈이 실제로 어떤 파일을 포함하는지 구체적으로 예를 들면 다음과 같습니다.
도메인 특징
package com.sample.domain.user
data class User(
val id: Long,
val name: String
) {
fun validateName(): Boolean {
return name.length in 2..20
}
}
interface UserRepository {
fun findById(id: Long): User?
}
JPA, MySQL, Redis 등 기술 정보 없음
"User를 어떻게 찾는다"가 아니라
"User를 찾을 수 있어야 한다"라는 정책(Port)만 정의
Application 모듈은 유즈케이스(서비스 로직)를 담당합니다.
class UserService(
private val userRepository: UserRepository
) {
fun getUser(id: Long): User {
return userRepository.findById(id)
?: throw IllegalArgumentException("User not found")
}
}
애플리케이션 특징
infrastructure는 DB, 외부 API, 메시지 브로커 등 기술 종속적인 구현체가 포함됩니다.
@Repository
class UserRepositoryJpaImpl(
private val jpaRepository: UserJpaRepository
) : UserRepository {
override fun findById(id: Long): User? {
return jpaRepository.findByIdOrNull(id)?.toDomain()
}
}
interface UserJpaRepository : JpaRepository<UserEntity, Long>
특징
API 계층은 외부 요청을 받고 Application 기능을 호출합니다.
class UserController(
private val userService: UserService
) {
@GetMapping("/{id}")
fun getUser(id: Long): UserResponse {
val user = userService.getUser(id)
return UserResponse.from(user)
}
}
UserRepository가 2개 생기는 것이 맞고, 의도된 구조입니다.
그 이유는 다음 두가지 원칙 때문입니다.
즉, Repoository를 두 개 만드는 이유는
"필요해서 억지로 분리하는 것"이 아니라
"아키텍처적으로 반드시 분리해야 하는 역할"이기 때문입니다.
단일 모듈에서는 아래 형태가 흔합니다.
public interface UserRepository extends JpaRepository<User, Long> {
}
하지만 멀티 모듈에서는 사용헐 수 없습니다.
그 이유는 아키텍처 원칙과 기술 분리 때문입니다.
JpaRepository를 상속한다는 것은
도메인 영역이 JPA라는 기술에 의존하고 있다는 의미입니다.
Domain은 "User를 저장할 수 있다"만 알면 되지
"JPA를 사용한다"를 알아야 할 이유가 없습니다.
(1) Domain/Application - 기술을 모르는 Repository 인터페이스
interface UserRepository {
fun findById(id: Long): User?
}
(2) Infrastructure - 기술을 아는 구현체
@Repository
class UserRepositoryJpaImpl(
private val jpaRepository: UserJpaRepository
) : UserRepository {
override fun findById(id: Long): User? {
return jpaRepository.findByIdOrNull(id)?.toDomain()
}
}
(3) JPA 자체 Repository
interface UserJpaRepository : JpaRepository<UserEntity, Long>
그래서 Repository가 총 3개처럼 보이지만 역할이 다 다릅니다.
| Repository 이름 | 위치 | 목적 | 기술 의존성 |
|---|---|---|---|
| UserRepository | domain/application | 비즈니스 기능 정의(정책, Port) | 없음 |
| UserRepositoryJpaImpl | infrastructure | UserRepository를 JPA로 실제 구현 | JPA 사용 |
| UserJpaRepository | infrastructure | JPA 전용 Repository (JpaRepository 상속) | JPA 사용 |
이 세 가지 역할을 분리해야
아키텍처의 경계가 정확히 지켜집니다.
단일 모듈에서는 가능합니다.
Spring Boot 일반 구조라면 문제 없습니다.
그러나 다음 문제를 피할 수 없습니다.
문제1 - 기술 변경 시 도메인이 영향을 받음
예:
JPA -> MongoDB로 교체한다고 가정하면?
문제2 - 테스트가 어려워짐
도메인 테스트에도 JPA Mocking이 필요해짐
(JPA는 Mocking이 어렵습니다.)
문제3 - 멀티 모듈 구조가 무너짐
domain 모듈은 JPA를 import 할 수 없음
-> 그러면 JpaRepository를 바로 상속하는 구조는 불가능해짐
이때문에 분리가 반드시 필요합니다.
멀티 모듈을 구성하려면 각 모듈 간 의존성을 명시해야 합니다.
settings.gradle.kts
rootProject.name = "multi-module-example"
include("api")
include("application")
include("domain")
include("infrastructure")
include("common")
dependencies {
implementation(project(":application"))
implementation(project(":common"))
}
implementation(project(":*"))는 무엇일까?
api 모듈을 기준으로 설명해보겠습니다.
implementation(project(":application"))
implementation(project(":common"))
이것은 api 모듈이 application-common 모듈에 있는 인터페이스들을 구현해야 하기 때문에
그 인터페이스들을 import 할 수 있도록 의존성을 추가하는 것입니다.
즉,
두 모듈을 참조할 수 있어야 합니다.
dependencies {
implementation(project(":domain"))
}
dependencies {
// 일반적으로 거의 비어 있음
}
dependencies {
implementation(project(":domain"))
implementation(project(":application"))
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
}
dependencies {
// 공통 util만 사용
}
아래는 “유저 조회” 기능이 모듈을 어떻게 지나가는지 흐름입니다.
[api] UserController
→ [application] UserService
→ [domain] UserRepository 인터페이스 호출
→ [infrastructure] UserRepositoryJpaImpl 구현체 동작
→ DB 접근
핵심은 다음과 같습니다.
예: MySQL → PostgreSQL
→ infra 모듈만 교체하면 됩니다.
도메인과 유즈케이스(application)가 분리되어 있으므로
재사용성이 높아집니다.
Application/Domain은 Spring Context 없이 유닛 테스트 가능해져 테스트 속도가 빨라집니다.
변경된 모듈만 다시 빌드하므로 대규모 프로젝트에서 효과가 큽니다.
규칙:
모든 모듈이 반드시 공통적으로 사용해야 하는 코드만 포함합니다.
도메인 규칙이 application에 섞이거나
비즈니스 로직이 infra에 있는 경우가 대표적입니다.
private val repo: UserRepositoryJpaImpl
반드시 인터페이스 의존으로 바꿔야 합니다.
멀티 모듈을 아래처럼 설계했다고 가정하겠습니다.
project
├── api
├── application
├── domain
└── infrastructure
Gradle 의존성 방향은 보통 이렇게 둡니다.
그리고 build.gradle.kts를 예를 들면:
// domain/build.gradle.kts
dependencies {
// 여기에 spring-boot-starter-data-jpa 를 넣지 않습니다.
// 순수 코어만 유지하는 모듈입니다.
}
// infrastructure/build.gradle.kts
dependencies {
implementation(project(":domain"))
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
}
여기서 핵심은:
이 상태에서 만약 domain 안에 아래와 같이 작성하려고 하면:
// domain 모듈 안
interface UserRepository : JpaRepository<UserEntity, Long>
이걸 억지로 되게 하려면?
// domain/build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
}
이렇게 JPA 의존성을 domain에 넣어야 합니다.
원래 의도: domain 은 어떤 기술(JPA, MySQL, Redis, Kafka…)에도 의존하지 않는 가장 안쪽 계층이어야 합니다.
그런데 domain 이 JPA(SPRING DATA)에 의존해 버리면
그래서
“domain 모듈은 JPA를 import 할 수 없음 -> 그러면 JpaRepository를 바로 상속하는 구조는 불가능해짐”
이라는 말은:
멀티 모듈에서 domain을 기술 독립적인 순수 계층으로 만들면,
도메인 코드에서 JPA 타입을 사용할 수도, 상속할 수도 없으니
기존 단일 모듈 스타일(도메인 Repository가 직접 JpaRepository 상속)로는 설계할 수 없다.
라는 의미입니다.
그냥 Service에서 JpaRepository를 바로 쓰면 안 될까??
class UserRepositoryJpaImpl( private val jpaRepository: UserJpaRepository ) : UserRepository { // ← domain 의존 필요!
override fun findById(id: Long): User? {
return jpaRepository.findByIdOrNull(id)?.toDomain()
}
}
핵심은 **"정책(무엇을 할지)과 구현(어떻게 할지)을 분리한다"**입니다.
### 2-1. 두 구조를 비교해 보겠습니다.
**A. 두 구조를 자주 보는 구조**
```kotlin
interface UserRepository : JpaRepository<UserEntity, Long>
@Service
class UserService(
private val userRepository: UserRepository
) {
fun getUser(id: Long): UserEntity {
return userRepository.findById(id).orElseThrow()
}
}
B. 멀티 모듈 DDD/헥사고날 스타일 구조
// domain 또는 application
interface UserRepository {
fun findById(id: Long): User?
}
// infrastructure
@Repository
class UserRepositoryJpaImpl(
private val jpaRepository: UserJpaRepository
) : UserRepository {
override fun findById(id: Long): User? {
return jpaRepository.findByIdOrNull(id)?.toDomain()
}
}
// application
class UserService(
private val userRepository: UserRepository
) {
fun getUser(id: Long): User {
return userRepository.findById(id)
?: throw IllegalArgumentException("User not found")
}
}
여기서 UserRepository(domain/application에 있는 인터페이스)는 정책(무엇을 해야 하는지)를 표현합니다.
UserReepositoryJpaImpl은 그 **정책을 JPA라는 기술로 구현한 "어탭터(구현체)"입니다.
"인터페이스 만들고, 구현체에서 override 하는 거 귀찮지 않나?
그냥 JpaRepository 쓰면 되는데?
단기적으로 보면 맞습니다. 코드 줄 수도 늘어나고, 구혀체로 하나 더 생깁니다.
하지만 아래 상황들이 오면 이 구조가 큰 힘을 발휘합니다.
상황 1. 저장소가 JPA가 아니게 되는 순간
예를 들어 유저 정보를 더 이상 우리 DB에 저장하지 않고,
외부 회원 시스템 API를 통해 조회하게 되는 요구사항이 생겼다고 가정하겠습니다.
A 구조(직접 JpaRepository 사용)에서는:
B 구조(인터페이스 + 구현 분리)에서는:
상황 2. 테스트(특히 단위 테스트)를 제대로 하고 싶을 때
A 구조(직접 JpaRepository 사용):
@Service
class UserService(
private val userRepository: UserRepository // = JpaRepository 상속
)
단위 테스트를 하려고 해도
실제 JpaRepository를 Mocking 해야 하고,
엔티티/트랜잭션도 같이 딸려 들어오기 쉽습니다.
B 구조(인터페이스 분리):
class UserService(
private val userRepository: UserRepository
)
테스트에서는 JPA 필요 없이 이렇게만 해도 됩니다.
class FakeUserRepository : UserRepository {
override fun findById(id: Long): User? {
return User(id, "test")
}
}
@Test
fun `유저 조회 테스트`() {
val repo = FakeUserRepository()
val service = UserService(repo)
val user = service.getUser(1L)
// 검증 ...
}
상황 3. 멀티 모듈 구조에서 의존 방향을 유지해야 할 때
멀티 모듈에서는 보통 이렇게 설계합니다.
api → application → domain
(infra) → application, domain
만약 Service 에서 바로 JpaRepository 를 사용하면?
이걸 막으려면:
그 도구가 바로
interface UserRepository // 정책 (Port)
class UserRepositoryJpaImpl : UserRepository // 구현 (Adapter)
입니다.
멀티 모듈은 단순히 폴더를 나누는 것이 아니라
비즈니스 규칙을 중심으로 기술 의존성을 분리하는 아키텍처적 구조 선택입니다.
멀티 모듈을 적용하면 다음과 같은 장점이 즉시 체감됩니다.
출처
멀티모듈 설계 이야기 with Spring, Gradle | 우아한형제들 기술블로그
Gradle 멀티 프로젝트 관리 by 향로 (기억보단 기록을)