사이드 프로젝트 로컬 캐시 (Caffeine) 도입기

hwibaski·2023년 9월 25일

Java

목록 보기
6/7

들어가며

사이드 프로젝트에 로컬 캐시를 도입한 배경과 과정을 공유하고자 합니다. 글로벌 캐시와 로컬 캐시 중 왜 로컬 캐시를 선택했는지, 간단한 적용 방법, 그리고 로컬 캐시를 적용했을 때와 그렇지 않았을 때의 어느 정도의 성능 차이가 나는지 살펴보겠습니다.

왜 로컬 캐시를 사용했나?

캐시 전략은 크게 두 가지가 있습니다. 글로벌 캐시와 로컬 캐시입니다. 글로벌 캐시는 여러 대의 서버가 있고 캐시용 서버를 마련하여 사용하는 캐시입니다. 반면 로컬 캐시는 각각의 서버의 자원을 이용하여 캐시를 구성하는 전략입니다.

글로벌 캐시는 캐시 서버를 따로 두어 사용하기에 분산 서버 환경에서 이점이 있습니다. 반면 외부 캐시 서버와 통신을 해야하니 네트워크 비용과 따로 캐시 서버를 구성해야 한다는 단점이 있습니다.

로컬 캐시는 분산 서버 환경에서 사용한다면 각 서버 인스턴스 별로 캐싱을 하므로 데이터 정합성의 문제가 생깁니다. 반면 구현이 쉽다는 장점이 있고, 하나의 서버 인스턴스로 운영할 시 외부 서버와의 네트워크 비용이 없기 때문에 글로벌 캐시 전략 대비 성능 상의 이점이 있습니다. (캐시란?, 로컬캐싱 vs 글로벌 캐싱, 캐시전략들 참고)

현재의 사이드 프로젝트는 캐시 기능을 사용해서 성능을 개선할만한 부분이 많이 없고, 프로토타입 단계를 개발 중이기 때문에 하나의 서버 인스턴스를 사용할 것을 예상해 로컬 캐시를 도입하게 되었습니다.

스프링에서는 로컬 캐시의 구현으로 ConcurrentHashMap, Caffeine, ehcache가 있습니다. 이 중 Caffeine 의 간단한 기능과 벤치마크 성능이 우수하다고 판단하여 로컬 캐시의 구현체를 Caffeine으로 선택했습니다.

스프링 부트에 캐시 적용하기

(Spring Cache, 제대로 사용하기)[https://gngsn.tistory.com/157] 참고하여 작성하였습니다. 해당 글의 시리즈를 보시면 자세한 설명과 사용예제도 함께 작성되어 있으니 스프링에 캐시를 적용하시는게 처음이시라면 한 번 읽어보시기를 추천드립니다.

1. 의존성 추가

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

2. 캐시 설정

  • 유지 보수성을 위해 캐시 설정을 enum class에 담아서 사용했습니다.
@Configuration  
@EnableCaching  
public class CacheConfig {  
  
	@Bean  
	public CacheManager cacheManager() {  
		SimpleCacheManager simpleCacheManager = new SimpleCacheManager();  
  
		List<CaffeineCache> caches = Arrays.stream(CacheType.values())  
									   .map(cacheType ->  
												new CaffeineCache(cacheType.getCacheName(), Caffeine.newBuilder()       .expireAfterWrite(cacheType.getExpiredAfterWriteSecond(), java.util.concurrent.TimeUnit.SECONDS)  
.maximumSize(cacheType.getMaximumSize())  
.build())  
).toList();  
	simpleCacheManager.setCaches(caches);  
  
	return simpleCacheManager;  
	}  
}
@Getter  
public enum CacheType {  
	LINK_COUNT("linkCount");  
	
	private final String cacheName;  
	private final int expiredAfterWriteSecond;  
	private final int maximumSize;  
  
	CacheType(String cacheName) {  
		this.cacheName = cacheName;  
		this.expiredAfterWriteSecond = ConstConfig.DEFAULT_TTL;  
		this.maximumSize = ConstConfig.DEFAULT_MAX_SIZE;  
	}  
  
	static class ConstConfig {  
		static final int DEFAULT_TTL = 5 * 60;  
		static final int DEFAULT_MAX_SIZE = 100;  
	}  
}

3. 캐싱 적용

@Service  
@RequiredArgsConstructor  
public class LinkStatReadService {  
private final LinkStatRepository linkStatRepository;  
  
	@Transactional(readOnly = true)  
	@Cacheable(cacheNames = "linkCount")  
	public GetLinkCountResponse countAll() {  
		return new GetLinkCountResponse(linkStatRepository.count());  
	}  
}

캐싱 전 vs 캐싱 후 성능 테스트 비교

제가 캐싱을 적용한 데이터는 내부적으로 사용되는 링크라는 도메인의 총 개수, 즉 link 테이블의 저장된 로우의 수입니다. 아래의 select 문이 발생해서 얻은 데이터를 캐싱했습니다.

select count(*) from link l1_0

테스트 환경

테스트는 저의 로컬 환경에서 테스트했습니다.. 테스트의 결과값 실행되는 환경마다 다를 수 있으니 참고 정도만 해주시면 좋을 것 같습니다. 더불어 분산 서버 환경에서의 테스트가 아닌 단일 서버 환경에서의 테스트임을 미리 밟힙니다. 또한 각 결과값이 항상 일치하지 않습니다. 데이터 개수의 따른 추이를 보시는게 좋겠습니다.

  • JAVA 17
  • macbook 14, M1
  • spring-boot 3.1.3
  • postgresql 14.6
  • k6 (성능 테스트 툴)

테스트 시나리오

  • 30명의 클라이언트가 5초 동안 지속적 요청을 보낸다. 요청의 총 수가 많다면 성능이 더 좋은 것으로 판단
  • 실패하는 요청은 없음.

캐싱 전

100 만 row

  • 총 요청 수 : 713 번
  • 응답 속도 : 32 ~ 340 ms, avg : 213 ms

500만 row

  • 총 요청 수 : 102 번
  • 응답 속도 : 321 ~ 2880 ms, avg : 1730 ms

1000만 row

  • 총 요청 수 : 33 번
  • 응답속도 : 691 ~ 3850 ms, avg : 2440 ms

데이터의 양이 커질 수록 캐싱을 적용하지 않은 쿼리는 더욱 늦은 응답값을 리턴했습니다.

row 수range(ms)avg(ms)
100M32 ~ 340213
500M321 ~ 28801730
1000M691 ~ 38502440

캐싱 후

1000만 row

  • 총 요청 수 : 99858 번

  • 2~3ms 응답 속도

row 수range(ms)avg(ms)
1000M0.129 ~ 2.71.27

1000만 row 일 때 캐싱 vs 캐싱 미적용

결과

  • 캐싱 : 99858 번
  • 캐싱 미적용 : 66 번

응답 시간

  • 캐싱 : 2~3 ms
  • 캐싱 미적용 : 335 ~ 1001 ms

캐싱 적용과 미적용 시 몇 배의 성능 차이가 있는지 무의미할 정도로 응답속도가 빨라졌습니다.

마치며

간단하게 캐시를 적용하고 데이터 양의 따라 어느 정도 성능의 이점이 있는지 한 번 살펴봤습니다. 서버 환경이나 비즈니스 전략에 따라 적절한 캐시 전략을 사용하는 것이 좋겠습니다.

적절히 사용한다면 빠른 응답속도라는 이점도 있고 데이터베이스에 가해지는 쿼리의 양도 줄일 수 있겠습니다.

오타나 잘못된 내용을 댓글로 남겨주세요!
읽어주셔서 감사합니다!

참조

0개의 댓글