스프링 EH Cache(성능 개선기)✨

‍서지오·2023년 11월 22일
4

스프링

목록 보기
4/4
post-thumbnail

0. 도입 배경

프로젝트 진행 중 서울 시 내 존재하는 공공 쓰레기 통 정보를 사용자 위치 기반으로 조회하는 API를 제공하였다. 쓰레기통 데이터는 변경이 거의 없고 조회가 주로 일어나며 사용자 간 거리가 가까운 경우 중복이 발생 해 캐시를 도입하기로 했고 가장 많이 사용되는 Look-Aside 캐시 전략을 사용하였다.

Look-Aside : 데이터 조회 시 먼저 캐시에서 검색을 하고 cache fault 발생 시 DB에서 데이터를 검색하고 결과를 캐시에 저장하는 캐싱 전략


1. EH Cache란?

  • Spring에서 사용할 수 있는 Java 기반 오픈소스 캐시 라이브러리

  • 데이터 직렬화가 필요해서 cache에 저장할 클래스는 Serializable을 implements해야한다.

  • 캐시 데이터를 heap, off-heap, disk에 저장한다.

  • 기존 2.x 버전과는 달리 3 버전에서는 off-heap 제공

    💡 on-heap: Java 프로그램에서 new를 통해 객체 생성 시 JVM이 힙 메모리에 객체를 저장하는 데, 이때 객체가 저장되는 영역을 on-heap이라고 한다.

    💡 off-heap : Garbage Collecotr에 의해 관리되지 않는 영역으로 GC 오버헤드가 일어나지 않아 성능이 좋다. 하지만 조작이 까다롭기 때문에 EhCache와 같은 캐시 라이브러리를 사용하는 걸 권장한다.

  • key-value 형태로 데이터를 저장한다.

  • Redis, MemCached와의 차이점

    • Redis와 달리 Spring 내부적으로 동작하여 네트워크 지연이 없다.
    • 스프링 어플리케이션과 life cycle을 공유
  • 3 버전 부터는 javax.cache API (JSR-107)와의 호환성을 제공

    JSR-197 : 자바의 표준 캐시 스펙이자 java 객체의 메모리 캐싱에서 사용할 API에 대한 기준


2. Eviction이 뭐죠..?

  • off-heap 영역이 꽉 차면…??
    • 기본적으로 LRU(Least Recently Used) 방식으로 Eviction(데이터 삭제) 진행
  • Eviction 정책 종류
    • LRU (Least Recently Used)
    • LFU (Least Frequently Used)
    • FIFO (First-In, First-Out)
    • Custom (사용자 정의 정책)
  • 스프링 내 Eviction 설정 방법
    <cache alias="myCache">
        <resources>
            <heap unit="entries">100</heap>
        </resources>
        <expiry>
            <tti unit="seconds">60</tti>
        </expiry>
        <eviction-policy>LRU</eviction-policy> // Eviction 정책 설정
    </cache>
    • xml 파일에 캐시 관련 설정을 진행

💡 EhCache 3버전 부터는 Eviction 정책이 캐시 저장 공간(heap, off-heap, disk)에 맞게 고정되어있다.
=> In Ehcache 3 there is no way of choosing an eviction policy. Each tier within the cache {heap, offheap, disk, cluster} is free to choose the most appropriate eviction policy given it’s implementation. The heap tier operates a biased sampling LRU scheme, the offheap and cluster use an LRU clock. Given that both of these are sampling based and therefore neither strict LRU or LFU, it was decided that configurability here wasn’t worth the implementation cost and that the bulk of users wouldn’t need this fine-grained control.


3. gradle 의존성 추가

// eh cache
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.ehcache:ehcache:3.10.6'

// JSR-107
implementation 'javax.cache:cache-api:1.1.1'

// JAXB(binding xml to java beans)
implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.1'
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'org.javassist:javassist:3.25.0-GA'
  • JAXB(Java Architecture for XML Binding) : XML 문서와 Java Bean들 간의 바인딩을 지정, XML 문서 정보를 동일한 형태의 오브젝트로 직접 매핑

4. Config 파일 추가

package com.twoez.zupzup.config.cache;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@EnableCaching
@Configuration
public class CacheConfig {
}
  • @EnableCaching : cache를 사용하겠다는 걸 명시

5. 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-3.0.xsd
            http://www.ehcache.org/v3/jsr107 http://www.ehcache.org/schema/ehcache-107-ext-3.0.xsd">

    <cache alias="trashcanList"> <!-- @Cacheable의 value 값으로 사용됨 -->
        <!--  key 타입 지정 -->
        <key-type>java.lang.String</key-type>
        <!-- value 타입 지정 -->
        <value-type>java.util.List</value-type>

        <expiry>
            <ttl unit="seconds">600</ttl> <!-- ttl 설정 -->
        </expiry>

        <listeners>
            <listener>
                <!-- 리스너 클래스 위치 -->
                <class>com.twoez.zupzup.config.cache.CacheEventLogger</class>

                <!-- 비동기 방식 사용, 캐시 동작을 블로킹하지 않고 이벤트를 처리, SYNCHRONOUS와 반대 -->
                <event-firing-mode>ASYNCHRONOUS</event-firing-mode>
                <!-- 이벤트 처리 순서 설정 X, ORDERED와 반대 -->
                <event-ordering-mode>UNORDERED</event-ordering-mode>

                <!-- 리스너가 감지할 이벤트 설정(EVICTED, EXPIRED, REMOVED, CREATED, UPDATED) -->
                <events-to-fire-on>CREATED</events-to-fire-on>
                <events-to-fire-on>UPDATED</events-to-fire-on>
                <events-to-fire-on>EXPIRED</events-to-fire-on>
            </listener>
        </listeners>

        <resources>
            <!-- JVM 힙 메모리에 캐시 저장 -->
            <heap unit="entries">100</heap>

            <!-- off-heap(외부 메모리)에 캐시 저장 -->
            <offheap unit="MB">10</offheap>
        </resources>
    </cache>

    <cache alias="googlePublicKeys"> <!-- @Cacheable의 value 값으로 사용됨 -->
        <expiry>
            <ttl unit="seconds">600</ttl> <!-- ttl 설정 -->
        </expiry>

        <listeners>
            <listener>
                <!-- 리스너 클래스 위치 -->
                <class>com.twoez.zupzup.config.cache.CacheEventLogger</class>

                <!-- 비동기 방식 사용, 캐시 동작을 블로킹하지 않고 이벤트를 처리, SYNCHRONOUS와 반대 -->
                <event-firing-mode>ASYNCHRONOUS</event-firing-mode>
                <!-- 이벤트 처리 순서 설정 X, ORDERED와 반대 -->
                <event-ordering-mode>UNORDERED</event-ordering-mode>

                <!-- 리스너가 감지할 이벤트 설정(EVICTED, EXPIRED, REMOVED, CREATED, UPDATED) -->
                <events-to-fire-on>CREATED</events-to-fire-on>
                <events-to-fire-on>EXPIRED</events-to-fire-on>
            </listener>
        </listeners>

        <resources>
            <!-- JVM 힙 메모리에 캐시 저장 -->
            <heap unit="entries">100</heap>

            <!-- off-heap(외부 메모리)에 캐시 저장 -->
            <offheap unit="MB">10</offheap>
        </resources>
    </cache>

    <cache alias="kakaoPublicKeys"> <!-- @Cacheable의 value 값으로 사용됨 -->
        <expiry>
            <ttl unit="seconds">600</ttl> <!-- ttl 설정 -->
        </expiry>

        <listeners>
            <listener>
                <!-- 리스너 클래스 위치 -->
                <class>com.twoez.zupzup.config.cache.CacheEventLogger</class>

                <!-- 비동기 방식 사용, 캐시 동작을 블로킹하지 않고 이벤트를 처리, SYNCHRONOUS와 반대 -->
                <event-firing-mode>ASYNCHRONOUS</event-firing-mode>
                <!-- 이벤트 처리 순서 설정 X, ORDERED와 반대 -->
                <event-ordering-mode>UNORDERED</event-ordering-mode>

                <!-- 리스너가 감지할 이벤트 설정(EVICTED, EXPIRED, REMOVED, CREATED, UPDATED) -->
                <events-to-fire-on>CREATED</events-to-fire-on>
                <events-to-fire-on>EXPIRED</events-to-fire-on>
            </listener>
        </listeners>

        <resources>
            <!-- JVM 힙 메모리에 캐시 저장 -->
            <heap unit="entries">100</heap>

            <!-- off-heap(외부 메모리)에 캐시 저장 -->
            <offheap unit="MB">10</offheap>
        </resources>
    </cache>
</config>

6. application.yml 수정

spring:
  cache:
    jcache:
      config: classpath:ehcache.xml

7. 캐싱 설정 - TrashQueryService

package com.twoez.zupzup.trashcan.service;

import com.twoez.zupzup.trashcan.domain.Trashcan;
import com.twoez.zupzup.trashcan.repository.TrashcanCacheRepository;
import com.twoez.zupzup.trashcan.repository.TrashcanQueryRepository;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class TrashcanQueryService {

    private final TrashcanCacheRepository trashcanCacheRepository;
    private static final int RANGE = 1;

    public List<Trashcan> findByLocation(BigDecimal latitude, BigDecimal longitude) {
        log.info("위치 조회 서비스 호출");
        return trashcanCacheRepository.findByLocationWithCache(latitude, longitude).stream()
                .filter(
                        target ->
                                isNearByTrashcanFromUser(
                                        latitude.doubleValue(),
                                        longitude.doubleValue(),
                                        target.getLatitude().doubleValue(),
                                        target.getLongitude().doubleValue()))
                .collect(Collectors.toList());
    }

    private static boolean isNearByTrashcanFromUser(
            double sourceLatitude,
            double sourceLongitude,
            double targetLatitude,
            double targetLongitude) {
        return calculateDistance(sourceLatitude, sourceLongitude, targetLatitude, targetLongitude)
                <= RANGE;
    }

    private static double calculateDistance(
            double sourceLatitude,
            double sourceLongitude,
            double targetLatitude,
            double targetLongitude) {

        // 지구 반지름 (단위: km)
        double earthRadius = 6371;

        // 위도와 경도의 차이
        double dLat = Math.toRadians(targetLatitude - sourceLatitude);
        double dLon = Math.toRadians(targetLongitude - sourceLongitude);

        // Haversine 공식 계산
        double a =
                Math.sin(dLat / 2) * Math.sin(dLat / 2)
                        + Math.cos(Math.toRadians(sourceLatitude))
                                * Math.cos(Math.toRadians(targetLatitude))
                                * Math.sin(dLon / 2)
                                * Math.sin(dLon / 2);

        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

        // 거리 계산
        double distance = earthRadius * c;

        return distance;
    }
}
  • @Cachalbe : cache hit일 경우 데이터를 바로 return, cache fault일 경우 db 조회 후 캐시에 저장
  • @CachePut : 무조건 캐시에 저장
  • @CacheEvict : 캐시 삭제

8. 캐싱 설정 - TrashcanCacheRepository

package com.twoez.zupzup.trashcan.repository;

import com.twoez.zupzup.trashcan.domain.Trashcan;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class TrashcanCacheRepository {
    private final TrashcanQueryRepository trashcanQueryRepository;

    /**
     * 위 경도에 따른 쓰레기통 조회 캐싱 사용자들 간의 거리가 1km 이내인 경우 cache hit
     *
     * @param latitude
     * @param longitude
     * @return
     */
    @Cacheable(
            cacheNames = "trashcanList", // xml 파일에서 설정해준 alias
            key =
                    "#latitude.toString().substring(0, 5) + '_' + #longitude.toString().substring(0, 6)" // key : "위도_경도", 소수 점 둘쨰 자리 까지
    )
    public List<Trashcan> findByLocationWithCache(BigDecimal latitude, BigDecimal longitude) {
        return trashcanQueryRepository.findByLocation(latitude, longitude);
    }
}
  • 캐싱 목표
    • 1km 내 근접한 사용자들은 쓰레기 통 조회 시 cache hit 발생 ⇒ 미리 캐싱된 주변 쓰레기 통 중 사용자로 부터 lkm 내 쓰레기통들 return(DB 조회 X)
    • cache fault 발생 시 근방 5km 내 모든 쓰레기통을 조회
      • cahce hit 확률을 높이기 위해 5km 근방 쓰레기통을 조회한다.

💡 @Cacheable 어노테이션은 내부적으로 스프링 AOP를 이용하기 때문에 내부 메소드에는 적용되지 않는다.
=> 서비스와 레포지토리 사이 캐시 레포지토리를 계층을 두어 서비스 단에서 내부 메소드로 사용 가능하도록 구현하였다. (TrashQueryService → TrashcanCacheRepository → TrashcanRepository


9. 캐시 데이터 직렬화

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Trashcan extends BaseTime implements Serializable {

}
  • 기존 클래스에 implements Serializable 구문 추가
  • record 타입은 default로 직렬화가 되어있다.

10. Event Logger 결과 화면

2023-11-15T13:19:16.853+09:00  INFO 15056 --- [e [_default_]-1] c.t.z.config.cache.CacheEventLogger      : cache event logger message. getKey: 37.51_127.03 / getOldValue: null / getNewValue:[com.twoez.zupzup.trashcan.domain.Trashcan@304c9431, com.twoez.zupzup.trashcan.domain.Trashcan@3798fc32, com.twoez.zupzup.trashcan.domain.Trashcan@79c07508, com.twoez.zupzup.trashcan.domain.Trashcan@280edde3, com.twoez.zupzup.trashcan.domain.Trashcan@7c3729cf, com.twoez.zupzup.trashcan.domain.Trashcan@c3e2317, com.twoez.zupzup.trashcan.domain.Trashcan@76ac6fad, com.twoez.zupzup.trashcan.domain.Trashcan@2813455e, com.twoez.zupzup.trashcan.domain.Trashcan@6d2f68c2, com.twoez.zupzup.trashcan.domain.Trashcan@1074d8eb, com.twoez.zupzup.trashcan.domain.Trashcan@5b00c4f, com.twoez.zupzup.trashcan.domain.Trashcan@373b8012, com.twoez.zupzup.trashcan.domain.Trashcan@2144874f, com.twoez.zupzup.trashcan.domain.Trashcan@1741a00c, com.twoez.zupzup.trashcan.domain.Trashcan@121604df, com.twoez.zupzup.trashcan.domain.Trashcan@79463d9f, com.twoez.zupzup.trashcan.domain.Trashcan@7cbda307, com.twoez.zupzup.trashcan.domain.Trashcan@66e62d5c, com.twoez.zupzup.trashcan.domain.Trashcan@44f4eda3, com.twoez.zupzup.trashcan.domain.Trashcan@c968a67]
  • key : 설정한 캐시의 키 값, “{위도 앞에서 부터 5자리}_{경도 앞에서 부터 6자리}”
  • old value : 기존 값
  • new value : 갱신된 값

11. 응답 시간 비교

  1. 첫 요청 시 결과
    • 위 이미지의 빨간색 박스를 보면 쓰레기통 조회 쿼리(DB 접근)가 나가는 걸 확인할 수 있다.

  1. 1km 내 존재하는 다른 사용자(위, 경도)로 조회 시(두 번째 조회 => cache hit)
    • cache hit이 발생하여 쓰레기통 조회 쿼리가 나가지 않는 걸 확인할 수 있다.

12. 성능 개선 정도 측정

  • 로컬에서 44ms → 25ms로 약 42.86%의 성능 개선이 일어남!!
  • 운영 환경에서는 40.7ms → 21.0ms로 약 48.44%의 성능 개선이 일어남
  • 쓰레기통 데이터가 훨씬 많아진다면 기대 효과 또한 더 커질 것으로 예상된다.
profile
백엔드 개발자를 꿈꾸는 학생입니다!

0개의 댓글