[Spring Boot] Caffeine Cache를 활용하여 부하를 줄이고, 성능을 개선하자

komment·2023년 5월 15일
2

Lovebird Project

목록 보기
1/7

1. 서론

  사이드 프로젝트를 진행하다가 개선할 필요가 있어보이는 로직이 있어서 해결 후 포스팅을 하게 되었다. 커플 일기라는 도메인으로 진행하는 프로젝트인데, D-Day를 불러오는 부분에서 고민을 하게 되었다. 일반적인 방법인 DB에서 읽어오는 식으로 구현하면 어플을 실행할 때마다, 또는 해당 탭을 킬 때마다 SELECT 동작이 반복된다. 이는 DB 부하뿐만 아니라 READ 비용까지 증가시킨다. 따라서 이를 막기 위해 Cache를 활용하기로 했다.


2. 기존 로직

  유저가 HOME 화면이나 Profile 화면을 로드했을 때 Service 레이어의 findMemberByNickname() 메서드가 실행된다. 기존에는 Repository의 메서드를 이용해 해당 로직을 수행했다.

. . .
	@Transactional(readOnly = true)
    public Member findMemberByNickname(String nickname) {
        return memberRepository.findMemberByNickname(nickname).orElseThrow(EntityNotFoundException::new);
    }
. . .

3. Cache와 Caffeine Cache

캐시(cache) : 데이터나 값을 미리 복사해 놓는 임시 장소

  • Local Cache
    - 서버마다 캐시를 따로 저장
    - 다른 서버의 캐시를 참조하기 어려움
    - 속도 빠름
    - 로컬 서버 장비의 Resource를 이용한다. (Memory, Disk)
  • Global Cache
    - 여러 서버에서 캐시 서버 접근 및 참조 가능
    - 별도의 캐시 서버 이용 → 서버 간 데이터 공유가 쉬움
    - 네트워크 트래픽을 사용해야 해서 로컬 캐시보다는 느리다.
    - 데이터를 분산하여 저장 가능

  캐시는 크게 로컬 캐시와 글로벌 캐시로 나뉘는데, 현재는 서버가 단일노드이기 때문에 다른 서버의 캐시를 참조할 일이 없어서 더 빠른 성능을 가져올 수 있는 로컬 캐시, Spring이 제공하는 Cache 중에서도 Caffeine Cache를 활용하기로 했다.

(Images: Caffeine Benchmarks)

  위의 데이터에서 알 수 있듯이 Caffeine은 초당 데이터 처리량 측면에서 매우 우수한 지표를 보여주고 있다. 다른 기능이 딱히 필요가 없고, 단지 ReadWrite 측면에서 최고의 성능을 보여주고 싶다면 Caffeine Cache만큼 적당한 것이 없을 것 같다.

Caffeine is a high performance, near optimal caching library.

✍️ by Caffeine Github


4. Caffeine Cache 적용하기

i) dependency 추가

# build.gradle
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'

ii) CacheType 생성

@Getter
@RequiredArgsConstructor
public enum CacheType {
    MEMBER_PROFILE("member", 12, 10000);

    private final String cacheName;
    private final int expiredAfterWrite;
    private final int maximumSize;
}
  • 현재는 하나 밖에 없지만, 이후 확장을 고려하여 enum으로 생성
  • expireAfterWrite : 항목이 생성된 후 또는 해당 값을 가장 최근에 바뀐 후 특정 기간이 지나면 각 항목이 캐시에서 자동으로 제거되도록 지정
  • maximumSize : 캐시에 포함할 수 있는 최대 엔트리 수 지정

iii) Cache 세팅

@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;
    }
}
  • Parameter
    • recordStats
      • 캐시에 대한 Statics 적용
    • expireAfterWrite
      • 항목이 생성된 후 또는 해당 값을 가장 최근에 바뀐 후 특정 기간이 지나면 각 항목이 캐시에서 자동으로 제거되도록 지정
    • maximumSize
      • 캐시에 포함할 수 있는 최대 엔트리 수 지정

iv) 로직 수정

	. . .
    @Transactional(readOnly = true)
    @Cacheable(cacheNames = "member", key = "#nickname", value = "member")
    public Member findMemberByNickname(String nickname) {
        return memberRepository.findMemberByNickname(nickname).orElseThrow(EntityNotFoundException::new);
    }
    
    @Transactional
    @CacheEvict(cacheNames = "member", key = "#nickname")
    public void delete(String nickname) {
        memberRepository.delete(findMemberByNickname(nickname));
    }
    . . .

5. Test

  Spring에서는 AOP Proxy가 @Cacheable을 처리해준다. 따라서 메서드 실행 전에 프록시 객체가 요청된 메서드의 결과가 캐싱돼 있는지 확인하게 된다. 이러한 동작을 하는 객체가 CacheInterceptor다.

  그럼 테스트는 어떻게 진행할까? 캐싱 동작이 이루어졌다면 해당 메서드를 여러 번 호출하더라도 한번만 실행될 것이다. 코드는 다음과 같다.

    . . .
    
    @Test
    @DisplayName("findMemberByNickname() 메서드를 호출하면 처음을 제외한 나머지는 Cache가 인터셉트한다.")
    void findMemberByNicknameUsedCache() throws IOException {
        // given
        Member mockMember = getMember("TEST", Gender.MALE, getLocalDate("2023-05-15"));

        given(memberRepository.findMemberByNickname(anyString()))
                .willReturn(Optional.of(mockMember));

        // when
        IntStream.range(0, 10).forEach((i) -> memberService.findMemberByNickname("TEST"));

        // then
        verify(memberRepository, times((1))).findMemberByNickname("TEST");
    }
    . . .



Reference

profile
안녕하세요. 서버 개발자 komment 입니다.

0개의 댓글