Reviewed technical note
Spring/JPA에서 도메인 모델 경계가 무너지는 순간
Spring/JPA 애플리케이션에서 도메인 규칙과 조회 최적화가 섞이는 신호와 분리 방법.
Spring/JPA 애플리케이션에서 도메인 모델은 처음에는 깨끗하다. 문제는 시간이 지나며 컨트롤러, 서비스, 엔티티, 조회 최적화 코드가 서로의 책임을 침범할 때 시작된다. JPA는 도메인 모델을 담을 수 있지만, JPA 자체가 도메인 모델을 만들어주지는 않는다.
경계가 무너지는 대표 신호
다음 신호가 보이면 도메인 규칙과 인프라 선택이 섞이고 있을 가능성이 높다.
- 엔티티가 API 응답 형식을 직접 의식한다.
- 서비스가 트랜잭션 스크립트처럼 모든 규칙을 순서대로 나열한다.
- 조회 성능 문제를 해결하려고 도메인 규칙이 리포지토리 쿼리로 이동한다.
- 테스트가 도메인 규칙이 아니라 컨트롤러 응답 모양에만 붙는다.
- 지연 로딩, fetch join, OSIV 같은 인프라 선택이 도메인 설계를 끌고 간다.
이 상태에서는 기능은 동작해도 변경이 어려워진다. 새 정책 하나를 추가하기 위해 API 계층, 서비스 계층, 영속성 계층을 동시에 만져야 하기 때문이다.
나쁜 냄새: 서비스가 모든 규칙을 안다
아래 코드는 흔한 출발점이다. 트랜잭션 경계, 조회, 검증, 상태 전이, 응답 조립이 한 곳에 섞인다.
@Transactional
public MemberResponse activate(Long memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException(\"member not found\"));
if (!member.isEmailVerified()) {
throw new IllegalStateException(\"email is not verified\");
}
if (member.isActive()) {
throw new IllegalStateException(\"already active\");
}
member.setStatus(MemberStatus.ACTIVE);
return MemberResponse.from(member);
}
처음에는 단순해 보이지만, 규칙이 늘어나면 서비스는 도메인을 설명하지 못하고 절차만 나열한다. 더 나은 방향은 상태 전이의 이유를 도메인 메서드로 옮기는 것이다.
@Transactional
public MemberResponse activate(Long memberId) {
Member member = memberRepository.getById(memberId);
member.activate();
return MemberResponse.from(member);
}
public void activate() {
if (!email.isVerified()) throw new EmailNotVerifiedException();
if (status == MemberStatus.ACTIVE) throw new AlreadyActiveMemberException();
status = MemberStatus.ACTIVE;
}
핵심은 코드 줄 수가 아니다. “회원은 언제 활성화될 수 있는가”라는 규칙이 서비스 절차가 아니라 도메인 언어로 드러나는지가 기준이다.
JPA 최적화는 읽기 경로의 선택이다
JPA 성능 문제는 중요하다. 하지만 성능 최적화를 도메인 규칙 안에 섞으면 장기적으로 더 비싼 문제가 된다. fetch join, batch size, projection은 대부분 읽기 경로의 선택이다. 쓰기 모델의 불변식과 분리해서 다뤄야 한다.
예를 들어 주문 목록 API에서 N+1이 발생한다고 해서 주문 도메인 규칙을 리포지토리 쿼리로 옮기면 안 된다. 읽기 모델은 필요한 연관 데이터를 트랜잭션 안에서 준비하고 DTO로 변환하면 된다. 반면 주문 취소 가능 여부, 결제 상태 전이, 재고 차감 같은 규칙은 도메인 모델 안에 남아야 한다.
컬렉션 fetch join도 만능이 아니다. 일대다 컬렉션을 함께 가져오면 중복 로우가 늘고 페이징이 깨질 수 있다. 목록 조회는 루트 엔티티를 페이지 단위로 가져오고, 필요한 컬렉션은 batch fetch나 별도 조회 모델로 다루는 편이 안전한 경우가 많다.
헥사고날 아키텍처가 주는 실익
헥사고날 아키텍처의 장점은 육각형 그림이 아니라 의존성 방향이다. 애플리케이션은 외부 기술의 이름이 아니라 필요한 능력을 포트로 표현한다. 웹, DB, 이메일, 보안 구현은 어댑터가 맡는다.
flowchart TD
web[Web Adapter] --> provided[Application Port]
provided --> app[Application Service]
app --> domain[Domain Model]
app --> required[Required Port]
required --> persistence[DB Adapter]
required --> integration[External Adapter]
이 구조가 있으면 컨트롤러 응답 형식, JPA 조회 전략, 외부 연동 방식이 도메인 규칙의 중심으로 들어오는 일을 줄일 수 있다. 테스트도 빨라진다. 핵심 규칙은 DB 없이 검증하고, JPA 매핑과 쿼리 최적화는 별도 통합 테스트에서 검증하면 된다.
실무 체크리스트
경계를 점검할 때는 다음 질문을 본다.
- 도메인 규칙을 DB 없이 테스트할 수 있는가?
- 서비스 메서드를 읽으면 유스케이스 흐름만 보이고, 세부 규칙은 도메인 메서드 이름으로 드러나는가?
- API DTO 변경이 엔티티 필드와 도메인 규칙 변경으로 번지지 않는가?
- 조회 최적화 코드는 읽기 경로에 고립되어 있는가?
- JPA 지연 로딩 문제를 컨트롤러에서 우연히 해결하고 있지 않은가?
Spring/JPA에서 좋은 도메인 모델은 JPA를 부정하지 않는다. 대신 JPA가 잘하는 저장과 조회 최적화, 도메인이 책임져야 하는 규칙과 불변식을 분리한다. 이 경계가 유지될 때 성능 개선도 설계 부채가 아니라 읽기 경로의 선택으로 남는다.