JPA와 Querydsl
회원 게시판의 단순 CRUD·파생 쿼리는 Spring Data 리포지토리로, 선택적 조건식·투영·키셋 쿼리는 Querydsl 사용자 정의 조각으로 구현하고 SQL 형태와 페이지네이션을 검증합니다.
Spring 데이터 JPA는 리포지토리 인터페이스에서 기본 CRUD 구현과 단순 쿼리를 생성합니다. Querydsl은 생성된 Q 타입으로 Java 컴파일러가 속성과 타입을 검사하는 쿼리 빌더를 제공합니다. 두 도구 모두 영속성 컨텍스트와 JPA SQL 실행 위에 있으며, 사용한다고 애그리거트 계약·트랜잭션·인덱스 설계가 사라지지 않습니다.
기본 CRUD 리포지토리
package board.jpa;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface PostJpaRepository
extends JpaRepository<PostEntity, Long>,
PostQueryRepository {
Optional<PostEntity> findByIdAndAuthorId(
long id,
long authorId);
boolean existsByAuthorIdAndCreatedOnAndTitle(
long authorId,
LocalDate createdOn,
String title);
@Query("""
select s
from PostEntity s
where s.authorId = :authorId
and s.createdOn between :from and :to
order by s.createdOn desc, s.id desc
""")
List<PostEntity> findRecent(
@Param("authorId") long authorId,
@Param("from") LocalDate from,
@Param("to") LocalDate to,
Pageable pageable);
}파생 메서드 이름이 한 줄에서 의미를 잃을 만큼 길어지면 @Query 또는 사용자 정의 조각으로 옮깁니다.
메서드 이름을 분해하는 파서 규칙을 팀원이 외워야 하는 상태는 타입 안전성이 아니라 가독성 비용입니다.
존재 여부를 먼저 확인해도 동시 삽입 경쟁을 막지 못하므로 고유 제약 조건이 필요합니다. 존재 여부 쿼리는 빠른 사용자 피드백이나 조건 분기에 쓰고 최종 일관성은 DB가 보장합니다.
Page는 매 요청마다 개수 쿼리를 실행할 수 있습니다.
전체 개수가 필요 없는 무한 스크롤은 Slice 또는 키셋 결과로 바꿉니다.
오프셋 페이지네이션의 깊은 페이지 비용도 고려합니다.
사용자 정의 조각
Spring Data는 기반 인터페이스와 사용자 정의 조각 구현을 조합합니다. 조각 이름과 구현 접미사 규칙을 버전 문서로 확인하고 시작 컨텍스트 테스트로 실제 연결을 검증합니다.
package board.jpa;
import java.time.LocalDate;
public interface PostQueryRepository {
PostSearchSlice search(PostSearchCondition condition);
record PostSearchCondition(
long authorId,
String keyword,
LocalDate from,
LocalDate to,
Integer minimumCharacters,
Cursor cursor,
int size
) {
public PostSearchCondition {
if (authorId <= 0 || size < 1 || size > 100) {
throw new IllegalArgumentException("invalid search condition");
}
if (from != null && to != null && from.isAfter(to)) {
throw new IllegalArgumentException("invalid date range");
}
}
}
record Cursor(LocalDate createdOn, long id) {
public Cursor {
if (createdOn == null || id <= 0) {
throw new IllegalArgumentException("invalid cursor");
}
}
}
}쿼리 조건은 웹 DTO가 아니라 쿼리 포트 입력입니다.
null 허용 정책과 크기 상한의 조합을 생성자에서 지킵니다.
정렬이 여러 개라면 커서 형태도 정렬마다 달라지므로 하나의 일반 커서 문자열에 숨기지 않습니다.
Querydsl 조건 조합
package board.jpa;
import static board.jpa.QPostEntity.postEntity;
import java.util.List;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;
public final class PostQueryRepositoryImpl
implements PostQueryRepository {
private final JPAQueryFactory queryFactory;
public PostQueryRepositoryImpl(JPAQueryFactory queryFactory) {
this.queryFactory = queryFactory;
}
@Override
public PostSearchSlice search(PostSearchCondition condition) {
var predicate = new BooleanBuilder(
postEntity.authorId.eq(condition.authorId()));
if (condition.keyword() != null
&& !condition.keyword().isBlank()) {
String keyword = condition.keyword().strip();
predicate.and(postEntity.title.containsIgnoreCase(keyword)
.or(postEntity.content.containsIgnoreCase(keyword)));
}
if (condition.from() != null) {
predicate.and(postEntity.createdOn
.goe(condition.from()));
}
if (condition.to() != null) {
predicate.and(postEntity.createdOn
.loe(condition.to()));
}
if (condition.minimumCharacters() != null) {
predicate.and(postEntity.content.length()
.goe(condition.minimumCharacters()));
}
if (condition.cursor() != null) {
var cursor = condition.cursor();
predicate.and(
postEntity.createdOn.lt(cursor.createdOn())
.or(postEntity.createdOn
.eq(cursor.createdOn())
.and(postEntity.id
.lt(cursor.id()))));
}
List<PostSearchRow> fetched = queryFactory
.select(Projections.constructor(
PostSearchRow.class,
postEntity.id,
postEntity.title,
postEntity.content,
postEntity.createdOn,
postEntity.version))
.from(postEntity)
.where(predicate)
.orderBy(
postEntity.createdOn.desc(),
postEntity.id.desc())
.limit(condition.size() + 1L)
.fetch();
boolean hasNext = fetched.size() > condition.size();
List<PostSearchRow> items = hasNext
? List.copyOf(fetched.subList(0, condition.size()))
: List.copyOf(fetched);
Cursor next = hasNext
? cursorOf(items.getLast())
: null;
return new PostSearchSlice(items, next);
}
private Cursor cursorOf(PostSearchRow row) {
return new Cursor(row.createdOn(), row.id());
}
}Q 타입은 빌드의 애노테이션 처리 과정에서 생성하며 소스 제어에 넣을지 생성된 소스로 만들지 팀 정책을 정합니다. Java 25와 사용하는 Querydsl/Jakarta 분류자 조합을 컴파일로 확인합니다. Q 클래스 생성 실패를 IDE 캐시 문제로만 보지 않고 처리기 경로와 소스 집합을 검사합니다.
Projections.constructor는 생성자 타입과 순서 오류가 런타임에 나타날 수 있습니다.
@QueryProjection으로 DTO Q 타입을 생성하거나 빈·필드 투영을 쓸 수 있지만 DTO가 Querydsl 애노테이션에 결합됩니다.
명시적 팩토리 표현식과 통합 테스트를 바탕으로 장단점을 평가합니다.
불변 DTO 투영
package board.jpa;
import java.time.LocalDate;
import java.util.List;
public record PostSearchRow(
long id,
String title,
String content,
LocalDate createdOn,
long version
) {
}
record PostSearchSlice(
List<PostSearchRow> items,
PostQueryRepository.Cursor next
) {
PostSearchSlice {
items = List.copyOf(items);
}
boolean hasNext() {
return next != null;
}
}엔티티를 반환하지 않아 웹 직렬화가 지연 연관을 건드리지 않습니다. 쿼리가 필요한 열만 선택하므로 애그리거트 전체 복원 비용도 줄어듭니다. 투영은 읽기 전용이며 변경 명령에 다시 엔티티처럼 넘기지 않습니다.
조건·키셋 검증
package board.jpa;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
@DataJpaTest
@Import(QuerydslJpaConfiguration.class)
class PostQueryRepositoryTest {
@Autowired
PostJpaRepository repository;
@Test
void 제목과_본문_검색어와_size_plus_one으로_next_cursor를_만든다() {
repository.save(PostEntity.create(
41L, "JPA 시작", "영속성 컨텍스트를 이해한다",
LocalDate.parse("2026-07-14"),
Instant.parse("2026-07-14T09:00:00Z")));
repository.save(PostEntity.create(
41L, "변경 감지", "JPA가 수정 SQL을 만든다",
LocalDate.parse("2026-07-13"),
Instant.parse("2026-07-13T09:00:00Z")));
var result = repository.search(
new PostQueryRepository.PostSearchCondition(
41L, "jpa", null, null, null, null, 1));
assertThat(result.items()).hasSize(1);
assertThat(result.items().getFirst().createdOn())
.isEqualTo(LocalDate.parse("2026-07-14"));
assertThat(result.hasNext()).isTrue();
assertThat(result.next()).isNotNull();
}
}repository fragment wired = true
case-insensitive title/body predicate = applied
requested size = 1
fetched for next detection = 2
returned items = 1
next cursor = presentQuerydslJpaConfiguration은 같은 EntityManager를 사용하는 JPAQueryFactory 빈을 제공합니다.
개수 쿼리가 실행되지 않는지 SQL 캡처 또는 데이터 소스 프록시로 확인할 수 있습니다.
결과 정확성과 쿼리 개수를 다른 검증으로 유지합니다.
package board.jpa;
import jakarta.persistence.EntityManager;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class QuerydslJpaConfiguration {
@Bean
JPAQueryFactory queryFactory(EntityManager entityManager) {
return new JPAQueryFactory(entityManager);
}
}연습 문제
주간 제목별 본문 글자 수 합계를 Querydsl groupBy로 만들고 최소 총 글자 수 조건과 상위 10개 정렬을 적용하세요.
같은 쿼리를 JPQL과 JdbcTemplate SQL로도 작성해 읽기 쉬움, 생성 SQL, 실행 계획을 비교합니다.
해설 보기
그룹 키, 합계, 개수를 투영에 명시하고 having과 orderBy(sum.desc(), title.asc())를 사용합니다.
합계 타입은 오버플로를 피하도록 long으로 받습니다.
package board.query;
public record WeeklyTitleTotal(
String title,
long totalCharacters,
long postCount
) {
public WeeklyTitleTotal {
if (title == null || title.isBlank()
|| totalCharacters < 0 || postCount < 0) {
throw new IllegalArgumentException("invalid weekly total");
}
}
}세 구현은 같은 픽스처와 예상된 투영을 공유합니다. Querydsl 표현이 SQL 계획을 개선해 주는 것은 아니므로 인덱스와 실제 계획은 별도로 비교합니다.
다음 문서에서는 지금까지의 JDBC·Spring JDBC·MyBatis·JPA·Spring 데이터·Querydsl을 팀 역량과 쿼리 모양, 트랜잭션 요구로 비교해 기술 선택과 마이그레이션 경계를 정합니다.