이전 글(Redis를 사용해 API Response 캐시화하기(with. Spring Boot) - 1)을 보시면 개요와 구현된 클래스를 확인하실 수 있습니다.
이번 글에는 구현한 코드를 가지고 API에 적용하는 예제를 정리해 보겠습니다.
예제 코드
CacheAop
전 글에서 구현한 어노테이션의 Aop를 구현한 곳입니다.
@Aspect
@Component
@RequiredArgsConstructor
public class CacheAop {
private final CacheService cacheService;
// 의존성 주입 시 모든 캐시 삭제
@PostConstruct
public void init() {
cacheService.clearCache();
}
// SaveCache
@Around("@annotation(com.beekei.example.redis.cache.SaveCache)")
public Object saveCache(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
SaveCache saveCache = method.getAnnotation(SaveCache.class);
CacheType cacheType = saveCache.type();
Map<String, Object> hashKey = Arrays.stream(saveCache.hashKey())
.collect(Collectors.toMap(CacheHashKey::name,
a -> ExpressionParserUtils.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), a.expression())));
Integer refreshTime = saveCache.timeout();
TimeUnit refreshTimeUnit = saveCache.timeUnit();
Object cache = cacheService.getCache(cacheType, hashKey, method.getReturnType());
if (cache != null) {
return cache;
} else {
Object result = joinPoint.proceed(joinPoint.getArgs());
cacheService.saveCache(cacheType, hashKey, result, refreshTime, refreshTimeUnit);
return result;
}
}
// DeleteCache
@After("@annotation(com.beekei.example.redis.cache.DeleteCache)")
public void deleteCache(final JoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DeleteCache deleteCache = method.getAnnotation(DeleteCache.class);
Set<CacheType> cacheTypes = Arrays.stream(deleteCache.type()).collect(Collectors.toSet());
Arrays.stream(deleteCache.bundleType()).forEach(cacheBundleType -> cacheTypes.addAll(cacheBundleType.getCacheTypes()));
Map<String, Object> hashKey = Arrays.stream(deleteCache.hashKey())
.collect(Collectors.toMap(CacheHashKey::name,
a -> ExpressionParserUtils.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), a.expression())));
cacheService.deleteCache(cacheTypes, hashKey);
}
}
saveCache와 deleteCache에서 말 그대로 캐시를 저장하고 삭제하고 있습니다.
CashHashKey에서 설정된 필드들을 해당 어노테이션이 선언된 메서드에서 추출해 hashKey를 생성하고 있습니다.
추출하는 코드는 ExpressionParserUtils에 선언되어 있습니다.
public class ExpressionParserUtils {
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
return parser.parseExpression(key).getValue(context, Object.class);
}
}
캐시를 저장하는 부분을 자세히 확인해 보면 저장된 캐시를 조회하고, 존재하지 않는다면 실제 프로세스 진행 후 반환된 Response를 캐시로 저장하고 반환하고 있습니다.
...
Object cache = cacheService.getCache(cacheType, hashKey, method.getReturnType());
if (cache != null) {
return cache;
} else {
Object result = joinPoint.proceed(joinPoint.getArgs());
cacheService.saveCache(cacheType, hashKey, result, refreshTime, refreshTimeUnit);
return result;
}
...
이렇게 되면 첫번째로 호출한 공지사항 목록 조회 API는 실제 프로세스로 진행되기 때문에 캐시화를 적용하기 전과 차이가 나지 않지만,
두 번째 API 호출(캐시화가 된 후)부터는 캐시에서 가져와 반환되기 때문에 데이터 수가 많아질수록 실제 DB 조회와는 많이 차이가 날 것입니다.
이해하기 쉽게 이미지로 단순화해 보면 아래처럼 프로세스가 진행된다고 생각하시면 됩니다.
@SaveCache 사용
API 메서드에 SaveCache 어노테이션을 사용해 캐시 저장을 설정합니다.
공지사항 목록 조회 시 page와 size를 통해 hashKey를 생성하고 캐시로 저장하고 있습니다.
@SaveCache(type = CacheType.NOTICE_LIST, hashKey = {
@CacheHashKey(name = "page", expression = "#page"),
@CacheHashKey(name = "size", expression = "#size")
})
@GetMapping(name = "공지 목록 조회", value = "/notice/list")
public ApiResponse<Page<NoticeDTO>> getNoticeList(
@RequestParam(name = "page", required = false) Integer page,
@RequestParam(name = "size", required = false) Integer size) {
final Page<NoticeDTO> noticeList = contentsService.getNoticeList(page, size);
return ApiResponse.ok(noticeList);
}
여기서 @CacheHashKey에 설정된 내용으로 hashKey를 생성하게 됩니다.
만약 공지사항 목록을 1 페이지부터 5 페이지까지 조회했을 때 5개의 캐시 데이터가 저장됩니다.
@DeleteCache 사용
여기서 만약 관리자가 새로운 공지사항을 등록했다면 위에서 캐시화 된 데이터는 업데이트되어야 할 것입니다.
공지사항 등록 시 캐시화된 공지사항 목록 데이터를 삭제해 주면 다시 DB를 조회해 최신화된 데이터를 캐시에 저장할 수 있습니다.
@DeleteCache(type = CacheType.NOTICE_LIST)
@PostMapping(name = "공지사항 등록", value = "/notice")
public ApiResponse<?> saveNotice(@RequestBody @Valid SaveNoticeRequestDto request) {
noticeManageService.saveNotice(request.convertParameter());
return ApiResponse.ok();
}
캐시가 삭제되어야 하는 API에 @DeleteCache 어노테이션을 달아 캐시를 삭제할 수 있습니다.
hashKey를 이용해 선택적으로 특정한 데이터인 캐시만 삭제가 가능하고, 설정해주지 않는다면 모든 NOTICE_LIST 캐시 데이터를 삭제하게 됩니다.
아래 이미지에서 4번 프로세스를 통해 캐시가 삭제되었다면 다시 1번 -> 2번 -> 3-2번 프로세스로 진행되게 됩니다.
실시간 데이터는 DB에서 조회하도록 캐시 커스텀
여기까지 어노테이션과 AOP를 통해 간단하게 API를 캐시 데이터로 저장하고 조회할 수 있는 예제 코드를 정리해 보았습니다.
실제 실무에 적용하면서 느낀 단점으로는 API Response를 통째로 캐시 화하기 때문에 정적이지 않고 실시간으로 움직여야 되는 데이터가 존재한다면 해당 캐시를 계속해서 삭제해야 하는 부분이 있었습니다....
이를 해결하기 위해 캐시를 조회한 후 특정한 필드만 DB에서 조회해 치환할 수 있도록 커스텀해보았습니다.
CacheType에 함수형 인터페이스인 BiFunction을 활용해 실제 DB 데이터로 치환하여 반환하도록 구현해 보았습니다.
@Getter
@AllArgsConstructor
@RequiredArgsConstructor
public enum CacheType {
...
NOTICE_LIST("NOTICE_LIST", (service, cache) -> {
// 캐시 데이터
JSONObject cacheData = new JSONObject(cache);
// responseData 추출
JSONArray noticeList = cacheData.optJSONArray("responseData");
for (int i = 0; i < list.length(); i++) {
// 공지사항 Row
JSONObject notice = list.optJSONObject(i);
// DB에서 공지사항 조회 수 조회
long noticeId = notice.getLong("noticeId");
long viewsCount = service.getNoticeViewsCount(noticeId);
// 공지사항 조회 수 치환
notice.put("viewsCount", viewsCount);
}
return service.getObjectMapper().readValue(cacheData.toString(), cache.getClass());
}), // 공지사항 목록
...
private final String key;
private BiFunction<CacheCustomService, Object, Object> cacheCustom;
}
(여기서는 예제이기 때문에 대충 for문을 돌리며 DB를 조회하고 있지만 실제로 이렇게 사용하시면 캐시 화하는 의미가 줄어들지 않을까 하는 생각입니다... DB 조회도 효율적으로!)
여기서 CacheCustomService에서는 실제 데이터들을 조회하는 서비스로 사용합니다.
enum에서 DB 데이터를 조회하는 코드를 작성할 수 없기 때문에 따로 CacheCustomService로 빼서 구현해 보았습니다.
@Service
@RequiredArgsConstructor
public class CacheCustomService {
@Getter
private final ObjectMapper objectMapper;
....
public long getNoticeViewsCount(long noticeId) {
// DB에서 공지사항 조회수를 조회하는 코드~~
return viewsCount;
}
}
그리고 AOP에서 CacheType에 커스텀 함수가 정의되어 있다면 실행되도록 캐시 데이터를 조회하는 부분을 수정하였습니다.
...
// Object cache = cacheService.getCache(cacheType, hashKey, method.getReturnType());
Object cache = Optional.ofNullable(cacheService.getCache(cacheType, hashKey, method.getReturnType()))
.map(c -> Optional.ofNullable(cacheType.getCacheCustom()).map(f -> f.apply(cacheCustomService, c)).orElse(c))
.orElse(null);
if (cache != null) {
return cache;
} else {
Object result = joinPoint.proceed(joinPoint.getArgs());
cacheService.saveCache(cacheType, hashKey, result, refreshTime, refreshTimeUnit);
return result;
}
....
만약 등록된 캐시 데이터가 있고 커스텀 함수가 정의되어 있다면 함수를 적용하고 캐시 데이터를 반환하게 됩니다.
이렇게 Spring AOP를 활용해 간단하게 API를 캐시화 하고 커스텀하여 조회할 수 있는 예제를 정리해 보았습니다.
더 좋은 방법이 많겠지만 간단한 정적 데이터 캐시화로는 쓸만하지 않을까 하는 (개인적인)생각입니다.
(ps. 코드가 더 업데이트된다면 계속해서 블로그에 정리하도록 하겠습니다.)
그럼 20000
'Redis' 카테고리의 다른 글
Redis를 사용해 API Response 캐시화하기(with. Spring Boot) - 1 (0) | 2024.07.15 |
---|---|
6. Spring Redis 분산락(Distribute Lock)을 활용한 동시성 처리 (1) | 2024.03.12 |
5. Redis HA(High availability)와 Sentinel (0) | 2024.02.21 |
4. Redis 복제 (0) | 2024.02.15 |
3. Redis 운영과 관리 (0) | 2023.10.01 |