헥사고날 아키텍처를 알아보자 2

spaghetti·2025년 5월 28일

이제 Spirng boot 와 JPA를 사용하여 헥사고날 아키텍처가 실제로 어떻게 코드단에서 이루어지는지 알아보자.

패키지 구조

패키지 구조는 각자 조금씩 다르다. 나의 경우는 application, adapter, infrastructure로 나누었다.

com.example.project/
├── application/                     # 애플리케이션 계층
│   ├── domain/                     # 도메인 계층
│   │   └── model/                 # 도메인 모델
│   │       └── Inventory.java
│   │
│   ├── port/                      # 포트 정의
│   │   ├── in/                    # 입력 포트
│   │   │   └── InventoryUseCase.java
│   │   └── out/                   # 출력 포트
│   │       ├── InventoryPersistencePort.java
│	│		└──	EventPublisherPort.java
│   ├── mapper/
│   │   └── InventoryMapper.java  
│   │
│   └── service/                   # 유스케이스 구현
│       └── InventoryService.java
│
├── adapter/                        # 어댑터 계층
│   ├── in/                        # 입력 어댑터
│   │   ├── web/
│   │   │   ├── controller/
│	│	│	│		└──InventoryController.java
│   │   │   └── dto/
│   │   │       └── InventoryDTO.java
│   │   └── event/
│   └── out/                       # 출력 어댑터
│       └── persistence/
│           ├── entity/  
│           │   └── InventoryEntity.java
│           ├── mapper/  
│           │   └── InventroyEntityMapper.java 
│           └── InventoryPersistenceAdapter.java
│
└── infrastructure/                 # 인프라스트럭처 계층
    ├── config/
    └── common/
  • application은 어떠한 외부 시스템도 의존하지 않는 인터페이스와(port, usercase) 도메인 (domain)이 존재하는 영역이다.
  • adapter는 in / out으로 구성하고 외부에서 요청해서 받는 web와 도메인에서 DB로 요청하는 persistence로 구성되어있다.
  • infrastructure에는 설정에 필요한 코드나 공통적으로 처리해야하는 부분들(advice 등)을 정리해놓은 영역이다.

도메인 중심 비즈니스 설계

헥사고날 아키텍처를 공부하다보면 도메인 중심의 설계가 중요해지는데, 이전에 JPA를 공부하면서 JPA 데이터 위주의 설계와 차이가 있었다. 그래서 이들의 차이가 무엇인지 코드에서 알아보자.

1) JPA Entity 중심 설계 (데이터 중심 설계)

과거에는 엔티티 중심으로 비즈니스 로직을 설계했다. 예를들어 아이템 재고를 줄인다와 같은 행위를 주로 엔티티 내부에서 설계했다.

@Table(name = "inventory")
@getter
public class Inventory {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String itemName;

    @Column(nullable = false)
    private int stock;

    // 기본 생성자 및 접근자
    protected Inventory() {}

    public Inventory(String itemName, int stock) {
        this.itemName = itemName;
        this.stock = stock;
    }

    // 비즈니스 로직: 재고 감소
    public void decreaseStock(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be greater than zero");
        }
        if (this.stock < quantity) {
            throw new IllegalArgumentException("Not enough stock to decrease");
        }
        this.stock -= quantity;
    }

}
@Service
@RequiredArgsConstructor
public class InventoryService {
    private final InventoryRepository inventoryRepository;

    @Transactional
    public void decreaseInventory(Long itemId, int quantity) {
        // 1. 영속성 컨텍스트에서 엔티티 조회
        Inventory entity = inventoryRepository.findById(itemId)
            .orElseThrow(() -> new IllegalArgumentException("Inventory not found"));

        // 2. 엔티티 비즈니스 로직 호출 (재고 감소)
        entity.decreaseStock(quantity);

        // 3. 영속성 컨텍스트가 변경사항을 감지해 자동 저장
    }
}

2) 도메인 중심 설계(DDD)

도메인은 데이터 저장소나 JPA와 같은 기술에 의존하지 않고 순수한 비즈니스 규칙을 표현하는 영역이다.
즉, 아이템 재고를 줄인다라는 비즈니스 행위는 도메인 안에서 작성되어야하고, 이는 JPA 엔티티와 연관되지 않는 독립적인 영역이 되어야한다.

public class Inventory {
    private Long itemId;
    private String itemName;
    private int stock;

    public Inventory(Long itemId, String itemName, int stock) {
        this.itemId = itemId;
        this.itemName = itemName;
        this.stock = stock;
    }

    // 재고 감소 비즈니스 로직
    public void decreaseStock(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be greater than zero");
        }
        if (this.stock < quantity) {
            throw new IllegalArgumentException("Not enough stock to decrease");
        }
        this.stock -= quantity;
    }
}
@Service
@RequiredArgsConstructor
public class InventoryService {
    private final InventoryRepository inventoryRepository;

    public void decreaseInventory(Long itemId, int quantity) {
        //1. entity 조회
        InventoryEntity entity = inventoryRepository.findById(itemId)
            .orElseThrow(() -> new IllegalArgumentException("Not Found"));
        
        //2. entity를 inventory 도메인으로 변환
        Inventory inventory = new Inventory(entity.getItemId(), entity.getItemName(), entity.getStock());

        // 도메인 비즈니스 로직 호출
        inventory.decreaseStock(quantity);

        // 결과 저장
        entity.setStock(inventory.getStock());
        inventoryRepository.save(entity);
    }
}

3) JPA Entity 중심 설계의 문제점과 도메인 중심 설계의 장점

코드를 보면 도메인 중심 설계가 복잡성이 더 증가한 측면이 있다. 결과를 저장할때 save를 호출해야하고 도메인으로 엔티티를 변환해줘야하는 등의 로직이 증가했기 때문이다. 하지만 엔티티 중심 설계는 다음과 같은 단점이 존재한다.

  • entity.decreaseStock(...) 방식처럼 JPA Entity에 비즈니스 로직을 담는 방식을 사용하면 JPA의 특정 영속성 컨텍스트와 강한 결합이 발생
  • 영속성에서 분리된 상태(Persistence Detached)에서는 문제가 발생한다. 예를 들어, Entity 객체가 영속성 컨텍스트에서 분리된 상태에서 비즈니스 로직을 호출했다면 변경 사항이 반영되지 않아 의도하지 않은 결과가 나타날 수 있다.

만약 재고 감소 비즈니스 로직이 단순히 재고를 줄이는 것에서 끝나는게 아니라 이벤트 발행이나 히스토리를 기록해야한다면 어떻게 될까?

 entity.decreaseStock(quantity);
 
 //이벤트 발행과 재고 히스토리 기록이 추가되어야함
 eventPublisher.publishEvent(new StockDecreasedEvent(entity.getId(), quantity));
 historyRepository.save(new StockHistory(entity.getId(), quantity, LocalDate.now()));

위와 같이 서비스에 로직을 추가하게 되는데 이는 엔티티가 여러 비즈니스 로직에 의존하게 되고 이러한 요구사항이 많아진다면 비즈니스 로직과 데이터베이스 접근을 위한 로직간의 관계가 모호해지면서 코드가 점점 복잡해지고 수정이 어려워진다.

또한, 만약 영속성을 관리하는 컨테이너 외부에서 동일한 비즈니스 로직이 필요하다면 JPA 엔티티로 접근하지 않아도 될 로직이 강한 결합성으로 인해 복잡하게 처리해야하는 경우가 생길 수도 있다.

도메인 중심 설계의 경우 엔티티는 데이터베이스를 위한 객체로 두고 데이터 자체를 관리하고 조작하는 역할을 부여하고, 비즈니스 로직은 데이터 저장소와 독립적으로 도메인에 두어 그 역할을 담당하면 위와 같은 모호한 경우를 줄일 수 있다.
이는 단위 테스트에서도 비즈니스 로직만 검증해야할 때 JPA 관련 설정을 따로 하지 않아도 빠르게 테스트 할 수 있는 이점이 존재한다.

코드로 비즈니스 로직을 알아보자.

@Transactional
    public void decreaseInventory(Long itemId, int quantity) {
        // 1. 엔티티 조회
        InventoryEntity entity = inventoryRepository.findById(itemId)
            .orElseThrow(() -> new IllegalArgumentException("Inventory not found"));

        // 2. 도메인 객체로 변환
        Inventory inventory = new Inventory(entity.getId(), entity.getItemName(), entity.getStock());

        // 3. 비즈니스 로직 수행
        inventory.decreaseStock(quantity);

        // 4. 데이터 동기화 (Entity 변경)
        entity.setStock(inventory.getStock());
        inventoryRepository.save(entity);

        // 5. 도메인 이벤트 처리
        for (DomainEvent event : inventory.getDomainEvents()) {
            // 5-1. 이벤트를 발행
            eventPublisher.publishEvent(event);

            // 5-2. 히스토리 저장
            if (event instanceof StockDecreasedEvent stockEvent) {
                historyRepository.save(new StockHistory(stockEvent.getItemId(), stockEvent.getQuantity(), LocalDate.now()));
            }
        }

        // 6. 도메인 이벤트 초기화
        inventory.clearDomainEvents();
    }

이러한 도메인 중심의 설계를 익혀야 헥사고날 아키텍처에서의 application 영역이 외부의 어떤 시스템에도 의존하지 않은 상태로 설계되어야 한다는 규칙을 이해하는데 도움이 된다고 생각한다.

다음 포스트에서는 전체적인 헥사고날 아키텍처 코드를 알아보고자 한다.

profile
개발 그렇게 하는거 아닌데의 그렇게를 맡고있습니다

0개의 댓글