학습 자료

영속성 컨텍스트 — 두 번 꺼낸 같은 책은 같은 객체다


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 이 난다. 있는 책만 물어볼 것이므로 그냥 둔다.

같은 번호를 두 번 꺼냈다. ab 는 서로 다른 객체여야 자연스럽다. 데이터베이스를 두 번 뒤졌을 테니까.

터미널
  • $ curl http://localhost:8080/books/1/same
  • a == b ? true / a 의 주소 = 1080108589

true 다.

이 시리즈는 여섯 강에 걸쳐 == 로 비교하지 말라고 가르쳤다. 자바 편 14강까지 거슬러 올라간다. 그 강의는 Objectequals 를 열어 보여줬다.

public boolean equals(Object obj) {
    return (this == obj);
}

그리고 이렇게 적었다.

그냥 == 다. 주소를 비교한다.

자바 편 4강은 더 앞에서 그 뜻을 정했다.

==상자 안에 든 것끼리 비교한다. 참조형 상자에 든 것은 주소였다.

그러니 a == btrue 라는 말은 하나뿐이다. 주소가 같다. 두 변수가 같은 곳을 가리키고 있다. 객체가 하나라는 뜻이다.

서버 로그가 그것을 확인해준다.

서버 로그 — findById 를 두 번 불렀다
  • 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 를 날리고, 받아온 것을 책상에 올려두고 준다.

findById(1) 를 두 번 부르면
  1. 첫 번째책상이 비었다 → SELECT → 책상에 올린다 → 준다
  2. 책상1번 = Book@1080108589
  3. 두 번째책상에 1번이 있다 → 그것을 준다. SELECT 없음
그래서 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강

  • 주소가 다르다
  • 책상이 요청마다 새로 놓인다
  • 요청이 끝나면 책상째 치워진다
그래서 요청을 넘어 같은 책임을 아는 방법은 주소가 아니라 번호다. 15강의 id 가 그것이다.

그런데 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 이 붙어 있다. 그것도 readOnlyfalse 인, 쓰는 트랜잭션이다. 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() 를 지우자 쓰기 트랜잭션이 통째로 사라졌다. 그래서 커밋이 없고, 커밋이 없으니 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 를 불렀다. 메서드는 아직 안 끝났다");
서버 로그 — PUT /books/2 한 번
  • 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=?

UPDATEchangeTo 뒤에 나갔다. 우리가 부른 것이 아니다. changeTo 는 필드 두 개를 바꿨을 뿐이고, 메서드가 끝나고 커밋될 때 UPDATE 가 나갔다.

JPA 는 책상에 책을 올릴 때 처음 모습을 따로 적어둔다. 커밋할 때 책상 위의 책을 그 사본과 하나씩 맞춰본다. 달라진 것이 있으면 그것만 UPDATE 로 내보낸다.

이것을 더티 체킹이라 부른다. 더러워진 것을 찾는다는 뜻이다.

세 갈래를 나란히 놓으면
코드나가는 SQLDB 가 바뀌나
17강 — save() 있음, @Transactional 없음SELECT + UPDATE바뀐다
18강 — save() 없음, @Transactional 있음SELECT + UPDATE바뀐다
save() 없음, @Transactional 없음SELECT안 바뀐다
위의 둘은 나가는 SQL 이 글자까지 같다. save() 가 하던 일을 표식 하나가 대신했을 뿐이다.

save() 는 틀린 코드가 아니었다. 울타리를 한 줄만 치고 있었을 뿐이다. 울타리를 메서드 전체로 넓히자 그 한 줄이 할 일이 없어졌다.

save 는 두 가지 일을 한다

16강에서 trap 으로 짚어두고 넘긴 것이 있다.

save(book)bookid 를 먼저 본다. idnull 이면 새로 넣고, 값이 있으면 그 번호의 행을 고친다. 이름이 하나라서 눈에 안 보일 뿐이다.

이제 그 둘을 가를 도구가 생겼다. == 다. 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 전에는 idnull 이고, 후에는 번호가 그 객체 안에 들어와 있었다. 응답도 그 객체를 그대로 실어 보낸다.

터미널
  • {"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 로 읽어 책상에 올린다. 그다음 우리가 넘긴 객체의 값을 그 위로 베껴 쓴다. 그리고 책상에 올라간 쪽을 돌려준다. 우리가 넘긴 객체는 끝까지 책상 밖에 있다.

id 를 보고 갈린다
persistmerge
언제idnull 일 때id 에 값이 있을 때
나가는 SQLINSERTSELECT 하고 UPDATE
책상에 올라가는 것우리가 넘긴 그 객체데이터베이스에서 읽어온 객체
save 가 돌려주는 것같은 객체 (== 가 참)다른 객체 (== 가 거짓)
그래서 save 의 반환값을 받아 쓰는 습관이 중요하다. 넘긴 객체가 갱신된다는 보장이 없다.

16강에서 id99 로 보냈을 때 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']

INSERTUPDATE 도 안 나갔다. 읽으러 갔다가 없어서 포기한 것이다. saveid 가 있으면 고칠 생각으로 간다. 없는 것을 만들어주지 않는다.

정리하면

save() 를 지울 수 있었던 것은 JPA 가 똑똑해서가 아니다. 울타리가 그 줄을 감쌀 만큼 넓어졌기 때문이다.

다시 짚어보기
  1. 01

    JPA 는 우리와 데이터베이스 사이에 책상을 놓는다

    영속성 컨텍스트다. findById 는 책상을 먼저 보고, 없을 때만 SELECT 한다. 그래서 두 번 꺼낸 책이 같은 객체다.

    Book a = repository.findById(id).orElseThrow();
    Book b = repository.findById(id).orElseThrow();
    // a == b 는 true. SELECT 는 한 번뿐이다
  2. 02

    책상은 요청 하나만큼 산다

    요청이 바뀌면 주소가 달라진다. 컨트롤러는 요청이 바뀌어도 같은 객체였다 (9강). 요청을 넘어 같은 책임을 아는 것은 id 뿐이다.

  3. 03

    save() 는 자기 한 줄만 한 트랜잭션을 연다

    SimpleJpaRepository.save@Transactional 이 붙어 있다. 리플렉션으로 확인했다. 15강에서 손으로 열고 닫던 그것이다.

  4. 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());
    }
  5. 05

    saveid 를 보고 두 갈래로 간다

    null 이면 persist 라 우리 객체가 책상에 올라가고, 값이 있으면 merge 라 읽어온 객체가 올라간다. == 로 갈린다.

확인용으로 만든 /books/{id}/sameprintln 들은 지운다. @Transactional 은 남겨둔다.

Part 4 가 끝났다

13강 끝에서 서버를 껐다 켰더니 GET /books[] 였다. 거기서 문을 하나 열어뒀다.

데이터를 남긴다 — 다섯 강
  1. 14강껐다 켜도 남는 곳이 있다. 데이터베이스
  2. 15강객체가 표가 된다. 그리고 id 가 정체성이 된다
  3. 16강빈 인터페이스 하나가 구현을 얻는다
  4. 17강없을 수 있다는 사실이 타입에 적혀 온다
  5. 18강꺼낸 객체는 책상 위에 있다. 고치면 알아서 나간다
이제 CRUD 넷이 데이터베이스 위에서 돈다. 껐다 켜도 남는다.

== 이야기도 여기서 닫힌다. 자바 편 4강이 "참조형은 상자에 주소를 담는다"고 했고, 자바 편 14강이 Objectequals 를 열어 "그냥 == 다"라고 했다. 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"라고 적어뒀다. 그 둘이 거기서 만난다.

영속성 컨텍스트 — 두 번 꺼낸 같은 책은 같은 객체다 — 디코드랩(DCODELAB)