반응형

Ehcache 3 버전을 Spring Boot에 적용해보겠다. Ehcache 3 버전부터는 JSR-107 cache manager를 구현했다고 한다. 참고로 Spring 4.1버전부터 JSR-107 annotations을 강력하게 지원해주기 시작했다. EhCache 2.x 버전과 3.x 버전의 환경설정 포맷 및 사용방법이 조금 달라졌다. 다들 사용중인 버전에 맞추어 환경설정하기를 바란다. (tti, ttl, expiry 등 캐시 유지기간에 대한 속성을 적용하려면 버전과 환경설정이 일치해야한다.)

다음과 같은 목차로 포스팅을 구성하였다.

  • Ehcache 적용하기

    1. 의존성 등록

    1. Spring cache management 활성화(@EnableCache)

    1. Ehcache 캐시 설정

    1. 캐시 적용 (내부에서 호출되는 경우)

    1. 캐시 간단 검증

  • 파라미터가 DTO와 같은 객체인 경우

 


Ehcache 적용하기

개발환경 Spring Boot 2.2.6 Gradle 5.6.4

 

1. 의존성 등록

build.gradle

// cache
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.ehcache:ehcache:3.8.0'
implementation 'javax.cache:cache-api:1.0.0' // expiry 기능을 위해 필요

 

2. Spring cache management 활성화(@EnableCache)

Spring cache management를 사용하기 위해서 Spring Bean에 @EnableCaching을 추가해야 한다. 아래와 같이 본인의 웹 어플리케이션을 실행하는 메인 메서드에 추가해도 된다.

 

Application.java

@EnableCaching // <-- 이거
@EnableJpaAuditing
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}

 

혹은 별도로 Configuration 클래스를 만들어 적용할 수도 있다. Spring의 auto-configuration이 CacheConfig 클래스를 찾아서 JSR-107을 구현한다.

CacheConfig.java

@Configuration
@EnableCaching
public class CacheConfig {
}

 

@EnableCaching어노테이션을 추가한다고 해서 캐시가 자동으로 적용되지 않는다. 단순히 spring에서 관리하는 cache management를 사용할 수 있게 활성화만 했을 뿐이다.

Spring이나 Ehcache는 어플리케이션 내에 ehcache.xml 파일이 존재하는지 찾는다. ehcache.xml 을 생성하고, Spring에 ehcache.xml 파일의 위치를 알려주자.

 

3. Ehcache 3 캐시 환경설정

application.yml 파일과 동일한 위치에 ehcache.xml 을 생성하였다. 환경설정에는 프로그래밍 방식과 xml 환경설정 방식이 있다. 나는 xml 방식으로 환경설정을 했고, 포맷은 ehcache3 에 맞추어 했다. (버전에 따라 xml 환경설정 방법이 다르다.)

- 버전2와 버전3 환경설정 공식문서 : https://stackoverflow.com/questions/36958656/ehcache-simple-example-with-time-to-live

ehcache.xml 에는 캐시의 이름, 유지 시간, 용량 등을 설정할 수 있다.

application.yml과 동일한 위치에 파일을 생성했다.

ehcache.xml

<config
        xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
        xmlns='http://www.ehcache.org/v3'
        xmlns:jsr107="http://www.ehcache.org/v3/jsr107"
        xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core.xsd
        http://www.ehcache.org/v3/jsr107 http://www.ehcache.org/schema/ehcache-107-ext-3.0.xsd">

    <service>
        <jsr107:defaults enable-management="true" enable-statistics="true"/>
    </service>
<!--여기부터 -->
    <cache-template name="myDefaultTemplate">
        <expiry>
            <ttl unit="seconds">600</ttl>
        </expiry>
        <heap>20</heap>
    </cache-template>

    <cache alias="getRecordCompleteCache" uses-template="myDefaultTemplate">
    </cache>
    
<!--여기까지 사용자 환경설정 -->
</config>

<cache-template> : 공통적으로 캐시에 적용할 내용 (먼저 적용되고, 그 후에 캐시별로 정의된 속성이 정의된다)

<expiry> : 기존 ehcache 2.x 버전에서 캐시 유지 시간을 관리하는 tti, ttl을 표현하는 속성이다. 현재는 tti과 ttl을 함께 사용할 수는 없고, 원한다면 클래스를 새로 추가해서 두 속성 모두 사용되게 하면 된다. ttl, tti의 기본 단위는 seconds이다(가독성을 위해 디폴트값을 명시적으로 표기했다)

<cache> : 캐시 정의. 기존 name 대신 alias라는 속성으로 캐시 이름을 정의한다.

<heap> : 메모리 크기 정의

 

EhCache 2.x 버전 환경설정 간단 예시

더보기

//ehcache.xml

여기에 옮겨야해 !@@

ehcache.xml

<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd"
         updateCheck="false">
    <diskStore path="java.io.tmpdir" />

    <cache name="getRecordCompleteCache"
           maxEntriesLocalHeap="5000"
           maxEntriesLocalDisk="1000"
           eternal="false"
           diskSpoolBufferSizeMB="20"
           timeToIdleSeconds="180" timeToLiveSeconds="360"
           memoryStoreEvictionPolicy="LRU"
           transactionalMode="off">
        <persistence strategy="localTempSwap" />
    </cache>
</ehcache>

환경설정 내용

  • name : 캐시의 이름 (getRecordCompleteCache)
  • timeToLiveSeconds : 캐시가 소멸됨 (360초 뒤에 캐시 소멸)
  • timeToIdleSeconds : 일정 시간동안 캐시가 사용되지 않으면 소멸됨 (180초 동안 캐시가 사용되지 않으면 소멸)

 

캐시 설정을 할 때에는 서버의 성능, 데이터가 정확성에 얼마나 민감한지, 변화의 빈도, 호출 빈도에 따라서 시간과 용량을 적절하게 선택하면 된다 (하지만 쉽지 않다). 일단 캐시를 하는 것이 적절한 지부터 판단해야 한다.

캐시 사용이 고려되는 경우

  • 반복적으로 동일한 결과를 돌려주는 작업
  • 작업 시간이 오래 걸리거나 서버에 부담을 주는 작업 (외부API, DB 조회 등)

캐시 사용을 자제해야 하는 경우 (데이터 특성을 살펴보자)

  • 데이터의 정확성에 민감한 경우 (도메인 특성)
  • 데이터의 변화가 자주 일어나는 경우

예를 들어 해당 데이터가 결제, 쿠폰 적용 등 실시간으로 변화되고 민감한 데이터의 경우 (외부와의 연계로 오래 걸릴지라도) 캐싱을 하는 것은 적절하지 않다. 그리고 캐시 서버가 따로 없다면, 캐시를 자주 하는 것은 오히려 application 서버에 부담이 될 수 있다. 이 경우 서버가 다운되어 서비스가 중단을 야기할 수 있다.

현재 개발 중인 프로젝트에서 원활한 테스트를 위해 캐싱 시간을 1시간으로 잡았다. 하지만 실제 운영에서 이렇게 긴 시간을 캐싱하는 것은 일반적이지는 않다. 도메인과 데이터 특성에 따라 다르겠지만 실제 B2C 서비스에서는 분 단위로 캐싱을 적용하는 것 같다.

 

이제 Spring에게 ehcache.xml 파일의 위치를 알려주자.

spring.cache.jcache.config=classpath:ehcache.xml 를 yml 형식으로 표현했다.

application.yml

spring:
  profiles:
    ...
  servlet:
     ...
  cache: <-- 여기부터
    jcache:
      config: classpath:ehcache.xml <-- 여기까지

 

4. 캐시 적용

캐싱을 하고 싶은 메소드에 @Cacheable을 적용하면 Spring AOP에서 캐싱한다. @Cacheable 어노테이션의 결과로, Spring은 해당 메소드가 있는 클래스의 proxy를 만들어서 해당 메소드가 호출되었을 때 intercept하고 Ehcache를 호출하는 것이다.

외부에서만 호출하는 경우 메소드는 아래와 같이 사용하면 된다.

@Service
public class PredictionService {

    @Cacheable(cacheNames = "getRecordCompleteCache")
    public HealthCheckupRecord getRecordComplete(Long memberId, String checkupDate) {
        //...
    }

@Cacheable의 인자로는 cacheNames(혹은 value)keycondition을 지정할 수 있다. 2.x 버전에서는 value라고 표현했으나, 3.x버전에서는 cacheNames로 표현한다. 웬만하면 3버전의 이름을 사용하자.

이 세 개의 인자에 대해서 설명하자면 아래와 같다.

  • cacheNames(혹은 value)는 ehcache.xml에서 등록했던 캐시 중 메서드에 적용할 캐시의 이름(ehcache.xml의 <cache>에 등록했던 이름)을 등록한다.
  • key는 캐시를 구분하기 위한 값이다. 만약 메소드의 모든 파라미터를 key로 사용하는 경우 굳이 명시할 필요는 없다. 이 때 KeyGenerator가 key를 생성 전략은 공식 문서를 참고한다.
  • condition은 캐싱할 조건을 정할 수 있다. 예를 들어 어떤 파라미터의 값이 1 이상 혹은 길이가 10 이하인 것 들만 캐싱을 하도록 조건 설정이 가능하다. 자세한 내용은 공식 문서를 참고한다.

 

그렇다면 내부에서 메서드를 호출하면 어떨까?

해당 메소드를 내부에서 call하게 되면(self-invocation) proxy interceptor를 타지 않고 바로 메소드를 호출하기 때문에 결국 캐싱이 되지 않는 다는 것이다.

캐시와 트랜잭션 처리는 Spring AOP를 활용해서 제공되는 기능이다. 그래서 Spring AOP 특성이나 제약을 그대로 이어받는다. Spring AOP는 proxy를 통해 이루어지는데, self-invocation(동일 클래스 내 호출) 상황에서는 Proxy 객체가 아닌 this를 이용해 메소드를 호출한다.

In proxy mode (the default), only external method calls coming in through the proxy are intercepted. This means that self-invocation (in effect, a method within the target object that calls another method of the target object) does not lead to actual caching at runtime even if the invoked method is marked with @Cacheable. Consider using the aspectj mode in this case. Also, the proxy must be fully initialized to provide the expected behavior, so you should not rely on this feature in your initialization code (that is, @PostConstruct).

참고글 : https://docs.spring.io/spring/docs/current/spring-framework-reference/integration.html#cache

추가 주의사항으로는 public, final이 적용된 메서드에서는 동작하지 않는다는 것이다. 하고 싶다면 AspectJ라는 AOP를 지원해주는 라이브러리를 사용해야한다.

 

self-invocation 상황에서 캐시를 적용하려면 두 가지 방법이 있다.

  1. AspectJ를 사용 (권장) ⇒ 공부 후 포스팅 예정

    Spring AOP는 쉽게 사용할 수 있도록 제공되지만, proxy class 단위로 intercept하기 때문에 제한이 존재한다. 대부분의 상황에서는 Spring AOP로 커버 가능하다. 반면에 AspectJ는 사용하기는 까다롭지만 완벽하게 AOP를 구현할 수 있도록 만들어놓은 라이브러리이다.

  1. proxy class를 탈 수 있도록 술수 부리기(ㅎㅎ) 
    • Spring AOP 프록시 객체를 얻어서 호출
    • ApplicationContext에서 getBean("내가원하는빈")하여 캐싱 메소드 호출
    • @Resource 어노테이션으로 self-autowiring (스프링 4.3부터 지원)
    • 캐싱 메서드가 있는 Bean의 Scope를 'prototype'으로 생성

     

AspectJ를 써야하는 이유

1. Spring에서 사실상 표준(?)으로 Spring AOP로 커버할 수 없는 영역의 AOP 처리를 위해 사용됨

2. Spring에서도 사용을 권장하고, 많은 글들에서도 권장함

3. 빠르고 강력함

 

그런데 AspectJ 설치부터 난항을 겪어서.. (흠흠) 일단 proxy class를 탈 수 있게 술수 부린 내용을 정리해보겠다.

첫번째 술수 : AOP 프록시 객체를 얻어서 메소드 호출

# build.gradle
implementation 'org.springframework.boot:spring-boot-starter-aop'

# Application.java
@EnableCaching
@EnableAspectJAutoProxy(exposeProxy = true) // <-- here
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
// 서비스 메소드에 캐시 적용 예시
public ResponsePredictionPredDiseaseDto getPredictionPredDiseaseList(Long memberId, String checkupDate) throws IOException {
    HealthCheckupRecord record = ((PredictionService) AopContext.currentProxy()).getRecordComplete(memberId, checkupDate);
    log.error("!@@ 캐시 주소=>" + record);
    ResponsePredictionPredDiseaseDto response = postPredictionPredDiseaseApi(record);

    return response;
}

 

두번째 술수 : ApplicationContext에서 getBean("내가원하는빈")하여 캐싱 메소드 호출

@Service
public class UserService implements Service {

    @Autowired
    private ApplicationContext applicationContext;

    private Service self;

    @PostConstruct
    private void init() {
        self = applicationContext.getBean(UserService.class);
    }
}

 

세번째 술수 : @Resource 어노테이션으로 self-autowiring (스프링 4.3부터 지원)

@Component
@CacheConfig(cacheNames = "SphereClientFactoryCache")
public class CacheableSphereClientFactoryImpl implements SphereClientFactory {

    /**
     * 1. Self-autowired reference to proxified bean of this class.
     */
    @Resource
    private SphereClientFactory self;

    @Override
    @Cacheable(sync = true)
    public SphereClient createSphereClient(@Nonnull TenantConfig tenantConfig) {
        // 2. call cached method using self-bean
        return self.createSphereClient(tenantConfig.getSphereClientConfig());
    }

    @Override
    @Cacheable(sync = true)
    public SphereClient createSphereClient(@Nonnull SphereClientConfig clientConfig) {
        return CtpClientConfigurationUtils.createSphereClient(clientConfig);
    }
}

 

네번째 술수 : Bean의 Scope를 'prototype'으로 생성

단점 : 단순히 proxy class를 탔으면 하는데 빈을 여러 개 만듦

@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)
@Service
public class PredictionService {

    private final PredictionService _cacheService;

    @Autowired
    public PredictionService(PredictionService predictionService) {
        _cacheService = predictionService;
    }
		
    /**
     * 캐시하고 싶은 메서드에 @Cacheable 어노테이션을 추가한다. 캐시를 적용할 메서드는 public 이어햐마.
     */
    @Cacheable(cacheNames = "getRecordCompleteCache")
    public HealthCheckupRecord getRecordComplete(Long memberId, String checkupDate) {
        HealthCheckupRecord recordCheckupData = healthCheckupRecordRepository.findByMemberIdAndCheckupDateAndStep(memberId, checkupDate, RecordStep.COMPLETE);
        return recordCheckupData;
    }

    public ResponsePredictionPredDiseaseDto getPredictionPredDiseaseList(Long memberId, String checkupDate) throws IOException {
        //캐싱할 메소드를 호출할 때에는 _cacheService를 통해 호출한다.
        HealthCheckupRecord record = _cacheService.getRecordComplete(memberId, checkupDate);

    }

 


5. 캐시 간단 검증

캐시가 정말 적용되었는 지 간단히 검증해보았다.

캐싱 메소드의 결과가 HealthCheckupRecord 클래스의 인스턴스이다. 캐시가 정상적으로 되었다면 메소드를 여러 번 호출해도 동일한 객체의 주소값이 출력되어야 한다. (원래 캐시가 안된 상태라면 동일한 값을 지녔더라도 매번 새로운 객체를 생성하여 리턴하기 때문에 주소값이 달라진다.)

public ResponsePredictionPredDiseaseDto getPredictionPredDiseaseList(Long memberId, String checkupDate) throws IOException {
    // 캐싱된 getRecordComplete 메소드를 호출한다.
    HealthCheckupRecord record = ((PredictionService) AopContext.currentProxy()).getRecordComplete(memberId, checkupDate);
    log.error("\n캐시가 된다면 객체값이 같아야함=>" + record);
        
    // ...
}

 

호출 결과

캐시가 된다면 객체값이 같아야함=>net.huray.da.entity.HealthCheckupRecord@3c895d6b
캐시가 된다면 객체값이 같아야함=>net.huray.da.entity.HealthCheckupRecord@3c895d6b

매번 캐시를 적용할 때 마다 이런 식으로 확인하기는 어렵다. 캐시 로그를 볼 수 있도록 구현도 가능하다.

아래 두 게시글을 참고하면 된다.

https://www.baeldung.com/spring-boot-ehcache

https://jojoldu.tistory.com/57

 

테스트코드도 작성해보자.

    @Test
    public void getRecordCompleteCacheCreate() {
        // given
        Long memberId = (long)this.testUserId;
        List<HealthCheckupRecord> recordDesc = healthCheckupRecordRepository.findByMemberIdAndStepOrderByCheckupDateDesc(testUserId, RecordStep.COMPLETE);
        String checkupDate = recordDesc.get(0).getCheckupDate();
        String checkupDate2 = recordDesc.get(1).getCheckupDate();

        // when
        HealthCheckupRecord first = predictionService.getRecordComplete(memberId,checkupDate);
        HealthCheckupRecord second = predictionService.getRecordComplete(memberId,checkupDate);
        HealthCheckupRecord third = predictionService.getRecordComplete(memberId,checkupDate2);

        // then
        assertThat(first).isEqualTo(second);
        assertThat(first).isNotEqualTo(third);
    }

첫번째와 두번째는 조회 조건이 같고, 세번째는 조회 조건이 다르다.

정상적으로 캐시가 적용됐다면 첫번째와 두번째의 객체 주소값은 동일하고, 세번째의 주소값은 다르게 나온다.

 


파라미터가 DTO와 같은 객체인 경우

사실 좀 전에 캐싱한 getRecordComplete 메소드는 DB에서 정보를 조회해오고, 이 record로 외부 API를 호출하여 User에게 결과를 반환하는 것이 하나의 기능이다.

파라미터가 DTO 객체인 경우에는 직렬화한 값이 동일할 지라도 객체의 주소값이 다르다. DB에서 동일한 데이터를 호출하여 DTO에 담아도, 호출될 때 마다 동일한 값을 가진 다른 객체가 생성된다.

좀 전에 캐시를 적용한 메소드 예제가 힌트가 되었을 것이다.

 

 

기능 구조

  1. (파랑) DB에서 데이터를 조회하여 requestDTO에 담는다.
  2. (빨강) 외부 API에 requestDTO를 보내 결과를 받는다.

시간이 10초 이상 소요되는 외부 API를 매번 호출할 수 없어서, 전체 데이터를 받아온 후 내부에서 가공하여 사용하도록 구성하였다. 하지만 매번 DB에서 데이터를 조회해 DTO에 담을 때 마다 주소값이 달라지기 때문에 캐싱을 적용하더라도 매번 외부 API를 호출하게 된다.

이 때문에 DB 조회하는 메소드에서 캐싱을 적용하여 동일한 객체의 주소값을 리턴하게 하고, 동일한 객체의 주소값일 때에는 외부 API를 타지 않고 캐싱된 외부API 결과값을 사용자에게 표시하도록 한다.

 

(참고) 나는 1, 2, 4번째 술수를 직접 테스트해봤고, 1번 술수를 적용했다. 추후 AspectJ로 리팩토링하여 포스팅을 다시 할 예정이다.

@Service
public class PredictionService {
		
    // DB에서 데이터를 호출한다.
    @Cacheable(cacheNames = "getRecordCompleteCache")
    public HealthCheckupRecord getRecordComplete(Long memberId, String checkupDate) {
        Optional<HealthCheckupRecord> recordCheckupData = healthCheckupRecordRepository.findByMemberIdAndCheckupDateAndStep(memberId, checkupDate, RecordStep.COMPLETE);
        return recordCheckupData.orElseThrow(NoSuchElementException::new);
    }

    // 외부 API를 호출한다. (새로 추가되었다)
    @Cacheable(cacheNames = "postPredictionPredDiseaseApiCache")
    public ResponsePredictionPredDiseaseDto postPredictionPredDiseaseApi(HealthCheckupRecord record) throws IOException {
	    // ...
    }

    public ResponsePredictionPredDiseaseDto getPredictionPredDiseaseList(Long memberId, String checkupDate) throws IOException {
        // DB에서 데이터를 호출한다. (self-invocation caching)
        HealthCheckupRecord record = ((PredictionService) AopContext.currentProxy()).getRecordComplete(memberId, checkupDate);
        // 외부 API를 요청하는 메소드에, 캐싱된 객체 주소를 파라미터로 넘긴다. (새로 추가되었다)
        ResponsePredictionPredDiseaseDto response = ((PredictionService) AopContext.currentProxy()).postPredictionPredDiseaseApi(record);

        return response;		
    }
}

 

또한 ehcache.xml에도 새로 적용한 캐시에 대한 설정을 해준다.

<config
        xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
        xmlns='http://www.ehcache.org/v3'
        xmlns:jsr107="http://www.ehcache.org/v3/jsr107"
        xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core.xsd
        http://www.ehcache.org/v3/jsr107 http://www.ehcache.org/schema/ehcache-107-ext-3.0.xsd">

    <service>
        <jsr107:defaults enable-management="true" enable-statistics="true"/>
    </service>

    <cache-template name="myDefaultTemplate">
        <expiry>
            <ttl unit="seconds">600</ttl>
        </expiry>
        <heap>20</heap>
    </cache-template>

    <cache alias="getRecordCompleteCache" uses-template="myDefaultTemplate">
    </cache>

	<!--새로 추가한 캐시-->
    <cache alias="postPredictionPredDiseaseApiCache">
        <heap>50</heap>
    </cache>
</config>

 


캐시를 적용하고, 데이터가 수정될 때 마다 자동으로 캐시가 업데이트되면 얼마나 좋을까? 캐싱한 데이터가 변경되었을 때에도 캐시도 update 되거나 삭제되어야 한다. 나 같은 경우에는 DB 데이터를 캐싱했기 때문에 데이터가 변경되면 캐시가 리프레시 되게끔 하고 싶었다. 일단은 아래와 같이 메서드를 호출해서 캐시가 리프레시 되게 적용했다. 하지만 여러 포인트에서 DB 데이터를 변경할 수 있기 때문에, 깔끔하게 하려면 Entity의 update 메소드에서 캐시를 지우는 것이 좋을 것 같다. 하지만 @Entity는 JPA 어노테이션으로 Spring Bean으로 생성되지 않기 때문에 Spring AOP 기반인 캐시를 사용할 수 없다. 이를 위해 AspectJ를 써야한다.

    @CacheEvict(cacheNames="getRecordCompleteCache", allEntries = true)
    public void evictTest() {
        // 캐시를 지울 수 있다.
    }

참고자료

Baeldung - Spring Boot Ehcache Example https://www.baeldung.com/spring-boot-ehcache

기억보단 기록을 - SpringBoot + Ehcache 기본 예제 및 소개 : https://jojoldu.tistory.com/57

최범균 - EHCache를 이용한 캐시 구현 https://javacan.tistory.com/entry/133

Spring 공식문서 https://docs.spring.io/spring/docs/current/spring-framework-reference/integration.html#cache

stackoverflow 내부에서 캐싱 메소드 호출하기 : https://stackoverflow.com/a/34091265/13885206

3의 환경설정 공식문서 - https://www.ehcache.org/documentation/3.8/getting-started.html

반응형