Engineering
멀티모듈 프로젝트, 왜 그리고 어떻게 해야 할까?
박현주Aaron(아론) / 검색플랫폼개발팀여기어때
2025년 10월 14일
원문에서 보기 ↗
안녕하세요. 검색플랫폼개발팀 아론입니다.
대규모 애플리케이션을 개발하다 보면 코드가 점점 커지고 복잡해져서, 관리가 어려워지는 순간이 오죠.
저도 그런 시점을 몇 번 겪으면서 “이걸 어떻게 더 깔끔하고, 중복 코드 없이 유지할 수 있을까?” 고민하게 됐습니다.
특히 최근에 팀에서 검색 API 리팩토링 을 진행하면서 그 고민이 다시 찾아왔습니다.
우리는 기존에 검색 API에 통합되어있던 자동완성 API도 분리하려고 계획중이었는데,
“이왕 리팩토링을 한다면 테스트가 쉬우면서, 중복 코드도 줄이고, 서비스 변경에도 유연하게 대응할 수 있는 구조로 가자!”
는 목표를 세웠습니다.
그 과정에서 단순한 코드 리팩토링을 넘어, 신규 아키텍처로 클린 아키텍처 도입을 시도했고 , 이를 기반으로 모놀리식(monolithic) 구조를 버리고 멀티모듈( multi-module**) 구조**를 적용하게 되었습니다.
오늘은 제가 실제로 자동완성 API를 리팩토링하면서 경험한 사례를 중심으로, 왜 멀티모듈 구조를 선택했는지 , 그리고 멀티모듈을 적용하며 어떤 점을 주의해야 했는지 이야기해보려 합니다.
멀티모듈 프로젝트란?
말 그대로 하나의 큰 애플리케이션을 여러 개의 독립적인 모듈로 나눠 관리하는 구조 입니다.
각 모듈은 도메인이나 계층 단위로 역할을 명확히 나누고, 필요한 부분만 서로 의존하도록 구성합니다.
예를 들어, core-domain 모듈은 핵심 비즈니스 규칙만 담당하고,
core-infra 모듈은 외부 시스템(예: DB, Redis, Elasticsearch 등)과의 연동만 책임집니다.
그리고 core-app 모듈은 이 두 영역을 연결해 실제 서비스 로직을 구현하며, API 모듈(api-autocomplete)은 외부 요청을 받아 애플리케이션 계층으로 전달하는 역할을 맡습니다.
root-project/
├── core-domain/ # 도메인(모델, 인터페이스)
├── core-infra/ # 인프라(외부연계)
├── core-app/ # 애플리케이션 서비스(비즈니스)
├── api-autocomplete/ # REST API(서비스 endpoint)
└── core-common/ # 공통 유틸리티
멀티모듈 구조의 장점
1. 관심사의 분리
각 모듈이 자기 역할에만 집중할 수 있고, 특정 기능을 수정하더라도 다른 영역에 불필요한 영향이 가지 않게 됩니다.
예를 들어 도메인 모델 및 인터페이스는 core-domain, 인프라 관련 로직은 core-infra로 분리해두면 코드가 훨씬 깔끔해집니다.
// core-domain
// 도메인 모델
public class AutocompleteRequest{
private final String q;
private final List<AutocompleteType> autocompleteTypes;
@Builder
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
private AutocompleteRequest(
@JsonProperty("q") String q,
@JsonProperty("autocompleteTypes") List<AutocompleteType> autocompleteTypes
) {
this.q = q;
this.autocompleteTypes = autocompleteTypes;
}
}
// 인터페이스
public interface SearchRepository { // core-infra interface
Object singleSearch(Object request);
Object multiSearch(Object request);
}
public interface SearchService { // core-app interface
SearchResult<List<AutocompleteResponse>> autocompleteSearchExecute(AutocompleteRequest request);
}
// core-infra
public class ElasticSearchRepository implements SearchRepository {
private final ElasticsearchClient elasticsearchClient;
/**
* 단일 검색
*/
@Override
public SearchResponse<Object> singleSearch(Object request) {
SearchRequest searchRequest = (SearchRequest) request;
try {
// 외부 시스템 연동만 담당
return elasticsearchClient.search(searchRequest, Object.class);
} catch (Exception e) {
throw new BaseExceptionHandler(e);
}
}
}
이렇게 하면 도메인 모듈이 외부 기술에 오염되지 않고, 순수한 비즈니스 규칙에만 집중할 수 있습니다.
2. 재사용성 향상
core-common이나 core-domain, core-infra같은 모듈은 다른 프로젝트에서도 쉽게 가져다 쓸 수 있습니다.
예를 들어, 검색 서비스와 예약 서비스가 같은 유틸 클래스를 사용해야 할 때, 공통 모듈로 분리해두면 중복 코드 없이 깔끔하게 재사용이 가능하죠.
검색 도메인의 경우 주로 사용하는 repository가 ElasticSearch와 Redis 이다보니 core-infra 영역의 중복코드가 많이 줄어들게 됩니다.
3. 병렬 개발에 유리
규모가 커질수록 여러 팀원이 동시에 개발하게 됩니다.
예를 들어 SRP 검색 영역을 수정해야 하는 경우에는 api-search 모듈을,
자동완성 기능을 수정해야 할 때는 api-autocomplete 모듈을 각각 수정하면 됩니다.
도메인이나 인프라 등 공통 로직이 필요한 경우에는 각 코어 모듈들의 공통 로직을 변경하면 되고(core-app, core-domain, core-infra, core-commnon)서비스별 특화기능이 필요한 경우에는 각 서비스 영역별로 구성된 core-app/service/*,core-domain/service/*, core-infra/service/* 모듈을 변경하면 되죠.
이렇게 서비스별로 역할을 명확히 분리하면 각 팀원이 독립적으로 개발하고 테스트할 수 있어 충돌이 줄고 협업도 훨씬 수월해집니다.
결과적으로 코드의 응집도는 높아지고 결합도는 낮아지는 효과를 얻을 수 있습니다.
4. 의존성 관리가 명확
모듈 간 의존성을 코드 차원에서 강제할 수 있습니다.
예를 들어, api → app → domain → infra 방향으로만 의존하도록 구조를 설계하면, 순환 의존 같은 아키텍처 붕괴를 미리 방지할 수 있습니다.
멀티모듈 구조의 단점
물론 단점도 있습니다. 개인적으로 직접 겪어본 어려움은 아래와 같습니다.
1. 초기 설정의 복잡함
처음 멀티모듈을 설계할 땐, project root의 settings.gradle.kts, build.gradle.kts부터 각 모듈의 build.gradle.kts까지 전부 손봐야 합니다.
이 과정이 생각보다 손이 많이 갑니다.
게다가 모듈 간 의존을 잘못 걸면, Gradle sync가 꼬이기도 쉽죠.
// project root - setting.gradle.kts
rootProject.name = "kr.co.gccompany"
include(
":core-common",
":core-domain",
":core-app",
":core-infra",
":api-autocomplete"
)
// project root - build.gradle.kts
plugins {
id("org.springframework.boot") version "3.4.5" apply false
id("io.spring.dependency-management") version "1.1.4" apply false
java
}
allprojects {
val buildNumber = System.getenv("BUILD_NUMBER") ?: "0"
group = "kr.co.gccompany"
version = "1.0.${buildNumber}"
repositories {
maven { url = uri("https://repo.spring.io/snapshot") }
mavenCentral()
}
}
subprojects {
apply(plugin = "java")
apply(plugin = "org.springframework.boot")
apply(plugin = "io.spring.dependency-management")
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
dependencies {
// Spring Boot dependencies
implementation("org.springframework.boot:spring-boot-starter-validation")
// apache-commons
implementation("org.apache.commons:commons-lang3:3.17.0")
implementation("org.apache.commons:commons-collections4:4.4")
// Elasticsearch 공식 Java Client
implementation("co.elastic.clients:elasticsearch-java:8.15.5")
implementation("org.elasticsearch.client:elasticsearch-rest-client:8.15.5")
// redis(blocking)
implementation("org.springframework.boot:spring-boot-starter-data-redis")
// lombok
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
// Spring Boot Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.test {
useJUnitPlatform()
}
// 모듈 이름이 "core-"로 시작하는 경우에만 적용
if (name.startsWith("core-")) {
tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
enabled = false
}
tasks.named<Jar>("jar") {
enabled = true
}
}
}
// api-autocomplete - build.gradle.kts
dependencies {
// module dependencies
implementation(project(":core-common"))
implementation(project(":core-domain"))
implementation(project(":core-app"))
testImplementation(project(":core-common"))
testImplementation(project(":core-domain"))
testImplementation(project(":core-app"))
// Spring Boot dependencies
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-aop")
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("org.springframework.boot:spring-boot-starter-actuator")
}
tasks {
// cleanBootJar 정의
register ("cleanBootJar") {
dependsOn ("clean", "bootJar")
}
bootJar {
mustRunAfter ("clean")
archiveFileName .set ("sphynx-autocomplete-api.jar")
}
}
// core-infra(structure) - build.gradle.kts
plugins {
id ("java-library")
}
dependencies {
// 모듈 종속성
implementation (project(":core-common"))
implementation (project(":core-domain"))
testImplementation (project(":core-common"))
testImplementation (project(":core-domain"))
}
2. 러닝 커브
멀티모듈 구조가 익숙하지 않은 팀원 입장에서는, “이 코드 어디에 넣어야 하지?”부터 막힐 수 있습니다.
의존성 방향을 잘못 이해하면 순식간에 구조가 엉킬 수도 있죠.
그래서 반드시 문서화와 공유 세션이 필요합니다.
3. 과도한 모듈화의 함정
처음 도입할 땐 “이것도 분리하자, 저것도 따로 빼자” 하다가 결국 너무 많은 모듈이 생겨버리는 경우가 많습니다.
그렇게 되면 단일 기능 하나 수정하려고 여러 모듈을 건드려야 해서 오히려 비효율적이 됩니다.

4. 디버깅의 번거로움
모듈이 많아지면 디버깅 시 여러 모듈을 오가며 확인해야 해서 추적이 번거로워집니다.
많은 단점에도 멀티모듈을 도입한 이유는..?
솔직히 말씀드리면, 멀티모듈 구조는 처음 도입할 때 꽤나 버겁습니다.
Gradle 설정부터 모듈 간 의존성 관리, 빌드 환경 세팅까지 생각보다 손이 많이 가고, 디버깅도 번거로워집니다.
그래서 “굳이 이렇게까지 해야 하나?” 하는 생각이 드는 것도 사실입니다.
그럼에도 불구하고 멀티모듈을 선택한 이유는 단기적인 복잡함보다 장기적인 단순함을 얻기 위해서였습니다.
기존의 모놀리식 구조에서는 다음과 같은 문제들이 꾸준히 쌓이고 있었습니다.
- 코드 간 결합도가 높아 리팩토링이 어려움
- 테스트 단위가 커지고, 단일 기능 검증에도 시간이 과도하게 소요
- 공통 로직 중복으로 인해 유지보수 비용이 점점 증가
이런 문제들은 결국 “개발 속도 저하 → 품질 저하 → 기술 부채 누적”으로 이어졌습니다.
즉, 지금의 불편함을 감수하지 않으면 앞으로는 더 큰 비효율을 감당해야 하는 상황이었죠.
멀티모듈 구조는 이런 문제들을 구조적으로 해결할 수 있는 가장 현실적인 방법이었습니다.
각 계층과 도메인을 분리하고, 의존성 방향을 클린 아키텍처에 맞게 설계함으로써 도메인은 순수하게 유지되고 , 외부 기술 변화에도 흔들리지 않는 구조를 만들 수 있었습니다.
결국 멀티모듈 전환은 단순한 기술 실험이 아니라, 장기적인 기술 부채를 줄이고 팀의 개발 체질을 개선하기 위한 전략적인 선택이었습니다.
당장은 불편하더라도, 그 불편을 견디면 코드의 유지보수성과 확장성이 눈에 띄게 달라집니다.
멀티모듈 설계 시 고려할 점
1. 모듈 분리 기준을 명확히
도메인 기준, 계층 기준, 기능 기준 중 하나로 정하고 일관되게 가져가야 합니다.
- 도메인 기준: user, order, payment
- 계층 기준: domain, application, infrastructure
- 기능 기준: search-api, admin-api, batch-job
보통은 계층 + 기능 혼합형 구조가 가장 현실적이라고 생각됩니다.
2. 의존성 방향은 도메인을 중심으로
presentation(api) → application(app) → domain ← infrastructure(infra)
도메인을 중심에 두고, 다른 모듈이 도메인에 의존하도록 방향을 유지해야 합니다.
도메인을 중심에 두는 이유는
- 비즈니스 로직의 독립성 확보: 도메인은 외부 기술(DB, 메시징, 프레임워크)에 의존하지 않고, 순수 비즈니스 규칙만을 포함합니다.
- 재사용성과 유지보수 용이: 도메인이 외부 구현에 얽매이지 않으므로, 새로운 기술 스택으로의 전환이나 테스트가 쉬워집니다.
- 모듈 간 결합도 최소화: 다른 계층이 도메인을 참조만 하므로, 의존성이 단방향으로 유지됩니다.
의존성 역전이 발생하여 도메인이 인프라나 애플리케이션에 의존하면, 기술 변경 시 도메인까지 수정해야 합니다. 결국 모놀리식 구조처럼 유지보수가 어렵고 테스트가 힘든 애플리케이션 이 됩니다. 따라서 도메인을 중심으로 다른 모듈이 도메인에 의존하는 구조를 반드시 지켜야 합니다.
3. 한 번에 나누지 말 것
기존 모놀리식 프로젝트를 한 번에 멀티모듈로 나누면 리펙토링 및 검증에 오랜 시간이 소요될 수도 있고, 모듈분리가 실패할 확률 또한 높습니다.
점진적으로 분리하는것을 추천합니다.

모놀리스에서 멀티 모듈로의 단계적 마이그레이션
4. 문서화는 선택이 아니라 필수.
각 모듈의 역할, 의존성 방향을 문서로 정리해두면 새로운 팀원이 들어와도 빠르게 적응 가능합니다.
요즘에는 다양한 생성형 AI가 잘 정리해주니 프로젝트 내에 md파일로 추가하는걸 추천합니다.


5. 테스트 전략 분리
각 모듈은 자기 단위 테스트에 집중하고, 통합 테스트는 별도 모듈로 분리하는 게 좋습니다.
@ExtendWith(MockitoExtension.class)
class ElasticSearchServiceTest {
// core-infra 단위 테스트
}
@SpringBootTest
class AutocompleteControllerTest {
// api-autocomplete - 자동완성 테스트(통합)
}
6. 공통 모듈 관리
모든 모듈에서 공통으로 쓰는 라이브러리는 core-common에서 관리합니다.
7. 버전 관리 일원화
라이브러리 버전은 한곳에서 관리합니다.
멀티모듈 프로젝트는 분명 대규모 애플리케이션을 효율적으로 관리할 수 있는 강력한 구조입니다.
하지만 무작정 도입하면, 모듈이 과도하게 분리되어 오히려 복잡도가 높아지고 개발 속도가 떨어질 수 있습니다.
모듈을 나누는 기준은 단순히 작게 쪼개는 것이 아니라, 팀이 이해하고 유지할 수 있는 수준으로 설계하는 것입니다.
즉, 명확한 분리 기준을 세우고 의존성 방향을 지키며, 점진적으로 구조를 다듬어 가는 것 이 핵심입니다.
비록 작은 변화처럼 보여도, 이런 구조적 개선은 장기적으로 개발 생산성과 유지보수성에 큰 차이를 만들어냅니다.
앞으로도 더 나은 코드 품질과 서비스 안정성을 위해 지속적으로 구조를 개선하고, 팀이 함께 성장할 수 있는 효율적이고 견고한 아키텍처 방향을 꾸준히 탐색해 나가겠습니다.
읽어주셔서 감사합니다.
