영속성 컨텍스트의 특징
1차 캐시
영속성 컨텍스트는 내부에 캐시를 가지고 있다.
쉽게 이야기해서 영속성 컨텍스트 내부에 엔티티를 보관하고 있는 Map이 하나 있다고 생각하면 된다.
1차 캐시의 키는 식별자 값인데, 식별자 값은 우리가 엔티티를 정의할 때 @Id
를 붙인 필드다.
따라서 영속성 컨텍스트에 데이터를 저장하고 조회하는 모든 기준은 데이터베이스 기본 키 값이다.
1차 캐시의 과정
- 조회할 때 1차 캐시에 해당 데이터가 있는지 탐색을 하고 존재하면 바로 가져온다.
- 조회할 때 1차 캐시에 해당 데이터가 없으면 DB에 접근해 값을 가져온다.
- DB에서 값을 바로 가져오는 것이 아닌 다음 탐색에서 재사용할 수 있도록 1차 캐시에 보관한다.
User를 조회해오는 메서드인데, 1차 캐시가 적용되어 쿼리가 한번만 날라가는지 확인하기 위해 메서드 호출을 2번 해보았다.
테스트 코드를 실행했을 때, findUser
메서드 내에서 userRepository
의 findById
메서드가 2번 호출되었지만 1차 캐시가 적용되어 쿼리가 한번만 날라가는 것을 확인할 수 있다.
findById
가 처음 호출 될때 1차 캐시에 데이터가 존재하지 않아 DB에 직접 접근하였고, 1차 캐시에 보관해놓았다. 그러므로 findById
가 2번째 호출 될 때 DB에 직접 접근하지 않고도 영속성 컨텍스트의 1차 캐시에서 데이터를 바로 가져올 수 있었다.
1차 캐시의 라이프 사이클
1차 캐시는 한 트랜잭션 내에서만 공유되었다 사라지는 영역이다. 왜냐하면 엔티티 매니저는 트랜잭션 단위이이므로 트랜잭션이 종료되면 1차 캐시도 지워버린다. 그래서 1차 캐시가 실질적으로 성능에 큰 기여를 하는 것은 아니다. 어플리케이션 전체 영역에서 공유하는 캐시 공간은 2차 캐시다.
트랜잭션이 종료될 때 1차 캐시도 지워지는지 확인해보기 위한 테스트다.
findUser
를 2번 호출해보았을 때, 쿼리가 몇번 날라가는지 확인해보겠다.
findUser
가 실행 완료될 때, 트랜잭션이 종료 되었기 때문에 1차 캐시는 비워지게 되었고 메서드를 호출한 수만큼 쿼리가 날라간 것을 확인할 수 있다.
쓰기 지연
엔티티 매니저는 트랜잭션을 커밋하기 직전까지 데이터베이스에 엔티티를 저장하지 않고 영속성 컨텍스트 내부 쿼리 저장소에 SQL문을 모아둔다. 그리고 트랜잭션을 커밋할 때 모아둔 쿼리를 데이터베이스에 보내는데 이것을 쓰기 지연이라 한다.
insert 쿼리문이 save
가 호출될 때마다 실행되는지, 트랜잭션이 종료될 때 쿼리문이 실행되는지 확인하기 위한 테스트다.
위와 같이 save
호출 시점이 아닌 트랜잭션 종료 시점에 쿼리문에 한번에 날라가는 것을 확인할 수 있다.
쓰기 지연의 과정
- Question1을 영속화한다. 영속성 컨텍스트는 1차 캐시에 Question1을 저장하면서 동시에 insert 쿼리를 만든다. 그리고 insert 쿼리를 SQL 저장소에 보관한다.
- Question2를 영속화한다. 동일하게 영속성 컨텍스트에 Question2를 저장하고 insert 쿼리를 만들어서 SQL 저장소에 보관한다.
- 트랜잭션이 커밋되기 이전에 엔티티 매니저는 영속성 컨텍스트를 flush 를 하게 되면서 SQL 저장소에 있는 쿼리들을 데이터베이스에 보낸다.
- 실제 데이터베이스 트랜잭션을 커밋한다.
만약 10개의 데이터를 저장한다고 할 때, 데이터베이스와 10번 통신할 것을 쓰기 지연 기능을 통해서 1번만 통신할 수 있게 해준다.
쓰기 지연 적용이 안되는 경우
엔티티 키 전략이 Identity인 경우에는 엔티티 매니저를 영속화하는 즉시 엔티티를 데이터베이스에 flush 한다. 왜냐하면 영속성 컨텍스트에 저장하기 위해서는 데이터베이스 PK가 필수이기 때문이다.
save
가 호출될 때마다 insert 쿼리가 날라가는 것을 확인할 수 있다.
변경 감지
JPA로 엔티티를 수정할 때는 단순히 엔티티를 변경하면 되면 update 쿼리가 날라간다. 이때 엔티티는 영속 상태인 엔티티에만 적용이된다. 이렇게 엔티티의 변경사항을 데이터베이스에 자동으로 반영하는 기능을 변경 감지라 한다.
변경 감지 과정
JPA는 엔티티를 영속성 컨텍스트에 보관할 때, 최초 상태를 복사해서 저장해두는데 이것을 스냅샷이라고 한다. 그리고 flush 되는 시점에 스냅샷과 엔티티를 비교해서 변경된 엔티티를 찾는다.
- 트랜잭션을 커밋하면 엔티티 매니저 내부에서 flush가 호출된다.
- 엔티티와 스냅샷을 비교해서 변경된 엔티티를 찾는다.
- 변경된 엔티티가 있으면 수정 쿼리를 생성해서 쓰기 지연 SQL 저장소에 보낸다.
- 쓰기 지연 저장소의 SQL을 데이터베이스에 보낸다.
- 데이터베이스 트랜잭션을 커밋한다.
select 문으로 user를 조회해와서 조회된 user 객체는 영속성 컨텍스트에 존재하게 되었고, user의 이름을 변경하기만 했는데 트랜잭션이 커밋되고 update 쿼리가 날라가는 것을 확인할 수 있다.
항상 update 쿼리가 똑같다?
위의 update 쿼리를 보면 모든 필드를 수정하고 있다. 이렇게 모든 필드를 사용하면 데이터 전송량이 증가하는 단점이 있지만, 수정 쿼리가 항상 같기 때문에 어플리케이션 로딩 시점에 수정 쿼리를 미리 생성해두고 재사용할 수 있고 데이터베이스에서는 이전에 한 번 파싱된 쿼리를 재사용할 수 있다.
상황에 따라서 필드가 많거나 내용이 너무 크면 수정된 데이터만 사용해서 동적으로 UPDATE DQL을 생성하는 전략을 선택하면 된다. 적용하는 방법으로는 엔티티에 @DynamicUpdate 어노테이션을 붙여주면 된다.
참고
자바 ORM 표준 JPA 프로그래밍