사이드 프로젝트를 진행하다가 개선할 필요가 있어보이는 로직이 있어서 해결 후 포스팅을 하게 되었다. 커플 일기
라는 도메인으로 진행하는 프로젝트인데, D-Day를 불러오는 부분에서 고민을 하게 되었다. 일반적인 방법인 DB에서 읽어오는 식으로 구현하면 어플을 실행할 때마다, 또는 해당 탭을 킬 때마다 SELECT
동작이 반복된다. 이는 DB 부하뿐만 아니라 READ 비용까지 증가시킨다. 따라서 이를 막기 위해 Cache
를 활용하기로 했다.
유저가 HOME 화면이나 Profile 화면을 로드했을 때 Service 레이어의 findMemberByNickname()
메서드가 실행된다. 기존에는 Repository의 메서드를 이용해 해당 로직을 수행했다.
. . .
@Transactional(readOnly = true)
public Member findMemberByNickname(String nickname) {
return memberRepository.findMemberByNickname(nickname).orElseThrow(EntityNotFoundException::new);
}
. . .
캐시(cache) : 데이터나 값을 미리 복사해 놓는 임시 장소
캐시는 크게 로컬 캐시와 글로벌 캐시로 나뉘는데, 현재는 서버가 단일노드이기 때문에 다른 서버의 캐시를 참조할 일이 없어서 더 빠른 성능을 가져올 수 있는 로컬 캐시, Spring이 제공하는 Cache 중에서도 Caffeine Cache를 활용하기로 했다.
위의 데이터에서 알 수 있듯이 Caffeine은 초당 데이터 처리량
측면에서 매우 우수한 지표를 보여주고 있다. 다른 기능이 딱히 필요가 없고, 단지 Read
와 Write
측면에서 최고의 성능을 보여주고 싶다면 Caffeine Cache만큼 적당한 것이 없을 것 같다.
Caffeine is a high performance, near optimal caching library.
✍️ by Caffeine Github
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;
}
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;
}
}
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));
}
. . .
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");
}
. . .