헥사고날 아키텍처
헥사고날 아키텍처Hexagonal Architecture는 복잡한 소프트웨어 시스템을 유연하고 확장 가능하게 만들기 위해 고안된 아키텍처 패턴으로, 비즈니스 로직을 외부 시스템과 명확하게 분리하는 데 중점을 둡니다. 흔히 “포트와 어댑터 아키텍처Ports and Adapters Architecture“라고도 불리며, 이는 외부 시스템과의 상호작용을 포트(인터페이스)와 어댑터(구현체)를 통해 처리하기 때문입니다. 이러한 구조는 어댑터 패턴과 연관이 깊으며, 외부 시스템의 변경이나 확장을 쉽게 할 수 있도록 설계됩니다.
헥사고날 아키텍처란
헥사고날 아키텍처는 비즈니스 로직을 시스템의 중심에 두고, 이를 둘러싸는 외부 시스템을 어댑터로 연결합니다. 핵심 개념은 외부 시스템의 구체적인 구현에 의존하지 않고, 도메인 로직을 중심으로 시스템을 설계하여 유지보수성과 확장성을 높이는 것입니다.
포트
포트Ports는 비즈니스 로직과 외부 시스템 간의 통신 인터페이스를 정의합니다. 포트는 비즈니스 로직이 외부 시스템과 상호작용하는 방식을 추상화하며, 이는 입력 포트와 출력 포트로 나눌 수 있습니다.
- 입력 포트Input Ports: 외부 시스템이 비즈니스 로직을 호출하는 인터페이스입니다. 이 포트를 통해 웹 애플리케이션, 명령줄 인터페이스Command Line Interface, CLI, API 등이 도메인 로직에 접근할 수 있습니다.
- 출력 포트Output Ports: 비즈니스 로직이 외부 시스템과 상호작용할 때 사용하는 인터페이스입니다. 예를 들어, 비즈니스 로직이 데이터베이스나 메시지 큐에 접근할 때 출력 포트를 사용하여 외부 시스템과 통신합니다.
어댑터
어댑터Adapters는 포트가 정의한 인터페이스를 구현하는 구체적인 클래스입니다. 외부 시스템의 세부적인 구현을 처리하며, 어댑터는 외부 시스템과의 상호작용을 비즈니스 로직에 영향을 주지 않도록 추상화합니다.
- 입력 어댑터Input Adapters: 외부 요청을 받아들여 이를 비즈니스 로직으로 전달하는 역할을 합니다. 예를 들어, 웹 요청을 처리하는 컨트롤러는 입력 어댑터로 작동하여, 사용자의 요청을 입력 포트에 전달합니다.
- 출력 어댑터Output Adapters: 비즈니스 로직에서 발생한 작업을 외부 시스템에 전달합니다. 예를 들어, 데이터베이스에 저장하거나 메시지 큐에 데이터를 전송하는 작업을 출력 어댑터가 처리합니다.
Adapter 패턴과의 연관성
헥사고날 아키텍처는 어댑터Adapter 패턴과 밀접하게 관련되어 있습니다. 어댑터 패턴은 서로 다른 인터페이스를 호환되도록 연결하는 패턴으로, 헥사고날 아키텍처의 어댑터는 외부 시스템(데이터베이스, 메시지 큐, API)과 도메인 간의 인터페이스를 연결하는 역할을 수행합니다.
헥사고날 아키텍처의 예시
다음은 헥사고날 아키텍처의 간단한 예시로, 책을 대출하는 도메인 로직을 포함한 시스템을 구현한 것입니다.
// 입력 포트 인터페이스
public interface IBookService
{
void BorrowBook(string bookId);
}
// 출력 포트 인터페이스
public interface IBookRepository
{
Book FindById(string id);
void Save(Book book);
}
// 비즈니스 로직 (입력 포트 사용)
public class BookService : IBookService
{
private readonly IBookRepository bookRepository;
public BookService(IBookRepository bookRepository)
{
this.bookRepository = bookRepository;
}
public void BorrowBook(string bookId)
{
var book = bookRepository.FindById(bookId);
book.Borrow();
bookRepository.Save(book);
}
}
// SQL 저장소 어댑터 (출력 포트 구현)
public class SqlBookRepository : IBookRepository
{
public Book FindById(string id)
{
// SQL 쿼리로 책 데이터 조회
}
public void Save(Book book)
{
// SQL 데이터베이스에 책 데이터 저장
}
}
// 메모리 저장소 어댑터 (출력 포트 구현)
public class InMemoryBookRepository : IBookRepository
{
private readonly List<Book> books = new();
public Book FindById(string id) => books.FirstOrDefault(b => b.Id == id);
public void Save(Book book) => books.Add(book);
}
BookService
가 도메인 로직을 담당하고,IBookRepository
인터페이스를 통해 데이터 저장소와 통신합니다. 즉,IBookRepository
는 포트입니다.SqlBookRepository
는 SQL 데이터베이스에 저장하는 어댑터이며,InMemoryBookRepository
는 메모리 저장소를 사용하는 어댑터입니다.- 이를 통해 저장소를 유연하게 교체할 수 있으며, 비즈니스 로직은 데이터베이스 변경에 영향을 받지 않습니다.
헥사고날 아키텍처의 방법론 및 전략
헥사고날 아키텍처는 다음과 같은 전략과 방법론을 통해 시스템의 유연성과 유지보수성을 높입니다.
포트와 어댑터의 명확한 분리
포트와 어댑터는 도메인 로직과 외부 시스템 간의 경계를 명확하게 설정하여 결합도를 줄입니다. 포트는 비즈니스 로직이 외부 시스템의 구체적인 구현과 상관없이 작동할 수 있도록 해주며, 어댑터는 외부 시스템과의 상호작용을 처리합니다. 이로 인해 외부 시스템이 변경되더라도 비즈니스 로직은 그대로 유지될 수 있습니다.
기술 선택의 유연성
헥사고날 아키텍처는 특정 기술에 의존하지 않는 구조를 제공하므로, 도메인 로직은 변경하지 않고도 새로운 기술을 도입할 수 있습니다. 예를 들어, SQL 데이터베이스에서 NoSQL 데이터베이스로 전환할 때, 어댑터만 새로 작성하면 됩니다. 비즈니스 로직과 포트 인터페이스는 그대로 유지되기 때문에 시스템 변경이 용이합니다.
입력과 출력의 다중 방식 지원
헥사고날 아키텍처는 웹 애플리케이션, CLICommand Line Interface, 메시지 큐 등의 다양한 입력 방식을 지원할 수 있습니다. 입력 포트를 통해 다양한 외부 시스템과의 상호작용을 처리할 수 있으며, 출력 포트 역시 다양한 출력 방식(데이터베이스, 파일 시스템, 메시지 큐)을 유연하게 지원할 수 있습니다.
// 웹 요청을 처리하는 입력 어댑터
public class BookController
{
private readonly IBookService bookService;
public BookController(IBookService bookService)
{
this.bookService = bookService;
}
public IActionResult BorrowBook(string bookId)
{
bookService.BorrowBook(bookId);
return Ok();
}
}
// CLI 명령을 처리하는 입력 어댑터
public class BookCli
{
private readonly IBookService bookService;
public BookCli(IBookService bookService)
{
this.bookService = bookService;
}
public void BorrowBook(string bookId)
{
bookService.BorrowBook(bookId);
Console.WriteLine("Book borrowed successfully.");
}
}
BookController
는 웹 요청을 처리하고,BookCli
는 명령줄 인터페이스CLI 요청을 처리하는 입력 어댑터입니다.- 두 어댑터는 동일한 도메인 로직에 접근하지만, 서로 다른 방식으로 사용자와 상호작용합니다.
테스트 주도 개발과의 통합
헥사고날 아키텍처는 외부 시스템과의 의존성을 최소화하기 때문에 테스트 주도 개발TDD과 자연스럽게 통합됩니다. 어댑터를 가짜mock 객체나 메모리 기반 구현체로 대체하여 비즈니스 로직을 독립적으로 테스트할 수 있습니다. 이를 통해 도메인 로직의 정확성을 보장하면서 외부 시스템과의 복잡한 상호작용을 배제할 수 있습니다.
public class BookServiceTests
{
private readonly IBookRepository bookRepository = new InMemoryBookRepository();
private readonly IBookService bookService;
public BookServiceTests()
{
bookService = new BookService(bookRepository);
}
[Fact]
public void BorrowBook_ShouldChangeBookStateToBorrowed()
{
var book = new Book("1", "Domain-Driven Design");
bookRepository.Save(book);
bookService.BorrowBook("1");
Assert.True(book.IsBorrowed);
}
}
InMemoryBookRepository
를 사용하여 도메인 로직을 테스트하고 있습니다.- 이를 통해 데이터베이스나 외부 시스템에 의존하지 않고 비즈니스 로직을 독립적으로 테스트할 수 있습니다.
도메인 이벤트를 통한 도메인 간 통신
헥사고날 아키텍처에서 도메인 이벤트는 도메인 간 통신을 효율적으로 처리하는 방법 중 하나입니다. 도메인 이벤트를 통해 각 도메인 모델이 독립적이면서도 필요할 때 상호작용할 수 있습니다. 이를 통해 도메인 간의 결합도를 줄이고, 비즈니스 이벤트 중심의 설계를 가능하게 합니다.
public class BookBorrowedEvent
{
public string BookId { get; }
public string UserId { get; }
public BookBorrowedEvent(string bookId, string userId)
{
BookId = bookId;
UserId = userId;
}
}
public class NotificationService
{
public void OnBookBorrowed(BookBorrowedEvent e)
{
// 대출된 책에 대한 알림 전송
}
}
BookBorrowedEvent
는 책이 대출되었을 때 발생하는 도메인 이벤트입니다.- 다른 바운디드 컨텍스트나 시스템에서 이 이벤트에 반응하여 필요한 작업을 수행할 수 있습니다.
헥사고날 아키텍처의 장점
- 유연한 확장성: 포트와 어댑터 구조를 통해 외부 시스템을 쉽게 교체하거나 확장할 수 있습니다.
- 비즈니스 로직 중심 설계: 도메인 로직을 외부 기술에 의존하지 않도록 하여 시스템의 핵심 비즈니스 로직을 보호합니다.
- 테스트 용이성: 외부 시스템과 독립된 도메인 로직 테스트가 가능하므로, 테스트 주도 개발TDD과 잘 어우러집니다.
- 기술 변화에 대한 적응성: 외부 시스템이 변경되더라도 도메인 로직에 영향을 주지 않아 유지보수 비용이 줄어듭니다.
헥사고날 아키텍처의 단점
- 초기 설계의 복잡성: 포트와 어댑터를 명확하게 분리하는 설계는 처음에는 복잡할 수 있으며, 작은 프로젝트에서는 불필요한 복잡성을 초래할 수 있습니다.
- 성능 문제: 도메인과 외부 시스템 간의 통신을 어댑터를 통해 처리하는 방식은 성능 저하를 일으킬 수 있으며, 이에 대한 최적화가 필요할 수 있습니다.
- 복잡한 의존성 관리 : 다양한 어댑터와 포트 간의 의존성을 관리하는 것이 복잡해질 수 있으며, 이를 관리하기 위한 추가적인 설계와 도구가 필요할 수 있습니다.
맺음말
헥사고날 아키텍처는 비즈니스 로직을 외부 시스템과 명확하게 분리하고, 어댑터를 통해 외부 시스템과의 통합을 유연하게 처리하는 강력한 아키텍처 패턴입니다. 포트와 어댑터를 통해 외부 시스템의 변경에 민감하지 않은 비즈니스 로직을 유지할 수 있으며, 다양한 기술 변화에 쉽게 대응할 수 있습니다.
심화 학습
CQRS 통합
CQRS는 명령Command과 조회Query를 분리하여 성능을 최적화하고, 비즈니스 로직과 데이터 조회를 더 쉽게 관리할 수 있는 패턴입니다. CQRS와 헥사고날 아키텍처는 서로 보완적이며, 시스템의 확장성과 성능을 높이는 데 효과적입니다.
// Command 인터페이스
public interface ICommandHandler<TCommand>
{
void Handle(TCommand command);
}
// Query 인터페이스
public interface IQueryHandler<TResult, TQuery>
{
TResult Handle(TQuery query);
}
// 명령 처리 예시: 책 대출 처리
public class BorrowBookCommand
{
public int BookId { get; }
public string UserId { get; }
public BorrowBookCommand(int bookId, string userId)
{
BookId = bookId;
UserId = userId;
}
}
public class BorrowBookCommandHandler : ICommandHandler<BorrowBookCommand>
{
private readonly IBookRepository _bookRepository;
private readonly IUserRepository _userRepository;
public BorrowBookCommandHandler(IBookRepository bookRepository, IUserRepository userRepository)
{
_bookRepository = bookRepository;
_userRepository = userRepository;
}
public void Handle(BorrowBookCommand command)
{
var book = _bookRepository.FindById(command.BookId);
var user = _userRepository.FindById(command.UserId);
if (book == null || user == null)
throw new InvalidOperationException("Book or User not found");
book.Borrow();
_bookRepository.Save(book);
}
}
// 조회 처리 예시: 대출된 책 조회
public class GetBorrowedBooksQuery
{
public string UserId { get; }
public GetBorrowedBooksQuery(string userId)
{
UserId = userId;
}
}
public class GetBorrowedBooksQueryHandler : IQueryHandler<List<Book>, GetBorrowedBooksQuery>
{
private readonly IBookRepository _bookRepository;
public GetBorrowedBooksQueryHandler(IBookRepository bookRepository)
{
_bookRepository = bookRepository;
}
public List<Book> Handle(GetBorrowedBooksQuery query)
{
return _bookRepository.FindBorrowedBooksByUser(query.UserId);
}
}
- 명령Command: 사용자의 요청으로 발생하는 책 대출 등의 작업은
BorrowBookCommandHandler
를 통해 처리됩니다. - 조회Query: 사용자의 대출 도서를 조회하는 작업은
GetBorrowedBooksQueryHandler
에서 처리합니다. - 헥사고날 아키텍처와의 통합: 명령과 조회 로직을 분리하면서도 각 핸들러가 헥사고날 아키텍처의 포트(인터페이스)에 의존하여 외부 시스템과 상호작용합니다.
이벤트 소싱
이벤트 소싱Event Sourcing은 시스템의 상태를 저장하는 대신, 상태 변화를 이벤트로 기록하는 방식입니다. 헥사고날 아키텍처의 이벤트 기반 설계는 이벤트 소싱과 자연스럽게 결합되며, 시스템의 상태를 시간에 따라 정확히 복원할 수 있습니다.
예시: 이벤트 소싱을 통한 책 대출 관리
// 도메인 이벤트 정의
public class BookBorrowedEvent
{
public int BookId { get; }
public string UserId { get; }
public DateTime BorrowedAt { get; }
public BookBorrowedEvent(int bookId, string userId, DateTime borrowedAt)
{
BookId = bookId;
UserId = userId;
BorrowedAt = borrowedAt;
}
}
// 이벤트 저장소 인터페이스
public interface IEventStore
{
void SaveEvent<TEvent>(TEvent domainEvent);
List<object> GetEvents(int aggregateId);
}
// 이벤트 소싱을 활용한 책 대출
public class Book
{
public int Id { get; private set; }
public bool IsBorrowed { get; private set; }
public Book(int id)
{
Id = id;
IsBorrowed = false;
}
public Book(List<object> events)
{
foreach (var @event in events)
{
Apply(@event);
}
}
public void Borrow(string userId)
{
if (IsBorrowed)
throw new InvalidOperationException("Already borrowed");
Apply(new BookBorrowedEvent(Id, userId, DateTime.Now));
}
private void Apply(object @event)
{
if (@event is BookBorrowedEvent borrowedEvent)
{
IsBorrowed = true;
}
}
}
- 도메인 이벤트:
BookBorrowedEvent
는 책이 대출되었을 때 발생하는 이벤트입니다. - 이벤트 저장소:
IEventStore
는 이벤트를 저장하고, 과거 이벤트를 불러와 애그리게이트의 상태를 복원하는 역할을 합니다. - 헥사고날 아키텍처와의 통합: 도메인 이벤트는 헥사고날 아키텍처의 핵심 개념인 외부 시스템과의 느슨한 결합을 유지하면서 비즈니스 로직을 중심으로 한 설계에 도움을 줍니다.
헥사고날과 마이크로서비스
헥사고날 아키텍처는 마이크로서비스 아키텍처와 결합하여 서비스 간의 독립성을 유지하면서도, 서비스 경계를 명확히 정의할 수 있습니다. 각 마이크로서비스는 독립적으로 배포되고 유지보수될 수 있으며, 헥사고날 아키텍처의 포트와 어댑터를 통해 외부 시스템과 통신할 수 있습니다.
Book Borrowing Service (도서 대출 서비스)
Book Borrowing Service
├── 포트: IBookRepository, IUserRepository
├── 어댑터: SQL Database (도서 정보, 사용자 정보)
- 포트Ports:
IBookRepository
와IUserRepository
는 도서와 사용자에 대한 데이터 액세스를 추상화한 인터페이스입니다. - 어댑터Adapters: SQL 데이터베이스에 도서와 사용자 정보를 저장하고 조회하는 어댑터입니다. 이는 헥사고날 아키텍처의 핵심 요소인 어댑터 패턴을 통해 구현됩니다.
public class BookBorrowingService
{
private readonly IBookRepository _bookRepository;
private readonly IUserRepository _userRepository;
public BookBorrowingService(IBookRepository bookRepository, IUserRepository userRepository)
{
_bookRepository = bookRepository;
_userRepository = userRepository;
}
public void BorrowBook(int bookId, string userId)
{
var book = _bookRepository.FindById(bookId);
var user = _userRepository.FindById(userId);
if (book == null || user == null)
throw new InvalidOperationException("Book or User not found");
if (book.IsBorrowed)
throw new InvalidOperationException("Book already borrowed");
book.Borrow();
_bookRepository.Save(book);
}
}
Member Management Service (회원 관리 서비스)
Member Management Service
├── 포트: IMemberRepository
├── 어댑터: SQL Database (회원 정보)
- 포트:
IMemberRepository
는 회원 정보를 관리하는 인터페이스입니다. - 어댑터: SQL 데이터베이스에 회원 정보를 저장하고 조회하는 어댑터입니다.
public class MemberService
{
private readonly IMemberRepository _memberRepository;
public MemberService(IMemberRepository memberRepository) => _memberRepository = memberRepository;
public Member GetMemberDetails(string userId) => _memberRepository.FindById(userId);
public void UpdateMemberDetails(Member member) => _memberRepository.Save(member);
}
테스트 전략 및 모킹 활용
헥사고날 아키텍처는 외부 의존성을 어댑터로 처리하기 때문에 모킹Mock을 사용한 테스트가 매우 쉽습니다. 각 포트를 모킹하여 비즈니스 로직을 테스트할 수 있으며, 실제 외부 시스템과의 상호작용 없이도 비즈니스 규칙을 검증할 수 있습니다.
예시: 모킹을 사용한 단위 테스트
public class BookServiceTests
{
private readonly Mock<IBookRepository> _bookRepositoryMock;
private readonly BookService _bookService;
public BookServiceTests()
{
_bookRepositoryMock = new Mock<IBookRepository>();
_bookService = new BookService(_bookRepositoryMock.Object);
}
[Fact]
public void BorrowBook_ShouldCallSaveMethod_WhenBookIsAvailable()
{
var book = new Book(1, "DDD in Action");
_bookRepositoryMock.Setup(r => r.FindById(1)).Returns(book);
_bookService.BorrowBook(1, "user123");
_bookRepositoryMock.Verify(r => r.Save(book), Times.Once);
}
}
- 모킹Mock:
IBookRepository
를 모킹하여 책 대출 시Save
메서드가 호출되는지 테스트합니다. - 헥사고날 아키텍처와의 통합: 외부 시스템과의 상호작용을 모킹으로 처리하면서, 비즈니스 로직을 독립적으로 검증할 수 있습니다.