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);
}이러한 전통적인 레이어드 아키텍처의 한계점은 다음과 같습니다:
-
도메인 로직의 분산
- 비즈니스 로직이 서비스 계층에 집중되어 복잡해지기 쉽습니다.
- 동일한 도메인 규칙이 여러 서비스에 중복되어 구현될 수 있습니다.
-
계층 간 강한 결합
- 하위 계층의 변경이 상위 계층에 쉽게 영향을 미칩니다.
- 데이터베이스 구조의 변경이 비즈니스 로직에까지 영향을 줄 수 있습니다.
-
테스트의 어려움
- 계층 간 의존성으로 인해 단위 테스트가 어려워집니다.
- 각 계층을 독립적으로 테스트하기 위해 많은 모의(mock) 객체가 필요합니다.
2.3.2 클린 아키텍처의 혁신
클린 아키텍처는 로버트 마틴(Robert C. Martin)이 제안한 아키텍처로, 기존 레이어드 아키텍처의 한계를 극복하고자 합니다. 이 아키텍처의 핵심 목표는 다음과 같습니다:
-
프레임워크 독립성
- 외부 프레임워크를 도구로만 사용합니다.
- 시스템의 핵심 로직이 프레임워크에 종속되지 않습니다.
-
테스트 용이성
- 비즈니스 규칙을 외부 요소 없이 테스트할 수 있습니다.
- 도메인 로직의 단위 테스트가 용이합니다.
-
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;
}
}이러한 현대적인 아키텍처 패턴들은 모두 도메인 로직을 핵심에 두고, 외부 의존성을 명확히 분리하는 것을 목표로 합니다. 선택은 프로젝트의 특성과 팀의 선호도에 따라 달라질 수 있으며, 때로는 이들의 장점을 혼합한 형태로 구현될 수도 있습니다.
각 아키텍처의 선택 기준은 다음과 같습니다:
-
레이어드 아키텍처
- 단순한 CRUD 애플리케이션
- 빠른 개발이 필요한 프로젝트
- 팀의 학습 곡선을 최소화하고 싶은 경우
-
클린 아키텍처
- 복잡한 비즈니스 규칙이 있는 프로젝트
- 장기적인 유지보수가 중요한 경우
- 테스트 용이성이 중요한 프로젝트
-
헥사고날 아키텍처
- 외부 시스템과의 통합이 많은 프로젝트
- 인터페이스 변경이 자주 발생하는 경우
- 다양한 클라이언트 지원이 필요한 경우
-
오니온 아키텍처
- 도메인 규칙이 매우 복잡한 프로젝트
- 계층 간 의존성 관리가 중요한 경우
- 도메인 중심의 설계가 필요한 프로젝트