MemberRegisterRequest 같은 요청 오브젝트를 어느 계층에 둘지 물으면, 많은 개발자가 반사적으로 “애플리케이션 레이어”라고 답한다. DTO는 데이터를 담아 계층 경계를 넘기는 역할이니 서비스 레이어에 두는 것이 자연스럽다는 이유다. 그런데 헥사고날 아키텍처로 회원 등록 기능을 만들 때, 이 오브젝트를 도메인 레이어에 두었다. 그리고 이 선택은 Q&A에서 가장 많은 질문을 받은 지점이 됐다
결론부터 말하면, 하나였던 MemberRegisterRequest를 도메인 값 객체 MemberRegisterInfo와 애플리케이션 포트 DTO MemberRegisterRequest 둘로 분리했다. 기능은 하나도 바뀌지 않는다. 바뀐 것은 계층 간 결합의 방향과, 앞으로 감당할 변경의 비용이다. 버그 수정이 아니라, 과거의 설계 결정을 트레이드오프로 다시 저울질한 작업이다
처음에 왜 도메인 레이어에 두었나
회원을 등록하려면 이메일, 닉네임, 비밀번호가 필요하다. 이 정보의 묶음은 그 자체로 “회원은 이런 정보로 등록된다”는 도메인 지식을 담고 있다. 식별자로 구분되는 영속화 대상은 아니니 엔티티는 아니고, 값으로만 의미를 갖는 값 객체(Value Object)다. 그래서 도메인 레이어에 두었다
더 결정적인 이유는 의존성 방향이다. 이 오브젝트는 Member 엔티티의 등록 메서드 파라미터로 쓰인다. 계층형 아키텍처에서 의존성은 바깥에서 안으로 향한다. 어댑터는 애플리케이션을, 애플리케이션은 도메인을 의존한다. 만약 파라미터 타입을 애플리케이션 레이어에 두면, 도메인 엔티티가 자기 바깥 계층의 오브젝트를 참조하게 된다. 이것은 계층형 아키텍처의 가장 핵심적인 원칙을 정면으로 위반한다.
[의존성 방향]
Actor → 어댑터 → 애플리케이션 → 도메인
↑
도메인 엔티티가 이 방향을 거슬러
애플리케이션 오브젝트를 참조하면 위반
여기에 원칙 하나가 더 붙는다. 어댑터는 애플리케이션뿐 아니라 도메인도 직접 의존해도 된다. 그렇다면 웹 API로 들어온 등록 요청을 포트로 넘길 때도, 도메인 지식을 담은 이 오브젝트를 어댑터에서부터 그대로 써도 된다는 계산이 선다. 파라미터를 하나씩 나열하면 필드가 늘어날 때마다 시그니처가 부풀어 오르니, 오브젝트 하나로 묶어 전 계층에서 재사용했다. 도입 시점에는 이 단순함이 분명한 이점이었다
하나를 재사용하면서 드러난 문제
문제는 이 하나의 오브젝트가 어댑터의 컨트롤러 파라미터, 애플리케이션 포트의 입력, 도메인 엔티티의 메서드 파라미터까지 거의 전 계층에서 재사용되고 있다는 점이었다.
첫째는 도메인이 상위 계층의 변경에 끌려다닐 위험이다. 회원 등록 API에는 앞으로 도메인과 무관한 정보가 붙기 쉽다. 요청이 웹인지 앱인지 파트너사 시스템인지 구분하는 채널 값, 접속 IP, 로그 분석용 메타데이터 같은 것이다. 이런 값은 애플리케이션 서비스까지는 들어와야 하지만, Member의 등록 메서드까지 갈 이유가 없다. 그런데 오브젝트가 하나뿐이면, 필요할 때 “여기에 필드를 하나 더 넣자”는 압력이 이 오브젝트로 향한다. 도메인 값 객체 안에 도메인 지식이 아닌 필드가 쌓이는 것이다. 지금은 필드 구성이 같아 하나로 충분하지만, 이 결합은 근미래에 도메인 계층을 API 변경에 종속시킬 수 있다
둘째는 이름이다. Request라는 접미사는 보통 외부에 노출하는 API 관점의 표현이다. 웹 API든 애플리케이션 포트든, “이 기능을 쓰려면 이렇게 요청하라”는 맥락에서 쓰인다. 반면 도메인에서 합의한 문장은 “회원을 등록할 때는 이런 정보가 필요하다”였지, “이런 정보로 등록을 요청하라”가 아니었다. 도메인 레벨에서는 무엇으로 이 값을 구성하는가에 대한 정의로 충분하다. Request는 도메인 지식을 표현하는 이름으로는 어울리지 않았다
여기에 실무적인 결합이 하나 더 있었다. 이 오브젝트에는 @Email, @Size 같은 jakarta.validation 애노테이션이 붙어 있었다. 즉 도메인 값 객체가 사실상 외부 입력 검증용 DTO의 성격까지 겸하면서, 검증 규칙 변경까지 도메인 계층으로 흘러들 수 있는 상태였다
트레이드오프의 정확한 의미부터 짚는다
이 지점에서 판단의 도구는 트레이드오프(trade-off)다. 워낙 자주 쓰는 말이라 의미가 뭉개지기 쉬운데, 정의를 정확히 잡고 써야 한다. 트레이드오프는 선택지가 둘 이상 있고, 어떤 선택이 이점을 주는 동시에 다른 가치를 희생시킬 때, 그 득실을 저울질하는 행위다. 따져보니 모든 면에서 좋아서 고르는 것은 트레이드오프가 아니다. 얻는 것과 잃는 것이 함께 있을 때에만 성립한다
『오브젝트』의 저자 조용호님은 「설계 트레이드오프」에서 이 개념을 이렇게 정리한다. 사람들은 트레이드오프를 말할 때 얻는 것에만 집중하고 잃는 것은 충분히 고민하지 않는 경향이 있다는 것이다. “이 설계를 하면 유연성이 좋아진다”는 말에는, 그 유연성이 단순함이나 가독성을 약화시킨다는 사실이 가려져 있다. 그래서 설계를 트레이드오프할 때는 이익보다 손해에 집중하는 편이 현명하고, 손해가 너무 크면 이익이 매력적이어도 그 설계를 택해선 안 된다
이 관점을 이번 결정에 그대로 대입하면 두 선택지의 득실은 이렇게 갈린다
[선택 A] 하나를 전 계층에서 재사용 (현재 상태) 얻는 것: 단순함. 변경 지점이 하나뿐 잃는 것: 도메인이 API 변경에 종속될 위험 [선택 B] 도메인 값 객체와 포트 DTO를 분리 얻는 것: 계층별 독립 진화. 도메인 격리 잃는 것: 겉보기 중복. 변경 시 두 곳과 변환 지점을 손봐야 함
핵심은 잃는 쪽이다. 선택 B의 손해는 “지금 당장 중복처럼 보이고 손댈 곳이 늘어난다”는 것이고, 선택 A의 손해는 “도메인 계층이 서서히 오염된다”는 것이다. 서비스가 커지면서 애플리케이션 레벨의 요구가 도메인 관점의 로직 범위를 넘어설 것이라고 판단한다면, A의 손해가 B의 손해보다 크다. 그렇게 판단했고, 분리를 택했다
무엇을 어떻게 분리했나
분리의 결과는 단순하다. 도메인에는 등록에 필요한 값만 표현하는 값 객체를 두고, 애플리케이션 포트에는 외부 요청을 표현하는 DTO를 둔다. 둘은 지금 필드 구성이 같지만 역할이 다르다
| 구분 | Before | After |
|---|---|---|
| 도메인 값 객체 | domain.member.MemberRegisterRequest | domain.member.MemberRegisterInfo (이름 변경) |
| 포트 요청 DTO | 없음 (도메인 클래스를 그대로 사용) | application.member.provided.MemberRegisterRequest (신규) |
MemberRegisterInfo는 도메인이 실제로 필요로 하는 등록 정보다. Member.register(...)는 이 타입만 알면 된다. MemberRegisterRequest는 외부에서 들어오는 등록 요청이다. @Valid 검증 대상이며 어댑터와 포트 인터페이스가 사용하고, toInfo()로 도메인 값 객체로 변환한다. 검증 애노테이션과 Request 이름은 이제 도메인 바깥에만 존재한다.
변환은 요청 DTO가 책임진다. 의존성 방향을 지키려면 애플리케이션의 DTO가 도메인 값 객체를 알아야 하고, 그 반대는 안 되기 때문이다.
// application/member/provided/MemberRegisterRequest.java (신규)
packagesplearn.application.member.provided;
importsplearn.domain.member.MemberRegisterInfo;
public record MemberRegisterRequest(
@Email String email,
@Size(min = 5, max = 20) String nickname,
@Size(min = 8, max = 100) String password
) {
public MemberRegisterInfo toInfo() {
return new MemberRegisterInfo(email, nickname, password);
}
}
MemberModifyService는 도메인을 호출하기 직전에 이 변환을 끼워 넣는다. 등록 요청이 도메인 경계를 넘는 유일한 지점이 toInfo()로 좁혀진다.
// application/member/MemberModifyService.java Member member = Member.register(registerRequest.toInfo(), passwordEncoder);
테스트를 한 번도 깨뜨리지 않고 옮긴다
여기서 더 중요한 것은 결과가 아니라 이 상태에 도달하는 과정이다. 리팩토링을 시작할 때 큼직하게 옮겨 놓고 마지막에 몰아서 테스트하는 방식은 위험하다. 마틴 파울러가 『리팩토링』 2판에서 강조하는 방식은 반대다. 아주 작은 단위로 바꾸고, 바꿀 때마다 테스트를 돌려 매번 초록불을 확인하며 최종 형태까지 나아간다
이 방식이 왜 필요한지는 순진하게 옮겨보면 바로 드러난다. 먼저 작업 전 상태를 기록해 두는 것이 기본이다. 테스트 37개가 모두 통과하는 것을 확인하고 시작했다. 그다음 도메인의 MemberRegisterRequest를 애플리케이션 포트 패키지로 옮기면, IntelliJ가 참조 경로를 자동으로 고쳐 빌드는 통과한다. 그러나 아키텍처 테스트가 깨진다
Architecture Violation [Priority: MEDIUM] - Rule 'Layered architecture ...' was violated (4 times):
Method <...domain.member.Member.register(...application.member.provided.MemberRegisterRequest, ...PasswordEncoder)>
calls method <...application.member.provided.MemberRegisterRequest.email()> in (Member.java:41)
...
Method <...domain.member.Member.register(...)> has parameter of type
<...application.member.provided.MemberRegisterRequest> in (Member.java:0)
at ...HexagonalArchitectureTest.hexagonalArchitecture(HexagonalArchitectureTest.java:21)
도메인의 Member가 애플리케이션 레이어의 타입을 파라미터로 받고 그 메서드를 호출하니, ArchUnit이 계층 의존성 위반을 4건 잡아낸 것이다. 이 상태로 새 클래스를 만들고 참조를 바꿔가며 진행하면, 리팩토링 내내 실패하는 테스트를 안고 가게 된다. 그래서 오브젝트를 도메인으로 되돌려 초록불을 회복한 뒤, 순서를 다시 짠다
핵심 기법은 메서드 오버로딩을 이용한 중간자다. 기존 register를 그대로 둔 채, 새 타입을 받는 register를 하나 더 만든다. 실제 로직은 새 메서드로 옮기고, 기존 메서드는 변환만 해서 새 메서드에 위임하게 한다
// 기존 시그니처는 살려 두고 변환 후 위임한다
public static Member register(MemberRegisterRequest registerRequest, PasswordEncoder passwordEncoder) {
return register(registerRequest.toInfo(), passwordEncoder);
}
// 실제 로직은 새 타입을 받는 메서드로 옮긴다
public static Member register(MemberRegisterInfo registerRequest, PasswordEncoder passwordEncoder) {
Member member = new Member();
member.email = new Email(registerRequest.email());
member.nickname = requireNonNull(registerRequest.nickname());
member.passwordHash = requireNonNull(passwordEncoder.encode(registerRequest.password()));
member.status = MemberStatus.PENDING;
member.detail = MemberDetail.create();
return member;
}
이 상태에서 테스트를 돌리면 여전히 전부 통과한다. 기존 코드는 옛 시그니처를 계속 호출하지만, 내부에서는 이미 새 로직을 거치기 때문이다. 이제 옛 메서드를 호출하는 지점을 하나씩 새 메서드로 옮긴다. 호출부를 빠짐없이 찾는 데는 IntelliJ의 Call Hierarchy가 유용하다. 맥 기준 단축키는 Ctrl+Option+H다
Callers of register(MemberRegisterRequest, PasswordEncoder) ├── MemberRepositoryTest.duplicateEmailFail() (2 usages) ├── MemberTest.setUp() ├── MemberFixture.createMember(Long) ├── MemberFixture.createMember() ├── MemberRepositoryTest.registerMember() ├── MemberTest.invalidEmail() (2 usages) └── MemberFixture.createMember(String)
여기 뜬 호출부를 하나 또는 몇 개씩 toInfo()를 붙이는 형태로 바꾸고, 그때마다 테스트를 돌린다. 매번 초록불이다. Call Hierarchy를 Refresh하면 옛 메서드를 부르는 곳이 하나씩 줄어든다. 전부 0이 되면, 옛 메서드를 삭제해도 아무 일도 일어나지 않는다. 삭제하고 테스트가 통과하면 리팩토링이 끝났다는 신호다
마지막으로, 처음에 실패했던 이동을 이제 안전하게 마무리한다. MemberRegisterRequest를 애플리케이션 포트 패키지로 옮긴다. 도메인의 Member는 더 이상 이 타입을 참조하지 않으므로, 이번에는 ArchUnit의 헥사고날 계층 검증까지 전부 통과한다. 순서를 바꿨을 뿐인데, 리팩토링 전 구간에서 테스트가 한 번도 깨지지 않았다
이 방식이 늘 정답은 아니다
분리에는 대가가 있다. 지금 두 클래스는 필드가 동일해서, MemberRegisterInfo와 MemberRegisterRequest는 겉보기에 중복이다. 요청 형식이 도메인 값과 끝까지 같게 유지되는 도메인이라면, 이 분리는 변환 코드와 클래스 하나를 더 얹는 순수한 비용으로 남는다. 조용호님의 글대로, 트레이드오프의 기준은 환경에 따라 달라진다. 애플리케이션 요구가 도메인 범위를 넘어서지 않을 것이 분명하다면 굳이 지금 나눌 필요는 없다
정리하면, 하나의 요청 오브젝트를 전 계층에서 재사용할지, 도메인 값 객체와 포트 DTO로 분리할지는 단순함과 유연성 사이의 트레이드오프다. “애플리케이션 레벨의 요구가 도메인 관점의 범위를 넘어설 것”이라고 판단되는 순간이 분리의 신호이며, 그 분리는 파울러식으로 매 단계 테스트를 지키며 옮기면 기능을 건드리지 않고 안전하게 끝낼 수 있다
출처 – 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2