🥕 이번 당근 클론 프로젝트에서는 기존에 자주 사용하던 MVC 구조가 아닌 헥사고날 아키텍처를 적용해보려 한다. 따라서 먼저 배경 지식을 습득후 간단한 예제 코드를 통해 헥사고날 아키텍처에 대해 익숙해지려 한다.
이전 포스팅과 이어집니다.
이번에는 이전 포스팅에서 설명한 MVC 구조에서 싱글 모듈 헥사고날 아키텍처를 적용한 API 서버를 멀티 모듈로 마이그레이션 해보려 한다. 싱글 모듈의 한계점은 명확하게 존재한다. 모든 로직이 하나의 dependency를 공유하며 사용하지 않는 의존성 마저 알고 있어야 한다는 것. 이를 해결하기 위해 아래의 배달의 민족 기술 블로그 포스팅을 참고하여 멀티 모듈로 구성해보려 한다.
싱글 모듈 프로젝트 구조
싱글 모듈의 디렉토리 구조는 아래의 사진과 같다.
- domain
- application: domain을 사용하여 비즈니스 로직을 수행한다.
- bootstrap: UseCase를 사용하여 클라이언트 요청을 처리한다.
- framework: application의 output Port에 의해 DB 로직을 처리한다.
이해를 위해 이전 포스팅의 그림을 인용하여 표현하면 아래와 같다.
현재는 4개의 모듈이 모두 같은 dependency를 공유하여, 불필요한 의존성을 가지고 있다. 도메인은 POJO의 구성을 가지면 충분하지만 도메인 디렉터리는 스프링의 존재를 알고 있다는 것이다. 이제 4개의 디렉터리를 각각의 모듈로 분리하고, 필요한 의존성만 추가하여 독립적으로 관리될 수 있게 구성할 것이다.
멀티 모듈로 마이그레이션
우선 싱글 모듈의 디렉터리를 각각의 이름을 딴 모듈로 생성한다.
이후 루트 프로젝트에서 하위 모듈을 관리할 수 있게 setting.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')
}
}
Domain
// build.gradle
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.0'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
}
도메인 모듈은 각 모듈에서 사용되는 POJO 도메인 객체로 구성된다.
Application
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
애플리케이션 모듈은 비즈니스 로직에서 사용할 UseCase, Output Port 인터페이스를 선언하고, 이를 이용하여 비즈니스 로직을 구현한다.
// ApplicationConfig
@Configuration
@ComponentScan(basePackages = {"org.flab.hyunsb.application.service"}, lazyInit = true)
public class ApplicationConfig {
}
service 디렉터리 즉, UseCase 인터페이스를 구현하는 비즈니스 로직 객체를 빈으로 등록한다. 해당 빈은 이후 Bootstrap 모듈의 Adapter(Controller)에서 사용된다. 비즈니스 로직은 런타임에 output 포트의 구체 클래스가 필요한데 해당 의존성은 Bootstrap 모듈에 존재하는 애플리케이션 시작 메서드에서 추가해줄 것이다.
Framework
// 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'
}
framework 모듈은 DB 처리를 구현하는 모듈이다. 해당 프로젝트에서는 MySQL과 JPA를 사용하기에 해당하는 의존성을 추가해준다.
@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 {
}
Adapter는 Application의 output 인터페이스를 구현하고, JpaRepository를 사용한다.
Bootstrap
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'
}
Bootstrap 모듈은 애플리케이션의 시작 메서드가 존재한다. 사용자의 입력 값을 검증하기 위해 spring-validation
과 web
의존성을 추가해주었다.
@Configuration
@ComponentScan(basePackages = "org.flab.hyunsb.bootstrap.rest")
public class BootstrapConfig {
}
스프링 컨테이너 빈 의존성 주입 플로우
메인 메서드는 아래와 같다.
@SpringBootApplication
@Import(value = {ApplicationConfig.class, PersistenceConfig.class})
public class BootstrapApplication {
public static void main(String[] args) {
SpringApplication.run(BootstrapApplication.class, args);
}
}
서버를 실행하며 스프링 컨테이너에 Application
모듈과 Framework
모듈의 configuration
파일을 읽어, 빈으로 등록하고 의존성을 주입해준다.
이렇게 Bootstrap 모듈은 애플리케이션을 실행하며 스프링 컨테이너에 Framework 모듈의 모든 Bean을 등록하고 Application 모듈의 Bean에 의존성을 주입해준다. 여기서 불필요한 Framework의 Bean또한 모두 스캔되어 의존성이 주입되는데, 이를 지연하기 위해 Application의 configuration 파일에 LazyInit
옵션을 추가했다.
LazyInit 옵션을 설정하면 애플리케이션의 시작과 동시에 모든 bean이 등록되는 것이 아닌, 클라이언트의 요청에 맞는 Adapter가 호출되면 요청을 처리하기 위해 필요한 Bean이 등록되고 의존성이 추가된다.
상품 등록 기능 구현
Domain 모듈에 비즈니스 로직에서 사용될 도메인 객체를 생성한다.
@Getter
@Builder
public class Product {
private final Long id;
private final String name;
private final String description;
}
Application 모듈에 도메인을 처리하기 위한 비즈니스 로직을 구현한다.
public interface CreateProductUseCase {
Product createProduct(Product product);
}
public interface ProductOutputPort {
Product saveProduct(Product product);
}
@Service
@RequiredArgsConstructor
public class ProductService implements CreateProductUseCase, GetProductUseCase {
private final ProductOutputPort productOutputPort;
@Override
public Product createProduct(Product product) {
return productOutputPort.saveProduct(product);
}
}
Framework 모듈에 비즈니스 로직을 외부 자원을 통해 처리하기 위한 Adapter를 구현한다.
public interface ProductRepository extends JpaRepository<ProductEntity, Long> {
}
@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();
}
}
Bootstrap 모듈에 클라이언트의 요청을 처리할 Adapter를 구현한다.
@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);
}
}
간단한 상품 등록 API 서버를 헥사고날 아키텍처를 적용하여 멀티 모듈로 구성해보았다.
마주쳤던 문제들
멀티 모듈로 프로젝트를 구성하며 Application 모듈에서 Service를 구현하고 의존성을 주입해주어야 했다. 그런데 Application 모듈은 Output 인터페이스의 구체 클래스를 모르기 때문에 어떻게 어디서 의존성을 주입해주어야 하는지 이해가 되지 않았다. 이 문제는 Framework와 Bootstrap 모듈을 구현하며 스프링 애플리케이션의 시작점을 잘 생각해보니 이해가 되었다.
멀티 모듈로 프로젝트를 구성하며 스프링이 엔티티를 인식하지 못하는 문제가 있었는데 @EntityScan 어노테이션을 사용하여 엔티티의 스캔을 명시적으로 선언해주었다.
'중고 거래 플랫폼 API 서버 개발' 카테고리의 다른 글
MySQL Full-Text Search를 통한 쿼리 개선 (0) | 2024.05.07 |
---|---|
헥사고날 아키텍처 Kafka + Event 적용 (0) | 2024.05.02 |
프로젝트 아키텍처와 의존성 방향 다이어그램 (0) | 2024.02.24 |
헥사고날 아키텍처: MVC 구조에서 헥사고날 아키텍처로 (0) | 2024.02.08 |