지금까지 내가 헥사고날 아키텍처를 설계해 보며 배운 점을 정리해 봤다.
📌 서론
지금까지 3번 정도 헥사고날 아키텍처를 적용시켜서 프로젝트를 만들어봤다.
첫 번째 설계에서는 왜 port 같은 것을 사용해서 이렇게 복잡하고 어렵게 설계하는 거지? 이런 생각이 많이 들었다. 원리를 이해하지 못하고 단순히 이게 좋다니까 해봐야겠다! 이런 생각을 하면서 개발을 진행했었던 것 같다. 관련된 자료를 열심히 찾아보기도 했지만 사실 제대로 알지 못하고 개발을 진행했다.
이렇게 원리를 잘 모르고 일단 개발해 보자는 마인드로 개발을 진행한 후 다시 한번 돌아보니 도메인 내부에 비즈니스 로직을 넣어두는 중요성을 놓치고 있었다. 그래서 서비스의 로직이 엄청 커지고 패키지도 많고 클래스도 많다 보니 오히려 이전과 큰 차이는 없으면서 관리하기만 복잡하고 힘들어졌다는 생각을 하게 되었다.
두 번째 설계에서는 그래도 한 번 해봤던 설계라서 이전과는 다르게 더 단단하게 만들겠다는 생각으로 여러 세미나 또는 자료들을 이전보다 더 많이 자세하게 찾아봤는데 확실히 조금 더 이해되는 부분들이 있었다. 그래서 이것들을 미리 테스트하면서 적용해 보고 이전에는 제대로 하지 못했던 도메인 내부에서 비즈니스 로직을 처리하도록 설계했다.
이렇게 개발을 하다 보니 느낀 점이 많았다. 도메인이 비즈니스를 처리하다 보니 역할이 확실히 분리되었고 서비스 로직도 간결해지면서 테스트를 작성하기도 좋았다. 특히 첫 번째 설계에서는 단위테스트를 하는 부분에서 상당히 힘든 부분이 있었는데 이번에는 도메인에서 비즈니스 처리를 하다 보니 단위 테스트가 정말 깔끔하고 작성하기 쉬웠다.
지금 진행 중인 세 번째 설계에서는 이 아키텍처를 사용하는 근본적인 이유에 대해서 많이 생각하게 되었고 큰 깨달음이 있었다. 특히 헥사고날 아키텍처가 왜 외부의 변경에 대해 자유로운지, MSA에서 자주 사용하고자 하는지, 테스트를 작성하기 좋은지 이런 것들이 이전보다 눈에 띄게 보이기 시작했다.
이번 포스트에서는 그 내용들을 공유하고자 한다. 아키텍처를 설계해 보며 배워가는 과정에 적은 내용이라 분명히 잘못된 부분이 있을 것이라고 생각한다. 혹여나 개선을 위해 댓글로 도움을 주시는 분들이 계시다면 감사히 배워가도록 할 생각이다. 시작해 보자 Let's go!
1. 헥사고날 아키텍처의 구조
헥사고날 아키텍처의 구조
- 내가 설계한 헥사고날 아키텍처는 다음과 같다. (아키텍처 구성에는 잘못된 부분이 있을 수 있으니 언제든 개선점을 알려주신다면 감사한 마음으로 새겨듣고 반영시켜 보도록 하겠습니다!)
- 하단의 구조에서 방향성을 보면 중앙의 Domain으로 향하고 있는 것을 알 수 있다. 이것은 로직 자체의 흐름이 아니라 호출 순서에 따른 스프링의 의존성 주입 방향을 화살표로 나타낸 것이다. 예를 들어 헥사고날에서 클래스(구현체) 사용은 controller -> service -> adapter 순서로 가겠지만 이 클래스들이 useCase , port라는 인터페이스를 구현하고 사용하기 때문에 스프링에서 인터페이스를 호출하면 발생하는 의존성 역전의 방향성을 화살표로 나타내면 아래 그림처럼 된다.
- 조금 더 자세히 설명하자면 로직 자체의 흐름은 당연히 "api 호출 -> 서비스 처리 -> DB 통신" 이런 방식이겠지만 각 클래스에서 주입받아 사용하는 의존성 클래스들은 추상화된 인터페이스를 사용하기에 서버가 실행되면 스프링이 빈을 등록하면서 직접 구현체를 찾아서 인터페이스에 주입시켜 준다. [IOC: 제어의 역전(스프링(프레임워크)이 제어), DI: 의존성 주입(스프링 빈을 생성할 때, 그 빈이 필요로 하는 의존성(다른 빈들)이 있다면, 스프링이 자동으로 그 의존성을 찾아서 주입해 준다.)]
- 그리고 모든 클래스 내부에 선언된 의존성이 추상화된 인터페이스를 바라보고 사용하도록 설계하기 때문에 구현체인 Service나 PersistenceAdapter 클래스에서는 의존성 역전 원칙(DIP)이 발생해서 이렇게 화살표를 표시한 것이다.
헥사고날 아키텍처 SpringBoot 패키지 Tree
- 가장 크게 3가지의 root 패키지를 구성했다. (adapter, application, domain)
- 이 3개의 큰 root 패키지 내부에서 in, out, port, service, command 등등 많은 패키지로 분리된다. 하단의 tree를 확인해 보는 것이 이해하는데 도움이 될 것이다. (구조는 잘못되었을 수도 있으니 언제든 조언해 주세요!)
src
└── main
└── java
└── com
└── example
├── adapter
│ ├── in
│ │ ├── web
│ │ │ ├── controller
│ │ │ │ ├── OrderController.java
│ │ │ │ └── CustomerController.java
│ │ │ └── dto
│ │ │ ├── OrderRequest.java
│ │ │ ├── OrderResponse.java
│ │ │ ├── CustomerRequest.java
│ │ │ └── CustomerResponse.java
│ ├── out
│ │ ├── persistence
│ │ │ ├── entity
│ │ │ │ ├── OrderEntity.java
│ │ │ │ └── CustomerEntity.java
│ │ │ ├── repository
│ │ │ │ ├── OrderRepository.java
│ │ │ │ └── CustomerRepository.java
│ │ ├── OrderRepositoryAdapter.java
│ │ ├── CustomerRepositoryAdapter.java
├── application
│ ├── port
│ │ ├── in
│ │ │ ├── OrderUseCase.java
│ │ │ └── CustomerUseCase.java
│ │ └── out
│ │ ├── OrderRepositoryPort.java
│ │ └── CustomerRepositoryPort.java
│ ├── service
│ │ ├── OrderService.java
│ │ └── CustomerService.java
│ └── command
│ ├── CreateOrderCommand.java
│ ├── UpdateOrderCommand.java
│ ├── CreateCustomerCommand.java
│ └── UpdateCustomerCommand.java
├── domain
│ ├── model
│ │ ├── Order.java
│ │ └── Customer.java
├── config
│ ├── AppConfig.java
│ └── SecurityConfig.java
├── util
│ └── DateUtils.java
├── exception
│ ├── OrderNotFoundException.java
│ └── CustomerNotFoundException.java
└── mapper
├── OrderMapper.java
└── CustomerMapper.java
헥사고날 아키텍처 요청의 흐름도
2. 도메인 영역
엔티티와 도메인 객체의 연관성
- 도메인 영역에 대해 이해하기 위해서는 엔티티와 도메인의 관계에 대해서 조금은 알아야 한다고 생각한다. 왜냐하면 헥사고날 아키텍처에서는 지속적으로 도메인을 엔티티로 변환하는 과정이 있기 때문이다. (이번 포스팅에서의 설명은 JPA를 기준으로 도메인을 엔티티로 변환하는 예시다. 만약 Mongo면 document , Redis면 RedisHash로 변환 작업을 해주면 된다.)
- 만약 DDD(도메인 주도 설계)로 개발하게 된다면 Aggregate라는 용어가 굉장히 많이 나오는데 일단 나는 이것은 고려하지 않고 엔티티와 도메인을 1:1 매핑시키도록 해서 설명을 진행할 예정이다. (이 부분은 아직 공부 중이라 개념이 잡히면 추후 글로 작성하겠습니다!)
엔티티 작성하기 (회원 엔티티 생성)
- 엔티티 내부에는 비즈니스 로직을 작성하지 않는다.
/**
* 회원 엔티티
*/
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor
@Getter
@Entity
@Table(name = "member")
public class MemberEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // PK
@Column(unique = true, nullable = false)
private String email; // 이메일
@Column(name = "password")
private String password; // 소셜 로그인 사용자는 NULL일 수 있음
@Column(name = "name")
private String name; // 이름
@Column(name = "nickname")
private String nickname; // 닉네임
// ID로 MemberEntity 객체를 생성하는 정적 팩토리 메서드
public static MemberEntity of(Long id) {
return MemberEntity.builder()
.id(id)
.build();
}
}
도메인 객체 작성하기 (도메인에서 외부로 향하는 어떤 의존성도 없어야 한다.)
- 도메인 순수성 유지:
- 도메인 계층은 비즈니스 로직을 담당하는 가장 중요한 부분이며, 외부 기술(예: JPA, 데이터베이스, 프레임워크)과 독립적으로 유지되어야 한다. 이는 도메인 모델이 어떤 외부 의존성에도 영향을 받지 않도록 하기 위함이다.
- 도메인 계층은 비즈니스 로직을 담당하는 가장 중요한 부분이며, 외부 기술(예: JPA, 데이터베이스, 프레임워크)과 독립적으로 유지되어야 한다. 이는 도메인 모델이 어떤 외부 의존성에도 영향을 받지 않도록 하기 위함이다.
- 외부 의존성 제거:
- 도메인 객체는 순수한 자바 객체(POJO, Plain Old Java Object)로 선언해야 하며, 외부 의존성(JPA 엔티티, 프레임워크 특정 클래스 등)을 포함하지 않아야 한다. 예를 들어, 도메인 객체가 JPA 엔티티를 필드로 가지게 되면, JPA 엔티티의 변화가 발생할 때 도메인 객체도 변경되어야 할 수 있다. 이는 도메인 계층이 외부 기술에 종속될 위험을 높이게 된다.
- 도메인 객체는 순수한 자바 객체(POJO, Plain Old Java Object)로 선언해야 하며, 외부 의존성(JPA 엔티티, 프레임워크 특정 클래스 등)을 포함하지 않아야 한다. 예를 들어, 도메인 객체가 JPA 엔티티를 필드로 가지게 되면, JPA 엔티티의 변화가 발생할 때 도메인 객체도 변경되어야 할 수 있다. 이는 도메인 계층이 외부 기술에 종속될 위험을 높이게 된다.
- 도메인 로직의 독립성:
- 도메인 계층은 외부 시스템이나 인프라스트럭처의 변화에 영향을 받지 않고, 순수한 비즈니스 로직만을 처리해야 한다. 이를 통해 도메인 로직의 변경이 필요할 때 외부 요소와의 결합 없이도 쉽게 변경할 수 있다.
/**
* 회원 도메인 객체
*/
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Member {
private Long id; // PK
private String email; // 이메일
private String password; // 소셜 로그인 사용자는 NULL일 수 있음
private String name; // 이름
private String nickname; // 닉네임
// factory method
public static Member of(long id) {
return Member.builder()
.id(id)
.build();
}
/**
* @apiNote 이름을 변경하는 메서드
* @param newName 새로운 이름
*/
public void changeName(String newName) {
// 추가 로직이나 검증을 여기에 포함할 수 있음
this.name = newName;
}
/**
* @apiNote 닉네임을 변경하는 메서드
* @param newNickname 새로운 닉네임
*/
public void changeNickname(String newNickname) {
// 추가 로직이나 검증을 여기에 포함할 수 있음
this.nickname = newNickname;
}
}
엔티티와 도메인은 무슨 차이가 있을까?
- 엔티티는 주로 데이터베이스와의 상호작용을 담당하는 객체로, 데이터베이스 테이블에 매핑되고 CRUD 작업을 수행한다. 반면, 도메인 객체는 비즈니스 로직을 담고 있는 객체로, 비즈니스 규칙과 도메인 개념을 표현하는 데 집중하게 된다.
- 예를 들어, 지금 코드 구성을 보면 엔티티에는 데이터베이스와의 통신을 위한 필드와 메서드가 주로 정의되며, 비즈니스와 관련된 로직은 포함되지 않는다. 반면, 도메인 객체는 "이름 변경", "닉네임 변경"과 같은 비즈니스 로직을 수행하는 메서드를 포함하고 있다.
이렇게 각 객체의 역할을 분리함으로써 다음과 같은 장점을 얻는다.
- 엔티티 객체는 데이터 저장소와의 상호작용에만 집중하게 되고,
- 도메인 객체는 비즈니스 규칙을 적용하고 유지하는 역할에 집중할 수 있다.
- 이로 인해 시스템의 설계가 더 명확해지고, 각 객체가 책임지는 역할이 분명해진다.
설계에서 배운 점은 도메인과 엔티티를 사용하면 각각의 역할이 명확해진다는 것이다.
- 도메인 객체는 비즈니스 규칙과 로직을 중심으로 설계되므로, 비즈니스의 핵심을 이해하고자 한다면 도메인 객체를 분석하는 것만으로도 이 도메인이 어떤 비즈니스를 수행하는지 명확히 파악할 수 있다.
- 그 결과, 코드의 응집도가 높아지고 시스템 설계가 더 명확해졌다고 느껴졌다.
3. 서비스 영역
헥사고날에서 서비스란 무엇인가? 지속적인 고민을 했고 이런 생각에 도달했다.
- 조정자(Coordinator): 서비스가 다양한 외부 의존성과 도메인 객체 간의 상호작용을 조정하는 역할을 한다.
- 조합자(Orchestrator): 서비스가 여러 작업을 조합하여 최종 결과를 만들어내는 역할을 한다.
서비스 코드를 보면서 설명하도록 하겠다.
- 이 서비스 메서드의 로직을 순서대로 확인해 보자.
/**
* @return 가입한 회원정보 DTO
* @Param signUpCommand 회원가입 요청 도메인
* @apiNote 회원 생성
*/
@Transactional
@Override
public MemberResponseDTO signUp(SignUpCommand signUpCommand) {
// 1. 회원가입 요청 도메인을 생성
Member member = memberMapper.commandToDomain(signUpCommand);
// 2. 비밀번호 암호화
member.changePassword(passwordEncoder.encode(member.getPassword()));
// 3. 회원 저장
Member savedMember = createMemberPort.createMember(member);
// 4. DTO로 변환해서 반환
return memberMapper.domainToResponseDTO(savedMember);
}
한 줄씩 이해해 보기
1. 가장 먼저 매개변수로 받은 SignUpCommand 객체를 Member 도메인 객체로 변환한다. (변환 작업)
// 1. 회원가입 요청 도메인을 생성
Member member = memberMapper.commandToDomain(signUpCommand);
2. Member 도메인의 "비밀번호 암호화" 로직을 호출한다. (비즈니스 로직 실행)
// 2. 비밀번호 암호화
member.changePassword(passwordEncoder.encode(member.getPassword()));
3. port로 Member 도메인을 전송해서 저장을 요청한다. (데이터 저장 요청)
// 3. 회원 저장
Member savedMember = createMemberPort.createMember(member);
4. 저장된 Member 도메인을 DTO객체로 변환한다. (변환 작업)
// 4. DTO로 변환해서 반환
return memberMapper.domainToResponseDTO(savedMember);
알 수 있는 점
- 서비스가 하고 있는 일은 다음과 같다. "변환 -> 도메인의 비즈니스 로직 호출 -> port로 저장 요청 -> 변환" 서비스 내부에서는 직접적으로 비즈니스 로직을 처리하지 않는다. 비즈니스 로직은 도메인에서 처리하게 된다. 그렇다 보니 서비스의 코드가 확연히 줄어들었고 가독성이 훨씬 좋아졌다.
- 즉, 서비스 메서드에서는 비즈니스 처리가 완료된 도메인 객체를 사용하여 데이터를 저장, 조회하거나 이 작업을 위해 필요한 객체를 생성하거나 변환하는 작업을 주로 처리한다. 이것은 마치 중앙에서 "조정, 조합"을 하는 것처럼 생각되었다. (이것은 제 경험을 기반으로 느낀 점을 적은 것이라 잘못된 지식일 수 있습니다.)
4. 어댑터 IN
어댑터의 IN이 무엇인지 현실적인 비유를 통해 알아보자
- Controller에서 "port. 스마트폰 충전()" 메서드를 호출하는 것은, 현실에서 스마트폰 충전을 위해 어댑터를 포트에 꽂는 것과 같다.
- 현실에서는 어댑터를 포트에 맞춰 꽂으면, 그 규격에 맞는 전류(110v, 220v)가 충전기로 전달된다. 개발적으로는, 스프링이 호출된 어댑터(in)에서 필요로 하는 useCase의 구현체를 자동으로 찾아 "의존성 역전 원칙"에 따라 주입해 준다.
코드를 통해 알아보자
나는 web controller가 사용할 AuthUseCase를 선언해 줬다.
- useCase는 Command 객체를 받도록 설계했다. (구현체에서 Command객체를 Domain으로 변환해서 사용한다.)
/**
* useCase에서는 Command를 받아서 도메인을 반환한다.
*/
public interface AuthUseCase {
MemberResponseDTO signUp(SignUpCommand signUpCommand);
}
web controller 클래스에서는 UseCase를 주입받아서 사용한다.
- 컨트롤러는 요청을 DTO로 받고 메서드에서 DTO를 Command 객체로 변환시켜 준다.
/**
* 회원 인증 관련 API 컨트롤러
*/
@RequestMapping("/member/auth")
@RequiredArgsConstructor
@RestController
public class AuthController {
private final AuthUseCase authUseCase;
/**
* @param signUpRequestDTO 회원가입 요청 DTO
* @return 회원가입 응답 DTO
* @apiNote 회원가입 요청을 받아 회원가입을 진행합니다.
*/
@PostMapping("/signUp")
public ResponseEntity<ApiResponse<MemberResponseDTO>> signUpAndPublishEvent(
@RequestBody MemberSignUpRequestDTO signUpRequestDTO
) {
SignUpCommand signUpCommand = SignUpCommand.of(signUpRequestDTO);
MemberResponseDTO responseDTO = authUseCase.signUp(signUpCommand);
return ResponseEntity.ok(ApiResponse.success(responseDTO));
}
}
Command 클래스는 다음과 같이 선언했다.
- Command 클래스는 애플리케이션 계층에서 사용되는 객체로, 외부에서 전달된 데이터를 캡슐화하여 특정 유스 케이스(Use Case) 또는 애플리케이션 서비스에서 처리할 수 있는 형태로 변환하는 역할을 한다.
- 이를 통해 DTO(Data Transfer Object)와 같은 외부 데이터를 내부의 도메인 모델이나 유스 케이스로 전달하기 전에 필요한 추가적인 작업(예: 데이터 검증, 문자열 조작 등)을 수행할 수 있다.
- 쉽게 생각하려면 이 Command 클래스는 useCase를 사용하기 위한 조작 "커맨드"라고 생각하면 된다.
@Builder
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class SignInCommand {
private String email;
private String password;
// factory method
public static SignInCommand of(SignInRequestDTO signInRequestDTO) {
// 여기서 필요한 데이터를 추가하거나 조작할 수 있다. (command)
return SignInCommand.builder()
.email(signInRequestDTO.getEmail() + "data 추가")
.password(signInRequestDTO.getPassword())
.build();
}
}
Service 클래스는 useCase 인터페이스를 구현한다.
- (서비스 설명은 위에서 확인하자) 여기서는 클래스를 살펴봐야 한다. implements로 AuthUseCase를 받고 있다.
- 이렇게 useCase를 구현하고 있으면서 스프링 빈으로 등록되어 있다면 스프링이 서버를 실행할 때 컨트롤러에 있는 useCase 인터페이스에 이 구현체를 주입시켜 준다. (다만 같은 인터페이스를 구현하는 여러 개 구현체가 있으면 빈 등록을 조심해야 한다. 주입하는 과정에서 문제가 생길 수도 있다.)
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class AuthService implements AuthUseCase {
private final CreateMemberPort createMemberPort;
private final MemberMapper memberMapper;
/**
* @return 가입한 회원 정보 DTO
* @Param signUpCommand 회원가입 요청 도메인
* @apiNote 회원 생성
*/
@Transactional
@Override
public MemberResponseDTO signUp(SignUpCommand signUpCommand) {
// 1. 회원가입 요청 도메인을 생성
Member member = memberMapper.commandToDomain(signUpCommand);
// 2. 비밀번호 암호화
member.changePassword(passwordEncoder.encode(member.getPassword()));
// 3. 회원 저장
Member savedMember = createMemberPort.createMember(member);
// 4. 저장된 회원 도메인을 응답 DTO로 변환
return memberMapper.domainToResponseDTO(savedMember);
}
}
5. 어댑터 OUT
어댑터의 OUT이 무엇인지 현실적인 비유를 통해 알아보자
- Service는 도메인을 활용해서 비즈니스 로직을 마친 후에 특정 작업(예: 상품 배송)을 수행하기 위해 port를 호출한다. 이는 현실에서 고객의 요청을 받아 물건을 배송하기 위해 택배 회사를 부르는 것과 유사하다.
- port는 스프링 프레임워크를 통해 요청을 전달하며, 스프링은 이 요청을 처리하기 위해 적절한 구현체를 선택한다. 이는 택배 회사가 물건을 전달할 택배 기사를 지정하는 것에 비유할 수 있다.
- 스프링은 이 요청을 처리할 persist adapter라는 구현체를 자동으로 주입한다. 이 persist adapter는 실제로 외부 시스템과 상호작용하여 요청된 작업을 수행한다. 이는 택배 기사가 실제로 물건을 받아 고객에게 전달하는 과정과 비슷하다.
- 마지막으로, 주입된 persist adapter는 외부 시스템과 상호작용하여 필요한 작업(예: 데이터 저장, API 호출 등)을 수행한다. 이 과정은 택배 기사가 물건을 받아 고객에게 안전하게 전달하는 것과 유사하다.
코드를 통해 알아보자
외부와의 소통에 사용할 out port 인터페이스를 선언한다.
public interface CreateMemberPort {
Member createMember(Member member);
}
port 인터페이스를 구현하는 PersistenceAdapter 클래스를 선언한다.
- JPA와 RDB를 사용할 때를 기준으로 작성했다. (mongo, redis, elastic, kafka 등 필요한 것을 따로 작성하면 된다.)
@RequiredArgsConstructor
@Component
public class MemberPersistenceAdapter implements CreateMemberPort, FindMemberPort, DeleteMemberPort, UpdateMemberPort {
private final MemberRepository memberRepository;
private final MemberMapper memberMapper;
/**
* @apiNote 회원 생성
* @param member 회원 도메인
* @return 생성된 회원
*/
@Override
public Member createMember(Member member) {
MemberEntity memberEntity = memberMapper.toEntity(member);
MemberEntity savedMember = memberRepository.save(memberEntity);
return memberMapper.toDomain(savedMember);
}
}
한번 더 서비스의 로직을 살펴보자. 서비스에서는 도메인으로 비즈니스를 마친 후 port를 호출해서 회원 정보를 저장시킨다.
- 이때 port에는 스프링이 주입해 준 persistenceAdapter 구현체가 주입되어 저장 로직을 수행하게 된다.
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class AuthService implements AuthUseCase {
private final CreateMemberPort createMemberPort;
private final MemberMapper memberMapper;
/**
* @return 가입한 회원 정보 DTO
* @Param signUpCommand 회원가입 요청 도메인
* @apiNote 회원 생성
*/
@Transactional
@Override
public MemberResponseDTO signUp(SignUpCommand signUpCommand) {
// 1. 회원가입 요청 도메인을 생성
Member member = memberMapper.commandToDomain(signUpCommand);
// 2. 비밀번호 암호화
member.changePassword(passwordEncoder.encode(member.getPassword()));
// 3. 회원 저장 (여기서 out port를 호출한다.)
Member savedMember = createMemberPort.createMember(member);
// 4. 저장된 회원 도메인을 응답 DTO로 변환
return memberMapper.domainToResponseDTO(savedMember);
}
}
6. 무언가를 배웠는가?
이 아키텍처를 계속하면서 느낀 점은 이 아키텍처는 SOLID 원칙을 준수하기 위한 아키텍처라는 것이다.
SRP (단일 책임 원칙)
- 헥사고날 아키텍처에서는 포트, 어댑터, 도메인 등이 각기 다른 역할을 수행하며, 단일 책임을 가지고 있다. 예를 들어, 어댑터는 외부와의 통신을 담당하고, 도메인 계층은 비즈니스 로직을 담당한다. 이렇게 책임이 명확하게 분리되어 있다.
OCP (개방 폐쇄 원칙)
- 기존 코드를 변경하지 않고 확장할 수 있다. 예를 들어, 새로운 외부 서비스와 통신하는 기능을 추가해야 한다고 가정해 보자. 이 경우, 기존 도메인 로직을 변경할 필요 없이, 새로운 out 어댑터를 구현하여 포트 인터페이스에 연결하면 된다. 도메인 로직은 그대로 유지되며, 새로운 기능이 추가될 수 있다는 것이다. 이는 OCP의 "변경에는 닫혀 있어야 한다"는 부분을 잘 지키는 구조다.
LSP(리스코프 치환 원칙)
- 헥사고날 아키텍처는 인터페이스(포트)를 통해 구현체(어댑터)를 주입받는 구조로, 특정 포트를 대체할 수 있는 다양한 어댑터를 쉽게 교체할 수 있다. 이는 구현체가 인터페이스의 계약을 준수하도록 하여, 대체 가능성을 보장한다는 것이다. 즉, LSP를 준수하여 구현체가 항상 인터페이스를 대체할 수 있도록 설계된다.
ISP(인터페이스 분리 원칙)
- 헥사고날 아키텍처에서는 인터페이스가 작고 명확한 역할을 수행하도록 설계된다. 포트 인터페이스는 특정 기능에 필요한 메서드들만 정의하며, 불필요한 의존성을 강제하지 않는다. 이는 인터페이스 분리 원칙을 지켜, 클라이언트가 자신에게 필요한 기능만을 제공받을 수 있도록 하는 것이다.
DIP(의존 역전 원칙)
- 헥사고날 아키텍처에서 의존성 역전 원칙(DIP)은 Controller가 구체적인 구현체가 아닌 추상화된 인터페이스(UseCase)에 의존하도록 함으로써 실현된다.
- 구체적으로, Controller는 특정 비즈니스 로직을 수행하기 위해 UseCase 인터페이스를 호출한다. 하지만 Controller는 실제로 어떤 구현체가 그 UseCase 인터페이스를 구현하는지 알 필요가 없다. 대신, 스프링 프레임워크가 제어권을 가져가서, Controller가 의존하는 UseCase 인터페이스에 맞는 구체적인 서비스 구현체를 주입해 준다.
- 이 과정이 바로 의존성 역전 원칙을 구현하는 방법이다. 즉, 상위 모듈인 Controller는 하위 모듈인 구체적인 서비스 구현체에 의존하는 게 아니라 추상화된 UseCase 인터페이스에 의존하고, 구체적인 구현체는 스프링이 관리하고 주입한다. 이로 인해 Controller는 특정 구현체에 대한 의존성을 가지지 않고, 보다 유연하고 변경에 강한 구조를 갖게 된다.
- 이것은 controller뿐만 아니라 out port에도 적용되어 service는 인터페이스인 port를 의존하고 스프링이 persistenceAdapter를 DIP로 주입한다.
헥사고날 아키텍처는 테스트가 용이하다.
1. 단위 테스트(Unit Testing)
- 포트와 어댑터의 분리: 헥사고날 아키텍처는 포트(인터페이스)와 어댑터(구현체)를 명확히 분리한다. 이 구조 덕분에 각 구성 요소(예: UseCase, 도메인 로직 등)를 독립적으로 테스트할 수 있다.
- 테스트 대역(Mock): 포트를 모킹(Mock)하여, 실제로 외부 시스템에 접근하지 않고도 도메인 로직이나 UseCase를 테스트할 수 있다. 이는 테스트를 빠르게 실행하고, 외부 의존성에 대한 복잡성을 제거하여 테스트의 신뢰성을 높인다.
2. 통합 테스트(Integration Testing)
- 모킹과 실제 구현체의 혼합: 테스트를 할 때, 일부 어댑터는 실제 구현체로 테스트하고, 다른 어댑터는 모킹 된 구성 요소로 대체할 수 있다. 예를 들어, 외부 API와의 상호작용은 모킹(Mock)하고, 데이터베이스와의 상호작용은 실제로 테스트할 수 있다. 이는 전체 시스템을 테스트할 때, 실제 환경을 일부 재현하면서도, 테스트의 범위를 제어할 수 있게 해 준다.
- 전체 흐름 테스트: 필요에 따라 모든 어댑터(예: 데이터베이스, 메시지 브로커, 외부 API 등)를 실제 구현체로 연결하여, 전체 시스템의 흐름을 통합적으로 테스트할 수도 있다. 이때도 각 어댑터가 독립적으로 구현되어 있기 때문에, 어떤 부분만 교체하거나 모킹 하는 것도 쉽다.
- 독립적인 테스트: 헥사고날 아키텍처의 독립적인 포트와 어댑터 덕분에, 통합 테스트에서 이들을 하나씩 조합해 가면서 시스템의 동작을 확인할 수 있다. 예를 들어, 데이터베이스 어댑터와 메시지 브로커 어댑터를 함께 테스트할 수 있다.
'Spring MSA' 카테고리의 다른 글
이벤트 소싱과 CQRS, 넌 대체 정체가 뭐냐? (0) | 2024.11.23 |
---|---|
[MSA] Transactional Outbox Pattern (1) | 2024.10.13 |
MSA 서버 간 통신: SNS의 MessageAttributes로 완벽한 Zeropayload 전략 구현하기 (1) | 2023.12.01 |
MSA 환경에서 SNS 메시지 재발행을 위한 스프링 배치 및 스케쥴러 구현 (1) | 2023.11.28 |
Spring MSA 프로젝트에서 단일 책임 원칙을 지키기 위한 리팩토링 (3) | 2023.11.24 |