애플리케이션에서 공유하는 캐시를 JPA는 공유 캐시(Shared Cache)또는 2차 캐시 (Second Level Cache, L2 Cache)라 부른다. 분산 캐시나 클러스터링 환경의 캐시는 애플리케이션보다 더 오래 유지 될 수도 있다.
2차 캐시를 적절히 활용하면 데이터베이스 조회 횟수를 획기적으로 줄일 수 있다.
1차캐시는 영속성 컨텍스트에서 관리되는 캐시로, 트랜잭션이 시작하고 종료할 때 까지만 유지된다. 그렇기 때문에 같은 트랜잭션에서 한 엔티티를 여러번 가져와야할때 효과를 볼 수 있다.
하지만 만약에 여러 유저가 한 엔티티의 정보를 필요로 한다면 어떨까? 여러 요청들에서 공통적으로 같은 엔티티의 데이터가 필요한 경우에는, 1차캐시가 효용을 미치지 못한다. (한 요청 안에서만 유지되기 때문)
그렇기 때문에 여러 요청에서 캐싱을 적용하기 위해선 2차 캐시를 사용해야 한다.
엔티티 매니저를 통해 엔티티를 조회하기 전 영속성 컨텍스트를 우선 찾은 다음에, 2차 캐시에서 엔티티를 찾고 2차 캐시에도 없는 경우엔 DB에 직접 연결해서 데이터를 찾는다.
2차 캐시의 동작을 간단하게 나타내면 다음과 같다.
- 영속성 컨텍스트에 엔티티가, 없으면 2차 캐시를 조회한다.
- 2차 캐시에 엔티티가 없으면, DB를 조회해서 결과를 2차 캐쉬에 보관한다.
- 2차 캐시에 엔티티가 있으면, 2차 캐시는 자신이 가지고 있는 엔티티를 복사해서 반환한다.
2차 캐시는 동시성을 극대화하기 위해 자신이 가지고 있는 엔티티를 깊은 복사로 반환한다. 만약 캐시한 객체를 그대로 반환하면 멀티 쓰레드 환경에서 동시성 이슈가 발생할 수 있다.
- 일반적으로 객체를 대입하거나 반환하는 경우에는, 얕은 복사(같은 메모리 참조)가 수행되기 떄문이다.
- 예를 들어, 동시에 요청을 받은 두 스레드가 함께 실행된다고 해보자.
- 한 요청을 수행하는 스레드에서 객체의 A라는 데이터를 B로 바꿨다.
- 그렇다면 그 객체가 참조하는 포인터가 가르키는 값이 바뀐다.
- 다른 스레드는 본인이 값을 바꾸지 않았더라도 해당 객제의 값을 B로 조회하게 된다.
- 깊은 복사로 반환하여 Read Commited의 개념을 구현하도록 한다고 볼 수 있다.
2차 캐시는 PK를 기준으로 캐시하지만, 객체를 복사해서 반환하기 때문에 영속성 컨텍스트가 다르면 객체 동일성을 보장하지 않는다.
2차캐시를 사용할떄도, 객체의 정보가 변경되었다면 캐시를 지우고 이후 요청에서 DB에 다시 접근해야한다. 그렇기 떄문에 수정이 많은 테이블에서는 2차 캐시가 큰 성능 향상을 줄 수 없을지도 모른다.
하지만 조회 요청이 더 많은 경우에는 캐시 데이터를 수정하거나 삭제할 필요가 없기 때문에 2차 캐시의 성능 개선 효과를 확실하게 느낄 수 있다. 특히 한 유저가 어떤 데이터를 수정하는 중이어서 해당 Row에 Lock이 걸려있는 상황에도, 2차 캐시가 있다면 단순 조회 요청에선 그 캐시 데이터를 바로 반환하면 되기 때문에 조회 성능을 더욱 높일 수 있다.
엔티티는 영속성 컨텍스트에서 상태를 관리하기 때문에 최적화를 위해선 엔티티를 DTO로 변환해서 변환한 DTO를 캐시에 저장해서 관리해야한다.
하지만 JPA(Hibernate)의 2차캐시는 사용자 지정 DTO를 정의하는 것이 어렵고, 설정이 복잡하고, 지원하는 캐시 라이브러리도 없다.
그에 반해 스프링을 사용하면 이 DTO를 효과적으로 캐시할 수 있고, 지원하는 캐시 라이브러리도 풍부하다. 그런데 2차 캐시는 단순히 엔티티 조회(쿼리포함)와 관련된 부분만 캐시가 지원되기 때문에 JPA 2차캐시보다는 스프링에서 지원하는 캐시를 사용하는 것이 더 좋다고 한다.