프로젝트에 헥사고날 아키텍처를 도입하면서 접하게 된 책이다. 첫 장부터 마지막 장까지 빠트릴 내용없이 굉장히 흥미롭게 읽었다. 책을 읽기 전, 읽은 후를 되새김질하며 계층형 아키텍처에서 헥사고날 아키텍처로 변환하면서 느낀점들을 서술하고자 한다.
계층형 아키텍처의 문제점
프로그램의 아키텍처가 데이터베이스에 의존하게 된다
당연히 어플리케이션 데이터의 기반은 데이터베이스가 아닌가? 라고 생각할 수 있다. 물론 어느정도 맞는 말이다. 모놀로틱 서비스에선 하나의 데이터베이스 모델만을 기반으로 아키텍처를 설계했고, 외부에서 데이터를 가져오는 일이 드물었다. 하지만 서비스는 점점 거대해지고 MSA 로의 변환이 필수가 되어버린다. 이렇게 되면 도메인 모델은 web에서 뿐만 아니라 gRPC, kafka 등등 다양한 인프라에서 가져오게 된다. 그러면 아키텍처는 무엇을 기반으로 설계되어야 하는가? 데이터베이스가 아닌 우리가 구현할 비즈니스 로직의 도메인이다.
계층간의 경계가 모호해진다
@RequiredArgsConstructor
@Service
public class SimpleService {
private final SimpleRepository repository;
public void badCode(final Long id, final String name){
SimpleEntity entity = repository.getById(id);
entity.setName("dirty check");
repository.save(entity);
}
}
entity의 필드 하나만 바꾸면 되는 기능인데... 엔티티를 모델로 변환하고 엔티티로 다시 변환해서 저장하는 번거로운 과정을 거쳐야하나..? 라는 생각을 누구나 한번쯤 하게 된다. 혹은 모델이랑 리스폰스랑 모두 같은 값인데 모델을 그대로 리스폰스로 전달해주면 안되나? 같은 생각을 하게 되고, 이제 점점 코드는 계층 간의 경계가 모호해지고 통일성도 사라지게 된다.
당장의 간편함은 있겠지만 프로그램이 커지게 될수록 가독성은 찾아볼 수 없고, 사이드 이펙트로 인해 코드를 건들이기도 싫어진다. "깨진 창문 이론" 에서 볼 수 있듯이, 한 번 경계가 무너지게 된 코드는 걷잡을 수 없이 커져간다.
헥사고날 아키텍처란?
어플리케이션 코어가 육각형으로 표현되어 아키텍처의 이름이 되었다. 다른 시스템이나 어댑터와 연결되는 4개 이상의 면을 가질 수 있음을 보여주기 위해 육각형을 사용했다고 한다.
헥사고날 아키텍처의 단점
수 많은 컨버팅
헥사고날의 근본적인 장점을 만들게 되는 요인이라고 생각한다. 계층을 분리한다는 것은 이 계층을 분리시켜줄 무언가가 필요한데 이것이 바로 컨버팅이다. 하지만 요청이 한번 들어왔을때,
웹 계층 -> [ request를 command로 컨버팅 ] -> [ command 를 domain model로 컨버팅 ] -> 도메인 서비스 -> [ domain model을 entity로 컨버팅 ] -> 영속성 계층 -> [ entity를 model로 컨버팅 ] -> [ model을 response로 컨버팅 ] -> 웹계층
무려 5번의 컨버팅이 이루어진다. 하나의 요청에 다양한 서브 도메인이 들어가 있다면? 컨버팅은 배로 많아진다. 물론 의존성을 가지게 되는건 굉장히 안좋은 일이지만, 이에 따른 단점이 수반될 수 밖에 없다.
내가 만드는 서비스는 간단한 CRUD 기능만 있고 웹 요청, 응답, 모델, 엔티티 모두 같은 필드만 사용하는데 계층형으로 해도 상관없어! 라고 한다면 맞는 말이다. 계층형 아키텍처에 비해 많은 컨버팅과 어댑터가 있고 간단한 프로젝트에선 생산성이 떨어질 수 밖에 없다. 하지만 꽤 큰 서비스를 개발하게 된다면 헥사고날의 완벽한 책임 분리의 매력에서 빠져나올 수 없어진다.
계층간 역할에 대한 이해
또한 헥사고날 아키텍처의 큰 단점 중의 하나라고 생각되는 점이 러닝커브이다. 프로젝트를 개발하는 팀원들 모두가 헥사고날 아키텍처의 각 계층 역할에 대해 제대로 알고 있지 않다면, 계층형에서 발생한 문제들이 똑같이 발생하게 된다. 영속성 계층에 비즈니스 로직이 추가되어 있다거나, 도메인 계층에서 사용하는 모델을 웹 계층으로 전달한다거나.. 나도 이 책을 읽기 전까진 각 계층의 역할에 대해 제대로 몰랐고, 아키텍처의 의도대로 사용하고 있지 않다는 것을 알게 되었다. 팀에서 헥사고날 아키텍처를 도입하고자 한다면, 팀원들 모두가 헥사고날 아키텍처의 의도에 대한 공부를 한 후 사용하는 것을 추천하고 싶다. 그렇지 않으면 파일만 많고 계층 간의 경계는 없어진 복구할 수 없는 코드가 되어간다....
헥사고날 아키텍처의 장점
완벽한 Domain Driven Design 기반 개발
- 인커밍 어댑터: 외부에서 어플리케이션으로 들어오는 요청을 서비스에 전달한다.
- 도메인 계층: 도메인 서비스의 비즈니스 로직만을 구현한다.
- 아웃고잉 어댑터: 도메인 서비스가 필요로 하는 모델을 가져온다.
도메인 계층은 어댑터의 정보를 전혀 몰라야 한다. 해당 요청이 웹에서 들어오는 요청이라면 도메인 계층은 http 프로토콜에 대한 정보를 전혀 가지고 있지 않아야 한다. 아웃고잉 어댑터도 마찬가지이다. 서비스가 원하는 데이터가 데이터베이스에 있는지, 캐시에 있는지 알 수 없어야 한다. 이러한 이유로 컨버팅은 모두 어댑터에서 이루어지게 된다.
모두 한번쯤은 데이터베이스 설계를 먼저 하고 코드를 작성해야 한다거나, JPA repository를 먼저 만든 뒤 개발을 해야 한다거나 하는 데이터베이스 기반 개발을 한 적이 있었을 것 이다. 헥사고날 아키텍처를 사용하면 정말 "도메인"의 비즈니스 로직을 먼저 작성할 수 있게 된다. 헥사고날 아키텍처로 개발을 해본다면 도메인 로직을 먼저 작성한 후 어댑터를 작성하는 것이 훨씬 자연스럽게 구현이 된다는 것을 깨달을 수 있을 것 이다.
비즈니스 로직 유닛 테스트
기존 계층형 아키텍처에서는 영속성 계층을 모킹하기 위해 서비스 테스트에서 repository를 모킹하고 entity를 반환하는 로직을 구현해야 했다. 이와 다르게 헥사고날 아키텍처에서는 오직 비즈니스 모델만을 테스트 할 수 있다. 인커밍 어댑터, 아웃고잉 어댑터 모두 비즈니스 모델을 반환하고 입력으로 전달한다. 내가 서비스의 비즈니스 요구사항에 대한 로직을 잘 구현했는가? 만을 테스트 할 수 있게 된다.
헥사고날의 매핑
헥사고날 아키텍처에서 매핑의 종류는 다양하다. 더 많은 컨버팅이 필요한 것도 있고, 좀 더 축소시킬 수 있는 방향도 있다. 매핑에도 정답은 없지만, 내가 느꼈던 전환이 필요하겠다고 느꼈던 부분들을 공유한다.
public class Catalog {
private Long id;
private String name;
private BigDecimal price;
private Integer stock;
private List<Category> categories;
}
예시를 들기 위해 Catalog라는 도메인 모델을 만든다. 혹시 Catalog라는 용어가 익숙하지 않다면 Product 라고 봐도 좋을 것 같다.
No Mapping -> Two-way Mapping
public class CatalogResponse {
private Long id;
private String name;
private BigDecimal price;
private Integer categoryCount;
}
Catalog 도메인을 응답으로 내려주어야 하는데, "재고사항인 stock은 빼주세요", "카테고리는 갯수만 응답으로 내려주세요" 라는 요구사항이 들어왔다. 모델와 웹 계층의 응답은 100% 같은 필드가 아닐 수 있다. 또한 필요한 정보만 사용자에게 내려주는 것도 API의 중요한 스펙이라고 생각하기 때문에, 계층간의 매핑인 Two-way 매핑이 필요하다.
Two-way Mapping -> Full-way Mapping
@Getter
public class SaveCatalogCommand {
@NotBlank
private String name;
@Min(0)
private BigDecimal price;
@Min(0)
private Integer stock;
@Size(min = 1)
private List<Long> categoryIds;
public Catalog getUser(){
return Catalog.builder()
.name(this.name)
.price(this.price)
.stock(this.stock)
.build();
}
}
Catalog를 저장하는 API를 만들고자 한다. 저장되는 Catalog는 id 값을 가지고 있지 않으며, 연관되어 저장할 카테고리 정보는 id 값만 요청으로 들어온다. 이때 기존의 Catalog Model을 사용하면 불필요한 id 필드가 생기고, Category 모델도 id값만 사용하는데 객체를 일일히 만들어주어야 한다.
또한 모델에 validation annotation이 무수히 많이 붙게 되고, 유스케이스마다 선택적으로 사용하는 필드엔 validation을 넣을 수 없게 된다. 예를 들어 수정 API는 id 값이 있어야 하기 때문에 @NotNull 이어야 하지만, 저장 API도 같은 모델을 사용하면 id에 validation을 추가할 수 없다.
조회도 마찬가지이다. 조회에 해당하는 validation 이 구분되어야 하는데, Full-Mapping이 아니라면 책임 분리가 어려워진다.
Full-way Mapping -> One-way Mapping
public static CatalogEntity from(final Catalog data) {
return CatalogEntity.builder()
.id(data.getId())
.name(data.getName())
.price(data.getPrice())
.stock(data.getStock())
.build();
}
위 코드는 Full-way Mapping의 컨버터이다. Full-way Mapping 방식으로 컨버팅을 하면서 "어댑터에서 도메인 모델을 불러오는 것도 결국은 계층 간의 분리가 완벽히 되지 않은 것 아닌가?" 라는 의문점이 생겼다. 아니나 다를까 이에 대한 해결책도 존재했는데,
public static CatalogEntity from(final CatalogState data) {
return CatalogEntity.builder()
.id(data.getId())
.name(data.getName())
.price(data.getPrice())
.stock(data.getStock())
.build();
}
바로 One-way Mapping이다. 인커밍 어댑터, 아웃고잉 어댑터, 도메인 모델들이 같은 상태(interface)를 공유한 채로 이 상태를 통해 컨버팅을 하게 된다. 이렇게 되면 어댑터에서도 도메인 모델의 구현체가 아닌 상태를 통해 컨버팅을 하게된다. 물론 이 방식에 대해서도 그러면 인터페이스에 얼마나 자세한 상태 값을 공유할 것인지, 어떤 형식으로 분리할 것 인지에 대한 고민이 남아있다.
한 장, 한 장 넘기면서 굉장히 감탄하면서 봤던 것 같다. 나는 지금까지 어떤 개발을 했던거지? 라는 생각을 하며... 지금까지 DDD를 관련있는 모델 간의 분리라고 단순하게 생각했었던 것 같다. DDD에 대해 다시 생각하게 되었고, DDD의 Bounded Contexts나 Aggregate와 같은 관련 용어에 대해 다시 되새김질 하면서 찾아보게 되었다. 해당 책이 아니더라도 개발자라면 헥사고날 아키텍처의 의도와 DDD 에 대해 반드시 알고 있어야 된다라고 생각할 만큼 좋은 공부를 한 것 같다. 헥사고날 아키텍처에 입문하는 분들에게 매우 추천!
'Clean Code' 카테고리의 다른 글
[Design Pattern] 구조 패턴 - 퍼사드 패턴 (0) | 2022.03.14 |
---|---|
[Design Pattern] 구조 패턴 - 브리지 패턴 (0) | 2022.03.12 |
[Design Pattern] 구조 패턴 - 어댑터 패턴 (0) | 2022.03.08 |
[Design Pattern] 구조 패턴 - 컴포지트 패턴 (0) | 2022.03.06 |
[Design Pattern] 구조 패턴 - 데코레이터 패턴 (0) | 2022.03.03 |