Layered Architecture에 DIP를 적용하면서 Repository Interface를 Domain Layer로 옮기던 중이었다.
구현체가 Infrastructure에 있으니 인터페이스만 상위 레이어에 올리면 되는데, 문득 질문이 떠올랐다.
"Application Layer에 둬도 의존성 방향은 안 깨지는데, 왜 꼭 Domain Layer여야 하지?"
처음엔 둘 다 괜찮다고 생각했는데, 결론은 그렇지 않다.
이번 글에서는 DIP만으로 설명할 수 없는 부분을 OOP 핵심 개념으로 풀어본다.
문제 상황
DIP(Dependency Inversion Principle)만 놓고 보면, Repository Interface는 Domain Layer든 Application Layer든 어디에 둬도 위반이 아니다.
그 이유를 보자면
- 둘 다 Infrastructure보다 상위 레이어다.
- 의존성 방향은 "저수준 → 고수준"으로 동일하게 유지된다.
[고수준] Domain Layer ←─── Infrastructure (구현체)
[고수준] Application Layer ←─── Infrastructure (구현체)
따라서 어디에 위치시키던 둘 다 타당해 보인다.
메타인지
먼저, 문제를 해결하기 위해 내 상태를 점검해보았다.
- 현재 문제가 무엇인가?
- Repository Interface가 위치해야 할 계층을 근거를 바탕으로 말하지 못한다.
- "Domain Layer에 둬야 해" — 그런데 왜? 라고 물으면 말문이 막힌다.
- 왜 말을 못하는가?
- DIP 관점으로만 생각했을 때, Repository Interface가 Application Layer에 위치한 것과 Domain Layer에 위치한 것의 차이점을 느끼지 못한다.
- 왜 느끼지 못하는가?
- DIP의 핵심을 다음처럼 이해하고 있다
- 고수준 모듈(비즈니스 규칙/정책)이 저수준 모듈(DB, 외부 API, 프레임워크 등)을 의존하면 안 된다
- 이를 위해 고수준 모듈에서 추상화를 정의하고 저수준 모듈에서 그를 구현해야 한다
- 이 관점에서 보면, Repository Interface가 Application Layer에 있든 Domain Layer에 있든 둘 다 DIP를 위반하지 않는다.
- DIP의 핵심을 다음처럼 이해하고 있다
- 두 방식이 전부 DIP를 위반하지 않는다는 근거는?
- 먼저 고수준/저수준 모듈을 다음처럼 정의했다:
- Domain Layer (고수준) > 다른 모든 Layer
- Application Layer (고수준) > Interfaces, Infrastructure Layer
- 따라서 DIP를 위반하지 않으려면 의존성 방향은 다음처럼 되어야 한다
- Domain Layer ← Application, Interfaces, Infrastructure
- Application Layer ← Infrastructure, Interfaces
- 이때 Repository Interface의 구현체는 Infrastructure Layer에 위치한다.
- 먼저 고수준/저수준 모듈을 다음처럼 정의했다:
결국 Repository Interface가 Application Layer에 있든 Domain Layer에 있든, 구현체(Infrastructure)는 항상 고수준을 향해 의존하기에, DIP만으로는 둘 사이의 차이를 설명할 수 없다.
원인
사실 DIP는 의존성 방향만 규정하는 게 아니다.
Robert C. Martin의 DIP 원문은 "추상화는 그것을 **사용하는** 고수준 모듈이 소유해야 한다"라고 말한다.
"방향"뿐 아니라 "소유권"까지 언급하고 있었다.
Domain Layer가 Repository를 사용해 도메인 규칙을 보호하므로, 원칙의 본질까지 따지면 이미 Domain Layer를 가리킨다.
하지만 "사용하는 모듈이 소유"라는 한 줄만으로는, 왜 그래야 하는지 설명하기 어렵다.
"응집도를 높이고, 결합도는 낮춘다"라는 목표와, "경계·책임·역할·협력"이라는 수단으로 다시 따져봐야 한다.
해결
OOP의 목표는 응집을 높이고 결합을 낮추는 것이다.
그리고 그 목표를 달성하기 위한 수단이 경계·책임·역할·협력이다.
Repository Interface의 위치를 이 개념들로 하나씩 따져보자.
목표 - 응집과 결합
- 응집은 하나의 모듈 안에 관련된 것들이 얼마나 모여 있는가다.
- Domain Model과 그 모델의 영속 계약(Repository Interface)은 필연적인 짝이다.
- 이 짝을 같은 레이어에 두면 응집이 높고, 다른 레이어에 두면 응집이 떨어진다.
- 결합은 모듈 간 의존 강도다. 낮을수록 좋다.
- Repository Interface를 Application Layer에 두면, 도메인 모델의 영속 계약이 바깥 레이어에 놓인다.
- 자신의 모델을 저장하고 복원하는 계약을 스스로 정의하지 못하는 Domain Layer — 자립성이 깨진다.
응집은 높이고 결합은 낮춰야 한다 — 여기까지는 목표다.
이제 그 목표를 어떤 수단으로 달성하는지 보자.
경계 - "여기까지가 내 영역"
경계는 레이어의 책임 범위를 구분한다.
- Domain Layer의 책임: 비즈니스 규칙, 불변식, 도메인 언어
- Application Layer의 책임: 유스케이스 흐름, 오케스트레이션, 트랜잭션
Repository Interface는 "이 도메인 객체를 저장/조회한다"는 계약이다.
비즈니스 규칙을 보호하기 위한 계약이지, 유스케이스 흐름을 위한 계약이 아니다.
즉, 계약의 성격이 Domain 경계 안에 있다.
책임 - "이건 내 일이야"
SRP(단일 책임 원칙)에서 변경의 이유로 책임을 판단한다.
- Repository Interface가 변경되는 이유: 도메인 규칙이 바뀔 때 (새 필드 추가, 조회 조건 변경 등)
- Application Layer가 변경되는 이유: 유스케이스가 바뀔 때 (화면 요구사항, API 스펙 변경 등)
변경의 이유가 다르다.
이걸 같은 레이어에 넣으면 Application Layer에 두 개의 변경 축이 생긴다.
구체적으로 보자.
- 도메인 규칙이 바뀌는 경우
: UserCommandRepository에 existsByEmail(String email) 메서드를 추가한다고 가정- 추가 이유: "회원가입 시 이메일 중복 검사"라는 도메인 규칙이 생겼기 때문
- 유스케이스 변경과는 무관: 어떤 API에서 호출하든, 이 규칙 자체는 도메인이 요구한 것
- 유스케이스가 바뀌는 경우
: "관리자 API에서 사용자 목록을 페이지네이션으로 조회한다"는 유스케이스가 추가된다고 가정UserQueryFacade에 메서드가 추가되고, Facade가UserQueryService를 호출한다- 이때
UserQueryRepository의 시그니처가 바뀌는가? 아니다. 도메인 모델의 조회 계약은 그대로다
Repository Interface는 도메인 규칙에 의해 변한다. 유스케이스 변경에는 영향받지 않는다.
따라서 Repository Interface의 책임은 Domain Layer에 속한다.
역할 - "이건 저쪽이 할 일 아닌가?"
역할은 누가 이 계약을 정의하고 소유할 것인가다.
- Domain Layer의 역할: 도메인 모델을 보호하고, 도메인 언어로 계약을 정의
- Application Layer의 역할: 유스케이스를 조합하고, 외부 요구에 맞춰 응답
Repository Interface의 시그니처를 확인해보자.
public interface OrderRepository {
public void save(Order);
public Order findById(Long);
public boolean existsByLoginId(String);
}
이들은 모두 도메인 언어다. 유스케이스 DTO도, 기술 타입(Page, Pageable)도 없다.
여기서 한 발 더 나가보자.
만약 Repository Interface를 Application Layer가 소유한다면 어떤 일이 벌어지는가?
- Application Layer가 도메인 언어로 된 계약을 소유 → 역할 월권
- Domain Layer는 자신의 영속 계약을 스스로 정의하지 못함 → 역할 부재
- Domain Model(
Order)은 Application Layer의 계약에 의존해야 저장/조회 가능 → 안쪽이 바깥에 의존
역할이 뒤집히면 응집이 깨지고 결합이 높아진다.
즉, 도메인 언어로 작성된 계약은 Domain Layer가 정의하고 소유해야 한다. 그것이 역할에 맞다.
협력 - "함께 해야 하는 일"
협력은 모듈 간 메시지를 주고받으며 목적을 달성하는 관계다.
Domain Model과 Repository는 필연적 협력 관계다.
- Order를 생성하면 -> OrderCommandRepository.save()가 영속화
- User를 조회하면 -> UserQueryRepository.findByLoginId()가 복원
이 협력의 계약은 협력의 주체인 Domain Layer가 소유해야 자연스럽다.
Application Layer가 소유하면, 협력 당사자가 아닌 중개자가 계약을 쥐고 있는 구조가 된다.
검증
/**
* 주문 명령 리포지토리
* - 도메인 규칙 보호를 위한 영속성 계약
*/
public interface OrderCommandRepository {
Order save(Order order);
void deleteById(Long id);
}
- 책임: 이 계약이 변경되는 이유는 도메인 규칙이다. 유스케이스가 바뀐다고 시그니처가 바뀌지 않는다.
- 역할:
Order,Long— 순수 도메인 언어로 작성되어 있다. 이 계약을 정의하고 소유하는 역할은 Domain Layer다.
→ 책임과 역할 모두 Domain Layer를 가리킨다.
정리
DIP는 최소 요건이다. 의존성 방향만 맞추면 Application Layer에 둬도 위반은 아니다.
하지만 책임을 따져보면 Repository Interface는 도메인 규칙에 의해 변하고, 역할을 따져보면 도메인 언어로 된 계약은 Domain Layer가 소유해야 한다.
책임과 역할이 Domain Layer를 가리킬 때, 그곳에 두는 것이 응집을 높이고 결합을 낮추는 길이다.