고성능 애플리케이션을 구축할 때 데이터베이스나 검색 엔진(ES)의 부하를 줄이기 위해 캐싱 전략은 필수적입니다. 로컬 캐시(Caffeine)의 빠른 속도와 분산 캐시(Redis)의 데이터 공유 장점을 결합한 다단계 캐시(Multi-level Cache) 구조를 책임 연쇄 패턴(Chain of Responsibility)과 AOP를 활용해 구현하는 방법을 살펴봅니다.
1. 커스텀 어노테이션 정의
메서드 단위로 캐시 설정을 적용하기 위한 어노테이션을 정의합니다. 키 접두사, 특정 키 값, 그리고 각 계층별 만료 시간(TTL)을 설정할 수 있습니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MultiLayerCache {
String prefix() default ""; // 캐시 키 접두사
String key() default ""; // 캐시 고유 키 (비어있을 경우 파라미터 해시값 사용)
int localTtl() default 60; // 로컬 캐시(Caffeine) 유지 시간(초)
int remoteTtl() default 300; // 원격 캐시(Redis) 유지 시간(초)
}
2. 캐시 조회 문맥(Context) 관리
캐시 계층을 통과하며 데이터를 전달하고 상태를 저장하기 위한 객체입니다.
@Getter
public class CacheLookupContext {
private final String cacheKey;
private String resultJson;
private boolean found = false;
public CacheLookupContext(String cacheKey) {
this.cacheKey = cacheKey;
}
public void markAsFound(String value) {
this.resultJson = value;
this.found = true;
}
}
3. 책임 연쇄 패턴을 이용한 캐시 계층화
각 캐시 레이어(L1: Local, L2: Redis)를 독립적인 핸들러로 구성하여 유연하게 확장할 수 있도록 설계합니다.
캐시 핸들러 인터페이스
public interface CacheProcessor {
// 캐시 조회 시도
boolean process(CacheLookupContext context);
// 다음 프로세서 설정
void setNext(CacheProcessor next);
// 데이터 저장 (계층 전파)
void write(String key, String value, int ttl);
}
프로세서 체인 구성
Spring의 @PostConstruct를 사용하여 애플리케이션 시작 시 로컬-원격-기본 순서로 체인을 연결합니다.
@Component
public class CacheProcessorChain {
@Autowired(required = false)
private LocalCacheProcessor localProcessor;
@Autowired(required = false)
private RedisCacheProcessor redisProcessor;
private CacheProcessor head;
@PostConstruct
public void setup() {
if (localProcessor != null && redisProcessor != null) {
localProcessor.setNext(redisProcessor);
head = localProcessor;
} else {
head = (localProcessor != null) ? localProcessor : redisProcessor;
}
}
public CacheProcessor getHead() {
return head;
}
}
4. AOP를 통한 캐시 로직 주입
비즈니스 로직에 침투하지 않고 캐싱을 적용하기 위해 관점 지향 프로그래밍(AOP)을 사용합니다. 키가 지정되지 않은 경우 파라미터를 MD5로 해싱하여 자동으로 생성합니다.
@Aspect
@Component
public class MultiLayerCacheAspect {
@Autowired
private CacheProcessorChain chainContainer;
@Around("@annotation(cacheConfig)")
public Object applyCache(ProceedingJoinPoint joinPoint, MultiLayerCache cacheConfig) throws Throwable {
String generatedKey = cacheConfig.key();
if (ObjectUtils.isEmpty(generatedKey)) {
String argsString = JSON.toJSONString(joinPoint.getArgs());
generatedKey = DigestUtils.md5DigestAsHex(argsString.getBytes());
}
String fullKey = cacheConfig.prefix() + generatedKey;
CacheLookupContext context = new CacheLookupContext(fullKey);
CacheProcessor processor = chainContainer.getHead();
// 1. 캐시 체인에서 데이터 조회
if (processor != null && processor.process(context)) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
return JSON.parseObject(context.getResultJson(), signature.getReturnType());
}
// 2. 캐시 미스 시 원본 메서드 실행
Object originResult = joinPoint.proceed();
// 3. 실행 결과 캐시 저장
if (originResult != null && processor != null) {
String jsonToCache = JSON.toJSONString(originResult);
processor.write(fullKey, jsonToCache, cacheConfig.remoteTtl());
}
return originResult;
}
}
5. 실제 적용 및 성능 결과
Elasticsearch 기반의 이미지 검색 API에 위 다단계 캐시를 적용하여 테스트를 진행했습니다.
@PostMapping("/search")
@MultiLayerCache(prefix = "gallery:search:", remoteTtl = 600)
public Response<Page<ImageVO>> searchImages(@RequestBody SearchRequest request) {
return ResultUtils.success(imageService.executeSearch(request));
}
성능 벤치마크 (JMeter 테스트 결과)
- 테스트 조건: 50명 동시 사용자, 1초 동안 반복 요청
- ES 직접 조회: 평균 응답 시간 30ms
- 다단계 캐시 적용: 평균 응답 시간 14ms
결과적으로 약 50% 이상의 성능 향상을 확인하였으며, 캐시 스탬피드(Cache Stampede) 현상을 방지하기 위해 Redis 만료 시간에 무작위 지연(Random Jitter)을 추가하여 안정성을 확보했습니다.