Skip to main content
검색 조건 하나만 더 붙였을 뿐인데, repository method 이름이 문장처럼 길어져요.
지난 글에서는 database schema 변경을 Flyway와 Liquibase로 어떻게 이력화하는지 봤어요. 이제 schema 위에서 실제 데이터를 읽는 장면으로 다시 올라와 볼게요. 처음에는 repository method가 참 편해 보여요.
그런데 화면 검색 조건이 조금씩 늘어나면 금방 이런 질문을 만나죠.
“상태도 고르고, 기간도 고르고, 고객 이름도 검색하고, 결제 금액도 범위로 걸고, 최신순이나 금액순 정렬도 해야 하면 method 이름을 계속 늘려야 하나요?”
처음에는 이렇게 만들 수 있어요.
작동할 수는 있어요. 하지만 읽기 시작하는 순간 불안해져요. 조건이 하나 더 생기면 method가 또 늘어나고, 어떤 조건은 optional이고, 어떤 조건은 join이 필요하고, 어떤 정렬은 index를 타야 해요. 오늘 목표는 “Querydsl이 더 좋아요”나 “Specification이 더 좋아요” 같은 결론을 빨리 내는 게 아니에요. query가 복잡해지는 이유를 먼저 나누고, 그 복잡함을 어떤 도구가 어느 정도까지 감당하는지 보는 거예요.
이 글은 Spring Data JPA 4.x의 Specifications, Spring Data Querydsl extension, OpenFeign Querydsl 문서를 기준으로 작성했어요. Spring Data JPA 4.x 문서에는 Specification뿐 아니라 더 넓게 조합할 수 있는 PredicateSpecification도 등장하지만, 이 글은 기존 프로젝트와 예제가 가장 많이 만나는 JpaSpecificationExecutorSpecification을 중심으로 설명할게요. Querydsl은 현재 OpenFeign fork와 기존 Querydsl 자료가 함께 보일 수 있으니, 새 프로젝트에서는 사용하는 Spring Boot와 Spring Data 버전이 어떤 Querydsl 좌표를 지원하는지 확인해야 해요.

문제는 조건 개수가 아니라 “조건 조합”이에요

주문 목록 API를 만든다고 해볼게요. 처음 요구사항은 단순해요.
상태로만 찾으면 derived query method가 충분해요.
그런데 검색 화면은 보통 이렇게 자라요.
여기서 조건은 모두 항상 들어오지 않아요. 사용자는 상태만 고를 수도 있고, 기간만 고를 수도 있고, keyword를 비워둘 수도 있어요. 그러면 query는 이런 모양이 돼요. 이 그림에서 복잡한 지점은 조건이 많다는 사실 자체가 아니에요. 조건이 들어오기도 하고 빠지기도 하며, join과 정렬과 pagination이 함께 움직인다는 점이에요. 그래서 repository method 이름만 늘리는 방식은 금방 한계에 닿아요. 처음에는 여기까지만 잡아도 충분해요. 복잡한 query는 “method 이름이 길어지는 문제”가 아니라 검색 조건을 조합하고, 실행 SQL을 통제하고, 성능을 예측해야 하는 문제예요.

먼저 검색 조건을 객체로 분리해요

도구를 고르기 전에 검색 조건부터 이름 붙이면 좋아요. Controller parameter가 바로 repository method로 흘러가면 query가 자라는 방향을 잡기 어려워요.
이 객체는 “JPA가 어떻게 query를 만들지”가 아니라 “사용자가 무엇으로 검색하고 싶은지”를 표현해요. 이 한 단계가 있으면 뒤에서 Specification을 쓰든 Querydsl을 쓰든 코드가 덜 흔들려요. 이 그림의 핵심은 검색 의도와 query 구현을 분리하는 거예요. 사용자의 요청 모양이 바뀌는 일과 JPA query 작성 방식이 바뀌는 일을 같은 곳에 섞지 않으면, 나중에 도구를 바꾸기도 쉬워져요.

단순한 query는 derived method와 @Query가 여전히 좋아요

모든 query를 처음부터 Querydsl로 밀어 넣을 필요는 없어요. 단순한 조건은 Spring Data JPA repository method가 더 읽기 좋을 때가 많아요.
이 정도는 이름만 봐도 의도가 분명해요. 별도 query builder를 만들면 오히려 장식이 많아질 수 있어요. 반대로 query가 고정되어 있고 SQL이나 JPQL 모양을 직접 보여주는 편이 낫다면 @Query도 좋은 선택이에요.
@Query는 이런 경우에 잘 맞아요. 하지만 조건이 optional로 늘어나면 @Query도 금방 복잡해져요. (:status is null or o.status = :status) 같은 조건이 계속 붙으면 query는 하나지만 의미는 흐려질 수 있어요.
고정 query는 derived method나 @Query가 읽기 좋을 수 있어요. 동적 query는 조건 조합을 코드로 다룰 수 있는 Specification이나 Querydsl 쪽이 자연스러워져요.

Specification은 조건 조각을 조립하는 방식이에요

Spring Data JPA의 Specification은 JPA Criteria API 위에서 조건을 조립하게 해주는 작은 abstraction이에요. repository는 JpaSpecificationExecutor를 함께 확장해요.
이제 조건을 작은 method로 나눌 수 있어요.
그리고 service에서는 들어온 조건만 골라 조립해요.
여기서 Specification.allOf(...)는 여러 조건을 and로 묶는 흐름이에요. 빈 목록이면 제한 없는 조건처럼 동작할 수 있어서, 예전처럼 Specification.where(null) 같은 모양으로 시작하지 않아도 돼요. Specification의 장점은 분명해요. 하지만 단점도 있어요. root.get("status")처럼 field 이름을 문자열로 쓰는 부분은 compile-time에 안전하지 않아요. join, fetch join, group by, projection, 복잡한 order가 늘어나면 Criteria API 특유의 문법이 읽기 어려워질 수 있어요.
Specification은 조건 조합에는 좋지만, 복잡한 select shape, fetch join, 집계, 전용 DTO projection까지 한곳에 억지로 넣으면 코드가 Criteria API 세부사항으로 가득 차기 쉬워요.

Querydsl은 query를 Java 코드로 쓰되 type-safe하게 잡아요

Querydsl은 SQL 비슷한 query를 Java의 fluent API로 만들게 해주는 도구예요. JPA에서는 Entity를 기준으로 QOrder, QCustomer 같은 Q type을 생성하고, 그 Q type을 사용해 query를 작성해요. 예를 들어 이런 Entity가 있다고 해볼게요.
Querydsl annotation processing이 설정되어 있으면 compile 과정에서 QOrder 같은 class가 만들어져요. 그러면 query는 이런 식으로 쓸 수 있어요.
여기서 눈여겨볼 부분은 문자열 field 이름이 아니라 order.createdAt, order.customer.name 같은 generated type을 쓴다는 점이에요. field 이름이 바뀌면 compile 단계에서 깨질 가능성이 커져요. query 모양도 JPQL 문자열보다 Java method chain으로 읽혀요. 이 그림에서 Querydsl의 힘은 Q type에 있어요. query를 문자열로 쓰지 않고도 Entity field와 연동된 표현으로 작성할 수 있기 때문이에요. Querydsl은 이런 경우에 특히 좋아요. 대신 초기 설정이 필요해요. Q type 생성을 위한 annotation processing, Gradle/Maven 설정, IDE generated sources 인식, Spring Data와 Querydsl fork 좌표 호환성을 챙겨야 해요. 단순 query 몇 개 때문에 도입하면 배보다 배꼽이 커질 수 있어요.
Spring Data 문서는 Querydsl의 유지보수 흐름이 느려지면서 OpenFeign fork가 생겼고, Spring Data가 그 fork를 best-effort로 지원한다고 설명해요. 그래서 새 프로젝트에서는 com.querydsl 자료와 io.github.openfeign.querydsl 자료가 함께 보일 수 있어요. 블로그 예시를 그대로 복사하기보다 프로젝트의 Spring Boot, Spring Data, Querydsl 버전 조합을 먼저 확인하세요.

QuerydslPredicateExecutor는 편하지만 API 경계를 흐릴 수 있어요

Spring Data에는 Querydsl predicate를 repository에 붙이는 방식도 있어요.
이 방식은 단순 predicate 실행에는 편해요. 하지만 검색 API 전체를 이 모양에 의존시키면 조심해야 해요. 이 그림에서 위험한 건 Entity field 구조가 API 검색 계약으로 새어 나가는 일이에요. 외부 API는 status, from, to, keyword 같은 사용자의 언어로 말해야 하는데, 내부 query predicate가 앞쪽으로 올라오면 Entity 구조 변경이 API 변경처럼 번질 수 있어요. 그래서 실무에서는 보통 이렇게 나눠요.
  • 단순 내부 admin 화면이나 빠른 검색에는 QuerydslPredicateExecutor가 편할 수 있어요.
  • 공개 API나 오래 유지할 검색 화면은 OrderSearchCondition 같은 별도 조건 객체를 두는 편이 좋아요.
  • join, fetch join, DTO projection, count query 최적화가 필요하면 custom query repository가 더 명확해요.
즉 Querydsl을 쓰더라도 “predicate를 어디까지 노출할 것인가”는 별도 설계 문제예요.

Pagination과 sorting은 화면 옵션이 아니라 query 성능 문제예요

검색 API에서 page, size, sort는 단순한 UI parameter처럼 보이지만, database 입장에서는 실행 비용을 바꾸는 조건이에요. Spring Data repository에서는 Pageable을 자주 써요.
이 방식은 편해요. content query와 count query를 함께 다루고, page metadata를 응답으로 만들기 좋거든요. 하지만 데이터가 많아지면 아래 질문을 봐야 해요. 그래서 정렬은 안정적으로 잡는 편이 좋아요. 예를 들어 최신순이면 createdAt만 쓰지 말고 같은 시각에 만들어진 row를 위해 id까지 함께 정렬할 수 있어요.
이건 단순 취향이 아니에요. 같은 createdAt 값을 가진 row가 여러 개 있을 때 DB가 매번 같은 순서를 보장하지 않으면, 사용자는 다음 page에서 같은 주문을 다시 보거나 어떤 주문을 건너뛸 수 있어요. 이 그림에서 pagination은 where 뒤에 살짝 붙는 장식이 아니에요. 정렬, limit, offset, count가 함께 움직여서 응답 시간과 안정성을 결정해요.
DB가 row를 항상 같은 순서로 돌려준다고 기대하면 안 돼요. page API에는 명시적인 정렬 기준을 두고, 가능하면 고유한 tie-breaker까지 함께 넣어야 해요.

어떤 도구를 고를까요?

선택을 한 장으로 줄이면 이렇게 볼 수 있어요. 중요한 건 하나의 프로젝트 안에서도 여러 방식을 섞을 수 있다는 점이에요. 이 그림처럼 기본 CRUD, 단순 조회, 동적 filter, 복잡한 목록 조회를 같은 도구 하나로 밀어붙일 필요는 없어요. query의 복잡함이 어디에 있는지에 따라 적절한 층을 나누면 돼요.

실무 코드 리뷰에서는 이 냄새를 먼저 봐요

복잡한 query는 처음부터 장애로 보이지 않아요. 작게 편해 보이는 선택이 쌓이다가 어느 날 검색 API가 손대기 어려워져요. 리뷰할 때는 아래 냄새를 자주 확인해요. 디버깅할 때는 질문을 이렇게 바꿔보세요.
  • 최종적으로 어떤 SQL이 실행됐나요?
  • optional 조건이 빠졌을 때 where 절은 어떻게 바뀌나요?
  • join이 필요한 조건과 아닌 조건이 섞여 있나요?
  • count query는 content query만큼 비싸지 않나요?
  • 정렬 column은 index와 맞나요?
  • API 검색 조건 이름이 Entity field 이름에 묶여 있지 않나요?
  • 지금 필요한 건 더 짧은 method 이름인가요, 아니면 query 전용 구조인가요?
이 질문이 있어야 Querydsl과 Specification을 “취향 차이”가 아니라 query 복잡도를 다루는 설계 도구로 볼 수 있어요.

처음에는 여기까지만 잡아도 충분해요

복잡한 query를 처음 정리한다면 이 흐름으로 보면 돼요. 이 그림의 핵심은 도구 이름보다 경계를 먼저 보는 거예요. 검색 조건은 API 계약이고, query builder는 구현 방식이고, pagination과 sorting은 성능 경계예요. 그래서 복잡한 query를 볼 때는 항상 이렇게 물어보세요.
“이 query의 복잡함은 조건 조합에 있나요, join과 select 모양에 있나요, 아니면 pagination과 정렬 비용에 있나요?”
이 질문이 derived method, @Query, Specification, Querydsl 사이의 선택을 훨씬 덜 감정적으로 만들어줘요.

참고한 링크


자, 정리해볼까요?

  • 복잡한 query의 핵심은 조건 개수가 아니라 optional 조건, join, 정렬, pagination이 함께 조합되는 방식이에요.
  • 단순하고 고정된 조회는 derived query method나 @Query가 여전히 읽기 좋아요.
  • Specification은 Spring Data JPA 안에서 동적 filter 조각을 조립하기 좋은 도구예요.
  • Querydsl은 Q type을 사용해 join, projection, 복잡한 정렬을 type-safe한 query code로 표현하기 좋아요.
  • QuerydslPredicateExecutor는 편하지만 내부 Entity field 구조가 API 검색 계약으로 새지 않게 조심해야 해요.
  • Pagination과 sorting은 UI 옵션이 아니라 count query, index, 안정적인 정렬 순서까지 포함하는 성능 설계예요.
  • 먼저 검색 조건 객체를 만들고, query의 복잡함이 어디에 있는지 본 뒤 도구를 고르는 편이 오래 버텨요.
다음 글에서는 Redis cache와 session을 볼 거예요. DB를 매번 읽지 않게 만드는 cache가 왜 성능을 올리면서도 invalidation, TTL, 분산 session 같은 새로운 버그를 만드는지 이어서 살펴볼게요.