스프링부트 JPA EhCache 3 - 적용

dragonappear·2022년 8월 17일
0

Cache

목록 보기
5/5

출처

제목: "4. JPA Cache 적용하기"
작성자: tistory(cla9)
작성자 수정일: 2020년8월5일
링크: https://cla9.tistory.com/100
작성일: 2022년8월16일

1. Cache 적용 기준

캐시란 간단하게 말해서 Key와 Value로 이루어진 Map이라고 불 수 있다
하지만 일반 Map과 다르게 만료시간을 통해 freshness 조절 및 캐시 제거 등을 통해서 공간을 조절할 수 있는 특징이 있다.

그렇다면 캐시 적용을 위해 고려해야할 척도는 무엇이 있을까?

1) 얼마나 자주 사용하는가

  • 위 그림은 파레토 법칙을 표현한다
  • 즉, 시스템 리소스 20프로가 전체 시간의 80프로를 소요함을 의미한다
  • 따라서 캐시 대상을 선정할 때에는 캐시 오브젝트가 얼마나 자주 사용되는지, 적용시 전체적인 성능을 대폭 개선할 수 있는지 등을 따져야 한다.

2) HitRatio

HitRatio는 캐시에 대하여 자원 요청과 비례하여 얼마나 캐시 정보를 획득했는지를 나타내며, 계산 식은 다음과 같다.

HitRatio = hits / (hits + misses) * 100

  • 캐시공간은 한정된 공간이기 때문에, 만료시간을 설정하여 캐시 유지시간을 설정할 수 있다.
  • misses가 높다는 것은 캐시공간의 여유가 없어 이미 캐시에서 밀려났거나, 혹은 자주 사용하지 않는 정보를 캐시하여 만료시간이 지난 오브젝트를 획득하고자할 때 발생할 수 있다.
  • 따라서 캐시를 설정할 때는 캐시 공간의 크기 및 만료 시간을 고려해야한다.

2. Cache 패턴

  • 이번에는 캐시에 적용되는 패턴에 대해서 알아보자

1. NoCaching

말 그대로 캐시 없이 App에서 직접 DB로 요청하는 방식을 말한다
별도 캐시한 내역이 없으므로 매번 DB와 통신이 필요하며, 부하가 유발되는 쿼리가 지속되면 DB 서버에도 영향을 준다.

2. Cache-aside

App 기동시 캐시에는 아무런 데이터가 없으며, App이 요청시에 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 버전 설정 방법이 다르다

자세한 내용은 공식문서 참고

공식 메뉴얼에 따르면, 캐시 중요도에 따라 세군데 영역으로 나뉘어 저장할 수 있다.

  • 먼저 Heap Tier는 GC가 관여할 수 있는 JVM의 Heap영역을 말한다.
  • 반면, Off Heap은 GC에서 접근이 불가능하며 OS가 관리하는 영역이다.
    • 해당 영역에 데이터를 저장하려면, -XX:MaxDirectMemorySize 옵션 설정을 통해 충분한 메모리를 확보해야합니다.
  • 마지막 영역은 Disk 영역으로 해당 설명은 Skip 하겠다.

예제 프로그램은 Cache-Aside 패턴을 통해 구현하며, 그외 나머지 패턴은 다루지 않는다.

1. EhCache3 적용

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이 설정된 엔티티에만 캐시를 적용함을 의미한다.

  • 만약 모든 엔티티에 적용하려면 all 옵션을 줄 수 있다.

use_second_level_cache

2차 캐시 활성화 여부를 지정한다.

  • JPA에서 1차 캐시는 PersistentContext를 의미하며, 각 세션 레벨에서 트랜잭션이 진행되는 동안에 캐시된다.
  • 반면 2차 캐시는 SessionFactory 레벨에서의 캐시를 의미하며 모든 세션에게 공유되는 공간이다.
    • 해당 옵션을 통해서 2차 캐시 설정 여부를 지정한다.

factory_class

캐시를 구현한 Provider 정보를 지정한다. Ehcache3는 JSR-107 표준을 준수하여 개발되었기 때문에 JCacheRegionFactory를 지정한다.

2. Configuration 설정

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 클래스를 작성한다.
  • 먼저 생성자를 통해 캐시의 기본 설정을 구성하였다.
  • 위 구성은 테스트를 위해 임의로 지정하였으며, 커스터마이징하여 작성 가능하다.

지정된 옵션 설명

  • 총 10000개의 Entity를 저장할 수 있으며, 각 오브젝트 사이즈는 1000 Byte를 넘지 않도록 제한하였다.
  • Object는 최초 캐시에 입력후 600초 동안 저장되며, 만약 마지막으로 캐시 요청이후에 300초동안 재요청이 없을 경우 만료되도록 지정하였다.

공식 문서: 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 는 캐시와 관련된 동시성 전략을 지정할 수 있다.
  • 지정할 수 있는 옵션으로는 NONE, READ_ONLY, NONSTRICT_READ_WRITE, READ_WRITE, TRANSACTIONAL 총 5가지 이다.
  • 예제 프로그램에서는 읽기 전용으로만 지정하기 위해서 READ_ONLY 옵션을 부여했다.

만약 ReadOnly가 달린 엔티티에 update를 진행하면 아래와 같은 에외가 발생한다.

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);
    }
}
  • 최초 기동 후에는 모든 캐시에 정보가 없으므로 Controller -> Service -> DB 순으로 호출되어 데이터가 캐싱되었음을 확인할 수 있다.

  • 이후에는 Service Layer의 DTO 또한 캐시되었으므로 지속 호출시에 Controller 로그는 출력되나 Service 레벨 메소드는 호출되지 않는다.

  • 20초가 지난 이후에는 Service Layer에 지정된 DTO 캐시가 만료되므로 Entity에 접근하나 해당 캐시는 아직 유효하므로 별도 DB 통신 없이 캐시에서 데이터를 반환하는 것을 확인할 수 있다.

0개의 댓글