2.3 아키텍처 패턴 비교

2.3 아키텍처 패턴 비교

소프트웨어 아키텍처는 시스템의 구조를 결정하는 중요한 결정들의 집합입니다. 여러 아키텍처 패턴들을 이해하고 비교함으로써, 우리는 프로젝트의 특성에 가장 적합한 아키텍처를 선택할 수 있습니다.

2.3.1 전통적인 레이어드 아키텍처의 이해

레이어드 아키텍처(Layered Architecture)는 애플리케이션을 수평적인 계층으로 구분하는 가장 기본적인 아키텍처 패턴입니다. 각 계층은 자신의 아래 계층에만 의존하며, 이는 단순하고 이해하기 쉬운 구조를 제공합니다.

도서관 시스템을 예로 들어 레이어드 아키텍처의 구현을 살펴보겠습니다:

// 프레젠테이션 계층: 사용자 인터페이스를 담당합니다
@RestController
public class BookController {
    private final BookService bookService;
    
    @PostMapping("/books/{bookId}/checkout")
    public ResponseEntity<CheckoutDTO> checkoutBook(
            @PathVariable String bookId, 
            @RequestBody CheckoutRequest request) {
        CheckoutDTO result = bookService.checkout(bookId, request);
        return ResponseEntity.ok(result);
    }
}

// 비즈니스 계층: 업무 규칙과 로직을 처리합니다
@Service
public class BookService {
    private final BookRepository bookRepository;
    private final MemberRepository memberRepository;
    
    @Transactional
    public CheckoutDTO checkout(String bookId, CheckoutRequest request) {
        // 엔티티 조회
        Book book = bookRepository.findById(bookId)
                    .orElseThrow(() -> new BookNotFoundException(bookId));
        Member member = memberRepository.findById(request.getMemberId())
                    .orElseThrow(() -> new MemberNotFoundException(request.getMemberId()));
        
        // 비즈니스 로직 처리
        if (!book.isAvailable()) {
            throw new BookNotAvailableException(bookId);
        }
        
        if (member.hasOverdueBooks()) {
            throw new OverdueBooksException(member.getId());
        }
        
        // 상태 변경
        book.setStatus(BookStatus.CHECKED_OUT);
        book.setBorrower(member);
        book.setDueDate(LocalDate.now().plusDays(14));
        
        // 저장
        bookRepository.save(book);
        
        return new CheckoutDTO(book);
    }
}

// 데이터 접근 계층: 데이터의 영속성을 관리합니다
@Repository
public interface BookRepository extends JpaRepository<Book, String> {
    Optional<Book> findById(String id);
    List<Book> findByStatus(BookStatus status);
}

이러한 전통적인 레이어드 아키텍처의 한계점은 다음과 같습니다:

  1. 도메인 로직의 분산

    • 비즈니스 로직이 서비스 계층에 집중되어 복잡해지기 쉽습니다.
    • 동일한 도메인 규칙이 여러 서비스에 중복되어 구현될 수 있습니다.
  2. 계층 간 강한 결합

    • 하위 계층의 변경이 상위 계층에 쉽게 영향을 미칩니다.
    • 데이터베이스 구조의 변경이 비즈니스 로직에까지 영향을 줄 수 있습니다.
  3. 테스트의 어려움

    • 계층 간 의존성으로 인해 단위 테스트가 어려워집니다.
    • 각 계층을 독립적으로 테스트하기 위해 많은 모의(mock) 객체가 필요합니다.

2.3.2 클린 아키텍처의 혁신

클린 아키텍처는 로버트 마틴(Robert C. Martin)이 제안한 아키텍처로, 기존 레이어드 아키텍처의 한계를 극복하고자 합니다. 이 아키텍처의 핵심 목표는 다음과 같습니다:

  1. 프레임워크 독립성

    • 외부 프레임워크를 도구로만 사용합니다.
    • 시스템의 핵심 로직이 프레임워크에 종속되지 않습니다.
  2. 테스트 용이성

    • 비즈니스 규칙을 외부 요소 없이 테스트할 수 있습니다.
    • 도메인 로직의 단위 테스트가 용이합니다.
  3. UI 독립성

    • 사용자 인터페이스의 변경이 시스템에 영향을 주지 않습니다.
    • 비즈니스 로직이 UI 기술과 분리됩니다.

다음은 클린 아키텍처를 적용한 도서 대출 시스템의 예시입니다:

// 엔티티 계층: 핵심 비즈니스 규칙을 포함합니다
public class Book {
    private final BookId id;
    private BookStatus status;
    private Member borrower;
    private LocalDate dueDate;

    public CheckoutResult checkout(Member member, CheckoutRule rule) {
        // 순수한 도메인 로직
        if (!rule.canCheckout(member)) {
            return CheckoutResult.failure("대출 자격이 없습니다");
        }
        
        if (status != BookStatus.AVAILABLE) {
            return CheckoutResult.failure("현재 대출 불가능한 상태입니다");
        }
        
        this.status = BookStatus.CHECKED_OUT;
        this.borrower = member;
        this.dueDate = LocalDate.now().plusDays(rule.getLoanDuration());
        
        return CheckoutResult.success();
    }
}

// 유스케이스 계층: 애플리케이션 특화 비즈니스 규칙을 구현합니다
public class CheckoutBookUseCase {
    private final BookRepository bookRepository;
    private final MemberRepository memberRepository;
    
    public CheckoutResult execute(CheckoutCommand command) {
        Book book = bookRepository.findById(command.getBookId())
            .orElseThrow(() -> new BookNotFoundException(command.getBookId()));
            
        Member member = memberRepository.findById(command.getMemberId())
            .orElseThrow(() -> new MemberNotFoundException(command.getMemberId()));
        
        CheckoutResult result = book.checkout(member, new CheckoutRule());
        
        if (result.isSuccess()) {
            bookRepository.save(book);
        }
        
        return result;
    }
}

// 인터페이스 어댑터 계층: 외부 인터페이스를 내부 로직에 맞게 변환합니다
@RestController
public class BookController {
    private final CheckoutBookUseCase checkoutBookUseCase;
    
    @PostMapping("/books/{bookId}/checkout")
    public ResponseEntity<CheckoutResponse> checkout(
            @PathVariable String bookId, 
            @RequestBody CheckoutRequest request) {
        CheckoutCommand command = new CheckoutCommand(bookId, request.getMemberId());
        CheckoutResult result = checkoutBookUseCase.execute(command);
        return ResponseEntity.ok(new CheckoutResponse(result));
    }
}

2.3.3 헥사고날 아키텍처와 오니온 아키텍처

헥사고날 아키텍처(포트와 어댑터 아키텍처)와 오니온 아키텍처는 클린 아키텍처와 유사한 원칙을 공유하면서도 각각의 특징을 가집니다.

헥사고날 아키텍처의 특징

헥사고날 아키텍처는 애플리케이션을 외부 세계와 분리하는 데 중점을 둡니다:

// 포트: 외부와의 상호작용을 정의하는 인터페이스입니다
public interface BookRepository {
    Optional<Book> findById(BookId id);
    void save(Book book);
}

// 어댑터: 포트의 실제 구현을 제공합니다
@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);
    }
}

오니온 아키텍처의 특징

오니온 아키텍처는 계층을 동심원 형태로 구성하여 의존성의 방향을 명확히 합니다:

// 도메인 모델 (가장 안쪽 계층)
public class Book {
    // 순수한 도메인 로직
    public CheckoutResult checkout(Member member) {
        // 도메인 규칙 검증 및 상태 변경
    }
}

// 도메인 서비스 계층
public class CheckoutService {
    private final BookRepository repository;
    
    public CheckoutResult checkout(BookId bookId, MemberId memberId) {
        // 도메인 객체 조율
    }
}

// 애플리케이션 서비스 계층
public class CheckoutApplicationService {
    private final CheckoutService checkoutService;
    private final NotificationService notificationService;
    
    @Transactional
    public CheckoutResult checkout(CheckoutRequest request) {
        CheckoutResult result = checkoutService.checkout(
            request.getBookId(), 
            request.getMemberId()
        );
        
        if (result.isSuccess()) {
            notificationService.notifyCheckout(result.getCheckout());
        }
        
        return result;
    }
}

이러한 현대적인 아키텍처 패턴들은 모두 도메인 로직을 핵심에 두고, 외부 의존성을 명확히 분리하는 것을 목표로 합니다. 선택은 프로젝트의 특성과 팀의 선호도에 따라 달라질 수 있으며, 때로는 이들의 장점을 혼합한 형태로 구현될 수도 있습니다.

각 아키텍처의 선택 기준은 다음과 같습니다:

  1. 레이어드 아키텍처

    • 단순한 CRUD 애플리케이션
    • 빠른 개발이 필요한 프로젝트
    • 팀의 학습 곡선을 최소화하고 싶은 경우
  2. 클린 아키텍처

    • 복잡한 비즈니스 규칙이 있는 프로젝트
    • 장기적인 유지보수가 중요한 경우
    • 테스트 용이성이 중요한 프로젝트
  3. 헥사고날 아키텍처

    • 외부 시스템과의 통합이 많은 프로젝트
    • 인터페이스 변경이 자주 발생하는 경우
    • 다양한 클라이언트 지원이 필요한 경우
  4. 오니온 아키텍처

    • 도메인 규칙이 매우 복잡한 프로젝트
    • 계층 간 의존성 관리가 중요한 경우
    • 도메인 중심의 설계가 필요한 프로젝트
Last updated on