멀티 모듈 구조로 전환한 이유와 과정

송현진·2025년 4월 8일
0

Spring Boot

목록 보기
10/23

❓ 왜 멀티 모듈로 바꿨는가?

처음 프로젝트를 시작할 땐 그냥 익숙한 대로 하나의 모듈에 컨트롤러, 서비스, 엔티티, 레포지토리, 설정 파일까지 다 몰아넣어서 진행했다.
나름대로 user, item, order 같은 디렉토리로 분리해서 관리는 했지만, 점점 코드가 많아지고 기능이 늘어날수록 구조적으로 뒤엉키기 시작했다.

예를 들어, 어떤 기능이 어디까지 의존해도 막을 수가 없고, 테스트 코드도 꼬이고, 레이어 간 경계도 흐릿해져서 "이거 맞나?" 싶은 생각이 들었다. 그래서 프로젝트 구조를 근본적으로 갈아엎자고 마음먹었다.

🏗️ 멀티 모듈 구조

root 
├── api         # 컨트롤러 / 서비스 조립, WebMVC 구성
├── domain      # 엔티티 / 레포지토리 / 도메인 서비스
├── infra       # S3, Redis 등 인프라 연동 모듈
├── common      # 공통 응답 DTO, 유틸, 예외 처리 등

모듈 간 의존 구조

⚠️ 순환 의존을 막기 위해 한 방향으로만 참조되도록 설계했다.

✅ 이렇게 나눈 이유

  • api: 사용자의 요청을 받고, 결과를 반환하는 입구 역할. 여기선 도메인 서비스들을 조합해서 실제 비즈니스 요구사항을 처리한다.
  • common: 어디서든 쓰는 것들. 예외처리, 응답 DTO 같은 것들 모아놓은 공용 창고 느낌이다.
  • domain: 핵심 로직. 어떤 기술에도 의존하지 않고, 정말 '서비스가 어떤 규칙과 구조로 돌아가야 하는지'를 담당한다.
  • infra: 외부 시스템(Redis, S3 등)과 연결하는 부분. domain은 외부 기술을 모르고, 여기서만 담당하게 했다.

과정

루트 모듈에서 새 모듈을 생성해준다.

컨트롤러 단을 위한 모듈로 api로 만들었다.

settings.gradle

rootProject.name = 'coupangclone'

include 'api'

이건 말 그대로 모듈 등록하는 것이다.
이제부터 Gradle은 api를 하나의 모듈로 인식하고 빌드해준다.

root/build.gradle

plugins {
	id 'org.springframework.boot' version '3.2.5' apply false
	id 'io.spring.dependency-management' version '1.1.7'apply false
	id 'java'
}

// group, version, repository 공통 세팅
allprojects {
	group = 'com.example'
	version = '0.0.1-SNAPSHOT'

	repositories {
		mavenCentral()
	}
}

// 공통으로 적용할 설정
subprojects {
	apply plugin: 'java'
	apply plugin: 'io.spring.dependency-management'

	java {
		toolchain {
			languageVersion = JavaLanguageVersion.of(17)
		}
	}

	configurations {
		compileOnly {
			extendsFrom annotationProcessor
		}
	}

	dependencies {
    	// 공통으로 사용할 dependency
		compileOnly 'org.projectlombok:lombok'
		annotationProcessor 'org.projectlombok:lombok'
		testImplementation 'org.springframework.boot:spring-boot-starter-test'
	}
	
}
  • subprojects
    • 모든 모듈에 공통으로 들어가야 할 lombok, test, Java 17 등을 여기서 한 번에 설정한다.
    • 각 모듈마다 일일이 넣는 게 아니라 root에서 통일감 있게 관리할 수 있는 장점이 있다.

왜 apply false인가?
apply false는 루트 빌드 스크립트에서 해당 플러그인을 선언만 하고, 실제로는 적용하지 않겠다는 의미다. 이걸 쓰는 이유는 루트 모듈은 직접 Spring Boot 애플리케이션을 실행하지 않기 때문이다. 대신 하위 모듈에서 필요한 플러그인을 사용하게 된다.

api/build.gradle

plugins {
    id 'org.springframework.boot' version '3.2.5'
    id 'io.spring.dependency-management' version '1.1.7'
    id 'java'
}

dependencies {
	// api가 domain, infra, common 모듈에 의존
    implementation project(':common')
    implementation project(':domain')
    implementation project(':infra')

    // Web, Security
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // JWT
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

    // Swagger
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'

    // Test
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

api는 외부에 노출되는 "입구"니까 여기서 도메인과 인프라를 조합한다.
대신 domain, infra, common은 서로 필요할 땐 일부만 참조할 수 있도록 했다.

root에 설정한 gradle과 api에 설정한 gradle이 정상적으로 된 모습이다.

모듈을 나누고 실행하니 잘 실행되는 모습이다.

🤔 바꾸고 나서 느낀 점

솔직히 처음엔 좀 귀찮았다. 파일 나누고, build.gradle 나누고, 설정 손보는 것도 일이었고.
근데 구조가 나뉘고 나니까 갑자기 프로젝트가 훨씬 ‘깨끗해진 느낌’이 들었다.

레이어 간 경계가 명확해서 어디서 어떤 책임을 지고 있는지 명확해졌고, 도메인만 따로 테스트하기도 훨씬 쉬워졌고, 인프라 의존성 분리가 되니까 테스트 환경 구성도 깔끔해져 프로젝트가 진짜 "서비스답게" 정리되었다는 느낌이 들었다.

앞으로 기능을 확장하거나 리팩토링할 때도 훨씬 수월해질 것 같아서, 이 결정은 꽤 만족스럽다.
이제는 기능 하나 만들더라도 "어디에 넣어야 하지?"가 아니라 "어떻게 조합해야 하지?"를 먼저 고민하게 되는 것 같다.

profile
개발자가 되고 싶은 취준생

0개의 댓글