Skip to the content.

QueryDSL Repository ArchUnit 가이드

목적: QueryDSL Repository 아키텍처 규칙 자동 검증 (유연한 메서드 패턴)


1️⃣ 검증 전략

ArchUnit이 검증하는 것

QueryDSL Repository 클래스:

검증 그룹 (4개)

그룹 규칙 수 내용
1. 클래스 구조 규칙 4개 클래스 타입, @Repository, JPAQueryFactory, QType
2. 메서드 규칙 3개 필수 메서드 2개, 허용 패턴, findAll 금지
3. 금지 사항 규칙 2개 @Transactional, Mapper 의존성 금지
4. 네이밍 규칙 1개 *QueryDslRepository 접미사

2️⃣ ArchUnit 테스트 코드

전체 테스트 구조

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

import com.querydsl.jpa.impl.JPAQueryFactory;
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.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

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

/**
 * QueryDslRepositoryArchTest - QueryDSL Repository 아키텍처 규칙 검증 (9개 규칙)
 *
 * <p>querydsl-repository-guide.md의 핵심 규칙을 ArchUnit으로 검증합니다.</p>
 *
 * <p><strong>검증 그룹:</strong></p>
 * <ul>
 *   <li>클래스 구조 규칙 (4개)</li>
 *   <li>메서드 규칙 (2개)</li>
 *   <li>금지 사항 규칙 (2개)</li>
 *   <li>네이밍 규칙 (1개)</li>
 * </ul>
 *
 * @author Development Team
 * @since 2.0.0
 */
@DisplayName("QueryDSL Repository 아키텍처 규칙 검증 (Zero-Tolerance)")
class QueryDslRepositoryArchTest {

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

    private static JavaClasses allClasses;
    private static JavaClasses queryDslRepositoryClasses;

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

        // QueryDslRepository 클래스만
        queryDslRepositoryClasses = allClasses.that(
            DescribedPredicate.describe(
                "QueryDSL Repository 클래스",
                javaClass -> javaClass.getSimpleName().endsWith("QueryDslRepository") &&
                    !javaClass.isInterface()
            )
        );
    }

    // ========================================================================
    // 1. 클래스 구조 규칙 (4개)
    // ========================================================================

    @Nested
    @DisplayName("1. 클래스 구조 규칙")
    class ClassStructureRules {

        @Test
        @DisplayName("규칙 1-1: QueryDslRepository는 클래스여야 합니다")
        void queryDslRepository_MustBeClass() {
            ArchRule rule = classes()
                .that().haveSimpleNameEndingWith("QueryDslRepository")
                .and().resideInAPackage("..repository..")
                .should().notBeInterfaces()
                .allowEmptyShould(true)
                .because("QueryDSL Repository는 클래스로 정의되어야 합니다");

            rule.check(queryDslRepositoryClasses);
        }

        @Test
        @DisplayName("규칙 1-2: @Repository 어노테이션이 필수입니다")
        void queryDslRepository_MustHaveRepositoryAnnotation() {
            ArchRule rule = classes()
                .that().haveSimpleNameEndingWith("QueryDslRepository")
                .and().resideInAPackage("..repository..")
                .should().beAnnotatedWith(Repository.class)
                .allowEmptyShould(true)
                .because("QueryDSL Repository는 @Repository 어노테이션이 필수입니다");

            rule.check(queryDslRepositoryClasses);
        }

        @Test
        @DisplayName("규칙 1-3: JPAQueryFactory 필드가 필수입니다")
        void queryDslRepository_MustHaveJPAQueryFactory() {
            ArchRule rule = classes()
                .that().haveSimpleNameEndingWith("QueryDslRepository")
                .and().resideInAPackage("..repository..")
                .should().dependOnClassesThat().areAssignableTo(JPAQueryFactory.class)
                .allowEmptyShould(true)
                .because("QueryDSL Repository는 JPAQueryFactory 필드가 필수입니다");

            rule.check(queryDslRepositoryClasses);
        }

        @Test
        @DisplayName("규칙 1-4: QType 필드는 static final이어야 합니다")
        void queryDslRepository_MustHaveStaticFinalQTypeField() {
            ArchRule rule = fields()
                .that().areDeclaredInClassesThat().haveSimpleNameEndingWith("QueryDslRepository")
                .and().haveNameMatching("^q[A-Z].*")  // qOrder, qProduct 등
                .should().beStatic()
                .andShould().beFinal()
                .allowEmptyShould(true)
                .because("QType 필드는 static final이어야 합니다");

            rule.check(allClasses);
        }
    }

    // ========================================================================
    // 2. 메서드 규칙 (3개)
    // ========================================================================

    @Nested
    @DisplayName("2. 메서드 규칙")
    class MethodRules {

        @Test
        @DisplayName("규칙 2-1: 허용된 메서드 패턴만 사용해야 합니다 (findBy*, existsBy*, search*, count*)")
        void queryDslRepository_MustUseAllowedMethodPatterns() {
            ArchRule rule = methods()
                .that().areDeclaredInClassesThat().haveSimpleNameEndingWith("QueryDslRepository")
                .and().areDeclaredInClassesThat().resideInAPackage("..repository..")
                .and().arePublic()
                .and().areNotStatic()
                .and().doNotHaveName("equals")
                .and().doNotHaveName("hashCode")
                .and().doNotHaveName("toString")
                .should().haveNameStartingWith("findBy")
                .orShould().haveNameStartingWith("existsBy")
                .orShould().haveNameStartingWith("search")
                .orShould().haveNameStartingWith("count")
                .allowEmptyShould(true)
                .because("QueryDSL Repository는 허용된 메서드 패턴만 사용해야 합니다 (findBy*, existsBy*, search*, count*)");

            rule.check(allClasses);
        }

        @Test
        @DisplayName("규칙 2-2: findAll() 메서드 사용이 금지됩니다 (OOM 위험)")
        void queryDslRepository_MustNotUseFindAll() {
            ArchRule rule = noMethods()
                .that().areDeclaredInClassesThat().haveSimpleNameEndingWith("QueryDslRepository")
                .and().areDeclaredInClassesThat().resideInAPackage("..repository..")
                .and().arePublic()
                .should().haveName("findAll")
                .allowEmptyShould(true)
                .because("QueryDSL Repository는 findAll() 사용이 금지됩니다 (OOM 위험, search* 사용)");

            rule.check(allClasses);
        }

        @Test
        @DisplayName("규칙 2-3: Join 사용이 금지됩니다 (수동 검증 필요)")
        void queryDslRepository_MustNotUseJoin() {
            // ⚠️ 주의: ArchUnit으로 Join 사용을 완벽히 검증하기 어려움
            // 코드 리뷰 및 수동 검증 필요
            //
            // 금지 패턴:
            // - queryFactory.selectFrom(q).join(...)
            // - queryFactory.selectFrom(q).leftJoin(...)
            // - queryFactory.selectFrom(q).rightJoin(...)
            // - queryFactory.selectFrom(q).innerJoin(...)
            // - queryFactory.selectFrom(q).fetchJoin(...)
            //
            // ✅ 이 테스트는 통과하지만, 실제 Join 사용 여부는 코드 리뷰로 확인해야 합니다.

            ArchRule rule = noClasses()
                .that().haveSimpleNameEndingWith("QueryDslRepository")
                .and().resideInAPackage("..repository..")
                .should().dependOnClassesThat().haveFullyQualifiedName("com.querydsl.jpa.impl.JPAJoin")
                .allowEmptyShould(true)
                .because("QueryDSL Repository는 Join 사용이 금지됩니다 (N+1은 Adapter에서 해결)");

            rule.check(queryDslRepositoryClasses);
        }
    }

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

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

        @Test
        @DisplayName("규칙 3-1: @Transactional 사용이 금지됩니다")
        void queryDslRepository_MustNotHaveTransactional() {
            ArchRule rule = noClasses()
                .that().haveSimpleNameEndingWith("QueryDslRepository")
                .and().resideInAPackage("..repository..")
                .should().beAnnotatedWith(Transactional.class)
                .allowEmptyShould(true)
                .because("QueryDSL Repository는 @Transactional 사용이 금지됩니다 (Service Layer에서 관리)");

            rule.check(queryDslRepositoryClasses);
        }

        @Test
        @DisplayName("규칙 3-2: Mapper 의존성이 금지됩니다")
        void queryDslRepository_MustNotDependOnMapper() {
            ArchRule rule = noClasses()
                .that().haveSimpleNameEndingWith("QueryDslRepository")
                .and().resideInAPackage("..repository..")
                .should().dependOnClassesThat().haveSimpleNameEndingWith("Mapper")
                .allowEmptyShould(true)
                .because("QueryDSL Repository는 Mapper 의존성이 금지됩니다 (Adapter에서 처리)");

            rule.check(queryDslRepositoryClasses);
        }
    }

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

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

        @Test
        @DisplayName("규칙 4-1: *QueryDslRepository 네이밍 규칙을 따라야 합니다")
        void queryDslRepository_MustFollowNamingConvention() {
            ArchRule rule = classes()
                .that().resideInAPackage("..repository..")
                .and().areAnnotatedWith(Repository.class)
                .and().areNotInterfaces()
                .should().haveSimpleNameEndingWith("QueryDslRepository")
                .allowEmptyShould(true)
                .because("QueryDSL Repository는 *QueryDslRepository 네이밍 규칙을 따라야 합니다");

            rule.check(allClasses);
        }
    }
}

3️⃣ 규칙 상세 설명

규칙 1: QueryDslRepository는 클래스

검증 내용: Repository 패키지의 *QueryDslRepository는 class여야 함

위반 예시:

// ❌ 인터페이스로 정의
public interface OrderQueryDslRepository {
}

올바른 예시:

// ✅ 클래스로 정의
@Repository
public class OrderQueryDslRepository {
}

규칙 2: @Repository 어노테이션

검증 내용: *QueryDslRepository 클래스는 @Repository 어노테이션 필수

위반 예시:

// ❌ @Repository 없음
public class OrderQueryDslRepository {
}

올바른 예시:

// ✅ @Repository 어노테이션
@Repository
public class OrderQueryDslRepository {
}

규칙 3: JPAQueryFactory 필드

검증 내용: JPAQueryFactory 타입 필드 보유 필수

위반 예시:

// ❌ JPAQueryFactory 없음
@Repository
public class OrderQueryDslRepository {
}

올바른 예시:

// ✅ JPAQueryFactory 필드
@Repository
public class OrderQueryDslRepository {
    private final JPAQueryFactory queryFactory;

    public OrderQueryDslRepository(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }
}

규칙 4: QType static final 필드

검증 내용: q* 패턴의 필드는 static final이어야 함

위반 예시:

// ❌ static final 아님
@Repository
public class OrderQueryDslRepository {
    private final QOrderJpaEntity qOrder = QOrderJpaEntity.orderJpaEntity;  // ❌
}

올바른 예시:

// ✅ static final 선언
@Repository
public class OrderQueryDslRepository {
    private static final QOrderJpaEntity qOrder = QOrderJpaEntity.orderJpaEntity;  // ✅
}

규칙 5: @Transactional 사용 금지

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

위반 예시:

// ❌ @Transactional 사용
@Repository
@Transactional  // ❌
public class OrderQueryDslRepository {
}

올바른 예시:

// ✅ @Transactional 없음
@Repository
public class OrderQueryDslRepository {
}

규칙 6: Mapper 의존성 금지

검증 내용: *Mapper 타입 의존성 금지

위반 예시:

// ❌ Mapper 의존성
@Repository
public class OrderQueryDslRepository {
    private final OrderJpaEntityMapper mapper;  // ❌

    public List<OrderDomain> search(SearchOrderQuery query) {  // ❌
        List<OrderJpaEntity> entities = queryFactory.selectFrom(qOrder).fetch();
        return entities.stream()
            .map(mapper::toDomain)  // ❌
            .toList();
    }
}

올바른 예시:

// ✅ Mapper 의존성 없음
@Repository
public class OrderQueryDslRepository {
    // Mapper 없음

    public List<OrderJpaEntity> search(SearchOrderQuery query) {  // ✅
        return queryFactory.selectFrom(qOrder).fetch();
    }
}

// ✅ Adapter에서 Mapper 사용
@Component
public class OrderQueryPersistenceAdapter {
    private final OrderQueryDslRepository repository;
    private final OrderJpaEntityMapper mapper;  // ✅

    public List<OrderDomain> search(SearchOrderQuery query) {
        List<OrderJpaEntity> entities = repository.search(query);
        return entities.stream()
            .map(mapper::toDomain)  // ✅
            .toList();
    }
}

4️⃣ 실행 방법

Gradle 실행

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

# QueryDSL Repository ArchUnit만 실행
./gradlew test --tests "*QueryDslRepositoryArchTest"

IDE 실행


5️⃣ 위반 시 대응

1단계: 위반 로그 확인

java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] -
Rule 'classes that have simple name ending with 'QueryDslRepository'
should be annotated with @Repository' was violated (1 times):
Class <OrderQueryDslRepository> is not annotated with @Repository

2단계: 위반 원인 파악

3단계: 코드 수정

// Before (위반)
public class OrderQueryDslRepository {  // ❌ @Repository 없음
}

// After (수정)
@Repository
public class OrderQueryDslRepository {  // ✅
}

4단계: 재검증

./gradlew test --tests "*QueryDslRepositoryArchTest"

6️⃣ 체크리스트

ArchUnit 테스트 작성 시:


7️⃣ 참고 문서


작성자: Development Team 최종 수정일: 2025-12-09 버전: 3.0.0 (유연한 메서드 패턴 적용)