JPA 영속성 컨텍스트
공유 스키마에 맞춘 JPA 엔티티로 동일성, 플러시, 롤백, 변경 감지와 낙관적 충돌을 검증합니다.
JPA는 SQL을 없애는 기술이 아니라 객체 상태와 SQL 실행 사이에 영속성 컨텍스트라는 작업 단위를 둡니다.
이 문서의 엔티티는 앞 장의 공유 posts 스키마만 매핑하고, 애플리케이션에는 엔티티 대신 PostSnapshot을 돌려줍니다. persist, flush, commit은 서로 다른 경계이며 GenerationType.IDENTITY는 식별자를 얻기 위해 persist 근처에서 INSERT를 실행할 수 있습니다.
JPA 상태 전이는 SQL 실행과 트랜잭션 완료를 구분한다
new→persist→managed에서 flush는 상태를 유지하고 clear는 detached로 바꾸는 차이가 핵심이다.
JPA · ENTITY STATE ≠ TRANSACTION EVENT
엔티티 상태와 flush·commit 사건을 구분한다
flush는 관리 상태의 변경을 SQL로 동기화하는 사건입니다. 엔티티가 “Flushed”라는 별도 상태로 바뀌는 것은 아닙니다.
- New → Managed · persist
영속성 컨텍스트가 엔티티를 추적합니다. 같은 컨텍스트의 동일 ID 조회는 같은 관리 객체를 반환합니다.
- Managed → Managed · flush
변경을 SQL과 동기화해도 관리 상태입니다. flush 자체는 commit이 아닙니다.
- Managed → Detached · clear
컨텍스트를 비워 관리 객체를 분리합니다. 분리 객체의 변경은 자동 추적되지 않습니다.
트랜잭션 commit
성공적으로 완료되면 DB 변경이 확정됩니다.
관리 객체가 계속 남는지는 영속성 컨텍스트의 수명에 따릅니다. “Committed”는 엔티티 상태명이 아닙니다.
트랜잭션 rollback
이미 flush한 DB 변경도 취소됩니다.
Java 객체 필드가 옛 값으로 자동 복원되지는 않습니다. 실패한 컨텍스트의 객체를 그대로 재사용하지 않습니다.
IDENTITY: ID를 얻기 위해 persist 근처에서 INSERT할 수 있습니다. flush가 모든 SQL의 최초 실행 시점이라고 가정하지 않습니다.
엔티티는 어댑터 내부에 두고 불변 PostSnapshot만 반환합니다. 공개 수정·삭제 포트는 1행 true / 0행 false라는 사실을 지킵니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 새 엔티티는 persist 후 managed가 된다. managed에서 flush해도 managed이다. clear는 엔티티를 detached로 만든다. flush와 commit은 상태 이름이 아니라 동기화와 트랜잭션 완료 사건이다.
- 가로 도표와 같은 세 상태 노드와 세 간선이다. New→Managed의 persist, Managed→Managed selfloop의 flush, Managed→Detached의 clear다. flush는 commit이 아니며 롤백이 Java 필드를 자동 원복하지 않는다는 범위는 원래 패널과 같다. Removed 등 모든 JPA 상태를 망라한 도식은 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | JPA 상태 전이는 SQL 실행과 트랜잭션 완료를 구분한다 |
| 19 | 가로 도표와 같은 세 상태 노드와 세 간선이다. New→Managed의 persist, Managed→Managed selfloop의 flush, Managed→Detached의 clear다. flush는 commit이 아니며 롤백이 Java 필드를 자동 원복하지 않는다는 범위는 원래 패널과 같다. Removed 등 모든 JPA 상태를 망라한 도식은 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| persist 후 managed 새 엔티티에 persist를 적용합니다. | 전 상태: new · 후 상태: managed | 엔티티가 관리 범위에 들어갑니다. |
| flush 동기화 managed 엔티티를 flush합니다. | 상태: managed 유지 · 사건: SQL 동기화 | SQL 동기화가 상태 이름을 바꾸지 않습니다. |
| clear 후 detached clear로 관리 범위를 비웁니다. | 후 상태: detached · commit: 별도 완료 사건 | 엔티티가 관리 범위에서 빠집니다. |
JPA 상태 전이는 SQL 실행과 트랜잭션 완료를 구분한다
new→persist→managed에서 flush는 상태를 유지하고 clear는 detached로 바꾸는 차이가 핵심이다.
JPA · ENTITY STATE ≠ TRANSACTION EVENT
엔티티 상태와 flush·commit 사건을 구분한다
flush는 관리 상태의 변경을 SQL로 동기화하는 사건입니다. 엔티티가 “Flushed”라는 별도 상태로 바뀌는 것은 아닙니다.
- New → Managed · persist
영속성 컨텍스트가 엔티티를 추적합니다. 같은 컨텍스트의 동일 ID 조회는 같은 관리 객체를 반환합니다.
- Managed → Managed · flush
변경을 SQL과 동기화해도 관리 상태입니다. flush 자체는 commit이 아닙니다.
- Managed → Detached · clear
컨텍스트를 비워 관리 객체를 분리합니다. 분리 객체의 변경은 자동 추적되지 않습니다.
트랜잭션 commit
성공적으로 완료되면 DB 변경이 확정됩니다.
관리 객체가 계속 남는지는 영속성 컨텍스트의 수명에 따릅니다. “Committed”는 엔티티 상태명이 아닙니다.
트랜잭션 rollback
이미 flush한 DB 변경도 취소됩니다.
Java 객체 필드가 옛 값으로 자동 복원되지는 않습니다. 실패한 컨텍스트의 객체를 그대로 재사용하지 않습니다.
IDENTITY: ID를 얻기 위해 persist 근처에서 INSERT할 수 있습니다. flush가 모든 SQL의 최초 실행 시점이라고 가정하지 않습니다.
엔티티는 어댑터 내부에 두고 불변 PostSnapshot만 반환합니다. 공개 수정·삭제 포트는 1행 true / 0행 false라는 사실을 지킵니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 새 엔티티는 persist 후 managed가 된다. managed에서 flush해도 managed이다. clear는 엔티티를 detached로 만든다. flush와 commit은 상태 이름이 아니라 동기화와 트랜잭션 완료 사건이다.
- 가로 도표와 같은 세 상태 노드와 세 간선이다. New→Managed의 persist, Managed→Managed selfloop의 flush, Managed→Detached의 clear다. flush는 commit이 아니며 롤백이 Java 필드를 자동 원복하지 않는다는 범위는 원래 패널과 같다. Removed 등 모든 JPA 상태를 망라한 도식은 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | JPA 상태 전이는 SQL 실행과 트랜잭션 완료를 구분한다 |
| 19 | 가로 도표와 같은 세 상태 노드와 세 간선이다. New→Managed의 persist, Managed→Managed selfloop의 flush, Managed→Detached의 clear다. flush는 commit이 아니며 롤백이 Java 필드를 자동 원복하지 않는다는 범위는 원래 패널과 같다. Removed 등 모든 JPA 상태를 망라한 도식은 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| persist 후 managed 새 엔티티에 persist를 적용합니다. | 전 상태: new · 후 상태: managed | 엔티티가 관리 범위에 들어갑니다. |
| flush 동기화 managed 엔티티를 flush합니다. | 상태: managed 유지 · 사건: SQL 동기화 | SQL 동기화가 상태 이름을 바꾸지 않습니다. |
| clear 후 detached clear로 관리 범위를 비웁니다. | 후 상태: detached · commit: 별도 완료 사건 | 엔티티가 관리 범위에서 빠집니다. |
JPA·Querydsl 의존성 추가
다음 블록은 기존 실습 프로젝트의 build.gradle.kts에 추가할 의존성과 처리기 설정입니다. 앞 절의 MyBatis 의존성, Java 25·Boot BOM·H2 설정을 같은 프로젝트에서 유지합니다. 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로 매핑을 검증할 뿐 테이블을 만들거나 고치지 않습니다.
이 절과 다음 절의 JPA 테스트는 같은 데이터 접근 실습 프로젝트에서 이어갑니다. 이 장의 Java 25·Boot 4.1.1 BOM·H2 설정과 앞 문서의 명령/조회 포트를 준비하고, 위의 JPA·Querydsl 의존성을 그 프로젝트의 build.gradle.kts에 추가합니다. 별도 실습 프로젝트를 사용하는 경우에도 시작 절의 board.BoardApplication처럼 board 패키지에서 Boot 주구성을 찾을 수 있어야 합니다. 첫 장의 Gradle 파일 전체나 앞 JDBC 트랜잭션 구성을 JPA 슬라이스에 섞어 넣지는 않습니다.
앞 장의 전체 schema.sql을 src/main/resources에 두고, 아래 회원 데이터는 src/test/resources/jpa-members.sql에 둡니다. 두 JPA 테스트의 @DataJpaTest 속성은 테스트 전용 H2에서 SQL 초기화를 먼저 실행하고 Hibernate가 매핑만 검증하도록 명시합니다. 운영 DB 프로필과 이 테스트 초기화 경로를 함께 사용하지 않습니다.
insert into members(
id, email, password_hash, name, active, daily_character_limit)
values (41, '[email protected]', 'hash', '회원41', true, 10000);회원 41은 테스트 메서드의 트랜잭션이 시작되기 전에 초기화됩니다. 그래서 NOT_SUPPORTED로 별도 트랜잭션을 여는 낙관적 충돌 예제에서도 같은 회원을 참조할 수 있습니다. 메서드별 게시글 변경은 기존 롤백/삭제 경로로 정리하고, 공유 스키마에 테스트 회원을 넣거나 외래 키를 제거하지 않습니다. 초기화 파일이 없거나 SQL이 실패하면 컨텍스트 준비 단계에서 먼저 확인하고, FK 실패라면 게시글 INSERT 전에 회원 41이 준비되었는지 살펴봅니다.
명령 문자열은 자르거나 정규화하지 않습니다. 제목 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(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"
})
@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가 실행됐다는 사실과 외부에서 커밋됐다는 주장을 분리합니다. 충돌 테스트도 한 영속성 컨텍스트에서 객체 두 개를 흉내 내지 않고 서로 다른 컨텍스트가 같은 버전을 읽게 합니다.
Java 객체와 DB 행의 결과를 따로 추적하기
원 관리 상태 테스트에서 엔티티의 본문을 바꾸고 flush한 뒤 clear()합니다. 다시 find한 객체는 옛 참조와 같은 객체인가요? 이어 트랜잭션을 롤백하고 새 트랜잭션에서 그 ID를 읽으면 무엇을 관찰해야 하나요? 옛 Java 참조의 ID가 여전히 양수라는 사실만으로 커밋을 증명할 수 있는지도 답하세요.
동일성·동기화·완료를 분리한 풀이
clear 전에는 같은 영속성 컨텍스트의 같은 ID가 같은 관리 객체를 가리킵니다. clear 뒤의 원 참조는 분리된 객체이고, 다시 조회한 객체는 같은 값의 행을 읽더라도 다른 Java 객체입니다. 이 예제의 새 행을 삽입한 트랜잭션을 롤백한 뒤 새 트랜잭션으로 조회하면 행이 없어야 합니다.
롤백이 옛 Java 객체의 ID·본문 필드를 이전 값으로 되돌리는 것은 아닙니다. 양수 ID나 snapshot 값은 그 객체가 가진 값의 증거이며 DB 커밋의 증거가 아닙니다. 영속성 컨텍스트의 동일성, flush로 보낸 SQL, 완료 후 새 경계에서 읽은 행을 각각 기록해야 합니다. Jakarta Persistence 명세의 엔티티 상태와 동기화 절을 함께 읽으세요. 이 추가 비교는 원 코드를 추적한 풀이이며 새 JPA 실행 결과가 아닙니다.
낙관적 충돌은 어댑터 내부 진단 값이다
공개 수정 포트는 여전히 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 생성 소스가 동적 검색을 담당하는 범위를 분리합니다.