JPA 캐시

이 글은 “ORM 표준 JPA 프로그래밍” 책의 16장을 정리한 내용입니다.

만약 저작권 관련 문제가 있다면 “gunjuko92@gmail.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.

1차 캐시와 2차 캐시

네트워크를 통해 데이터베이스에 접근하는 시간 비용은 애플리케이션 서버에서 내부 메모리에 접근하는 시간 비용보다 수만에서 수십만 배 이상 비싸다. 따라서 조회한 데이터를 메모리에 캐시해서 데이터베이스 접근 횟수를 줄이면 애플리케이션 성능을 획기적으로 개선할 수 있다.

  • 영속성 컨텍스트 내부에는 엔티티를 보관하는 저장소가 있는데, 이를 1차 캐시라 한다.
    • 일반적인 웹 애플리케이션 환경은 트랜잭션이 시작하고 종료할 때 까지만 1차 캐시가 유효하다.
    • OSIV를 사용해도 클라이언트의 요청이 들어올 때부터 끝날때 까지만 1차 캐시가 유효하다.
  • 데이터베이스 접근 횟수를 획기적으로 줄여주진 못한다.
  • 하이버네이트를 포함한 대부분의 JPA 구현체들은 애플리케이션 범위의 캐시를 지원하는데 이것을 공유 캐시 또는 2차 캐시라 한다.

1차 캐시

  • 1차 캐시는 영속성 컨텍스트 내부에 있다. 엔티니 매니저로 조회하거나 변경하는 모든 엔티티는 1차 캐시에 저장된다.
  • 1차 캐시는 끄고 켤 수 있는 옵션이 아니다. 영속성 컨텍스트 자체가 사실상 1차 캐시다.
  • 1차 캐시의 동작 방식은 아래와 같다.
    • 최초 조회할 때는 1차 캐시에 엔티티가 없다.
    • 데이터베이스에서 엔티티를 조회한다.
    • 엔티티를 1차 캐시에 보관한다.
    • 1차 캐시에 보관된 결과를 반환한다.
    • 이후 같은 엔티티를 조회하면 1차 캐시에 같은 엔티티가 있으므로 데이터베이스를 조회하지 않고 1차 캐시의 엔티티를 그대로 반환한다.
  • 1차 캐시는 아래와 같은 특징이 있다.
    • 1차 캐시는 객체의 동일성 (a == b)를 보장한다.
    • 1차 캐시는 기본적으로 영속성 컨텍스트 범위의 캐시다. 따라서 컨테이너 환경에서는 트랜잭션 범위의 캐시, OSIV를 적용하면 요청 범위의 캐시다.

2차 캐시

  • 애플리케이션에서 공유하는 캐시를 JPA는 공유 캐시(Shared Cache)라 하는데 일반적으로 2차 캐시 (Second Level Cache, L2 Cache)라 부른다. 2차 캐시는 애플리케이션 범위의 캐시다. 따라서 애플리케이션을 종료할 때까지 캐시가 유지된다. 분산 캐시나 클러스터링 환경의 캐시는 애플리케이션보다 더 오래 유지 될 수도 있다.
  • 엔티티 매니저를 통해 데이터를 조회할 때 우선 2차 캐시에서 찾고 없으면 데이터베이스에서 찾는다. 2차 캐시를 적절히 활용하면 데이터베이스 조회 횟수를 획기적으로 줄일 수 있다.
  • 2차 캐시의 동작 방식
    • 영속성 컨텍스트는 엔티티가 필요하면 2차 캐시를 조회한다.
    • 2차 캐시에 엔티티가 없으면 데이터베이스를 조회한다.
    • 결과를 2차 캐시에 보관한다.
    • 2차 캐시는 자신이 보관하고 있는 엔티티를 복사해서 반환한다.
    • 2차 캐시에 저장되어 있는 엔티티를 조회하면 복사본을 만들어 반환한다.
  • 2차 캐시는 동시성을 극대화하기 위해 캐시한 객체를 직접 반환하지 않고 복사본을 만들어서 반환한다.
  • 2차 캐시의 특징
    • 2차 캐시는 영속성 유닛 범위의 캐시다.
    • 2차 캐시는 조회한 객체를 그대로 반환하는 것이 아니라 복사본을 만들어서 반환한다.
    • 2차 캐시는 데이터베이스 기본 키를 기준으로 캐시하지만 영속성 컨텍스트가 다르면 객체 동일성 (a == b)을 보장하지 않는다.

2. JPA 2차 캐시 기능

JPA 구현체 대부분은 캐시 기능을 각자 지원했는데 JPA는 2.0에 와서야 캐시 표준을 정의했다. JPA 캐시 표준은 여러 구현체가 공통으로 사용하는 부분만 표준화해서 세밀한 설정을 하려면 구현체에 의존적인 기능을 사용해야 한다.

캐시 모드 설정

2차 캐시를 사용하려면 아래와 같이 엔티티에 Cacheable 애노테이션을 사용하면 된다. @Cacheable은 @Cacheable(true), @Cacheable(false)를 설정할 수 있는데 기본값은 true다.

@Cacheable
@Entity
public class Member {
	@Id @GeneratedValue
    private Long id;
    
    ...
}

스프링 프레임워크를 사용할 때 설정하는 방법은 아래와 같다.

<bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntitiyManagerFactoryBean">
    <property name="sharedCacheMode" value="ENABLE_SELECTIVE" />
    ...

캐시 모드는 SharedCacheMode에 정의되어 있다. 보통은 ENABLE_SELECTIVE를 사용한다.

  • ALL : 모든 엔티티를 캐시한다.
  • NONE : 캐시를 사용하지 않는다.
  • ENABLE_SELECTIVE : Cacheable(true)로 설정된 엔티티만 캐시한다.
  • DISABLE_SELECTIVE : 모든 엔티티를 캐시하는데 Cacheable(false)로 명시된 엔티티는 캐시하지 않는다.
  • UNSPECIFIED : JPA 구현체가 정의한 설정을 따른다.

캐시 조회, 저장 방식 설정

캐시를 무시하고 데이터베이스를 직접 조회하거나 캐시를 갱신하려면 캐시 조회 모드와 캐시 보관 모드를 사용하면 된다. 캐시 조회 모드나 보관 모드에 따라 사용할 프로퍼티와 옵션이 다르다.

캐시 조회 모드

  • 프로퍼티 : javax.persistence.cache.retrieveMode : 캐시 조회 모드 프로퍼티 이름
  • 옵션 : javax.persistence.CacheRetrieveMode
/**
 * Used as the value of the
 * <code>javax.persistence.cache.retrieveMode</code> property to
 * specify the behavior when data is retrieved by the
 * <code>find</code> methods and by queries.
 *
 * @since Java Persistence 2.0
 */
public enum CacheRetrieveMode {

    /**
     * Read entity data from the cache: this is
     * the default behavior.
     */
    USE,

    /**
     * Bypass the cache: get data directly from
     * the database.
     */
    BYPASS
}
  • USE : 캐시에서 조회한다. (기본값)
  • BYPASS : 캐시를 무시하고 데이터베이스에 직접 접근한다.

캐시 보관 모드

  • 프로퍼티 : javax.persistence.cache.storeMode
  • 옵션 : javax.persistence.CacheStoreMode
/**
 * Used as the value of the
 * <code>javax.persistence.cache.storeMode</code> property to specify
 * the behavior when data is read from the database and when data is
 * committed into the database.
 *
 * @since Java Persistence 2.0
 */
public enum CacheStoreMode {

    /**
     * Insert/update entity data into cache when read
     * from database and when committed into database:
     * this is the default behavior. Does not force refresh
     * of already cached items when reading from database.
     */
    USE,

    /**
     * Don't insert into cache.
     */
    BYPASS,

    /**
     * Insert/update entity data into cache when read
     * from database and when committed into database.
     * Forces refresh of cache for items read from database.
     */
    REFRESH
}
  • USE : 조회한 데이터를 캐시에 저장한다. 조회한 데이터가 이미 캐시에 있으면 캐시 데이터를 최신 상태로 갱신하지는 않는다. 트랜잭션을 커밋하면 등록 수정한 엔티티도 캐시에 저장한다. (기본값이다)
  • BYPASS : 캐시에 저장하지 않는다.
  • REFRESH : USE 전략에 추가로 데이터베이스에 조회한 엔티티를 최신 상태로 다시 캐시한다.

캐시 모드는 EntityManager.setProperty()로 엔티티 매니저 단위로 설정할 수 있다. 혹은 EntityManager.find(), EntityManager.refresh()에 설정할 수 있다. 그리고 Query.setHint()에 사용할 수 있다.

em.setProperty("javax.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS);
em.setProperty("javax.persistence.cache.storeMode", CacheStoreMode.BYPASS);

JPA 캐시 관리 API

JPA는 캐시를 관리하기 위한 javax.persistence.Cache 인터페이스를 제공한다. 이것은 EntityManagerFactory에서 구할 수 있다.

Cache cache = emf.getCache();

boolean contains = cache.contains(TestEntity.class, testEntity.getId());
log.info("contains : {}", contains);

Cache 인터페이스는 아래와 같다.

/**
 * Interface used to interact with the second-level cache.
 * If a cache is not in use, the methods of this interface have
 * no effect, except for <code>contains</code>, which returns false.
 *
 * @since Java Persistence 2.0
 */
public interface Cache {

    /**
     * Whether the cache contains data for the given entity.
     * @param cls  entity class
     * @param primaryKey  primary key
     * @return boolean indicating whether the entity is in the cache
     */
    public boolean contains(Class cls, Object primaryKey);

    /**
     * Remove the data for the given entity from the cache.
     * @param cls  entity class
     * @param primaryKey  primary key
     */
    public void evict(Class cls, Object primaryKey);

    /**
     * Remove the data for entities of the specified class (and its
     * subclasses) from the cache.
     * @param cls  entity class
     */
    public void evict(Class cls);

    /**
     * Clear the cache.
     */
    public void evictAll();

	/**
	 * Return an object of the specified type to allow access to the provider-specific API.
	 *
	 * If the provider's Cache implementation does not support the specified class, the
	 * PersistenceException is thrown.
	 * @param cls the class of the object to be returned. This is normally either the
	 * underlying Cache implementation class or an interface that it implements.
	 * @return an instance of the specified class
	 * @throws PersistenceException if the provider does not support the call
	 */
	public <T> T unwrap(Class<T> cls);
}
  • contains : 해당 엔티티가 캐시에 있는지 확인한다.
  • evict(Class cls, Object primaryKey) : 해당 엔티티중 특정 식별자를 가진 엔티티를 캐시에서 제거
  • evict(Class cls) : 해당 엔티티 전체를 캐시에서 제거
  • evictAll() : 모든 캐시 데이터 제거
  • unwrap(Class<T> cls) : JPA Cache 구현체 조회

하이버네이트 2차 캐시

하이버네이트가 지원하는 캐시는 크게 3가지가 있다.

  • 엔티티 캐시 : 엔티티 단위로 캐시한다. 식별자로 엔티티를 조회하거나 컬렉션이 아닌 연관된 엔티티를 로딩할 때 사용한다.
  • 컬렉션 캐시 : 엔티티와 연관된 컬렉션을 캐시한다. 컬렉션이 엔티티를 담고 있으면 식별자 값만 캐시한다. (하이버네이트 기능)
  • 쿼리 캐시 : 쿼리와 파라미터 정보를 키로 사용해서 캐시한다. 결과가 엔티티면 식별자 값만 캐시한다. (하이버네이트 기능)

엔티티 캐시와 컬렉션 캐시

@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
@Entity
public class ParentMember {
    
    @Id @GeneratedValue
    private Long id;
    
    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
    @OneToMany(mappedBy = "parentMember", cascade = CascadeType.ALL)
    public List<ChildMember> childMembers = new ArrayList<>();
}

@Cache 애노테이션은 하이버네이트 전용이다. 위와 같이 세밀한 설정을 할 때나 컬렉션 캐시를 적용할 때도 사용한다.

@Cache

하이버네이트 전용은 Cache 애노테이션을 사용하면 세밀한 캐시 설정이 가능하다.

  • usage : CacheConcurrencyStrategy를 사용해서 캐시 동시성 전략을 설정한다.
  • region : 캐시 지역 설정
  • include : 연관 객체를 캐시에 포함할지 선택한다. all, non-lazy 옵션을 선택할 수 있다. 기본값은 all이다.
@Target({TYPE, METHOD, FIELD})
@Retention(RUNTIME)
public @interface Cache {
	/**
	 * The concurrency strategy chosen.
	 */
	CacheConcurrencyStrategy usage();

	/**
	 * The cache region name.
	 */
	String region() default "";

	/**
	 * How lazy properties are included in the second level cache. Default value is "all"; other allowable
	 * value: "non-lazy"
	 */
	String include() default "all";
}

중요한 것은 캐시 동시성 전략을 설정할 수 있는 usage 설정이다.

public enum CacheConcurrencyStrategy {
	/**
	 * Indicates no concurrency strategy should be applied.
	 */
	NONE( null ),
	/**
	 * Indicates that read-only strategy should be applied.
	 *
	 * @see AccessType#READ_ONLY
	 */
	READ_ONLY( AccessType.READ_ONLY ),
	/**
	 * Indicates that the non-strict read-write strategy should be applied.
	 *
	 * @see AccessType#NONSTRICT_READ_WRITE
	 */
	NONSTRICT_READ_WRITE( AccessType.NONSTRICT_READ_WRITE ),
	/**
	 * Indicates that the read-write strategy should be applied.
	 *
	 * @see AccessType#READ_WRITE
	 */
	READ_WRITE( AccessType.READ_WRITE ),
	/**
	 * Indicates that the transaction strategy should be applied.
	 *
	 * @see AccessType#TRANSACTIONAL
	 */
	TRANSACTIONAL( AccessType.TRANSACTIONAL );
    
    // ...   
}

  • NONE : 캐시를 설정하지 않는다.
  • READ_ONLY : 읽기 전용으로 설정한다. 등록, 삭제는 가능하지만 수정은 불가능하다. 참고로 읽기 전용인 불변 객체는 수정되지 않으므로 하이버네이트는 2차 캐시를 조회할 때 객체를 복사하지 않고 원본 객체를 반환한다.
  • NONSTRICT_READ_WRITE : 엄격하지 않은 읽고 쓰기 전략이다. 동시에 같은 엔티티를 수정하면 데이터 일관성이 깨질 수 있다. EHCACHE는 데이터를 수정하면 캐시 데이터를 무효화하한다.
  • READ_WRITE : 읽고 쓰기가 가능하도 READ_COMMITTED 정도의 격리 수준을 보장한다. EHCACHE는 데이터를 수정하면 캐시 데이터도 같이 수정한다.
  • TRANSACTIONAL : 컨테이너 관리 환경에서 사용할 수 있다. 설정에 따라 REPEATABLE_READ 정도의 격리 수준을 보장받을 수 있다.

캐시 영역

  • 엔티티 캐시 영역 : 기본값으로 “패키지 명 + 클래스 명”을 사용
  • 컬렉션 캐시 영역 : 엔티티 캐시 영역 이름에 캐시한 컬렉션의 필드 명이 추가
  • 필요하다면 @Cache(region = “customRegion”, …) 처럼 region 속성을 사용해서 캐시 영역을 직접 지정할 수 있음
  • 캐시 영역별로 세부 설정이 가능

쿼리 캐시

  • 쿼리 캐시는 쿼리와 파라미터 정보를 키로 사용해서 쿼리 결과를 캐시하는 방법이다. 쿼리 캐시를 적용하려면 영속성 유닛을 설정에 hibernate.cache.use_query_cache 옵션을 꼭 true로 설정해야 한다.
  • 캐시를 적용하려는 쿼리마다 “org.hibernate.cacheable”을 true로 설정하는 힌트를 주면 된다.
  • hibernate.cache.use_query_cache 옵션을 true로 설정해서 쿼리 캐시를 활성화하면 두 캐시 영역이 추가된다.
    • org.hibernate.cache.internal.StandardQueryCache : 쿼리 캐시를 저장하는 영역이다. 이곳에는 쿼리, 쿼리 결과 집합, 쿼리를 실행한 시점의 타임 스탬프를 보관한다.
    • org.hibernate.cache.spi.UpdateTimestampsCache : 쿼리 캐시가 유효한지 확인하기 위해 쿼리 대상 테이블의 가장 최근 변경 (등록, 수정, 삭제) 시간을 저장하는 영역이다. 이곳에는 테이블 명과 해당 테이블의 최근 변경된 타임스탬프를 보관한다.
  • 쿼리 캐시는 캐시한 데이터 집합을 최신 데이터로 유지하려고 쿼리 캐시를 실행하는 시간과 쿼리 캐시가 사용하는 테이블들이 가장 최근에 변경된 시간을 비교한다. 쿼리 캐시를 적용하고 난 후에 쿼리 캐시가 사용하는 테이블에 조금이라도 변경이 있으면 데이터베이스에서 데이터를 읽어와서 쿼리 결과를 다시 캐시한다.
  • 쿼리 캐시를 잘 활용하면 극적인 성능 향상이 있지만 빈번하게 변경이 있는 테이블을 사용하면 오히려 성능이 더 저하된다. 따라서 수정이 거의 일어나지 않는 테이블에 사용해야 효과를 볼 수 있다.

org.hibernate.cache.spi.UpdateTimestampsCache 쿼리 캐시 영역은 만료되지 않도록 설정해야 한다. 해당 영역이 만료되면 모든 쿼리 캐시가 무효화된다. EHCACHE의 eternal=”true” 옵션을 사용하면 캐시에서 삭제되지 않는다.

쿼리 캐시와 컬렉션 캐시의 주의점

  • 엔티티 캐시를 사용해서 엔티티를 캐시하면 엔티티 정보를 모두 캐시하지만, 쿼리 캐시와 컬렉션 캐시는 결과 집합의 식별자 값만 캐시한다. 그리고 이 식별자 값을 하나씩 엔티티 캐시에서 조회해서 실제 엔티티를 찾는다.
  • 문제는 쿼리 캐시나 컬렉션 캐시만 사용하고 대상 엔티티에 엔티티 캐시를 적용하지 않으면 성능상 심각한 문제가 발생할 수 있다.
    • “select m from Member m” 쿼리를 실행했는데 쿼리 캐시가 적용되어 있다. 결과 집합은 100건이다.
    • 결과 집합에는 식별자만 있으므로 한 건씩 엔티티 캐시 영역에서 조회한다.
    • Member 엔티티는 엔티티 캐시를 사용하지 않으므로 한 건씩 데이터베이스에서 조회한다.
    • 결국 100건의 SQL이 실행된다.
  • 쿼리 캐시나 컬렉션 캐시만 사용하고 엔티티 캐시를 사용하지 않으면 최악의 상황에 결과 집합 수만큼 SQL이 실행된다.
  • 따라서 쿼리 캐시나 컬렉션 캐시를 사용하면 결과 대상 엔티티에는 꼭 엔티티 캐시를 적용해야 한다.

더 나아가기