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를 사용합니다.
- 공개 포트 → JPA 어댑터 → 프록시
호출자는 PostQuery와 PostRepository만 봅니다. JpaPostRepository가 최종 애플리케이션 스냅샷과 실패 의미를 복원합니다.
- 프록시 내부에서 역할을 조합
JpaRepository의 저장 기능·선언한 고정 JPQL과 custom fragment의 동적 검색을 함께 사용합니다.
빌드 의존 · 생성 QPostEntity
annotation processor의 출력입니다. custom fragment가 타입으로 참조하며 런타임에 호출하는 서비스가 아닙니다. 생성 실패는 빌드에서 드러납니다.
공유 실행 · EntityManager → posts
두 경로는 같은 영속성 컨텍스트와 트랜잭션을 사용합니다. 동적 검색은 여덟 필드 투영과 고정 정렬로 PostPage를 만듭니다.
custom fragment는 JPA 내부 조각입니다. 제3의 애플리케이션 포트를 만들지 않습니다.
그림은 공개 포트를 구현한 JpaPostRepository가 PostJpaRepository 프록시를 호출하고, 프록시가 저장과 소유자 조회 같은 고정된 행 연산과, 조건과 키셋, size + 1을 다루는 커스텀 프래그먼트의 동적 검색을 함께 조합하는 모습을 보여 줍니다. 두 역할은 이어서 실행되는 순서가 아니라 프록시 안에서 나뉜 경로입니다. 프래그먼트는 빌드할 때 생성된 QPostEntity를 타입으로 참조하고, 두 경로 모두 같은 EntityManager와 posts를 씁니다.
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행일 때 없음과 다른 소유자, 오래된 버전 가운데 하나를 마음대로 고르지 않습니다.
이 JPQL 벌크 UPDATE는 관리 엔티티의 변경 감지와 다릅니다. 벌크 연산은 공급자의 자동 낙관적 버전 검사를 거치지 않으므로, 코드가 WHERE의 기대 버전과 SET의 버전 증가를 직접 명시합니다. 그래서 오래된 기대 버전은 0행이라는 사실로 돌아옵니다. 반면 앞 글에서 본 관리 객체의 변경과 flush의 충돌은 @Version을 검사하는 공급자의 예외로 드러납니다.
flushAutomatically는 벌크 문장 전에 남은 관리 객체의 변경을 flush하고, clearAutomatically는 실행한 뒤 컨텍스트를 비워 벌크 결과와 어긋난 이전 객체를 계속 읽지 않게 합니다. 반영되지 않은 변경이 있으면 앞선 flush 자체가 실패하거나 버전을 바꿀 수 있으므로, 벌크 행 수만 보기 전에 이것도 확인합니다. Jakarta Persistence의 벌크 연산 규칙을 관리 객체의 자동 검사와 나눠 읽습니다.
더 깊이: 저장소 메서드마다 트랜잭션 설정이 다른 이유
updateOwned와 deleteOwned에 @Transactional이 따로 붙은 데는 이유가 있습니다. Spring Data JPA 레퍼런스에 따르면 CrudRepository에서 물려받은 메서드는 구현 클래스인 SimpleJpaRepository의 트랜잭션 설정을 따르는데, 읽기 연산에는 readOnly = true가, 나머지에는 기본 @Transactional이 붙어 있습니다. 반면 인터페이스에 직접 선언한 쿼리 메서드에는 기본 트랜잭션 설정이 적용되지 않습니다. 그래서 findByIdAndMemberId 같은 파생 쿼리나 @Query 메서드를 트랜잭션 안에서 실행하려면, 인터페이스나 메서드에 @Transactional을 직접 적거나 호출한 쪽의 트랜잭션에 기대야 합니다.
readOnly가 하는 일도 생각보다 좁습니다. 같은 레퍼런스는 이 플래그가 변경 쿼리를 막는 검사가 아니라 JDBC 드라이버에 전달되는 힌트이고, 일부 데이터베이스만 읽기 전용 트랜잭션 안의 INSERT와 UPDATE를 거부한다고 설명합니다. 대신 Hibernate와 함께 쓰면 Spring이 플러시 모드를 MANUAL로 바꾸므로, 변경 감지(dirty checking)를 건너뛰어 큰 객체 그래프에서 비용을 줄입니다. 거꾸로 말하면 읽기 전용 트랜잭션 안에서 관리 엔티티의 필드를 바꿔도 플러시되지 않아 저장되지 않으므로, 변경이 사라졌다면 바깥 트랜잭션의 readOnly 설정부터 확인합니다.
동적 검색은 커스텀 프래그먼트 하나에 격리합니다
커스텀 프래그먼트는 세 번째 애플리케이션 포트가 아닙니다. 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에서 애노테이션 처리기가 만든 출력만 정적으로 import하며, 생성이 실패하면 직접 만든 대체 클래스로 숨기지 않고 빌드 실패로 드러냅니다.
검색은 회원과 포함 날짜 범위를 언제나 적용합니다. 검색어는 PostSearch가 확정한 문자열을 containsIgnoreCase에 넘깁니다. 값을 바인딩하는 것만으로는 %와 _가 LIKE의 와일드카드라는 사실이 바뀌지 않으므로, Querydsl은 값 안의 %와 _, 자신의 이스케이프 문자 ! 앞에 !를 붙인 뒤 바인딩하고 JPQL에 escape '!'를 붙입니다. 그래서 %와 _는 글자 그대로 비교되고, 이스케이프 문자가 아닌 역슬래시도 보통 글자로 다뤄집니다. 공격 문자열은 바인딩 값으로 전달되므로 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행만 읽으며, 다음 커서는 버린 미리 읽은 행이 아니라 실제로 돌려준 마지막 PostSnapshot에서 만듭니다.
생성자 투영과 페이지 규칙을 실행합니다
테스트 설정은 슬라이스의 EntityManager로 JPAQueryFactory 하나를 만듭니다. 운영 설정을 복제하지 않고, 커스텀 프래그먼트가 실제 프록시에 연결되는지만 실행합니다.
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행을 돌려주고 커서가 있음, 있음, 없음인지 확인합니다. 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.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 집합과 정렬을 함께 검사해, 같은 행을 두 번 읽거나 페이지가 넘어가는 지점에서 빠뜨리는 경우도 막습니다.
이제 JPA·Querydsl 어댑터도 첫 글의 공통 테스트에 붙입니다. 구체 클래스는 앞의 두 JPA 테스트와 같은 슬라이스 설정을 쓰되, 회원 41을 넣는 jpa-members.sql은 뺍니다. 공통 테스트가 회원 41과 99를 직접 넣으므로 그대로 두면 기본 키가 겹치고, 속성이 달라졌으므로 Spring 테스트는 이 클래스에 앞의 두 테스트와 다른 컨텍스트와 H2를 만듭니다. @DataJpaTest는 원래 테스트마다 트랜잭션을 열고 롤백하지만, MyBatis 구체 클래스에서 본 것처럼 상속한 공통 테스트의 메서드에는 테스트 트랜잭션이 생기지 않습니다. 그래서 saveAndFlush와 버전 조건의 수정·삭제는 Spring Data 메서드의 트랜잭션에서 하나씩 커밋되고, 커밋된 행은 resetDatabase()가 메서드마다 지웁니다.
이 조건에서는 중복 생성 검사도 실패한 세션을 다시 쓰지 않습니다. Hibernate의 Session 문서는 세션이 예외를 던지면 현재 트랜잭션을 롤백하고 그 세션을 버려야 한다고 설명합니다. 여기서는 실패한 saveAndFlush의 트랜잭션과 영속성 컨텍스트가 그 호출에서 끝나므로, 공통 테스트가 실패 직후에 실행하는 조회는 새 컨텍스트에서 시작합니다.
package board.jpa;
import board.application.PostQuery;
import board.application.PostRepository;
import board.contract.PostPersistenceContract;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
@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.datasource.generate-unique-name=true"
})
@Import({JpaPostRepository.class, QuerydslJpaConfiguration.class})
@Execution(ExecutionMode.SAME_THREAD)
class JpaPostContractTest extends PostPersistenceContract {
@Autowired
JpaPostRepository adapter;
@Autowired
JdbcTemplate jdbc;
@Override
protected PostRepository repository() {
return adapter;
}
@Override
protected PostQuery query() {
return adapter;
}
@Override
protected void resetDatabase() {
jdbc.update("delete from post_event_outbox");
jdbc.update("delete from daily_post_stats");
jdbc.update("delete from posts");
jdbc.update("delete from members");
}
@Override
protected void insertMember(long memberId) {
jdbc.update("""
insert into members(
id, email, password_hash, name,
active, daily_character_limit)
values (?, ?, ?, ?, true, 10000)
""",
memberId,
"member" + memberId + "@example.test",
"hash",
"회원" + memberId);
}
}JdbcTemplate은 @DataJpaTest가 함께 구성하는 빈이며, 트랜잭션 밖에서 실행하므로 정리와 회원 삽입도 문장마다 바로 커밋됩니다.
집계용 내부 값도 포트를 대신하지 않습니다
제목별 주간 집계처럼 쿼리 구현 안에서만 쓰는 값은 별도의 애플리케이션 결과로 올리지 않습니다. 아래의 값은 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과 인덱스 사용은 따로 확인하고, 커스텀 프래그먼트의 장점은 조건과 정렬, 투영을 한곳에서 타입과 함께 지킨다는 데 둡니다.
정리
- Spring Data JPA는 저장과 고정된 쿼리를, Querydsl은 조건이 달라지는 읽기를 맡으며, 둘 다 같은
EntityManager위에서 동작합니다. - 버전 조건의 수정과 삭제는 JPQL 벌크 문장으로 고정하고, 벌크 연산은 자동 버전 검사를 거치지 않으므로 기대 버전과 증가를 직접 명시합니다.
flushAutomatically와clearAutomatically로 벌크 연산 전후의 영속성 컨텍스트를 맞춥니다.- 커스텀 프래그먼트는 JPA 내부의 조각일 뿐 새 포트가 아니며, 생성된
QPostEntity를 쓰고 검색어는 바인딩 값으로 넘깁니다. - 페이지마다 쿼리가 하나뿐인지 Hibernate 통계로 확인하고, 내부 집계 타입은 공개 포트의 결과를 대신하지 않습니다.
연습 문제
1. 마지막 페이지 계산하기
고정된 대상과 정렬 키, 페이지 크기 20, 페이지마다 size + 1행만 읽는 조건을 유지합니다. 대상이 정확히 40행이면 돌려주는 크기와 커서는 어떻게 되나요? 41행이면 무엇이 달라지나요? 돌려준 목록만 보면 같아 보이는 두 번째 요청의 차이를 설명하세요.
해설 보기
40행이면 20, 20행을 돌려주고 커서는 있음, 없음입니다. 두 번째 요청 뒤에 남은 행이 없어 미리 읽은 행도 없습니다. 41행이면 20, 20, 1행을 돌려주고 커서는 있음, 있음, 없음입니다. 두 번째 요청은 21행을 읽어 20행을 돌려주므로, 아직 돌려주지 않은 한 행이 있다는 것을 압니다.
테스트처럼 조회마다 SQL이 정확히 하나이고 개수 쿼리가 따로 없다면, 두 조건의 조회 SQL 수는 각각 2와 3입니다. 이 값은 조회 사이에 변경이 없고 자동으로 더해지는 쿼리도 없다는 전제에서 계산한 것입니다.
2. 벌크 UPDATE 뒤의 이전 객체
같은 트랜잭션에서 게시글 70을 find해 관리 객체로 가진 채, clearAutomatically 없이 버전 조건의 벌크 UPDATE를 실행했습니다. 그 뒤 다시 find(70)을 하면 어떤 값이 보일까요? clearAutomatically = true로 바꾸면 어떻게 달라지나요?
해설 보기
벌크 UPDATE는 영속성 컨텍스트를 거치지 않고 DB를 직접 바꾸므로, clearAutomatically가 없으면 컨텍스트에 남은 이전 관리 객체가 그대로 돌아와 바뀌기 전의 제목과 버전이 보입니다. clearAutomatically = true로 두면 실행 뒤 컨텍스트가 비워지므로, 다시 find할 때 DB에서 새로 읽어 바뀐 제목과 올라간 버전이 보입니다. 다만 비워지면서 그때까지 들고 있던 다른 관리 객체도 분리되므로, 반영하지 않은 변경이 있었다면 flushAutomatically로 먼저 내보내야 합니다.
다음 글 데이터 접근 기술 전환에서는 같은 공개 읽기 포트의 기존 구현과 후보 구현을 비동기로 비교하고, 후보가 실패해도 사용자의 응답이 바뀌지 않는 전환 상태 기계를 만듭니다.