프로젝트 진행 중 서울 시 내 존재하는 공공 쓰레기 통 정보를 사용자 위치 기반으로 조회하는 API를 제공하였다. 쓰레기통 데이터는 변경이 거의 없고 조회가 주로 일어나며 사용자 간 거리가 가까운 경우 중복이 발생 해 캐시를 도입하기로 했고 가장 많이 사용되는 Look-Aside 캐시 전략을 사용하였다.
Look-Aside : 데이터 조회 시 먼저 캐시에서 검색을 하고 cache fault 발생 시 DB에서 데이터를 검색하고 결과를 캐시에 저장하는 캐싱 전략
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와의 차이점
3 버전 부터는 javax.cache API (JSR-107)와의 호환성을 제공
JSR-197 : 자바의 표준 캐시 스펙이자 java 객체의 메모리 캐싱에서 사용할 API에 대한 기준
<cache alias="myCache">
<resources>
<heap unit="entries">100</heap>
</resources>
<expiry>
<tti unit="seconds">60</tti>
</expiry>
<eviction-policy>LRU</eviction-policy> // Eviction 정책 설정
</cache>
💡 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.
// 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'
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를 사용하겠다는 걸 명시<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>
spring:
cache:
jcache:
config: classpath:ehcache.xml
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
: 캐시 삭제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);
}
}
💡
@Cacheable
어노테이션은 내부적으로 스프링 AOP를 이용하기 때문에 내부 메소드에는 적용되지 않는다.
=> 서비스와 레포지토리 사이 캐시 레포지토리를 계층을 두어 서비스 단에서 내부 메소드로 사용 가능하도록 구현하였다. (TrashQueryService → TrashcanCacheRepository → TrashcanRepository
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Trashcan extends BaseTime implements Serializable {
}
implements Serializable
구문 추가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
: 갱신된 값