1. 헥사고날 아키텍처란?
헥사고날 아키텍처는 포트(Port)와 어댑터(Adapter) 라는 개념으로 소프트웨어를 안쪽(핵심 비즈니스)과 바깥쪽(외부 기술)으로 명확하게 분리하는 설계 방식입니다.
핵심 원칙은 단 하나입니다.
비즈니스 핵심 로직(도메인)은 외부 기술(웹, DB, 프레임워크)에 의존하면 안 된다.
의존 방향은 항상 바깥 → 안쪽(도메인)으로만 흐릅니다.
요청 흐름:
클라이언트 요청
↓
Bootstrap (Controller / REST Adapter)
↓ [IN Port 호출]
Application (UseCase / Service - 비즈니스 로직)
↓ [OUT Port 호출]
Framework (Persistence Adapter / JPA)
↓
DB
2. 왜 규칙이 무너지나?
아무리 좋은 규칙도 사람이 코드를 작성하다 보면 실수가 생깁니다.
싱글 모듈 구조의 문제는 명확합니다.
⚠️ 싱글 모듈의 핵심 한계
모든 로직이 하나의 dependency를 공유합니다.
도메인은 POJO(순수 자바 객체)로 구성되면 충분하지만, 도메인 디렉터리가 스프링의 존재를 알고 있게 됩니다.
즉, 사용하지 않는 의존성마저 알고 있어야 하는 구조가 됩니다.
이를 해결하기 위해 각 디렉터리를 독립된 모듈로 분리하고, 필요한 의존성만 추가하는 멀티 모듈 구조로 전환합니다.
3. 규칙을 강제하는 3가지 방법
방법 01 — Gradle 멀티 모듈로 물리적 격벽 세우기
Gradle · build.gradle
폴더 분리가 아니라 프로젝트 자체를 독립된 모듈로 분리합니다.
핵심 도메인 모듈의 build.gradle에 외부 라이브러리를 아예 추가하지 않으면,
개발자가 JPA나 Spring Web 코드를 쓰려 해도 컴파일 에러가 발생합니다.
💬 비유로 이해하기
아파트 동(棟)처럼 물리적으로 공간을 나눠버리면, 허락 없이는 옆 동에 들어갈 수 없습니다.
// 핵심 도메인 모듈 — 외부 기술 의존성 없음
dependencies {
// ✅ 순수 자바 코드만
// ❌ implementation(project(":framework")) ← 추가 시 컴파일 에러!
}
⚠️ 한계: 핵심 모듈 내부 패키지끼리 소통하려면 public을 열어야 하는데,
이 틈으로 외부 모듈도 접근할 수 있게 됩니다.
방법 02 — Java 9 JPMS로 공개 패키지 범위 제어하기
module-info.java · Java 9+
module-info.java 파일에 "외부로 공개할 패키지"만 명시적으로 exports 선언합니다.
선언되지 않은 패키지는 외부에서 아예 접근이 불가능해집니다. 방법 1의 한계를 해결합니다.
module com.example.domain {
// 외부에 공개할 패키지만 선언
exports com.example.domain.port;
// 선언 없는 패키지는 외부 완전 차단
}
방법 03 — Kotlin internal 키워드 활용하기
Kotlin · internal modifier
Kotlin의 internal 접근 제어자는 "이 클래스는 현재 내가 속한 모듈 안에서만 사용 가능" 합니다.
복잡한 설정 없이 키워드 하나로 모듈 외부 접근을 차단합니다.
// 외부 모듈에서 접근 불가
internal class OrderPersistenceAdapter : OrderRepository {
override fun save(order: Order) { /* DB 저장 */ }
}
// 인터페이스(포트)만 외부 공개
interface OrderRepository {
fun save(order: Order)
}
4. 싱글 모듈 구조 (Before)
싱글 모듈에서 헥사고날 아키텍처를 적용하면 아래와 같이 **디렉터리(폴더)**로 역할을 나눕니다.
디렉터리 역할
| domain | 순수 자바 비즈니스 객체 (POJO) |
| application | domain을 사용하여 비즈니스 로직 수행 (UseCase, Port, Service) |
| bootstrap | UseCase를 사용하여 클라이언트 요청 처리 (Controller) |
| framework | application의 Output Port를 구현해 DB 로직 처리 (JPA) |
문제점: 4개 영역이 모두 같은 dependency를 공유합니다.
→ 도메인은 POJO면 충분하지만, 스프링의 존재를 알게 됩니다.
5. 멀티 모듈로 마이그레이션 (After)
싱글 모듈의 4개 디렉터리를 각각 독립된 Gradle 모듈로 분리합니다.
프로젝트 구조
used-trading-market/ ← 루트 프로젝트
├── used-trading-domain/ ← 도메인 모듈 (순수 POJO)
├── used-trading-application/ ← 비즈니스 로직 모듈
├── used-trading-framework/ ← DB 처리 모듈 (JPA)
└── used-trading-bootstrap/ ← 실행 및 웹 요청 처리 모듈
모듈 의존 관계
bootstrap → application → domain
bootstrap → framework → domain
bootstrap → domain
framework → application
✅ domain은 아무 모듈도 바라보지 않습니다. (가장 순수한 모듈)
✅ application은 domain만 바라봅니다.
✅ framework는 domain과 application만 바라봅니다.
✅ bootstrap이 모든 모듈을 조립해 앱을 실행합니다.
settings.gradle
rootProject.name = 'used-trading-market'
include 'used-trading-bootstrap'
include 'used-trading-domain'
include 'used-trading-application'
include 'used-trading-framework'
루트 build.gradle — 모듈 간 의존성 설정
subprojects {
apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
dependencies {
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
tasks.named('test') {
useJUnitPlatform()
}
tasks.named('jar') {
enabled = false
}
}
// application 모듈은 domain 모듈을 사용한다
project(':used-trading-application') {
dependencies {
implementation project(':used-trading-domain')
}
}
// bootstrap 모듈은 domain, application, framework 모듈을 모두 사용한다
project(':used-trading-bootstrap') {
dependencies {
implementation project(':used-trading-domain')
implementation project(':used-trading-application')
implementation project(':used-trading-framework')
}
}
// framework 모듈은 domain, application 모듈을 사용한다
project(':used-trading-framework') {
dependencies {
implementation project(':used-trading-domain')
implementation project(':used-trading-application')
}
}
6. 각 모듈 상세 — 코드와 설정까지
🟢 Domain 모듈
역할: 각 모듈에서 공통으로 사용되는 순수 자바 도메인 객체
특징: 스프링, JPA 등 외부 기술 의존성 없음
// build.gradle
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.0'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
}
🟣 Application 모듈
역할: 비즈니스 로직. UseCase(IN Port)와 OutputPort(OUT Port) 인터페이스 선언 및 구현
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
// ApplicationConfig.java
@Configuration
@ComponentScan(
basePackages = {"org.flab.hyunsb.application.service"},
lazyInit = true // ← 지연 초기화로 불필요한 빈 등록 방지
)
public class ApplicationConfig {
}
💡 lazyInit = true란?
앱 시작 시 모든 빈을 등록하는 것이 아니라,
클라이언트 요청이 들어왔을 때 필요한 빈만 그때그때 등록합니다.
Framework 모듈의 불필요한 빈까지 모두 스캔되는 것을 방지합니다.
🔵 Framework 모듈
역할: Application의 Output Port를 구현해 실제 DB 처리 수행 (MySQL + JPA)
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.mysql:mysql-connector-j'
}
// PersistenceConfig.java
@Configuration
@EnableJpaRepositories(
basePackages = "org.flab.hyunsb.framework.persistence.repository"
)
@EntityScan(
basePackages = "org.flab.hyunsb.framework.persistence.entity"
)
@ComponentScan(
basePackages = "org.flab.hyunsb.framework.persistence.adapter"
)
public class PersistenceConfig {
}
⚠️ @EntityScan이 필요한 이유
멀티 모듈 환경에서는 스프링이 다른 모듈의 @Entity를 자동으로 인식하지 못합니다.
@EntityScan으로 엔티티 위치를 명시적으로 선언해야 합니다. (실제 겪은 문제!)
🟠 Bootstrap 모듈
역할: 애플리케이션 실행 + 클라이언트 요청 처리 (Controller)
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation:2.6.3'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
// BootstrapConfig.java
@Configuration
@ComponentScan(basePackages = "org.flab.hyunsb.bootstrap.rest")
public class BootstrapConfig {
}
// BootstrapApplication.java — 메인 실행 파일
@SpringBootApplication
@Import(value = {ApplicationConfig.class, PersistenceConfig.class})
public class BootstrapApplication {
public static void main(String[] args) {
SpringApplication.run(BootstrapApplication.class, args);
}
}
💡 @Import의 역할
Bootstrap이 Application 모듈과 Framework 모듈의 설정 파일을 읽어,
스프링 컨테이너에 빈으로 등록하고 의존성을 주입합니다.
7. 스프링 빈 의존성 주입 흐름
BootstrapApplication 실행
↓
@Import(ApplicationConfig, PersistenceConfig)
↓
스프링 컨테이너에 빈 등록
├── Application 모듈: ProductService (lazyInit)
└── Framework 모듈: ProductPersistenceAdapter, Repository, Entity
↓
Bootstrap 모듈: ProductRestAdapter (Controller)
├── CreateProductUseCase (= ProductService) 주입
└── ProductOutputPort (= ProductPersistenceAdapter) 주입
Application 모듈의 ProductService는 ProductOutputPort(인터페이스)에 의존합니다.
구체 클래스(ProductPersistenceAdapter)는 Framework 모듈에 있고,
Bootstrap의 메인 메서드가 실행될 때 스프링이 자동으로 연결(DI)해줍니다.
8. 실전 코드 — 상품 등록 기능 구현
Domain 모듈 — 순수 도메인 객체
// Product.java
@Getter
@Builder
public class Product {
private final Long id;
private final String name;
private final String description;
}
Application 모듈 — 포트와 서비스
// CreateProductUseCase.java (IN Port)
public interface CreateProductUseCase {
Product createProduct(Product product);
}
// ProductOutputPort.java (OUT Port)
public interface ProductOutputPort {
Product saveProduct(Product product);
}
// ProductService.java
@Service
@RequiredArgsConstructor
public class ProductService implements CreateProductUseCase {
private final ProductOutputPort productOutputPort;
@Override
public Product createProduct(Product product) {
return productOutputPort.saveProduct(product);
}
}
Framework 모듈 — DB 처리 어댑터
// ProductRepository.java
public interface ProductRepository extends JpaRepository<ProductEntity, Long> {
}
// ProductPersistenceAdapter.java (OUT Port 구현체)
@Component
@RequiredArgsConstructor
public class ProductPersistenceAdapter implements ProductOutputPort {
private final ProductRepository productRepository;
@Override
public Product saveProduct(Product product) {
ProductEntity productEntity = ProductEntity.from(product);
productEntity = productRepository.save(productEntity);
return productEntity.toDomain(); // JPA Entity → Domain 객체 변환
}
}
Bootstrap 모듈 — REST 컨트롤러
// ProductRestAdapter.java
@RequiredArgsConstructor
@RestController
public class ProductRestAdapter {
private final CreateProductUseCase createProductUseCase;
@PostMapping("/products")
public ResponseEntity<ProductCreateResponse> createProduct(
@RequestBody @Valid ProductCreateRequest productCreateRequest) {
Product product = productCreateRequest.toEntity();
product = createProductUseCase.createProduct(product);
ProductCreateResponse response = ProductCreateResponse.from(product);
return new ResponseEntity<>(response, HttpStatus.CREATED);
}
}
💡 핵심 포인트
ProductRestAdapter는 CreateProductUseCase(인터페이스)만 알고 있습니다.
ProductService가 실제 구현체라는 사실을 Controller는 모릅니다.
나중에 DB를 바꿔도 Framework 모듈만 수정하면 됩니다.
9. 마주쳤던 실제 문제들
문제 1 — 의존성 주입을 어디서 해야 하는가?
상황: Application 모듈의 ProductService는 ProductOutputPort(인터페이스)에 의존하는데,
구체 클래스(ProductPersistenceAdapter)가 다른 모듈(Framework)에 있어서 어떻게 주입해야 할지 몰랐습니다.
해결: Bootstrap 모듈의 메인 실행 파일에서 @Import로 ApplicationConfig와 PersistenceConfig를 가져오면,
스프링 컨테이너가 시작되면서 인터페이스와 구현체를 자동으로 연결해줍니다.
@SpringBootApplication
@Import(value = {ApplicationConfig.class, PersistenceConfig.class})
public class BootstrapApplication { ... }
문제 2 — 스프링이 @Entity를 인식하지 못함
상황: 멀티 모듈 환경에서 JPA 엔티티가 Framework 모듈에 있으면,
스프링이 자동으로 스캔하지 못해 에러가 발생합니다.
해결: @EntityScan으로 엔티티 위치를 명시적으로 알려줍니다.
@Configuration
@EntityScan(basePackages = "org.flab.hyunsb.framework.persistence.entity")
public class PersistenceConfig { ... }
10. 우아한형제들 블로그 참고
출처: 우아한형제들 기술블로그 - Spring Boot Kotlin Multi Module로 구성해보는 헥사고날 아키텍처 (2023.07)
11. 트레이드오프와 결론
✅ 멀티 모듈의 장점
- 규칙 위반을 구조적으로 원천 차단 (컴파일 에러)
- 코드 리뷰 없이도 아키텍처 유지
- 기술 교체 시 Domain · Application 코드 무변경
- 모듈별 독립 테스트 용이
- 사용하는 의존성만 각 모듈에 정확히 추가
⚠️ 멀티 모듈의 단점
- 기능 하나 수정 시 여러 모듈 동시 변경 필요
- 초기 설정 비용이 높음 (settings.gradle, 루트 build.gradle 등)
- @EntityScan, @ComponentScan 등 명시적 설정 필요
- 소규모 팀·초기 프로젝트엔 과도한 복잡성
- 인터페이스·DTO 변환 코드 증가
🎯 최종 정리
상황 권장 방법
| Kotlin 사용 시 | internal 키워드부터 도입. 가장 간단하고 실용적인 시작점 |
| Java 사용 시 | 멀티 모듈 + @EntityScan · @ComponentScan 명시 설정 |
| 공통 주의점 | 모듈을 너무 잘게 쪼개지 말 것. 생산성도 아키텍처의 일부 |
| Spring 어노테이션 | Application 모듈에서 사용 허용은 실용적 타협 — 팀이 결정 |
아키텍처를 완벽하게 지키는 것도 중요하지만,
개발자가 얼마나 편하게 일할 수 있는가(개발 생산성) 도 매우 중요합니다.
실제 서비스를 운영해 보며 적절한 타협점을 찾아가는 것이 핵심입니다.
'개발 > spring boot' 카테고리의 다른 글
| 멀티모듈 Gradle 프로젝트에서 IntelliJ 빌드하는 방법 (0) | 2026.03.06 |
|---|---|
| self-invocation 때문에 서비스 검증(@Validated)이 안 걸리는 문제 코드와, 실무에서 제일 흔한 해결책인 검증이 필요한 메서드를 다른 빈으로 분리 방법 (0) | 2026.02.26 |
| 헥사고날 아키텍처 구조 설명 (0) | 2026.02.12 |
| (Spring) sso 외부 인증 토큰을 검증하고 세션에 사용자 정보를 저장하는 흐름 (0) | 2026.01.07 |
| resolveLocale(req)가 하는 일 - 이 요청의 언어/국가(locale)를 어떻게 정할까? (0) | 2025.10.15 |