JPA 영속성 컨텍스트
회원 게시판 JPA 엔티티의 동일성·변경 감지·플러시·@Version·지연 연관을 구현하고 @Repository 변환과 낙관적 충돌을 실제 트랜잭션 테스트로 검증합니다.
JPA는 SQL을 없애는 기술이 아니라 객체 상태 변화와 SQL 실행 사이에 영속성 컨텍스트라는 작업 단위를 둡니다. 엔티티를 영속화하고 필드를 바꾸면 트랜잭션 커밋 또는 플러시 시점에 SQL이 생성됩니다. 편리함의 대가는 SQL 발생 시점, 지연 로딩, 동일성, 매핑과 예외 변환을 이해해야 한다는 점입니다.
엔티티·스키마 제약 일치
Spring Boot 4.1은 Jakarta 영속성 패키지를 사용합니다.
javax.persistence와 섞지 않습니다.
엔티티는 JPA용 protected 기본 생성자를 두되 애플리케이션이 불완전한 상태로 만들 수 없게 팩토리를 제공합니다.
package board.jpa;
import java.time.Instant;
import java.time.LocalDate;
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_author_day_title",
columnNames = {"author_id", "created_on", "title"}))
public class PostEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "author_id", nullable = false)
private long authorId;
@Column(nullable = false, length = 80)
private String title;
@Column(nullable = false, length = 5_000)
private String content;
@Column(name = "created_on", nullable = false)
private LocalDate createdOn;
@Version
@Column(nullable = false)
private long version;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
protected PostEntity() {
}
private PostEntity(
long authorId,
String title,
String content,
LocalDate createdOn,
Instant createdAt
) {
validate(title, content, createdOn, createdAt);
this.authorId = authorId;
this.title = title.strip();
this.content = content;
this.createdOn = createdOn;
this.createdAt = createdAt;
}
public static PostEntity create(
long authorId,
String title,
String content,
LocalDate createdOn,
Instant createdAt
) {
if (authorId <= 0) {
throw new IllegalArgumentException("positive member id required");
}
return new PostEntity(
authorId, title, content, createdOn, createdAt);
}
public void change(String title, String content) {
validate(title, content, createdOn, createdAt);
this.title = title.strip();
this.content = content;
}
private static void validate(
String title,
String content,
LocalDate createdOn,
Instant createdAt
) {
if (title == null || title.isBlank()
|| title.strip().length() > 80) {
throw new IllegalArgumentException("invalid title");
}
if (content == null || content.isBlank()
|| content.length() > 5_000
|| createdOn == null || createdAt == null) {
throw new IllegalArgumentException("invalid post values");
}
}
public Long getId() { return id; }
public long getAuthorId() { return authorId; }
public String getTitle() { return title; }
public String getContent() { return content; }
public LocalDate getCreatedOn() { return createdOn; }
public long getVersion() { return version; }
public Instant getCreatedAt() { return createdAt; }
}애노테이션의 null 허용·길이·고유 설정은 스키마 마이그레이션을 대체하지 않습니다.
ddl-auto=validate로 매핑과 스키마 차이를 발견하고 마이그레이션을 DDL의 단일 진실 공급원으로 삼을 수 있습니다.
확인 제약 조건과 인덱스, 운영 환경에 특화된 타입은 마이그레이션에 명시합니다.
더티 체킹과 플러시
트랜잭션 안에서 find한 엔티티는 관리 상태입니다.
세터 호출 뒤 merge나 명시적 갱신 쿼리가 없어도 스냅샷과 비교해 UPDATE가 나갑니다.
트랜잭션 밖의 분리 상태 엔티티를 바꾸면 자동 반영되지 않습니다.
package board.jpa;
import java.util.Optional;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
@Repository
public class JpaPostRepository {
@PersistenceContext
private EntityManager entityManager;
public PostEntity saveNew(PostEntity entity) {
if (entity.getId() != null) {
throw new IllegalArgumentException("new entity already has id");
}
entityManager.persist(entity);
return entity;
}
public Optional<PostEntity> findById(long id) {
return Optional.ofNullable(
entityManager.find(PostEntity.class, id));
}
public void change(
long id,
long ownerId,
String title,
String content
) {
PostEntity entity = entityManager.find(
PostEntity.class, id);
if (entity == null || entity.getAuthorId() != ownerId) {
throw new PostNotFoundException(id);
}
entity.change(title, content);
}
public void flush() {
entityManager.flush();
}
}GenerationType.IDENTITY는 ID를 얻기 위해 삽입이 영속화 시점 근처에 즉시 실행될 수 있습니다.
시퀀스 전략은 ID 할당과 배치 삽입 특성이 다릅니다.
공급자와 DB 조합을 실제 SQL 로그와 메트릭으로 확인합니다.
플러시는 커밋이 아닙니다. SQL을 DB에 보내 제약 조건과 낙관적 잠금 실패를 일찍 발견하지만 트랜잭션 롤백은 여전히 가능합니다. API에서 중복을 정확히 매핑하려면 서비스 경계에서 플러시해 예외를 어드바이스까지 전달할 수 있지만 불필요한 플러시는 배치와 성능을 해칩니다.
@Version 낙관적 잠금
JPA 공급자는 버전 열을 갱신 조건에 넣고 성공 시 증가시킵니다.
갱신된 행이 0개면 OptimisticLockException 계열이 발생합니다.
Spring 트랜잭션과 @Repository 변환은 이를 OptimisticLockingFailureException 계열로 바꿀 수 있습니다.
클라이언트가 보낸 버전을 엔티티 필드에 세터로 덮지 않습니다.
현재 관리되는 엔티티 버전과 If-Match 값을 비교하거나 명시적 갱신 명령을 사용합니다.
버전은 권한 증명이 아니며 책임 주체 확인을 함께 수행합니다.
벌크 JPQL 갱신은 영속성 컨텍스트 변경 감지와 엔티티 콜백을 우회하고 관리되는 엔티티 스냅샷을 낡게 만들 수 있습니다.
clearAutomatically 또는 명시적 정리를 검토하고 애그리거트 규칙이 필요한 변경에는 사용하지 않습니다.
영속성 예외 변환
@Repository 빈에는 영속성 예외 변환 후처리기가 적용되어 공급자 예외를 Spring DataAccessException으로 바꿀 수 있습니다.
Spring Boot가 필요한 빈을 자동 구성하지만 사용자 정의 컨텍스트에서는 PersistenceExceptionTranslationPostProcessor를 등록해야 합니다.
고유 제약 조건 위반이 메서드 내부의 영속화에서 즉시 나지 않고 트랜잭션 커밋에서 날 수 있습니다.
리포지토리 안의 try·예외 처리만으로 잡히지 않을 수 있으므로 예외 매핑 위치를 서비스·어드바이스 경계와 플러시 전략에 맞춥니다.
제약 조건 이름이 필요한 세밀한 분류는 근본 원인을 안전하게 추출하고 공급자 통합 테스트로 확인합니다.
| 발생 | 보이는 시점 | 대표 Spring 의미 |
|---|---|---|
| 고유 제약 조건 | 플러시·커밋 | DataIntegrityViolationException |
| 오래된 버전 | 플러시·커밋 | OptimisticLockingFailureException |
| 엔티티 없음 | 애플리케이션 조회 | 도메인 찾을 수 없음 |
| 지연 접근 종료 후 | 속성 접근 | 지연 초기화 실패 |
지연 연관을 웹 직렬화에 노출하지 않습니다. 트랜잭션 안 쿼리 투영 또는 가져오기 조인으로 필요한 값을 읽어 DTO로 반환합니다. 모든 연관을 즉시로 바꾸면 N+1과 거대한 조인이 생깁니다.
트랜잭션 JPA 테스트
@DataJpaTest는 Spring Boot 4의 분리된 JPA 테스트 스타터가 제공합니다.
처음부터 같은 예제를 실행하려면 테스트 의존성을 추가합니다.
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'package board.jpa;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.time.LocalDate;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
@DataJpaTest
class JpaPostRepositoryTest {
@Autowired
EntityManager entityManager;
@Test
void managed_entity_변경은_flush에서_UPDATE와_version_증가를_만든다() {
var entity = PostEntity.create(
41L,
"JPA",
"JPA로 저장한 게시글 본문",
LocalDate.parse("2026-07-14"),
Instant.parse("2026-07-14T09:00:00Z"));
entityManager.persist(entity);
entityManager.flush();
long initialVersion = entity.getVersion();
entity.change("JPA 수정", "변경 감지로 수정한 게시글 본문");
entityManager.flush();
entityManager.clear();
PostEntity reloaded = entityManager.find(
PostEntity.class, entity.getId());
assertThat(reloaded.getContent())
.isEqualTo("변경 감지로 수정한 게시글 본문");
assertThat(reloaded.getVersion())
.isEqualTo(initialVersion + 1);
}
}persist generated id = positive
first flush inserted row = true
managed change explicit update call = none
second flush updated content = 변경 감지로 수정한 게시글 본문
version increment = 1
clear then reload = database stateBoot 4.1의 테스트 슬라이스 패키지와 애노테이션은 사용하는 의존성 버전의 컴파일 결과로 확인합니다. 테스트가 롤백되므로 다른 메서드와 격리되지만 커밋 콜백과 지연된 제약 조건은 별도 커밋 테스트가 필요합니다.
연습 문제
두 EntityManager 트랜잭션이 같은 게시글 버전을 읽고 서로 다른 본문으로 바꾸게 하세요. 첫 커밋 뒤 두 번째 플러시가 낙관적 충돌을 내고 최종 값이 첫 변경인지 확인합니다. 재시도할지 409를 반환할지 명령 의미로 결정합니다.
해설 보기
각 트랜잭션은 별도 영속성 컨텍스트와 연결을 사용합니다. 래치로 둘 다 최초 값을 읽은 뒤 첫 트랜잭션을 커밋하고 두 번째를 플러시합니다. 대기으로 순서를 맞추지 않습니다.
package board.jpa;
public record OptimisticConflict(
long postId,
long expectedVersion
) {
public OptimisticConflict {
if (postId <= 0 || expectedVersion < 0) {
throw new IllegalArgumentException("invalid conflict context");
}
}
}사용자가 직접 입력한 변경은 자동 재시도하면 다른 변경을 덮을 수 있으므로 최신 상태를 보여 주고 병합을 요청합니다. 재계산 가능한 멱등 애그리거트는 제한된 재시도가 적합할 수 있습니다.
다음 문서에서는 Spring 데이터 JPA가 리포지토리 구현을 생성하는 범위와 Querydsl이 복잡한 동적 조건식·투영을 타입-안전하게 조립하는 위치를 구분합니다.