팀 프로젝트를 하며 클린 코드, 클린 아키텍처란 무엇인가 고민을 하게 되었습니다.
테스트 코드를 작성하며 이리 저리 꼬인 의존성에 따라 모의 객체를 만들어주는 것이 너무 힘들었기 때문입니다.
.
그러던 중, [ 만들면서 배우는 클린 아키텍처 - 톰 홈버그 ] 책을 추천 받아 읽게 되어 해당 내용을 정리하고 간단하게 진행하고 있던 프로젝트에 적용해 본 후기를 작성해보겠습니다.
클린 아키텍처와 헥사고날 아키텍처
클린 아키텍처에서 소개된 의존성 규칙이 있습니다. 이는 모든 소스코드 의존성은 외부에서 내부로, 고수준 정책을 향해야 한다는 것입니다. 이를 통해 업무 로직(고수준 정책)은 세부 사항들(저수준 정책)의 변경에 영향을 받지 않도록 할 수 있습니다. 이와 같은 구조는 변경에 유연성을 제공하며, 테스트 용이성을 향상시킨다는 장점이 있습니다.
.
클린 아키텍처는 의존성 규칙을 강조하는 상위 개념으로, 헥사고날 아키텍처는 이를 실현하기 위한 구체적인 구현 모델 중 하나입니다.
.
헥사고날 아키텍처
비즈니스 관심사를 다루는 내부와 기술적인 관심사를 다루는 외부를 철저히 분해 하였으며, 외부에 포함된 기술적인 컴포넌트를 Adapter 어댑터라 부르고, 어댑터가 내부와 상호작용하는 접점을 포트라고 지칭합니다. 아래와 같은 구성으로 이루어져 있습니다.
- 핵심 비즈니스 로직 = 어플리케이션 코어(Domain)
애플리케이션의 비즈니스 규칙과 도메인 모델은 포함하는 개념으로, 외부 기술이나 인터페이스에 의존하지 않고 순수한 비즈니스 로직으로 구성됩니다. 도메인 객체와 서비스 계층이 포함됩니다.
. - 포트 (Ports)
도메인 로직이 외부와 통신하기 위한 계약 또는 인터페이스로, 입/출력 동작을 정의하며 외부 시스템과의 의존성을 추상화 합니다.
예: Repository, Usecase, Service Interface
. - 어댑터 (Adapters)
포트를 구현하여 실제 외부 시스템과 도메인을 연결합니다. 어댑터는 포트에 정의된 규칙에 따라 동작합니다.
예: DataBase, 메시지 브로커, REST API
. - 경계 (Boundary)
도메인 로직과 일부 기술 간의 경계를 명확히 하여 서로 독립적으로 발전할 수 있도록 설계
.
또한 포트와 어댑터는 각각 인커밍/아웃고잉으로 또 나뉩니다. 인커밍 포트/어댑터는 외부 요청을 받아 비즈니스 로직으로 전달하는 인터페이스이며, 아웃고잉 포트/어댑터는 비즈니스 로직에서 외부 시스템과 통신하기 위한 인터페이스입니다.
.
이와 같이 포트와 어댑터를 활용하여 의존성 역전을 통해 클린코드가 제시하는 의존 규칙을 지킬 수 있게 설계되었습니다.
장점과 단점
테스트 시, 어댑터를 Mocks으로 대체할 수 있어 도메인 로직을 독립적으로 테스트하기 용이해집니다. 또한 외부 기술 변경 시 어댑터만 수정해주면 되어 확장성이 좋아집니다.
.
다만 기능마다 어댑터와 포트를 만들어야 해서 보일러 플레이트 코드가 많이 생기고 복잡해지는 것이 해당 아키텍처의 단점이라고 볼 수 있습니다.
.
따라서 소규모의 집단에서보단 마이크로서비스를 적용하거나 다양한 입출력을 요구하는 대규모 시스템에 적합한 아키텍처라고 할 수 있습니다.
.
구현 규칙
1. 코드 구성 (패키징)
필자는 패키징을 할 때 종종 기능을 기준으로 구성해왔습니다. 기능 기준으로 해야 협업 시 충돌이 일어나는 경우가 적었고, 기능을 위해 필요한 세부 구성들을 관리하기가 쉽다고 생각했기 때문입니다.
.
하지만 책에서는 이러한 구조가 특정 기능을 찾는게 어렵고, 인커밍/아웃고잉 포트가 숨겨져 아키텍처가 흐려진다고 주장했습니다.
.
예를 들어, Post를 작성하는 기능을 찾는다고 하면, 필자가 사용한 패키징이라면 Post 패키지에 들어가서 Service를 찾고, 그 안에서 post를 작성하는 코드를 드래그를 한참 내려서 찾아야 했을 것입니다. (혹은 ctrl + f4)
.
만약 주요 로직이 있는 application 패키지와 adapter 패키지를 나누고 그 안 에서 in, out을 패키지로 구별 해 놓는다면 어떻게 될까요?
.
또한 Service 기능을 usecase로 아주 잘게 나눠 그 패키지 안에 모아 둔다면, 어떤 기능이 어디에 있는지 한 눈에 찾기 쉬울 것 입니다. 아래와 같은 구조로 말이죠!
.
이때, 패키지의 모든 클래스들은 application 패키지 내에 있는 포트 인터페이스를 통하지 않고는 바깥에서 호출되지 않기 때문에 package-private 접근 수준이 가능합니다. 따라서 애플리케이션 계층에서 어댑터 클래스로 향하는 우발적인 의존성을 방지할 수 있습니다.
2. 유스케이스
유스케이스는 말 그대로 사용되는 경우, 기능을 뜻합니다. 헥사고날 아키텍처에선 Service가 구현해야 하는 애플리케이션의 설계도이기 때문에 interface로 제작해주고, 각 Service가 이를 구현합니다.
package.example.application.service
@RequiredArgsConstructor
@Transactional
public class WritePostService implements WritePostUseCase {
private final LoadPostPort loadPostrPort; // DB에서 데이터 로드하는 아웃고잉 Port
@Override
public Long writePost(WritePostCommand command) {
// 비즈니스 규칙 검증
// 모델 상태 조작
// 출력 반환
}
}
.
이때 입력 유효성 검증은 두 단계로 나뉩니다. 입력모델 (WritePostCommand) 내에서 하는 유효성 검증은 구문상의 유효성 검증으로, 입력 값을 컨트롤 하는 역할을 합니다.
package.example.application.port.in; // Command는 유스케이스 API의 일부이기 때문에 인커밍 포트 패키지에 위치
private final Member member; // 생성에 성공하고 나면 값을 바꿀 수 없도록 불변 필드로 지정
private final String content;
private final String title;
@Getter
public class WritePostCommand(String content, String title, Member member) {
this.content = content;
this.title = title;
this.member = member;
requireNonNull(title); // 유효성 검사
}
.
이 밖에 비즈니스 규칙과 같은 의미적인 유효성은 도메인 엔티티 안에서 검증합니다. 예) 잔고가 0원이면 출금을 할 수 없다.
3. 영속성 어댑터
육각형 아키텍처에서 영속성 어댑터는 아웃고잉 어댑터 입니다. 애플리케이션 서비스에서는 application.port.out 패키지에 있는 port interface를 호출하고, 해당 port interface를 구현한 어댑터가 실행됩니다.
.
Spring JPA를 쓰는 개발자들은 주로 Service는 자잘하게 구분되어 있어도, Repository를 도메인(Entity)당 하나를 만들어 의존성을 만드는 경우가 많습니다. 하지만 이런 방식은 하나의 인터페이스에 데이터베이스 연산을 과도하게 모으게 되어 모든 서비스가 실제로는 필요하지 않은 메서드에 의존하게 하는 경우를 초래 하기도 합니다.
.
이런 넓은 포트 인터페이스를 지양하고 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)를 적용해 문제를 예방할 수 있습니다. 클라이언트가 오로지 자신이 필요로 하는 메서드만 알면 되도록 포트 하나 당 하나의 메서드를 기준으로 분리하는 것입니다.
이와 같이 port interface를 분리한다면 테스트 시에도, 어떤 메서드를 사용해야 되는지 헷갈리지 않고 port를 구현한 클래스를 바로 찾아 주입할 수 있어 테스트가 간단해 집니다.
.
프로젝트에 적용하며 느낀점
필자가 정리한 내용 말고도 매핑/테스트/빌드에 대한 전략이 책에 더 나와 있습니다. 추후 시간이 된다면 더 정리해보도록 하겠습니다.
.
헥사고날 아키텍처는 포트와 어댑터를 사용해서 의존성 역전을 이용해 유연성과 확장성을 높였다는 부분에서 생각할 볼 점이 있는것 같습니다. 무의식적으로 항상 Controller, Service, Repository를 Class로 제작해왔는데, 이와 같은 코드가 얼마나 딱딱하고 유연하지 않았는지 반성하게 되었습니다.
.
따라서 책을 읽고 나서, 현재 진행하고 있는 프로젝트에 패키징만이라도 적용해 보려고 같은 백엔드 개발자와 아래와 같은 패키징 규칙을 만들어 보고, 적용해보았습니다.
domain
-- adapter
---- in
------ controller
-------- dto
---- out
------ persistence : repo 구현한 것, JPArepository를 사용해서 구현한 것.
------ jpaRepsotiroy
-------- entity
-- apllication
---- usecase : interface
---- service : class
------ out
------- repository : interface
---- domain : package
-- infra
---- config
---- s3
---- redis
-- global
---- error handling
---- util
이렇게 계획을 세워 직접 프로젝트에 적용해보고 있습니다만,, 아직은 단점이 더 많이 보이는것 같습니다. 대표적으로 JPA를 사용하고 있어서 JPA DATA가 자동으로 만들어 주는 쿼리를 Class 구현체에 또 적어 쓸데 없는 코드를 늘리는 것 같은 느낌이 들고,, 코드가 늘어날 수록 유지보수성에 대한 의문이 들었기 때문입니다.
.
따라서 아키텍처 변경을 통해 이득을 볼 수 있을지는 확실치 않아 일부 기능부터 적용하였고 다른 기능은 필요시 점진적으로 확장하는 전략을 취했습니다.
.
헥사고날 아키텍처에 대해 더 잘 이해하기 위해 팀 프로젝트 단위에서 헥사고날을 맛보기로 적용해볼 순 있겠지만 이후 개발 시 점점 더 많은 코드를 추가해야 될 수 있음을 감안해야 될 것 같습니다.
.
여러분들도 헥사고날 맛보고 싶다면 책 사서 읽으면서 한번 따라해보시길 추천드립니다. 아키텍처에 대해 시야가 넓어질 수 있는 경험은 된다고 생각합니다.
'Architecture' 카테고리의 다른 글
불변객체를 사용 하여 동시성 문제를 방지하자 (feat. Java Record) (0) | 2024.09.22 |
---|