2.4 클린 아키텍처 기반의 구현

2.4 클린 아키텍처 기반의 구현

들어가며

소프트웨어를 설계할 때 가장 중요한 것은 핵심 가치를 지키는 것입니다. 클린 아키텍처는 이러한 핵심 가치를 보호하기 위해 만들어진 설계 방식입니다. 이 문서에서는 계층을 동심원처럼 배치하는 오니온 아키텍처를 기반으로 클린 아키텍처를 실제로 구현하는 방법을 자세히 알아보겠습니다. 특히 기존의 유스케이스라는 개념 대신 커맨드 핸들러 패턴을 활용하는 방식을 설명하도록 하겠습니다.

2.4.1 아키텍처 개요

오니온 아키텍처는 마치 양파의 껍질처럼 여러 계층이 겹겹이 쌓인 구조를 가집니다. 아래 다이어그램은 우리가 구현할 클린 아키텍처의 전체적인 구조를 보여줍니다.

![[detailed-onion.svg]]

이 다이어그램에서 볼 수 있듯이, 각 계층의 의존성은 항상 안쪽을 향합니다. 가장 안쪽에는 도메인 모델이 위치하며, 바깥으로 갈수록 기술적인 구현에 가까워집니다. 이러한 구조는 비즈니스 핵심 로직을 외부 요인으로부터 보호합니다.

이런 구성을 선택한 이유는:

  1. 시각적으로 계층 구조를 먼저 보여줌으로써 독자가 전체 아키텍처를 직관적으로 이해할 수 있습니다.
  2. 이어지는 상세 설명에서 각 계층의 코드 예시를 볼 때, 다이어그램을 참조하며 해당 코드가 어느 계층에 속하는지 쉽게 파악할 수 있습니다.
  3. 의존성의 방향을 시각적으로 명확하게 보여줌으로써, 계층 간 상호작용의 원칙을 더 잘 이해할 수 있습니다.

핵심 계층 구조

  1. 도메인 모델 (Domain Model) 가장 안쪽에 위치한 이 계층은 비즈니스의 핵심 개념을 표현합니다.

     public class Book {
         private final BookId id;
         private Title title;
         private Author author;
         private BookStatus status;
    
         private Book(BookId id, Title title, Author author) {
             this.id = Objects.requireNonNull(id, "id must not be null");
             this.title = Objects.requireNonNull(title, "title must not be null");
             this.author = Objects.requireNonNull(author, "author must not be null");
             this.status = BookStatus.AVAILABLE;
         }
    
         public BookStatusChangedEvent updateStatus(BookStatus newStatus) {
             if (this.status == newStatus) {
                 return null;
             }
             this.status = newStatus;
             return new BookStatusChangedEvent(this.id, newStatus);
         }
     }

    여기서 주목할 점은 도메인 모델이 순수하게 비즈니스 규칙만을 표현한다는 것입니다. 외부 의존성이 전혀 없으며, 기술적인 세부사항과도 완전히 분리되어 있습니다.

  2. 도메인 서비스 (Domain Services) 도메인 모델을 둘러싼 첫 번째 계층으로, 복잡한 비즈니스 규칙을 구현합니다.

    public class BookCheckoutService {
        public CheckoutResult processCheckout(Book book, Member member, CheckoutRule rule) {
            // 대출 가능 여부 검증
            rule.validate(member);
    
            // 도메인 규칙 적용
             BookCheckout checkout = BookCheckout.create(book, member, policy.getCheckoutPeriod());
    
            // 도메인 이벤트 생성
            return new CheckoutResult(loan, new BookCheckedOutEvent(book.getId(), member.getId()));
        }
    }
  3. 애플리케이션 서비스 (Application Services) 커맨드 핸들러를 통해 사용자의 의도를 실제 동작으로 변환합니다.

    public class CheckoutBookCommandHandler {
        private final BookRepository bookRepository;
        private final MemberRepository memberRepository;
        private final BookCheckoutService checkoutService;
        private final DomainEventPublisher eventPublisher;
    
        public LoanResult handle(LoanBookCommand command) {
            // 1. 필요한 도메인 객체 로드
            Book book = bookRepository.findById(command.getBookId())
                .orElseThrow(() -> new BookNotFoundException(command.getBookId()));
            Member member = memberRepository.findById(command.getMemberId())
                .orElseThrow(() -> new MemberNotFoundException(command.getMemberId()));
    
            // 2. 도메인 서비스를 통한 비즈니스 로직 실행
            CheckoutResult result = checkoutService.processCheckout(
                 book, 
                 member, 
                 new StandardCheckoutRule()
            );
            // 3. 결과 저장 및 이벤트 발행
            bookRepository.save(book);
            eventPublisher.publish(result.getDomainEvents());
    
            return result;
        }
    }
  4. 인프라스트럭처 계층 (Infrastructure Layer) 가장 바깥쪽 계층으로, 구체적인 기술 구현을 담당합니다.

    Input Adapters (컨트롤러)

    @RestController
    @RequestMapping("/api/books")
    public class BookCheckoutController {
        private final CheckoutBookCommandHandler checkoutHandler;
    
        @PostMapping("/{bookId}/checkout")
        public ResponseEntity<CheckoutResponse> checkoutBook(
            @PathVariable UUID bookId,
            @RequestBody CheckoutRequest request
        ) {
            CheckoutBookCommand command = new CheckoutBookCommand(bookId, request.getMemberId());
            CheckoutResult result = checkoutHandler.handle(command);
            return ResponseEntity.ok(CheckoutResponse.from(result));
        }
    }

    Output Adapters (리포지토리)

    @Repository
    public class JpaBookRepository implements BookRepository {
        private final JpaBookEntityRepository jpaRepository;
        private final BookMapper mapper;
    
        @Override
        public Optional<Book> findById(BookId id) {
            return jpaRepository.findById(id.getValue())
                .map(mapper::toDomain);
        }
    
        @Override
        public void save(Book book) {
            BookEntity entity = mapper.toEntity(book);
            jpaRepository.save(entity);
        }
    }

2.4.2 아키텍처 원칙과 이점

이러한 계층화된 구조는 다음과 같은 이점을 제공합니다:

  1. 비즈니스 규칙 보호 도메인 모델과 도메인 서비스는 외부 의존성 없이 순수하게 비즈니스 규칙만을 표현합니다. 이는 핵심 비즈니스 로직이 기술적 변화에 영향받지 않도록 보호합니다.

  2. 테스트 용이성 각 계층이 명확히 분리되어 있어 단위 테스트가 용이합니다. 특히 도메인 계층은 외부 의존성 없이 테스트할 수 있습니다.

  3. 유지보수성 기술적 구현이 인프라스트럭처 계층에 격리되어 있어, 데이터베이스나 프레임워크 변경 시 다른 계층에 영향을 미치지 않습니다.

Last updated on