헥사고날 아키텍처 - 클래스 의존성 주입 및 도메인, 엔티티의 객체 변환 과정
헥사고날 아키텍처를 도입할 때 주의해야 할 점에 대해서 알아보자
우리는 기존에 Spring에서 자주 사용되는 MVC 패턴을 사용하고 있었다. 그런데 팀원 중 한 분이 '헥사고날 아키텍처를 도입해 보는 것은 어떨까?'라는 아이디어를 제안하셨고, 그 아이디어를 함께 탐구하고자 한다.
이 글에서 다룰 내용은 해당 YouTube 영상에서 영감을 받았다. NHN FORWARD 22에서 김민중 개발자님의 '클린 아키텍처 애매한 부분 정해 드립니다' 발표 내용을 참고하여, 우리 프로젝트에 헥사고날 아키텍처를 도입했다.
아래 영상은 헥사고날 아키텍처를 적용하는 시점에 보면 굉장히 유용한 내용이니 꼭 봤으면 좋겠다.
유튜브 링크: https://youtu.be/g6Tg6_qpIVc?si=N0pqwLjmdgqbMz9i
1. 영상 요약
이 발표에서 핵심적으로 다룬 내용은 클린 아키텍처와 그중에서도 특히 헥사고날 아키텍처에 관한 것이다. 클린 아키텍처는 소프트웨어 개발에서 아키텍처의 중요성을 강조하며, 좋은 아키텍처 구성을 위한 원칙과 패턴을 제시한다. 핵심 규칙은 중요도에 따라 계층을 나누고, 이 계층들 간의 의존성이 고수준 계층을 향해야 한다는 것이다.
헥사고날 아키텍처는 클린 아키텍처의 일부로, 도메인 중심의 설계를 강조한다. 이 아키텍처는 도메인(비즈니스 로직)을 중심으로 주변의 세부사항들(예: 데이터베이스, UI)이 도메인에 의존하도록 설계한다. 이를 통해 도메인의 순수성을 유지하고, 변경에 대응하기 쉬운 구조를 만드는 것이 목표다.
헥사고날 아키텍처의 핵심 구성 요소로는 아래와 같다.
- 도메인 엔티티(Domain Entities): 비즈니스 로직을 포함하는 핵심 클래스
- 유스 케이스/서비스(Use Cases/Services): 시스템의 비즈니스 규칙을 캡슐화한 인터페이스(유스 케이스)와 그 구현체(서비스)
- 포트와 어댑터(Ports and Adapters): 시스템의 외부와의 상호작용을 처리하는 인터페이스(포트)와 그 구현체(어댑터)
- 외부 요소(External Elements): 데이터베이스, 웹 서비스 등 외부 시스템과의 통신을 담당
헥사고날 아키텍처를 도입함으로써 얻는 주요 이점은 유연성과 테스트 용이성이다. 도메인 로직이 외부 요소로부터 격리되어 있기 때문에, 외부 시스템의 변경이 도메인 로직에 미치는 영향을 최소화할 수 있으며, 도메인 로직을 중심으로 테스트를 진행하기 용이하다.
이러한 점을 고려하여 프로젝트에 헥사고날 아키텍처를 적용했을 때, 비즈니스 로직의 순수성을 유지하고 변경에 유연하게 대응할 수 있는 강력한 아키텍처를 구축할 수 있다.
아래 이미지에서 볼 수 있는 헥사고날 아키텍처의 패키지 구조를 우리 프로젝트의 기본 틀로 채택했다. 이미지에 포함되지 않은 설정 파일과 DTO 등은 별도의 패키지로 추가하여 구조를 완성했다.
2. 헥사고날 아키텍처에서 의존성 주입 원칙
우리 팀은 프로젝트에 헥사고날 아키텍처를 적용하기로 결정하면서 가능한 헥사고날 아키텍처 원칙에 적합한 코드로 설계하기로 합의했다. 만약 헥사고날 아키텍처의 적용 과정에서 불분명하거나 혼란스러운 부분이 있을 경우, 우리의 해결방법이 원칙에 부합하는지 여부를 판단하기 위해 우리끼리 나름대로 헥사고날 아키텍처의 구체적인 원칙을 정해봤다.
일단 헥사고날 아키텍처는 크게 세 가지 주요 원칙에 기반한다.
- 사용자 측면, 비즈니스 로직, 서버 측면의 명확한 분리: 이 구조는 코드를 세 부분으로 명확하게 구분한다. 사용자 측면은 사용자 또는 외부 프로그램이 애플리케이션과 상호 작용하는 부분이며, 비즈니스 로직은 애플리케이션의 핵심이며 모든 비즈니스 규칙을 포함한다. 서버 측면은 데이터베이스 상호작용, 파일 시스템 호출 등 인프라 관련 세부사항을 포함한다.
- 의존성은 내부로 향한다: 비즈니스 로직은 사용자 측면이나 서버 측면에 의존하지 않고, 오히려 그 반대가 된다. 즉, 사용자 측면과 서버 측면이 비즈니스 로직에 의존해야 한다.
- 인터페이스를 사용한 경계의 격리: 사용자 측면 코드는 비즈니스 코드를 인터페이스를 통해 제어하고, 비즈니스 코드는 서버 측면을 또 다른 인터페이스를 통해 제어한다. 이러한 인터페이스는 내부와 외부 사이의 명확한 분리를 제공한다.
이 세 가지 주요 원칙을 조금 더 이해하기 쉽게 풀어서 설명해 보겠다.
Controller의 의존성 주입
Controller는 외부 요청을 처리하는 진입점으로, UseCase 인터페이스에 의존해야 한다. Controller가 구체적인 Service 클래스 대신 UseCase 인터페이스를 의존성 주입받음으로써, 비즈니스 로직의 변경에 유연하게 대응할 수 있다.
이는 UseCase 인터페이스가 Port의 역할을 하여, 다양한 Adapter(구현체)를 수용할 수 있는 구조를 막는다.
Service의 의존성 주입
Service는 UseCase 인터페이스를 구현하여 실제 비즈니스 로직을 담당한다. Service는 비즈니스 로직을 구현하는데 필요한 데이터 접근을 위해 Port 인터페이스를 의존성 주입받는다. Port 인터페이스는 데이터 저장소와의 상호작용을 추상화한 것으로, Service는 이 인터페이스를 통해 데이터를 관리한다.
즉, 구체적인 데이터 저장소(예: JPA Repository)는 Adapter에 의해 구현되어, Service와 데이터 저장소 사이의 결합도를 낮춘다.
Port와 Adapter의 관계
Port는 데이터 저장소 접근을 위한 인터페이스로, 데이터 저장소와의 상호작용을 정의한다. Adapter는 이 Port 인터페이스를 구현하며, 실제 데이터 저장소(예: JPA Repository)와의 상호작용을 처리한다. Adapter 내에서 데이터 저장소(Repository)는 의존성 주입을 통해 제공된다. 이는 Adapter가 데이터 저장소의 구체적인 구현과 상호작용할 수 있게 해 준다.
3. 레이어 간 객체 전달 방법
헥사고날 아키텍처에서 각각의 클래스가 어떤 일을 담당하는지는 이해하기 쉬웠는데 이제 각 객체(DTO, Domain, Entity)들이 언제, 어떻게 변환되어서 어느 클래스에서 사용되는지 정리된 글은 없는 것 같아서 한번 정리해 보도록 하겠다.
1. Controller: Request DTO → Domain
Controller는 외부 요청을 Request DTO 형태로 받고 Controller 내에서 DTO를 Domain 객체로 변환한다. 이렇게 함으로써, Controller는 UseCase(Service)에 Domain 객체를 전달하며, UseCase는 외부 계층에 대한 의존성 없이 순수한 비즈니스 로직에 집중할 수 있다.
2. Service: Domain 객체 처리
Service는 Controller로부터 Domain 객체를 전달받아 로직을 수행한다. Service는 Domain 객체만을 의존하며, 데이터베이스와의 상호작용은 Port를 통해 이루어진다. Service는 데이터베이스와의 상호작용을 위해 Domain 객체를 Adapter에 전달한다.
3. Adapter: Domain → Entity 변환 및 데이터 처리
Adapter는 Service로부터 Domain 객체를 받아 Entity로 변환한다. Adapter는 변환된 Entity를 사용하여 데이터베이스 작업을 수행한다.
데이터베이스 작업 후, 필요한 경우, 반환받은 Entity를 다시 Domain 객체로 변환하여 Service에 반환한다.
4. Service → Controller: Domain → Response DTO 변환
Service는 처리된 Domain 객체를 Controller에 반환한다. Controller는 Domain 객체를 Response DTO로 변환하여 클라이언트에 응답한다.
위에서 설명한 변환 과정이 왜 헥사고날 아키텍처에 적합한지 알아보자.
계층 간의 엄격한 분리
DTO, Domain, Entity 간의 변환을 통해, 각 계층은 자신의 책임에만 집중한다. 이는 각 계층이 독립적으로 변경 가능하며, 유연한 아키텍처를 구성할 수 있게 한다.
의존성의 역전과 포트 & 어댑터 메커니즘
Service가 데이터베이스와 직접 소통하지 않고, Port를 통해 Adapter와 소통함으로써 의존성을 역전시킨다. 이로 인해 데이터베이스나 외부 시스템에 대한 변경이 Service에 영향을 미치지 않는다.
도메인 중심의 설계
모든 계층에서 Domain 객체에 초점을 맞춤으로써, 비즈니스 로직과 응용 프로그램 로직이 분리된다. 이는 비즈니스 로직의 재사용성과 테스트 용이성을 증가시킨다.
이 방식은 헥사고날 아키텍처의 핵심 원칙인 '의존성 역전', '계층 간의 엄격한 분리', '도메인 중심 설계'를 잘 구현한다. 따라서, 유지 보수성이 높고 확장 가능한 시스템을 구축할 수 있다.
4. 객체 간 변환 작업을 위한 별도의 클래스 생성
이렇게 컨트롤러와 어댑터에서 domain을 다른 객체로 변환해야 하는 과정이 꽤 빈번할 것 같아서 변환 작업만을 위한 Converter 클래스를 별도로 생성했다. 변환 작업을 위해 Converter 클래스를 별도로 생성하는 접근 방식은 헥사고날 아키텍처에서 매우 효율적이고 깔끔한 구현 방식이다.
이 방법의 주요 장점과 구현 방식에 대해 설명하겠다.
코드의 분리와 관심사의 분리
DTO, Domain, Entity 간의 변환 로직을 별도의 클래스로 분리함으로써, 각각의 클래스가 자신의 주요 책임에만 집중할 수 있다. 이는 Controller, Service, Repository 등의 클래스가 변환 로직으로 인해 복잡해지는 것을 방지한다.
재사용성과 유지보수성 향상
변환 로직을 중앙화함으로써 동일한 변환 로직의 재사용이 용이하다. 변환 로직에 변경이 필요한 경우, Converter 클래스만 수정하면 되므로 유지보수가 용이하다.
테스트의 용이성
변환 로직이 별도의 클래스에 구현되어 있기 때문에, 이 로직에 대한 단위 테스트를 작성하기가 더 쉽다.
Converter 클래스는 static 메소드를 사용하여 인스턴스 생성 없이 변환 로직을 제공한다. 아래 예시에서 보여준 MemberConverter 클래스는 MemberEntity와 Member Domain 사이의 변환을 담당한다.
이 클래스는 필요한 곳에서 직접 호출될 수 있으며, 변환 로직이 중앙집중화되어 있어 관리가 용이하다.
(코드 사용 예시)
package com.recipia.member.hexagonal.domain.converter;
import com.recipia.member.hexagonal.adapter.out.persistence.MemberEntity;
import com.recipia.member.hexagonal.domain.Member;
/**
* dto, entity와 domain을 변환해주는 로직과
* domain을 entity로 변환해주는 로직을 담당
*/
public class MemberConverter {
/**
* MemberEntity를 받아서 Member Domain을 반환
* @param entity MemberEntity
* @return MemberDoamin
*/
public static Member entityToDomain(MemberEntity entity) {
return Member.of(
entity.getId(),
entity.getUsername(),
entity.getPassword(),
entity.getFullName(),
entity.getNickname())
}
/**
* MemberDomain을 받아서 MemberEntity를 반환
* @param member MemberDomain
* @return MemberEntity
*/
public static MemberEntity domainToEntity(Member member) {
return MemberEntity.of(
member.getUsername(),
member.getPassword(),
member.getFullName(),
member.getNickname())
}
/**
* MemberRequestDTO를 받아서 Member Domain으로 반환
* @param requestDTO MemberRequestDTO
* @return MemberDomain
*/
public static Member dtoToDomain(MemberRequestDTO requestDTO) {
return Member.of(
null, // ID는 null로 설정하거나 적절한 값으로 초기화
requestDTO.getUsername(),
requestDTO.getPassword(),
requestDTO.getFullName(),
requestDTO.getNickname()
);
}
}
@RestController
@RequestMapping("/api/members")
@RequiredArgsConstructor
public class MemberController {
private final MemberUseCase memberUseCase;
// POST 요청을 통해 Member 등록
@PostMapping
public ResponseEntity<MemberResponseDTO> registerMember(@RequestBody MemberRequestDTO requestDTO) {
// Request DTO를 Member Domain으로 변환
Member member = MemberConverter.dtoToDomain(requestDTO);
// MemberUseCase 사용하여 Member 등록 로직 수행
Member registeredMember = memberUseCase.registerMember(member);
// 등록된 Member를 Response DTO로 변환
MemberResponseDTO responseDTO = MemberConverter.domainToDto(registeredMember);
return ResponseEntity.ok(responseDTO);
}
// 다른 컨트롤러 메서드들도 유사한 방식으로 구현 가능
}
이처럼 Converter 클래스를 사용하면 변환 로직이 각 계층의 주요 로직에서 분리되어, 각 계층이 자신의 주요 역할에 더 집중할 수 있게 된다. 또한, 전체 시스템의 유지보수성과 확장성이 향상된다.
5. 헥사고날 아키텍처와 DDD(도메인 주도 설계)의 관계
이번 글에서 우리는 헥사고날 아키텍처의 구현에 초점을 맞췄다. 하지만, 도메인 주도 설계(DDD)와의 관계에 대해서도 언급하는 것이 중요하다. 헥사고날 아키텍처와 DDD는 서로 다른 개념이지만, 상호 보완적인 관계를 가진다. DDD는 복잡한 소프트웨어 시스템을 모델링하고 설계하는 방법론으로, 도메인의 복잡성을 관리하는 데 초점을 맞춘다. 반면, 헥사고날 아키텍처는 이러한 도메인 모델을 효과적으로 통합하고, 인프라스트럭처와의 의존성을 최소화하는 구조를 제공한다.
우리 프로젝트에서는 DDD를 직접적으로 적용하지 않았지만, 헥사고날 아키텍처의 도입으로 인해 어느 정도 도메인 중심의 설계가 이루어졌다고 볼 수 있다. 헥사고날 아키텍처는 DDD의 일부 원칙을 자연스럽게 통합할 수 있는 구조를 제공하며, 이는 우리 프로젝트에서도 목격할 수 있다. 하지만, DDD를 목표로 한 설계가 아니었기 때문에, DDD의 모든 원칙과 패턴을 완전히 반영하고 있다고는 할 수 없다. 특히, DDD의 복잡한 전략과 패턴은 우리 프로젝트의 현재 요구사항과 복잡성에 비추어 볼 때 과도할 수 있다.
헥사고날 아키텍처를 통해 우리는 도메인의 중심성을 강화하고, 의존성을 관리하는 데 성공했다. 하지만, DDD의 전체적인 도입은 프로젝트의 범위와 복잡성에 따라 결정되어야 할 사항이다. 향후 프로젝트가 발전함에 따라 DDD의 추가적인 원칙과 패턴을 통합하는 것을 고려할 수도 있겠지만, 현재로서는 헥사고날 아키텍처만으로도 충분한 유연성과 구조적 명확성을 제공하고 있다.
아직 우리도 헥사고날 아키텍처에 대해서 완벽하게 이해한 건 아니라 글 내용 중에 틀린 부분이 있을 수 있다. 만약 틀린부분이 있다면 댓글이나 메일로 알려주셨으면 좋겠습니다,,
다음 장에서는 우리가 실제로 어떻게 도메인을 설계하고, 구체적인 코드를 어떻게 작성했는지에 대한 과정을 자세히 다루어보겠다.
🔻 AWS 비용을 줄이기 위해 테라폼을 도입한 과정이 궁금하다면? 🔻
이 포스트는 Team chillwave에서 사이드 프로젝트를 하던 중 적용했던 부분을 다시 공부하면서 기록한 것입니다.
시간이 괜찮다면 팀원 '개발자의 서랍'님의 블로그도 한번 봐주세요 :)
'Spring > Spring Boot' 카테고리의 다른 글
스프링 부트 3 버전에서 AWS SNS 클라이언트 여러 개 사용하기 (2) | 2023.12.29 |
---|---|
Spring Boot 3 버전에서 AWS SNS를 통한 SMS 발송하기 (3) | 2023.12.29 |
[Spring Boot] Spring Boot 3.X 버전에 p6spy 적용하기 (0) | 2023.12.08 |
[Spring Boot] 스프링 배치와 JPA를 활용해 누락된 SNS 이벤트 재발행 (0) | 2023.11.29 |
[Spring Boot] SNS MessageAttributes를 이용한 분산 시스템 추적용 Trace Id 전달 방법 (0) | 2023.11.28 |