본문 바로가기
SpringBoot/성능 개선 흔적들

[Spring] 레디스 없이 caffeine cache로 조회 API 성능 개선

by rla124 2024. 8. 26.

1차 MVP 이후 출시를 하고 기능 고도화를 위해 2차 MVP를 진행하고 있는 프로젝트에서 꽃다발 커스터마이징을 할 때 필요한 꽃 선택을 위해 내가 맡았던 "키워드 별 꽃 조회 API" 통신 과정이 중요했었다. 하지만 QA 당시 꽃 데이터, 특히 서로 다른 꽃말에 대해 로딩해야할 이미지가 많다보니 일부 iOS 기기에서 이미지 로딩 속도가 너무 더디다는 의견이 나왔었고 프론트에서 이미지 캐싱을 적용해보기도 하였으나 WIFI 신호에 상관없이 빠른 통신을 위해 백엔드에서도 이 문제를 해결하고자 한다. 코드는 이 링크에서 확인할 수 있다. 

 

Caffeine Cache 도입 배경

이미지 로딩 속도를 개선하기 위해 내가 생각했던 여러 대안은 아래와 같았다.

 

1. cloudFront 도입

AWS CloudFront를 도입하게 되면 컨텐츠 전송 네트워크로 캐싱을 하여 이미지를 캐싱해두었다가 같은 요청 시 캐싱해두었던 데이터를 다시 응답하여 속도를 높이는 것이다. 

 

2. 이미지 리사이즈

이 방법은 프론트에서 MultiPart로 이미지를 보내면 컨트롤러에서 받아 이미지 리사이즈를 통해 S3에 업로드하는 방식인데 내가 다루려는 "키워드 별 꽃 조회 API"에서는 이미 s3 이미지 url 경로가 db에 저장이 되어있었고 이를 response로 반환하는 것이었기 때문에 이 방식을 적용하게 될 경우 처음부터 데이터 저장을 다시하게 된다는 문제점이 있어서 제외하였다. 

 

3. 조회 API 캐시 적용

AWS 단에서 캐시를 적용하는 것이 아니라 SpringBoot 의존성 추가로, 그리고 인메모리 DB인 Redis 없이도 충분히 라이브러리 상에서 제공하는 캐시를 이용하는 방법을 선택하였다. 

 

Caffeine dependencies를 왜 선택했는가

이전에 이 게시글에서 Redis를 도입하여 별도의 DB를 세팅하여 캐싱된 데이터를 저장하였었는데 AWS에서 별도의 운영DB 구축 없이 스프링부트 AOP를 적용하여 application level에서 cache 기능의 추상화를 최대한 이용하고자 이 방식을 선택했다. 다양한 캐시 지원 라이브러리가 있는데 Caffeine보다 더 많은 기능을 제공(분산 처리 등 공식 문서)하는 EhCache가 아니라 Caffeine을 선택한 계기는 아래와 같다. 

 

Caffeine Benchmarks

Caffeine 캐시가 다른 캐시 라이브러리들에 비해 읽기와 쓰기 기능에서 압도적인 성능 지표를 보여주고 있기 때문이다. 지금 내가 리펙토링 하려는 부분이 단순 data caching이기 때문에 가장 뛰어난 것을 선택하는 것이 최대 효율을 낼 수 있을 것이라 생각했다. 

 

캐시 적용 코드 

먼저 Caffeine 적용 전 성능을 먼저 살펴보고자 한다. 키워드별 꽃 조회는 keywordId를 프론트에서 받아오면 이에 해당하는 꽃 정보를 반환하는 로직으로 0은 전체 조회, 1부터는 특정 키워드 단어와 Keyword 테이블에 매핑이 되어있다. 또한 keywordId가 컨트롤러와 서비스 레이어의 유일한 파라미터였기 때문에 keywordId 별로 서로 다른 캐시 내용을 저장하는 것이 필요하여 이에 따라 구별해주는 별도의 선언이 필요했다. 

 

캐시 적용 전 0(전체 조회)을 pathVariable로 전달 시 전체 조회에서 이상치는 2~3초 사이, 평균 1600ms 대의 시간이 소요되었었고 매번 hibernate select 쿼리문이 찍혔었다. 

 

 

이제 키워드 별로 서로 다른 데이터를 조회 할 때 매번 DB 접근 없이 구별해서 캐시 데이터를 만들어보자!

 

build.gradle 추가 

// Cache
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'

 

cacheType enum 선언 

package com.whoa.whoaserver.global.config.type;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum CacheType {
	FLOWER_INFORMATION_BY_KEYWORD("keyword", 12, 200);

	private final String cacheName;
	private final int expiredAfterWrite; // hour
	private final int maximumSize; // dto count
}

 

앞으로 캐시가 다른 조회 API 혹은 캐시 삭제 등 도입될 가능성을 염두에 두고 cacheType enum을 통해 관리하고자 했다.

 

각 필드를 작성하면서 들었던 생각은 아래와 같다.

 

1. cacheName

키워드 별 꽃 조회 로직에 적용하기 때문에 캐시 이름은 "keyword"로 하였다.

 

2. expiredAfterWrite

두 번째 인자는 캐시 만료 기간으로 단위는 "시간"이다. 이 시간이 지나면 캐시 데이터가 자동으로 삭제된다. 마치 Redis의 TTL 조건과 동일하다.

 

3. maximumSize

마지막 인자는 DTO 객체의 수이다. 이 프로젝트는 꽃이 색깔이 다르면 다양한 꽃말과 그에 따른 다양한 키워드를 가질 수 있다는 점을 모두 고려하여 DB 설계를 진행하였기 때문에 실제 DB에 저장된 Flower 엔티티의 row 수는 30여개 이상으로 적어보일 수 있으나 FlowerExpression 엔티티의 row 수는 160여개 정도였다. 따라서 전체 조회 시 FlowerExpression의 정보를 모두 FlowerInfoByKeywordResonse record dto에 담아오기 때문에 size는 이를 충분히 수용할 수 있는 200개로 선정하였다. 

 

CacheConfig 작성 

package com.whoa.whoaserver.global.config;

import com.github.benmanes.caffeine.cache.Caffeine;
import com.whoa.whoaserver.global.config.type.CacheType;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;

@Configuration
@EnableCaching
public class CacheConfig {

	@Bean
	public List<CaffeineCache> caffeineCaches() {
		return Arrays.stream(CacheType.values())
			.map(cache -> new CaffeineCache(cache.getCacheName(), Caffeine.newBuilder().recordStats()
				.expireAfterWrite(cache.getExpiredAfterWrite(), TimeUnit.HOURS)
				.maximumSize(cache.getMaximumSize())
				.build()))
			.toList();
	}
	@Bean
	public CacheManager cacheManager(List<CaffeineCache> caffeineCaches) {
		SimpleCacheManager cacheManager = new SimpleCacheManager();
		cacheManager.setCaches(caffeineCaches);

		return cacheManager;
	}
}

 

레디스 캐시를 위한 CacheConfig를 작성할 때와 비슷하고 다만 Caffeine이라는 점이 다르다. CacheType에서 정의한 enum 상수에 따라 get을 이용해서 builder로 CaffeineCache를 생성한다. 

 

비즈니스 로직에 적용

@Service
@Transactional
@RequiredArgsConstructor
public class FlowerKeywordService {
    private static final int TOTAL_FLOWER_INFORMATION = 0;

    private final FlowerExpressionKeywordRepository flowerExpressionKeywordRepository;
    private final FlowerImageRepository flowerImageRepository;

    @Transactional(readOnly = true)
    @Cacheable(cacheNames = "keyword", key = "#keywordId")
    public List<FlowerInfoByKeywordResponse> getFlowerInfoByKeyword(final Long keywordId) {
        List<FlowerExpression> flowerExpressionList;
        if (keywordId == TOTAL_FLOWER_INFORMATION) {
            flowerExpressionList = getAllFlowerExpressions();
        } else {
            flowerExpressionList = getExpressionsByKeyword(keywordId);
        }

        Set<FlowerExpression> uniqueFlowerExpressions = flowerExpressionList.stream().collect(Collectors.toUnmodifiableSet());

        return uniqueFlowerExpressions.stream()
                .map(this::mapToResponse)
                .collect(Collectors.toUnmodifiableList());
    }

 

Redis 캐시와 마찬가지로 @Cacheable 어노테이션을 이용한다 cacheNames는 위에서 cacheType cacheName과 동일하게 적용하였고 위에서 언급했듯이 프론트에서 넘겨받는 keywordId 별로 서로 다른 캐시 데이터를 저장해야 했기 때문에 key를 추가로 두어 #keywordId를 통해 다르게 저장되도록 세팅했다.

 

캐시 적용 결과

성능 비교는 모두 동일한 keywordId pathVariable이 0으로 전달된 실험 상황에서 진행했다.

캐시 적용 전 1600ms의 수치가 나왔던 것이 무색할 정도의 평균 일의 자리 수치 8ms를 기록했다..... 대단한 캐시 

 

캐시에 데이터 저장 후 같은 keywordId로 재요청 시 hibernate select db 접근 쿼리가 찍히지 않는 모습이다. 바로 200 ok가 뜬다. 

 


Redis @Cacheable처럼 변경 가능성이 거의 없는 데이터 조회 API에 캐시를 적용해보았다. Redis를 적용했을 상황 당시는 더 적은 크기의 데이터임에도 30ms 대의 수치를 기록했는데 Caffeine 라이브러리를 적용하였더니 이보다 훨씬 response로 담아야할 데이터의 양이 큼에도 불구하고 한자리수 ms 수치를 기록했다. 대단한 Caffeine Cache... 다른 캐시 라이브러리에 비해 압도적인 이유를 몸소 체감했다.