SpringHard#76
JPA의 영속성 컨텍스트(Persistence Context)란 무엇이며 1차 캐시, 변경 감지의 동작 원리를 설명해주세요.
#Spring#JPA#영속성컨텍스트#ORM
힌트
EntityManager가 관리하는 엔티티 저장소로, 스냅샷을 통해 변경을 감지합니다.
정답 및 해설
JPA의 영속성 컨텍스트(Persistence Context)란 무엇이며 1차 캐시, 변경 감지의 동작 원리를 설명해주세요.
영속성 컨텍스트(Persistence Context)는 JPA에서 엔티티 객체를 관리하는 논리적인 저장 공간입니다. EntityManager가 이 공간을 통해 엔티티의 생명 주기를 관리하며, 데이터베이스와의 동기화를 담당합니다. 영속성 컨텍스트는 트랜잭션 범위와 함께 생성되고 소멸되며, 1차 캐시, 변경 감지, 지연 쓰기 등 강력한 기능을 제공합니다.
영속성 컨텍스트 개요
EntityManager와의 관계
// EntityManager가 영속성 컨텍스트를 통해 엔티티를 관리
@Service
@Transactional
public class UserService {
@PersistenceContext
private EntityManager em; // 영속성 컨텍스트에 접근하는 인터페이스
public void example() {
// 트랜잭션 시작 → 영속성 컨텍스트 생성
User user = em.find(User.class, 1L); // 영속 상태
user.setName("변경된 이름");
// 트랜잭션 커밋 → 자동으로 DB에 UPDATE → 영속성 컨텍스트 소멸
}
}
엔티티의 4가지 상태
JPA에서 엔티티는 영속성 컨텍스트와의 관계에 따라 4가지 상태를 가집니다.
new User() em.persist() 트랜잭션 커밋
┌──────────┐ ────────────────────> ┌──────────────┐
│ 비영속 │ │ 영속 │
│ (New) │ │ (Managed) │
└──────────┘ └──────┬───────┘
│
em.detach() │ em.remove()
│
┌───────────┴──────────┐
│ │
┌────▼─────┐ ┌──────▼──────┐
│ 준영속 │ │ 삭제 │
│(Detached)│ │ (Removed) │
└──────────┘ └─────────────┘
1. 비영속 (New/Transient)
// new 키워드로 생성, 영속성 컨텍스트와 무관
User user = new User();
user.setName("홍길동");
user.setEmail("hong@example.com");
// DB와 전혀 연관 없음, ID도 없음
2. 영속 (Managed)
@Transactional
public void example() {
// em.persist()로 영속 상태로 전환
User newUser = new User("홍길동", "hong@example.com");
em.persist(newUser); // 영속 상태 → INSERT SQL은 커밋 시 실행
// em.find()로 조회된 엔티티도 영속 상태
User foundUser = em.find(User.class, 1L); // 영속 상태
}
3. 준영속 (Detached)
@Transactional
public void example() {
User user = em.find(User.class, 1L);
em.detach(user); // 특정 엔티티만 준영속으로 전환
// em.clear(); // 영속성 컨텍스트 전체 초기화 (모든 엔티티 준영속)
// em.close(); // EntityManager 종료
user.setName("변경"); // 변경 감지 안됨 → DB에 반영 안됨
}
4. 삭제 (Removed)
@Transactional
public void example() {
User user = em.find(User.class, 1L);
em.remove(user); // 삭제 예약 → 트랜잭션 커밋 시 DELETE SQL 실행
}
1차 캐시 (First-Level Cache)
동작 원리
영속성 컨텍스트 내부에는 Map 형태의 1차 캐시가 존재합니다. 키는 엔티티의 식별자(PK), 값은 엔티티 인스턴스입니다.
// 1차 캐시 구조 (개념적)
Map<EntityKey, Object> firstLevelCache = new HashMap<>();
// EntityKey = {EntityClass, primaryKey}
캐시 히트 과정
@Transactional
public void cacheExample() {
// 첫 번째 조회: DB에서 SELECT
User user1 = em.find(User.class, 1L);
// → SELECT * FROM users WHERE id = 1 (SQL 실행)
// → 결과를 1차 캐시에 저장
// 두 번째 조회: 1차 캐시에서 반환 (SQL 미실행)
User user2 = em.find(User.class, 1L);
// → SQL 실행 없음! 캐시에서 바로 반환
System.out.println(user1 == user2); // true (동일한 인스턴스)
}
em.find(User.class, 1L) 호출
│
▼
1차 캐시에 존재?
┌────┴────┐
│YES │NO
▼ ▼
캐시에서 DB에서 SELECT
반환 ↓
1차 캐시에 저장
↓
반환
1차 캐시의 범위
중요한 점은 1차 캐시는 트랜잭션 범위에서만 유효합니다. 요청이 끝나면 영속성 컨텍스트와 함께 소멸합니다. Redis나 Ehcache 같은 2차 캐시(공유 캐시)와는 다릅니다.
// 트랜잭션 A
@Transactional
public void transactionA() {
User user = em.find(User.class, 1L); // DB 조회 후 캐시 저장
// 트랜잭션 종료 → 1차 캐시 소멸
}
// 트랜잭션 B (새로운 영속성 컨텍스트)
@Transactional
public void transactionB() {
User user = em.find(User.class, 1L); // 새로운 영속성 컨텍스트, 다시 DB 조회
}
변경 감지 (Dirty Checking)
동작 원리
JPA는 영속 상태의 엔티티를 관리할 때, 처음 조회(또는 저장)한 시점의 **스냅샷(Snapshot)**을 함께 저장합니다. 트랜잭션 커밋 시 현재 상태와 스냅샷을 비교하여 변경된 필드가 있으면 자동으로 UPDATE SQL을 생성합니다.
@Transactional
public void updateExample() {
User user = em.find(User.class, 1L);
// 스냅샷 저장: { id: 1, name: "홍길동", email: "hong@old.com" }
user.setEmail("hong@new.com"); // 엔티티 수동 수정
// 별도의 update() 메서드 호출 불필요
// 트랜잭션 커밋 시 자동으로:
// 1. 스냅샷과 현재 상태 비교
// 2. email 필드 변경 감지
// 3. UPDATE users SET email = 'hong@new.com' WHERE id = 1 실행
}
트랜잭션 커밋 호출
│
▼
flush() 실행
│
▼
영속 상태의 모든 엔티티 순회
│
▼
스냅샷과 현재 상태 비교
│
변경 있음?
┌────┴────┐
│YES │NO
▼ ▼
UPDATE SQL 건너뜀
생성 및 실행
변경 감지가 동작하지 않는 경우
@Transactional
public void detachedExample() {
User user = em.find(User.class, 1L);
em.detach(user); // 준영속 상태로 전환
user.setEmail("new@email.com");
// 변경 감지 안됨! 준영속 상태는 관리 대상 아님
}
// 준영속 엔티티를 수정하려면 merge() 사용
@Transactional
public void mergeExample(User detachedUser) {
User managedUser = em.merge(detachedUser); // 준영속 → 영속 상태로 병합
// detachedUser는 여전히 준영속, managedUser가 영속 상태
}
쓰기 지연 (Write-Behind / Transactional Write-Behind)
JPA는 persist() 호출 시 즉시 DB에 INSERT하지 않고, SQL 저장소에 모아두었다가 트랜잭션 커밋 직전에 일괄 실행합니다.
@Transactional
public void batchInsertExample() {
User user1 = new User("사용자1", "user1@example.com");
User user2 = new User("사용자2", "user2@example.com");
User user3 = new User("사용자3", "user3@example.com");
em.persist(user1); // SQL 저장소에 INSERT 추가 (DB 미실행)
em.persist(user2); // SQL 저장소에 INSERT 추가 (DB 미실행)
em.persist(user3); // SQL 저장소에 INSERT 추가 (DB 미실행)
// 트랜잭션 커밋 시 3개 INSERT를 한 번에 실행
// → JDBC batch 처리로 성능 최적화 가능
}
flush() 수동 호출
커밋 전에 강제로 DB에 반영하고 싶을 때 사용합니다. (영속성 컨텍스트는 그대로 유지)
@Transactional
public void flushExample() {
User user = new User("홍길동", "hong@example.com");
em.persist(user);
em.flush(); // 지금 당장 INSERT 실행 (트랜잭션은 유지)
// 이후 같은 트랜잭션에서 user.getId() 사용 가능
Long generatedId = user.getId();
}
영속성 컨텍스트의 이점 요약
@Service
@Transactional
public class ExampleService {
public void demonstrateAllFeatures(Long userId) {
// 1. 1차 캐시: 동일 트랜잭션 내 재조회 시 DB 접근 없음
User user1 = em.find(User.class, userId); // DB 조회
User user2 = em.find(User.class, userId); // 캐시 반환
assert user1 == user2;
// 2. 동일성 보장: == 비교 가능
assert user1 == user2; // true
// 3. 변경 감지: update() 호출 불필요
user1.setName("새 이름");
// 커밋 시 자동 UPDATE
// 4. 쓰기 지연: persist()가 즉시 INSERT하지 않음
User newUser = new User("신규", "new@email.com");
em.persist(newUser); // SQL 저장소 대기
// 5. 지연 로딩: 연관 엔티티를 실제 접근 시 로딩
List<Order> orders = user1.getOrders(); // 프록시 반환
orders.size(); // 여기서 SELECT orders WHERE user_id = ? 실행
}
}
요약 표
| 기능 | 설명 | 이점 |
|---|---|---|
| 1차 캐시 | 트랜잭션 내 동일 엔티티 재조회 시 캐시 반환 | DB 접근 최소화 |
| 동일성 보장 | 같은 PK의 엔티티는 항상 동일 인스턴스 | == 비교 가능 |
| 변경 감지 | 스냅샷 비교로 변경된 필드만 UPDATE | 명시적 update() 불필요 |
| 쓰기 지연 | SQL을 모아두었다가 커밋 시 일괄 실행 | JDBC 배치 최적화 |
| 지연 로딩 | 연관 엔티티를 실제 접근 시 로딩 | 불필요한 조인 방지 |
| 엔티티 상태 | 영속성 컨텍스트 관리 | 변경 감지 | DB 반영 |
|---|---|---|---|
| 비영속 (New) | X | X | X |
| 영속 (Managed) | O | O | 커밋 시 자동 |
| 준영속 (Detached) | X | X | X |
| 삭제 (Removed) | O (삭제 예약) | X | 커밋 시 DELETE |