시작하며
안녕하세요. 개발자 stark입니다!
오늘은 제가 헥사고날 아키텍처를 하면서 항상 고민하던 것을 정리해 봤습니다. 그 내용은 바로 in/out port 인터페이스 메서드 시그니처를 작성할 때 매개변수로 dto, 도메인, 기본 타입 중에 어떤 것이 가장 적절한가?입니다. 저는 여러 타입의 매개변수를 다 적용해 봤는데도 어떤 것을 써야 할지 감이 제대로 잡히지 않았는데 이번 기회에 다시 분석하고 정리하며 제 나름의 기준점을 잡았고 그 내용을 적어봤습니다. 이번에는 제가 겪은 과정 자체를 맛있게 담지는 못했지만 결론은 담아두었으니 다들 재미있게 봐주셨으면 좋겠습니다. Let's go!
In port(UseCase)의 매개변수는 DTO보다 Command
헥사고날 아키텍처에서는 애플리케이션의 각 계층이 자기 역할만 명확히 수행하도록 엄격히 분리해야 합니다. 먼저 웹 어댑터, 즉 컨트롤러 계층은 HTTP 요청과 응답의 형식에만 관심을 가져야 합니다. 클라이언트가 JSON으로 보내는 UserCreateRequestDto 같은 웹 전용 DTO를 바인딩해서 받은 뒤, 바로 도메인 언어로 정의된 CreateUserCommand 객체로 변환해서 애플리케이션 서비스(UseCase)를 호출합니다. 이때 컨트롤러는 비즈니스 로직이나 데이터베이스 접근에 대한 지식이 전혀 없어야 하며, 오직 “웹 DTO ↔ 애플리케이션 커맨드/응용 DTO” 간 매핑만 담당합니다.
참고로 Line 테크 블로그의 글 내용 중에서는 웹 컨트롤러에서 받은 UserCreateRequestDto를 곧바로 in port에 전달한다면, 나중에 다른 어댑터(예: RPC, CLI 등)에서 동일한 포트를 사용하려 할 때 문제가 생긴다는 점을 정리해 주셨습니다. RPC 어댑터(컨트롤러)도 UserCreateRequestDto를 만들어서 호출해야 한다면 이는 불합리하며, 결국 DTO 변경이 도메인 계층과 다른 어댑터들까지 전파되는 강한 결합을 초래합니다.
그렇다면 Command 객체는 in port(UseCase)에 사용해도 될까요? 여기서는 두 가지 맥락을 구분해야 합니다. 만약 Command 객체가 애플리케이션 계층의 Use Case를 표현하는 요청 모델이라면 (예: CreateUserCommand 같은 클래스), 이것은 DTO와 달리 도메인 계층 내부의 타입으로 간주할 수 있습니다. 이런 Command 객체는 도메인 계층에서 정의된 것이므로, in port 인터페이스에서 사용해도 문제없습니다. 오히려 많은 파라미터를 전달하는 대신 하나의 의도를 가진 객체로 전달하니 앞서 말한 Parameter Object 리팩토링 방식과 비슷한 이점이 있습니다.
@RestController
@RequestMapping("/users")
public class UserController {
private final CreateUserUseCase createUserUseCase;
public UserController(CreateUserUseCase createUserUseCase) {
this.createUserUseCase = createUserUseCase;
}
@PostMapping
public ResponseEntity<UserResponseDto> createUser(
@RequestBody @Valid UserCreateRequestDto dto
) {
CreateUserCommand cmd = UserWebMapper.toCommand(dto);
UserDto userDto = createUserUseCase.createUser(cmd);
UserResponseDto response = UserWebMapper.toResponse(userDto);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
애플리케이션 서비스, 즉 UseCase 구현체는 응용 서비스로의 역할(조율)을 하게 됩니다. 컨트롤러가 전달한 CreateUserCommand에는 웹 스펙이나 JSON 어노테이션 같은 것이 전혀 섞여 있지 않고, 오직 도메인이 필요로 하는 데이터와 검증 로직만 들어 있습니다. 응용 서비스 계층에서는 이 커맨드를 사용해서 User 도메인 객체를 생성·검증하고 도메인 비즈니스를 수행한 뒤 out port 인터페이스를 통해 저장소 어댑터를 호출하여 영속화합니다.
public interface CreateUserUseCase {
UserDto createUser(CreateUserCommand command);
}
@Transactional
@Service
public class CreateUserService implements CreateUserUseCase {
private final UserRepositoryPort userRepository;
public CreateUserUseCaseImpl(UserRepositoryPort userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDto createUser(CreateUserCommand command) {
User user = User.create(command.getName(), command.getEmail());
User saved = userRepository.save(user);
return new UserDto(saved.getId().getValue(), saved.getName(), saved.getEmail());
}
}
참고로 저장소 어댑터(Persistence Adapter)는 out port의 구현 클래스로 내부에서는 JPA 엔티티나 QueryDSL 코드를 사용해 데이터를 저장하거나 조회하지만, 이것은 응용 서비스와 도메인 계층에 전혀 노출되지 않습니다. 서비스는 저장소가 반환한 도메인 객체를 UserDto라는 “애플리케이션 전용 DTO”로 변환해 컨트롤러에 리턴할 뿐입니다.
여기서 새로운 개념인 애플리케이션 전용 DTO가 나오는데 이 DTO는 도메인 관점에서 클라이언트에게 꼭 전달해야 할 필드만 담고 있으며, 웹 전용 필드나 어노테이션은 없는 DTO 객체입니다. 컨트롤러는 return값으로 UserDto(애플리케이션 전용)를 받아서 최종적으로 클라이언트에 응답할 UserResponseDto로 매핑합니다.
이 단계에서 웹 전용 어노테이션(@JsonProperty 등)이나 HATEOAS 링크, 공통 응답 래퍼(CommonResponse 등) 같은 HTTP 스펙에 필요한 메타데이터를 추가할 수 있습니다. 이렇게 하면 웹 스펙이 바뀌더라도 애플리케이션 서비스와 도메인 로직은 전혀 건드릴 필요가 없고, 반대로 도메인 모델이 진화하더라도 컨트롤러와 웹 DTO만 적절히 수정하면 됩니다.
Out port의 매개변수에는 도메인의 개념을 담자
헥사고날 아키텍처에서 '포트 인터페이스'는 도메인 관점의 계약을 나타내는 것이 중요합니다.
도메인 관점의 계약이란, 애플리케이션의 핵심 로직(도메인 모델)이 “무엇을” 얼마나, 어떤 방식으로 수행해야 하는지를 순수한 비즈니스 용어로 정의한 인터페이스를 말합니다. 예를 들어 OrderUseCase 포트 인터페이스가 createOrder(주문정보)나 cancelOrder(주문 ID) 같은 메서드 시그니처로만 구성되는 것처럼, 실제로 내부 비즈니스 구현에서 어떤 기술을 쓰는지는 신경 쓸 필요가 없습니다. 단지 도메인이 필요로 하는 업무 행위와 입력·출력의 형태만 계약으로 제시하면 됩니다. 이렇게 하면 도메인 개발자는 비즈니스 규칙에만 집중할 수 있고, 기술 스택을 바꾸거나 테스트할 때도 인터페이스만 지키면 되므로 시스템 전체의 유연성과 유지보수성이 높아집니다.
따라서 포트 내부에 선언된 메서드 시그니처의 인자로 도메인 객체(Aggregate)나 도메인 개념을 담은 값 객체(Value Object)를 사용하는 것이 권장됩니다. 예를 들어, 도메인 주도 설계(DDD)의 Repository 패턴에서는 save()나 add() 메서드가 도메인 엔티티를 인자로 받아 저장하도록 합니다.
아래 github에 있는 Vaughn Vernon의 DDD 예제 코드를 살펴보면 CalendarRepository 인터페이스의 save 메서드가 Calendar 도메인 객체를 직접 받도록 정의되어 있습니다. (iddd_collaboration 패키지의 코드 확인하기!) 그 이유는 저장소(Repository)의 책임이 도메인 객체를 영속화하는 것이며, 이미 유효성 검증이 끝난 도메인 객체만을 인자로 받는 것이 바람직하기 때문입니다.
public interface CalendarRepository {
// 코드 생략
public void save(Calendar aCalendar);
}
// 위의 인터페이스를 구현하는 코드에서는 아래와 같이 사용됩니다.
@Override
public void save(Calendar aCalendar) {
EventStreamId eventId =
new EventStreamId(
aCalendar.tenant().id(),
aCalendar.calendarId().id(),
aCalendar.mutatedVersion());
this.eventStore().appendWith(eventId, aCalendar.mutatingEvents());
}
https://github.com/VaughnVernon/IDDD_Samples
GitHub - VaughnVernon/IDDD_Samples: These are the sample Bounded Contexts from the book "Implementing Domain-Driven Design" by V
These are the sample Bounded Contexts from the book "Implementing Domain-Driven Design" by Vaughn Vernon: http://vaughnvernon.co/?page_id=168 - VaughnVernon/IDDD_Samples
github.com
대체 왜 유효성 검증이 끝난 객체를 인자로 받아야 하는지에 대한 궁금증이 드실 수도 있는데 그 이유는 유효성 검증이 끝나지 않은 객체를 그대로 저장소(Repository)에 넘기면 저장소가 잘못된 데이터를 DB에 저장할 가능성이 있으며 도메인 규칙에 어긋나는 데이터가 DB에 들어가 버리면, 데이터 신뢰성에 심각한 문제가 발생하기 때문입니다.
Order invalidOrder = Order.of(null, emptyList()); // 유효성 검증되지 않은 상태
orderRepository.save(invalidOrder); // 잘못된 데이터 DB 저장
또한 책임이 혼란스럽게 될 수 있다는 단점도 있습니다. 저장소(Repository)의 책임은 영속화지, 도메인 규칙에 따른 유효성 검사가 아닙니다. 유효성을 검증하는 것은 도메인의 고유 책임입니다. 근데 만약 저장소(Repository)에 이런 유효성 관련 책임을 넣는 순간 책임이 분산되고 코드가 복잡해집니다. 그렇기에 도메인 객체의 생성자나 팩토리 메서드에서 검증 로직을 구현하고, 저장소는 믿고 쓰는 도메인 객체만 받는 게 바람직합니다.
그렇다면 port에 개별 원시 타입 매개변수(Long id, String name...)를 넘긴다면 어떻게 될까요? 이렇게 개별 값을 전달하면 점점 port의 메서드 시그니처가 장황해지고, 도메인 객체의 내부 구조에 의존한 파라미터 나열이 생겨 유지보수가 어려워집니다. 또한 이렇게 개별로 매개변수를 준다고 해도 포트를 구현하는 어댑터 클래스에서 결국 이 값들을 모아 다시 객체를 구성해야 하므로 별다른 이득 없이 복잡도만 증가합니다.
그러므로 도메인 개념을 담은 값 객체(VO)나 도메인 자체(Aggregate)를 port의 매개변수로 사용해서 port 호출 시 데이터가 이미 올바른 형태로 검증된 상태임을 보장하는 것이 좋습니다. 이는 헥사고날 아키텍처의 의도인 “안쪽(core)에서는 바깥세상의 기술 상세를 모르고, 도메인 개념으로만 의사소통”한다는 원칙과 부합합니다.
다만 예외적으로, port 내부에 선언된 메서드가 특정 간단한 값 하나만 필요로 한다면 굳이 전체 도메인 객체를 전달하지 않고 도메인 값 객체로 전달할 수도 있습니다. 예를 들어 “사용자 이름으로 중복 여부 확인” 기능의 포트 메서드는 Username 값 객체(VO) 하나만 인자로 받을 수 있습니다.
핵심은 포트 인터페이스의 시그니처가 최대한 도메인 언어를 표현하도록 만드는 것입니다. 도메인 객체(Aggregate)나 값 객체(VO)를 사용하면 코드의 의미가 분명해지고 구현체(어댑터 클래스)에서도 도메인 규칙을 바로 활용할 수 있다는 장점이 있습니다. 반대로 필드를 풀어서 전달하면 메서드 정의가 길어지고, 도메인 지식이 희석되어 의도가 불명확해질 수 있습니다.
Out port의 매개변수를 캡슐화하는 방법
이제 도메인 개념을 담은 VO를 생성하는 방법을 알아봅시다. 먼저 port 인터페이스의 메서드 시그니처에 8개 이상의 매개변수가 존재한다고 가정해 보겠습니다. 이런 경우 관련된 값들을 하나의 객체로 묶어서 매개변수로 전달한다면 훨씬 깔끔하고 좋을 것입니다. 그리고 이를 가능하게 하는 리팩토링 기법 중 "Parameter Object(매개변수 객체)라는 방법이 존재합니다.
Martin Fowler의 리팩터링에서도, 함수에 함께 전달되는 여러 파라미터들을 하나의 객체로 묶는 것은 코드를 더 이해하기 쉽게 만들고 중복을 줄이는 개선책으로 소개됩니다. 이 리팩토링 기법(Parameter Object)은 자연스럽게 함께 다니는 관련 파라미터들을 한 데 모으라는 것이 핵심입니다. 이 리팩토링 기법을 사용하면 메서드 시그니처가 간결해지고 가독성이 높아집니다. 예를 들어,
List<User> findUsers(String name, Integer age, Gender gender, Boolean active, Date createdAfter, ...);
같이 매개변수를 엄청 길게 나열하는 것보단
List<User> findByCriteria(UserSearchCriteria criteria);
처럼 '검색 조건 객체'를 사용해서 메서드의 정의를 명확하게 표현하는 것이 좋습니다. 이 객체 안에는 이름, 나이, 성별, 활성 여부, 생성일 등의 필드를 포함하고, 필요한 경우 자체적으로 검증이나 조합 로직을 가질 수도 있을 것입니다.
refactoring.guru 사이트에 정리된 내용에 의하면 이렇게 Parameter Object(매개변수 객체)를 사용하면 '코드의 의도'가 드러나도록 이름을 부여할 수 있고, 관련 있는 데이터들을 한 곳에서 관리하게 되어 변경에 유연해진다고 합니다. 또한 새로운 검색 조건이 추가되더라도 port 인터페이스의 메서드 시그니처를 변경하지 않고 객체 내부의 구현만 수정하면 되므로, 인터페이스의 안정성도 높아집니다. 그리고 단순 매개변수를 나열했던 과거와는 달리 Port 인터페이스가 비즈니스 의도를 문서처럼 표현하게 되므로 코드의 가독성과 명료성이 높아집니다.
유의할 점은 매개변수들을 한 객체 내부에 묶는다고 해서 아무 관련 없는 값들을 억지로 한 객체에 넣어서는 안 됩니다. 서로 관련 없는 값들을 한데 모으기만 하면 의미 없는 데이터 덩어리(Data Class)가 될 위험이 있으므로, 응집도 있게 묶을 수 있는 도메인 개념으로 판단되었을 때 사용하는 것이 좋을 것입니다.
예를 들어 여러 필터 조건을 포함하는 UserSearchCriteria는 도메인에서 '사용자 검색 조건'이라는 의미 있는 값 객체(VO)로 볼 수 있습니다. 하지만 단순히 매개변수를 줄이려고 전혀 관련 없는 값들을 한 클래스에 넣는 것은 피해야 합니다.
// Out 포트
public interface UserSearchPort {
List<User> findByCriteria(UserSearchCriteria criteria);
}
// Criteria 객체 (불변 + 검증 로직 포함)
public class UserSearchCriteria {
private final String name;
private final Integer ageFrom;
private final Integer ageTo;
// factory method로 validate(), getters...
}
// 어댑터(인프라 레이어)
@Repository
public class UserPersistenceAdapter implements UserSearchUseCase {
private final JpaUserRepository repository;
@Override
public List<User> findByCriteria(UserSearchCriteria c) {
// c 내부에서 꺼낼 값(name, ageFrom, ageTo)을
// → 파생쿼리, @Query, QueryDSL, Specification 중
// 아무 방식으로든 변환해서 JPA 호출
return repository.findByNameContainingAndAgeBetween(
c.getName(), c.getAgeFrom(), c.getAgeTo()
)
.stream()
.map(mapper::toDomain)
.toList();
}
}
이처럼 Parameter Object 패턴을 쓰면, Port는 UserSearchCriteria만 알고, Adapter가 꺼내서 JPA 리포지토리(또는 QueryDSL 등)를 호출합니다. 즉, Port 인터페이스는 도메인 개념(검색 조건)만 표현하고, 구체 기술은 모두 어댑터로 숨길 수 있어 헥사고날 아키텍처의 핵심 원칙을 지키는 설계가 됩니다.
출처
https://refactoring.guru/introduce-parameter-object#:~:text=Benefits
Introduce Parameter Object
Tired of reading? No wonder, it takes 7 hours to read all of the text we have here. Try our interactive course on refactoring. It offers a less tedious approach to learning new stuff. Let's see…
refactoring.guru
https://engineering.linecorp.com/ko/blog/port-and-adapter-architecture
지속 가능한 소프트웨어 설계 패턴: 포트와 어댑터 아키텍처 적용하기
들어가며 헥사고날 아키텍처(Hexagonal Architecture)로 더 잘 알려져 있는 포트와 어댑터 아키텍처(Ports and Adapters Architecture)는 인터페이스나 기반 요소(infrastructure)의 변경에 영향을 받지 않는 핵심 ..
engineering.linecorp.com
Domain-Driven Design (DDD) and Hexagonal Architecture in Java | Vaadin
In this in-depth guide, learn about DDD (domain-driven design) and how to turn a domain model into working software using a hexagonal architecture.
vaadin.com
'아키텍처' 카테고리의 다른 글
[MSA] Kafka 컨슈머 Inbox 패턴 (0) | 2025.04.07 |
---|---|
이벤트 소싱과 CQRS (0) | 2024.11.23 |
[MSA] Transactional Outbox Pattern (1) | 2024.10.13 |
[Spring] 헥사고날 아키텍처 (2) | 2024.08.10 |
스프링에서 느슨한 결합 만들기: 이벤트 기반 아키텍처 적용 (37) | 2023.12.25 |