본문 바로가기

개발기술/ORM

Persistence Context, EntityManager

Persistence Context

 

개념

  • JPA가 데이터를 DB로부터 불러오거나 저장할 때 사용하는 1차 캐시. 이 안에 들어간 엔티티들은 영속 상태(Persistent State) 라고 불리고, JPA가 자동으로 변경 사항을 추적합니다.
    • 트랜잭션이 시작될때 영속성 컨텍스트를 생성하였다가 트랜잭션이 끝나면 추적한 변경사항을 일괄 DB에 반영합니다.
  • JPA 동작 구조 (Repository → EntityManager → Persistence Context)
    • JPA는 DB가 아니라 Persistence Context를 다루는 기술이다.
    • EntityManager는 이 Persistence Context를 조작하는 표준 API 계층이며, Repository는 EntityManager를 추상화해서 개발자가 쉽게 사용할 수 있도록 감싼 상위 레벨 추상화 계층이다.

 

Persistence Context의 핵심 기능

기능 설명
1차 캐시 (First-Level Cache) 같은 엔티티를 여러 번 조회해도 DB 쿼리를 다시 날리지 않음
엔티티 동일성 보장 같은 영속성 컨텍스트 안에서는 동일한 ID의 엔티티는 항상 같은 객체 (== 비교 true)
변경 감지 (Dirty Checking) 트랜잭션 커밋 시, 엔티티의 변경 사항을 감지해 자동으로 update 쿼리를 생성
쓰기 지연 (Write-Behind) persist/save한 insert 쿼리를 모아뒀다가 트랜잭션 종료 시 한꺼번에 실행
지연 로딩 (Lazy Loading) 프록시 객체로 관계 엔티티를 미리 가져오지 않고, 실제 사용할 때 조회

 

영속성 컨텍스트 등록하여 데이터 캐쉬

  • 엔티티 신규 생성 후 저장 (persist())이나 엔티티 호출 (find())이 실행되면 엔티티는 영속성 컨텍스트에 캐시된다
  • JPA는 DB에 엔티티를 찾아보기전에 영속성 컨텍스트에 엔티티가 존재하는지 확인한다. 존재한다면 DB 쿼리를 실행하지않는다

스냅샷(snapshot) 관리를 통한 DirtyChecking

  •  엔티티가 영속성 컨텍스트에 들어와 영속 상태가 되는 순간 엔티티의 초기 상태의 값을 복사해 저장해둠
  • flush() 시점에 스냅샷 값과 엔티티 값을 비교하여 변경사항을 일괄 update 실행함
class PersistenceContext {
    Map<EntityKey, Object> entitiesByKey;       // 1차 캐시: ID로 엔티티 객체 추적
    Map<Object, EntityEntry> entityEntries;     // 스냅샷 메타 정보
}

 

액션플래그 생성을 통한 쓰기지연

  • entityManager.persist(), entityManager.remove() , entityManager.merge()같은 메서드를 호출했을 때, JPA는 즉시 SQL을 실행하지 않고, 내부에 "INSERT/UPDATE/DELETE 수행 예정"이라는 플래그(액션)를 따로 저장함
  • 쓰기 지연 SQL 목록
List<InsertAction> insertQueue
List<UpdateAction> updateQueue
List<DeleteAction> deleteQueue
  • flush는 변경 감지된 내용과 쓰기 지연된 SQL을 모아서 DB에 실제 일괄 반영하는 것 
    • 1. 스냅샷 비교하여 변경감지 (Dirty Checking) → 변경사항이 발견되면 SQL 업데이트 액션플래그를 생성 
    • 2. INSERT 쓰기 큐 내 액션플래그 처리 → INSERT INTO user (name) VALUES ('변상화')
    • 3. Update 큐 내 액션플래그 처리  UPDATE user SET name = '변상화'
    • 4. DELETE 큐  내 액션플래그 처리   Delete

 

어플리케이션 레벨에서 예외발생시 rollback 처리하여 자원 절약 

  • DB에서 예외처리할 시에 발생하는 Undo log 쓰기, 락 획득, MVCC 처리 등 부하 발생을 없앰

지연쓰기로 인해 여러번 분할하여 실행할 쿼리를 한번에 전송함

  • JDBC 호출, DB parsing, 네트워크 왕복 비용이 줄어들음

 

Persistence Context의 자료구조

  • 자료구조
    • Persistence Context는 Map<EntityKey(EntityType, PrimaryKeyValue), EntityInstance> 형태의 구조
Map<EntityKey, EntityEntry>
  • 여기서 EntityKey는 Entity의 Class Type과 @Id의 조합 값으로 구성됩니다
class EntityKey {
    private final Class<?> entityClass; // 엔티티 타입 (ex: User.class)
    private final Object primaryKey;    // @Id 값 (ex: 1L)
}
  • EntityEntry는 현재 Managed Entity 객체와, 최초의 스냅샷을 둘 다 가지고 있는 객체. 
class EntityEntry {
    Object managedEntity;  // 현재 메모리에서 관리 중인 객체
    Map<String, Object> originalValues; // 최초 영속화 시 복사된 필드값 스냅샷
}

 

Persistence Context 안의 동작

repository.save(entity) 호출 

  • save()는 id가 null이면 persist, id가 있으면 merge를 결정한다.
    • id 값을 개발자가 수동으로 세팅했든 말든, save()는 "id 유무"만 보고 persist/merge를 선택한다.
    • 때문에 혼동을 방지하기 위해서 일반적으로 id는 JPA가 @GeneratedValue로 자동 생성하게 두고, 개발자가 직접 id를 builder로 세팅하는 일은 최소화하는 걸 권장. id를 수동으로 건드리면, persist/merge 판단이나 flush, cascade 동작에서 복잡한 예외 상황이 생길 수 있기 때문이야.
save(entity)
    ↓
if (entity.getId() == null)
    → persist(entity)
else
    → merge(entity)
  • entityManager.persist(entity) :  ID가 부여되지 않은 새로운 엔티티를 영속성 컨텍스트에 등록
    • id == null (ID가 아직 없는 경우), @GeneratedValue(strategy = IDENTITY) 같은 경우 Insert 예정 Entity'로 따로 저장
    • id != null, @GeneratedValue(strategy = SEQUENCE) 같은 경우 nextval 해서 ID를 뽑은 경우 바로 EntityKey 만들어서 Persistence Context에 put
// 1. persist() 호출
public <T> void persist(T entity) {
    // 1-1. ID 값을 가져온다
    Object id = getId(entity);

if (id == null) {
        // 1-2. ID가 아직 없는 경우 (IDENTITY 전략처럼)
        //     - 아직 EntityKey를 만들 수 없다
        //     - 임시로 Persistence Context에 "insert 예정 상태"로 등록

        persistenceContext.addInsertPendingEntity(entity);
// (※ 별도 관리 컬렉션에 "ID 미확정 Entity"로 추가)
    } else {
        // 1-3. ID가 이미 있는 경우 (SEQUENCE처럼 nextval로 미리 뽑은 경우)
        EntityKey key = new EntityKey(entity.getClass(), id);
persistenceContext.put(key, entity);
}
}
  • entityManager.merge(entity) :
    • Detached 상태(Persistence Context에 없는 엔티티)를 Managed 상태(Persistence Context가 관리하는 상태)로 변환하는 과정이야
public <T> T merge(T detachedEntity) {
    // 1. Detached entity의 id를 가져온다
    Object id = getId(detachedEntity);

// 2. 1차 캐시(Persistence Context)에서 id로 찾는다
    T managedEntity = persistenceContext.get(detachedEntity.getClass(), id);

    if (managedEntity == null) {
        // 3. 1차 캐시에 없으면 DB select 쿼리로 찾는다
        managedEntity = dbSelect(detachedEntity.getClass(), id);

        if (managedEntity != null) {
            // 4. DB에 row가 있으면 Persistence Context에 등록
            persistenceContext.put(new EntityKey(detachedEntity.getClass(), id), managedEntity);
        }
    }

    if (managedEntity == null) {
        // 5. DB에도 없으면 detachedEntity를 복제(clone)해서 새 Managed Entity를 만든다
        managedEntity = cloneEntity(detachedEntity); // 복제본 생성

        // 복제본에는 id를 그대로 복사하거나 Hibernate가 임시 ID 부여 가능
        persistenceContext.put(new EntityKey(detachedEntity.getClass(), id), managedEntity);
    }

    // 6. Detached Entity의 변경된 값들을 복제본(managedEntity)에 복사
    copyProperties(detachedEntity, managedEntity);

// 7. flush 타이밍에 update 또는 insert SQL 발생
    return managedEntity;
}
  • repository.findById(id) 호출
    • entityManager.find(entityClass, id) : 1차 캐시(Persistence Context)에서 먼저 조회 하고없으면 DB select 쿼리 날린 후 조회 후 1차 캐시에 저장함 
Object entity = persistenceContext.get(entityClass, id);
if (entity == null) {
    entity = executeSelectSQL(entityClass, id);  // DB select 쿼리
    persistenceContext.put(entityClass, id, entity);  // 1차 캐시에 등록
}
return entity;

 

 

EntityManager 공통 메서드 정리

메서드 설명
persist(entity) 새로운 엔티티를 메모리에 등록하고 "Insert 대상"으로 관리하기 시작하는 것 (→ 나중에 insert SQL 발생)
find(entityClass, id) ID로 엔티티를 조회 (1차 캐시(Persistence Context) 먼저 조회)
merge(entity) detached 상태(영속성 컨텍스트에서 분리된) 엔티티를 다시 연결해서 관리 대상으로 만듦
remove(entity) 엔티티를 삭제 대상으로 표시 (flush/commit 시 실제 삭제)
detach(entity) 특정 엔티티를 영속성 컨텍스트에서 분리 (더 이상 상태 추적 안 함)
flush() 영속성 컨텍스트의 변경사항을 DB에 강제로 반영 (batch 작업 전에 강제 동기화할 때 유용)
clear() 영속성 컨텍스트에 등록된 모든 엔티티를 제거 (1차 캐시 비우기)

 

Persistence Context(영속성 컨텍스트)는 트랜잭션에 묶여있다. 그리고 아래 경우에 종료된다:

  • 트랜잭션이 커밋될 때 @Transactional이 붙은 서비스 메서드가 끝나서 트랜잭션이 커밋되면, 영속성 컨텍스트도 flush(변경사항 동기화) 후 종료된다.
  • entityManager.clear() 또는 entityManager.close()를 호출할 때
  • Spring Web 애플리케이션에서 요청이 끝날 때 

 

 

 

JPA 구조로 인한 Best Practice

  • JPA에서 양방향 연관관계를 선언했으면, 객체 양쪽의 상태를 일치시켜줘야한다
public void addUserAuth(UserAuth userAuth) {
    this.userAuthList.add(userAuth);
    userAuth.setUser(this);
}
@Test
void test_권한부여_로직() {
    User user = new User();
    UserAuth userAuth = new UserAuth(user, auth);
    // user.getUserAuthList().add(userAuth); 누락!
    // setUser만 호출했다고 가정

    // 대상 서비스 로직
    myService.assignUserAuth(user);

    assertEquals(1, user.getUserAuthList().size()); // 실패!
}

 

 

 

Common Mistakes & How to Avoid Them

MistakeSolution
Loading too many entities into memory Use pagination (Pageable in Spring Data JPA).
Updating detached entities without merging Use merge() before updating.
Using find() repeatedly for the same entity Store the entity in a variable; JPA caches it.
Fetching unnecessary data Use @Query or JOIN FETCH to optimize queries.

 

JPA를 잘못사용하는 케이스

 

 

 

  • 쓰기 지연(Write-Behind) 상태일 때, flush 전에 객체 값을 바꾸면 바뀐 값이 저장이 됨
    • JPA는 flush()가 실행될때까지 쓰기지연상태이며 dirtychecking 후 쓰기가 생성되므로 변경된 값이 저장됨
User user = userRepository.save(new User("홍길동", 30)); // INSERT SQL 안 나감 — 쓰기 지연 큐에 등록

user.setAge(40); // save 이후 값 변경 (변경 감지 대상)

em.flush(); // 이 시점에 INSERT 쿼리 날림 → age = 40이 반영됨
  • record를 변경하고자 PK값을 바꾸면 영속성 컨텍스트 구조상 새로운 값으로 삽입됨
    • 영속성 컨텍스트에 엔티티는 PK와 Object 맵으로 저장하고 있기때문에 PK를 변경하면 새로운 key-value가 저장되어서 insert로 처리될 수 있음 
@Transactional
public void updateUserInfo(UserDto.UserInfoUpdateReqDto userReqDto) {

    User user = userRepository.findWithUserAuthsById()
    user.updateUserInfo();

    // UserAuth 업데이트
    UserAuth userAuth = user.getUserAuthList();
    userAuth.changeKey(user.getId(), userReqDto.getAuthId());

    // user, userauth 업데이트
    User updatedUser = userRepository.save(user);
    UserAuth updatedUserAuth = userAuthRepository.save(userAuth);

    return;
}

 

User user = userRepository.findById(1L).get();
user.setId(999L); // ❌ id를 직접 수정!

userRepository.save(user); // save하면 merge 호출

 

  • Hibernate는 id=999L로 "다른" 엔티티라고 착각
  • DB에 새 insert 시도
  • PK 충돌 또는 중복 데이터 생성
User user = new User();
user.setId(1L); // 수동으로 id 세팅 (❌)
user.setName("홍길동");

userRepository.save(user);
  • Hibernate는 id=1L이 있으니까 "update 흐름"을 탄다.
  • 근데 실제 DB에 id=1L row가 없으면 update 0건 (silent fail)
  • 기대는 insert였는데, 아무것도 저장되지 않는다.