JPA Entity를 도메인 모델로 쓰지 않기로 한 이유

유민우·2026년 7월 15일

Spring Boot 프로젝트에서 DDD·헥사고날 아키텍처를 실제 규칙과 테스트로 정착시킨 과정

프로젝트 초기에는 JPA Entity가 데이터베이스 테이블을 표현하는 객체이면서 동시에 비즈니스 규칙을 수행
하는 객체였다. 기능이 적을 때는 빠르게 개발할 수 있었지만, 회원·센터·결제처럼 도메인이 늘어나면서 문
제가 분명해졌다.

  • Entity의 상태 변경 메서드에 비즈니스 규칙이 쌓였다.

  • 테스트를 작성하려면 JPA와 영속성 환경을 함께 고려해야 했다.

  • application 계층이 Repository와 Entity에 직접 의존하기 쉬웠다.

  • 다른 도메인의 persistence 구현을 참조하는 구조적 침범이 생길 수 있었다.

    이번 글에서는 이 문제를 해결하기 위해 도메인 모델을 JPA Entity와 분리하고, ArchUnit으로 구조 규칙을
    테스트한 과정을 정리한다.

    문제: Entity가 모든 책임을 갖기 시작했다

    JPA Entity는 영속성 프레임워크가 요구하는 제약을 가진다. 기본 생성자, 어노테이션, 지연 로딩, 변경 감
    지처럼 데이터 저장에 필요한 관심사가 포함된다.

    처음에는 Entity에 가입 검증이나 상태 변경 메서드를 넣는 방식이 자연스러워 보였다. 하지만 시간이 지날
    수록 “데이터를 저장하기 위한 모델”과 “업무 규칙을 표현하는 모델”이 하나의 객체에 섞였다.

    그 결과, 비즈니스 규칙을 검증하기 위한 단위 테스트도 불필요하게 JPA의 영향을 받게 됐다. 또한 Entity
    변경이 DB 매핑에 영향을 주는지, 도메인 규칙에 영향을 주는지 파악하기 어려워졌다.

    목표: 도메인 규칙은 도메인 모델에 둔다

    내가 정한 기준은 단순했다.

    상태 전이와 검증은 domain에 두고, JPA Entity는 저장과 복원에만 집중한다.

    프로젝트의 기본 의존성 방향은 다음과 같다.

    Web Adapter → Application → Domain

    Outbound Port

    Persistence Adapter → JPA

  • Domain: 가입, 검증, 상태 전이처럼 업무 의미가 있는 규칙

  • Application: 유스케이스 흐름 조합과 포트 호출

  • Persistence Adapter: Domain Model과 JPA Entity의 변환, DB 저장

  • JPA Entity: 테이블 매핑과 영속성 관리

    이 구조에서는 application 계층이 JpaRepository나 JPA Entity를 직접 알 필요가 없다.

    적용: 회원 도메인을 독립적인 모델로 표현하기

    회원 도메인은 JPA 어노테이션 없이 생성·복원·검증 규칙을 가진다.

    public class Member {

    private final Long id;
    private final String email;
    private String passwordHash;
    private final MemberRole role;
    private final MemberStatus status;
    
    public static Member registerCenterMaster(RegistrationRequest request) {
        return register(request, MemberRole.CENTER_MASTER);
    }
    
    public static Member registerTrainer(RegistrationRequest request) {
        return register(request, MemberRole.TRAINER);
    }
    
    public void changePasswordHash(String passwordHash) {
        this.passwordHash = requireText(passwordHash, "passwordHash");
    }
    
    public boolean isActive() {
        return status == MemberStatus.ACTIVE;
    }

    }

    중요한 점은 Member가 “어떻게 저장되는가”가 아니라 “어떤 규칙으로 생성되고 변경되는가”를 표현한다는
    것이다.

    예를 들어 역할별 이메일 또는 CI 중복 여부는 회원 생성 규칙에 포함된다.

    if (request.emailDuplicatedForRole() || request.ciHashDuplicatedForRole()) {
    throw new MemberException(MemberErrorCode.MEMBER_ALREADY_EXISTS);
    }

    이 규칙은 JPA 없이 단위 테스트할 수 있고, 이후 저장 방식이 바뀌어도 도메인 규칙은 유지된다.

    애플리케이션 계층은 JPA 대신 Port에 의존한다

    application 계층은 구체적인 Repository가 아니라 필요한 기능만 정의한 Port를 사용한다.

    public interface MemberRegistrationPersistencePort {

    Member save(Member member);

    }

    이후 persistence adapter가 Port를 구현하며, 이곳에서만 Domain Model과 JPA Entity를 변환한다.

    MemberRegistrationService

    MemberRegistrationPersistencePort

    MemberRegistrationPersistenceAdapter

    MemberJpaEntity / JpaRepository

    이 방식은 처음에는 Port와 변환 코드가 늘어나는 것처럼 보인다. 하지만 유스케이스가 커질수록
    application 계층의 책임은 명확해지고, 영속성 구현 변경의 영향 범위도 adapter로 제한된다.

    “규칙을 지키자”만으로는 부족했다

    구조를 문서화하는 것만으로는 충분하지 않았다. 시간이 지나면 새 기능을 구현하는 사람이 구조를 모르거
    나, 급한 일정 때문에 편한 의존성을 추가할 수 있다.

    그래서 ArchUnit 테스트로 아키텍처 규칙을 검증했다.

    @ArchTest
    static final ArchRule application_must_not_depend_on_persistence_adapters =
    noClasses().that().resideInAPackage("..application..")
    .should().dependOnClassesThat()
    .resideInAnyPackage("..adapter.out.persistence..");

    @ArchTest
    static final ArchRule application_must_not_depend_on_jpa_repositories =
    noClasses().that().resideInAPackage("..application..")
    .should().dependOnClassesThat()
    .areAssignableTo(JpaRepository.class);

    이 테스트는 application 계층이 persistence adapter나 JpaRepository에 직접 의존하는 순간 실패한다.

    또한 JPA Entity에 aggregate 관계 어노테이션이나 업무 상태 전이 메서드가 다시 추가되지 않도록 검증했
    다.

    @ArchTest
    static final ArchRule jpa_entities_must_not_declare_aggregate_relationships =
    classes().that().areAnnotatedWith(Entity.class)
    .should(notDeclareJpaRelationshipFields());

    @ArchTest
    static final ArchRule jpa_entities_must_not_declare_business_transition_methods =
    classes().that().areAnnotatedWith(Entity.class)
    .should(notDeclareBusinessTransitionMethods());

    아키텍처는 리뷰에서만 확인하는 가이드가 아니라, 빌드 과정에서 실패할 수 있는 실행 가능한 규칙이 됐
    다.

    선택하지 않은 대안

    Entity를 그대로 도메인 모델로 사용하기

    구현 속도는 빠르다. 하지만 영속성 관심사와 비즈니스 규칙이 결합되고, 도메인 테스트의 독립성이 낮아진
    다. 프로젝트가 커질수록 변경 비용이 커질 가능성이 높다고 판단했다.

    모든 규칙을 Application Service에 두기

    JPA와 분리할 수는 있지만, 여러 유스케이스에서 재사용돼야 할 도메인 규칙까지 application 계층에 흩어
    진다. 결국 유스케이스 조합과 도메인 규칙의 경계가 흐려진다.

    트레이드오프와 결과

    이 구조는 분명 비용이 있다.

  • Domain Model과 JPA Entity 사이의 매핑 코드가 필요하다.

  • 단순 CRUD도 Port와 Adapter를 거쳐야 한다.

  • 팀이 패키지와 의존성 규칙을 이해해야 한다.

    그럼에도 얻은 이점이 더 컸다.

  • 도메인 규칙을 JPA 없이 단위 테스트할 수 있다.

  • application 계층이 영속성 구현에 덜 묶인다.

  • bounded context 간 persistence 침범을 줄일 수 있다.

  • ArchUnit이 구조 회귀를 조기에 감지한다.

    마무리

    이번 작업에서 가장 중요했던 것은 “DDD 패키지 구조를 만들었다”는 사실이 아니다.

    문제가 커지기 전에 도메인 규칙과 영속성 관심사를 분리했고, 그 결정을 문서와 테스트로 남겨 이후 변경
    에서도 유지할 수 있게 만들었다는 점이다.

    앞으로도 새로운 기능을 추가할 때마다 다음 질문을 기준으로 삼으려 한다.

    이 규칙은 데이터 저장의 규칙인가, 아니면 비즈니스의 규칙인가?

    답이 비즈니스 규칙이라면, 그 코드는 JPA Entity가 아니라 도메인 모델에 있어야 한다.

profile
유민우요

0개의 댓글