(JPA) 1차 캐시, Dirty Checking, Flush 타이밍

1000E·2026년 3월 20일
  1. 영속성 컨텍스트(Persistence Context) = ‘엔티티를 관리하는 전체 작업 공간

    이것은 어떤 특정한 메모리 자료구조 하나만을 지칭하는 것이 아니라, JPA가 엔티티 객체들을 관리하기 위해 조성해 놓은 논리적인 환경이자 시스템 전체를 의미한다. (실제 코드 상으로는 보통 하나의 EntityManager 당 하나의 영속성 컨텍스트가 생성된다.

  2. 영속성 컨텍스트 ‘내부’의 핵심 구성 요서 3가지

    • 1차 캐시 (1st Level Cache): 방금 DB에서 조회했거나 새로 생성한 엔티티 객체들의 현재 상태를 저장해두는 실제 메모리 공간(Map<EntityUID, Entity> 형태)입니다. 캐시 히트를 통해 DB 접근을 줄여준다.
    • 스냅샷 (Snapshot): 엔티티가 1차 캐시에 처음 들어오는 순간의 최초 상태를 복사해서 따로 보관해 두는 영역이다. 트랜잭션이 끝날 때, JPA는 '1차 캐시의 현재 상태'와 '스냅샷의 최초 상태'를 비교하여 변경 사항을 감지(Dirty Checking)한다.
    • 쓰기 지연 SQL 저장소 (Write-behind SQL Storage): Dirty Checking을 통해 변경이 감지되었거나 새로 저장해야 할 객체가 있을 때, 당장 DB에 쿼리를 날리지 않고 SQL문들을 모아두는 큐(Queue)이다. 나중에 Flush가 발생할 때 한꺼번에 DB로 쏟아낸다.

📌1차 캐시

JPA에서 트랜잭션이 시작되면 내부에 ‘영속성 컨텍스트’라는 가상의 공간이 생긴다.
이 안에는 1차 캐시라는 Map 형태의 저장소(Key: DB PK, Value: Entity 객체)가 존재한다.

@Transactional
public void viewPost() {
		// 1. 처음 조회: 1차 캐시에 없으므로 DB로 SELECT 쿼리를 날린다. 
		//             후에 1차 캐시에 저장 하고 반환
		Post post1 = postRepository.findById(1L);
		
		// 2. 두 번째 조회 : 이미 1차 캐시에 있으므로 DB를 가지 않고 캐시에서 바로 가져온다!
		Post post2 = postRepository.findById(1L);
}
  • 핵심 원리: 위 코드에서 post1post2== 비교하면 true가 나온다. JPA는 같은 트랜잭션 안에서 동일한 식별자(PK)를 가진 엔티티에 대해 객체의 동일성을 보장해 주기 때문이다.
  • 오해하기 쉬운 점 : 1차 캐시는 애플리케이션 전체가 공유하는 글로벌 캐시(Redis 같은 2차 캐시)가 아니다. 딱 하나의 트랜잭션(하나의 사용자 요청)
    안에서만 잠깐 살아있다가 사라지는 찰나의 공간
    이다. 따라서 극적인 성능 향상보다는 ‘객체 지향적인 매커니즘 보장’에 더 큰 의의가 있다.

📌2. 변경 감지(Dirty Checking): 왜 update() 메서드가 없을까?

게시글의 제목이나 내용을 수정할 때, JPA 코드를 보면 save()update() 를 명시적으로 호출하지 않는 경우가 많다. 그냥 객체의 값만 바꿨는데 알아서 UPDATE 쿼리가 날아간다.

@Transactional
public void updatePostTitle(Long postId, String newTitle) {
		Post post = postRepository.findById(postId); // 1차 캐시에 저장 + '스냅샷' 생성
		
		post.setTitle(newTitle); // 객체의 값만 변경
		
		// postRepository.save(post); <- 이런 코드가 필요 없다.
} // 트랜잭션 종료 시점
  • 스냅샷(snapshot): 엔티티가 1차 캐시에 처음 들어올 때, JPA는 그 최초 상태를 복사해서 ‘스냅샷’으로 보관한다.
  • 비교와 쿼리 생성 : 트랜잭션이 끝나는 시점(Commit)에 JPA는 1차 캐시에 있는 현재 엔티티의 상태와 보관해둔 스냅샷을 싹 다 비교한다. 만약 제목이 변경 되었다면(상태가 다르면)? 그때 알아서 UPDATE SQL을 생성해 DB에 날린다.
    이것이 Dirty(변경됨)를 Checking(감지)하는 원리이다.
📌

Q : 프로젝트에는 save()가 있는데 이건 뭐지?

A : 결론 → updatePost는 DirtyChecking이 맞고, createPost의 save()는 반드시 필요하다.


  • save() = 새로 만든 객체를 JPA에 등록할 때, 새로 생성된 것은 비영속이기 때문에 JPA가 모른다. 그래서 JPA가 관리하기 위해 save()가 필요한 것이다.
  • Dirty Checking은 이미 JPA가 관리하는 엔티티(영속 상태)에만 동작한다.
    new 로 만든 객체는 JPA가 모르는 비영속 상태라서 save()로 등록해야 한다.
  • findById() 같은 것들은 이미 JPA가 관리를 하기 때문에 save()가 필요하지 않다.

📌3. Flush 타이밍 : 모아둔 쿼리를 DB로 쏘는 순간

우리는 객체를 수정하거나 생성했다고 해서 그 즉시 DB에 쿼리가 날아가는 것은 아니다. JPA는 쿼리를 ‘쓰기 지연 SQL 저장소’라는 곳에 차곡차곡 모아둔다. 이 모아둔 쿼리들을 실제 DB에 동기화하는 작업이 Flush(플러시) 이다.

Flush가 발생하는 타이밍은 보통 다음 세 가지이다.

  1. 트랜잭션 커밋(Commit) 시: (가장 일반적) 코드가 정상적으로 다 돌고 트랜잭션이 끝날 때 자동으로 발생한다.
  2. JPQL 쿼리 실행 시 : 만약 게시글 5개를 새로 생성(1차 캐시에만 있음)해 둔 상태에서, 갑자기 SELECT * FROM post 같은 JPQL을 날리면 어떻게 될까?
    • DB에는 아직 새로 만든 5개의 데이터가 없기 때문에 조회가 안 될 것이다.
    • 이런 데이터 불일치를 막기 위해, JPA는 JPQL을 실행하기 직전에 무조건 자동으로 Flush를 호출하여 DB와 상태를 동기화한다.
  3. 수동 호출 : em.flush() 를 직접 호출할 때. (테스트 코드 작성할 때 외에는 실무에서 직접 쓸 일은 거의 없다)
  • 주의할 점 : Flush는 1차 캐시를 비우는 것이 아니다! 단순히 변경 내용(생성/수정/삭제)을 DB에 반영(SQL 전송)할 뿐, 1차 캐시는 트랜잭션이 끝날 때까지 그대로 유지된다.

Q : 만약 게시글의 제목을 수정했는데(post.setTitle()), 메서드가 끝나기 전에 예상치 못한 에러가 발생해서 트랜잭션이 롤백(Rollback)된다면, DB의 데이터와 1차 캐시의 상태는 각각 어떻게 될까?

A : 트랜잭션이 커밋되기 전에 롤백되었으므로 DB에는 아무런 변화가 없다.
하지만 1차 캐시에 대해서는 초기 상태로 되돌아가는 것이 아니라, 그냥 파괴(clear)되어 버린다.

  • 영속성 컨텍스트 초기화 : 대신 JPA 데이터 정합성이 깨졌다고 판단하고, 트랜잭션을 롤백함과 동시에 영속성 컨텍스트 자체를 싹 비워버리거나 종료해버린다.
  • 준영속 상태(Detached) : 그 결과, 아까 수정했던 post 객체는 더 이상 JPA의 관리를 받지 못하는 준영속 상태가 된다.

Q : 1차 캐시에 대해서 설명할 때, post1post2는 서로 다른 인스턴스 같은데 == 비교하면 true가 나온다고 했는데 Java에서는 서로 다른 인스턴스는 메모리 주소 할당이 서로 다르기 때믄에 == 비교하면 false가 나와서 equals()를 쓰는 걸로 알고있는데 이건 왜 이런거지?

A : 말한대로 자바에서 new 키워드로 각각 생성한 서로 다른 인스턴스는 메모리 주소가 다르기 때문에 == 로 비교하면 false가 나오는 것이 맞다.
그런데 JPA의 1차 캐시에서 post1 == post2가 true 가 나오는 이유는, post1과 post2 가 서로 다른 인스턴스가 아니기 때문이다. 완벽히 동일한 하나의 메모리 주소를 가리키고 있다.

작동 원리를 순서대로 보면 이렇다.

  1. 첫 번째 조회 (post1): findById(1L)을 호출하면 JPA는 1차 캐시(내부적으로 Map<Object, Object> 형태)를 뒤져본다. 비어있으니 DB에 SELECT 쿼리를 날려 데이터를 가져온다.
  2. 인스턴스 생성 및 캐시 저장: 가져온 데이터로 new Post()를 해서 자바 객체를 하나 만든 다음, 1차 캐시에 Key: 1L, Value: 방금 만든 Post 인스턴스의 메모리 주소 형태로 저장한다. 그리고 post1 변수에 그 주소를 준다.
  3. 두 번째 조회 (post2): 다시 findById(1L)을 호출한다. JPA가 1차 캐시를 확인해 보니 1L이라는 Key가 이미 있다.
  4. 캐시 히트(Cache Hit): DB에 가지 않고, 1차 캐시에 저장되어 있던 아까 그 Post 인스턴스의 메모리 주소를 그대로 post2에게 던져줍니다.

결과적으로 post1post2는 힙(Heap) 메모리에 떠 있는 단 하나의 동일한 객체를 쳐다보고 있는 쌍둥이 참조 변수일 뿐이다. 그래서 == 비교 시 메모리 주소가 같으므로 true가 반환됩니다. JPA는 이를 통해 애플리케이션 레벨에서 '동일성(Identity) 보장'이라는 엄청난 장점을 제공한다.


정리

영속성 컨텍스트 안에는 1차 캐시와 스냅샷이 존재.

  1. 비영속(New)
    • 상태 : 방금 new 키워드로 생성된 순수한 자바 객체
    • 위치 : 영속성 컨텍스트 바깥에 위치. 당연히 1차 캐시에도 없음
    • JPA 추적 : X (JPA는 이 객체의 존재 자체를 모름. 값을 아무리 바꿔도 DB에 영향X)
    • 코드 : Post pst = new Post(”새 글”);
  2. 영속(Managed)
    • 상태: 객체가 영속성 컨텍스트 안으로 들어와 정식 관리를 받는 상태
    • 위치 : 영속성 컨텍스트 내부의 1차 캐시에 이름(PK)과 현재 상태가 등록되고, 동시에 스냅샷에 들어올 때의 최초 모습이 복사되어 안전하게 보관
    • JPA 추적 : O (트랜잭션이 끝날 떄, JPA가 1차 캐시와 스냅샷을 비교해서 다르면 UPDATE 쿼리를 날린다. 즉, 변경 감지가 동작하는 유일한 상태)
    • 코드 : em.persist(post); (저장) 또는 postRepository.findById(1L); (조회해서 영속성 컨텍스트로 끌고 옴)
  3. 준영속(Detached)
    • 상태 : 한때는 영속 상태였지만, 지금은 관리가 끊긴 상태
    • 위치 : 객체 자체는 자바 메모리에 살아있지만, 1차 캐시와 스냅샷에서는 기록이 완전히 삭제 되었다.(DB에 데이터가 남아있든 말든 상관없다)
    • JPA 추적 : X (영속성 컨텍스트에서 지워졌으므로, 이제 객체의 값을 바꿔도 JPA 관리를 받지 않기 때문에 변경 감지가 작동되지 않는다)
    • 발생 시점 : 트랜잭션이 끝나서 영속성 컨텍스트 자체가 파괴될 때
  4. 삭제(Removed)
    • 상태: 관리소 안에는 있지만, '삭제 예정' 딱지가 붙은 상태입니다.
    • 위치: 여전히 영속성 컨텍스트 안의 1차 캐시에 있지만, 트랜잭션이 끝날 때 DB에 DELETE 쿼리를 날리기로 예약되어 있습니다.
    • 코드: em.remove(post); 또는 postRepository.delete(post);
profile
Java/Spring BackEnd

0개의 댓글