Spring Data JPA와 Querydsl
Spring Data custom fragment와 생성된 Querydsl Q 타입으로 동일한 게시글 검색 계약을 구현하고 검증합니다.
Spring Data JPA는 엔티티 저장과 짧은 고정 쿼리를 조합하고, Querydsl은 조건이 달라지는 읽기를 타입이 있는 식으로 조립합니다. 두 도구는 별도 저장소가 아니라 같은 EntityManager와 같은 posts 테이블 위에서 역할을 나눕니다.
이 문서의 외부 경계도 PostRepository와 PostQuery뿐입니다. Spring Data 인터페이스, custom fragment, 엔티티와 Querydsl 표현식은 모두 board.jpa 안에 머물고 최종 결과만 애플리케이션의 PostPage, PostSnapshot, PostSummary로 바뀝니다.
SPRING DATA + QUERYDSL
공개 포트 하나, JPA 내부 역할 둘
고정 행 연산은 Spring Data가, 동적 조건·키셋·투영은 생성 Q 타입을 쓰는 custom fragment가 맡습니다.
공개 애플리케이션 포트
호출자는 PostQuery와 PostRepository의 명령·결과만 압니다.
JPA 어댑터
JpaPostRepository가 엔티티와 공급자 실패를 애플리케이션 의미로 바꿉니다.
Spring Data 프록시
PostJpaRepository가 기본 저장 기능과 custom fragment를 한 프록시에 조합합니다.
고정 행 연산
소유자 조회와 버전 조건 수정·삭제는 파생 메서드와 고정 JPQL이 맡습니다.
동적 검색 조각
생성된 Q 타입으로 리터럴 조건, 배타 키셋, size+1과 여덟 필드 투영을 조립합니다.
공유 실행 기반
두 경로는 같은 EntityManager, 트랜잭션과 B90 posts 테이블을 사용합니다.
- 동적 검색 핵심 경로
- 런타임 호출
- 빌드에서 생성된 타입 참조
핵심: Q 타입 생성 실패는 수동 클래스로 우회하지 않고 컴파일 관문에서 닫습니다.
Spring Data 인터페이스는 고정된 행 연산을 맡는다
기본 saveAndFlush와 소유자 조회는 메서드 이름으로 충분합니다. 버전 조건이 있는 수정과 삭제는 JPQL 한 문장으로 고정해 id, memberId, expectedVersion이 같은 행을 동시에 가리킬 때만 1행을 바꿉니다.
package board.jpa;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
public interface PostJpaRepository
extends JpaRepository<PostEntity, Long>, PostQueryRepository {
Optional<PostEntity> findByIdAndMemberId(long id, long memberId);
Optional<PostEntity> findByMemberIdAndClientRequestId(
long memberId,
String clientRequestId);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("""
update PostEntity post
set post.title = :title,
post.content = :content,
post.version = post.version + 1
where post.id = :id
and post.memberId = :memberId
and post.version = :expectedVersion
""")
int updateOwned(
@Param("id") long id,
@Param("memberId") long memberId,
@Param("title") String title,
@Param("content") String content,
@Param("expectedVersion") long expectedVersion);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("""
delete from PostEntity post
where post.id = :id
and post.memberId = :memberId
and post.version = :expectedVersion
""")
int deleteOwned(
@Param("id") long id,
@Param("memberId") long memberId,
@Param("expectedVersion") long expectedVersion);
}사전 존재 확인은 동시 수정 경쟁을 막지 못합니다. 한 문장의 결과 행 수가 공개 포트의 true/false 사실이며, 0행일 때 누락·다른 소유자·낡은 버전 중 하나를 임의로 고르지 않습니다.
동적 검색은 내부 custom fragment 하나로 격리한다
custom fragment는 제3의 애플리케이션 포트가 아닙니다. Spring Data가 PostJpaRepository 프록시에 붙이는 JPA 내부 조각이며, 공유 PostSearch를 받아 공유 결과로 돌려줍니다.
package board.jpa;
import board.application.PostPage;
import board.application.PostSearch;
import board.application.PostSummary;
public interface PostQueryRepository {
PostPage findPage(PostSearch search);
PostSummary summary(long memberId);
}QPostEntity는 이 문서가 정의하지 않습니다. PostEntity에서 annotation processor가 만든 출력만 정적 import하며, 생성 실패는 수동 대체 클래스로 숨기지 않고 빌드 실패로 드러냅니다.
검색은 회원과 포함 날짜 범위를 항상 적용합니다. 검색어는 PostSearch가 확정한 문자열을 containsIgnoreCase의 바인딩 값으로 전달하므로 %, _, 역슬래시와 공격 문자열도 와일드카드나 JPQL 조각이 아니라 리터럴 데이터입니다.
package board.jpa;
import static board.jpa.QPostEntity.postEntity;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import board.application.PostCursor;
import board.application.PostPage;
import board.application.PostSearch;
import board.application.PostSnapshot;
import board.application.PostSummary;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
public final class PostQueryRepositoryImpl
implements PostQueryRepository {
private final JPAQueryFactory queryFactory;
public PostQueryRepositoryImpl(JPAQueryFactory queryFactory) {
this.queryFactory = Objects.requireNonNull(queryFactory, "queryFactory");
}
@Override
public PostPage findPage(PostSearch search) {
Objects.requireNonNull(search, "search");
BooleanBuilder predicate = new BooleanBuilder(
postEntity.memberId.eq(search.memberId())
.and(postEntity.publishedOn.goe(search.from()))
.and(postEntity.publishedOn.loe(search.to())));
if (search.keyword() != null) {
predicate.and(postEntity.title.containsIgnoreCase(search.keyword())
.or(postEntity.content.containsIgnoreCase(search.keyword())));
}
if (search.minimumCharacters() != null) {
predicate.and(postEntity.content.length()
.goe(search.minimumCharacters()));
}
search.after().ifPresent(cursor -> predicate.and(
postEntity.publishedOn.lt(cursor.publishedOn())
.or(postEntity.publishedOn.eq(cursor.publishedOn())
.and(postEntity.id.lt(cursor.id())))));
List<PostSnapshot> fetched = queryFactory
.select(Projections.constructor(
PostSnapshot.class,
postEntity.id,
postEntity.memberId,
postEntity.title,
postEntity.content,
postEntity.publishedOn,
postEntity.clientRequestId,
postEntity.createdAt,
postEntity.version))
.from(postEntity)
.where(predicate)
.orderBy(postEntity.publishedOn.desc(), postEntity.id.desc())
.limit((long) search.size() + 1L)
.fetch();
boolean hasNext = fetched.size() > search.size();
List<PostSnapshot> items = List.copyOf(hasNext
? fetched.subList(0, search.size())
: fetched);
Optional<PostCursor> nextCursor = hasNext
? Optional.of(PostCursor.from(items.getLast()))
: Optional.empty();
return new PostPage(items, nextCursor);
}
@Override
public PostSummary summary(long memberId) {
if (memberId <= 0) {
throw new IllegalArgumentException("memberId must be positive");
}
NumberExpression<Long> postCount = postEntity.count();
NumberExpression<Long> totalCharacters =
postEntity.content.length().longValue().sum();
Tuple aggregate = queryFactory
.select(postCount, totalCharacters)
.from(postEntity)
.where(postEntity.memberId.eq(memberId))
.fetchOne();
if (aggregate == null) {
throw new IllegalStateException("aggregate query returned no row");
}
Long count = aggregate.get(postCount);
Long characters = aggregate.get(totalCharacters);
return new PostSummary(
memberId,
count == null ? 0L : count,
characters == null ? 0L : characters);
}
}정렬은 항상 (publishedOn DESC, id DESC)입니다. 다음 페이지는 두 필드 모두를 쓰는 배타 조건을 추가하고 size + 1행만 읽습니다. 다음 커서는 버린 look-ahead 행이 아니라 실제 반환한 마지막 PostSnapshot에서 만듭니다.
생성자 투영과 페이지 계약을 실행한다
테스트 설정은 슬라이스의 EntityManager로 JPAQueryFactory 하나를 만듭니다. 운영 설정을 복제하지 않고 custom fragment가 실제 프록시에 연결되는지만 실행합니다.
package board.jpa;
import jakarta.persistence.EntityManager;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
@TestConfiguration(proxyBeanMethods = false)
public class QuerydslJpaConfiguration {
@Bean
JPAQueryFactory queryFactory(EntityManager entityManager) {
return new JPAQueryFactory(entityManager);
}
}45행 탐색은 세 요청이 정확히 20 / 20 / 5를 반환하고 커서 상태가 present / present / empty인지 확인합니다. Hibernate 통계는 각 페이지마다 준비된 문장이 하나뿐인지 확인해 숨어 있는 개수 쿼리를 막습니다. 별도 픽스처는 %, _, 역슬래시와 공격 문자열이 바인딩된 리터럴 부분 문자열인지 실행합니다.
package board.jpa;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import board.application.PostCursor;
import board.application.PostPage;
import board.application.PostSearch;
import board.application.PostSnapshot;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
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(properties =
"spring.jpa.properties.hibernate.generate_statistics=true")
@Import({JpaPostRepository.class, QuerydslJpaConfiguration.class})
class PostQueryRepositoryTest {
private static final long MEMBER_ID = 41L;
private static final LocalDate DAY = LocalDate.parse("2026-07-14");
private static final Instant CREATED_AT =
Instant.parse("2026-07-14T09:00:00Z");
@Autowired
PostJpaRepository repository;
@Autowired
JpaPostRepository adapter;
@Autowired
EntityManager entityManager;
@Autowired
EntityManagerFactory entityManagerFactory;
@Test
void generated_fragment_projects_and_traverses_45_rows_as_20_20_5() {
List<Long> insertedIds = new ArrayList<>();
for (int index = 0; index < 45; index++) {
PostSnapshot created = adapter.create(
command(
"jpa-page-%02d".formatted(index),
"post-%02d".formatted(index),
"querydsl projection %02d".formatted(index),
DAY.minusDays(index / 5)),
CREATED_AT.plusSeconds(index));
insertedIds.add(created.id());
}
entityManager.flush();
entityManager.clear();
Statistics statistics = entityManagerFactory
.unwrap(SessionFactory.class)
.getStatistics();
statistics.clear();
List<PostSnapshot> observed = new ArrayList<>();
List<Integer> pageSizes = new ArrayList<>();
List<Boolean> cursorStates = new ArrayList<>();
Optional<PostCursor> after = Optional.empty();
do {
PostPage page = repository.findPage(new PostSearch(
MEMBER_ID,
null,
DAY.minusDays(20),
DAY,
null,
after,
20));
observed.addAll(page.items());
pageSizes.add(page.items().size());
cursorStates.add(page.nextCursor().isPresent());
after = page.nextCursor();
} while (after.isPresent());
assertThat(repository).isInstanceOf(PostQueryRepository.class);
assertThat(pageSizes).containsExactly(20, 20, 5);
assertThat(cursorStates).containsExactly(true, true, false);
assertThat(statistics.getPrepareStatementCount()).isEqualTo(3L);
assertThat(observed).hasSize(45);
assertThat(observed).isSortedAccordingTo(
Comparator.comparing(
PostSnapshot::publishedOn,
Comparator.reverseOrder())
.thenComparing(
PostSnapshot::id,
Comparator.reverseOrder()));
assertThat(observed).extracting(PostSnapshot::id)
.doesNotHaveDuplicates()
.containsExactlyInAnyOrderElementsOf(insertedIds);
PostSnapshot first = observed.getFirst();
assertThat(first.memberId()).isEqualTo(MEMBER_ID);
assertThat(first.title()).isEqualTo("post-04");
assertThat(first.content()).isEqualTo("querydsl projection 04");
assertThat(first.publishedOn()).isEqualTo(DAY);
assertThat(first.clientRequestId()).isEqualTo("jpa-page-04");
assertThat(first.createdAt()).isEqualTo(CREATED_AT.plusSeconds(4));
assertThat(first.version()).isZero();
}
@Test
void percent_underscore_backslash_and_attack_text_are_literal_values() {
PostSnapshot percent = adapter.create(
command("jpa-lit-percent", "rate", "value is 100% exact", DAY),
CREATED_AT);
PostSnapshot underscore = adapter.create(
command("jpa-lit-under", "key_a_b", "underscore", DAY),
CREATED_AT.plusSeconds(1));
PostSnapshot backslash = adapter.create(
command("jpa-lit-slash", "path", "C:\\temp", DAY),
CREATED_AT.plusSeconds(2));
PostSnapshot attack = adapter.create(
command(
"jpa-lit-attack",
"quoted input",
"' OR 1=1 --",
DAY),
CREATED_AT.plusSeconds(3));
adapter.create(
command("jpa-lit-decoy", "plain", "value is 100 exact", DAY),
CREATED_AT.plusSeconds(4));
entityManager.flush();
entityManager.clear();
assertThat(findLiteral("100%"))
.extracting(PostSnapshot::id)
.containsExactly(percent.id());
assertThat(findLiteral("a_b"))
.extracting(PostSnapshot::id)
.containsExactly(underscore.id());
assertThat(findLiteral("C:\\temp"))
.extracting(PostSnapshot::id)
.containsExactly(backslash.id());
assertThat(findLiteral("' OR 1=1 --"))
.extracting(PostSnapshot::id)
.containsExactly(attack.id());
}
private List<PostSnapshot> findLiteral(String keyword) {
return repository.findPage(new PostSearch(
MEMBER_ID,
keyword,
DAY.minusDays(1),
DAY.plusDays(1),
null,
Optional.empty(),
100)).items();
}
private static CreatePostCommand command(
String requestId,
String title,
String content,
LocalDate publishedOn
) {
return new CreatePostCommand(
MEMBER_ID,
title,
content,
publishedOn,
requestId);
}
}테스트가 PostSnapshot의 여덟 필드를 직접 읽으므로 생성자 투영 순서를 바꾸면 실행 중 즉시 드러납니다. ID 집합과 정렬을 함께 검사해 같은 행을 두 번 읽거나 커서 경계에서 빠뜨리는 경우도 닫습니다.
집계용 내부 값도 포트를 대신하지 않는다
제목별 주간 집계처럼 쿼리 구현 내부에서만 쓰는 값은 별도 애플리케이션 결과로 승격하지 않습니다. 아래 값은 Querydsl groupBy 투영 연습에만 쓰이며 공개 summary(memberId)는 계속 PostSummary를 반환합니다.
package board.jpa;
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 계획을 대신 선택하지 않습니다. 생성 SQL과 인덱스 사용은 별도로 관찰하고, custom fragment의 장점은 조건·정렬·투영을 한 곳에서 타입과 함께 보존한다는 데 둡니다.
다음 문서에서는 같은 공개 읽기 포트의 기존 구현과 후보 구현을 비동기로 비교하고, 후보 실패가 사용자 응답을 바꾸지 않는 전환 상태 기계를 만듭니다.