0단계에서 "MSA + 헥사고날 + DDD"로 가기로 했으니, 프로젝트 뼈대를 어떻게 잡을지가 첫 과제였다.
결정해야 할 게 생각보다 많았다.
user-service로 할지 user로 할지하나씩 결론을 냈다.
단일 모듈로 시작하면 나중에 MSA로 분리할 때 공통 코드를 어디에 둘지가 문제가 된다. :common 모듈을 처음부터 두면 ApiResponse, ErrorCode 같은 공통 유틸을 여러 서비스가 공유할 수 있다.
common ← user, post, like, notification, feed
의존성 방향은 항상 서비스 → common. 반대 방향은 금지다.
멀티모듈을 Initializr로 한 번에 뽑는 건 안 된다. Initializr는 단일 모듈만 만들어준다. 그래서 이렇게 했다.
gradlew, gradlew.bat, gradle/wrapper/만 가져오기src/ 폴더 삭제 (루트엔 코드 없어야 함)settings.gradle, build.gradle 직접 재작성build.gradle은 손으로 생성Initializr에서 가장 중요하게 챙긴 건 Gradle Wrapper다. gradlew가 없으면 시스템에 Gradle이 설치돼 있어야 하고, 버전이 안 맞으면 이유 모를 에러가 생긴다.
JAR vs WAR는 고민할 것도 없었다. Docker 컨테이너에서 java -jar app.jar로 바로 띄우려면 JAR이다. WAR은 외부 WAS가 따로 있어야 한다.
모듈 이름은 -service 없이 도메인 이름만 쓰기로 했다. user-service보다 user가 짧고 패키지 경로도 깔끔해진다.
rootProject.name = 'imon'
include 'admin'
include 'common'
include 'feed'
include 'like'
include 'notification'
include 'post'
include 'user'
멀티모듈에서 루트 build.gradle의 역할은 "모든 서브모듈에 공통으로 적용할 것들을 한 곳에서 관리"다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.15' apply false // 선언만, 적용은 서비스 모듈에서
id 'io.spring.dependency-management' version '1.1.7'
}
allprojects {
group = 'com.imon'
version = '0.0.1-SNAPSHOT'
repositories { mavenCentral() }
}
subprojects {
apply plugin: 'java'
apply plugin: 'io.spring.dependency-management'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
dependencyManagement {
imports {
mavenBom "org.springframework.boot:spring-boot-dependencies:3.5.15"
}
}
dependencies {
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
}
여기서 중요한 포인트가 두 가지다.
apply false의 의미는 "이 플러그인을 클래스패스에는 올려두되, 지금 바로 적용하지는 않는다"는 뜻이다. Spring Boot 플러그인은 bootJar 태스크를 활성화하는데, :common은 실행 모듈이 아니라 라이브러리라서 bootJar가 필요 없다. 서비스 모듈에서만 id 'org.springframework.boot'를 적어서 켜면 된다.
sourceCompatibility 대신 toolchain을 쓴 이유는 JDK 버전을 더 명시적으로 고정할 수 있어서다. 시스템에 JDK가 여러 버전 깔려있어도 17로 강제된다.
// 라이브러리 모듈 — Spring Boot 플러그인 미적용
// bootJar 태스크 없음, 일반 jar로 빌드되어 각 서비스 모듈에 포함됨
dependencies {
// ApiResponse, ApiError에서 ResponseEntity / HttpStatus 사용
// ErrorCode에서 HttpStatus 사용
implementation 'org.springframework.boot:spring-boot-starter-web'
// 모든 서비스 PK 생성에 사용하는 Monotonic ULID 라이브러리
implementation 'com.github.f4b6a3:ulid-creator:5.2.3'
}
:common에 뭘 넣을지 기준을 잡아뒀다. 이 기준이 없으면 나중에 뭐가 들어가야 하는지 헷갈린다.
:common 포함 기준 — 아래 조건을 모두 만족할 때만 추가한다.
1. 모든(또는 대부분의) 서비스 모듈에서 필요하다
2. 서비스마다 다른 구현이 필요하지 않다
3. 도메인 로직이 아닌 인프라/공통 유틸 성격이다
현재 common 포함 항목:
- UlidGenerator → 모든 서비스가 동일한 PK 생성 전략 사용
- ErrorCode (인터페이스) → 각 서비스가 자기 에러코드 enum을 구현
- CommonErrorCode → 인증, 입력값 오류 등 서비스 무관한 공통 에러
- BusinessException → 모든 서비스가 동일한 예외 체계 사용
- ApiResponse, ApiError → 모든 서비스가 동일한 응답 포맷 사용
BaseEntity와 GlobalExceptionHandler는 common에 두지 않는다. BaseEntity는 JPA에 종속된 코드라 JPA를 쓰는 서비스 모듈(persistence 패키지) 안에 위치하고, GlobalExceptionHandler는 Spring MVC 컨텍스트에 등록되는 빈이라 각 서비스의 컴포넌트 스캔 범위 안에 있어야 한다. 둘 다 서비스 모듈에 두는 게 자연스럽다.
서비스 전용 에러코드도 common에 섞지 않는다. UserErrorCode처럼 도메인별 에러는 해당 서비스 모듈 안에 *ErrorCode enum으로 정의하고 ErrorCode 인터페이스를 구현한다. 이렇게 하면 user 서비스가 post 서비스의 에러코드를 알게 되는 상황을 막을 수 있다.
plugins {
id 'org.springframework.boot' // bootJar 활성화, 버전은 루트에서 관리
}
dependencies {
implementation project(':common')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
runtimeOnly 'com.h2database:h2'
}
처음에 ulid-creator를 :user에도 중복 선언했는데, :common에 이미 있어서 제거했다. implementation은 컴파일 클래스패스까지 전파되지 않아서 직접 호출할 일이 있으면 명시해야 하지만, UlidGenerator는 :common 안에서 쓰고 서비스에서 직접 import 안 하기 때문에 중복이었다.
헥사고날 아키텍처는 adapter, application, domain 세 계층으로 나뉜다. application 레이어를 명시적으로 분리해야 의존성 방향이 명확해진다.
user/src/main/java/com/imon/user/
├── adapter/
│ ├── in/
│ │ └── web/
│ │ ├── UserController.java
│ │ ├── GlobalExceptionHandler.java
│ │ └── dto/
│ │ ├── RegisterRequest.java
│ │ └── UserResponse.java
│ └── out/
│ └── persistence/
│ ├── BaseEntity.java
│ ├── UserJpaEntity.java
│ ├── UserJpaRepository.java
│ └── UserPersistenceAdapter.java
│
├── application/
│ ├── port/
│ │ ├── in/
│ │ │ ├── RegisterUseCase.java
│ │ │ └── GetUserUseCase.java
│ │ └── out/
│ │ └── UserRepository.java
│ └── service/
│ └── RegisterService.java
│
└── domain/
└── model/
└── User.java
의존성 방향은 adapter → application → domain 단방향이다. 도메인은 아무것도 의존하지 않는다.
Gradle Wrapper 경로 문제: Windows PowerShell에서 ./gradlew가 안 됐다. ./이 아니라 .\을 써야 하고, gradlew는 루트에서 실행해야 한다.
# 틀린 것
./gradlew :user:compileJava
# 맞는 것 (루트에서, Windows)
.\gradlew :user:compileJava
common 의존성 전파: implementation은 런타임 클래스패스까지만 전파된다. 소비 모듈 컴파일 타임엔 보이지 않는다. api를 쓰면 컴파일까지 전파되는데, 이를 사용하려면 java 플러그인 대신 java-library 플러그인이 필요하다. 현재 구조에선 서비스가 공통 코드를 직접 import해서 쓰는 경우가 있어서, 이 부분을 고려해서 의존성 구성을 했다.
.\gradlew :user:compileJava
# BUILD SUCCESSFUL in 2s
다음 글에서는 DDD 패턴(Entity, Repository, UseCase, Command)을 이 폴더 구조에 실제로 매핑한 과정을 기록할 예정이다.