clean-architecture은(는) 무엇을 하나요?
Java 21+ Spring Boot 3.5+ 애플리케이션에서 클린 아키텍처, 헥사고날 아키텍처(포트와 어댑터), 도메인 주도 설계를 구현하기 위한 패턴을 제공합니다. 계층화된 아키텍처 구조화, 도메인 로직과 프레임워크 분리, 포트와 어댑터 구현, 엔티티/값 객체/집합체 생성, 테스트 용이성과 유지보수성을 위한 모노리식 코드베이스 리팩토링 시 사용하세요.
개요
이 스킬은 최신 Java 및 Spring Boot 생태계에서 클린 아키텍처, 헥사고날 아키텍처, 그리고 도메인 주도 설계(DDD) 원칙을 적용하는 방법을 안내합니다. 주요 목표는 다음과 같습니다:
- 관심사의 분리: 도메인 로직을 인프라스트럭처(데이터베이스, 웹, 메시징 등)로부터 완전히 분리합니다.
- 테스트 용이성: 도메인 계층을 독립적으로 단위 테스트할 수 있도록 만듭니다.
- 유지보수성: 코드 변경이 시스템의 다른 부분에 미치는 영향을 최소화합니다.
- 프레임워크 독립성: 비즈니스 로직이 특정 프레임워크에 결합되지 않도록 합니다.
주요 개념
1. 계층 구조
다음은 일반적인 의존성 방향을 보여줍니다:
[인프라스트럭처] --> [애플리케이션] --> [도메인]
| |
+------------- (인터페이스) -----------+
2. 핵심 용어
| 용어 | 설명 |
|---|---|
| 도메인 (Domain) | 비즈니스 규칙과 로직 (프레임워크에 의존하지 않음) |
| 애플리케이션 (Application) | 유스케이스 조정 및 트랜잭션 관리 |
| 인프라스트럭처 (Infrastructure) | 데이터베이스, 메시징, 외부 API 등과의 통신 |
| 엔티티 (Entity) | 고유 식별자를 가진 도메인 객체 |
| 값 객체 (Value Object) | 속성에 의해 식별되는 불변 객체 |
| 집합체 (Aggregate) | 일관성 경계로 묶인 엔티티 및 값 객체의 클러스터 |
| 포트 (Port) | 애플리케이션 계층이 정의하는 인터페이스 (인바운드/아웃바운드) |
| 어댑터 (Adapter) | 포트를 구현하여 외부 시스템과 통신하는 클래스 |
구현 가이드
패키지 구조 예시
com.example.myapp
├── domain
│ ├── model // 엔티티, 값 객체, 집합체
│ ├── service // 도메인 서비스
│ ├── repository // 리포지토리 인터페이스 (포트)
│ └── event // 도메인 이벤트
├── application
│ ├── port // 인바운드 포트 (유스케이스 인터페이스)
│ ├── service // 유스케이스 구현
│ └── dto // 애플리케이션 DTO
├── infrastructure
│ ├── persistence // JPA, MyBatis 등 구현 (어댑터)
│ ├── web // REST 컨트롤러 (어댑터)
│ └── messaging // 메시징 구현 (어댑터)
└── shared
└── kernel // 공통 기본 클래스 or 유틸리티
샘플 코드
1. 도메인 엔티티 (Java 21 Record 사용)
package com.example.myapp.domain.model;
import java.util.UUID;
public record Customer(UUID id, String name, Email email, LoyaltyPoints points) {
public Customer {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("고객 이름은 필수입니다.");
}
}
public Customer addPoints(int additionalPoints) {
return new Customer(this.id, this.name, this.email, this.points.add(additionalPoints));
}
}
2. 값 객체 (불변)
package com.example.myapp.domain.model;
public record Email(String value) {
private static final String EMAIL_PATTERN = "^[A-Za-z0-9+_.-]+@(.+)$";
public Email {
if (value == null || !value.matches(EMAIL_PATTERN)) {
throw new IllegalArgumentException("유효하지 않은 이메일 주소입니다.");
}
}
}
3. 포트 인터페이스 (아웃바운드)
package com.example.myapp.domain.repository;
import com.example.myapp.domain.model.Customer;
import java.util.Optional;
import java.util.UUID;
public interface CustomerRepository {
Customer save(Customer customer);
Optional<Customer> findById(UUID id);
void delete(Customer customer);
}
4. 유스케이스 (애플리케이션 계층)
package com.example.myapp.application.service;
import com.example.myapp.domain.model.Customer;
import com.example.myapp.domain.repository.CustomerRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class UpdateCustomerLoyaltyPointsUseCase {
private final CustomerRepository customerRepository;
public Customer execute(UUID customerId, int pointsToAdd) {
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new IllegalArgumentException("고객을 찾을 수 없습니다: " + customerId));
Customer updatedCustomer = customer.addPoints(pointsToAdd);
return customerRepository.save(updatedCustomer);
}
}
5. 인프라스트럭처 어댑터 (JPA)
package com.example.myapp.infrastructure.persistence;
import com.example.myapp.domain.model.Customer;
import com.example.myapp.domain.repository.CustomerRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
@Repository
@RequiredArgsConstructor
public class JpaCustomerAdapter implements CustomerRepository {
private final JpaCustomerEntityRepository jpaRepository;
@Override
public Customer save(Customer customer) {
// 엔티티 변환 로직 포함
CustomerEntity entity = CustomerEntity.fromDomain(customer);
CustomerEntity savedEntity = jpaRepository.save(entity);
return savedEntity.toDomain();
}
@Override
public Optional<Customer> findById(UUID id) {
return jpaRepository.findById(id).map(CustomerEntity::toDomain);
}
@Override
public void delete(Customer customer) {
jpaRepository.deleteById(customer.id());
}
}
모범 사례
- 도메인 계층을 순수하게 유지하세요: 도메인 클래스에
@Entity,@Table,@Service등의 프레임워크 애노테이션을 넣지 마십시오. - 의존성 역전을 사용하세요: 도메인 계층이 인터페이스(포트)를 정의하게 하고, 인프라스트럭처가 이를 구현하게 하십시오.
- 값 객체를 선호하세요: 원시 타입 대신 값 객체를 사용하면 타입 안전성이 향상되고 비즈니스 규칙이 캡슐화됩니다.
- 집합체를 사용하여 일관성 경계를 유지하세요: 트랜잭션 경계를 명확히 하고 집합체 외부의 수정은 리포지토리를 통해서만 이루어지도록 하십시오.
- 인바운드와 아웃바운드 포트를 분리하세요:
port.in및port.out과 같은 별도의 패키지나 레이블을 사용하십시오.