[JPA] 영속성 컨텍스트 동작 원리: 1차 캐시, 더티 체킹, 쓰기 지연, flush 시점
![[JPA] 영속성 컨텍스트 동작 원리: 1차 캐시, 더티 체킹, 쓰기 지연, flush 시점](https://blog.kakaocdn.net/dna/b1SODD/dJMcad3h3qR/AAAAAAAAAAAAAAAAAAAAAPn4L-37y_y1y9eQAkq3D7CdA0fipZocj3II-WooCsdF/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1785509999&allow_ip=&allow_referer=&signature=4HrtaGXHzZcruwjLAYSL5aH5AqY%3D)

[JPA] 영속성 컨텍스트 동작 원리: 1차 캐시, 더티 체킹, 쓰기 지연, flush 시점
JPA를 쓰다 보면 이상한 일을 겪어요. update를 안 불렀는데 UPDATE 쿼리가 나가요. 분명 수정했는데 DB에 반영이 안 돼요. save를 했는데 INSERT가 한참 뒤에 나가요. 이게 전부 영속성 컨텍스트 한 군데에서 벌어지는 일이에요. JPA의 동작 대부분이 이 위에서 일어나서 이걸 모르면 JPA는 계속 "가끔 이상한 도구"로 남아요.
영속성 컨텍스트가 뭔지부터 짚고 1차 캐시와 동일성 보장, 쓰기 지연, 더티 체킹, flush 시점, 엔티티 생명주기, 자주 만나는 문제 순으로 따라가요. JPA 시리즈의 기둥 글이에요.
01. 영속성 컨텍스트가 뭔가요
영속성 컨텍스트는 애플리케이션과 DB 사이에 있는 중간 저장소예요. 엔티티를 조회하거나 저장하면 바로 DB로 가는 게 아니라, 일단 이 컨텍스트가 엔티티를 관리해요.

안에는 두 가지가 있어요. 1차 캐시는 조회한 엔티티를 (스냅샷과 함께) 보관하고, 쓰기 지연 SQL 저장소는 보낼 SQL을 쌓아둬요. 스프링에서는 보통 트랜잭션 하나당 영속성 컨텍스트 하나가 만들어지고 트랜잭션이 끝나면 같이 사라져요.
이 중간 저장소 덕분에 JPA의 핵심 기능들이 가능해져요. 하나씩 볼게요.
02. 1차 캐시, 같은 트랜잭션 안에선 같은 객체
엔티티를 조회하면 먼저 1차 캐시를 봐요. 있으면 DB에 안 가고 캐시의 객체를 그대로 반환해요.
User a = em.find(User.class, 1L); // DB 조회 → 1차 캐시 저장
User b = em.find(User.class, 1L); // 1차 캐시에서 반환 (쿼리 안 나감)
a == b; // true — 같은 객체 (동일성 보장)
두 번째 조회는 쿼리가 아예 안 나가요. 그리고 둘은 equals가 아니라 ==로 같아요. 같은 트랜잭션 안에서는 같은 ID의 엔티티가 항상 같은 객체라는 보장이에요.
여기서 흔한 오해 — 1차 캐시를 성능 최적화 캐시로 기대하면 안 돼요. 1차 캐시는 트랜잭션 하나의 스코프예요. 트랜잭션이 끝나면 사라지고 다른 요청과 공유되지 않아요. 여러 요청에 걸친 캐싱 효과를 원하면 그건 Redis 같은 외부 캐시의 일이에요. 1차 캐시의 진짜 역할은 성능보다 동일성 보장과 더티 체킹의 기반이에요.
03. 쓰기 지연, save 했는데 INSERT가 안 보이는 이유
persist(save)를 불러도 INSERT가 바로 안 나가요. SQL은 쓰기 지연 저장소에 쌓였다가, flush 시점에 한꺼번에 DB로 가요.
em.persist(userA); // INSERT 안 나감 — SQL 저장소에 쌓임
em.persist(userB); // 역시 안 나감
// ... 트랜잭션 커밋 직전(flush)에 INSERT 2건이 한 번에 전송
이게 쓰기 지연이에요. 모아서 보내니 네트워크 왕복이 줄고, JDBC 배치와 결합하면 대량 insert 성능에도 유리해요(이건 성능 편에서 다뤄요). "로그에 INSERT가 안 보여요"는 대부분 아직 flush 전이라 그래요.
04. 더티 체킹, update를 안 불러도 UPDATE가 나가요
JPA에는 update 메서드가 없어요. 그냥 영속 상태 엔티티의 값을 바꾸면 돼요.

@Transactional
public void changeName(Long id, String name) {
User user = userRepository.findById(id).orElseThrow();
user.setName(name);
// save() 호출 없음 — 커밋 시점에 UPDATE가 자동으로 나간다
}
원리는 스냅샷 비교예요. 영속성 컨텍스트는 엔티티를 1차 캐시에 넣을 때 최초 상태의 복사본(스냅샷)도 같이 보관해요. flush 시점에 스냅샷과 현재 엔티티를 비교해서 달라진 엔티티의 UPDATE SQL을 만들어 보내요. 이게 더티 체킹(변경 감지)이에요.
더티 체킹의 함정 두 방향. 첫째, "수정했는데 반영이 안 돼요" — 트랜잭션 밖에서 수정했거나, 영속 상태가 아닌(준영속·비영속) 객체를 수정한 거예요. 더티 체킹은 트랜잭션 안 + 영속 상태에서만 동작해요. 둘째, 반대로 "의도 안 한 UPDATE가 나가요" — 조회한 엔티티를 트랜잭션 안에서 무심코 setter로 건드리면 그게 그대로 DB에 반영돼요. 영속 엔티티의 수정은 곧 DB 수정이라는 걸 의식해야 해요.
05. flush, SQL이 DB로 가는 세 시점
쌓아둔 SQL이 DB로 전송되는 게 flush예요. 세 시점에 일어나요.

- 트랜잭션 커밋 직전 — 가장 기본이에요. 쌓아둔 SQL을 보내고 커밋해요.
- JPQL 실행 직전 — 자동 flush예요. 방금 persist한 엔티티가 JPQL 결과에서 빠지면 이상하니까, 쿼리 전에 먼저 반영해요.
flush()직접 호출 — SQL을 지금 보내야 할 때 수동으로 불러요.
JPQL 자동 flush는 모르면 헷갈리는 동작이에요. "조회만 했는데 왜 UPDATE가 나가지?"의 답이 보통 이거예요. 그 전에 수정한 영속 엔티티가 있어서 JPQL 직전에 더티 체킹+flush가 먼저 돈 거예요. Hibernate는 최적화로, 쿼리가 건드리는 테이블에 미반영 변경이 있을 때만 flush해요. 무관한 엔티티 조회면 flush가 안 일어날 수 있어요.
그리고 flush는 커밋이 아니에요. SQL을 보냈을 뿐, 트랜잭션이 롤백되면 전부 없던 일이 돼요. "flush 했으니 저장 확정"이라고 생각하면 안 돼요.
06. 엔티티 생명주기, 모든 마법은 "영속"에서만
지금까지의 기능들(1차 캐시·더티 체킹·쓰기 지연)은 전부 엔티티가 영속 상태일 때만 동작해요.

- 비영속(new) —
new User()로 막 만든 객체. JPA는 이 객체를 몰라요. - 영속(managed) —
persist나 조회로 영속성 컨텍스트가 관리하는 상태. 모든 기능이 동작해요. - 준영속(detached) — 관리에서 벗어난 상태.
detach·clear를 했거나, 트랜잭션이 끝난 뒤예요. 더티 체킹이 안 되고 지연 로딩을 시도하면 예외(LazyInitializationException)가 나요.merge를 부르면 다시 영속으로 돌아와요. - 삭제(removed) —
remove를 부른 상태예요. 그 자리에서 지워지는 게 아니라 flush 때 DELETE가 나가요.
실무에서 만나는 JPA 미스터리의 대부분은 "이 객체가 지금 어느 상태인가"를 따지면 풀려요. 특히 트랜잭션 범위 밖으로 나온 엔티티는 준영속이라는 걸 기억하면, 수정이 반영 안 되는 문제와 지연 로딩 예외가 한 줄로 설명돼요. 지연 로딩 이야기는 다음 편에서 깊게 다뤄요.
07. 자주 만나는 문제
수정했는데 DB에 반영이 안 돼요
트랜잭션 밖이거나 준영속 객체를 수정한 거예요. @Transactional 안에서 조회한(영속) 엔티티를 수정하는지 확인해요.
조회만 했는데 UPDATE 쿼리가 나가요
더티 체킹이에요. 트랜잭션 안에서 영속 엔티티의 값을 어딘가에서 바꾼 거예요. 화면용 가공을 엔티티에 직접 하면 이게 터져요. 가공은 DTO로 분리해요.
save를 했는데 INSERT가 로그에 안 보여요
쓰기 지연이에요. flush 시점(커밋 직전)에 나가요. 즉시 확인이 필요하면 flush()를 부르거나 saveAndFlush를 써요.
같은 ID를 두 번 조회했는데 쿼리가 한 번만 나가요
1차 캐시 정상 동작이에요. 같은 트랜잭션 안 재조회는 캐시에서 반환돼요.
정리
영속성 컨텍스트는 1차 캐시(엔티티+스냅샷)와 쓰기 지연 저장소를 가진 중간 관리자예요. 같은 트랜잭션 안에서 동일성을 보장해요. SQL을 모아 flush 시점에 보내요. 스냅샷을 비교(더티 체킹)해 UPDATE를 자동으로 만들어요. 단, 이 모든 동작은 엔티티가 영속 상태일 때만 일어나요. 그래서 JPA가 이상하게 굴 때 가장 먼저 던질 질문은 "이 객체가 지금 영속인가"예요.
여기서 출발해 지연 로딩과 프록시, N+1 문제, 연관관계 매핑으로 차례차례 내려가요. 여러 요청에 걸친 캐싱이 필요해지는 지점은 @Cacheable 캐싱 글로 이어지고요.
출처: Spring Data JPA Reference · Hibernate User Guide — Persistence Context
'백엔드 > 데이터 & DB' 카테고리의 다른 글
| [JPA] 지연 로딩과 프록시: FetchType.LAZY 동작 원리, LazyInitializationException 해결 (0) | 2026.07.07 |
|---|
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."