기록해야 성장한다

AOP와 커스텀 어노테이션을 활용한 Redis 캐시 초기화 전략 본문

TIL

AOP와 커스텀 어노테이션을 활용한 Redis 캐시 초기화 전략

sodapapa-dev 2025. 4. 25. 16:58

들어가며 🚀

Redis 캐시를 활용하는 Spring Boot 애플리케이션에서는 데이터 변경 시점에 캐시를 어떻게 효과적으로 초기화할지가 중요한 과제로 남는다. 특히 여러 API에서 데이터 수정이 일어나거나, 한 사용자의 데이터 변경이 연관된 다른 사용자들의 캐시에도 영향을 미쳐야 하는 경우 단순한 해결책으로는 부족하다. 더욱이 getMain::102와 같이 ID가 포함된 동적 키 패턴을 사용할 때는 캐시 관리의 복잡성이 한층 높아진다.

AOP(Aspect-Oriented Programming)와 커스텀 어노테이션은 이러한 복잡한 캐시 초기화 문제를 해결하는 효과적인 접근법이다. 이 글에서는 이 기법을 활용해 가족 구성원 간 캐시 동기화 문제를 해결한 과정을 살펴본다.

캐싱 도입의 배경 🤔

우리 서비스는 GET /main API를 통해 스케줄, 숙제 등 다량의 데이터를 한 번에 조회하여 메인 화면을 구성한다. 이 데이터들은 변경 빈도가 높지 않아 성능 개선을 위해 Redis 캐싱을 도입했다. 초기에는 1분의 짧은 TTL을 설정했으나, 이는 또 다른 문제를 야기했다.

💡 도입 배경 핵심 포인트:

  • GET /main API에서 많은 양의 데이터를 한번에 조회
  • 스케줄, 숙제 등 데이터 변경 빈도가 낮음
  • 성능 개선을 위해 Redis 캐싱 도입

자녀가 캐릭터를 변경하거나 숙제를 등록하는 경우, 변경된 데이터가 메인 화면에 즉시 반영되지 않아 사용자 경험이 저하되는 상황이 발생했다. 사용자는 자신이 변경한 내용이 즉시 화면에 표시되길 기대하기 때문이다.

⚠️ 문제점:

  • 1분 TTL 적용 시 데이터 변경이 즉시 반영되지 않음
  • 사용자 경험 저하
  • 데이터 일관성 문제 발생

이 문제 해결을 위해 데이터 변경이 발생할 때마다 해당 Redis 캐시를 초기화하는 전략을 채택했다. 이를 통해 데이터 변경 시에는 최신 정보를 제공하고, 변경이 없는 일반적인 상황에서는 캐시 TTL을 늘려 응답 속도를 높일 수 있었다.

기존 캐시 초기화 방법의 한계 ⚠️

Spring Boot에서 Redis 캐시를 초기화하는 일반적인 방법은 크게 두 가지다.

첫째, @CacheEvict 어노테이션 활용:

@Scheduled(cron = "0 1 * * * *")
@CacheEvict(value = "getMain", key = "#memberId")
public void getMainCacheEvict() {}

둘째, RedisTemplate을 직접 사용:

redisTemplate.delete("getMain::" + memberId);

그러나 이 방법들은 각기 한계를 지닌다.

📌 기존 방법의 문제점:

  • @CacheEvict는 가족 구성원의 캐시를 함께 초기화하는 등 복잡한 요구사항 처리 어려움
  • RedisTemplate 직접 사용은 코드 중복 발생 및 호출 누락 위험 존재
  • 여러 API에서 일관된 캐시 초기화 로직 적용이 어려움

특히 우리 서비스에서는 GET /main API를 호출하는 사용자가 가족 구성원이며, 한 구성원의 데이터 변경이 모든 가족 구성원의 캐시에 영향을 미쳐야 했다. 기존 방식으로는 이런 복잡한 연관 관계를 효과적으로 처리하기 어려웠다.

AOP와 커스텀 어노테이션을 활용한 해결책 💡

1. 커스텀 어노테이션 정의 🏷️

먼저 캐시 초기화를 선언적으로 지정할 수 있는 커스텀 어노테이션을 만든다.

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 데이터 수정 후 가족 구성원의 캐시를 초기화하기 위한 어노테이션
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface EvictFamilyCache {
    /**
     * 초기화할 캐시 이름 배열
     */
    String[] cacheNames();

    /**
     * memberId를 나타내는 파라미터 이름
     */
    String memberIdParam() default "memberId";
}

이 어노테이션은 초기화할 캐시의 이름 배열과 회원 ID를 찾을 파라미터 이름을 정의한다.

2. 캐시 초기화 서비스 구현 🔄

캐시 초기화 로직을 담당할 서비스는 다음과 같다.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
@Slf4j
public class CacheResetService {
    private static final Logger log = LoggerFactory.getLogger(CacheResetService.class);

    private final RedisTemplate<String, Object> redisTemplate;
    private final FamilyService familyService;


    /**
     * 특정 회원의 특정 캐시를 초기화
     */
    public void resetMemberCache(String cacheName, Long memberId) {
        String fullKey = cacheName + "::" + memberId;
        redisTemplate.delete(fullKey);
        log.debug("캐시 초기화됨: {}", fullKey);
    }

    /**
     * 특정 회원과 그 가족 구성원 모두의 여러 캐시를 초기화
     */
    public void resetFamilyMultipleCaches(String[] cacheNames, Long memberId) {
        long startTime = System.currentTimeMillis();
        int cacheCount = 0;

        try {
            // 1. 가족 구성원 목록 조회
            List<Long> familyMemberIds = familyService.getFamilyMemberIds(memberId);
            // 본인도 포함
            if (!familyMemberIds.contains(memberId)) {
                familyMemberIds.add(memberId);
            }

            // 2. 모든 가족 구성원의 여러 캐시 초기화
            for (String cacheName : cacheNames) {
                for (Long familyMemberId : familyMemberIds) {
                    String key = cacheName + "::" + familyMemberId;
                    redisTemplate.delete(key);
                    cacheCount++;
                }
            }

            long duration = System.currentTimeMillis() - startTime;
            log.debug("가족 캐시 초기화 완료: {}ms ({}개 캐시): 회원ID={}", 
                      duration, cacheCount, memberId);
        } catch (Exception e) {
            log.error("가족 캐시 초기화 실패: 회원ID={}", memberId, e);
            throw e;
        }
    }
}

주요 기능:

  • 단일 회원의 특정 캐시 초기화
  • 회원과 그 가족 구성원 모두의 여러 캐시 동시 초기화
  • 성능 모니터링을 위한 실행 시간 측정

3. AOP Aspect 구현 🔄

커스텀 어노테이션이 붙은 메서드가 실행될 때 캐시 초기화 로직을 적용하는 Aspect를 구현한다.

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class FamilyCacheEvictionAspect {
    private static final Logger log = LoggerFactory.getLogger(FamilyCacheEvictionAspect.class);

    private final CacheResetService cacheResetService;

    @Autowired
    public FamilyCacheEvictionAspect(CacheResetService cacheResetService) {
        this.cacheResetService = cacheResetService;
    }

    /**
     * @EvictFamilyCache 어노테이션이 적용된 메서드 실행 후 캐시 초기화 수행
     */
    @AfterReturning("@annotation(evictFamilyCache)")
    public void evictFamilyCaches(JoinPoint joinPoint, EvictFamilyCache evictFamilyCache) {
        try {
            // 1. 어노테이션에서 설정한 캐시 이름 배열 가져오기
            String[] cacheNames = evictFamilyCache.cacheNames();

            // 2. memberId 파라미터 찾기
            Long memberId = extractMemberId(joinPoint, evictFamilyCache.memberIdParam());

            if (memberId != null) {
                log.debug("캐시 초기화 시작: 메서드={}, 회원ID={}", 
                         joinPoint.getSignature().toShortString(), memberId);

                // 3. 모든 지정된 캐시에 대해 가족 전체의 캐시 초기화
                cacheResetService.resetFamilyMultipleCaches(cacheNames, memberId);
            } else {
                log.warn("회원ID를 찾을 수 없어 캐시 초기화를 건너뜁니다: 메서드={}",
                        joinPoint.getSignature().toShortString());
            }
        } catch (Exception e) {
            // 4. 캐시 초기화 실패가 원래 비즈니스 로직에 영향을 주지 않도록 예외 처리
            log.error("캐시 초기화 중 오류 발생: {}", joinPoint.getSignature().toShortString(), e);
        }
    }

    /**
     * JoinPoint에서 memberId 추출 (파라미터 이름 또는 객체 내부 필드)
     */
    private Long extractMemberId(JoinPoint joinPoint, String memberIdParam) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        String[] parameterNames = signature.getParameterNames();
        Object[] args = joinPoint.getArgs();

        // 1. 파라미터 이름으로 찾기
        for (int i = 0; i < parameterNames.length; i++) {
            if (parameterNames[i].equals(memberIdParam)) {
                return (Long) args[i];
            }
        }

        // 2. DTO 객체에서 getMemberId 메서드로 찾기
        for (Object arg : args) {
            if (arg != null) {
                try {
                    java.lang.reflect.Method method = arg.getClass().getMethod("getMemberId");
                    if (method != null && method.getReturnType().equals(Long.class)) {
                        return (Long) method.invoke(arg);
                    }
                } catch (Exception e) {
                    // getMemberId 메서드가 없거나 호출 실패시 무시
                }
            }
        }

        return null;
    }
}

🔍 Aspect의 주요 역할:

  • @EvictFamilyCache 어노테이션이 붙은 메서드 감지
  • 메서드 파라미터에서 memberId 자동 추출
  • 캐시 초기화 서비스 호출
  • 예외 처리를 통한 비즈니스 로직 보호

4. 커스텀 어노테이션 사용 예시 👨‍💻

이제 다양한 API 메서드에 @EvictFamilyCache 어노테이션을 적용해 데이터 변경 시 자동으로 캐시를 초기화할 수 있다.

컨트롤러에서 사용

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/members")
public class MemberDataController {

    @Autowired
    private MemberDataService memberDataService;

    /**
     * 회원 프로필 업데이트 API
     * - getMain 캐시를 초기화
     */
    @PutMapping("/{memberId}/profile")
    @EvictFamilyCache(cacheNames = {CacheNames.GET_MAIN}, memberIdParam = "memberId")
    public ResponseEntity<?> updateProfile(@PathVariable Long memberId, @RequestBody ProfileDTO profileDTO) {
        memberDataService.updateProfile(memberId, profileDTO);
        return ResponseEntity.ok().build();
    }

    /**
     * 게시글 작성 API
     * - getBoardList, getMain 두 개의 캐시를 동시에 초기화
     */
    @PostMapping("/{memberId}/posts")
    @EvictFamilyCache(
        cacheNames = {CacheNames.GET_BOARD_LIST, CacheNames.GET_MAIN}, 
        memberIdParam = "memberId"
    )
    public ResponseEntity<?> createPost(@PathVariable Long memberId, @RequestBody PostDTO postDTO) {
        Long postId = memberDataService.createPost(memberId, postDTO);
        return ResponseEntity.status(HttpStatus.CREATED).body(postId);
    }
}

서비스 계층에서 사용

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class MemberDataService {

    /**
     * 사용자 설정 업데이트 - getMain, getWeather 캐시 초기화
     */
    @Transactional
    @EvictFamilyCache(cacheNames = {CacheNames.GET_MAIN, CacheNames.GET_WEATHER})
    public void updatePreferences(Long memberId, PreferencesDTO preferencesDTO) {
        // 사용자 설정 업데이트 로직
    }
}

📝 어노테이션 사용의 장점:

  • 선언적 방식으로 캐시 초기화 지정
  • 컨트롤러와 서비스 계층 모두에 적용 가능
  • 비즈니스 로직과 캐시 관리 로직 분리

⚙️ 추가 고려사항:

  • 병목 현상 방지를 위한 가족 구성원 조회 최적화
  • 캐시 키 패턴의 일관성 유지
  • 로깅 레벨 조정으로 운영 환경에서의 로그 부하 관리

결론 ✨

AOP와 커스텀 어노테이션을 활용한 Redis 캐시 초기화 전략은 코드 중복 제거와 관심사 분리라는 기본적인 이점을 넘어 복잡한 캐시 관리 요구사항을 효과적으로 해결한다. 개발자가 캐시 초기화 코드를 누락할 가능성이 크게 줄어들고, 한 사용자의 데이터 변경이 가족 구성원 전체의 캐시에 일관되게 반영된다.

🎯 주요 개선 효과:

  • 즉각적인 데이터 반영 - 사용자가 변경한 데이터가 즉시 화면에 표시됨
  • 캐시 TTL 최적화 - 변경이 없는 경우 TTL을 더 길게 설정하여 성능 향상
  • 일관된 캐시 관리 - 다양한 API에서 동일한 캐시 초기화 패턴 적용
  • 코드 유지보수성 향상 - 캐시 관리 로직이 중앙화되어 변경이 용이

이러한 캐시 관리 전략은 특히 가족 구성원 간 데이터 공유가 중요한 우리 서비스에서 사용자 경험과 시스템 성능을 균형 있게 향상시키는 효과적인 방법이다.

반응형
Comments