← Posts

JPA · ORM · Spring

Persistence Context

save()를 안 불렀는데 UPDATE가 나간다. JPA가 엔티티를 관리하는 공간, 영속성 컨텍스트로 그 수수께끼를 푼다.

목차
  1. save()를 안 불렀는데 값이 바뀐다
  2. 영속성 컨텍스트는 엔티티를 관리하는 공간
  3. 같은 걸 두 번 찾아도 SELECT는 한 번이다
  4. 바꾸면 공간이 안다
  5. SQL은 언제 나가나
  6. 엔티티의 네 가지 상태
  7. 실무: 트랜잭션 밖에선 아무 일도 안 일어난다
  8. 정리

save()를 안 불렀는데 값이 바뀐다

ORM을 쓰다 보면 처음에 이상해 보이는 코드를 만난다. 사용자의 이메일을 바꾸는 메서드다.

java
@Transactional
public void changeEmail(Long id, String newEmail) {
    User user = userRepository.findById(id).orElseThrow();
    user.setEmail(newEmail);
    // save()도 update()도 부르지 않았다
}

이 메서드가 끝나면 DB의 이메일이 실제로 바뀐다. UPDATE가 나갔다는 뜻이다. 그런데 저장하라는 말을 한 적이 없다. setEmail으로 객체의 필드만 바꿨을 뿐이다.

누가 UPDATE를 시켰나. ORM에서 “SQL은 ORM이 만든다”고 했는데, 그 SQL을 언제, 왜 만들지를 정하는 무언가가 뒤에 있다. 그게 영속성 컨텍스트다.

영속성 컨텍스트는 엔티티를 관리하는 공간

영속성 컨텍스트는 JPA가 엔티티를 담아 관리하는 공간이다. 앱과 DB 사이에 있다.

diagramdiagram

findById로 꺼낸 user는 그냥 자바 객체가 아니라, 이 공간이 관리하는(managed) 엔티티가 된다. 이 상태를 영속 상태라고 부른다. 공간 안에 들어와 있으니, 그 엔티티에 무슨 일이 일어나는지 공간이 지켜본다.

앞 코드가 이상해 보였던 건 이 공간을 몰랐기 때문이다. user는 혼자 떠 있는 객체가 아니라 관리받는 중이었다.

같은 걸 두 번 찾아도 SELECT는 한 번이다

관리 공간이 있으면 생기는 첫 번째 효과가 이거다.

java
User a = userRepository.findById(1L).orElseThrow();  // SELECT 나간다
User b = userRepository.findById(1L).orElseThrow();  // SELECT 안 나간다
System.out.println(a == b);                          // true

두 번째 조회는 DB에 안 간다. 첫 조회에서 가져온 엔티티를 공간이 이미 들고 있어서, 같은 id를 다시 찾으면 그걸 그대로 준다. 이걸 1차 캐시라고 한다.

그래서 ab는 값만 같은 게 아니라 같은 객체다(a == b가 참). 한 공간 안에서 같은 행은 항상 같은 객체 하나로 다뤄진다.

바꾸면 공간이 안다

이제 처음 수수께끼의 답이다. 영속 상태 엔티티는 값을 바꾸면 공간이 알아챈다.

엔티티가 공간에 들어올 때, JPA는 그 순간의 값을 스냅샷으로 찍어둔다. 그리고 트랜잭션이 끝날 때 현재 값과 스냅샷을 비교한다. 다르면 바뀐 만큼 UPDATE를 만든다.

diagramdiagram

이것을 **변경 감지(dirty checking)**라고 한다. 그래서 save()를 부를 필요가 없었다. 값을 바꾸는 것만으로 공간이 차이를 발견하고 UPDATE를 만든다.

참고

그래서 JPA에서 수정은 대개 “조회 → 필드 변경”으로 끝난다. save()를 부르는 습관이 있으면 틀린 건 아니지만, 영속 상태에서는 없어도 반영된다. 정작 save()가 꼭 필요한 건 새 엔티티를 처음 저장할 때다.

SQL은 언제 나가나

여기서 한 가지를 분명히 해두면 헷갈림이 줄어든다. 변경이 생긴 즉시 SQL이 나가는 게 아니다.

공간은 바뀐 것들을 모아뒀다가 flush라는 시점에 한꺼번에 SQL로 내보낸다. flush는 보통 트랜잭션 커밋 때 일어난다(쿼리 실행 직전이나 직접 부를 때도 있다).

java
@Transactional
public void changeEmail(Long id, String newEmail) {
    User user = userRepository.findById(id).orElseThrow();
    user.setEmail(newEmail);     // 여기선 아직 UPDATE 안 나간다
}                                // 메서드가 끝나며 커밋 → 이때 flush → UPDATE

그래서 메서드 중간을 아무리 봐도 UPDATE가 안 보이다가, 끝(커밋)에 몰아서 나갈 수 있다. “로그에 SQL이 늦게 찍힌다”의 정체가 이것이다.

엔티티의 네 가지 상태

지금까지 “영속 상태”를 얘기했는데, 엔티티는 사실 네 가지 상태를 오간다.

상태
비영속 (transient)방금 new로 만든 것. 공간이 아직 모른다
영속 (managed)공간이 관리 중. findById로 꺼냈거나 저장한 것 - 변경 감지가 도는 상태
준영속 (detached)공간을 떠난 것. 한때 영속이었지만 이제 안 지켜본다
삭제 (removed)지우기로 표시된 것. 커밋 때 DELETE

핵심은 하나다. 변경 감지가 도는 건 오직 영속 상태뿐이다. new로 갓 만든 것(비영속)도, 트랜잭션을 떠난 것(준영속)도, 값을 바꿔봐야 아무 일도 안 난다. 그리고 다음 절의 함정이 정확히 이 준영속에서 온다.

실무: 트랜잭션 밖에선 아무 일도 안 일어난다

지금까지의 편리함에는 조건이 하나 붙어 있었다. 엔티티가 영속 상태, 즉 공간 안에 있을 때만이다.

영속성 컨텍스트는 대개 트랜잭션 범위로 산다. Spring에서는 @Transactional이 그 경계다. 트랜잭션이 끝나면 그 안에 있던 엔티티는 공간을 떠나 준영속(detached) 상태가 된다. 그리고 준영속 엔티티는 아무도 지켜보지 않는다.

java
// @Transactional 밖에서 받은 엔티티 (준영속)
User user = getUserFromSomewhere();
user.setEmail("new@example.com");   // 아무 일도 일어나지 않는다

setEmail을 불러도 UPDATE가 안 나간다. 지켜보는 공간이 없기 때문이다. 변경 감지는 공짜 마법이 아니라 영속 상태일 때만 도는 기능이다.

이게 실무에서 “왜 값이 안 바뀌지”의 흔한 원인이다. 수정 로직은 트랜잭션 안(영속 상태)에서 조회하고 바꿔야 한다. 밖에서 바꾼 걸 반영하려면 다시 영속 상태로 들여보내는 별도의 일이 필요하다.

정리

영속성 컨텍스트란JPA가 엔티티를 담아 관리하는 공간. 앱과 DB 사이
영속 상태그 공간이 관리하는 엔티티. findById로 꺼낸 것이 여기 든다
1차 캐시같은 id를 다시 찾으면 DB 안 가고 같은 객체를 준다
변경 감지값을 바꾸면 스냅샷과 비교해 UPDATE를 만든다. save() 불필요
flush변경은 즉시가 아니라 flush(대개 커밋)에 SQL로 나간다
범위대개 트랜잭션(@Transactional). 밖(준영속)에선 변경 감지가 없다

ORM이 객체와 테이블을 이어준다면, 영속성 컨텍스트는 그 사이에서 엔티티를 지켜보다가 SQL을 만들 때를 정하는 관리인이다. JPA가 편한 이유도, 가끔 이상해 보이는 이유도 전부 이 관리인 때문이다.

관련 글