QueryDSL Repository ArchUnit 가이드
목적: QueryDSL Repository 아키텍처 규칙 자동 검증 (유연한 메서드 패턴)
1️⃣ 검증 전략
ArchUnit이 검증하는 것
QueryDSL Repository 클래스:
- ✅ 클래스 타입 (class 키워드)
- ✅ @Repository 어노테이션
- ✅ JPAQueryFactory 필드 보유
- ✅ QType static final 필드 보유
- ✅ 필수 메서드 (2개): findById, existsById
- ✅ 허용 메서드 패턴: findBy, existsBy, search, count
- ✅ 금지 메서드: findAll (OOM 위험)
- ✅ Join 절대 금지 (join, leftJoin, rightJoin, fetchJoin 모두 금지)
- ✅ @Transactional 어노테이션 없음
- ✅ Mapper 의존성 없음
- ✅ 네이밍 규칙 (*QueryDslRepository)
검증 그룹 (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 실행
- IntelliJ IDEA:
QueryDslRepositoryArchTest.java우클릭 → Run - 개별 테스트: 각
@Test메서드 우클릭 → Run
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단계: 위반 원인 파악
- ArchUnit 로그에서 위반 클래스 확인
- querydsl-repository-guide.md 규칙 재확인
3단계: 코드 수정
// Before (위반)
public class OrderQueryDslRepository { // ❌ @Repository 없음
}
// After (수정)
@Repository
public class OrderQueryDslRepository { // ✅
}
4단계: 재검증
./gradlew test --tests "*QueryDslRepositoryArchTest"
6️⃣ 체크리스트
ArchUnit 테스트 작성 시:
- QueryDslRepository 검증
- 클래스 타입 검증
- @Repository 어노테이션 검증
- JPAQueryFactory 필드 검증
- QType static final 필드 검증
- 금지 사항 검증
- @Transactional 금지
- Mapper 의존성 금지
- 네이밍 규칙 검증
- *QueryDslRepository 네이밍 규칙
7️⃣ 참고 문서
- querydsl-repository-guide.md - QueryDSL Repository 컨벤션
- jpa-repository-archunit.md - JPA Repository ArchUnit
작성자: Development Team 최종 수정일: 2025-12-09 버전: 3.0.0 (유연한 메서드 패턴 적용)