토이프로젝트를 진행하면서 헥사고날 아키텍처(Hexagonal Architecture) 로 설계하고 구현하기로 했다. 진행을 하면서 헥사고날 아키텍처를 정리 할 필요를 느꼈다. 예전에 관련 책이나 강의를 본 적도 있고, 그 때에는 잘 이해 했다고 생각 했다.
하지만 실제 코드로 구현하고 의존성에 대한 방향에 대한 고민과 의문, 그리고 적절한 패키징 구조를 생각하면서 끊임없이 코드 변경의 과정을 거쳤다.
결국 완벽하지는 않고, 정답은 아니지만 나의 방식대로 헥사고날 아키텍처를 구현한 내용을 정리 해 보려고 한다.
비교적 익숙한 레이어드 아키텍처(Layered Architecture) 는 계층별로 책임을 분리하여 시스템을 설계한다. 그러나 시간이 지남에 따라 레이어드 아키텍처는 변경에 취약하고, 테스트하기 어려운 구조로 변질되거나, 그렇게 설계될 가능성이 있다.
이러한 문제를 해결하기 위해 헥사고날 아키텍처(Hexagonal Architecture) 를 대안으로 많은 선배 개발자들이 이 설계 방식을 제안하고 있다. 레이어드 아키텍처의 구조와 비교하며, 어떤 점에서 헥사고날 아키텍처가 이점을 갖는지 알아보며 정리하고자 한다.
레이어드 아키텍처는 소프트웨어 시스템을 계층으로 나누어 설계하는 전통적인 방식이다. 일반적으로 표현 계층(Presentation Layer), 비지니스 계층(Business Logic Layer), 데이터 접근 계층(Data Access Layer)로 구성되는 3계층의 형태가 일반적인 레이어드 아키텍처의 구조이다.
challenge
│ ChallengeApplication.java
│
├─controller
│ ChallengeController.java
│
├─service
│ ChallengeService.java
│
├─repository
│ ChallengeRepository.java
간단한 패키지와 코드 구현을 나타내면 위와 같다. challenge라는 애플리케이션을 구현하기로 한다. 위 구조에서 service와 repository 레이어에서 따로 인터페이스로 구현하고, 따로 구현체를 만드는 경우가 많다.(여기서는 가장 단순한 구조를 예시로 한다.)
레이어드 아키텍처의 개념과 대응하면 다음과 같다.
레이어드 아키텍처에서 의존성
은 일반적으로 상위 계층에서 하위 계층으로 흐른다.
Controller → Service → Repository
의존성의 방향
은 내부에서 외부로 흐른다. 비즈니스 로직 계층이 외부의 UI나 DB에 의존하기 때문이다.
내부와 외부의 정의
나는 의존성의 흐름과 방향의 설명을 봤을 때 제대로 이해가 가지 않았다. 자세히 찾아보니 이 설명에서 말하고 있는 내부와 외부의 정의가 기존의 생각과는 달랐기 때문이다. 아래의 정의를 기준으로 설명을 보면 이해할 수 있을 것이다.
- 내부(Inside): 시스템의 핵심 비즈니스 로직이나 도메인 로직을 뜻한다. 이 부분은 애플리케이션의 본질적인 규칙과 행동을 담당하는 중심부이다. 즉, 비즈니스 규칙을 결정하는 도메인 로직이 있는 곳이 내부이다.
- 외부(Outside): 데이터베이스, 사용자 인터페이스(UI), 외부 시스템(API), 프레임워크 등의 비즈니스 로직을 지원하는 기술적인 요소를 의미한다. 이는 주로 시스템의 입출력, 외부와의 상호작용을 담당한다.
소규모나 중간 규모의 프로젝트에서 적합하며, 표준화된 방식으로 빠르게 시스템을 구축하고 확장할 수 있다는 장점이 있다.
레이어드 아키텍처는 초기에는 이해하기 쉽고 간단하게 시작할 수 있다. 하지만 시스템이 커지면서 다음과 같은 문제가 발생할 수 있다. 장점으로 나타난 부분이 오염되어갈 가능성이 나타난다.
위 내용은 레이어드 아키텍처의 문제를 검색 해 봤을 때 나오는 단점이다. 하지만 위 문제는 레이어드 아키텍처 자체의 문제라고 보기에는 어려운데, 각각의 구현 원칙과 방법을 잘 선택하면 전혀 발생시키지 않을 수도 있는 문제이다. 간단하게는 다음과 같은 방법으로 해결이 가능하다. 위에서 제시하는 단점은 개념적 문제점이라고 생각하자.
- 의존성 역전 위반 : 의존성 주입을 사용하여 구현체를 주입하는 방식으로, 의존성을 역전시켜서 구현체에 직접 의존하는 않도록 설계 및 구현.
(하지만 의존성 흐름이 내부에서 외부로만 흐르는 문제는 여전히 해결 할 수 없다. 비즈니스 로직이 데이터 접근 계층의 세부 사항이나 외부 기술에 영향을 받게 될 가능성이 높다.)- 유지보수의 어려움 : 비지니스 레이어에서만 비지니스 로직을 담당하도록 한다.
(이 부분은 실제로는 의도적으로 생각하고 있지 않으면 해결하기 어려운 부분이라고 생각한다. 데이터베이스에서의 결과를 처리하거나, 쿼리 자체에 비지니스 로직이 포함되는 경우가 빈번히 발생 할 수 있기 때문)- 테스트 어려움 : 직접 구현체를 사용하지 않고, 의존성을 주입을 통해 구현체를 주입 받는 식으로 설계하고 목(Mock) 구현체 등을 사용하여 외부 의존성을 제거하면 단위(소형) 테스트로의 전환이 가능하다. (의존성 역전을 제대로 지키면 자연스럽게 따라오는 부가효과이다. 단 의존성 흐름이 여전히 내부에서 외부로 흐르기 때문에 외부의 영향을 예상한 테스트를 작성 할 수 없다. 또 상위 계층이 하위 계층에 의존하는 구조를 가지고 있기 때문에, 테스트 과정에서 필요없는 하위 계층의 의존성이 필요하거나 완전 독립된 테스트를 하기에는 어렵다)
헥사고날 아키텍처는 이러한 문제점을 해결하기 위해 의존성 역전 원칙을 적용하고, 도메인 모델을 중심으로 설계하고자 한다.
헥사고날 아키텍처는 포트와 어댑터 패턴(Ports and Adapters Pattern) 으로도 불리며, 도메인 로직을 중심으로 외부와의 의존성을 분리하는 설계 방식이다. 이 내부의 구조 영역을 크게 다음의 3개의 영역으로 나누면 다음과 같다.
주요 구성 요소
도메인(Domain) 객체
: 상태와 행동을 포함하는 객체로 핵심 비지니스로직을 가지고 있다값 객체(Value Object)
: 불변 객체로, 하나의 값을 나타내며 식별자가 없음주요 구성 요소
입력 포트(Input Port)
: 외부에서 들어오는 요청을 처리하는 인터페이스출력 포트(Output Port)
: 외부 시스템과의 통신을 위한 인터페이스유스케이스 구현체(Use Case Implementation)
: 입력 포트를 구현하여 비즈니스 로직을 실행주요 구성 요소
입력 어댑터(Inbound Adapter)
: 외부 요청을 받아 애플리케이션 헥사곤의 입력 포트를 호출출력 어댑터(Outbound Adapter)
: 애플리케이션 헥사곤의 출력 포트를 구현하여 외부 시스템과 상호작용 함challenge
│ ChallengeApplication.java
│
├─application
│ ├─dto
│ │ ChallengeInputDTO.java
│ │ ChallengeOutputDTO.java
│ │
│ ├─port
│ │ ├─inbound
│ │ │ CreateChallengeUseCase.java
│ │ │
│ │ └─outbound
│ │ ChallengeRepository.java
│ │
│ └─service
│ CreateChallengeService.java
│
├─domain
│ ├─model
│ │ Challenge.java
│ │
│ └─vo
│ AdditionalInfo.java
│ ...
│
└─framework
└─adapter
├─inbound
│ └─web
│ ChallengeController.java
│ CreateRequest.java
│ CreateResponse.java
│ ...
│
└─outbound
└─jpa
└─entity
ChallengeJPAEntity
...
ChallengeJpaAdapter.java
ChallengeJpaRepository.java
헥사고날(Hexagonal)은 육각형을 뜻하는데, 아키텍처의 구조를 그림으로 간단히 나타내면 위와 같다.
패키징이나 네이밍에 대해서는 딱히 정답은 없다. 나는 개념적인 부분을 코드에도 적용시키고자, 상위 패키지 이름을 domain, application, framework로 정의 했고, 각각의 헥사곤 영역을 나타내게 표현했다.
헥사고날 아키텍처의 구조와 패키징 구조를 의도적으로 매칭 시켰다. 내부와 외부의 경계를 코드만으로 이해하고, 의존성의 방향과 흐름을 이해하기 쉽도록 의도했다.
DDD를 기반으로 풍부한 도메인 코드를 구현하면, 자연스럽게 가장 코드의 양이 많은 영역이 된다. 도메인 코드의 객체 생성 규칙, 검증, 비지니스 로직 등은 생략 한 예시이다. 또 도메인은 값 객체를 사용하여 도메인을 더 의미 있고 직관적으로 표현 할 수 있다.
public class Challenge {
private final Long challengeId;
private final Long userId;
private final String nickName;
private final Period period;
// ...
}
입력 포트
public interface CreateChallengeUseCase {
ChallengeOutputDTO createChallenge(ChallengeInputDTO challengeInputDTO);
}
InputPort에 해당한다. 외부의 입력을 받고 (이 입력은 DTO를 통해서 받는다) 이것을 처리하기 위한 인터페이스다.
출력 포트
public interface ChallengeRepository {
Challenge save(Challenge challenge);
}
OutputPort에 해당한다. 외부 출력을 위한 인터페이스로, 여기서는 도메인을 저장하는 행위 자체만을 정의했다.
@Service
@Transactional
@RequiredArgsConstructor
public class CreateChallengeService implements CreateChallengeUseCase {
private final ChallengeRepository challengeRepository;
@Override
public ChallengeOutputDTO createChallenge(ChallengeInputDTO challengeInputDTO) {
Challenge challenge = Challenge.create(null, challengeInputDTO.getUserId(), challengeInputDTO.getNickName(),
new Period(challengeInputDTO.getStartDate(), challengeInputDTO.getEndDate()),
GoalContent.create(challengeInputDTO.getMainContent(), challengeInputDTO.getAdditionalContent(), GoalType.valueOf(challengeInputDTO.getGoalType())),
challengeInputDTO.getAttachedImagePaths(), challengeInputDTO.getChallengeCertificateImagePath());
return ChallengeOutputDTO.from(challengeRepository.save(challenge));
}
}
service에 해당하며, InputPort의 구체적인 구현체이다. 하지만 여기서도 구체적인 외부의 저장소를 알지 못한다. 전달 받은 외부의 DTO 값을 객체로 만들고, 이 생성된 객체를 통해 외부로 저장을 요청한다. 이 결과값으로 받은 값 또한 다시 출력을 위한 DTO로 변환된 값을 받도록 했다.
입력 어댑터
@RestController
@RequiredArgsConstructor
public class ChallengeController {
private final CreateChallengeUseCase createChallengeUseCase;
@PostMapping("/challenges")
public ResponseEntity<CreateResponse> createChallenge(@RequestBody @Valid CreateRequest request) {
return ResponseEntity.ok(CreateResponse.toDTO(createChallengeUseCase.createChallenge(request.toDTO()));
}
}
프레임워크 헥사곤은 실제 기술의 의존하며, 구현하는 영역이다. 이 코드의 입력 어댑터는 Rest방식의 WebController를 사용한 것이 된다. 애플리케이션 헥사곤에서 구현한 CreateChallengeUseCase를 통해 필요한 데이터를 전달하여, 사용자의 요청을 처리한다.
나의 경우는 특별히 여기서 별도의 Request와 Response 객체를 따로 만들었다. API 요청과 응답을 나타내는 DTO이다. 여기서 Request에서 사용자 입력 값을 전달받고, 값 검증과 같은 로직을 담당하며, Response는 사용자에게 API에 맞는 출력을 위해 사용한다.
외부의 입력과 출력만을 위한 단일책임과 역할분리를 위해서 만들었다. 이 결과로 애플리케이션 헥사곤에 데이터를 전달하기 위한 DTO로 변환하는 번거로운 작업과 비용이 발생했다. 하지만 유지보수와 책임 분할이라는 이점을 생각하면 나쁘지 않은 선택이라고 생각한다.
출력 어댑터
@Repository
@RequiredArgsConstructor
public class ChallengeJpaAdapter implements ChallengeRepository {
private final ChallengeJpaRepository challengeJPARepository;
@Override
public Challenge save(Challenge challenge) {
return challengeJPARepository.save(ChallengeJPAEntity.fromDomain(challenge)).toDomain();
}
}
public interface ChallengeJpaRepository extends JpaRepository<ChallengeJPAEntity, Long> {}
출력 어댑터로, 구체적인 외부의 저장소를 로직을 구현했다. JPA의 기술을 통해 저장하며, 이 단계에서도 외부의 의존성 주입을 통해서 SprigDataJPA를 사용할 수 있게 처리했다.
추가로 나의 경우는 별도의 JPAEntity 코드를 정의 했다. 순수 도메인과 거의 1:1로 대응하지만, JPA의 영속성을 위한 JPAEntity 객체 코드로 명확한 역할을 구분하기 위해서 별도로 분리하였다.
헥사고날 아키텍처에서의 의존성
은 항상 외부에서 내부로 향하도록 설계되어야 한다. 가장 내부는 도메인 헥사곤 영역이다.
헥사고날 아키텍처에서 핵심은 비즈니스 로직과 외부 시스템의 의존성을 분리하는 것에 있기 때문에 레이어드 아키텍처 처럼 수직관계인 상위, 하위 개념으로 설명하지 않고 외부와 내부로 표현하는게 적절하다고 한다.
의존성의 방향
은 외부에서 내부로 흐른다.
데이터베이스, 외부 API, UI 같은 외부 의존성은 내부 비즈니스 로직에 의존하지만, 비즈니스 로직은 외부의 세부 사항에 의존하지 않는다. 따라서 내부의 핵심 비즈니스 로직은 외부 시스템(DB, UI, 외부 API 등)에 대해 몰라도 된다. 내부 로직은 기술적인 세부 사항에 의존하지 않고, 외부 시스템이 변경되더라도 비즈니스 로직은 영향을 받지 않는다. 즉 도메인 헥사곤 영역의 코드만으로도 완벽하게 돌아가는 프로그램인 것이다.
- 클라이언트가 HTTP 요청을 보낸다.
입력 어댑터(ChallengeController)
가 요청을 받아입력 포트(CreateChallengeUseCase)
를 호출한다.유스케이스 구현체인 CreateChallengeService
가 비즈니스 로직의 흐름을 제어하고, 모든 비즈니스 규칙은도메인(Challenge)
에서 처리된다.
필요에 따라출력 포트(ChallengeRepository)
를 호출한다.출력 어댑터(ChallengeJpaAdapter)
가출력 포트(ChallengeRepository)
를 구현하여 데이터베이스에 접근하고, DB에 직접적으로 데이터를 저장하거나, 조회, 삭제 등을 수행한다.- 결과는 역순으로 반환되어 클라이언트에게 응답된다.
설계 원칙은 명확하며 쉽다. 의존성과 의존성의 흐름이 항상 외부에서 내부로 가도록 설계하면 된다. 이것은 의존성 역전의 원칙을 지키며, 인터페이스를 통해 구현할 수 있다.
하지만 막상 실제 코드로 구현 해보니 개인적으로는 쉽지는 않았다.
추가로 인터페이스 분리 원칙을 지키면서 이것이 포트와 어댑터 패턴으로 나타나면서 여러 코드들이 나타나며 이 구조를 따라가는게 처음에는 쉽지 않았다.
찾아보는 자료마다 패키징 구조와 함께 명칭이 조금씩은 달랐기에, 어느 것을 표준으로 해야하는 지에 대한 선택의 고민이 있었다. 하지만 단순히 모양을 따라하고 싶은게 아니라 직접적인 목적과 의미를 이해해보고 싶은 마음에 혼자서 여러 시행 착오를 거치며 나름의 독자적인 형태를 완성했다. 나의 결과는 정답은 아니라는 것을 알고, 아마 실제로 개발을 한다고 해도 일부 원칙과 타협해야 하는 지점이나, 오용하는 부분이 생길 것이다. 그럼에도 우선 기본 형태 자체는 이상적인 형태는 만들어 봤으니 만족을 하고 있다.
토이프로젝트를 진행하며, 이 헥사고날 아키텍처 자체는 과도한 오버엔지니어링인 것을 사전에 인지하면서까지 구현 해 본 결과 가장 크게 이점으로 느꼈던 점은 테스트 용이성이였다.
사실 이전까지만해도 모킹(Mocking)을 통한 테스트에 부정적이였다. 실제 값이 아니기 때문에 100% 신뢰할 수 없는 테스트 결과라는 생각과 함께, 계층별로 테스트가 성공한다고 해도, 통합 테스트로 테스트가 이어지는 경우 성공하지 못하는 가능성 등의 요인을 부정적으로 생각했다.
헥사고날 아키텍처로 구현하면서 완전히 독립된 테스트가 작성하기 때문에 모킹을 통한 테스트에 대해 신뢰성을 더 믿을 수 있게 되었다. 또 명확한 계층 구조의 이해와 테스트를 수행 할 수 있어서 자연스럽게 TDD/BDD 를 쉽게 적용 할 수 있는 점이 큰 이점으로 다가왔다.
코드 구현에 대한 복잡성과 개발 속도의 저하 문제가 있지만, 복잡한 비즈니스 로직을 다루는 시스템에서는 충분히 고려해 볼 만한 가치가 있다고 생각한다. 결과적으로는 변경이 많이 예상되는 시스템에서는 더욱 유연한 변경과 안정적인 시스템 검증과 개발이 가능 할 것으로 보기 때문이다.
다만 레이어드 아키텍처에서도 도메인 계층을 분리하고(4계층 레이어드 아키텍처) DDD 관점에서 설계하며 의존성 역전 원칙을 활용하면, 충분히 테스트가 용이하며 유지보수성과 확장성을 갖춘 시스템을 만들 수 있다고 생각한다. 모든 일은 트레이드 오프 (Trade-Off)가 있는 법이며 은탄환은 없기 때문에 (No Silver Bullet) 실무에서는 프로젝트의 규모, 변경가능성, 팀원의 개발 역량 등을 고려해서 적절한 아키텍처를 선택하면 좋을 것 같다.