검색 조건 하나만 더 붙였을 뿐인데, repository method 이름이 문장처럼 길어져요.지난 글에서는 database schema 변경을 Flyway와 Liquibase로 어떻게 이력화하는지 봤어요. 이제 schema 위에서 실제 데이터를 읽는 장면으로 다시 올라와 볼게요. 처음에는 repository method가 참 편해 보여요.
“상태도 고르고, 기간도 고르고, 고객 이름도 검색하고, 결제 금액도 범위로 걸고, 최신순이나 금액순 정렬도 해야 하면 method 이름을 계속 늘려야 하나요?”처음에는 이렇게 만들 수 있어요.
이 글은 Spring Data JPA 4.x의 Specifications, Spring Data Querydsl extension, OpenFeign Querydsl 문서를 기준으로 작성했어요. Spring Data JPA 4.x 문서에는
Specification뿐 아니라 더 넓게 조합할 수 있는 PredicateSpecification도 등장하지만, 이 글은 기존 프로젝트와 예제가 가장 많이 만나는 JpaSpecificationExecutor와 Specification을 중심으로 설명할게요. Querydsl은 현재 OpenFeign fork와 기존 Querydsl 자료가 함께 보일 수 있으니, 새 프로젝트에서는 사용하는 Spring Boot와 Spring Data 버전이 어떤 Querydsl 좌표를 지원하는지 확인해야 해요.문제는 조건 개수가 아니라 “조건 조합”이에요
주문 목록 API를 만든다고 해볼게요. 처음 요구사항은 단순해요.
처음에는 여기까지만 잡아도 충분해요. 복잡한 query는 “method 이름이 길어지는 문제”가 아니라 검색 조건을 조합하고, 실행 SQL을 통제하고, 성능을 예측해야 하는 문제예요.
먼저 검색 조건을 객체로 분리해요
도구를 고르기 전에 검색 조건부터 이름 붙이면 좋아요. Controller parameter가 바로 repository method로 흘러가면 query가 자라는 방향을 잡기 어려워요.Specification을 쓰든 Querydsl을 쓰든 코드가 덜 흔들려요.
이 그림의 핵심은 검색 의도와 query 구현을 분리하는 거예요. 사용자의 요청 모양이 바뀌는 일과 JPA query 작성 방식이 바뀌는 일을 같은 곳에 섞지 않으면, 나중에 도구를 바꾸기도 쉬워져요.
단순한 query는 derived method와 @Query가 여전히 좋아요
모든 query를 처음부터 Querydsl로 밀어 넣을 필요는 없어요. 단순한 조건은 Spring Data JPA repository method가 더 읽기 좋을 때가 많아요.
@Query도 좋은 선택이에요.
@Query는 이런 경우에 잘 맞아요.
하지만 조건이 optional로 늘어나면
@Query도 금방 복잡해져요. (:status is null or o.status = :status) 같은 조건이 계속 붙으면 query는 하나지만 의미는 흐려질 수 있어요.
Specification은 조건 조각을 조립하는 방식이에요
Spring Data JPA의Specification은 JPA Criteria API 위에서 조건을 조립하게 해주는 작은 abstraction이에요. repository는 JpaSpecificationExecutor를 함께 확장해요.
Specification.allOf(...)는 여러 조건을 and로 묶는 흐름이에요. 빈 목록이면 제한 없는 조건처럼 동작할 수 있어서, 예전처럼 Specification.where(null) 같은 모양으로 시작하지 않아도 돼요.
Specification의 장점은 분명해요.
하지만 단점도 있어요.
root.get("status")처럼 field 이름을 문자열로 쓰는 부분은 compile-time에 안전하지 않아요. join, fetch join, group by, projection, 복잡한 order가 늘어나면 Criteria API 특유의 문법이 읽기 어려워질 수 있어요.
Querydsl은 query를 Java 코드로 쓰되 type-safe하게 잡아요
Querydsl은 SQL 비슷한 query를 Java의 fluent API로 만들게 해주는 도구예요. JPA에서는 Entity를 기준으로QOrder, QCustomer 같은 Q type을 생성하고, 그 Q type을 사용해 query를 작성해요.
예를 들어 이런 Entity가 있다고 해볼게요.
QOrder 같은 class가 만들어져요. 그러면 query는 이런 식으로 쓸 수 있어요.
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에 붙이는 방식도 있어요.
status, from, to, keyword 같은 사용자의 언어로 말해야 하는데, 내부 query predicate가 앞쪽으로 올라오면 Entity 구조 변경이 API 변경처럼 번질 수 있어요.
그래서 실무에서는 보통 이렇게 나눠요.
- 단순 내부 admin 화면이나 빠른 검색에는
QuerydslPredicateExecutor가 편할 수 있어요. - 공개 API나 오래 유지할 검색 화면은
OrderSearchCondition같은 별도 조건 객체를 두는 편이 좋아요. - join, fetch join, DTO projection, count query 최적화가 필요하면 custom query repository가 더 명확해요.
Pagination과 sorting은 화면 옵션이 아니라 query 성능 문제예요
검색 API에서page, size, sort는 단순한 UI parameter처럼 보이지만, database 입장에서는 실행 비용을 바꾸는 조건이에요.
Spring Data repository에서는 Pageable을 자주 써요.
그래서 정렬은 안정적으로 잡는 편이 좋아요. 예를 들어 최신순이면
createdAt만 쓰지 말고 같은 시각에 만들어진 row를 위해 id까지 함께 정렬할 수 있어요.
createdAt 값을 가진 row가 여러 개 있을 때 DB가 매번 같은 순서를 보장하지 않으면, 사용자는 다음 page에서 같은 주문을 다시 보거나 어떤 주문을 건너뛸 수 있어요.
이 그림에서 pagination은 where 뒤에 살짝 붙는 장식이 아니에요. 정렬, limit, offset, count가 함께 움직여서 응답 시간과 안정성을 결정해요.
어떤 도구를 고를까요?
선택을 한 장으로 줄이면 이렇게 볼 수 있어요.
중요한 건 하나의 프로젝트 안에서도 여러 방식을 섞을 수 있다는 점이에요.
이 그림처럼 기본 CRUD, 단순 조회, 동적 filter, 복잡한 목록 조회를 같은 도구 하나로 밀어붙일 필요는 없어요. query의 복잡함이 어디에 있는지에 따라 적절한 층을 나누면 돼요.
실무 코드 리뷰에서는 이 냄새를 먼저 봐요
복잡한 query는 처음부터 장애로 보이지 않아요. 작게 편해 보이는 선택이 쌓이다가 어느 날 검색 API가 손대기 어려워져요. 리뷰할 때는 아래 냄새를 자주 확인해요.
디버깅할 때는 질문을 이렇게 바꿔보세요.
- 최종적으로 어떤 SQL이 실행됐나요?
- optional 조건이 빠졌을 때 where 절은 어떻게 바뀌나요?
- join이 필요한 조건과 아닌 조건이 섞여 있나요?
- count query는 content query만큼 비싸지 않나요?
- 정렬 column은 index와 맞나요?
- API 검색 조건 이름이 Entity field 이름에 묶여 있지 않나요?
- 지금 필요한 건 더 짧은 method 이름인가요, 아니면 query 전용 구조인가요?
처음에는 여기까지만 잡아도 충분해요
복잡한 query를 처음 정리한다면 이 흐름으로 보면 돼요. 이 그림의 핵심은 도구 이름보다 경계를 먼저 보는 거예요. 검색 조건은 API 계약이고, query builder는 구현 방식이고, pagination과 sorting은 성능 경계예요. 그래서 복잡한 query를 볼 때는 항상 이렇게 물어보세요.“이 query의 복잡함은 조건 조합에 있나요, join과 select 모양에 있나요, 아니면 pagination과 정렬 비용에 있나요?”이 질문이 derived method,
@Query, Specification, Querydsl 사이의 선택을 훨씬 덜 감정적으로 만들어줘요.
참고한 링크
- Spring Data JPA Reference: Specifications
- Spring Data JPA API:
JpaSpecificationExecutor - Spring Data JPA Reference: Spring Data Extensions
- OpenFeign Querydsl GitHub Repository
- Querydsl Reference Guide
자, 정리해볼까요?
- 복잡한 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의 복잡함이 어디에 있는지 본 뒤 도구를 고르는 편이 오래 버텨요.