본문 바로가기

JPA 영속성 컨텍스트

공유 스키마에 맞춘 JPA 엔티티로 동일성, 플러시, 롤백, 변경 감지와 낙관적 충돌을 검증합니다.

JPA는 SQL을 없애는 기술이 아니라, 객체의 상태와 SQL 실행 사이에 영속성 컨텍스트(persistence context)라는 작업 단위를 둡니다. 이 글의 엔티티는 앞 장의 공유 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”라는 별도 상태로 바뀌는 것은 아닙니다.

JPA 엔티티 상태와 관리 상태에서 일어나는 flush새 엔티티는 persist 후 managed가 된다. managed에서 flush해도 managed이다. clear는 엔티티를 detached로 만든다. flush와 commit은 상태 이름이 아니라 동기화와 트랜잭션 완료 사건이다.New아직 context 밖persistManaged동일성 · 변경 추적clearDetached자동 변경 감지 없음flush · 여전히 Managed관리 객체 변경 → SQL 동기화 → 아직 트랜잭션 미완료
flush는 Managed로 돌아오는 사건이다desktop과 같은 세 상태 노드·세 간선이다. New→Managed의persist,Managed→Managed selfloop의flush,Managed→Detached의clear이다. flush는commit이아니며롤백이Java필드를자동원복하지않는범위는원패널대로이다. Removed등모든JPA상태를망라한도식은아니다.Newcontext 밖Managed동일성 · 변경 추적Detached자동 추적 없음persistflushclear
  1. New → Managed · persist

    영속성 컨텍스트가 엔티티를 추적합니다. 같은 컨텍스트의 동일 ID 조회는 같은 관리 객체를 반환합니다.

  2. Managed → Managed · flush

    변경을 SQL과 동기화해도 관리 상태입니다. flush 자체는 commit이 아닙니다.

  3. 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 상태를 망라한 도식은 아니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1JPA 상태 전이는 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”라는 별도 상태로 바뀌는 것은 아닙니다.

JPA 엔티티 상태와 관리 상태에서 일어나는 flush새 엔티티는 persist 후 managed가 된다. managed에서 flush해도 managed이다. clear는 엔티티를 detached로 만든다. flush와 commit은 상태 이름이 아니라 동기화와 트랜잭션 완료 사건이다.New아직 context 밖persistManaged동일성 · 변경 추적clearDetached자동 변경 감지 없음flush · 여전히 Managed관리 객체 변경 → SQL 동기화 → 아직 트랜잭션 미완료
flush는 Managed로 돌아오는 사건이다desktop과 같은 세 상태 노드·세 간선이다. New→Managed의persist,Managed→Managed selfloop의flush,Managed→Detached의clear이다. flush는commit이아니며롤백이Java필드를자동원복하지않는범위는원패널대로이다. Removed등모든JPA상태를망라한도식은아니다.Newcontext 밖Managed동일성 · 변경 추적Detached자동 추적 없음persistflushclear
  1. New → Managed · persist

    영속성 컨텍스트가 엔티티를 추적합니다. 같은 컨텍스트의 동일 ID 조회는 같은 관리 객체를 반환합니다.

  2. Managed → Managed · flush

    변경을 SQL과 동기화해도 관리 상태입니다. flush 자체는 commit이 아닙니다.

  3. 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 상태를 망라한 도식은 아니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1JPA 상태 전이는 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: 별도 완료 사건엔티티가 관리 범위에서 빠집니다.

그림은 새 엔티티가 persist로 관리 상태(managed)가 되고, 관리 상태에서 flush해도 여전히 관리 상태이며, clear하면 분리 상태(detached)가 되는 세 가지 전이를 보여 줍니다. flush와 commit은 엔티티의 상태 이름이 아니라 SQL 동기화와 트랜잭션 완료라는 사건입니다. 아래 칸들은 커밋하면 DB 변경이 확정되지만, 롤백해도 이미 바뀐 Java 객체의 필드는 저절로 되돌아가지 않는다는 점과, 공개 포트는 엔티티를 내보내지 않는다는 점을 함께 설명합니다. Removed를 포함한 모든 JPA 상태를 다룬 그림은 아닙니다.


JPA와 Querydsl 의존성 추가

다음 블록은 기존 실습 프로젝트의 build.gradle.kts에 더할 의존성과 처리기 설정입니다. 앞 글의 MyBatis 의존성과 Java 25, Boot BOM, H2 설정은 같은 프로젝트에 그대로 둡니다. Querydsl의 Q 소스는 Jakarta 분류자의 애노테이션 처리기가 만들며, 처리기가 빠지면 Q 클래스를 직접 만들어 우회하지 않고 컴파일 실패로 처리합니다. 처리기 옵션은 main 소스를 컴파일하는 compileJava에만 줍니다. 테스트 컴파일의 처리기 경로인 testAnnotationProcessor는 비어 있으므로, 같은 -processor를 모든 JavaCompile에 주면 compileTestJava가 그 처리기를 찾지 못해 실패합니다.

build.gradle.kts
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.named<JavaCompile>("compileJava") {
    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.2 BOM, H2 설정, 첫 글의 명령·조회 포트를 준비하고, 위의 JPA와 Querydsl 의존성을 그 프로젝트의 build.gradle.kts에 더합니다. @DataJpaTest는 테스트 클래스의 패키지에서 위로 올라가며 @SpringBootConfiguration을 찾는데, 9장부터 이어 온 이 프로젝트에는 아직 Boot의 주 구성이 없습니다. 그래서 1장 첫 글의 board.BoardApplication처럼 board 패키지에 @SpringBootApplication 클래스를 하나 둡니다. 첫 장의 Gradle 파일 전체나 앞의 JDBC 트랜잭션 구성을 JPA 슬라이스에 섞어 넣지는 않습니다.

9장 첫 글의 전체 schema.sql을 src/main/resources에 두고, 아래의 회원 데이터는 src/test/resources/jpa-members.sql에 둡니다. 두 JPA 테스트의 @DataJpaTest 속성은 테스트 전용 H2에서 SQL 초기화를 먼저 실행하고, Hibernate는 매핑만 검증하도록 명시합니다. 운영 DB 프로필과 이 테스트의 초기화 경로를 함께 쓰지 않습니다.

src/test/resources/jpa-members.sql
insert into members(
    id, email, password_hash, name, active, daily_character_limit)
values (41, '[email protected]', 'hash', '회원41', true, 10000);

회원 41은 테스트 메서드의 트랜잭션이 시작되기 전에 초기화됩니다. 그래서 NOT_SUPPORTED로 별도의 트랜잭션을 여는 낙관적 충돌 예제에서도 같은 회원을 참조할 수 있습니다. 메서드마다의 게시글 변경은 기존의 롤백이나 삭제로 정리하며, 공유 스키마에 테스트 회원을 넣거나 외래 키를 지우지 않습니다. 초기화 파일이 없거나 SQL이 실패하면 컨텍스트 준비 단계에서 먼저 드러나므로, 외래 키 실패라면 게시글 INSERT 전에 회원 41이 준비되었는지 확인합니다.

명령의 문자열은 자르거나 정규화하지 않습니다. 제목 80자와 본문 720자, (member_id, client_request_id) 유일성 제약, 업무 날짜, 생성 시각과 버전 열을 그대로 매핑합니다.

src/main/java/board/jpa/PostEntity.java
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 밖으로 내보내지 않고, 공개 포트는 애플리케이션의 불변 스냅샷만 돌려줍니다.

더 깊이: IDENTITY를 고르면 무엇을 포기할까

Hibernate 7.4 사용자 안내서는 IDENTITY 열을 쓰면 식별자 값을 알기 전에 행을 실제로 삽입해야 한다는 실행 시점의 제약이 생긴다고 설명합니다. persist 무렵에 INSERT가 나가는 이유가 이것입니다. 삽입한 뒤 키를 얻는 방법도 정해진 순서가 있어서, JDBC 환경이 Statement.getGeneratedKeys를 지원하면 그것을 쓰고, 아니면 방언이 아는 INSERT와 SELECT를 합친 구문을, 그것도 없으면 마지막 IDENTITY 값을 묻는 별도의 SQL을 씁니다.

같은 안내서는 실행 시점의 영향이 하나 더 있다고 적습니다. IDENTITY 생성을 쓰는 엔티티는 Hibernate가 INSERT를 JDBC 배치로 묶을 수 없어서, hibernate.jdbc.batch_size를 설정해도 그 엔티티의 삽입은 하나씩 DB에 갑니다. 요청마다 게시글 하나를 저장하는 이 글에서는 큰 차이가 없지만, 수천 건을 한 트랜잭션에서 가져오는 작업이라면 왕복 횟수가 그대로 행 수가 됩니다. 그런 작업이 중요하다면 시퀀스(GenerationType.SEQUENCE)와, 시퀀스 값을 묶음으로 받아 왕복을 줄이는 pooled 계열 최적화기를 검토합니다. 다만 이 장의 posts.id는 공유 schema.sql에서 IDENTITY 열로 정의되어 있으므로, 생성 전략을 바꾸려면 엔티티가 아니라 스키마부터 함께 바꿔야 합니다.


공개 포트는 한 행이나 0행이라는 사실을 보존합니다

생성은 saveAndFlush로 제약 위반이 어댑터 안에서 드러나게 합니다. 실제 제약 이름이 uq_posts_member_request인 경우만 DuplicatePostRequestException으로 좁히고, 나머지 저장 실패는 원인을 담은 PostPersistenceException으로 바꿉니다.

수정과 삭제는 ID와 회원, 버전을 하나의 JPQL 조건에 넣습니다. 결과가 1이면 true, 0이면 false이고 그 이유는 추측하지 않으며, 2행 이상은 규칙이 깨진 것이므로 실패합니다.

이 어댑터가 받는 PostJpaRepository와 그 커스텀 프래그먼트인 PostQueryRepository, PostQueryRepositoryImpl, 아래 테스트가 가져오는 QuerydslJpaConfiguration은 다음 글 Spring Data JPA와 Querydsl에서 정의합니다. 이 글의 코드를 컴파일하고 테스트를 실행하려면 그 네 파일도 함께 만들어 둡니다.

src/main/java/board/jpa/JpaPostRepository.java
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와 트랜잭션을 써서, 정확히 두 번째 변경만 실패하는지 확인합니다.

src/test/java/board/jpa/JpaPostRepositoryTest.java
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가 실행되었다는 사실과, 바깥에서 커밋되었다는 주장을 나눕니다. 충돌 테스트도 한 영속성 컨텍스트 안에서 객체 두 개를 흉내 내지 않고, 서로 다른 컨텍스트가 같은 버전을 읽게 합니다.


낙관적 충돌은 어댑터 안의 진단 값입니다

공개 수정 포트는 여전히 행이 맞았는지에 대한 true나 false만 돌려줍니다. JPA 공급자의 충돌 타입은 기술별 테스트와 진단에만 머뭅니다.

src/main/java/board/jpa/OptimisticConflict.java
package board.jpa;

record OptimisticConflict(
        long postId,
        long expectedVersion
) {
    public OptimisticConflict {
        if (postId <= 0 || expectedVersion < 0) {
            throw new IllegalArgumentException("invalid conflict context");
        }
    }
}

사용자가 직접 편집한 본문을 충돌 뒤에 자동으로 다시 시도하면 다른 사람의 변경을 덮을 수 있습니다. 최신 PostSnapshot을 다시 보여 주고, 병합할지는 사용자에게 맡깁니다.


정리

  • 영속성 컨텍스트는 같은 ID에 같은 관리 객체를 돌려주고 변경을 추적하며, flush는 SQL 동기화일 뿐 커밋이 아닙니다.
  • IDENTITY 전략은 식별자를 얻으려고 persist 무렵에 INSERT를 실행할 수 있으므로, flush가 언제나 첫 SQL이라고 가정하지 않습니다.
  • 롤백하면 이미 플러시한 DB 변경도 취소되지만, Java 객체의 필드는 저절로 되돌아가지 않으므로 실패한 컨텍스트의 객체를 다시 쓰지 않습니다.
  • 엔티티는 어댑터 안에만 두고, 공개 포트는 불변 스냅샷과 1행 true, 0행 false라는 사실만 돌려줍니다.
  • 낙관적 충돌은 독립된 두 컨텍스트로 검증하며, 사용자가 편집한 내용은 자동으로 다시 시도하지 않습니다.

연습 문제

1. Java 객체와 DB 행의 결과를 따로 추적하기

관리 상태 테스트에서 엔티티의 본문을 바꾸고 flush한 뒤 clear()합니다. 다시 find한 객체는 이전 참조와 같은 객체인가요? 이어서 트랜잭션을 롤백하고 새 트랜잭션에서 그 ID를 읽으면 무엇이 보여야 하나요? 이전 Java 참조의 ID가 여전히 양수라는 것만으로 커밋되었다고 말할 수 있는지도 답하세요.

해설 보기

clear 전에는 같은 영속성 컨텍스트의 같은 ID가 같은 관리 객체를 가리킵니다. clear 뒤의 이전 참조는 분리된 객체이고, 다시 조회한 객체는 같은 값의 행을 읽더라도 다른 Java 객체입니다. 이 예제의 새 행을 삽입한 트랜잭션을 롤백한 뒤 새 트랜잭션에서 조회하면 행이 없어야 합니다.

롤백이 이전 Java 객체의 ID나 본문 필드를 예전 값으로 되돌리지는 않습니다. 양수 ID나 스냅샷의 값은 그 객체가 가진 값일 뿐, DB에 커밋되었다는 뜻이 아닙니다. 영속성 컨텍스트의 동일성과 flush로 보낸 SQL, 완료한 뒤 새 트랜잭션에서 읽은 행을 각각 기록해야 합니다. Jakarta Persistence 명세의 엔티티 상태와 동기화 부분을 함께 읽습니다.

2. 같은 컨텍스트에서 충돌을 흉내 내면

낙관적 충돌을 테스트하려고, 한 EntityManager에서 같은 ID를 두 번 find해 두 변수에 담고 각각 고친 뒤 flush했습니다. 두 번째 변경이 충돌로 실패할까요? 왜 이 방식으로는 충돌을 재현할 수 없는지 설명하세요.

해설 보기

같은 영속성 컨텍스트에서 같은 ID를 두 번 find하면 같은 관리 객체가 돌아오므로, 두 변수는 실제로 한 객체를 가리킵니다. 두 번의 변경이 한 객체에 차례로 적용되고 flush는 버전을 한 번만 올리므로 충돌이 나지 않습니다. 충돌을 재현하려면 독립된 두 EntityManager와 트랜잭션이 각자 같은 버전을 읽은 뒤, 하나가 먼저 커밋하고 다른 하나가 오래된 버전으로 쓰도록 만들어야 합니다.

다음 글 Spring Data JPA와 Querydsl에서는 Spring Data가 내부 저장소를 어디까지 조합하는지와, Querydsl의 생성 소스가 동적 검색을 어디까지 맡는지를 나눠 봅니다.