QueryDSL 가이드 (6/8)

이전 편: [QueryDSL] 5. 프로젝션과 DTO 매핑

다음 편: [QueryDSL] 7. 성능 최적화

QueryDSL로 쿼리를 작성하는 법은 배웠다. 이제 이것을 Spring Data JPA의 Repository 패턴과 어떻게 합치는지가 문제다. 기본 CRUD는 JpaRepository가 처리하고, 복잡한 조회는 QueryDSL이 담당하는 구조를 만들어야 한다.

커스텀 Repository 패턴

Spring Data JPA에서 QueryDSL을 쓰는 표준 패턴은 커스텀 Repository다. 세 개의 파일로 구성된다.

classDiagram direction BT class JpaRepository { <> +save() +findById() +findAll() } class FeedRepositoryCustom { <> +search(condition) +findRecentFeeds(limit) } class FeedRepository { <> } class FeedRepositoryImpl { -JPAQueryFactory queryFactory +search(condition) +findRecentFeeds(limit) } FeedRepository --|> JpaRepository FeedRepository --|> FeedRepositoryCustom FeedRepositoryImpl ..|> FeedRepositoryCustom

커스텀 인터페이스

QueryDSL로 구현할 메서드를 선언한다.

public interface FeedRepositoryCustom {
    List<Feed> search(FeedSearchCondition condition);
    List<Feed> findRecentFeeds(int limit);
}

이 인터페이스에는 @Repository 같은 어노테이션이 필요 없다. 순수한 Java 인터페이스다.

구현체

커스텀 인터페이스를 구현하고, JPAQueryFactory를 주입받아 QueryDSL 쿼리를 작성한다.

@RequiredArgsConstructor
public class FeedRepositoryImpl implements FeedRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<Feed> search(FeedSearchCondition condition) {
        return queryFactory
                .selectFrom(feed)
                .join(feed.user, user).fetchJoin()
                .where(
                        titleContains(condition.title()),
                        regionEq(condition.region())
                )
                .orderBy(feed.createdAt.desc())
                .fetch();
    }

    @Override
    public List<Feed> findRecentFeeds(int limit) {
        return queryFactory
                .selectFrom(feed)
                .orderBy(feed.createdAt.desc())
                .limit(limit)
                .fetch();
    }

    private BooleanExpression titleContains(String title) {
        return title != null ? feed.title.contains(title) : null;
    }

    private BooleanExpression regionEq(String region) {
        return region != null ? feed.region.eq(region) : null;
    }
}

JpaRepository에 합치기

기존 JpaRepository가 커스텀 인터페이스를 추가로 상속하면 된다.

public interface FeedRepository extends JpaRepository<Feed, Long>, FeedRepositoryCustom {
    // JpaRepository의 기본 메서드 + 커스텀 메서드가 모두 사용 가능
}

이제 FeedRepository를 주입받으면 save(), findById() 같은 기본 메서드와 search(), findRecentFeeds() 같은 커스텀 메서드를 모두 쓸 수 있다.

sequenceDiagram participant S as Service participant P as FeedRepository
(Proxy) participant JPA as SimpleJpaRepository participant Impl as FeedRepositoryImpl S->>P: save(feed) P->>JPA: 기본 메서드 위임 JPA-->>S: 결과 반환 S->>P: search(condition) P->>Impl: 커스텀 메서드 위임 Impl-->>S: 결과 반환
구현체 네이밍 규칙

구현체의 이름은 반드시 JpaRepository 인터페이스명 + Impl이어야 한다. FeedRepository가 메인 인터페이스이면 구현체는 FeedRepositoryImpl이다. Spring Data JPA가 이 네이밍 규칙으로 구현체를 자동 인식하여 프록시에 연결한다. FeedRepositoryCustomImpl이 아니라 FeedRepositoryImpl이다.

별도 쿼리 Repository 패턴

커스텀 Repository가 항상 정답은 아니다. 조회 쿼리가 많아지면 하나의 FeedRepositoryImpl에 메서드가 수십 개씩 쌓이고, 네이밍 규칙도 신경 써야 한다.

graph TD subgraph "커스텀 Repository 패턴" S1[Service] --> FR1[FeedRepository] FR1 --> JPA1[기본 CRUD] FR1 --> IMPL1[커스텀 조회] end subgraph "별도 Repository 패턴" S2[Service] --> FR2[FeedRepository] S2 --> FQR[FeedQueryRepository] FR2 --> JPA2[기본 CRUD] FQR --> IMPL2[복잡한 조회] end

대안으로, QueryDSL 전용 Repository를 별도로 만드는 방법이 있다.

@Repository
@RequiredArgsConstructor
public class FeedQueryRepository {

    private final JPAQueryFactory queryFactory;

    public List<Feed> search(FeedSearchCondition condition) {
        return queryFactory
                .selectFrom(feed)
                .where(
                        titleContains(condition.title()),
                        regionEq(condition.region())
                )
                .fetch();
    }

    // 조건 메서드들...
}

이 클래스는 Spring Data JPA와 무관한 일반 @Repository 빈이다. Service에서 FeedRepositoryFeedQueryRepository를 각각 주입받아 사용한다.

@Service
@RequiredArgsConstructor
public class FeedService {
    private final FeedRepository feedRepository;           // 기본 CRUD
    private final FeedQueryRepository feedQueryRepository;  // 복잡한 조회
}

어떤 패턴을 선택할까

커스텀 Repository

  • 하나의 인터페이스로 모든 메서드에 접근할 수 있다.
  • Spring Data JPA의 공식 확장 방식이다.
  • Impl 네이밍 규칙을 지켜야 한다.
  • 메서드가 많아지면 구현체가 비대해진다.

별도 쿼리 Repository

  • 조회 전용 로직을 분리하여 관심사가 명확하다.
  • 네이밍 규칙에 묶이지 않는다.
  • Service에서 Repository를 두 개 주입받아야 한다.
  • 팀 내 컨벤션이 필요하다.

두 방식을 섞어 쓰는 것도 가능하다. 간단한 커스텀 조회는 커스텀 Repository에, 복잡한 검색 쿼리는 별도 Repository에 두는 식이다. 프로젝트의 규모와 팀 컨벤션에 맞게 선택하면 된다.

QuerydslRepositorySupport

QueryDSL이 제공하는 추상 클래스로, EntityManagerQuerydsl 헬퍼를 자동으로 설정해준다.

public class FeedRepositoryImpl extends QuerydslRepositorySupport
        implements FeedRepositoryCustom {

    public FeedRepositoryImpl() {
        super(Feed.class);
    }

    @Override
    public List<Feed> search(FeedSearchCondition condition) {
        return from(feed)
                .where(titleContains(condition.title()))
                .fetch();
    }
}

from()을 바로 쓸 수 있어서 편리해 보이지만, 몇 가지 문제가 있다.

  • select로 시작할 수 없다. 항상 from으로 시작해야 한다.
  • JPAQueryFactory를 직접 사용할 때보다 API가 제한적이다.
  • Spring Data JPA의 Sort와의 통합이 번거롭다.
graph LR subgraph "QuerydslRepositorySupport" QRS["from()으로 시작
select 불가"] end subgraph "JPAQueryFactory 직접 사용" JQF["select/from 자유
유연한 API"] end QRS -.->|"레거시"| OLD[기존 프로젝트] JQF -->|"권장"| NEW[새 프로젝트] style JQF fill:#9f9,stroke:#333

실무에서는 JPAQueryFactory를 직접 주입받는 방식이 더 선호된다. QuerydslRepositorySupport는 레거시 프로젝트에서 볼 수 있지만, 새 프로젝트에서는 굳이 쓸 필요 없다.

자주 하는 실수

Impl 클래스 이름 오류

FeedRepository에 커스텀 인터페이스를 연결했는데, 구현체를 FeedCustomRepositoryImpl이나 FeedRepositoryCustomImpl로 이름 지으면 Spring이 인식하지 못한다. 반드시 FeedRepositoryImpl이어야 한다. 메인 JpaRepository 인터페이스 이름 + Impl이 규칙이다.

[!DANGER] 커스텀 인터페이스를 JpaRepository에 연결하지 않음

FeedRepositoryCustom을 만들고 FeedRepositoryImpl도 만들었는데, FeedRepository extends JpaRepository<Feed, Long>FeedRepositoryCustom을 추가 상속하지 않으면 커스텀 메서드가 연결되지 않는다.

[!DANGER] Impl 클래스에 @Repository 생략

커스텀 Repository 패턴에서 Impl 클래스에는 @Repository를 붙이지 않는다. Spring Data JPA가 자동으로 프록시에 연결하기 때문이다. 반면, 별도 쿼리 Repository 패턴에서는 @Repository를 붙여야 스프링 빈으로 등록된다. 두 패턴을 혼동하지 않도록 주의한다.