Skip to the content.

JPA Repository ArchUnit 가이드

목적: JPA Repository 아키텍처 규칙 자동 검증 (7개 규칙)


1️⃣ 검증 전략

ArchUnit이 검증하는 것

JPA Repository 인터페이스:

검증 그룹 (3개)

그룹 규칙 수 내용
1. 인터페이스 규칙 3개 인터페이스 타입, JpaRepository 상속, Querydsl 금지
2. 금지 사항 규칙 3개 Query Method, @Query, Custom Repository 금지
3. 네이밍 규칙 1개 *Repository 접미사

2️⃣ ArchUnit 테스트 코드

전체 테스트 구조

package com.company.adapter.out.persistence.architecture.repository;

import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;

/**
 * JpaRepositoryArchTest - JPA Repository 아키텍처 규칙 검증 (7개 규칙)
 *
 * <p>jpa-repository-guide.md의 핵심 규칙을 ArchUnit으로 검증합니다.</p>
 *
 * <p><strong>검증 그룹:</strong></p>
 * <ul>
 *   <li>인터페이스 규칙 (3개)</li>
 *   <li>금지 사항 규칙 (3개)</li>
 *   <li>네이밍 규칙 (1개)</li>
 * </ul>
 *
 * @author Development Team
 * @since 2.0.0
 */
@DisplayName("JPA Repository 아키텍처 규칙 검증 (Zero-Tolerance)")
class JpaRepositoryArchTest {

    private static final String BASE_PACKAGE = "com.company.adapter.out.persistence";

    private static JavaClasses allClasses;
    private static JavaClasses jpaRepositoryClasses;

    @BeforeAll
    static void setUp() {
        allClasses = new ClassFileImporter()
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
            .importPackages(BASE_PACKAGE);

        // JpaRepository 인터페이스만 (QueryDsl 제외)
        jpaRepositoryClasses = allClasses.that(
            DescribedPredicate.describe(
                "JPA Repository 인터페이스",
                javaClass -> javaClass.getSimpleName().endsWith("Repository") &&
                    !javaClass.getSimpleName().contains("QueryDsl") &&
                    javaClass.isInterface()
            )
        );
    }

    // ========================================================================
    // 1. 인터페이스 규칙 (3개)
    // ========================================================================

    @Nested
    @DisplayName("1. 인터페이스 규칙")
    class InterfaceRules {

        @Test
        @DisplayName("규칙 1-1: JpaRepository는 인터페이스여야 합니다")
        void jpaRepository_MustBeInterface() {
            ArchRule rule = classes()
                .that().haveSimpleNameEndingWith("Repository")
                .and().haveSimpleNameNotContaining("QueryDsl")
                .and().resideInAPackage("..repository..")
                .should().beInterfaces()
                .allowEmptyShould(true)
                .because("JPA Repository는 인터페이스로 정의되어야 합니다");

            rule.check(jpaRepositoryClasses);
        }

        @Test
        @DisplayName("규칙 1-2: JpaRepository 상속이 필수입니다")
        void jpaRepository_MustExtendJpaRepository() {
            ArchRule rule = classes()
                .that().haveSimpleNameEndingWith("Repository")
                .and().haveSimpleNameNotContaining("QueryDsl")
                .and().areInterfaces()
                .should().beAssignableTo(JpaRepository.class)
                .allowEmptyShould(true)
                .because("JPA Repository는 JpaRepository 인터페이스를 상속해야 합니다");

            rule.check(jpaRepositoryClasses);
        }

        @Test
        @DisplayName("규칙 1-3: QuerydslPredicateExecutor 상속이 금지됩니다")
        void jpaRepository_MustNotExtendQuerydslPredicateExecutor() {
            ArchRule rule = classes()
                .that().haveSimpleNameEndingWith("Repository")
                .and().haveSimpleNameNotContaining("QueryDsl")
                .and().areInterfaces()
                .should().notBeAssignableTo(QuerydslPredicateExecutor.class)
                .allowEmptyShould(true)
                .because("JPA Repository는 QuerydslPredicateExecutor 상속이 금지됩니다 (순수 Command 전용)");

            rule.check(jpaRepositoryClasses);
        }
    }

    // ========================================================================
    // 2. 금지 사항 규칙 (3개)
    // ========================================================================

    @Nested
    @DisplayName("2. 금지 사항 규칙")
    class ProhibitionRules {

        @Test
        @DisplayName("규칙 2-1: Query Method 추가가 금지됩니다")
        void jpaRepository_MustNotHaveQueryMethods() {
            ArchRule rule = methods()
                .that().areDeclaredInClassesThat().haveSimpleNameEndingWith("Repository")
                .and().areDeclaredInClassesThat().haveSimpleNameNotContaining("QueryDsl")
                .and().areDeclaredInClassesThat().areInterfaces()
                .and().arePublic()
                .and().haveNameMatching("find.*|search.*|count.*|exists.*|get.*")
                .should().notBeDeclared()
                .allowEmptyShould(true)
                .because("JPA Repository는 Query Method 추가가 금지됩니다 (QueryDslRepository 사용)");

            rule.check(allClasses);
        }

        @Test
        @DisplayName("규칙 2-2: @Query 어노테이션 사용이 금지됩니다")
        void jpaRepository_MustNotUseQueryAnnotation() {
            ArchRule rule = methods()
                .that().areDeclaredInClassesThat().haveSimpleNameEndingWith("Repository")
                .and().areDeclaredInClassesThat().haveSimpleNameNotContaining("QueryDsl")
                .and().areDeclaredInClassesThat().areInterfaces()
                .should().notBeAnnotatedWith(Query.class)
                .allowEmptyShould(true)
                .because("JPA Repository는 @Query 어노테이션 사용이 금지됩니다 (QueryDSL 사용)");

            rule.check(allClasses);
        }

        @Test
        @DisplayName("규칙 2-3: Custom Repository 구현이 금지됩니다")
        void jpaRepository_MustNotHaveCustomImplementation() {
            ArchRule rule = classes()
                .that().haveSimpleNameMatching(".*RepositoryImpl")
                .and().resideInAPackage("..repository..")
                .should().notExist()
                .allowEmptyShould(true)
                .because("Custom Repository 구현이 금지됩니다 (QueryDslRepository 사용)");

            rule.check(allClasses);
        }
    }

    // ========================================================================
    // 3. 네이밍 규칙 (1개)
    // ========================================================================

    @Nested
    @DisplayName("3. 네이밍 규칙")
    class NamingRules {

        @Test
        @DisplayName("규칙 3-1: *Repository 네이밍 규칙을 따라야 합니다")
        void jpaRepository_MustFollowNamingConvention() {
            ArchRule rule = classes()
                .that().areInterfaces()
                .and().areAssignableTo(JpaRepository.class)
                .and().resideInAPackage("..repository..")
                .should().haveSimpleNameEndingWith("Repository")
                .allowEmptyShould(true)
                .because("JPA Repository는 *Repository 네이밍 규칙을 따라야 합니다");

            rule.check(allClasses);
        }
    }
}

3️⃣ 규칙 상세 설명

규칙 1: JpaRepository는 인터페이스

검증 내용: Repository 패키지의 *Repository 클래스는 interface여야 함

위반 예시:

// ❌ 클래스로 정의
public class OrderRepository extends SimpleJpaRepository<OrderJpaEntity, Long> {
}

올바른 예시:

// ✅ 인터페이스로 정의
public interface OrderRepository extends JpaRepository<OrderJpaEntity, Long> {
}

규칙 2: JpaRepository 상속

검증 내용: *Repository 인터페이스는 JpaRepository<Entity, ID> 상속 필수

위반 예시:

// ❌ 상속 없음
public interface OrderRepository {
}

올바른 예시:

// ✅ JpaRepository 상속
public interface OrderRepository extends JpaRepository<OrderJpaEntity, Long> {
}

규칙 3: QuerydslPredicateExecutor 상속 금지

검증 내용: QuerydslPredicateExecutor 상속 절대 금지

위반 예시:

// ❌ QuerydslPredicateExecutor 상속 금지
public interface OrderRepository extends
    JpaRepository<OrderJpaEntity, Long>,
    QuerydslPredicateExecutor<OrderJpaEntity> {  // ❌
}

올바른 예시:

// ✅ JpaRepository만 상속
public interface OrderRepository extends JpaRepository<OrderJpaEntity, Long> {
}

규칙 4: Query Method 추가 금지

검증 내용: find*, search*, count*, exists*, get* 메서드 금지

위반 예시:

// ❌ Query Method 추가 금지
public interface OrderRepository extends JpaRepository<OrderJpaEntity, Long> {
    Optional<OrderJpaEntity> findByOrderNumber(String orderNumber);  // ❌
    List<OrderJpaEntity> findByStatus(OrderStatus status);           // ❌
    long countByStatus(OrderStatus status);                          // ❌
}

올바른 예시:

// ✅ Query Method 없음
public interface OrderRepository extends JpaRepository<OrderJpaEntity, Long> {
}

// ✅ QueryDSL Repository에서 처리
@Repository
public class OrderQueryDslRepository {
    public Optional<OrderJpaEntity> findByOrderNumber(String orderNumber) {
        return Optional.ofNullable(
            queryFactory.selectFrom(qOrder)
                .where(qOrder.orderNumber.eq(orderNumber))
                .fetchOne()
        );
    }
}

규칙 5: @Query 어노테이션 사용 금지

검증 내용: @Query JPQL 어노테이션 사용 금지

위반 예시:

// ❌ @Query 사용 금지
public interface OrderRepository extends JpaRepository<OrderJpaEntity, Long> {
    @Query("SELECT o FROM OrderJpaEntity o WHERE o.status = :status")  // ❌
    List<OrderJpaEntity> findByStatus(@Param("status") OrderStatus status);
}

올바른 예시:

// ✅ @Query 없음
public interface OrderRepository extends JpaRepository<OrderJpaEntity, Long> {
}

// ✅ QueryDSL Repository에서 타입 안전 쿼리 사용
@Repository
public class OrderQueryDslRepository {
    public List<OrderJpaEntity> findByStatus(OrderStatus status) {
        return queryFactory.selectFrom(qOrder)
            .where(qOrder.status.eq(status))
            .fetch();
    }
}

규칙 6: Custom Repository 구현 금지

검증 내용: *RepositoryImpl 클래스 존재 금지

위반 예시:

// ❌ Custom Repository 구현 금지
public interface OrderRepositoryCustom {
    List<OrderJpaEntity> searchOrders(SearchOrderQuery query);
}

public class OrderRepositoryImpl implements OrderRepositoryCustom {  // ❌
    @Override
    public List<OrderJpaEntity> searchOrders(SearchOrderQuery query) {
        // 복잡한 로직
    }
}

올바른 예시:

// ✅ Custom Repository 없음
public interface OrderRepository extends JpaRepository<OrderJpaEntity, Long> {
}

// ✅ QueryDSL Repository로 대체
@Repository
public class OrderQueryDslRepository {
    public List<OrderJpaEntity> searchOrders(SearchOrderQuery query) {
        return queryFactory.selectFrom(qOrder)
            .where(buildConditions(query))
            .fetch();
    }
}

4️⃣ 실행 방법

Gradle 실행

# 전체 테스트 실행
./gradlew test

# JPA Repository ArchUnit만 실행
./gradlew test --tests "*JpaRepositoryArchTest"

IDE 실행


5️⃣ 위반 시 대응

1단계: 위반 로그 확인

java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] -
Rule 'classes that have simple name ending with 'Repository'
should not be assignable to QuerydslPredicateExecutor' was violated (1 times):
Interface <OrderRepository> is assignable to QuerydslPredicateExecutor

2단계: 위반 원인 파악

3단계: 코드 수정

// Before (위반)
public interface OrderRepository extends
    JpaRepository<OrderJpaEntity, Long>,
    QuerydslPredicateExecutor<OrderJpaEntity> {  // ❌
}

// After (수정)
public interface OrderRepository extends JpaRepository<OrderJpaEntity, Long> {
}

4단계: 재검증

./gradlew test --tests "*JpaRepositoryArchTest"

6️⃣ 체크리스트

ArchUnit 테스트 작성 시:


7️⃣ 참고 문서


작성자: Development Team 최종 수정일: 2025-12-04 버전: 2.0.0