본문 바로가기
Framework/Spring Boot

[Spring Boot] QueryDSL 심화 - 동적 쿼리, 서브 쿼리(Sub-Query), 그리고 튜플(Tuple) 추출과 DTO 프로젝션 성능 최적화의 끝판왕

반응형

우리는 지난 58단계를 통해 JPA의 고질적인 퍼포먼스 버그인 N+1 문제를 @BatchSize 설정 하나로 우아하게 부숴버렸습니다. 정적인 조회 쿼리, 즉 검색 조건이 딱 정해져 있는 findAllByUsername(String username) 같은 쿼리는 스프링 데이터 JPA가 제공하는 메서드 이름명명 규칙(Query Method)이나 @Query 애노테이션으로 작성한 JPQL만으로도 충분히 아름답게 처리할 수 있습니다.

하지만 여러분이 실무에서 마주하게 될 진짜 난관은 '어드민 백오피스(Admin Backoffice)' 화면입니다. 검색 폼에 아이디, 이름, 휴대폰 번호, 주문 날짜 범위, 회원 등급 등 무려 10개가 넘는 검색 필터 조건이 존재하고, 관리자가 이 중 몇 개의 조건을 입력할지 실행 전까지는 아무도 모르는 미친 듯이 복잡한 '동적 쿼리(Dynamic Query)'를 짜야하는 상황이 반드시 닥칩니다.

만약 JPA의 기본 기능인 JPQL 문자열(String)을 더하기(+) 연산자로 이어 붙이면서 동적 쿼리를 짠다면, 띄어쓰기 하나 잘못해서 Syntax Error가 터지고, NullPointerException을 맞으며 며칠 밤을 꼬박 새우게 될 것입니다. 이를 완벽하게 구원하기 위해 자바(Java) 코드로 타입 세이프(Type-safe)하게 쿼리를 조립할 수 있게 해주는 궁극의 기술이 바로 QueryDSL(쿼리디에스엘)입니다.

이번 59단계 포스팅에서는 QueryDSL의 단순한 기본 사용법을 아득히 넘어, BooleanBuilderBooleanExpression을 활용한 극강의 동적 쿼리 설계법, 실무에서 까다로운 서브 쿼리(Sub-Query) 작성법, 그리고 엔티티를 통째로 조회하지 않고 필요한 컬럼만 쏙쏙 뽑아내는 튜플(Tuple) 추출과 DTO 프로젝션 성능 최적화 기법에 대해 상세히 알아보겠습니다.

1. 동적 쿼리의 혁명: BooleanBuilder vs BooleanExpression

동적 쿼리를 작성할 때 가장 기초적인 방법은 BooleanBuilder 객체를 생성한 뒤, 파라미터가 Null이 아닐 때마다 if문을 사용해 builder.and(member.name.eq(name)) 식으로 조건을 하나씩 누적시켜 나가는 것입니다. 주니어 시절에는 이 방법이 가장 직관적이라 많이 쓰지만, 조건이 10개가 넘어가면 코드가 지저분한 if문 덩어리가 되어 가독성이 완전히 파괴됩니다.

QueryDSL의 BooleanExpression을 활용한 동적 쿼리 조립 패턴 및 DTO 프로젝션 동작 원리를 보여주는 아키텍처 다이어그램

 

실무 고수들은 BooleanBuilder 대신 BooleanExpression 메서드를 체이닝(Chaining)하는 방식을 압도적으로 선호합니다. 각 검색 조건을 반환하는 작은 메서드들을 분리하여 생성하고, 메인 쿼리의 where() 절에서 이 메서드들을 쉼표(,) 연산자로 이어 붙입니다. 만약 파라미터가 Null이라면 해당 메서드는 null을 반환하게 되는데, QueryDSL의 where() 절은 인자로 null이 들어오면 아무 에러 없이 자동으로 그 조건을 무시해 버립니다! 이 놀라운 특징 덕분에 if문 없이도 기가 막히게 깔끔하고 재사용 가능한 동적 쿼리 코드가 탄생합니다.

// 💡 BooleanExpression을 활용한 예술적인 동적 쿼리 작성법
public List<Member> searchDynamicMembers(String username, Integer age, String teamName) {
    return queryFactory
            .selectFrom(member)
            .leftJoin(member.team, team)
            .where(
                // null이 반환되면 QueryDSL이 자동으로 조건을 무시함!
                usernameEq(username), 
                ageGoe(age),
                teamNameEq(teamName)
            )
            .fetch();
}

// 각각의 검색 조건을 분리된 메서드로 재사용 가능하게 선언
private BooleanExpression usernameEq(String username) {
    return StringUtils.hasText(username) ? member.username.eq(username) : null;
}
private BooleanExpression ageGoe(Integer age) {
    return age != null ? member.age.goe(age) : null;
}
private BooleanExpression teamNameEq(String teamName) {
    return StringUtils.hasText(teamName) ? team.name.eq(teamName) : null;
}

2. 뼈아픈 한계: 서브 쿼리(Sub-Query) 작성의 난제와 우회술

실무 통계 쿼리를 짜다 보면 "전체 회원들의 평균 나이보다 나이가 많은 회원들만 조회하라" 같은 요구사항이 떨어집니다. 이를 위해서는 WHERE 절 안에 또 다른 SELECT 문을 넣는 서브 쿼리가 필수적입니다. QueryDSL에서는 JPAExpressions 클래스를 사용하여 서브 쿼리를 매우 직관적으로 작성할 수 있습니다.

⚠️ 치명적인 주의사항 (From 절 서브 쿼리 불가): 하지만 여기서 수많은 개발자가 좌절을 맛봅니다. JPA(정확히는 Hibernate)와 QueryDSL의 치명적인 설계 한계상, SELECT 절이나 WHERE 절에는 서브 쿼리를 자유롭게 쓸 수 있지만, FROM 절 내부에는 절대로 서브 쿼리(인라인 뷰)를 사용할 수 없습니다. 오라클(Oracle) 문법에 익숙한 레거시 DBA들이 FROM 절 안에 서브 쿼리를 이중 삼중으로 말아놓은 미친 쿼리를 던져주며 "이거 JPA로 바꿔주세요"라고 하면 아키텍트들은 머리를 쥐어뜯게 됩니다.

FROM 절 서브 쿼리를 해결하는 실무 우회 방법은 세 가지입니다.
1. 서브 쿼리를 일반적인 JOIN으로 변경할 수 있는지 쿼리 구조를 완전히 재설계한다. (대부분 조인으로 해결 가능합니다.)
2. 애플리케이션 단(Java)으로 데이터를 먼저 가져온 뒤, 메모리에서 Stream API를 돌려 그룹핑 및 필터링 처리를 한다.
3. 복잡한 통계 쿼리나 도무지 조인으로 풀 수 없는 인라인 뷰라면 억지로 JPA로 풀려 하지 말고 MyBatis나 JdbcTemplate, Native Query를 사용하여 SQL 그대로를 던진다.

3. 극강의 성능 튜닝: Tuple과 DTO 프로젝션 (@QueryProjection)

JPA를 쓸 때 가장 많은 성능 낭비가 발생하는 부분이 바로 '데이터 타입'입니다. 회원(Member) 테이블에 컬럼이 50개가 넘는다고 가정해 봅시다. 메인 화면에 띄워줄 데이터는 회원의 '이름'과 '나이' 단 두 개뿐인데, selectFrom(member)로 엔티티 전체를 조회하면 쓰지도 않을 48개의 컬럼 데이터까지 DB에서 네트워크를 타고 자바 메모리로 몽땅 끌고 오게 됩니다. 이는 막대한 대역폭 낭비와 힙 메모리(Heap Memory) 낭비를 초래합니다. 이때 필요한 것이 조회 대상 컬럼을 명확하게 좁히는 프로젝션(Projection) 기술입니다.

QueryDSL에서 프로젝션을 할 때 반환 타입이 애매하면 Tuple이라는 자체 객체를 반환합니다. 하지만 Tuple은 서비스 계층(Service Layer)까지 끌고 가면 QueryDSL 기술에 강하게 종속되는 나쁜 구조(결합도 상승)가 되므로 피해야 합니다. 최고의 실무 패턴은 DTO로 직접 데이터를 꽂아버리는 것입니다. 생성자(Constructor) 접근, 필드(Fields) 직접 접근, Setter 접근 등 여러 방식이 있지만, 가장 타입 안전하고 컴파일 시점에 에러를 완벽하게 잡아주는 궁극의 기술은 DTO의 생성자에 @QueryProjection 애노테이션을 달아주는 것입니다.

// 💡 @QueryProjection을 사용한 완벽한 DTO 프로젝션
@Data
public class MemberDto {
    private String username;
    private int age;

    @QueryProjection // 빌드 시 QMemberDto 라는 Q클래스가 자동 생성됨!
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

// 🚀 QueryDSL Repository 구현부 (엔티티를 거치지 않고 가벼운 DTO로 즉시 반환)
public List<MemberDto> findMemberDtoByProjection() {
    return queryFactory
            .select(new QMemberDto(member.username, member.age)) // 50개 컬럼 중 딱 2개만 SELECT 절에 포함!
            .from(member)
            .fetch();
}

이렇게 @QueryProjection을 사용하면, QueryDSL 컴파일 시점에 파라미터 타입이 다르면 컴파일 에러를 뿜어주므로 런타임 에러를 100% 방지할 수 있습니다. 또한 DB에서 딱 2개의 컬럼만 SELECT 하여 곧바로 DTO로 매핑하므로 서버의 메모리 부하가 극단적으로 줄어들게 됩니다.

정리하며

이번 59단계 포스팅에서는 어드민 화면의 필수 요구사항인 복잡한 동적 쿼리를 BooleanExpression의 체이닝 마법으로 깔끔하게 제어하는 방법, 서브 쿼리(Sub-Query) 작성의 치명적 한계(FROM 절 사용 불가)와 우회 기법, 그리고 불필요한 엔티티 데이터 로드를 차단하고 서버 메모리를 사수하는 @QueryProjection 기반의 DTO 프로젝션 최적화 기술에 대해 뼈 때리게 파헤쳐 보았습니다. 문자열 조합의 공포에서 벗어나, 컴파일(Compile) 시점에 쿼리 에러를 100% 검출해 내는 QueryDSL은 실무 백엔드 개발자에게 신이 내린 선물과도 같습니다. 자, 이제 조회(Select) 튜닝의 끝을 보았습니다. 그렇다면 수만 명의 유저가 동시에 하나의 상품을 결제하려고 몰려드는 '데이터 수정(Update)' 상황에서는 어떤 비극이 벌어질까요? 내 포인트가 중복 차감되고, 남은 재고가 마이너스(-100개)가 되는 최악의 티켓팅 동시성 이슈!

다음 60단계(Phase 12의 마지막) 포스팅에서는 "동시성 제어의 절대 반지: 낙관적 락(Optimistic Lock) vs 비관적 락(Pessimistic Lock)의 철학과 분산 환경에서의 Retry 메커니즘"에 대해 아주 치밀하게 딥다이브 해보겠습니다!

반응형