JPA 영속성 컨텍스트
B90 스키마에 맞춘 JPA 엔티티로 동일성, 플러시, 롤백, 변경 감지와 낙관적 충돌을 검증합니다.
JPA는 SQL을 없애는 기술이 아니라 객체 상태와 SQL 실행 사이에 영속성 컨텍스트라는 작업 단위를 둡니다.
이 문서의 엔티티는 B90의 posts 스키마만 매핑하고, 애플리케이션에는 엔티티 대신 PostSnapshot을 돌려줍니다. persist, flush, commit은 서로 다른 경계이며 GenerationType.IDENTITY는 식별자를 얻기 위해 persist 근처에서 INSERT를 실행할 수 있습니다.
JPA PERSISTENCE CONTEXT
flush는 SQL 실행이고, commit은 트랜잭션 완료다
객체의 모양보다 영속성 컨텍스트와의 연결, SQL 실행 시점, 트랜잭션 결과를 따로 관찰합니다.
New
ID가 없고 영속성 컨텍스트가 추적하지 않는 새 엔티티입니다.
Managed
persist 뒤 같은 ID 조회는 같은 객체이며 변경 감지가 동작합니다.
Flushed
INSERT·UPDATE와 버전 증가가 실행되지만 트랜잭션은 아직 끝나지 않았습니다.
Committed 또는 Rolled back
commit만 결과를 확정하고 rollback은 이미 flush된 변경도 되돌립니다.
Detached
clear 뒤 객체는 자동 변경 감지 대상이 아니며 다시 읽은 객체와 동일하지 않습니다.
애플리케이션 경계
엔티티 대신 불변 PostSnapshot과 정확한 1행·0행 사실만 돌려줍니다.
- flush와 변경 감지
- commit 경로
- rollback 경로
- context 분리
핵심: flush 시점의 SQL 관찰과 commit 시점의 영속 결과를 같은 사건으로 부르지 않습니다.
통합 담당자가 병합하는 JPA·Querydsl 의존성 delta
이 fence는 root build.gradle.kts를 이 문서가 소유한다는 뜻이 아닙니다. 통합 담당자가 기존 Java 25·Boot BOM·H2 설정과 ch10-5의 MyBatis delta에 한 번만 병합합니다. Querydsl Q 소스는 Jakarta 분류자의 annotation processor가 만들며, 처리기가 빠지면 수동 Q 클래스로 우회하지 않고 컴파일 관문을 닫습니다.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
annotationProcessor("com.querydsl:querydsl-apt:5.1.0:jakarta")
annotationProcessor("jakarta.persistence:jakarta.persistence-api:3.2.0")
annotationProcessor("jakarta.annotation:jakarta.annotation-api:3.0.0")
testImplementation(
"org.springframework.boot:spring-boot-starter-data-jpa-test")
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf(
"-proc:full",
"-processor",
"com.querydsl.apt.jpa.JPAAnnotationProcessor",
))
}엔티티는 스키마를 그대로 매핑한다
DDL은 계속 src/main/resources/schema.sql 하나가 소유합니다. Hibernate는 ddl-auto=validate로 매핑을 검증할 뿐 테이블을 만들거나 고치지 않습니다.
명령 문자열은 자르거나 정규화하지 않습니다. 제목 80자, 본문 720자, (member_id, client_request_id) 고유 제약, 업무 날짜, 생성 시각과 버전 열을 그대로 매핑합니다.
package board.jpa;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
import board.application.PostSnapshot;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;
@Entity
@Table(
name = "posts",
uniqueConstraints = @UniqueConstraint(
name = "uq_posts_member_request",
columnNames = {"member_id", "client_request_id"}))
public class PostEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "member_id", nullable = false)
private long memberId;
@Column(nullable = false, length = 80)
private String title;
@Column(nullable = false, length = 720)
private String content;
@Column(name = "published_on", nullable = false)
private LocalDate publishedOn;
@Column(name = "client_request_id", nullable = false, length = 64)
private String clientRequestId;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Version
@Column(nullable = false)
private long version;
protected PostEntity() {
}
private PostEntity(CreatePostCommand command, Instant createdAt) {
this.memberId = command.memberId();
this.title = command.title();
this.content = command.content();
this.publishedOn = command.publishedOn();
this.clientRequestId = command.clientRequestId();
this.createdAt = Objects.requireNonNull(createdAt, "createdAt");
}
static PostEntity create(CreatePostCommand command, Instant createdAt) {
if (command == null || createdAt == null) {
throw new IllegalArgumentException("command and createdAt are required");
}
return new PostEntity(command, createdAt);
}
void changeForPersistenceLesson(String title, String content) {
if (title == null || title.isBlank() || title.length() > 80
|| content == null || content.isBlank()
|| content.length() > 720) {
throw new IllegalArgumentException("invalid post change");
}
this.title = title;
this.content = content;
}
PostSnapshot snapshot() {
if (id == null || id <= 0) {
throw new IllegalStateException("persisted id must be positive");
}
return new PostSnapshot(
id,
memberId,
title,
content,
publishedOn,
clientRequestId,
createdAt,
version);
}
public Long getId() { return id; }
public long getMemberId() { return memberId; }
public String getTitle() { return title; }
public String getContent() { return content; }
public LocalDate getPublishedOn() { return publishedOn; }
public String getClientRequestId() { return clientRequestId; }
public Instant getCreatedAt() { return createdAt; }
public long getVersion() { return version; }
}JPA용 기본 생성자는 protected로 제한합니다. PostEntity와 지연 프록시는 board.jpa 밖으로 내보내지 않고, 공개 포트는 애플리케이션의 불변 스냅샷만 반환합니다.
공개 포트는 한 행 또는 0행의 사실을 보존한다
생성은 saveAndFlush로 제약 발생 지점을 어댑터 안에 둡니다. 실제 제약 이름이 uq_posts_member_request인 경우만 DuplicatePostRequestException으로 좁히고, 나머지 저장 실패는 원인을 가진 PostPersistenceException으로 바꿉니다.
수정과 삭제는 ID, 회원, 버전을 한 JPQL 조건에 넣습니다. 결과 1은 true, 0은 false이고 그 이유를 추측하지 않습니다. 2행 이상은 손상된 계약이므로 실패합니다.
package board.jpa;
import java.time.Instant;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import board.application.ChangedPost;
import board.application.DeletePostCommand;
import board.application.DuplicatePostRequestException;
import board.application.PostPage;
import board.application.PostPersistenceException;
import board.application.PostQuery;
import board.application.PostRepository;
import board.application.PostSearch;
import board.application.PostSnapshot;
import board.application.PostSummary;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.stereotype.Repository;
@Repository
public final class JpaPostRepository implements PostRepository, PostQuery {
private static final Pattern REQUEST_CONSTRAINT = Pattern.compile(
"(?i)(?:^|[.\\s\"`])uq_posts_member_request"
+ "(?:_index_\\d+)?(?=$|[.\\s\"`])");
private static final Pattern REQUEST_ID =
Pattern.compile("[A-Za-z0-9_-]{8,64}");
private final PostJpaRepository entities;
public JpaPostRepository(PostJpaRepository entities) {
this.entities = entities;
}
@Override
public PostSnapshot create(CreatePostCommand command, Instant createdAt) {
if (command == null || createdAt == null) {
throw new IllegalArgumentException("command and createdAt are required");
}
PostEntity entity = PostEntity.create(command, createdAt);
try {
PostEntity persisted = entities.saveAndFlush(entity);
if (persisted.getId() == null || persisted.getId() <= 0) {
throw failure("create returned a non-positive id", null);
}
return persisted.snapshot();
} catch (RuntimeException storageFailure) {
if (isRequestDuplicate(storageFailure)) {
throw new DuplicatePostRequestException(
command.memberId(),
command.clientRequestId(),
storageFailure);
}
if (storageFailure instanceof PostPersistenceException translated) {
throw translated;
}
throw failure("JPA create failed", storageFailure);
}
}
@Override
public boolean update(ChangedPost command) {
if (command == null) {
throw new IllegalArgumentException("change is required");
}
return exactRowCount(
"update",
translate("JPA update failed", () -> entities.updateOwned(
command.id(),
command.memberId(),
command.title(),
command.content(),
command.expectedVersion())));
}
@Override
public boolean delete(DeletePostCommand command) {
if (command == null) {
throw new IllegalArgumentException("delete command is required");
}
return exactRowCount(
"delete",
translate("JPA delete failed", () -> entities.deleteOwned(
command.postId(),
command.memberId(),
command.expectedVersion())));
}
@Override
public Optional<PostSnapshot> find(long postId, long memberId) {
requirePositive(postId, "postId");
requirePositive(memberId, "memberId");
return translate(
"JPA owner-scoped find failed",
() -> entities.findByIdAndMemberId(postId, memberId)
.map(PostEntity::snapshot));
}
@Override
public Optional<PostSnapshot> findByIdempotencyKey(
long memberId,
String clientRequestId
) {
requirePositive(memberId, "memberId");
if (clientRequestId == null
|| !REQUEST_ID.matcher(clientRequestId).matches()) {
throw new IllegalArgumentException("invalid clientRequestId");
}
return translate(
"JPA idempotency lookup failed",
() -> entities
.findByMemberIdAndClientRequestId(
memberId, clientRequestId)
.map(PostEntity::snapshot));
}
@Override
public PostPage findPage(PostSearch search) {
if (search == null) {
throw new IllegalArgumentException("search is required");
}
return translate("JPA page query failed", () -> entities.findPage(search));
}
@Override
public PostSummary summary(long memberId) {
requirePositive(memberId, "memberId");
return translate("JPA summary query failed", () -> entities.summary(memberId));
}
private static boolean exactRowCount(String operation, int rows) {
if (rows == 0) {
return false;
}
if (rows == 1) {
return true;
}
throw failure(
operation + " changed more than one row",
new IllegalStateException("changed rows: " + rows));
}
private static <T> T translate(String message, Supplier<T> work) {
try {
return work.get();
} catch (PostPersistenceException translated) {
throw translated;
} catch (RuntimeException storageFailure) {
throw failure(message, storageFailure);
}
}
private static boolean isRequestDuplicate(Throwable failure) {
Throwable current = failure;
while (current != null) {
if (current instanceof ConstraintViolationException violation) {
String name = violation.getConstraintName();
if (name != null
&& REQUEST_CONSTRAINT.matcher(name).find()) {
return true;
}
}
Throwable next = current.getCause();
if (next == current) {
break;
}
current = next;
}
return false;
}
private static PostPersistenceException failure(
String message,
Throwable cause
) {
Throwable retained = cause != null
? cause
: new IllegalStateException(message);
return new PostPersistenceException(message, retained);
}
private static void requirePositive(long value, String name) {
if (value <= 0) {
throw new IllegalArgumentException(name + " must be positive");
}
}
}플러시는 분류를 앞당길 뿐 커밋하지 않습니다. 어댑터는 호출자의 트랜잭션에 참여하며 자체 REQUIRES_NEW 경계를 만들지 않습니다.
관리 상태, 플러시와 버전을 서로 다른 오라클로 검증한다
@DataJpaTest는 JPA용 JpaTransactionManager를 사용합니다. JDBC 트랜잭션 구성을 이 슬라이스에 가져오지 않고, 매니저가 가진 DataSource가 슬라이스의 객체와 같은 인스턴스인지 확인합니다.
기본 롤백 테스트에서는 TestTransaction으로 플러시 뒤 트랜잭션을 끝낸 다음 새 트랜잭션에서 행이 없는지 읽습니다. 낙관적 충돌 테스트는 두 개의 독립 EntityManager와 트랜잭션을 사용해 정확히 두 번째 변경만 실패하는지 확인합니다.
package board.jpa;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import java.time.LocalDate;
import javax.sql.DataSource;
import board.application.ChangedPost;
import board.application.DeletePostCommand;
import board.application.DuplicatePostRequestException;
import board.application.PostSnapshot;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceException;
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;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.test.context.transaction.TestTransaction;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@DataJpaTest
@Import({JpaPostRepository.class, QuerydslJpaConfiguration.class})
class JpaPostRepositoryTest {
private static final Instant CREATED_AT =
Instant.parse("2026-07-14T09:00:00Z");
@Autowired
EntityManager entityManager;
@Autowired
EntityManagerFactory entityManagerFactory;
@Autowired
JpaPostRepository adapter;
@Autowired
PlatformTransactionManager transactionManager;
@Autowired
DataSource dataSource;
@Test
void managed_identity_dirty_check_and_clear_reload_are_distinct() {
PostEntity entity = PostEntity.create(
command("jpa-id-01", "JPA", "managed identity"),
CREATED_AT);
entityManager.persist(entity);
entityManager.flush();
PostEntity same = entityManager.find(PostEntity.class, entity.getId());
assertThat(same).isSameAs(entity);
long initialVersion = entity.getVersion();
entity.changeForPersistenceLesson("JPA 수정", "dirty checking");
entityManager.flush();
assertThat(entity.getVersion()).isEqualTo(initialVersion + 1);
entityManager.clear();
PostEntity reloaded = entityManager.find(PostEntity.class, entity.getId());
assertThat(reloaded).isNotSameAs(entity);
assertThat(reloaded.getContent()).isEqualTo("dirty checking");
}
@Test
void flush_is_not_commit_and_rollback_removes_the_flushed_row() {
PostEntity entity = PostEntity.create(
command("jpa-rb-01", "flush", "rollback after flush"),
CREATED_AT);
entityManager.persist(entity);
entityManager.flush();
long id = entity.getId();
assertThat(id).isPositive();
TestTransaction.flagForRollback();
TestTransaction.end();
TestTransaction.start();
entityManager.clear();
assertThat(entityManager.find(PostEntity.class, id)).isNull();
}
@Test
void application_adapter_preserves_create_update_delete_facts() {
PostSnapshot created = adapter.create(
command("jpa-port-01", "port", "shared snapshot"),
CREATED_AT);
assertThat(created.id()).isPositive();
assertThat(created.version()).isZero();
assertThat(adapter.update(new ChangedPost(
created.id(), 41L, "changed", "version one", 0L))).isTrue();
assertThat(adapter.update(new ChangedPost(
created.id(), 41L, "stale", "must not win", 0L))).isFalse();
assertThat(adapter.update(new ChangedPost(
created.id(), 42L, "other", "must not win", 1L))).isFalse();
PostSnapshot changed = adapter.find(created.id(), 41L).orElseThrow();
assertThat(changed.content()).isEqualTo("version one");
assertThat(changed.version()).isEqualTo(1L);
assertThat(adapter.delete(new DeletePostCommand(
created.id(), 42L, 1L))).isFalse();
assertThat(adapter.delete(new DeletePostCommand(
created.id(), 41L, 1L))).isTrue();
}
@Test
void member_request_constraint_is_the_only_duplicate_classification() {
CreatePostCommand command =
command("jpa-dup-01", "first", "original row");
adapter.create(command, CREATED_AT);
assertThatThrownBy(() -> adapter.create(command, CREATED_AT.plusSeconds(1)))
.isInstanceOf(DuplicatePostRequestException.class)
.satisfies(failure -> assertThat(failure.getCause()).isNotNull());
}
@Test
void jpa_slice_uses_its_jpa_manager_and_the_same_data_source() {
assertThat(transactionManager).isInstanceOf(JpaTransactionManager.class);
JpaTransactionManager manager =
(JpaTransactionManager) transactionManager;
assertThat(manager.getDataSource()).isSameAs(dataSource);
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void two_independent_contexts_produce_one_optimistic_conflict() {
long id = insertCommitted();
EntityManager first = entityManagerFactory.createEntityManager();
EntityManager second = entityManagerFactory.createEntityManager();
try {
first.getTransaction().begin();
second.getTransaction().begin();
PostEntity winner = first.find(PostEntity.class, id);
PostEntity loser = second.find(PostEntity.class, id);
winner.changeForPersistenceLesson("winner", "first commit");
loser.changeForPersistenceLesson("loser", "must conflict");
first.flush();
first.getTransaction().commit();
assertThatThrownBy(second::flush)
.isInstanceOf(PersistenceException.class);
} finally {
rollbackIfActive(first);
rollbackIfActive(second);
first.close();
second.close();
}
EntityManager verification = entityManagerFactory.createEntityManager();
try {
PostEntity stored = verification.find(PostEntity.class, id);
assertThat(stored.getContent()).isEqualTo("first commit");
assertThat(stored.getVersion()).isEqualTo(1L);
} finally {
verification.close();
deleteCommitted(id);
}
}
private long insertCommitted() {
EntityManager manager = entityManagerFactory.createEntityManager();
try {
manager.getTransaction().begin();
PostEntity entity = PostEntity.create(
command("jpa-lock-01", "initial", "version zero"),
CREATED_AT);
manager.persist(entity);
manager.flush();
long id = entity.getId();
manager.getTransaction().commit();
return id;
} finally {
rollbackIfActive(manager);
manager.close();
}
}
private void deleteCommitted(long id) {
EntityManager manager = entityManagerFactory.createEntityManager();
try {
manager.getTransaction().begin();
PostEntity stored = manager.find(PostEntity.class, id);
if (stored != null) {
manager.remove(stored);
}
manager.getTransaction().commit();
} finally {
rollbackIfActive(manager);
manager.close();
}
}
private static void rollbackIfActive(EntityManager manager) {
if (manager.getTransaction().isActive()) {
manager.getTransaction().rollback();
}
}
private static CreatePostCommand command(
String requestId,
String title,
String content
) {
return new CreatePostCommand(
41L,
title,
content,
LocalDate.parse("2026-07-14"),
requestId);
}
}flush_is_not_commit...는 실제 INSERT가 실행됐다는 사실과 외부에서 커밋됐다는 주장을 분리합니다. 충돌 테스트도 한 영속성 컨텍스트에서 객체 두 개를 흉내 내지 않고 서로 다른 컨텍스트가 같은 버전을 읽게 합니다.
낙관적 충돌은 어댑터 내부 진단 값이다
공개 수정 포트는 여전히 true 또는 false라는 행 매치 사실만 반환합니다. JPA 공급자의 충돌 타입은 기술별 테스트와 진단에만 머뭅니다.
package board.jpa;
record OptimisticConflict(
long postId,
long expectedVersion
) {
public OptimisticConflict {
if (postId <= 0 || expectedVersion < 0) {
throw new IllegalArgumentException("invalid conflict context");
}
}
}사용자가 직접 편집한 본문은 충돌 뒤 자동 재시도하면 다른 변경을 덮을 수 있습니다. 최신 PostSnapshot을 다시 보여 주고 병합 여부를 사용자에게 맡깁니다.
다음 문서에서는 Spring Data가 내부 리포지토리를 조합하는 범위와 Querydsl 생성 소스가 동적 검색을 담당하는 범위를 분리합니다.