본문 바로가기

개발공부/API

API 캐시 도입 (@Cacheable)

 

동일한 데이터를 가져오면서 해당 데이터 호출하는 api 횟수가 많아 생기는 이슈가 발생하였다.

해당 이슈는 캐시 처리 해서 디비 커넥션을 줄일 수 있다고 판단하였다.

 

  • 이슈상황

RDS 의 CPU가 갑작스럽게 치는 이슈 생김

  • 가설
    1. RDS 매일 백업이 일어난다.
    2. 쿼리의 속도가 느리다.
    3. 커넥션이 많아졌다.

 

문제의 쿼리 확인

 

캐시 도입 ( Cacheable / EhCache / ElastiCache )

@Cacheable, @EhCache, 그리고 AWS ElastiCache는 모두 캐싱과 관련된 개념이지만, 각각의 적용 범위와 사용 목적이 다릅니다. 이를 비교하면서 설명하겠습니다.


1. @Cacheable

  • 설명
  • @Cacheable은 Spring Framework에서 제공하는 어노테이션으로, 메서드 레벨에서 캐싱을 설정하는 데 사용됩니다.
  • 주요 특징
    • Spring의 캐싱 추상화에 기반합니다.
    • 메서드의 반환값을 캐시에 저장하고, 동일한 입력 값으로 메서드를 다시 호출하면 캐시된 값을 반환합니다.
    • 실제 캐시 저장소는 다양한 캐시 제공자(EhCache, Redis, Caffeine 등)를 사용할 수 있으며, 이를 Spring Cache Manager로 관리합니다.
  • 한계
    • 자체적으로 캐시 저장소를 제공하지 않으며, 백엔드 캐시 솔루션이 필요합니다.
    • 주로 애플리케이션 레벨에서 동작하며, 분산 캐싱 솔루션으로 사용하려면 추가적인 설정이 필요합니다.

2. @EhCache

  • 설명
  • @EhCache는 EhCache 라이브러리와 함께 사용되며, 캐싱을 위해 EhCache API 또는 설정을 활용하는 방식입니다.
  • 주요 특징
    • EhCache는 JVM 내부 메모리 기반의 캐싱 솔루션입니다.
    • Spring과 독립적으로 동작하거나 Spring과 통합하여 사용할 수 있습니다.
    • 강력한 캐시 관리 기능을 제공하며, XML이나 프로그래밍 방식으로 세부적인 캐시 설정이 가능합니다.
    • 디스크 캐시, TTL(Time to Live), TTI(Time to Idle) 등 고급 설정을 지원합니다.
  • 한계
    • JVM 기반으로 동작하므로, 분산 캐시를 직접적으로 지원하지 않습니다.
    • 분산 환경에서 캐싱하려면 Terracotta 같은 확장 솔루션이 필요합니다.

3. AWS ElastiCache

  • 설명Redis와 Memcached의 두 가지 엔진을 지원합니다.
  • AWS ElastiCache는 Amazon Web Services에서 제공하는 클라우드 기반 분산 캐시 서비스입니다.
  • 주요 특징
    • 클라우드 기반 분산 캐시로, 고가용성과 확장성이 뛰어납니다.
    • 애플리케이션 서버 간 캐시 데이터를 공유할 수 있어 분산 환경에서 적합합니다.
    • 캐시 데이터는 메모리에 저장되므로 읽기/쓰기 속도가 빠릅니다.
    • AWS 관리형 서비스로, 복잡한 인프라 관리를 단순화합니다.
    • Redis의 고급 기능(예: Pub/Sub, Lua scripting)과 Memcached의 단순성을 모두 제공합니다.
  • 한계
    • 네트워크를 통해 접근하므로, 레이턴시가 JVM 내부 캐싱 솔루션(EhCache)보다 높을 수 있습니다.
    • 사용량에 따라 비용이 발생하며, 온프레미스 환경에서는 사용할 수 없습니다.

4. 차이점 비교

항목 @Cacheable @EhCache AWS ElastiCache

캐시 제공 방식 Spring의 추상화, 백엔드 캐시 필요 JVM 내부 메모리 기반 클라우드 기반 분산 캐시
적용 범위 애플리케이션 코드 레벨 애플리케이션 내부 클라우드/분산 환경
캐시 저장소 EhCache, Redis, Memcached 등 지원 EhCache (단일 JVM, 옵션에 따라 디스크) Redis 또는 Memcached 엔진 사용
분산 지원 백엔드 캐시 의존 직접 지원하지 않음 분산 캐시 기본 지원
성능 메서드 호출 시 캐시 사용 JVM 내에서 매우 빠름 네트워크를 통한 접근으로 약간의 레이턴시
운영 환경 코드 기반 관리 애플리케이션 내부에서만 동작 AWS 클라우드 관리형 서비스
유연성 다양한 캐시 솔루션 연동 가능 EhCache에 종속적 Redis/Memcached의 다양한 기능 지원
사용 비용 오픈소스 기반 오픈소스 기반 AWS 사용 요금 적용

5. 사용 추천 시나리오

  1. @Cacheable
    • Spring 기반 애플리케이션에서 선언적으로 캐싱 로직을 간단히 추가하려는 경우.
    • 다양한 캐시 제공자와 유연하게 연동하고 싶을 때.
  2. @EhCache
    • 단일 JVM에서 작동하는 고성능 캐시가 필요할 때.
    • 캐시 설정을 세밀하게 제어하고 싶을 때.
  3. AWS ElastiCache
    • 클라우드 기반 분산 환경에서 캐싱을 구현해야 할 때.
    • 고가용성과 확장성이 중요한 경우.
    • AWS 인프라에서 애플리케이션을 운영 중일 때.

 

 

@Cacheable 도입

동일한 로직과 동일한 파라메터로 api 호출이 많을 경우엔  @Cacheable  도입이 적당하다고 판단. 

 

1. 캐시 삭제를 위한 스케줄 도입 ( 30분 마다 초기화) - CacheResetScheduler.java

package com.lifefourcuts.api.task;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class CacheResetScheduler {
    private boolean taskBatchEnabled = true;
    private final CacheManager cacheManager;
    @Scheduled(cron = "0 */30 * * * *") // 매 30분마다 실행
    public void clearFrameResourceCache() {
        if( taskBatchEnabled ) {
            //초기화
            Cache frameResourceCache = cacheManager.getCache("frameResource");
            System.out.println("[CACHE-TASK] Clearing 'frameResource' cache start.");
            if (frameResourceCache != null) {
                frameResourceCache.clear(); // 캐시 초기화
                System.out.println("[CACHE-TASK] Clearing 'frameResource' cache cleared successfully.");
            }
            Cache frameDetailCache = cacheManager.getCache("frameDetail");
            System.out.println("[CACHE-TASK] Clearing 'frameDetail' cache start.");
            if (frameDetailCache != null) {
                frameDetailCache.clear(); // 캐시 초기화
                System.out.println("[CACHE-TASK] Clearing 'frameResource' cache cleared successfully.");
            }
            Cache frameListCache = cacheManager.getCache("frameList");
            System.out.println("[CACHE-TASK] Clearing 'frameList' cache start.");
            if (frameListCache != null) {
                frameListCache.clear(); // 캐시 초기화
                System.out.println("[CACHE-TASK] Clearing 'frameList' cache cleared successfully.");
            }
        }
    }
}

 

 

2.서비스 임플 단에서 캐시 데이터 도입 - VersionServiceImpl.java

    // 버전 번호로 프레임 목록 조회
    @Override
    @Cacheable(value = "frameList", key = "#versionNo + '-' + #licenseDivision")  // "id" 값을 key로 사용하여 캐시
    public List<Frame> getAvailableFramesByVersion(Long versionNo, LicenseDivision licenseDivision) {
        log.debug("### getAvailableFramesByVersion");
        List<Frame> frames = new ArrayList<>();
        QFrame qFrame = QFrame.frame;
        QDtsAccntFrame qDtsAccntFrame = QDtsAccntFrame.dtsAccntFrame;
        QDtsAccnt qDtsAccnt = QDtsAccnt.dtsAccnt;
        QCtgryDtsAccnt qCtgryDtsAccnt = QCtgryDtsAccnt.ctgryDtsAccnt;
        QCtgryAccnt qCtgryAccnt = QCtgryAccnt.ctgryAccnt;
        QFrameAccnt qFrameAccnt = QFrameAccnt.frameAccnt;
        QPpAccnt qPpAccnt = QPpAccnt.ppAccnt;
        QMainAccnt qMainAccnt = QMainAccnt.mainAccnt;
        QLicnsAccnt qLicnsAccnt = QLicnsAccnt.licnsAccnt;
        QRcmdDtsCtntChoice qRcmdDtsCtntChoice = QRcmdDtsCtntChoice.rcmdDtsCtntChoice;
        QRcmdDtsCtnt qRcmdDtsCtnt = QRcmdDtsCtnt.rcmdDtsCtnt;
        QRcmdCtntAccnt qRcmdCtntAccnt = QRcmdCtntAccnt.rcmdCtntAccnt;

        // 배포 중인 버전에 속한 프레임은 프레임의 운영기간, IP의 계약기간 및 삭제 유무는 체크하지 않음.
        List<Frame> categoryAccountFrames = jpaQueryFactory.select(qFrame)
                .from(qFrame)
                .join(qDtsAccntFrame).on(qFrame.frameId.eq(qDtsAccntFrame.frameId))
                .join(qDtsAccnt).on(qDtsAccntFrame.dtsAccntNo.eq(qDtsAccnt.dtsAccntNo))
                .join(qCtgryDtsAccnt).on(qDtsAccnt.dtsAccntNo.eq(qCtgryDtsAccnt.dtsAccntNo))
                .join(qCtgryAccnt).on(qCtgryDtsAccnt.ctgryAccntNo.eq(qCtgryAccnt.ctgryAccntNo))
                .join(qMainAccnt).on(qCtgryAccnt.mainAccntNo.eq(qMainAccnt.mainAccntNo))
                .join(qLicnsAccnt).on(qMainAccnt.licnsAccntNo.eq(qLicnsAccnt.licnsAccntNo))
                .where(

                        qLicnsAccnt.licnsDiv.eq(licenseDivision.getCode())
                                .and(qLicnsAccnt.verNo.eq(versionNo))
                                .and(qDtsAccnt.accntTy.eq(DetailAccountType.FRAME.getCode()))
                ).fetch();
        log.debug("### categoryAccountFrames:{}", categoryAccountFrames.size());
        List<Frame> frameAccountFrames = jpaQueryFactory.select(qFrame)
                .from(qFrame)
                .join(qDtsAccntFrame).on(qFrame.frameId.eq(qDtsAccntFrame.frameId))
                .join(qDtsAccnt).on(qDtsAccntFrame.dtsAccntNo.eq(qDtsAccnt.dtsAccntNo))
                .join(qFrameAccnt).on(qDtsAccnt.dtsAccntNo.eq(qFrameAccnt.dtsAccntNo))
                .join(qMainAccnt).on(qFrameAccnt.mainAccntNo.eq(qMainAccnt.mainAccntNo))
                .join(qLicnsAccnt).on(qMainAccnt.licnsAccntNo.eq(qLicnsAccnt.licnsAccntNo))
                .where(

                        qLicnsAccnt.licnsDiv.eq(licenseDivision.getCode())
                                .and(qLicnsAccnt.verNo.eq(versionNo))
                                .and(qDtsAccnt.accntTy.eq(DetailAccountType.FRAME.getCode()))
                ).fetch();
        log.debug("### frameAccountFrames:{}", frameAccountFrames.size());
        List<Frame> ppAccountFrames = jpaQueryFactory.select(qFrame)
                .from(qFrame)
                .join(qDtsAccntFrame).on(qFrame.frameId.eq(qDtsAccntFrame.frameId))
                .join(qDtsAccnt).on(qDtsAccntFrame.dtsAccntNo.eq(qDtsAccnt.dtsAccntNo))
                .join(qPpAccnt).on(qDtsAccnt.dtsAccntNo.eq(qPpAccnt.dtsAccntNo))
                .join(qMainAccnt).on(qPpAccnt.mainAccntNo.eq(qMainAccnt.mainAccntNo))
                .join(qLicnsAccnt).on(qMainAccnt.licnsAccntNo.eq(qLicnsAccnt.licnsAccntNo))
                .where(

                        qLicnsAccnt.licnsDiv.eq(licenseDivision.getCode())
                                .and(qLicnsAccnt.verNo.eq(versionNo))
                                .and(qDtsAccnt.accntTy.eq(DetailAccountType.FRAME.getCode()))
                ).fetch();
        log.debug("### ppAccountFrames:{}", ppAccountFrames.size());
        List<Frame> recommendContentAccountFrames = jpaQueryFactory.select(qFrame)
                .from(qFrame)
                .join(qRcmdDtsCtntChoice).on(qFrame.frameId.eq(qRcmdDtsCtntChoice.mmtSrnId))
                .join(qRcmdDtsCtnt).on(qRcmdDtsCtntChoice.rcmdDtsCtntNo.eq(qRcmdDtsCtnt.rcmdDtsCtntNo))
                .join(qRcmdCtntAccnt).on(qRcmdDtsCtnt.rcmdCtntAccntNo.eq(qRcmdCtntAccnt.rcmdCtntAccntNo))
                .join(qMainAccnt).on(qRcmdCtntAccnt.mainAccntNo.eq(qMainAccnt.mainAccntNo))
                .join(qLicnsAccnt).on(qMainAccnt.licnsAccntNo.eq(qLicnsAccnt.licnsAccntNo))
                .where(
                        qLicnsAccnt.licnsDiv.eq(licenseDivision.getCode())
                                .and(qLicnsAccnt.verNo.eq(versionNo))
                                .and(qRcmdDtsCtntChoice.mmtSrnTy.eq(RecommendNextScreenType.FRAME.getCode()))
                ).fetch();
        log.debug("### recommendContentAccountFrames:{}", recommendContentAccountFrames.size());
        Map<String,Frame> map = new HashMap<>();
        if(CollectionUtils.isNotEmpty(categoryAccountFrames)) {
            categoryAccountFrames.forEach(f -> {
                log.debug("### CTGRY:{}", f.getFrameId());
                if( !map.containsKey(f.getFrameId()))
                    map.put(f.getFrameId(),f);
            });
        }
        if(CollectionUtils.isNotEmpty(frameAccountFrames)) {
            frameAccountFrames.forEach(f -> {
                log.debug("### FRAME:{}", f.getFrameId());
                if( !map.containsKey(f.getFrameId()))
                    map.put(f.getFrameId(),f);
            });
        }
        if(CollectionUtils.isNotEmpty(ppAccountFrames)) {
            ppAccountFrames.forEach(f -> {
                log.debug("### PP:{}", f.getFrameId());
                if( !map.containsKey(f.getFrameId()))
                    map.put(f.getFrameId(),f);
            });
        }
        if(CollectionUtils.isNotEmpty(recommendContentAccountFrames)) {
            recommendContentAccountFrames.forEach(f -> {
                log.debug("### RECNT:{}", f.getFrameId());
                if( !map.containsKey(f.getFrameId()))
                    map.put(f.getFrameId(),f);
            });
        }
        if(!map.isEmpty()) {
            frames = new ArrayList<>(map.values());
        }
        return frames;
    }
    
    
    // 버전 번호로 프레임 목록 조회
    @Override
    @Cacheable(value = "frameDetail", key = "#versionNo + '-' + #licenseDivision")  // "id" 값을 key로 사용하여 캐시
    public List<FrameDetailDTO> getFrameDetailListByVersion(Long versionNo, LicenseDivision licenseDivision , String nationCode, LocalDateTime reqDate) {
        log.debug("### getFrameDetailListByVersion");
        List<FrameDetailDTO> returnDetailList = new ArrayList<>();
        List<Map<String, Object>> frameInfoList = frameRepository.getFrameDetailListByVersion(versionNo, licenseDivision.getCode() , nationCode, reqDate);
        for(int i = 0 ; i < frameInfoList.size() ; i++){
            FrameDetailDTO frameDetail = new FrameDetailDTO(frameInfoList.get(i));
            returnDetailList.add(frameDetail);
        }

        return returnDetailList;
    }
    
    
    @Override
    @Cacheable(value = "frameResource", key = "#versionNo + '-' + #licenseDivision")  // "id" 값을 key로 사용하여 캐시
    public List<Map<String,Object>> getFrameResourceListByVersion(Long versionNo, LicenseDivision licenseDivision , String nationCode, LocalDateTime reqDate) {
        log.debug("### getFrameResourceListByVersion");
        //List<Map<String, Object>> returnDetailList = frameRepository.getFrameDetailListByVersion(versionNo, licenseDivision.getCode() , nationCode, reqDate);
        List<Map<String, Object>> returnDetailList = new ArrayList<>();
        if(bucket.equals("release-renewal-s3")){
            // relase
            returnDetailList = frameRepository.getFrameResourceListByVersionReplaceRelease(versionNo, licenseDivision.getCode() , reqDate);
        }else if(bucket.equals("field-renewal-s3")){
            // field
            returnDetailList = frameRepository.getFrameResourceListByVersionReplaceFiled(versionNo, licenseDivision.getCode() , reqDate);
        }else{
            // local
            returnDetailList = frameRepository.getFrameResourceListByVersion(versionNo, licenseDivision.getCode() , reqDate);
        }
        return returnDetailList;
    }

'개발공부 > API' 카테고리의 다른 글

잘된 REST API 설계 vs 잘못된 REST API 설계  (0) 2025.02.24
REST API / REST / RESTful API 란 ?  (0) 2025.02.24
RESTful API 이란?  (0) 2025.02.13
REST, REST API, RESTful 특징  (0) 2025.02.13