서론
이번 포스팅에서는 JPA에 Cache 적용방법에 대해서 다루어보겠습니다. 먼저 Cache 선정 기준 및 패턴에 대한 소개 및 적용 방법을 설명합니다. Cache로는 Ehcache3을 적용하며, Spring Actuator를 통해서 캐시 Metric 변화도 함께 살펴보겠습니다.
1. Cache 적용 기준
캐시란 간단하게 말해서 Key와 Value로 이루어진 Map이라고 볼 수 있습니다.
하지만 일반 Map과는 다르게 만료 시간을 통해 freshness 조절 및 캐시 제거 등을 통해서 공간을 조절할 수 있는 특징이 있습니다.
그렇다면 캐시 적용을 위해 고려해야할 척도는 무엇이 있을까요?
1. 얼마나 자주 사용하는가?
위 그림은 파레토 법칙을 표현합니다. 즉 시스템 리소스 20%가 전체 전체 시간의 80% 정도를 소요함을 의미합니다. 따라서 캐시 대상을 선정할 때에는 캐시 오브젝트가 얼마나 자주 사용하는지, 적용시 전체적인 성능을 대폭 개선할 수 있는지 등을 따져야합니다.
2. HitRatio
HitRatio는 캐시에 대하여 자원 요청과 비례하여 얼마나 캐시 정보를 획득했는지를 나타내며, 계산 식은 다음과 같습니다.
HitRatio = hits / (hits + misses) * 100
캐시공간은 한정된 공간이기 때문에, 만료시간을 설정하여 캐시 유지시간을 설정할 수 있습니다. misses가 높다는 것은 캐시공간의 여유가 없어 이미 캐시에서 밀려났거나, 혹은 자주 사용하지 않는 정보를 캐시하여 만료시간이 지난 오브젝트를 획득하고자할 때 발생할 수 있습니다. 따라서 캐시를 설정할 때는 캐시 공간의 크기 및 만료 시간을 고려해야합니다.
2. Cache 패턴
이번에는 캐시에 적용되는 패턴에 대해서 알아보도록 하겠습니다.
1. No Caching
말 그대로 캐시없이 Application에서 직접 DB로 요청하는 방식을 말합니다. 별도 캐시한 내역이 없으므로 매번 DB와의 통신이 필요하며, 부하가 유발되는 SQL이 지속 수행되면 DB I/O에 영향을 줍니다.
2. Cache-aside
Application 기동시 캐시에는 아무 데이터가 없으며, Application이 요청시에 Cache Miss가 발생하면, DB로부터 데이터를 읽어와 Cache에 적재합니다. 이후에 동일한 요청을 반복하면, 캐시에 데이터가 존재하므로 DB 조회 없이 바로 데이터를 전달받을 수 있습니다.
해당 패턴은 Application이 캐시 적재와 관련된 일을 처리하므로, Cache Miss가 발생했을 때 응답시간이 저하될 수 있습니다.
3. Cache-through
캐시에 데이터가 없는 상황에서 Miss가 발생했을 때, Application이 아닌 캐시제공자가 데이터를 처리한 다음 Application에게 데이터를 전달하는 방법입니다. 즉 기존에는 동기화의 책임이 Application에 있었다면, 해당 패턴은 캐시 제공자에게 책임이 위임됩니다.
Cache-through 패턴은 다음과 같이 세분화할 수 있습니다.
Read-through
데이터 읽기 요청시, 캐시 제공자가 DB와의 연계를 통해 데이터를 적재하고 이를 반환합니다.
Write-through
데이터 쓰기 요청시, Application은 캐시에만 적용을 요청하면, 캐시 제공자가 DB에 데이터를 저장하고, Application에게 응답하는 방식입니다. 모든 작업은 동기로 진행됩니다.
Write-behind
데이터 쓰기 요청시, Application은 데이터를 캐시에만 반영한 다음 요청을 종료합니다. 이후 일정 시간을 간격으로 비동기적으로 캐시에서 DB로 데이터를 저장요청합니다. 이 방식은 Application의 쓰기 요청 성능을 높일 수 있으나 만약 캐시에 DB에 저장하기 전에 다운된다면, 데이터 유실이 발생합니다.
3. EhCache
Ehcache는 Java에서 주로 사용하는 캐시 관련 오픈소스이며, Application에 Embedded되어 간편하게 사용할 수 있는 특징을 지니고 있습니다. EhCache3에서는 JSR-107에서 요구하는 표준을 준수하여 만들어졌기 때문에 2 버전과 3 버전 설정 방법이 다릅니다.
Ehcache에서는 이전에 설명한 캐시 패턴을 모두 적용할 수 있습니다. 그 중 Cache-through 전략은 CacheLoaderWriter 인터페이스 구현을 통해서 적용할 수 있으나 해당 내용에 대해서는 다루지 않겠습니다.
자세한 설명은 공식 홈페이지를 참고바랍니다.
공식 메뉴얼에 따르면, 캐시 중요도에 따라 세군데 영역으로 나뉘어 저장할 수 있습니다. 먼저 Heap Tier는 GC가 관여할 수 있는 JVM의 Heap영역을 말합니다. 반면, Off Heap은 GC에서 접근이 불가능하며 OS가 관리하는 영역입니다. 해당 영역에 데이터를 저장하려면, -XX:MaxDirectMemorySize 옵션 설정을 통해 충분한 메모리를 확보해야합니다. 마지막 영역은 Disk 영역으로 해당 설명은 Skip 하겠습니다.
그럼 지금부터 지금까지 JPA 포스팅하면서 다룬 예제를 확장하여 EhCache를 적용하겠습니다. 예제 프로그램은 Cache-Aside 패턴을 통해 구현하며, 그외 나머지 패턴은 다루지 않겠습니다.
1. EhCache3 적용
build.gradle
...(중략)...
dependencies {
...(중략)...
//cache
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation group: 'javax.cache', name: 'cache-api', version: '1.1.1'
implementation group: 'org.hibernate', name: 'hibernate-jcache', version: '5.4.19.Final'
implementation group: 'org.ehcache', name: 'ehcache', version: '3.8.1'
}
...(중략)...
application.yaml
spring:
jpa:
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
format_sql: true
show_sql: true
use_sql_comments: true
hibernate:
ddl-auto: none
database-platform: org.hibernate.dialect.Oracle10gDialect
JPA와 관련된 캐시 설정을 합니다. 설정 내용 중 캐시와 관련된 옵션을 살펴보겠습니다.
먼저 SharedCache는 캐시모드를 설정할 수 있는 옵션으로 enable_selective는 @Cacheable이 설정된 엔티티에만 캐시를 적용함을 의미합니다. 만약 모든 엔티티에 적용하려면 all 옵션을 줄 수 있습니다.
use_second_level_cache는 2차 캐시 활성화 여부를 지정합니다. JPA에서 1차 캐시는 PersistentContext를 의미하며, 각 세션레벨에서 트랜잭션이 진행되는 동안에 캐시됩니다. 반면 2차 캐시는 SessionFactory 레벨에서의 캐시를 의미하며 모든 세션에게 공유되는 공간입니다. 해당 옵션을 통해서 2차 캐시 설정 여부를 지정합니다.
factory_class는 캐시를 구현한 Provider 정보를 지정합니다. Ehcache3는 JSR-107 표준을 준수하여 개발되었기 때문에 JCacheRegionFactory를 지정합니다.
2. Configuration 설정
@Configuration
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(javax.cache.CacheManager cacheManager) {
return hibernateProperties -> hibernateProperties.put(ConfigSettings.CACHE_MANAGER, cacheManager);
}
@Bean
public JCacheManagerCustomizer cacheManagerCustomizer() {
return cm -> {
cm.createCache(DB_CACHE, jcacheConfiguration);
};
}
}
Cache Config 클래스를 작성합니다. 먼저 생성자를 통해 캐시의 기본 설정을 구성했습니다. 위 구성은 테스트를 위해 임의로 지정하였으며, 커스터마이징하여 작성 가능합니다.
지정된 옵션 설명은 다음과 같습니다.
총 10000개의 Entity를 저장할 수 있으며, 각 오브젝트 사이즈는 1000 Byte를 넘지 않도록 제한하였습니다. Object는 최초 캐시에 입력후 600초 동안 저장되며, 만약 마지막으로 캐시 요청이후에 300초동안 재요청이 없을 경우 만료되도록 지정하였습니다.
자세한 설정 방법은 공식 문서를 참고 바랍니다.
@Entity
@Table
@Getter
@Cacheable
@org.hibernate.annotations.Cache(region = CachingConfig.DB_CACHE, usage = CacheConcurrencyStrategy.READ_ONLY)
public class Customer {
@Id
@GeneratedValue
@Column(name = "id")
private Long customerId;
@Column(name = "name")
private String customerName;
}
SharedCache 모드를 enable_selective로 지정하였으므로, @Cacheable 어노테이션을 추가하여 해당 엔티티를 캐시할 수 있도록 설정하였습니다. 캐시 제공자내에는 여러 캐시가 존재할 수 있으며, 캐시마다 이름이 부여되어있으므로 region영역에는 캐시내에서 참조할 캐시이름을 지정합니다.
usage는 캐시와 관련된 동시성 전략을 지정할 수 있습니다. 지정할 수 있는 옵션으로는 NONE, READ_ONLY, NONSTRICT_READ_WRITE, READ_WRITE, TRANSACTIONAL 총 5가지 입니다. 예제 프로그램에서는 읽기 전용으로만 지정하기 위해서 READ_ONLY 옵션을 부여했습니다.
@Service
@Slf4j
public class CustomerService {
...(중략)...
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")
public class CustomerController {
...(중략)...
@GetMapping("/{id}")
public CustomerDTO getCustomer(@PathVariable Long id) {
return service.getCustomer(id);
}
}
테스트를 위해 Service 및 Controller에 관련 메소드를 생성합니다.
2020-08-05 23:09:30.493 INFO 1872 --- [nio-8080-exec-1] c.e.paging_demo.service.CustomerService : getCustomer from db
Hibernate:
select
customer0_.id as id1_0_0_,
customer0_.name as name2_0_0_
from
customer customer0_
where
customer0_.id=?
2020-08-05 23:09:40.278 INFO 1872 --- [nio-8080-exec-2] c.e.paging_demo.service.CustomerService : getCustomer from db
2020-08-05 23:09:51.598 INFO 1872 --- [nio-8080-exec-3] c.e.paging_demo.service.CustomerService : getCustomer from db
2020-08-05 23:09:52.384 INFO 1872 --- [nio-8080-exec-4] c.e.paging_demo.service.CustomerService : getCustomer from db
실행 후 로그 출력 결과입니다. 최초 요청시에는 SQL 수행결과가 로그에 기록되었지만, 그 이후에는 SQL이 수행되지 않고 2차 캐시에서 정상적으로 가져온 것을 확인할 수 있습니다.
3. DTO 레벨 캐시 추가 설정
이번에는 테스트 목적으로 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);
}
}
Service 클래스에 @Cacheable 어노테이션을 추가하여 캐시를 지정합니다. 이때 key는 파라미터로 전달받은 id를 지정하며, SPEL을 사용할 수 있습니다.
Controller 클래스에는 별다른 로직 추가 없이 로그만 남기도록 수정했습니다. 이제 어플리케이션을 재기동 후 결과를 보겠습니다.
2020-08-05 23:18:53.012 INFO 13244 --- [nio-8080-exec-1] c.e.p.controller.CustomerController : Controller 영역
2020-08-05 23:18:53.055 INFO 13244 --- [nio-8080-exec-1] c.e.paging_demo.service.CustomerService : getCustomer from db
Hibernate:
select
customer0_.id as id1_0_0_,
customer0_.name as name2_0_0_
from
customer customer0_
where
customer0_.id=?
2020-08-05 23:18:57.093 INFO 13244 --- [nio-8080-exec-2] c.e.p.controller.CustomerController : Controller 영역
2020-08-05 23:18:57.821 INFO 13244 --- [nio-8080-exec-3] c.e.p.controller.CustomerController : Controller 영역
2020-08-05 23:19:03.127 INFO 13244 --- [nio-8080-exec-4] c.e.p.controller.CustomerController : Controller 영역
2020-08-05 23:19:29.327 INFO 13244 --- [nio-8080-exec-6] c.e.p.controller.CustomerController : Controller 영역
2020-08-05 23:19:29.329 INFO 13244 --- [nio-8080-exec-6] c.e.paging_demo.service.CustomerService : getCustomer from db
최초 기동 후에는 모든 캐시에 정보가 없으므로 Controller -> Service -> DB 순으로 호출되어 데이터가 캐싱되었음을 확인할 수 있습니다.
이후에는 Service Layer의 DTO 또한 캐시되었으므로 지속 호출시에 Controller 로그는 출력되나 Service 레벨 메소드는 호출되지 않습니다.
20초가 지난 이후에는 Service Layer에 지정된 DTO 캐시가 만료되므로 Entity에 접근하나 해당 캐시는 아직 유효하므로 별도 DB 통신 없이 캐시에서 데이터를 반환하는 것을 확인할 수 있습니다.
지금까지 캐시 적재에 대해서만 살펴봤는데, 만약 수정, 삭제등으로 인해 캐시를 삭제해야한다면 @CacheEvict 어노테이션을 통해 삭제할 수 있습니다.
4. Actuator 적용
Spring Actuator 프로젝트를 사용하게되면, 모니터링에 필요한 유용한 Metric 뿐만 아니라 HealthCheck 및 Dump 생성 그리고 Reload 등이 가능합니다. Spring Actuator를 통해서 Cache와 관련된 Metric을 활용해 Prometheus & Grafana 대시보드로 시각화 또한 가능합니다.
이번에는 Actuator 적용 후 URL 호출을 통해 Metric을 확인하는 방법에 대해서 살펴보겠습니다.
1. Actuator 설정
build.gradle
...(중략)...
dependencies {
...(중략)...
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
먼저 actuator 관련 의존성을 추가합니다.
application.yaml
management:
endpoints:
health:
show-details: always
web:
exposure:
include: "*"
metrics:
cache:
instrument: true
해당 설정을 통해 actuator 기능을 활성화합니다.
2. 적용 확인
프로그램 기동 후 ip:port/actuator URL을 호출하면 위와 같이 상세 정보를 볼 수 있는 URL이 제공됩니다.
Cache 관련 정보는 metrics 하위에 위 표시된 영역으로 정보를 확인할 수 있습니다.
위 그림은 gets과 관련된 metric 정보이며, 총 7번의 get 요청이 있었음을 확인할 수 있습니다. 그 밖에 다른 정보들도 제공되는 URL을 통해 정보 확인이 가능합니다.
마치며
이번 포스팅에서는 JPA 캐시 적용에 대해서 알아봤습니다. 일반적으로 코드성 데이터나 공지사항과 같이 수정/삭제가 거의 없고 자주 사용되는 데이터에 대해서 캐시를 적용하면, 좋은 효과를 볼 수 있습니다. 하지만 무턱대고 적용했다가는 오히려 성능 저하가 발생될 수 있으니 전략 수립이 필요합니다.
이번 포스팅에서는 각 Application 내부에서만 유효한 Local Cache에 대해서 다루었습니다. EhCache가 Clustering을 지원하지만, Application Scale Out에 영향을 주기때문에 개인적으로는 가급적 Local Cache로써 사용하고 캐시 무효화 시간을 짧게 가지는 것이 좋다고 생각합니다. 만약 캐시간의 동기화가 필요하다면 Clustering을 고려하거나 Redis와 같은 Third 캐시를 추가로 두는 것을 고려해볼 수 있습니다.
'JAVA > JPA' 카테고리의 다른 글
3. JPA Hateoas 적용하기 (8) | 2020.08.01 |
---|---|
2. JPA Sort 조건 개선 (2) | 2020.07.30 |
1. JPA 페이징 쿼리 개선 (0) | 2020.07.29 |