처음 프로젝트를 시작할 땐 그냥 익숙한 대로 하나의 모듈에 컨트롤러, 서비스, 엔티티, 레포지토리, 설정 파일까지 다 몰아넣어서 진행했다.
나름대로 user
, item
, order
같은 디렉토리로 분리해서 관리는 했지만, 점점 코드가 많아지고 기능이 늘어날수록 구조적으로 뒤엉키기 시작했다.
예를 들어, 어떤 기능이 어디까지 의존해도 막을 수가 없고, 테스트 코드도 꼬이고, 레이어 간 경계도 흐릿해져서 "이거 맞나?" 싶은 생각이 들었다. 그래서 프로젝트 구조를 근본적으로 갈아엎자고 마음먹었다.
root
├── api # 컨트롤러 / 서비스 조립, WebMVC 구성
├── domain # 엔티티 / 레포지토리 / 도메인 서비스
├── infra # S3, Redis 등 인프라 연동 모듈
├── common # 공통 응답 DTO, 유틸, 예외 처리 등
⚠️ 순환 의존을 막기 위해 한 방향으로만 참조되도록 설계했다.
루트 모듈에서 새 모듈을 생성해준다.
컨트롤러 단을 위한 모듈로 api
로 만들었다.
rootProject.name = 'coupangclone'
include 'api'
이건 말 그대로 모듈 등록하는 것이다.
이제부터 Gradle은 api
를 하나의 모듈로 인식하고 빌드해준다.
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'
}
}
왜 apply false인가?
apply false는 루트 빌드 스크립트에서 해당 플러그인을 선언만 하고, 실제로는 적용하지 않겠다는 의미다. 이걸 쓰는 이유는 루트 모듈은 직접 Spring Boot 애플리케이션을 실행하지 않기 때문이다. 대신 하위 모듈에서 필요한 플러그인을 사용하게 된다.
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 나누고, 설정 손보는 것도 일이었고.
근데 구조가 나뉘고 나니까 갑자기 프로젝트가 훨씬 ‘깨끗해진 느낌’이 들었다.
레이어 간 경계가 명확해서 어디서 어떤 책임을 지고 있는지 명확해졌고, 도메인만 따로 테스트하기도 훨씬 쉬워졌고, 인프라 의존성 분리가 되니까 테스트 환경 구성도 깔끔해져 프로젝트가 진짜 "서비스답게" 정리되었다는 느낌이 들었다.
앞으로 기능을 확장하거나 리팩토링할 때도 훨씬 수월해질 것 같아서, 이 결정은 꽤 만족스럽다.
이제는 기능 하나 만들더라도 "어디에 넣어야 하지?"가 아니라 "어떻게 조합해야 하지?"를 먼저 고민하게 되는 것 같다.