목차
save()를 안 불렀는데 값이 바뀐다
ORM을 쓰다 보면 처음에 이상해 보이는 코드를 만난다. 사용자의 이메일을 바꾸는 메서드다.
@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 사이에 있다.
내 코드가 든 것은 사본이 아니라 이 공간 안의 엔티티다. DB와의 왕래도 공간을 거친다 - 그래서 값을 바꾸면 공간이 안다.
findById로 꺼낸 user는 그냥 자바 객체가 아니라, 이 공간이 관리하는(managed) 엔티티가 된다. 이 상태를 영속 상태라고 부른다. 공간 안에 들어와 있으니, 그 엔티티에 무슨 일이 일어나는지 공간이 지켜본다.
앞 코드가 이상해 보였던 건 이 공간을 몰랐기 때문이다. user는 혼자 떠 있는 객체가 아니라 관리받는 중이었다.
같은 걸 두 번 찾아도 SELECT는 한 번이다
관리 공간이 있으면 생기는 첫 번째 효과가 이거다.
User a = userRepository.findById(1L).orElseThrow(); // SELECT 나간다
User b = userRepository.findById(1L).orElseThrow(); // SELECT 안 나간다
System.out.println(a == b); // true두 번째 조회는 DB에 안 간다. 첫 조회에서 가져온 엔티티를 공간이 이미 들고 있어서, 같은 id를 다시 찾으면 그걸 그대로 준다. 이걸 1차 캐시라고 한다.
a == b 둘 다 이 카드
갈 일이 없다
두 번째 조회는 카드 앞에서 되돌아온다. 가는 거리가 다를 뿐 아니라, 두 변수가 받아 가는 것도 같은 카드 하나다.
그래서 a와 b는 값만 같은 게 아니라 같은 객체다(a == b가 참). 한 공간 안에서 같은 행은 항상 같은 객체 하나로 다뤄진다.
바꾸면 공간이 안다
이제 처음 수수께끼의 답이다. 영속 상태 엔티티는 값을 바꾸면 공간이 알아챈다.
엔티티가 공간에 들어올 때, JPA는 그 순간의 값을 스냅샷으로 찍어둔다. 그리고 트랜잭션이 끝날 때 현재 값과 스냅샷을 비교한다. 다르면 바뀐 만큼 UPDATE를 만든다.
이것을 **변경 감지(dirty checking)**라고 한다. 그래서 save()를 부를 필요가 없었다. 값을 바꾸는 것만으로 공간이 차이를 발견하고 UPDATE를 만든다.
그래서 JPA에서 수정은 대개 “조회 → 필드 변경”으로 끝난다. save()를 부르는 습관이 있으면 틀린 건 아니지만, 영속 상태에서는 없어도 반영된다. 정작 save()가 꼭 필요한 건 새 엔티티를 처음 저장할 때다.
SQL은 언제 나가나
여기서 한 가지를 분명히 해두면 헷갈림이 줄어든다. 변경이 생긴 즉시 SQL이 나가는 게 아니다.
공간은 바뀐 것들을 모아뒀다가 flush라는 시점에 한꺼번에 SQL로 내보낸다. flush는 보통 트랜잭션 커밋 때 일어난다(쿼리 실행 직전이나 직접 부를 때도 있다).
@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) 상태가 된다. 그리고 준영속 엔티티는 아무도 지켜보지 않는다.
// @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가 편한 이유도, 가끔 이상해 보이는 이유도 전부 이 관리인 때문이다.