Tuesday, June 12, 2018

실무 예제로 살펴보는 Spring Data JPA 동적 쿼리 구현

애플리케이션을 개발하다 보면 사용자의 입력에 따라 동적으로 변하는 검색 조건을 처리해야 하는 경우가 비일비재합니다. 예를 들어, 쇼핑몰의 상품 검색 페이지를 생각해보겠습니다. 사용자는 상품 카테고리, 최소/최대 가격, 브랜드, 색상, 재고 유무 등 수많은 필터를 조합하여 원하는 상품을 찾으려 할 것입니다. 이 모든 조합을 각각의 고정된 쿼리 메소드(e.g., `findByNameAndPriceBetweenAndCategory`)로 만들어두는 것은 사실상 불가능하며, 유지보수의 악몽을 초래할 것입니다.

이러한 문제를 해결하기 위해 Spring Data JPA는 '동적 쿼리'를 생성할 수 있는 여러 가지 강력한 메커니즘을 제공합니다. 단순히 '있고 없고' 수준의 조건을 넘어, 복잡한 비즈니스 로직을 쿼리에 녹여낼 수 있어야 견고한 백엔드 시스템을 구축할 수 있습니다. 본 글에서는 실무에서 가장 널리 사용되는 동적 쿼리 구현 전략 세 가지—Query by Example (QBE), JPA Specifications (명세), 그리고 Querydsl—를 심도 있게 비교 분석하고, 각각의 장단점과 최적의 사용 시나리오를 구체적인 예제 코드와 함께 제시합니다. 이를 통해 프로젝트의 성격과 복잡도에 맞는 최상의 기술을 선택할 수 있는 안목을 기르는 것을 목표로 합니다.


전략 1: Query by Example (QBE) - 가장 단순한 접근법

Query by Example(QBE)은 가장 직관적이고 간단한 동적 쿼리 생성 방식입니다. 이름 그대로, '예제'가 되는 도메인 객체(Probe)를 하나 만들고, null이 아닌 필드들을 기준으로 'AND' 조건을 묶어 쿼리를 자동으로 생성해줍니다. 특별한 인터페이스나 복잡한 빌더 클래스 없이, 기존 Repository의 `findAll()` 메소드에 `Example` 객체만 넘겨주면 되기 때문에 학습 곡선이 매우 낮습니다.

QBE의 기본 사용법

먼저 검색 조건으로 사용할 엔티티 클래스가 필요합니다. `Member`라는 엔티티가 이름(`username`), 나이(`age`), 소속팀(`team`) 정보를 가지고 있다고 가정해봅시다.


@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String username;
    private Integer age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    // ... 생성자 등
}

이제 사용자가 입력한 검색 조건을 담을 DTO(또는 엔티티 객체 자체)를 Controller에서 받아 `Example.of()`를 사용해 동적 쿼리를 실행할 수 있습니다.


// MemberService.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

    private final MemberRepository memberRepository;

    public List findMembersByExample(MemberSearchCondition condition) {
        // 1. 검색 조건을 담을 Probe(예제) 객체 생성
        Member probe = new Member();
        probe.setUsername(condition.getUsername()); // 이름이 있으면 설정
        probe.setAge(condition.getAge());       // 나이가 있으면 설정

        // Team 엔티티도 조인 조건으로 활용 가능
        if (condition.getTeamName() != null) {
            probe.setTeam(new Team(condition.getTeamName()));
        }

        // 2. Example 객체 생성 (기본적으로 null 필드는 무시)
        Example example = Example.of(probe);
        
        // 3. JpaRepository의 findAll(Example) 실행
        return memberRepository.findAll(example);
    }
}

// MemberController.java
@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @GetMapping("/members/qbe")
    public List findMembersByQbe(@ModelAttribute MemberSearchCondition condition) {
        return memberService.findMembersByExample(condition).stream()
                .map(MemberDto::new)
                .collect(Collectors.toList());
    }
}

위 코드에서 `condition.getUsername()`이 "John"이고 `condition.getAge()`가 null이라면, QBE는 `WHERE username = 'John'` 이라는 쿼리를 생성합니다. 만약 둘 다 값이 있다면 `WHERE username = 'John' AND age = 30` 과 같이 동적으로 AND 조건이 추가됩니다. 매우 간단하죠.

`ExampleMatcher`를 이용한 상세 설정

기본 QBE는 정확히 일치(exact match)하는 조건만 처리하며, 모든 조건을 AND로 묶습니다. 하지만 실무에서는 '포함' 검색(LIKE), 특정 필드 무시, 대소문자 구분 없음 등의 요구사항이 훨씬 많습니다. 이때 `ExampleMatcher`를 사용해 검색 방식을 커스터마이징할 수 있습니다.


public List findMembersWithMatcher(MemberSearchCondition condition) {
    Member probe = new Member();
    probe.setUsername(condition.getUsername());
    
    // ExampleMatcher를 사용한 상세 설정
    ExampleMatcher matcher = ExampleMatcher.matching()
            // username 필드는 '포함' 조건으로 검색 (e.g., WHERE username LIKE '%...%')
            .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING) 
            // 대소문자 구분 없이 검색
            .withIgnoreCase() 
            // "age" 필드는 검색 조건에서 명시적으로 제외
            .withIgnorePaths("age"); 

    Example example = Example.of(probe, matcher);
    return memberRepository.findAll(example);
}

`ExampleMatcher`는 이 외에도 `withMatcher()`를 통해 각 필드별로 다른 매칭 전략을 적용하거나, `withIgnoreNullValues()`(기본값), `withNullHandler()` 등의 옵션을 제공하여 유연성을 높여줍니다.

QBE의 명확한 한계

QBE는 단순한 케이스에서는 매우 편리하지만, 복잡한 비즈니스 로직을 담기에는 명확한 한계점을 가집니다.

  • 제한적인 조건자(Predicate): `BETWEEN`, `GREATER THAN (>)`, `LESS THAN (<)`과 같은 범위 검색이나 `IN`절을 사용할 수 없습니다. `age > 30`과 같은 조건은 QBE로 표현할 수 없습니다.
  • OR 조건의 한계: 모든 조건은 기본적으로 AND로 묶입니다. 여러 필드에 걸친 OR 조건(예: `username = 'A' OR team_name = 'B'`)을 구현하기 어렵습니다. `ExampleMatcher.matchingAny()`를 사용하면 모든 조건을 OR로 묶을 수는 있지만, AND와 OR를 조합하는 것은 불가능합니다.
  • 복잡한 조인 및 중첩 구조 한계: 외래 키를 통한 Inner Join은 가능하지만, Left Outer Join을 명시적으로 사용하거나, 조인 대상 테이블의 필드를 상세하게 제어하기 어렵습니다.
  • 프로퍼티 경로 제한: `member.team.name` 과 같이 중첩된 프로퍼티 경로에 대한 매칭이 불가능합니다. 오직 1 depth의 프로퍼티만 지원합니다.

따라서 QBE는 관리자 페이지의 간단한 목록 필터링 기능 등, 복잡성이 낮은 조회 기능에 제한적으로 사용하는 것이 바람직합니다.


전략 2: JPA Specifications (명세) - 표준적이고 강력한 대안

JPA Criteria API를 더 쉽게 사용하도록 Spring Data JPA가 추상화해 놓은 것이 바로 '명세(Specifications)'입니다. 명세는 쿼리의 `where` 절 자체를 하나의 객체로 간주하여, 이를 조합(Composition)함으로써 동적 쿼리를 생성하는 방식입니다. 타입-세이프(Type-safe)하며, 거의 모든 종류의 복잡한 쿼리 조건을 만들어낼 수 있어 QBE의 한계를 완벽하게 극복할 수 있습니다.

Specifications를 사용하려면 Repository가 `JpaSpecificationExecutor` 인터페이스를 상속받아야 합니다.


public interface MemberRepository extends JpaRepository, JpaSpecificationExecutor {
}

Specification의 기본 구조와 사용법

`Specification`은 `toPredicate` 라는 단 하나의 메소드를 가진 함수형 인터페이스입니다. 이 메소드는 `Root`, `CriteriaQuery`, `CriteriaBuilder` 세 가지 파라미터를 받아 최종적인 조건절(Predicate)을 반환합니다.

  • `Root`: 쿼리의 루트 엔티티. 즉, 쿼리의 시작점(`FROM Member`). 이를 통해 엔티티의 속성에 접근할 수 있습니다.
  • `CriteriaQuery`: 쿼리 자체에 대한 정보를 담고 있으며, `orderBy`, `groupBy` 등을 정의할 수 있습니다.
  • `CriteriaBuilder`: 조건(Predicate)을 생성하는 빌더. `equal`, `like`, `greaterThan`, `and`, `or` 등 쿼리 조건을 만드는 데 필요한 거의 모든 메소드를 제공합니다.

각각의 검색 조건을 별도의 `Specification` 객체로 분리하여 정의하는 것이 핵심입니다.


// MemberSpec.java (또는 MemberSpecifications)
public class MemberSpec {

    // 이름(username)으로 검색하는 Specification
    public static Specification hasUsername(String username) {
        return (root, query, builder) -> 
            builder.equal(root.get("username"), username);
    }

    // 특정 나이 이상인 멤버를 검색하는 Specification
    public static Specification isAgeGreaterThan(int age) {
        return (root, query, builder) -> 
            builder.greaterThan(root.get("age"), age);
    }

    // 팀 이름으로 검색하는 Specification (Join 필요)
    public static Specification hasTeamName(String teamName) {
        return (root, query, builder) -> {
            // Join을 사용하여 Team 엔티티에 조인
            Join team = root.join("team", JoinType.INNER);
            return builder.equal(team.get("name"), teamName);
        };
    }
}

문자열 기반의 필드 이름("username", "age") 대신 JPA Metamodel을 사용하면 컴파일 시점에 타입 체크가 가능하여 더욱 안전한 코드를 작성할 수 있으나, 설정이 번거로워 문자열 방식도 많이 사용됩니다.

Specification 조합(Composition)

개별적으로 만들어진 Specification들은 `and()`, `or()` 메소드를 통해 손쉽게 조합할 수 있습니다. 이것이 바로 Specifications의 진정한 강력함이 드러나는 부분입니다.


// MemberService.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

    private final MemberRepository memberRepository;

    public List findMembersBySpec(MemberSearchCondition condition) {
        // 기본이 되는 Specification. 항상 참인 조건으로 시작하면 null 체크를 피할 수 있다.
        Specification spec = Specification.where(null);

        if (condition.getUsername() != null && !condition.getUsername().isBlank()) {
            spec = spec.and(MemberSpec.hasUsername(condition.getUsername()));
        }

        if (condition.getAgeGoe() != null) { // Age Greater Or Equal
            spec = spec.and(MemberSpec.isAgeGreaterThan(condition.getAgeGoe() - 1));
        }

        if (condition.getTeamName() != null && !condition.getTeamName().isBlank()) {
            spec = spec.and(MemberSpec.hasTeamName(condition.getTeamName()));
        }

        return memberRepository.findAll(spec);
    }
}

위 코드를 보면, 사용자의 입력값 유무에 따라 `spec` 객체에 `and()`를 통해 조건들이 동적으로 추가되는 것을 확인할 수 있습니다. `Specification.where(null)`로 시작하면 첫 번째 `and()` 호출 시 `NullPointerException` 없이 안전하게 스펙을 조합할 수 있는 유용한 트릭입니다.

Specifications의 장단점

장점

  • 최상의 유연성과 표현력: JPA Criteria API의 모든 기능을 사용할 수 있으므로, 상상할 수 있는 거의 모든 `WHERE` 절을 표현할 수 있습니다. (서브쿼리, 복잡한 조인, 함수 호출 등)
  • 타입 세이프(Type-Safe): JPA Metamodel과 함께 사용하면 컴파일 시점에 오류를 잡을 수 있습니다.
  • 재사용성 및 조합 용이성: 각 검색 조건을 독립된 `Specification` 객체로 만들어 두면, 애플리케이션 전반에서 필요한 곳에 가져다 붙여 재사용하기 매우 편리합니다.
  • Spring Data 표준 기술: 별도의 라이브러리 추가 없이 Spring Data JPA에 내장된 표준 기능입니다.

단점

  • 높은 코드 장황함(Boilerplate): 간단한 쿼리 하나를 만드는 데도 `(root, query, builder) -> ...` 와 같은 람다식과 빌더 메소드를 반복적으로 작성해야 합니다. 코드가 직관적이지 않고 길어질 수 있습니다.
  • 학습 곡선: `Root`, `CriteriaQuery`, `CriteriaBuilder`의 개념과 사용법을 익히는 데 시간이 필요합니다. 직관적이지 않다는 평이 많습니다.

전략 3: Querydsl - 개발 생산성과 가독성의 왕

Querydsl은 정적 타입을 사용하여 SQL과 유사한 형태로 쿼리를 생성할 수 있게 해주는 프레임워크입니다. 즉, 문자열로 쿼리를 작성하는 것이 아니라, 자바 코드를 통해 쿼리를 빌드합니다. 가장 큰 특징은 Annotation Processor를 사용하여 프로젝트의 엔티티 클래스에 대한 'Q-타입' 클래스(`QMember.java` 등)를 컴파일 시점에 자동으로 생성한다는 것입니다. 이 Q-타입 클래스를 사용하면 필드명을 문자열이 아닌 코드(객체)로 참조할 수 있어 타입 안정성과 IDE의 자동 완성 기능을 100% 활용할 수 있습니다.

Querydsl 설정

Querydsl을 사용하려면 먼저 `build.gradle` (또는 `pom.xml`)에 의존성을 추가하고, Q-타입 생성을 위한 플러그인을 설정해야 합니다.


// build.gradle (Gradle 5.x 이상 기준)
plugins {
    // ...
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

dependencies {
    // ...
    implementation 'com.querydsl:querydsl-jpa'
    // kapt 또는 annotationProcessor로 Querydsl Annotation Processor 지정
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
}

// Q-타입 생성 경로 설정
def querydslDir = "$buildDir/generated/querydsl"

querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}

sourceSets {
    main.java.srcDir querydslDir
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    querydsl.extendsFrom compileOnly, annotationProcessor
}

compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}

빌드 도구 설정 후 프로젝트를 빌드(e.g., `./gradlew build`)하면 `build/generated/querydsl` 경로에 엔티티와 매핑되는 Q-타입들이 생성된 것을 확인할 수 있습니다.

Querydsl을 이용한 동적 쿼리 구현

Querydsl로 동적 쿼리를 작성하는 가장 좋은 방법은 `where` 절에 들어갈 조건(Predicate)을 반환하는 메소드를 만들고, 이들을 조합하는 것입니다. `BooleanBuilder`를 사용하는 방법도 있지만, `where` 절에 null을 전달하면 무시되는 특성을 활용하는 것이 더 깔끔하고 재사용성이 높습니다.

먼저, Querydsl을 직접 사용하기 위한 Custom Repository를 정의합니다.


// MemberRepositoryCustom.java (커스텀 인터페이스)
public interface MemberRepositoryCustom {
    List findBySearchCondition(MemberSearchCondition condition);
}

// MemberRepositoryImpl.java (커스텀 구현체, 이름 규칙이 중요: `...Impl`)
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List findBySearchCondition(MemberSearchCondition condition) {
        // QMember.member를 static import 하여 사용하는 것을 권장
        // import static com.example.project.entity.QMember.member;

        return queryFactory
                .selectFrom(member)
                .leftJoin(member.team, team).fetchJoin() // fetchJoin으로 N+1 문제 해결
                .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
                )
                .fetch();
    }
    
    // BooleanExpression으로 조건 메소드를 분리
    private BooleanExpression usernameEq(String username) {
        return hasText(username) ? member.username.eq(username) : null;
    }

    private BooleanExpression teamNameEq(String teamName) {
        return hasText(teamName) ? team.name.eq(teamName) : null;
    }

    private BooleanExpression ageGoe(Integer ageGoe) { // Greater or Equal
        return ageGoe != null ? member.age.goe(ageGoe) : null;
    }

    private BooleanExpression ageLoe(Integer ageLoe) { // Less or Equal
        return ageLoe != null ? member.age.loe(ageLoe) : null;
    }
}
// MemberRepository.java
public interface MemberRepository extends JpaRepository, MemberRepositoryCustom {
}

이 코드의 아름다움은 `where()` 메소드에 있습니다. `where()`는 파라미터로 `null`이 전달되면 해당 조건을 무시합니다. 따라서 각 조건 메소드(`usernameEq`, `ageGoe` 등)는 파라미터 값이 유효할 때만 `BooleanExpression`(Predicate의 Querydsl 버전)을 반환하고, 그렇지 않으면 `null`을 반환합니다. 덕분에 `if`문으로 덕지덕지 조건을 붙여나가는 코드를 작성할 필요 없이, 매우 선언적이고 깔끔한 쿼리를 구성할 수 있습니다.

또한, `usernameEq`와 같은 조건 메소드들은 다른 쿼리에서도 재사용될 수 있으며, `.and()`나 `.or()`를 사용해 쉽게 조합할 수도 있습니다.

// 예시: 이름이 A 이거나 나이가 10살 이상인 멤버
    .where(usernameEq("A").or(ageGoe(10)))

Querydsl의 장단점

장점

  • 압도적인 생산성과 가독성: 코드가 마치 SQL 문장처럼 읽힙니다. (`selectFrom(member).where(...)`) 복잡한 쿼리도 매우 직관적으로 작성할 수 있습니다.
  • 완벽한 타입 안정성 및 IDE 지원: Q-타입 덕분에 필드 이름 오타와 같은 실수는 컴파일 시점에 모두 잡힙니다. IDE의 강력한 자동완성 기능은 개발 속도를 비약적으로 향상시킵니다.
  • 동적 쿼리 작성의 용이성: `BooleanExpression`을 반환하는 메소드들을 조합하는 방식은 매우 우아하고 재사용성이 높습니다.
  • JPQL이 제공하는 모든 기능 지원: JPQL로 할 수 있는 모든 것은 Querydsl로도 가능합니다. (select, from, where, group by, having, join, subquery 등)

단점

  • 초기 설정의 번거로움: `build.gradle` 설정과 Annotation Processor 구성이 다소 복잡하게 느껴질 수 있습니다.
  • 외부 라이브러리 의존성: Spring Data JPA 표준이 아닌 제3자 라이브러리입니다. 라이브러리 버전 관리가 필요하며, 프로젝트에 새로운 기술 스택을 추가하는 것에 대한 부담이 있을 수 있습니다. (하지만 Java 생태계에서는 사실상 표준으로 받아들여집니다.)

어떤 전략을 선택해야 할까? (전략별 비교 분석)

세 가지 전략은 각기 다른 상황에서 빛을 발합니다. "어떤 것이 절대적으로 좋다"기보다는 "내 상황에 어떤 것이 가장 적합한가"를 판단하는 것이 중요합니다. 아래 표는 각 전략의 핵심적인 특징을 한눈에 비교합니다.

항목 Query by Example (QBE) JPA Specifications Querydsl
쿼리 표현력 낮음 (단순 AND, =, LIKE만 가능) 매우 높음 (Criteria의 모든 기능) 매우 높음 (JPQL의 모든 기능)
학습 곡선 매우 낮음 높음 (Criteria 개념 필요) 중간 (설정 후 SQL과 유사)
코드 가독성/직관성 높음 (간단한 경우) 낮음 (코드가 길고 복잡함) 매우 높음 (자바 코드가 SQL처럼 읽힘)
타입 안정성 낮음 (필드명 문자열 사용) 높음 (Metamodel 사용 시) 매우 높음 (Q-타입 기본)
코드 재사용성 거의 불가능 높음 (Spec 객체 재사용) 매우 높음 (BooleanExpression 메소드 재사용/조합)
의존성 내장 기능 내장 기능 외부 라이브러리

최종 권장 시나리오

  • Query by Example (QBE)를 선택할 때:

    프로젝트 규모가 매우 작고, 관리자 페이지 등에서 간단한 필터링(정확히 일치, 포함 검색) 기능만 필요할 때. 쿼리 복잡도가 증가할 가능성이 거의 없는 경우 빠른 개발 속도를 위해 고려해볼 수 있습니다.

  • JPA Specifications를 선택할 때:

    어떤 이유로든 외부 라이브러리를 추가하고 싶지 않을 때. Spring Data 생태계 내에서만 문제를 해결해야 하는 상황에서 복잡한 동적 쿼리가 필요하다면 유일한 대안입니다. 코드의 장황함을 감수할 수 있다면 충분히 강력한 솔루션입니다.

  • Querydsl을 선택할 때: (강력 추천)

    대부분의 실무 애플리케이션에 가장 권장되는 방식입니다. 복잡한 검색 조건, 통계 쿼리, 동적 정렬 등 비즈니스 요구사항이 조금이라도 복잡해질 가능성이 있다면 무조건 Querydsl을 선택하는 것이 장기적으로 이득입니다. 초기 설정의 허들을 넘고 나면, 비교할 수 없는 수준의 개발 생산성과 유지보수 편의성을 제공합니다.


결론

Spring Data JPA에서 동적 쿼리를 구현하는 것은 더 이상 어려운 과제가 아닙니다. QBE, Specifications, Querydsl이라는 세 가지 강력한 도구가 각기 다른 장단점을 가지고 개발자를 기다리고 있습니다. 단순히 코드를 짧게 만드는 것을 넘어, 애플리케이션의 유지보수성, 확장성, 그리고 동료 개발자와의 협업 효율성까지 고려하여 기술을 선택해야 합니다.

간단한 기능은 QBE로 빠르게 해결하고, 표준 기술을 고수해야 한다면 Specifications를 깊이 있게 학습하며, 대부분의 복잡하고 중요한 비즈니스 로직은 Querydsl을 통해 안정적이고 가독성 높게 구현하는 것이 현명한 전략일 것입니다. 이 글을 통해 각 기술의 본질을 이해하고, 당신의 다음 프로젝트에 최적의 동적 쿼리 전략을 자신 있게 적용할 수 있기를 바랍니다.


0 개의 댓글:

Post a Comment