Spring Boot와 Redis-Caffeine을 이용한 다단계 캐시 시스템 설계 및 구현

고성능 애플리케이션을 구축할 때 데이터베이스나 검색 엔진(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)을 추가하여 안정성을 확보했습니다.

태그: Spring Boot Redis Caffeine Cache AOP Design Patterns

8월 7일 04:24에 게시됨