Spring 멀티 모듈 아키텍처 정리: 구현과 예시

박상민·2025년 12월 6일

개념 정리!

목록 보기
25/26

멀티 모듈 구현 예시 및 적용 방법

앞서 멀티 모듈의 목적과 설계 원칙을 정리했습니다.
그러나, 이론적 개념만으로는 멀티 모듈이 어떻게 적용되는지 이해하기 어렵습니다.
아래에서는 구현 예시, Gradle 설정, 모듈 간 코드 흐름, 그리고 실제 서비스에서 어떻게 적용되는지를 단계별로 정리하였습니다.

1. 멀티 모듈 실제 구성 예시

아래 구조는 가장 일반적인 멀티 모듈 구성입니다.

project
 ├── api
 ├── application
 ├── domain
 ├── infrastructure
 └── common

각 모듈이 실제로 어떤 파일을 포함하는지 구체적으로 예를 들면 다음과 같습니다.


1) domain 모듈 예시

도메인 특징

  • 비즈니스 규칙 중심
  • JPA, 외부 API 등 어떤 기술에도 의존하지 않음

엔티티 예시

package com.sample.domain.user

data class User(
    val id: Long,
    val name: String
) {
    fun validateName(): Boolean {
        return name.length in 2..20
    }
}

Repository 인터페이스

interface UserRepository {
    fun findById(id: Long): User?
}
  • JPA, MySQL, Redis 등 기술 정보 없음

  • "User를 어떻게 찾는다"가 아니라
    "User를 찾을 수 있어야 한다"라는 정책(Port)만 정의


2) application 모듈 예시

Application 모듈은 유즈케이스(서비스 로직)를 담당합니다.

서비스 예시

class UserService(
    private val userRepository: UserRepository
) {
    fun getUser(id: Long): User {
        return userRepository.findById(id)
            ?: throw IllegalArgumentException("User not found")
    }
}

애플리케이션 특징

  • 유즈케이스(비즈니스 흐름) 담당
  • Repository 구현체가 무엇인지 모름 (DIP 적용)
  • 도메인 규칙을 기반으로 기능 흐름을 orchestrate

3) infrastructure 모듈 예시

infrastructure는 DB, 외부 API, 메시지 브로커 등 기술 종속적인 구현체가 포함됩니다.

Spring Data JPA 구현체

@Repository
class UserRepositoryJpaImpl(
    private val jpaRepository: UserJpaRepository
) : UserRepository {

    override fun findById(id: Long): User? {
        return jpaRepository.findByIdOrNull(id)?.toDomain()
    }
}

Spring Data Repository

interface UserJpaRepository : JpaRepository<UserEntity, Long>

특징

  • 기술 의존성(JPA, Redis, WebClient 등) 포함
  • Application/Domain을 기술 의존성으로부터 보호
  • Port(UserRepository)를 실제 기술로 구현하는 Adapter

4) api 모듈

API 계층은 외부 요청을 받고 Application 기능을 호출합니다.

Controller 예시

class UserController(
    private val userService: UserService
) {
    @GetMapping("/{id}")
    fun getUser(id: Long): UserResponse {
        val user = userService.getUser(id)
        return UserResponse.from(user)
    }
}

왜 굳이 JpaRepository를 바로 상속하지 않고, UserRepository를 분리할까?

UserRepository가 2개 생기는 것이 맞고, 의도된 구조입니다.
그 이유는 다음 두가지 원칙 때문입니다.

  • 도메인 계층(Domain)이 기술(JPA, MySQL, Redis, ...)을 몰라야 한다.
  • 고수준 정책(비즈니스 로직)이 저수준 구현에 의존하면 안 된다. (DIP)

즉, Repoository를 두 개 만드는 이유는
"필요해서 억지로 분리하는 것"이 아니라
"아키텍처적으로 반드시 분리해야 하는 역할"이기 때문입니다.

단일 모듈에서는 아래 형태가 흔합니다.

public interface UserRepository extends JpaRepository<User, Long> {
}

하지만 멀티 모듈에서는 사용헐 수 없습니다.
그 이유는 아키텍처 원칙과 기술 분리 때문입니다.

JpaRepository를 상속한다는 것은
도메인 영역이 JPA라는 기술에 의존하고 있다는 의미입니다.

Domain은 "User를 저장할 수 있다"만 알면 되지
"JPA를 사용한다"를 알아야 할 이유가 없습니다.

멀티 모듈/DDD/헥사고날 아키텍처에서는 Repository를 이렇게 분리합니다.

(1) Domain/Application - 기술을 모르는 Repository 인터페이스

interface UserRepository {
    fun findById(id: Long): User?
}
  • 도메인 계층의 인터페이스
  • "User를 찾는다"라는 정책/규칙만표현
  • JPA, MySQL, Redis등 어떤 기술을 쓰는지 모름

(2) Infrastructure - 기술을 아는 구현체

@Repository
class UserRepositoryJpaImpl(
    private val jpaRepository: UserJpaRepository
) : UserRepository {

    override fun findById(id: Long): User? {
        return jpaRepository.findByIdOrNull(id)?.toDomain()
    }
}
  • 실제 기술(JPA)를 이용해 저장소 접근을 수행
  • domain의 인터페이스(UserRepository)를 구현
  • 기술 교체 시 여기만 변경하면 됨

(3) JPA 자체 Repository

interface UserJpaRepository : JpaRepository<UserEntity, Long>

그래서 Repository가 총 3개처럼 보이지만 역할이 다 다릅니다.

Repository 이름위치목적기술 의존성
UserRepositorydomain/application비즈니스 기능 정의(정책, Port)없음
UserRepositoryJpaImplinfrastructureUserRepository를 JPA로 실제 구현JPA 사용
UserJpaRepositoryinfrastructureJPA 전용 Repository (JpaRepository 상속)JPA 사용

이 세 가지 역할을 분리해야
아키텍처의 경계가 정확히 지켜집니다.


그냥 JpaRepository 하나만 쓰면 안 되나요?

단일 모듈에서는 가능합니다.
Spring Boot 일반 구조라면 문제 없습니다.

그러나 다음 문제를 피할 수 없습니다.

문제1 - 기술 변경 시 도메인이 영향을 받음
예:
JPA -> MongoDB로 교체한다고 가정하면?

  • Domain에서 Repository를 다시 수정해야 함
  • 비즈니스 로직이 기술에 끌려다니는 구조

문제2 - 테스트가 어려워짐
도메인 테스트에도 JPA Mocking이 필요해짐
(JPA는 Mocking이 어렵습니다.)

문제3 - 멀티 모듈 구조가 무너짐
domain 모듈은 JPA를 import 할 수 없음
-> 그러면 JpaRepository를 바로 상속하는 구조는 불가능해짐

이때문에 분리가 반드시 필요합니다.


2. Gradle 설정 예시

멀티 모듈을 구성하려면 각 모듈 간 의존성을 명시해야 합니다.

settings.gradle.kts

rootProject.name = "multi-module-example"

include("api")
include("application")
include("domain")
include("infrastructure")
include("common")

build.gradle.kts 구조 예시

1) api 모듈

dependencies {
    implementation(project(":application"))
    implementation(project(":common"))
}

implementation(project(":*"))는 무엇일까?

api 모듈을 기준으로 설명해보겠습니다.

implementation(project(":application"))
implementation(project(":common"))

이것은 api 모듈이 application-common 모듈에 있는 인터페이스들을 구현해야 하기 때문에
그 인터페이스들을 import 할 수 있도록 의존성을 추가하는 것
입니다.

즉,

  • api는 application의 에서 선언한 일부 인터페이스나 DTO/포트를 구현하고
  • api는 common의 공통 유틸, 상수를 사용해야 하므로

두 모듈을 참조할 수 있어야 합니다.

2) application 모듈

dependencies {
    implementation(project(":domain"))
}

3) domain 모듈

dependencies {
    // 일반적으로 거의 비어 있음
}

4) infrastructure 모듈

dependencies {
    implementation(project(":domain"))
    implementation(project(":application"))

    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")
}

5) common 모듈

dependencies {
    // 공통 util만 사용
}

3. 모듈 간 데이터 및 호출 흐름 예시

아래는 “유저 조회” 기능이 모듈을 어떻게 지나가는지 흐름입니다.

[api] UserController
    → [application] UserService
        → [domain] UserRepository 인터페이스 호출
            → [infrastructure] UserRepositoryJpaImpl 구현체 동작
                → DB 접근

핵심은 다음과 같습니다.

  • Domain은 DB를 모릅니다.
  • Application은 Infra의 구현체를 모릅니다.
  • API는 비즈니스 규칙을 직접 실행하지 않습니다.

4. 실제 프로젝트에서 멀티 모듈이 빛나는 순간

1) 인프라 교체 시

예: MySQL → PostgreSQL
→ infra 모듈만 교체하면 됩니다.

2) 여러 API에서 동일 도메인을 사용할 때

도메인과 유즈케이스(application)가 분리되어 있으므로
재사용성이 높아집니다.

3) 테스트 효율 향상

Application/Domain은 Spring Context 없이 유닛 테스트 가능해져 테스트 속도가 빨라집니다.

4) 빌드 속도 개선

변경된 모듈만 다시 빌드하므로 대규모 프로젝트에서 효과가 큽니다.


5. 멀티 모듈 구성 시 자주 하는 실수

1) common 모듈 비대화

규칙:
모든 모듈이 반드시 공통적으로 사용해야 하는 코드만 포함합니다.

2) 역할이 모호한 모듈 분리

도메인 규칙이 application에 섞이거나
비즈니스 로직이 infra에 있는 경우가 대표적입니다.

3) DIP를 어긴 구현체 직접 참조

private val repo: UserRepositoryJpaImpl

반드시 인터페이스 의존으로 바꿔야 합니다.


개인적으로 궁금했던 부분

"domain 모듈은 JPA를 import 할 수 없음"이 무슨 뜻일까?

멀티 모듈을 아래처럼 설계했다고 가정하겠습니다.

project
 ├── api
 ├── application
 ├── domain
 └── infrastructure

Gradle 의존성 방향은 보통 이렇게 둡니다.

  • api -> appplication
  • application -> domain
  • infrastructure -> application, domain

그리고 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 모듈에는 JPA 의존성이 없다
    • 즉, domain 코드에서는 JpaRepository 타입 자체를 모릅니다.
  • 대신 infrastructure 모듈만 JPA를 사용합니다.

이 상태에서 만약 domain 안에 아래와 같이 작성하려고 하면:

// domain 모듈 안
interface UserRepository : JpaRepository<UserEntity, Long>
  • JpaRepository가 어떤 타입인지 domain 입장에서는 모릅니다.
  • 의존성을 추가하지 않아서 컴파일 자체가 되지 않습니다.

이걸 억지로 되게 하려면?

// 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)에 의존해 버리면

    • 도메인이 특정 기술에 묶여 버리는 구조가 됩니다.
    • 나중에 JPA -> 다른 저장소로 바꾸고 싶을 때 domain 코드도 수정해야 합니다.

그래서
“domain 모듈은 JPA를 import 할 수 없음 -> 그러면 JpaRepository를 바로 상속하는 구조는 불가능해짐”
이라는 말은:

멀티 모듈에서 domain을 기술 독립적인 순수 계층으로 만들면,
도메인 코드에서 JPA 타입을 사용할 수도, 상속할 수도 없으니
기존 단일 모듈 스타일(도메인 Repository가 직접 JpaRepository 상속)로는 설계할 수 없다.

라는 의미입니다.


왜 굳이 domain 계층의 UserRepository를 구현해서 사용할까?

그냥 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()
    }
}
  • 장점: 코드가 짧고 단순합니다.
  • 단점:
    • 도메인/서비스가 JPA라는 기술을 직접 알고 있습니다.
    • 나중에 DB를 변경하거나, 외부 User API를 통해 가져오도록 바꾸면
      • Service 코드도 같이 수정해야 합니다.
    • 테스트 시에도 JPA나 DB를 끌고 들어오기 쉽습니다.

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에 있는 인터페이스)는 정책(무엇을 해야 하는지)를 표현합니다.

  • "id로 User를 찾아라"
  • 이때 "JPA로 찾을지, Mongo로 찾을지, 외부 HTTP로 찾을지"는 몰라도 됩니다.

UserReepositoryJpaImpl은 그 **정책을 JPA라는 기술로 구현한 "어탭터(구현체)"입니다.


2-2 override라는 "불편함"을 감수하는 이유

"인터페이스 만들고, 구현체에서 override 하는 거 귀찮지 않나?
그냥 JpaRepository 쓰면 되는데?

단기적으로 보면 맞습니다. 코드 줄 수도 늘어나고, 구혀체로 하나 더 생깁니다.

하지만 아래 상황들이 오면 이 구조가 큰 힘을 발휘합니다.

상황 1. 저장소가 JPA가 아니게 되는 순간
예를 들어 유저 정보를 더 이상 우리 DB에 저장하지 않고,
외부 회원 시스템 API를 통해 조회하게 되는 요구사항이 생겼다고 가정하겠습니다.

  • A 구조(직접 JpaRepository 사용)에서는:

    • Service, Controller, 심지어 도메인까지
      JPA/JpaRepository를 기준으로 짜여 있으므로
      여러 군데 코드를 수정해야 합니다.
  • B 구조(인터페이스 + 구현 분리)에서는:

    • UserRepository 인터페이스는 그대로 둔 채
    • UserRepositoryExternalApiImpl 같은 구현체를 하나 더 만들고
      • 이 구현체는 WebClient 로 외부 API 호출
    • Spring 설정에서 어떤 구현체를 쓸지 바꿔주거나, 용도에 따라 여러 구현체를 쓸 수 있습니다.
    • Service/Domain 코드는 그대로 유지됩니다.

상황 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)

    // 검증 ...
}
  • JPA 필요 없음
  • Spring Context 띄울 필요 없음
  • 테스트가 매우 가볍고 빠릅니다.

상황 3. 멀티 모듈 구조에서 의존 방향을 유지해야 할 때
멀티 모듈에서는 보통 이렇게 설계합니다.

api        → application → domain
(infra)    → application, domain
  • Service(application)는 domain에만 의존합니다.
  • infra는 application/domain에 의존합니다.

만약 Service 에서 바로 JpaRepository 를 사용하면?

  • Service가 JPA를 알게 됩니다. (기술 의존)
  • 이 Service를 정의한 application 모듈이 JPA에 의존하게 됩니다.
  • application 모듈이 더 이상 “도메인 중심”이 아니라 “JPA 중심”이 됩니다.

이걸 막으려면:

  • JPA는 오직 infra에만 두고
  • application/domain은 JPA를 알지 못하게 해야 합니다.

그 도구가 바로

interface UserRepository   // 정책 (Port)
class UserRepositoryJpaImpl : UserRepository   // 구현 (Adapter)

입니다.


마무리

멀티 모듈은 단순히 폴더를 나누는 것이 아니라
비즈니스 규칙을 중심으로 기술 의존성을 분리하는 아키텍처적 구조 선택입니다.

멀티 모듈을 적용하면 다음과 같은 장점이 즉시 체감됩니다.

  • 변경에 강한 구조
  • 테스트 용이성 향상
  • 빌드 및 배포 효율성 증가
  • 도메인 중심 설계가 자연스럽게 반영됨

출처
멀티모듈 설계 이야기 with Spring, Gradle | 우아한형제들 기술블로그
Gradle 멀티 프로젝트 관리 by 향로 (기억보단 기록을)

0개의 댓글