본문 바로가기

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 · COMPOSED ROLES

Spring Data 프록시가 고정 연산과 동적 검색을 조합한다

공개 포트는 JPA 어댑터만 봅니다. 내부 프록시가 저장 기능과 custom fragment를 조합하고 같은 EntityManager를 사용합니다.

Spring Data 프록시와 custom fragment 조합공개 포트를 구현한 JpaPostRepository가 PostJpaRepository 프록시를 호출한다. 프록시는 JpaRepository 기본 연산과 선언한 고정 JPQL, custom fragment 동적 검색을 조합한다. custom fragment는 빌드에서 생성한 QPostEntity를 참조하고 두 경로는 같은 EntityManager와 posts를 사용한다.PostQuery · PostRepository공개 애플리케이션 포트JpaPostRepository엔티티 → 스냅샷 · 예외 경계PostJpaRepository 프록시기본 연산 + 선언 쿼리 + fragment고정 행 연산save · 소유자 조회고정 JPQLCustom fragment조건 · 키셋 · size + 1
프록시가 고정 행 연산과 동적 검색을 조합한다desktop과 같은 다섯 노드·네 간선이다. 공개포트→JPA어댑터→내부프록시에서고정행연산과customfragment로분기한다. 두하위역할을한실행순서로이어붙이지않으며Q생성과같은EntityManager사용은원패널그대로이다.PostRepositoryPostQuery공개 포트JpaPostRepository스냅샷 · 예외 경계PostJpaRepository내부 프록시고정 행 연산save · 소유자 조회버전 JPQLCustom fragment조건 · 키셋size + 1
  1. 공개 포트 → JPA 어댑터 → 프록시

    호출자는 PostQuery와 PostRepository만 봅니다. JpaPostRepository가 최종 애플리케이션 스냅샷과 실패 의미를 복원합니다.

  2. 프록시 내부에서 역할을 조합

    JpaRepository의 저장 기능·선언한 고정 JPQL과 custom fragment의 동적 검색을 함께 사용합니다.

빌드 의존 · 생성 QPostEntity

annotation processor의 출력입니다. custom fragment가 타입으로 참조하며 런타임에 호출하는 서비스가 아닙니다. 생성 실패는 빌드에서 드러납니다.

공유 실행 · EntityManager → posts

두 경로는 같은 영속성 컨텍스트와 트랜잭션을 사용합니다. 동적 검색은 여덟 필드 투영과 고정 정렬로 PostPage를 만듭니다.

custom fragment는 JPA 내부 조각입니다. 제3의 애플리케이션 포트를 만들지 않습니다.


Spring Data 인터페이스는 고정된 행 연산을 맡는다

기본 saveAndFlush와 소유자 조회는 메서드 이름으로 충분합니다. 버전 조건이 있는 수정과 삭제는 JPQL 한 문장으로 고정해 id, memberId, expectedVersion이 같은 행을 동시에 가리킬 때만 1행을 바꿉니다.

src/main/java/board/jpa/PostJpaRepository.java
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행일 때 누락·다른 소유자·낡은 버전 중 하나를 임의로 고르지 않습니다.

이 JPQL 벌크 UPDATE는 관리 엔티티의 변경 감지와 다릅니다. 벌크 연산은 공급자의 자동 낙관적 버전 검사를 거치지 않으므로 코드가 WHERE의 기대 버전과 SET의 버전 증가를 직접 명시합니다. 그래서 낡은 기대 버전은 0행이라는 사실로 돌아옵니다. 반면 앞 절의 관리 객체 변경·flush 충돌은 @Version을 검사하는 공급자의 예외로 관찰합니다.

flushAutomatically는 벌크 문장 전에 남은 관리 객체 변경을 flush하고, clearAutomatically는 실행 뒤 컨텍스트를 비워 벌크 결과와 어긋난 옛 객체를 계속 읽는 것을 막습니다. 미반영 변경이 있으면 사전 flush 자체가 실패하거나 버전을 바꿀 수 있으므로 벌크 행 수만 보기 전에 이 경계도 확인합니다. Jakarta Persistence의 벌크 연산 규칙을 관리 객체의 자동 검사와 나누어 읽으세요.


동적 검색은 내부 custom fragment 하나로 격리한다

custom fragment는 제3의 애플리케이션 포트가 아닙니다. Spring Data가 PostJpaRepository 프록시에 붙이는 JPA 내부 조각이며, 공유 PostSearch를 받아 공유 결과로 돌려줍니다.

src/main/java/board/jpa/PostQueryRepository.java
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 조각이 아니라 리터럴 데이터입니다.

src/main/java/board/jpa/PostQueryRepositoryImpl.java
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가 실제 프록시에 연결되는지만 실행합니다.

src/test/java/board/jpa/QuerydslJpaConfiguration.java
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 통계는 각 페이지마다 준비된 문장이 하나뿐인지 확인해 숨어 있는 개수 쿼리를 막습니다. 별도 픽스처는 %, _, 역슬래시와 공격 문자열이 바인딩된 리터럴 부분 문자열인지 실행합니다.

마지막 페이지 경계를 직접 계산하기 고정된 대상·정렬 키, 페이지 크기 20, 페이지마다 size + 1만 읽는 조건을 유지합니다. 대상이 정확히 40행이면 반환 크기와 커서 상태는 어떻게 되나요? 41행이면 무엇이 달라지나요? 반환 목록만으로는 같아 보이는 두 번째 요청의 차이를 설명하세요.

남은 행과 한 행 더 읽기의 풀이

40행은 20/20을 반환하고 커서는 present/empty입니다. 두 번째 요청 뒤에 남은 행이 없어 lookahead가 없습니다. 41행은 20/20/1을 반환하고 커서는 present/present/empty입니다. 두 번째 요청은 21행을 읽어 20행을 반환하므로 아직 반환하지 않은 한 행이 있다는 사실을 압니다.

원 테스트처럼 조회당 SQL이 정확히 하나이고 따로 개수 쿼리가 없다면 두 조건의 조회 SQL 수는 각각 2와 3입니다. 조회 사이의 변경이나 자동 추가 쿼리가 없는 전제에서 계산한 값이며 새 Hibernate 통계 실행 결과가 아닙니다.

src/test/java/board/jpa/PostQueryRepositoryTest.java
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.hibernate.ddl-auto=validate",
        "spring.jpa.defer-datasource-initialization=false",
        "spring.sql.init.mode=always",
        "spring.sql.init.schema-locations=classpath:schema.sql",
        "spring.sql.init.data-locations=classpath:jpa-members.sql",
        "spring.datasource.generate-unique-name=true",
        "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를 반환합니다.

src/main/java/board/jpa/WeeklyTitleTotal.java
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의 장점은 조건·정렬·투영을 한 곳에서 타입과 함께 보존한다는 데 둡니다.

다음 문서에서는 같은 공개 읽기 포트의 기존 구현과 후보 구현을 비동기로 비교하고, 후보 실패가 사용자 응답을 바꾸지 않는 전환 상태 기계를 만듭니다.