영속성 컨텍스트 — 두 번 꺼낸 같은 책은 같은 객체다
Article
17강은 PUT 을 이렇게 닫았다.
found.changeTo(book.getTitle(), book.getPrice());
return repository.save(found);
그리고 저 save 는 없어도 되는 자리가 있다고 예고했다. 지운다.
@PutMapping("/{id}")
public ResponseEntity<Book> update(@PathVariable Long id, @RequestBody Book book) {
return repository.findById(id)
.map(found -> {
found.changeTo(book.getTitle(), book.getPrice());
return found; // save() 를 지웠다
})
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
3번 책은 HTTP · 15000 이다. 고쳐본다.
curl -X PUT http://localhost:8080/books/3 \
-H 'Content-Type: application/json' \
-d '{"title":"저장안함","price":111}'
- {"title":"저장안함","price":111,"id":3}
- $ curl http://localhost:8080/books/3
- {"title":"HTTP","price":15000,"id":3}
PUT 은 바뀐 값을 돌려줬다. 바로 뒤에 GET 을 하면 옛날 값이 나온다.
응답은 바뀌었고 데이터베이스는 안 바뀌었다. 상태코드는 200 이다. 아무도 에러라고 말해주지 않는다.
예고가 틀린 것은 아니다. 조건이 빠졌다. 그 조건을 찾는 것이 이 강의다.
SQL 을 눈으로 본다
지금까지 우리는 SQL 을 한 줄도 안 적었다. 14강에서 H2 콘솔로 직접 쳐본 것이 마지막이다. 그 뒤로는 JPA 가 대신 적어줬고, 우리는 그것을 본 적이 없다.
한 줄이면 보인다.
spring.jpa.show-sql=true
다시 띄우고, 방금 한 것을 그대로 한다. PUT 한 번, GET 한 번이다.
- # PUT /books/3
- Hibernate: select b1_0.id,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
- # GET /books/3
- Hibernate: select b1_0.id,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
SELECT 가 둘. UPDATE 가 없다.
changeTo 는 분명히 불렸다. 응답에 바뀐 값이 실려 나갔으니까. 객체는 바뀌었는데 아무도 그것을 데이터베이스에 알리지 않았다.
그러면 found 는 대체 무엇인가. 그것부터 봐야 한다.
두 번 꺼내면 몇 개인가
확인용 메서드를 하나 만든다. 이 강의가 끝나면 지운다.
@GetMapping("/{id}/same")
public String same(@PathVariable Long id) {
Book a = repository.findById(id).orElseThrow();
Book b = repository.findById(id).orElseThrow();
return "a == b ? " + (a == b) + " / a 의 주소 = " + System.identityHashCode(a);
}
orElseThrow 를 썼으니 없는 번호로 부르면 17강에서 본 그 500 이 난다. 있는 책만 물어볼 것이므로 그냥 둔다.
같은 번호를 두 번 꺼냈다. a 와 b 는 서로 다른 객체여야 자연스럽다. 데이터베이스를 두 번 뒤졌을 테니까.
- $ curl http://localhost:8080/books/1/same
- a == b ? true / a 의 주소 = 1080108589
true 다.
이 시리즈는 여섯 강에 걸쳐 == 로 비교하지 말라고 가르쳤다. 자바 편 14강까지 거슬러 올라간다. 그 강의는 Object 의 equals 를 열어 보여줬다.
public boolean equals(Object obj) {
return (this == obj);
}
그리고 이렇게 적었다.
그냥
==다. 주소를 비교한다.
자바 편 4강은 더 앞에서 그 뜻을 정했다.
==는 상자 안에 든 것끼리 비교한다. 참조형 상자에 든 것은 주소였다.
그러니 a == b 가 true 라는 말은 하나뿐이다. 주소가 같다. 두 변수가 같은 곳을 가리키고 있다. 객체가 하나라는 뜻이다.
서버 로그가 그것을 확인해준다.
- Hibernate: select b1_0.id,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
SELECT 가 한 번뿐이다. 두 번째 findById 는 데이터베이스에 가지도 않았다.
책상 하나가 놓여 있다
JPA 는 우리와 데이터베이스 사이에 책상을 하나 놓는다. 이름이 영속성 컨텍스트다.
findById 는 곧바로 데이터베이스로 가지 않는다. 먼저 책상 위를 본다. 1번 책이 이미 올라와 있으면 그것을 준다. 없으면 그때 SELECT 를 날리고, 받아온 것을 책상에 올려두고 준다.
- 첫 번째책상이 비었다 → SELECT → 책상에 올린다 → 준다
- 책상1번 = Book@1080108589
- 두 번째책상에 1번이 있다 → 그것을 준다. SELECT 없음
책상은 요청 하나가 사는 동안만 있다. 요청이 끝나면 치운다. 그러니 요청을 나눠서 두 번 물어보면 책상도 둘이다.
- $ curl http://localhost:8080/books/1/same
- a == b ? true / a 의 주소 = 1080108589
- $ curl http://localhost:8080/books/1/same
- a == b ? true / a 의 주소 = 855156387
한 요청 안에서는 언제나 true 다. 그런데 주소가 바뀌었다. 앞 요청의 그 객체가 아니다.
9강에서 컨트롤러를 두 번 불러보고 System.identityHashCode(this) 를 찍었던 것을 기억하는지. 그때는 659672450 이 두 번 나왔다. 컨트롤러는 빈이라 하나뿐이다.
컨트롤러
9강
- 주소가 같다
- 빈이므로 하나뿐이다 (5강의 싱글턴)
- 요청이 바뀌어도 살아 있다
Book
18강
- 주소가 다르다
- 책상이 요청마다 새로 놓인다
- 요청이 끝나면 책상째 치워진다
그런데 save() 는 왜 됐나
책상 이야기로는 아직 부족하다. 17강 코드에는 save(found) 가 있었고 그때는 UPDATE 가 나갔다. 우리가 한 것은 그 한 줄을 지운 것뿐이다.
save 가 특별한 무언가를 하고 있다. 16강에서 우리는 그 구현이 SimpleJpaRepository 라는 것까지 봤다. 그 클래스를 리플렉션으로 열어본다.
Class<?> impl = SimpleJpaRepository.class;
var save = impl.getMethod("save", Object.class);
var findById = impl.getMethod("findById", Object.class);
System.out.println("### 클래스에 붙은 것 = readOnly " + impl.getAnnotation(Transactional.class).readOnly());
System.out.println("### save() 에 붙은 것 = readOnly " + save.getAnnotation(Transactional.class).readOnly());
System.out.println("### findById() 에 붙은 것 = " + findById.getAnnotation(Transactional.class));
- ### 클래스에 붙은 것 = readOnly true
- ### save() 에 붙은 것 = readOnly false
- ### findById() 에 붙은 것 = null
save() 에 @Transactional 이 붙어 있다. 그것도 readOnly 가 false 인, 쓰는 트랜잭션이다. findById() 에는 아무것도 없어서 클래스에 붙은 readOnly true 를 따른다.
15강에서 손으로 이렇게 적었던 것을 기억하는지.
em.getTransaction().begin();
em.persist(book);
em.getTransaction().commit();
열고, 넣고, 닫는다. 16강에서 이 세 줄이 사라진 것은 일이 사라져서가 아니라, save() 가 그것을 자기 안에서 하고 있었기 때문이다.
그리고 여기에 답이 있다. save() 는 트랜잭션을 열지만, 그 트랜잭션의 폭이 save() 딱 한 줄이다.
17강의 PUT 메서드
- findById(id)
- 읽기 트랜잭션
- → 열고 곧바로 닫는다
- changeTo(...)
- 울타리 밖
- → 객체만 바뀐다. 아무도 안 본다
- save(found)
- 쓰기 트랜잭션
- → 열고, 커밋하고, 닫는다. 여기서 UPDATE 가 나갔다
울타리를 넓힌다
save() 를 되살리는 대신, 울타리를 메서드 전체로 넓힌다. 표식 하나면 된다.
@PutMapping("/{id}")
@Transactional
public ResponseEntity<Book> update(@PathVariable Long id, @RequestBody Book book) {
return repository.findById(id)
.map(found -> {
found.changeTo(book.getTitle(), book.getPrice());
return found;
})
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@Transactional 은 이 메서드가 끝날 때 커밋하라는 뜻이다. 지금 필요한 것은 딱 그것뿐이다.
2번 책은 스프링 · 30000 이다. 가격만 내려본다.
curl -X PUT http://localhost:8080/books/2 \
-H 'Content-Type: application/json' \
-d '{"title":"스프링","price":27000}'
- {"title":"스프링","price":27000,"id":2}
- $ curl http://localhost:8080/books/2
- {"title":"스프링","price":27000,"id":2}
save() 가 없는데 저장됐다. 17강의 예고가 지켜졌다.
언제 UPDATE 가 나가는가
changeTo 를 부른 자리에 한 줄 심어두고 로그를 본다.
found.changeTo(book.getTitle(), book.getPrice());
System.out.println("### changeTo 를 불렀다. 메서드는 아직 안 끝났다");
- Hibernate: select b1_0.id,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
- ### changeTo 를 불렀다. 메서드는 아직 안 끝났다
- Hibernate: update book set price=?,title=? where id=?
UPDATE 가 changeTo 뒤에 나갔다. 우리가 부른 것이 아니다. changeTo 는 필드 두 개를 바꿨을 뿐이고, 메서드가 끝나고 커밋될 때 UPDATE 가 나갔다.
JPA 는 책상에 책을 올릴 때 처음 모습을 따로 적어둔다. 커밋할 때 책상 위의 책을 그 사본과 하나씩 맞춰본다. 달라진 것이 있으면 그것만 UPDATE 로 내보낸다.
이것을 더티 체킹이라 부른다. 더러워진 것을 찾는다는 뜻이다.
| 코드 | 나가는 SQL | DB 가 바뀌나 |
|---|---|---|
17강 — save() 있음, @Transactional 없음 | SELECT + UPDATE | 바뀐다 |
18강 — save() 없음, @Transactional 있음 | SELECT + UPDATE | 바뀐다 |
save() 없음, @Transactional 없음 | SELECT 만 | 안 바뀐다 |
save() 는 틀린 코드가 아니었다. 울타리를 한 줄만 치고 있었을 뿐이다. 울타리를 메서드 전체로 넓히자 그 한 줄이 할 일이 없어졌다.
save 는 두 가지 일을 한다
16강에서 trap 으로 짚어두고 넘긴 것이 있다.
save(book)은book의id를 먼저 본다.id가null이면 새로 넣고, 값이 있으면 그 번호의 행을 고친다. 이름이 하나라서 눈에 안 보일 뿐이다.
이제 그 둘을 가를 도구가 생겼다. == 다. create 에 한 줄 심는다.
Book saved = repository.save(book);
System.out.println("### save 가 돌려준 것 == 우리가 넘긴 것 ? " + (saved == book));
먼저 id 없이 보낸다. 새 책이다.
curl -X POST http://localhost:8080/books \
-H 'Content-Type: application/json' \
-d '{"title":"새책","price":1000}'
- Hibernate: insert into book (price,title,id) values (?,?,default)
- ### save 가 돌려준 것 == 우리가 넘긴 것 ? true
true 다. 우리가 new 로 만든 그 객체가 그대로 책상에 올라갔다. 15강에서 본 그 장면이다 — persist 전에는 id 가 null 이고, 후에는 번호가 그 객체 안에 들어와 있었다. 응답도 그 객체를 그대로 실어 보낸다.
- {"title":"새책","price":1000,"id":4}
이번에는 id 를 실어 보낸다. 1번 책은 이미 있다.
curl -X POST http://localhost:8080/books \
-H 'Content-Type: application/json' \
-d '{"id":1,"title":"머지","price":9000}'
- Hibernate: select b1_0.id,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
- Hibernate: update book set price=?,title=? where id=?
- ### save 가 돌려준 것 == 우리가 넘긴 것 ? false
false 다. 우리가 넘긴 객체는 책상에 안 올라갔다.
merge 는 이렇게 일한다. 먼저 그 번호의 행을 SELECT 로 읽어 책상에 올린다. 그다음 우리가 넘긴 객체의 값을 그 위로 베껴 쓴다. 그리고 책상에 올라간 쪽을 돌려준다. 우리가 넘긴 객체는 끝까지 책상 밖에 있다.
| persist | merge | |
|---|---|---|
| 언제 | id 가 null 일 때 | id 에 값이 있을 때 |
| 나가는 SQL | INSERT | SELECT 하고 UPDATE |
| 책상에 올라가는 것 | 우리가 넘긴 그 객체 | 데이터베이스에서 읽어온 객체 |
save 가 돌려주는 것 | 같은 객체 (== 가 참) | 다른 객체 (== 가 거짓) |
16강에서 id 를 99 로 보냈을 때 500 이 났던 것도 이제 설명된다. merge 는 99번 행을 읽으러 갔는데 그런 행이 없었다.
curl -X POST http://localhost:8080/books \
-H 'Content-Type: application/json' \
-d '{"id":99,"title":"유령","price":1}'
- {"timestamp":"2026-07-10T12:53:58.985Z","status":500,"error":"Internal Server Error","path":"/books"}
- Hibernate: select b1_0.id,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
- Hibernate: select b1_0.id,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
- org.hibernate.StaleObjectStateException: Row was already updated or deleted
- by another transaction for entity [kr.dcodelab.bookstore.Book with id '99']
INSERT 도 UPDATE 도 안 나갔다. 읽으러 갔다가 없어서 포기한 것이다. save 는 id 가 있으면 고칠 생각으로 간다. 없는 것을 만들어주지 않는다.
정리하면
save() 를 지울 수 있었던 것은 JPA 가 똑똑해서가 아니다. 울타리가 그 줄을 감쌀 만큼 넓어졌기 때문이다.
- 01
JPA 는 우리와 데이터베이스 사이에 책상을 놓는다
영속성 컨텍스트다.
findById는 책상을 먼저 보고, 없을 때만SELECT한다. 그래서 두 번 꺼낸 책이 같은 객체다.Book a = repository.findById(id).orElseThrow(); Book b = repository.findById(id).orElseThrow(); // a == b 는 true. SELECT 는 한 번뿐이다 - 02
책상은 요청 하나만큼 산다
요청이 바뀌면 주소가 달라진다. 컨트롤러는 요청이 바뀌어도 같은 객체였다 (9강). 요청을 넘어 같은 책임을 아는 것은
id뿐이다. - 03
save()는 자기 한 줄만 한 트랜잭션을 연다SimpleJpaRepository.save에@Transactional이 붙어 있다. 리플렉션으로 확인했다. 15강에서 손으로 열고 닫던 그것이다. - 04
울타리를 메서드로 넓히면 그 줄이 필요 없어진다
커밋할 때 JPA 가 책상 위의 책을 처음 모습과 맞춰보고, 달라진 것만
UPDATE로 내보낸다. 더티 체킹이다.@PutMapping("/{id}") @Transactional public ResponseEntity<Book> update(@PathVariable Long id, @RequestBody Book book) { return repository.findById(id) .map(found -> { found.changeTo(book.getTitle(), book.getPrice()); return found; }) .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); } - 05
save는id를 보고 두 갈래로 간다null이면persist라 우리 객체가 책상에 올라가고, 값이 있으면merge라 읽어온 객체가 올라간다.==로 갈린다.
확인용으로 만든 /books/{id}/same 과 println 들은 지운다. @Transactional 은 남겨둔다.
Part 4 가 끝났다
13강 끝에서 서버를 껐다 켰더니 GET /books 가 [] 였다. 거기서 문을 하나 열어뒀다.
- 14강껐다 켜도 남는 곳이 있다. 데이터베이스
- 15강객체가 표가 된다. 그리고 id 가 정체성이 된다
- 16강빈 인터페이스 하나가 구현을 얻는다
- 17강없을 수 있다는 사실이 타입에 적혀 온다
- 18강꺼낸 객체는 책상 위에 있다. 고치면 알아서 나간다
== 이야기도 여기서 닫힌다. 자바 편 4강이 "참조형은 상자에 주소를 담는다"고 했고, 자바 편 14강이 Object 의 equals 를 열어 "그냥 == 다"라고 했다. 15강에서 데이터베이스가 그 둘을 다 거부하고 번호를 요구했다. 그리고 18강에서, 책상 위에서만은 주소가 다시 답이 됐다.
그런데 아직 못 막은 것이 있다. 16강과 17강에서 두 번 짚었다.
curl -X POST http://localhost:8080/books \
-d '{"id":1,"title":"머지","price":9000}'
손님이 보낸 JSON 이 표의 행에 곧바로 닿는다. id 를 실어 보내면 POST 가 수정이 되고, 없는 번호를 보내면 500 이 난다. 우리가 막은 게 아니라 PUT 은 그 필드를 안 읽어서 무사했을 뿐이다.
19강에서 그 사이에 문을 하나 세운다. 밖으로 나가는 객체와 표의 행을 나눈다. 10강이 "그래서 내보낼 객체는 record 로 만든다"고 했고, 15강이 "엔티티는 클래스, DTO 는 record"라고 적어뒀다. 그 둘이 거기서 만난다.