[Spring Boot] 캐싱 사용하기

이정진·2024년 2월 22일
0

개발

목록 보기
5/8
post-thumbnail

왜 필요한가?

공작소 서비스를 개발하고 테스트를 진행하고 있는 와중, 배너 정보를 반환하는 API 호출 수가 굉장히 많은 것을 확인했다. 프론트분들의 개발 과정에서 아직 최적화가 되지 않아서 발생하는 상황으로 예상이 되는데, 짧은 시간동안 show-sql 옵션이 걸려있어서인지 서버로그가 약 100만줄 이상 쌓여있었다. 그렇기에, 백엔드에서 이에 대해 API 요청이 올 시 캐싱을 통해 응답을 할 수 있도록 하여 성능 개선을 하고자 캐싱을 적용하게 되었다.

Cache

API Caching


위 사진은 컴퓨터 구조 과목에서 캐시에 대하여 배울 때 이미지이다. 이렇게 Main Memory, 즉 백엔드 기준 API 요청에 대해 계속 DB에 쿼리를 통해 조회를 하는 것이 아닌 캐시에 정보를 저장해놓음으로써 DB까지 도달하지 않고 빠르게 API 요청에 대한 응답을 할 수 있도록 하는 것을 말한다. 동일한 요청에 대해 다시 DB에 쿼리를 활용하여 데이터를 조회하는 행동이 발생하지 않으므로, 불필요한 일을 반복하지 않게 된다.

장점

  • 동일한 요청이 반복적으로 오게 될 때, 해당 결과값을 캐싱하여 가져오게 되어 API 응답 속도가 향상됨.

단점

  • 만약 동일한 요청에 대해 결과값이 달라지는 빈도가 높다면, 오히려 부하가 발생하여 성능이 떨어지는 결과를 가져올 수 있음

적용 로직

추후 배너 등록/수정/삭제 등의 기능들을 기획자분들이 원할하게 하실 수 있도록 백오피스를 구축하고자 생각하고 있는데, 이에 맞추어 API화를 시키고 있는 상황에 같이 적용하고자 한다.

캐시 삭제 조건

  • 배너 등록 (해당하는 배너 타입에 대한 캐시만 삭제)
  • 배너 수정 (해당하는 배너 타입에 대한 캐시만 삭제)
  • 배너 삭제 (모든 캐시 삭제)
  • 배너 게시 상태 변경 (모든 캐시 삭제)

하위 두 개의 경우, 조회한 배너의 타입을 이용해서 특정 캐시만 삭제할 수 있지만, allEntries를 사용해보고자 모든 캐시를 삭제하는 방식으로 구현했다.

캐시 등록 조건

  • 메인 페이지 배너 반환
  • 프로젝트 페이지 배너 반환
  • 공모전 페이지 배너 반환

Spring 내부 캐싱 적용

Dependency

implementation 'org.springframework.boot:spring-boot-starter-cache'

CacheConfig

null은 캐싱되지 않도록 설정해주기 위하여, CacheConfig를 별도로 구성했다.
별도로 추가하고자 하는 설정이 없다면, Application파일에 바로 @EnableCaching을 추가해도 된다.

@EnableCaching
@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager();
        
        // null값은 캐싱되지 않도록 설정
        cacheManager.setAllowNullValues(false);
        
        return cacheManager;
    }
}

@EnableCaching: 캐시 활성화를 위한 어노테이션

관련 어노테이션

@Cacheable: 캐시 조회, 저장 기능
동작 방식

  • 캐시 존재 시: 메소드 호출 전 실행
  • 캐시 미 존재 시: 메소드 호출 후 실행

관련 옵션

  • value: 캐시로 사용될 이름을 지정
  • key: 캐시의 key로 사용될 것 지정 (SpEL 표현식 사용)
  • condition: 캐시를 저장하는 조건 (SpEL 표현식 사용)

@CachePut: 캐시 저장 기능
동작 방식

  • 캐시 존재 시: 메소드 호출 후 실행
  • 캐시 미 존재 시: 메소드 호출 후 실행

관련 옵션

  • value: 캐시로 사용될 이름을 지정
  • key: 캐시의 key로 사용될 것 지정 (SpEL 표현식 사용)
  • condition: 캐시를 저장하는 조건 (SpEL 표현식 사용), false이면 저장하지 않음.

@CacheEvict: 캐시 삭제 기능
관련 옵션

  • value: 삭제할 캐시로 사용된 이름을 지정
  • key: 삭제할 캐시의 key 지정
  • condition: 캐시를 저장하는 조건 (SpEL 표현식 사용)
  • beforeInvocation: true = 메소드 호출 전 실행, false = 메소드 호출 후 실행
  • allEntries: 전체 캐시 삭제 여부

true일 경우, 메소드 호출 전 캐시를 비우는 작업을 수행하게 된다. 기본 값은 false이다.

SpEL(Spring Expression Language) 관련 문서

@CacheConfig(cacheName = ?)를 설정하면, 메소드에서 별도로 value를 지정해줄 필요가 없다. 다만, value를 메소드 단위에서 설정해주면, 해당 value가 더 우선시된다.

실제 API 적용

@Cacheable(key = "'MAIN'")
    public List<BannerRes> getMainImageList() {
        // Business Logic
        List<Banner> bannerList = bannerRepository.findAllByDomainTypeAndDeletedAtIsNullOrderByPriorityAsc(DomainType.MAIN);

        // Response
        return bannerList.stream()
                .map(BannerRes::of)
                .collect(Collectors.toList());
    }

    @Cacheable(key = "'PROJECT'")
    public List<BannerRes> getProjectImageList() {
        // Business Logic
        List<Banner> bannerList = bannerRepository.findAllByDomainTypeAndDeletedAtIsNullOrderByPriorityAsc(DomainType.PROJECT);

        // Response
        return bannerList.stream()
                .map(BannerRes::of)
                .collect(Collectors.toList());
    }

    @Cacheable(key = "'CONTEST'")
    public List<BannerRes> getContestImageList() {
        // Business Logic
        List<Banner> bannerList = bannerRepository.findAllByDomainTypeAndDeletedAtIsNullOrderByPriorityAsc(DomainType.CONTEST);

        // Response
        return bannerList.stream()
                .map(BannerRes::of)
                .collect(Collectors.toList());
    }

    @CacheEvict(key = "#bannerReq.domainType", condition = "#bannerReq != null && #bannerReq.domainType != null")
    @Transactional
    public BannerRes registerBanner(Member member, BannerReq bannerReq, MultipartFile multipartFile) {
        // Validation
        if(!member.getMemberType().equals(MemberType.ADMIN)) {
            throw new ApplicationException(ErrorCode.UNAUTHORIZED_EXCEPTION);
        }
        if(multipartFile == null || multipartFile.isEmpty()) {
            throw new ApplicationException(ErrorCode.INVALID_VALUE_EXCEPTION);
        }

        // Business Logic
        String imageUrl = s3Client.upload(multipartFile, "banner");
        Banner banner = bannerReq.from(imageUrl);
        Banner savedBanner = bannerRepository.save(banner);

        // Response
        return BannerRes.of(savedBanner);
    }

    @CacheEvict(key = "#bannerReq.domainType", condition = "#bannerReq != null && #bannerReq.domainType != null")
    @Transactional
    public BannerRes updateBanner(Member member, Long bannerId, BannerReq bannerReq, MultipartFile multipartFile) {
        // Validation
        if(!member.getMemberType().equals(MemberType.ADMIN)) {
            throw new ApplicationException(ErrorCode.UNAUTHORIZED_EXCEPTION);
        }
        Banner banner = bannerRepository.findById(bannerId).orElseThrow(() -> new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION));

        // Business Logic
        String imageUrl = "";
        if(multipartFile != null && !multipartFile.isEmpty()) {
            s3Client.delete(banner.getImageUrl());
            imageUrl = s3Client.upload(multipartFile, "banner");
        }

        banner.update(bannerReq, imageUrl);
        Banner updatedBanner = bannerRepository.save(banner);

        // Response
        return BannerRes.of(updatedBanner);
    }

    @CacheEvict(allEntries = true)
    @Transactional
    public BannerRes changeIsPost(Member member, Long bannerId) {
        // Validation
        if(!member.getMemberType().equals(MemberType.ADMIN)) {
            throw new ApplicationException(ErrorCode.UNAUTHORIZED_EXCEPTION);
        }
        Banner banner = bannerRepository.findById(bannerId).orElseThrow(() -> new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION));

        // Business Logic
        banner.changeIsPost();
        Banner updatedBanner = bannerRepository.save(banner);

        // Response
        return BannerRes.of(updatedBanner);
    }

    @CacheEvict(value = {"bannerMain", "bannerProject", "bannerContest"})
    @Transactional
    public void deleteBanner(Member member, Long bannerId) {
        // Validation
        if(!member.getMemberType().equals(MemberType.ADMIN)) {
            throw new ApplicationException(ErrorCode.UNAUTHORIZED_EXCEPTION);
        }
        Banner banner = bannerRepository.findById(bannerId).orElseThrow(() -> new ApplicationException(ErrorCode.NOT_FOUND_EXCEPTION));

        // Business Logic
        s3Client.delete(banner.getImageUrl());
        bannerRepository.delete(banner);

        // Response
    }

주의사항
CacheEvict를 구현하는 과정에서, record 내의 enum을 key로 지정하고자 했는데, condition을 설정하지 않아 아래와 같은 오류가 떴었다.

EL1007E: Property or field 'domainType' cannot be found on null

EL1011E: Method call: Attempted to call method name() on null context object

이러한 오류가 뜬다면, null check를 조건으로 걸어주어야 한다.

최초 호출 시

재호출 시

재호출 시, 별도로 쿼리가 실행되지 않고 바로 반환되는 것을 볼 수 있다.

삭제 후 호출 시

삭제 후 호출하게 되면, 쿼리가 실행되면서 캐시가 사라졌다는 것을 알 수 있다.

다음
현재는 Java 내부의 자료구조를 활용한 캐싱 방식으로 구현했지만, CacheManger의 종류를 변경하여 다른 방식으로도 캐싱을 구현할 수 있기에, Redis를 활용하여 캐싱하는 방식도 추후 다루어볼 예정이다.

레퍼런스

0개의 댓글