본문 바로가기

3.구현/Java or Kotlin

[kotlin] JPA 12: Spring Boot에서 2차 캐시

들어가기

JPA에서 캐시는 2단계를 걸쳐서 동작한다. 1차 캐시는 영속 컨텍스에 해당하며 세션이 유지될때까지 현재 세션의 모든 엔티티는 영속 컨텍스트 내에서 캐싱된다. 영속 컨텍스트는 트랜잭션 범위에서 유효한데 보통 트랜잭션이 종료되면 영속 컨텍스트도 같이 종료된다. 그렇기 때문에 세션과 영속 컨텍스트의 생명이 동일하다고 할 수 있다. 세션이 종료된 경우 1차 캐시가 사라지기 때문에 애플리케이션 전체에서 보면 동일한 요청을 할 경우 새로 캐싱해서 처리하기 때문에 효율적이지 않다. 결국, 세션에 상관 없이 캐시를 유지 관리하는 2차 캐시가 필요하다. 2차 캐시에 대해서 살펴보자.

작성자: http://ospace.tistory.com/ (ospace114@empal.com)

2차 캐시

애플리케이션 라이프타임에 사용할 수 있는 캐시가 공유 캐시 또는 2차 캐시라고 한다. 2차 캐시는 세션 팩토리 범위을 가지며 이는 모든 세션에 동일한 세션 패토리를 공유하므로 일반적으로 애플리케이션이 종료될때까지 캐시가 유지된다. 만약, 분산 캐시나 클러스터 환경에서 캐시라면 애플리케이션 종료와 상관없이 유지 될 수 있다.

2차 캐시는 데이터베이스에 붙어서 캐시가 관리되고 1차 캐시는 2차 캐시를 통해서 엔티티를 접근한다. 호출 흐름을 보면 먼저 1차 캐시을 거쳐서 2차 캐시를 통해 데이터베이스로 흘러간다. 1차 캐시가 2차 캐시의 엔티티를 가져올 때에는 복제해서 가져온다. 그렇기 때문에 1차 캐시와 2차 캐시의 같은 엔티티라고 해도 객체 동일성은 달라질 수 있다. 2차 캐시를 설정과 사용하는 방법을 알아보자.

기본 환경 구성

2차 캐시는 SessionFactory 범위를 가지고 있기 때문에 모든 세션이 SessionFactory에 의해서 생성되므로 2차 캐시는 모든 세션에 공유된다. 하이버네이트에서 2차 캐시는 실제 캐시 제공자를 구현하지도 않고 어떤 캐시 제공자가 있는지 알 수 없다. org.hibernate.cache.spi.RegionFactory 인터페이스 구현체를 지원하는 캐시 제공자를 지정하면 해당 캐시 구현체를 가져다 사용한다. 그렇기 때문에 어떤 캐시 제공자를 사용할지 환경 구성이 필요하다.

Dependency 설정

기본 환경에서는 2차 캐시를 사용할 수 없다. 2차 캐시를 사용하기 위해서는 사전 준비가 필요하다. maven을 기준으로 설명하겠다. 여기서 사용할 캐시 제공자는 Ehcache를 사용했다. 참고로 다음 예제는 Spring boot 3.2.6 기준으로 작성되었다. 이 버전에서 hibernate-core 버전은 6.4.8.Final이다. 다른 버전인 경우 Dependency와 환경 설정이 달라질 수 있으니 주의가 필요하다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
</dependency>

Spring Data JPA와 ehcache에 대한 dependency을 추가했다. 그리고 추가로 하이버네이트에서 JCache을 사용하기 위한 dependency도 추가했다. Spring Data JPA는 이미 추가되었다면 무시해도 된다. 하이버네이트에서 캐시 제공자 라이브러리로 ehcache와 infinispan을 많이 사용된다. Spring boot을 사용한다면 ehcache 버전을 알아서 선택한다.

혹시나 해서 JCache없이 바로 사용할 경우 ehcache에서 hibernate-ehcache 디펜던시 설치가 필요하다. 이는 메이븐 중앙 리포지토리에 없고 아래 리포지토리를 추가해야 한다.

<repository>
    <id>terracotta-releases</id>
    <url>http://www.terracotta.org/download/reflector/releases</url>
    <releases><enabled>true</enabled></releases>
    <snapshots><enabled>false</enabled></snapshots>
</repository>

그런후 hibernate-ehcache 디펜던시를 추가할 수 있다.

<dependency>
  <groupId>org.hibernate</groupId>
  <artifactId>hibernate-ehcache</artifactId>
  <version>${hibernateVersion}</version>
</dependency>

혹시나 해서 설정에 어려움이 있을까해서 그냥 한번 언급해보았다.

캐시 설정

Application.properties 설정

캐시 사용 설정은 application.properties에서 설정한다.

spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.ehcache.EhCacheRegionFactory
spring.jpa.properties.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider

기본적으로 설정해야할 항목이 spring.jpa.properties.hibernate.cache.use_second_level_cache이다.

추가로 spring.jpa.properties.hibernate.cache.region.factory_class 또는 spring.jpa.properties.hibernate.javax.cache.provider를 설정할 수 있다.

spring.jpa.properties.hibernate.cache.use_second_level_cache 항목은 2차 캐시 사용여부이다. 기본은 false로 사용안함으로 되어 있다.

spring.jpa.properties.hibernate.cache.region.factory_class는 캐시 팩토리 구현체를 지정한다. 해당 구현체는 org.hibernate.cache.spi.RegionFactory를 구현한 클래스로 이를 통해 하이버네이트와 캐시 제공자가 연결된다. 만약 지정하지 않으면 2차 캐시는 활성화에 에러가 발생될 수 있다. 지정할 때 주의할 부분은 전체 패키지 경로를 표기해야한다. 예를 들어 앞의 EhCacheRegionFactory 클래스인 경우 org.hibernate.cache.ehcache.EhCacheRegionFactory으로 사용해야 한다. 이 클래스는 JCache 표준을 사용하도록 설정하였다. 그리고 하이버네이트 버전에 따라 ehcache에서 사용할 구현체 클래스가 달라진다. 하이버네이트 4.x인 경우 org.hibernate.cache.ehcache.EhCacheRegionFactgory나 org.hibernate.cache.ehcache.SingletonEhCacheRegionFactory를 사용한다. 이전 버전에서는 net.sf.ehcache.hibernate.EhCacheRegionFactory나 net.sf.ehcache.hibernate.SingletonEhCacheRegionFactory를 사용한다.

spring.jpa.properties.hibernate.javax.cache.provider항목으로도 캐시 구현체를 설정하는데 JCache를 사용한다. 이는 자바 표준이 JSR-107를 따르는 구현체만 설정할 수 있다. JCache 표준을 따르는 제공자로 ehcache, hazelcast, infinispan 등이 있다.

JCache 표준을 따르지 않는다면 spring.jpa.properties.hibernate.cache.region.factory_class를 사용하고, JCache 표준을 따르는 경우에는 spring.jpa.properties.javax.cache.provider을 사용한다.

정리하면, 현재 환경 구성으로는 hibernate-jcache로 인해 org.hibernate.cache.jcache.JCacheRegionFactory를 사용해 JCache 제공자를 사용한다. 그리고 JCache 구현체인 ehcache을 디펜던시에 추가했다. 결국 use_second_level_cache 설정만 true로 하면 된다. 그러나 JCache 제공자를 여러개 사용할 경우 순서에 따라 사용되는 제공자가 달라 질 수 있으므로 안전한 설정을 위해 spring.jpa.properties.javax.cache.provider 설정을 추가한다.

spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider

만약 JCache을 사용하고 싶지 않다면 hibernate-jcache를 제거하고 ehcache대신에 하이버네이트의 RegionFactory 구현체 라이브러리로 대처하면 된다. 여기까지 했으면 2차 캐시를 사용하기 위한 기본 환경 구성은 완료되었다.

Ehcache.xml 설정

XML을 사용한 EHCACHE 설정은 리소스 폴더에 “ehcache.xml”를 추가해서 설정한다.

<ehcache>
  <defaultCache
    mainElementsInMemory="10000"
    eternal="false"
    timeToIdleSeconds="1200"
    timeToLiveSeconds="1200"
    diskExpiryThreadIntervalSeconds="1200"
    memoryStoreEvictionPolicy="LRU"
    />
    <cache
      name="com.tistory.ospace.entity.Member"
      maxElementsInMemrory="3000"
      eternal="false"
      timeToIdleSeconds="3600"
      timeToLieveSeconds="3600"
      overflowToDisk="false"
    />
</ehcache>

기존 캐시 설정과 엔티티별 캐시 설정을 할 수 있다.

2차 캐시 사용

하이버네트에서 2차 캐시를 사용해보자. 하이버네트에서는 2차 캐시에 대해 3가지 용도를 제공한다.

  • 엔티티 캐시: 엔티티 단위로 캐시. 식별자로 엔티티 조회하거나 단일 연관 엔티티 로딩에 사용.
  • 컬랙션 캐시: 엔티티와 연관된 컬랙션 캐시. 컬랙션에서 엔티티를 담고 있으면 식별자만 캐싱.
  • 쿼리 캐시: 쿼리와 파라미터 키로 캐시. 결과가 엔티티이면 식별자로 캐싱.

쿼리 캐시나 컬랙션 캐시는 엔티티가 아닌 식별자 값이 저장된다. 식별자 값으로 다시 엔티티를 조회하게 된다. 그렇기 때문에 실제 엔티티가 캐시되어 있지 않으면 캐시 효과가 없다. 그렇기 때문에 쿼리 캐시나 컬랙션 캐시인 경우 반드시 엔티티 캐시를 사용해야 한다. 이제 하나씩 살펴보자.

@Cachable

엔티티가 2차 캐시를 사용하기 위해서는 엔티티에 @Cacheable 어노테이션을 사용한다. 해당 엔티티는 2차 캐시에 캐싱된다.

@Cacheable
@Entity
open class Member {
  //...
}

2차 캐싱을 하기 위한 엔티티에 @Cachable 어노테이션을 지정하면 된다. 엔티티 캐시 영역은 “패키지 이름 + 클래스 이름”으로 사용한다. 앞의 엔티티는 “com.tistory.ospace.entity.Member”이라는 캐시 영역에 인스탄스가 저장된다.

@Cache

만약 캐시에 대해 세부 설정하기 위해서는 @Cache 어노테이션을 같이 사용한다. 캐시의 동시성 전략이나 연관 계체 포함 여부 등을 설정할 수 있다. 이 어노테이션이 제공하는 속성이다.

  • use: 캐시 동시성 전략 설정. CacheConcurrentStrategy 열거형으로 설정
  • region: 캐시 영역 설정
  • include: 연관 객체를 캐시에 포함 여부

CacheConcurrentStrategy 열거형으로 캐시 동시성 전략 설정할 수 있다. 설정할 수 있는 값은 다음과 같다.

  • NONE: 캐시 설정하지 않음
  • READ_ONLY: 읽기 전용. 추가, 삭제는 가능하나 수정은 불가.
  • NONSTRICT_READ_WRITE: 엄격하지 않은 읽기 쓰기 전략. 데이터 수정시 캐시 무효화
  • READ_WRITE: 읽기 쓰기 가능. READ COMMITED 격리 수준. 데이터 수정시 캐시 데이터도 수정
  • TRANSACTIONAL: 컨테이너 환경에서 사용 가능. REPEATABLE READ 격리 수준 보장

컬랙션 캐싱

한가지 주의할 부분은 속성 중에 OneToMany나 ManyToMany 연관관계가 있는 컬랙션은 캐싱되지 않는다. 해당 속성도 캐싱하려면 직접 @Cacheable 어노테이션을 지정해야 한다.

@Cacheable
@Entity
open class Member(
  @Cacheable
  @OneToMany
  var posts: Collection<Post>,
) {
  //...
}

컬랙션이 캐싱될 때에는 각 요소는 개별적으로 저장된다. 이때 저장되는 캐시 영역 이름이 엔티티 클래스 이름과 컬랙션 속성이 합쳐진 이름(”캐시 영역 이름 + 필드 이름”) 을 사용한다. 앞의 예인 경우 “com.tistory.ospace.entity.Member.posts”가 된다. 이는 콜랙션을 위한 별도 캐시 설정을 할 수 있게 한다.

엔티티가 캐시에 저장될 때에는 엔티티 인스탄스로 저장되지 않고 분리된 상태 형태로 저장된다. 엔티티에서 Id에 해당하는 속성은 저장되지 않고 키 형태로 저장되다. 그리고 컬랙션과 @Transient가 지정된 속성도 저장되지 않는다. 연관 안된 속성의 값만 원래 형태로 저장된다. 물론 ToOne 으로 연관관계 만들어진 엔티티의 id(외래키에 해당)는 저장된다. 그렇기에 해당 엔티티도 캐싱하는게 좋다.

공유캐시 모드

간단하게 엔티티에 2차 캐시를 사용할 수 있게 적용해보았다. 현재 2차 캐시 사용 전략이 ENABLE_SELECTIVE으로 @Cachable이 지정된 엔티티만 캐싱되도록 되어 있다. 이를 환경설정에서 캐싱되는 대상 선택 전략을 spring.jpa.properties.javax.persistence.sharedCache.mode을 사용해서 변경할 수 있다.

spring.jpa.properties.javax.persistence.sharedCache.mode=ALL

spring.jpa.properties.javax.persistence.sharedCache.mode은 엔티티의 공유캐시인 2차 캐시를 사용하는 방법을 설정한다. 선택할 수 있는 값은 다음과 같다.

  • NONE: 캐싱 하지 않음
  • ALL: 모든 엔티티 캐싱
  • ENABLE_SELECTIVE: Cacheable이 지정된 엔티티만 캐싱 (기본)
  • ENABLE_SELECTIVE: Cacheable(false)가 지정된 엔티티만 제외하고 캐싱
  • UNSPECIFIED: JPA 구현체의 설정을 따름

현재 목적에 맞게 선택해서 적용하면 된다.

쿼리 캐시

쿼리 캐시는 쿼리 결과를 캐싱하는 기능이다. 쿼리 캐시는 쿼리와 파라미터 정보를 키로 해서 결과릴 개싱하는 방법이다. 이는 쿼리 결과에 의한 엔티티가 거의 바뀌지 않는다면 매우 유용한 기능이다. 먼저 이를 활성화하려면 application.properties 설정 파일에 hibernate.cache.use_query_case를 true로 설정하면 된다.


spring.jpa.properties.hibernate.cache.use_query_cache=true
spring.jpa.properties.hibernate.generate_statistics=true

캐싱하려는 쿼리마다 힌트에 대해 “org.hibernate.cacheable” 힌트를 true로 설정한다. 간단한 예를 보자.

val res = em.createQuery("SELECT m FROM Member m", Member::class.java)
  .setHint("org.hibernate.cacheable", true)
  .resultList

크게 복잡하지 않다. 쿼리 캐싱 관련해서 몇가지 주의할 부분이다.

  • 컬랙션 처럼 결과로 반환된 엔티티의 id만 캐싱된다. 이런 엔티티에 매우 유용하다.
  • 각 쿼리 인자 값의 조합별로 캐시가 생성된다. 인자로 인해 너무 많은 경우의 수가 생기기 때문에 좋은 캐싱이 아니다.
  • 쿼리 결과에 포함된 엔티티가 자주 변경되는 경우 좋은 캐싱 대상은 아니다.
  • 기본으로 모든 쿼리 캐시는 org.hibernate.cache.internal.StandardQueryCache 영역에 캐싱된다. 이는 엔티티/컬랙션 처럼 캐싱 인자를 사용자 정의해서 정책 변경할 수 있다. 다른 쿼리에 대해 다른 설정을 사용하기 위해 각 쿼리마다 사용자 영역을 지정할 수 있다.
  • 캐시 유효성 확인을 위해 마지막 갱신 타입스탭프는 org.hibernate.cache.spi.UpdateTimestampsCache 영역에 저장된다. 많은 메모리를 소비하지 않는다면 유효성 확인을 비활성해도 좋다.

캐시 조회/저장 모드

쿼리나 엔티티 조회할 경우 캐시에서 데이터를 가져오거나 저장하는 방법을 설정할 수 있다. 이와 관련한 옵션으로 javax.persistence.CacheRetriveMode와 javax.persistence.CacheStoreMode가 있다. 각

  • javax.persistence.CacheRetriveMode: 캐시 조회 모드 설정한다. 설정할 수 있는 값은 아래과 같고 열거형 CacheRetriveMode를 사용해서 설정한다.
    • USE: 기본값으로 캐시를 사용해서 조회한다.
    • BYPASS: 캐시를 무시하고 항상 데이터베이스에서 조회한다.
  • javax.persistence.CacheStoreMode: 캐시 저장 모드 설정한다. 설정할 수 있는 값은 아래과 같고 열거형 CacheStoreMode를 사용해서 설정한다.
    • USE:기본값으로 캐시에 결과를 저장한다.
    • BYPASS: 캐시를 사용하지 않고 바로 데이터베이스에 저장/갱신한다.
    • REFRESH: 데이터베이스 조회나 커밋할 때 데이터를 캐시에 저장/갱신한다.

캐시 모드 설정할 때에는 사용하는 곳에서 관련 옵션을 설정하는 방법이 조금 차이가 있다. 하나씩 살펴보자.

엔티티 매니저에서 사용하는 예는 다음과 같다.

em.setProperty("javax.persistence.cache.retriveMode", CacheRetriveMode.BYPASS)

엔티티 조회에서 사용하는 예는 다음과 같다.

val hints = mutableMap<String, Any>()
hints["javax.persistence.cache.retriveMode"] = CacheRetriveMode.BYPASS
val res = em.find(Member::class.java, id, hints)

쿼리 조회에서 사용하는 예는 다음과 같다.

val res = em.createQuery("...", Member::class.java)
  .setHint("javax.persistence.cache.retriveMode", CacheRetriveMode.BYPASS)
  .singleResult

Cache Interface

JPA에서 직접 캐시관리를 할 수 있다. 엔티티 매니저 팩토리에서 캐시 인터페이스를 접근할 수 있다. 엔티티 매니저 팩토리는 리포지토리 구현체에서 의존성 주입을 통해서 획득할 수 있다.

@Repository
open class MemberRepositoryImpl(
  private val emf: EntityManagerFactory,
  //...
) {
    //...
}

엔티티 메니저 팩토리를 통해서 캐시 인터페이스에 접근하는 예를 보자.

val cache:Cache = emf.getCache()
val contained = cache.contains(Member::calss.java, foo.getId())

캐시 인터페이스는 다음과 같다.

public interface Cache {
  boolean contains(Class cls, Object primaryKey);
  void evict(Class cls, Object primaryKey);
  void evict(Class cls);
  void evictAll();
  <T> T unwrap(Class<T> cls);
}

contains()은 캐시에 해당 데이터가 포함되었는지 여부를 알 수 있고, evit()은 지정된 대상을 캐시에서 제거 한다. evictAll()은 캐시에서 모든 데이터를 재거한다. unwrap()은 공급자별 API에 접근하기 위해 지정된 타입의 JPA 캐시 구현체 객체를 획득한다. 만약 해당 클래스 타입을 지원하지 않으면 예외를 발생한다.

마무리

2차 캐시를 정리하는데 시간이 의왜로 오래 걸렸다. 특히 디펜던시 설정이 까다로웠다. 사용할 디펜던시와 버전을 확인하는데 어려웠다. 여러 검증을 통해 데펜던시 설정을 단순화시켰다. 현재 ehcache을 사용했지만, 다른 캐시 구현체도 있으니 참고하시기 바란다. 2차 캐시는 잘 사용하면 성능향샹에 좋지만 언제나 그렇지만 잘못 사용한 캐시는 성능을 저하하고 리소스 낭비한다. 그렇기에 자신이 사용하는 목적과 환경을 잘 파악해서 적용해야 한다.

부족한 글이지만 도움이 되었으면 하네요. 모두 즐거운 코딩 생활되세요. ospace.

참고

[1] 김영한, 자바 ORM 표준 JPA 프로그래밍, 에이콘, 2015

[2] Hibernate ORM User Guide, https://docs.jboss.org/hibernate/orm/6.4/userguide/html_single/Hibernate_User_Guide.html

[3] baeldung, Hibernate Second-Level Cache, https://www.baeldung.com/hibernate-second-level-cache, 2024.03.11

[4] Using Hibernate and BigMemeory Go, https://www.ehcache.org/documentation/2.8/integrations/hibernate.html, 2024.07.09

[5] EHCACHE Offical site, https://www.ehcache.org

[6] Infinispan Offical site, https://infinispan.org/

반응형