- 1차 캐시, 2차 캐시는 보통 JPA나 그 구현체에서 사용되는 용어다.
- 통상적으로 말하는 캐시는 일반적으로 메서드 수준에서 동작하며, 캐시 데이터는 인메모리 캐시를 사용한다면 하나의 애플리케이션 인스턴스 수준에서 유지되고, 분산 캐시를 사용한다면 분산 캐시 클러스터에 걸쳐 유지된다.
- 아래 메서드 호출 시 xxx 캐시를 먼저 확인해서 데이터가 있다면 xxxRepository.findAll()을 실행하지 않고(DB에 접근하지 않고) 캐시에 있는 데이터를 바로 반환한다.
@Cacheable(cacheNames = ["xxx"])
fun findAllXxx(): List<Xxx> {
return xxxRepository.findAll()
}
- JPA나 그 구현체에서 사용되는 1차 캐시는 트랜잭션 수준에서 유지되는 캐시이며 JPA의 persistence context(EntityManager 또는 Session)가 1차 캐시로 동작한다.
- JPA의 persistence context 자체가 1차 캐시이므로 별도의 캐시 저장소 구성 없이도 1차 캐시는 기본적으로 활성화 돼 있다.
- 개별 트랜잭션 수준에서만 유지되므로 일반적으로 성능 개선 효과가 매우 크지는 않다.
// Tx 시작
// ...
entityManager.find(Xxx:class.java, 111L) // 111L에 대한 최초 조회 시 DB에 접근해서 1차 캐시에 저장한다.
// ...
entityManager.find(Xxx:class.java, 111L) // 여기에서는 DB에 접근하지 않고 1차 캐시에 있는 데이터를 바로 반환한다.
// ...
// Tx 종료
- 2차 캐시는 통상적인 캐시와 마찬가지로 트랜잭션 수준을 넘어서 유지되므로, 1차 캐시의 단점을 극복하고 큰 폭의 성능 개선 효과를 기대할 수 있다.
- 2차 캐시로 인메모리 캐시 사용 시 JVM 수준(구체적으로는 SessionFactory 수준), 분산 캐시 사용 시 분산 캐시 클러스터 수준에서 유지된다.
- 통상적인 캐시와 마찬가지로 캐시 저장소 구성이 필요하며, 이미 통상적인 캐시를 위한 저장소가 구성돼 있다면 2차 캐시도 해당 저장소를 재활용할 수 있다.
- 통상적인 캐시와 가장 큰 차이점은
@Cacheable
로 지정한 메서드를 통해서가 아니라 JPA를 통해 데이터에 접근할 때 캐시를 활용한다는 점이다.
(xxx#111 조회 요청) -> (1차 캐시) -- 없으면 --> (2차 캐시) -- 없으면 --> (DB)
- 검색하면 많이 나옴
-
컬렉션 데이터를 캐시할 때 컬렉션의 원소가
- int 같은 기본 타입이거나, 원소가 기본 타입이 아니지만
@ElementCollection
을 사용한다면 컬렉션을 이루는 객체 자체가 캐시되지만, - 기본 타입이 아니면서
@OneToMany
나@ManyToOne
를 사용한다면 컬렉션의 원소인 객체의 식별자만 캐시된다. - https://docs.jboss.org/hibernate/orm/6.4/userguide/html_single/Hibernate_User_Guide.html#caching-collection 참고
- int 같은 기본 타입이거나, 원소가 기본 타입이 아니지만
-
그래서 다음과 같이
@OneToMany
가 붙어 있는 컬렉션에만@Cache
를 붙이고 컬렉션의 원소인 Yyy에@Cache
를 붙이지 않으면,-
식별자만 2차 캐시에 저장되고,
-
이후 컬렉션을 가져올 때 2차 캐시에서 식별자 목록만 가져오고, 식별자에 해당하는 객체의 정보는 2차 캐시에 없으므로 항상 DB에서 1건 씩 별개의 쿼리로 조회해서 가져오므로 심각한 성능 저하가 발생할 수 있다.
class Xxx { // ... @OneToMany(mappedBy = "xxx") @Cache( region = "xxx.yyys", // cache의 name(또는 alias)와 같은 값이어야 한다 usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE, ) val yyys: List<Yyy> } // 여기에 @Cache 가 없으면 성능 저하 발생 class Yyy { // ... }
-
-
따라서 컬렉션을 캐시할 때는 컬렉션의 원소가 되는 클래스에 반드시
@Cache
를 붙여줘야 한다! -
그렇다고 컬렉션의 원소가 되는 클래스에만
@Cache
를 붙이고, 컬렉션쪽에@Cache
를 붙이지 않아도 되냐하면 그건 아니다.- 컬렉션쪽에
@Cache
를 붙이지 않으면, 캐시된 식별자가 없으므로 대략.select col1, col2, ... from yyy where xxx_id = ?
와 같은 쿼리가 항상 실행되므로 캐시 효과를 볼 수 없게 된다.select col1, col2, ... from yyy where xxx_id = ?
의 결과인 yyy 들은 각각 캐시에 저장되겠지만 컬렉션으로서 yyys를 불러올 때는 항상 쿼리가 실행되므로 캐시가 없는 것과 마찬가지다.
- 컬렉션쪽에
- 처음 캐시에 저장할 때는 에러가 발생하지 않지만, 동일한 데이터를 다시 조회해서 캐시에서 읽어오면 다음과 같은 에러 발생
Caused by: java.lang.IllegalArgumentException: Can not set �a.b.c.MyClass field a.b.c.myField to a.b.c.MyClass
- 에러 발생 위치
- jdk.internal.reflect.UnsaveObjectFieldAccessoImpl.set() 내부 아래 위치에서 예외 던짐
public void set(Object obj, Object value) throws IllegalArgumentException, IllegalAccessException { ensureObj(obj); if (isFinal) { throwFinalFieldIllegalAccessException(value); } if (value != null) { if (!field.getType().isAssignableFrom(value.getClass())) { // 여기!! 결과가 false 라서 throwSetIllegalArgumentException 발생 throwSetIllegalArgumentException(value); } } unsafe.putReference(obj, fieldOffset, value); }
- jdk.internal.reflect.UnsaveObjectFieldAccessoImpl.set() 내부 아래 위치에서 예외 던짐
- 클래스 자체는 동일하나 캐시에 저장할 때의 MyClass를 로딩한 클래스로더와 Deser 할 때 MyClass를 로딩한 클래스로더가 달라서 isAssignableFrom() 이 false 반환
- 애플리케이션 구동 시 RegionFactory, DomainDataStorageAccess 를 로딩하는 클래스로더: RestartClassLoader, parent: AppClassLoader
- Deser 할 때 생성하는 객체의 field: field.getType().classLoader: RestartClassLoader, parent: AppClassLoader
- 캐시에서 가져온 값 value: value.getClass().classLoader: AppClassLoader, parent: PlatformClassLoader
- 캐시에 저장할 때는 애플리케이션을 통해 저장되므로 AppClassLoader 에 의해 로딩된 클래스로 저장되고,
- 캐시에서 가져와서 Deser 해서 객체를 생성할 때는 RegionFactory, DomainDataStorageAccess 를 로딩한 RestartClassLoader 에 의해 로딩된 클래스를 로딩
- RestartClassLoader는 Spring Boot Dev Tools 사용 시에만 사용되는 클래스로더이며, Spring Boot Dev Tools를 비활성화하면 위 타입에러는 발생하지 않음
- 기본 타입이 아니면서
@OneToMany
나@ManyToMany
를 사용한다면 컬렉션의 원소인 객체의 식별자만 캐시되며, - Many 쪽에 있는 엔티티 리스트 전부를 불러올 때는 식별자 목록을 2차 캐시에서 가져와서,
- 각 식별자에 해당하는 엔티티를 하나씩 2차 캐시에서 가져온다.
- 따라서 엔티티 갯수가 1000개면 캐시를 1000번 호출하며, 2차 캐시가 Redis와 같이 원격 캐시인 경우 DB 조회보다 더 오래 걸릴 수도 있다.
- 결국 2차 캐시도 자주 조회되는 단건 엔티티에 대해서는 성능 개선 효과가 크지만, 다건의 엔티티에 대한 성능 개선 효과는 크지 않다.
- 예를 들어 컬렉션 캐시인 xxx.yyys 캐시의 TTL을 10분, 컬렉션 원소 클래스의 캐시인 yyy 캐시의 TTL을 30분으로 지정하면,
-
10분 이내에는 항상 xxx.yyys 캐시에 있는 식별자를 사용해서, yyy 캐시에서 실제 데이터를 가져온다.
-
10분 초과 30분 이내에는 xxx.yyys 캐시가 만료된 상태이므로 식별자 목록이 없어 DB에서 데이터를 가져오고(식별자 뿐만아니라 데이터도 가져옴), 이 때 가져온 yyy 데이터를 yyy 캐시에 저장하려고 하지만 yyy 캐시 TTL이 아직 남아있고 데이터 버전이 다르지 않아 non-writable 이므로 캐시에 저장되지 않는다(AbstractReadWriteAccess 소스 참고).
o.h.c.s.support.AbstractReadWriteAccess : Caching data from load [region=`yyy` (AccessType[read-write])] : key[yyy#5751] -> value[CacheEntry(yyy)] o.h.c.s.support.AbstractReadWriteAccess : Checking writeability of read-write cache item [timestamp=`7010386888146944`, version=`0`] : txTimestamp=`7010386955526144`, newVersion=`0` o.h.c.s.support.AbstractReadWriteAccess : Cache put-from-load [region=`AccessType[read-write]` (yyy), key=`yyy#5751`, value=`CacheEntry(yyy)`]. failed due to being non-writable
-
30분 초과이후에는 두 캐시 모두 만료된 상태이므로 DB에서 데이터를 가져와서 캐시에 저장한다.
-
- 결국 컬렉션으로 가져올 때는 컬렉션쪽 캐시의 TTL을 기준으로 DB 조회 여부가 나눠진다.
- 컬렉션 조회 관점에서는 컬렉션 원소 클래스 쪽 캐시 TTL을 컬렉션 쪽 캐시 TTL보다 길게 지정해도 실익이 없다.
- 다만 컬렉션 원소 클래스 캐시가 컬렉션 조회뿐 아니라 개별 인스턴스에 대한 조회에도 사용된다면 컬렉션 쪽 캐시 TTL과 무관하게 컬렉션 원소 클래스 캐시의 TTL이 그 자체로도 효과가 있다.