제목: "4. JPA Cache 적용하기"
작성자: tistory(cla9)
작성자 수정일: 2020년8월5일
링크: https://cla9.tistory.com/100
작성일: 2022년8월16일
캐시란 간단하게 말해서 Key와 Value로 이루어진 Map이라고 불 수 있다
하지만 일반 Map과 다르게 만료시간을 통해 freshness 조절 및 캐시 제거 등을 통해서 공간을 조절할 수 있는 특징이 있다.
그렇다면 캐시 적용을 위해 고려해야할 척도는 무엇이 있을까?
HitRatio는 캐시에 대하여 자원 요청과 비례하여 얼마나 캐시 정보를 획득했는지를 나타내며, 계산 식은 다음과 같다.
HitRatio = hits / (hits + misses) * 100
말 그대로 캐시 없이 App에서 직접 DB로 요청하는 방식을 말한다
별도 캐시한 내역이 없으므로 매번 DB와 통신이 필요하며, 부하가 유발되는 쿼리가 지속되면 DB 서버에도 영향을 준다.
App 기동시 캐시에는 아무런 데이터가 없으며, App이 요청시에 Cache miss가 발생하면, DB로부터 데이터를 읽어와 Cache에 적재한다
이후에 동일한 요청을 반복하면, 캐시에 데이터가 존재하므로 DB조회 없이 바로 데이터를 전달받을 수 있다.
해당 패턴은 Application이 캐시 적재와 관련된 일을 처리하므로, Cache Miss가 발생했을 때 응답시간이 저하될 수 있다.
캐시에 데이터가 없는 상황에서 Miss가 발생했을 때, Application이 아닌 캐시제공자가 데이터를 처리한 다음 Application에게 데이터를 전달하는 방법이다.
즉 기존에는 동기화의 책임이 Application에 있었다면, 해당 패턴은 캐시 제공자에게 책임이 위임된다.
Cache-through 패턴은 다음과 같이 세분화할 수 있다.
데이터 읽기 요청시, 캐시 제공자가 DB와의 연계를 통해 데이터를 적재하고 이를 반환한다.
데이터 쓰기 요청시, Application은 캐시에만 적용을 요청하면, 캐시 제공자가 DB에 데이터를 저장하고, Application에게 응답하는 방식이다.
모든 작업은 동기로 진행된다.
데이터 쓰기 요청시, Application은 데이터를 캐시에만 반영한 다음 요청을 종료한다.
이후 일정 시간을 간격으로 비동기적으로 캐시에서 DB로 데이터를 저장 요청한다.
이 방식은 Application의 쓰기 요청 성능을 높일 수 있으나 만약 캐시에 DB에 저장하기 전에 다운된다면, 데이터 유실이 발생한다.
Ehcache는 Java에서 주로 사용하는 캐시 관련 오픈소스이며, Application에 Embedded되어 간편하게 사용할 수 있는 특징을 지니고 있다.
EhCache3에서는 JSR-107에서 요구하는 표준을 준수하여 만들어졌기 때문에 2 버전과 3 버전 설정 방법이 다르다
자세한 내용은 공식문서 참고
공식 메뉴얼에 따르면, 캐시 중요도에 따라 세군데 영역으로 나뉘어 저장할 수 있다.
Heap Tier
는 GC가 관여할 수 있는 JVM의 Heap영역을 말한다. Off Heap
은 GC에서 접근이 불가능하며 OS가 관리하는 영역이다. -XX:MaxDirectMemorySize
옵션 설정을 통해 충분한 메모리를 확보해야합니다. 예제 프로그램은 Cache-Aside 패턴을 통해 구현하며, 그외 나머지 패턴은 다루지 않는다.
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.ehcache:ehcache:3.10.0'
implementation 'org.hibernate:hibernate-jcache:6.0.2.Final'
implementation 'javax.cache:cache-api:1.1.1'
application.yml
spring:
profiles:
active: local
---
spring:
config:
activate:
on-profile: local
datasource:
url: jdbc:h2:tcp://localhost/~/db/test
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
database-platform: org.hibernate.dialect.H2Dialect
properties:
javax:
persistence:
sharedcache:
mode: ENABLE_SELECTIVE
hibernate:
cache:
use_second_level_cache: true
region:
factory_class: org.hibernate.cache.jcache.internal.JCacheRegionFactory
temp:
use_jdbc_metadata_defaults: false
use_sql_comments: true
show-sql: true
JPA와 관련된 캐시 설정을 한다. 설정 내용 중 캐시와 관련된 옵션을 살펴보자
SharedCache
캐시모드를 설정할 수 있는 옵션으로 enable_selective
는 @Cacheable
이 설정된 엔티티에만 캐시를 적용함을 의미한다.
2차 캐시 활성화 여부를 지정한다.
PersistentContext
를 의미하며, 각 세션 레벨에서 트랜잭션이 진행되는 동안에 캐시된다. SessionFactory
레벨에서의 캐시를 의미하며 모든 세션에게 공유되는 공간이다. 캐시를 구현한 Provider 정보를 지정한다. Ehcache3는 JSR-107 표준을 준수하여 개발되었기 때문에 JCacheRegionFactory를 지정한다.
CachingConfig
package me.dragonappear.replicationdatasource.config;
import org.ehcache.config.builders.CacheConfigurationBuilder;
import org.ehcache.config.builders.ExpiryPolicyBuilder;
import org.ehcache.config.builders.ResourcePoolsBuilder;
import org.ehcache.config.units.EntryUnit;
import org.ehcache.config.units.MemoryUnit;
import org.ehcache.jsr107.Eh107Configuration;
import org.hibernate.cache.jcache.ConfigSettings;
import org.springframework.boot.autoconfigure.cache.JCacheManagerCustomizer;
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.cache.CacheManager;
import java.time.Duration;
@Configuration
@EnableCaching
public class CachingConfig {
public static final String DB_CACHE = "db_cache";
private final javax.cache.configuration.Configuration<Object, Object> jCacheConfiguration;
public CachingConfig() {
this.jCacheConfiguration = Eh107Configuration.fromEhcacheCacheConfiguration(CacheConfigurationBuilder.newCacheConfigurationBuilder(Object.class, Object.class,
ResourcePoolsBuilder.newResourcePoolsBuilder()
.heap(10000, EntryUnit.ENTRIES))
.withSizeOfMaxObjectSize(1000, MemoryUnit.B)
.withExpiry(ExpiryPolicyBuilder.timeToIdleExpiration(Duration.ofSeconds(300)))
.withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofSeconds(600))));
}
@Bean
public HibernatePropertiesCustomizer hibernatePropertiesCustomizer(CacheManager cacheManager) {
return hibernateProperties -> hibernateProperties.put(ConfigSettings.CACHE_MANAGER, cacheManager);
}
@Bean
public JCacheManagerCustomizer cacheManagerCustomizer() {
return cm -> {
cm.createCache(DB_CACHE, jCacheConfiguration);
cm.createCache("account", jCacheConfiguration);
cm.createCache("member", jCacheConfiguration);
};
}
}
Cache Config
클래스를 작성한다. 공식 문서: https://www.ehcache.org/documentation/3.10/getting-started.html#configuring-ehcache
package me.dragonappear.replicationdatasource.entity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import me.dragonappear.replicationdatasource.config.CachingConfig;
import org.hibernate.annotations.CacheConcurrencyStrategy;
import javax.persistence.*;
import java.io.Serializable;
import static javax.persistence.GenerationType.IDENTITY;
import static lombok.AccessLevel.PROTECTED;
@org.hibernate.annotations.Cache(region = CachingConfig.DB_CACHE, usage = CacheConcurrencyStrategy.READ_ONLY)
@Cacheable
@NoArgsConstructor(access = PROTECTED)
@Getter
@Entity
public class Account implements Serializable {
@Id
@GeneratedValue(strategy = IDENTITY)
@Column(name = "account_id")
private Long id;
private String name;
public Account(String name) {
this.name = name;
}
public void changeName(String name) {
this.name = name;
}
}
SharedCache
SharedCache
모드를 enable_selective
로 지정하였으므로, @Cacheable
어노테이션을 추가하여 해당 엔티티를 캐시할 수 있도록 설정하였다. 캐시 제공자내에는 여러 캐시가 존재할 수 있으며, 캐시마다 이름이 부여되어있으므로 region 영역에는 캐시내에서 참조할 캐시이름을 지정한다.
usage
usage
는 캐시와 관련된 동시성 전략을 지정할 수 있다. 만약 ReadOnly가 달린 엔티티에 update를 진행하면 아래와 같은 에외가 발생한다.
이번에는 테스트 목적으로 Entitiy 뿐만 아니라 DTO에도 캐시를 적용해보자.
테스트를 위해 두 대상은 다른 캐시영역에 저장되며, 두 캐시영역간 설정에 차이를 두겠다.
@Configuration
public class CachingConfig {
public static final String DB_CACHE = "db_cache";
public static final String USER_CACHE = "user_cache";
...(중략)...
@Bean
public JCacheManagerCustomizer cacheManagerCustomizer() {
return cm -> {
cm.createCache(DB_CACHE, jcacheConfiguration);
cm.createCache(USER_CACHE, Eh107Configuration.fromEhcacheCacheConfiguration(CacheConfigurationBuilder.newCacheConfigurationBuilder(Long.class, CustomerDTO.class,
ResourcePoolsBuilder.newResourcePoolsBuilder()
.heap(10000, EntryUnit.ENTRIES))
.withSizeOfMaxObjectSize(1000, MemoryUnit.B)
.withExpiry(ExpiryPolicyBuilder.timeToIdleExpiration(Duration.ofSeconds(10)))
.withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofSeconds(20)))));
};
}
}
Configuration
클래스를 수정했다.USER_CACHE
는 만료시간을 훨씬 짧게하여 최장 20초간만 캐시에 저장되도록 설정하였다.@Service
@Slf4j
public class CustomerService {
...(중략)...
@Cacheable(value = CachingConfig.USER_CACHE, key ="#id")
public CustomerDTO getCustomer(Long id) {
log.info("getCustomer from db ");
Customer customer = repository.findById(id).orElseThrow(IllegalAccessError::new);
return CustomerDTO.of(customer);
}
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/customers")
@Slf4j
public class CustomerController {
...(중략)...
@GetMapping("/{id}")
public CustomerDTO getCustomer(@PathVariable Long id) {
log.info("Controller 영역");
return service.getCustomer(id);
}
}
최초 기동 후에는 모든 캐시에 정보가 없으므로 Controller -> Service -> DB 순으로 호출되어 데이터가 캐싱되었음을 확인할 수 있다.
이후에는 Service Layer의 DTO 또한 캐시되었으므로 지속 호출시에 Controller 로그는 출력되나 Service 레벨 메소드는 호출되지 않는다.
20초가 지난 이후에는 Service Layer에 지정된 DTO 캐시가 만료되므로 Entity에 접근하나 해당 캐시는 아직 유효하므로 별도 DB 통신 없이 캐시에서 데이터를 반환하는 것을 확인할 수 있다.