들어가기 앞서..
링크 된 글에서 파생된 글입니다. 왜 이런 짓(?)을 하는 지는 글을 읽으시면 이해하실 수 있습니다!
https://marstech.tistory.com/17
검색 API 최적화 여행기
들어가기 앞서..개발이 끝난 후, 코드를 검증해보기 위해 성능테스트를 진행하고 끔찍한 쿼리의 응답속도와 비효율적인 코드에 의한 병목을 발견하고 개선해나가는 경험을 쓴 기록입니다.해당
marstech.tistory.com
위의 글을 요약하자면, Full-Text Index 적용은 성공적이었지만, 빈도수가 높은 단어는 적용되지 않는 점을 보완하기 위해
캐싱을 도입하여 최초 1회의 요청을 제외한 나머지 요청의 응답속도를 낮춰 평균 응답속도를 개선하고자 캐시 도입을 시작하였습니다.
캐시 도입
앞선 이유들로 인해 캐시를 적용하고자 했습니다.
그런데, 캐시가 많아도 너무 많아요..
- 로컬 캐시
- Caffeine Cache
- Ehcache
- 등등..
- 글로벌 캐시
- Redis
- Memcached
- 등등...
각각의 캐시에 대해서 서칭을 통해 대략 찾아봤지만 적용을 해야하는 이유에 대해 와닿지 못했습니다.
그래서, 어차피 캐시가 Map 아닌가? 라는 생각으로 제가 직접 구현해봤습니다.
로컬 캐시
직접 만든 Cache 유틸
캐시를 만들기 전에 캐시의 정의부터 다시 짚고 넘어갔습니다.
캐시란?
캐시는 컴퓨터 과학에서 데이터나 값을 미리 복사해 놓는 임시 장소를 가리킨다.
캐시는 캐시의 접근 시간에 비해 미가공 데이터 또는 1차 데이터에 접근하는 시간이 오래 걸리는 경우나 값을 다시 계산하는 시간을 절약하고 싶은 경우에 사용한다.
요는 접근 시간이 오래걸리는 데이터를 따로 저장해놓고 빠르게 접근하는 것입니다.
그렇기 때문에 캐시를 만들기 위해서 가장 먼저 생각한 자료구조는 HashMap입니다.
그런데 캐시를 만들기전에 고려해야할 부분이 있는데요, 바로 동시성에 대한 부분입니다.
HashMap의 문제점
Collection API에 구현되어있는 HashMap 구현체는 동시성에 대한 고려가 되어있지 않습니다.
따라서 다음과 같은 상황에서 동시성 문제가 야기될 수 있습니다.
// 멀티 스레드 환경
map.put(key1, value1);
map.put(key2, value2); // 이 시점에서 충돌 발생
만약 두개의 스레드가 동시에 두 메서드를 실행 한다면 다음과 같은 결과를 낳을 수 있습니다.
- 무한 루프 발생
- HashMap 내부에서 배열 확장 및 재배치가 일어날 때 스레드 A가 resize()를 시작한 상태에서
스레드 B도 동시에 put()을 호출하면 리스트 간 연결이 꼬이면서 원형 참조가 발생할 수 있음- → get() 또는 순회 시 무한 루프에 빠짐
- HashMap 내부에서 배열 확장 및 재배치가 일어날 때 스레드 A가 resize()를 시작한 상태에서
- 데이터 유실
- 두 스레드가 같은 index에 동시에 데이터를 넣는 경우, 하나의 put() 결과가 다른 스레드의 값에 의해 덮어쓰기됨
- → 나중에 넣은 값만 살아남고, 먼저 넣은 값은 사라짐
- 두 스레드가 같은 index에 동시에 데이터를 넣는 경우, 하나의 put() 결과가 다른 스레드의 값에 의해 덮어쓰기됨
- 잘못된 값 조회 (Dirty Read)
- 스레드 A가 put(key, value)를 하던 중 스레드 B가 get(key)을 호출하면
아직 데이터가 완전히 반영되지 않은 상태에서 잘못된 값이 반환될 수 있음
- 스레드 A가 put(key, value)를 하던 중 스레드 B가 get(key)을 호출하면
따라서 동시성이 보장되어있는 java.util.concurrent 패키지의 ConcurrentHashMap을 사용해야 합니다.
ConcurrentHashMap
ConcurrentHashMap은 버킷 단위 락 + CAS기반으로 고도화된 고성능 동시성 해시 테이블이며,
읽기는 락 없이, 쓰기는 최소한의 락으로 병렬성을 극대화합니다.
또한 null금지, 반복자 안정성, 병렬 처리 기능 등에서도 강력한 특징을 가집니다.
캐시의 경우에는 힙위의 인스턴스 변수가 아닌, 정적 변수로 모든 요청을 한 곳에서 처리해야 하기 때문에 동시성을 고려해야 합니다.
따라서, 동시성을 고려한 HashMap 자료구조인 ConcurrentHashMap을 통해 구현했습니다.
Cache<K, V>
package com.recycle.common.util;
import java.util.Deque;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedDeque;
public class Cache<K, V> {
private final Map<K, CacheData<V>> storage;
private final int capacity;
private final Deque<K> orderQueue;
private static final long EXPIRATION_TIME = 1000 * 60 * 5;
private static class CacheData<V>{
private final V value;
private final long createdAt;
CacheData(V value) {
this.value = value;
this.createdAt = System.currentTimeMillis();
}
boolean isExpired() {
return (System.currentTimeMillis() - createdAt) > EXPIRATION_TIME;
}
V getValue() {
return value;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof CacheData<?>)) return false;
return value.equals(((CacheData<?>) obj).getValue());
}
@Override
public int hashCode() {
return value.hashCode();
}
}
public Cache(int capacity) {
this.capacity = capacity;
this.storage = new ConcurrentHashMap<K,CacheData<V>>();
this.orderQueue = new ConcurrentLinkedDeque<K>();
}
public synchronized void put(K key, V value) {
if(storage.containsKey(key)) {
orderQueue.remove(key);
}
storage.put(key, new CacheData<>(value));
orderQueue.addLast(key);
if(storage.size() > capacity) {
K oldKey = orderQueue.pollFirst();
if(oldKey != null) {
storage.remove(oldKey);
}
}
}
public V get(K key) {
synchronized (this) {
CacheData<V> cacheData = storage.get(key);
if (storage.containsKey(key) && !cacheData.isExpired()) {
orderQueue.remove(key);
orderQueue.addLast(key);
return cacheData.value;
}
storage.remove(key);
orderQueue.remove(key);
return null;
}
}
public void remove(K key) {
synchronized (this) {
storage.remove(key);
orderQueue.remove(key);
}
}
public void clear() {
synchronized (this) {
storage.clear();
orderQueue.clear();
}
}
public boolean containsKey(K key) {
return storage.containsKey(key);
}
public boolean containsValue(V value) {
return storage.containsValue(value);
}
}
캐시 유틸은 다음과 같이 구현했습니다.
- 클래스 내의 타입 안정성과 재사용, 확장성을 위해 제네릭 타입 매개변수를 사용
- 오직 내부에서만 사용하기 때문에 정적 멤버 클래스 사용 -> 일반 멤버 클래스 외부 인스턴스가 생성될 수 있기 때문
- TTL 구현을 위해 정적 멤버 클래스를 통해 시간 값을 포함하여 래핑해 TTL 단순 구현
- 동시성 고려를 위해 자료구조의 데이터를 제어하는 메서드에는 모두 synchronized 적용
@Repository
@Slf4j
public class QuestionCacheRepositoryImpl implements QuestionCacheRepository {
private static final Cache<String, CachedQuestionPage> questionCache;
static {
questionCache = new Cache<>(5000);
}
그리고 이를 사용하는 repository 클래스에서는 해당 유틸을 사용하여 싱글톤으로 생성하였습니다.
캐시 적용
캐시는 다음과 같은 흐름으로 적용되었습니다.
요청: 데이터 조회
├── 캐시에 key 존재?
│ ├── Yes: 캐시 히트
│ │ └── 캐싱된 데이터 반환
│ └── No: 캐시 미스
│ ├── MySQL에서 데이터 조회
│ ├── 조회 결과 캐시에 저장
│ └── DB 조회 결과 반환
테스트 진행
구현한 캐시로 다음과 같은 부하로 Jmeter를 이용하여 테스트를 진행했습니다.
| Number of Threads | 150 |
| Ramp-up period | 60s |
| Durations | 00:05:00 |

평균 응답속도가 612ms, 요청 중 약 1만 7천개의 요청이 Cache Hit하여 결과를 반환한 것을 알 수 있습니다.

평균 TPS는 약 70~80으로 나타나는 것을 알 수 있습니다.
결과 분석
기존 병목이 발생하여 제대로 실행도 되지 않던 테스트가 시작 몇초의 병목을 제외하고는 정상적인 흐름으로 왔습니다!!
그렇지만, 로컬 캐시인것을 감안하면 생각보다 낮은 TPS와 응답속도가 나왔습니다.
특히, 부하를 준 만큼 전부 소화하지도 못했기 때문에 서버 내에서 아주 약간의 병목이 있던 것으로 추정됩니다.
즉, 서버에서의 처리 시간으로 인해 시간이 지연되었다는 뜻으로 판단했습니다.
그리고 가장 신경쓰였던 부분은 바로 synchronized 블록이였습니다.
public synchronized void put(K key, V value) {
if(storage.containsKey(key)) {
orderQueue.remove(key);
}
storage.put(key, new CacheData<>(value));
orderQueue.addLast(key);
if(storage.size() > capacity) {
K oldKey = orderQueue.pollFirst();
if(oldKey != null) {
storage.remove(oldKey);
}
}
}
제가 구현했던 코드들 중 synchronized 블록이 들어간 메서드 중 하나입니다.
ConcurrentHashMap은 메서드 전체에 synchronized 블럭이 달린게 아닌, 내부에서 효율적으로 synchronized 블럭을 사용하여 처리합니다.
하지만 제 코드에서는 LRU 알고리즘을 구현하기 위해 ConcurrentHashMap만 사용하는 것이 아닌 ConcurrentLinkedDeque도 사용합니다. 따라서 이 두 자료구조를 동시에 다룰 때 원자적으로 처리하기 위해 메서드 자체를 synchronized로 감싸버렸습니다.
따라서 ConcurrentHashMap의 효율성 측면에서의 장점이 사라져버린 것입니다.
public V get(K key) {
synchronized (this) {
CacheData<V> cacheData = storage.get(key);
if (storage.containsKey(key) && !cacheData.isExpired()) {
orderQueue.remove(key);
orderQueue.addLast(key);
return cacheData.value;
}
storage.remove(key);
orderQueue.remove(key);
return null;
}
}
거기에 ConcurrentHashMap은 내부적으로 get 메서드에서는 속도를 위하여 synchronized 블럭을 사용하지 않습니다.
하지만 제 코드에서는 LRU 알고리즘과 TTL 만료 여부를 확인하기 위해 ConcurrentHashMap에서 꺼낸 데이터를 한번 검증하고
다시 넣는 과정이 있습니다. 따라서 이 행동 역시 원자적으로 처리해야 하므로 synchronized 블럭을 적용했습니다.
이렇게 동시성에 대하여 최적화를 하지 못해 약간의 병목이 발생했습니다.
동시성 최적화에 대한 부분은 부족한 멀티스레드와 동시성 프로그래밍에 대한 지식으로 인해 수행하지 못했습니다.
부족한 멀티스레드와 동시성 프로그래밍에 대한 부분은 진행했던 스터디를 통해 다시 한번 상기하고자 합니다.
부족한 저의 코드는 아쉽지만 묻어두고, 대신해서 라이브러리로 제공되는 로컬 캐시를 사용하여 조금 더 개선해볼까 합니다.
Caffeine Cache 적용
Caffeine Cache를 적용하기 전에, Caffeine Cache이 무엇인지 알고 넘어가야 합니다.
Caffeine Cache
https://github.com/ben-manes/caffeine
GitHub - ben-manes/caffeine: A high performance caching library for Java
A high performance caching library for Java. Contribute to ben-manes/caffeine development by creating an account on GitHub.
github.com
Caffeine은 Guava에서 영감을 받은 API를 사용하는 인메모리 캐시 라이브러리입니다.
Guava Cache 및 ConcurrentLinkedHashMap 설계 경험을 바탕으로 성능을 향상시켰습니다.
Caffeine은 다음과 같은 유연한 캐시 기능 구성을 지원합니다
| 자동 로딩 | 항목이 없을 때 자동으로 불러오는 로직을 설정할 수 있음 (LoadingCache) |
| 용량 기반 제거 | 사용 빈도(frequency) + 최신 사용(recency) 기반으로 크기 초과 시 항목 제거 |
| 시간 기반 만료 | 마지막 접근(access) 또는 마지막 기록(write) 이후 시간 기준으로 만료 처리 |
| 자동 새로고침 | 항목이 오래되어 처음 요청될 때 비동기로 갱신(refresh) 가능 |
| 약한 키 | 키를 약한 참조(WeakReference) 로 저장하여 GC 대상이 되도록 함 |
| 약한/부드러운 값 | 값도 약한 참조 또는 소프트 참조(SoftReference) 로 저장 가능 |
| 제거 알림 | 항목이 제거되었을 때(만료/용량 초과 등) 알림 콜백을 받을 수 있음 |
| 외부 자원에 기록 | 캐시에 쓴 값을 외부 저장소(DB, 파일 등)에 동기화 가능 |
| 통계 수집 | 캐시 적중률(Hit), 누락률(Miss), 제거 수 등을 모니터링 가능 |
저의 프로젝트에서는 Caffeine Cache를 통해 다음과 같은 이점을 얻을 수 있습니다.
- 기존 LRU 알고리즘 + TTL을 기반으로 수동 처리하던 불완전한 캐시 데이터 자동 업데이트
- Caffeine Cache의 최적화에 따른 용량 (램 점유) 감소
- 시간 기반 만료를 통해 Cache Hit가 많은 데이터 생존 가능성 증가 -> 쿼리 비용이 높은 검색 쿼리 요청 가능성 감소
그래서 바로 적용해 보았습니다.
@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.MINUTES)
.expireAfterAccess(cache.getExpiredAfterWrite(), TimeUnit.MINUTES)
.maximumSize(cache.getMaximumSize())
.build()))
.toList();
}
@Bean
public CacheManager cacheManager(List<CaffeineCache> caffeineCaches) {
SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(caffeineCaches);
return cacheManager;
}
}
Caffeine Cache의 configuration 입니다. 앞서 캐시 유틸을 골머리 썩으면서 구현하다가 구현체 하나 가져다가 쓰니 새삼.. 좋네요
위에 CaffeineCache 객체를 생성하고 언급한 기능 중 시간 기반 만료와 최대 크기를 설정하였습니다. 이때, 최대 크기는 제가 구현한 캐시와 똑같이 5000으로 지정했습니다.
코드에는 안나와있지만 CacheType enum class를 만들어 여러 캐시 타입 인스턴스를 열 타입으로 만들어 모아두었습니다.
@Cacheable(key = "#keyword + ':' + #pageable.pageSize + ':' + #pageable.pageNumber", value = "questionsearch")
public Page<QuestionRdsResponse> findQuestionsFromRds(String keyword, Pageable pageable) {
return questionRdsQueryService.findQuestionsByKeyword(keyword, pageable);
}
그리고 CacheManager로 Caffeine Cache를 등록했기 때문에 @Cacheable 어노테이션으로 폴백 알고리즘을 짜지 않아도
AOP를 통해 캐싱이 적용되었습니다.
테스트 진행
적용한 Caffeine Cache를 테스트 해보고자 이전 테스트 환경과 동일한 환경에서 테스트를 진행하였습니다.

평균 응답속도가 604ms, 요청 중 약 1만 6천개의 요청이 Cache Hit하여 결과를 반환한 것을 알 수 있습니다.

초반 램프업 구간을 제외하면, 평균 TPS는 약 100~110으로 나타나는 것을 알 수 있습니다.
결과 분석
앞서 제가 구현했던 캐시보다 효율적으로 설계되어 있는 구현체인 만큼 TPS가 약 20%가 상승했습니다.
응답속도 측면에서는 크게 차이가 나진 않지만, 효율적인 운용으로 감당할 수 있는 부하가 증가했습니다.
글로벌 캐시
앞서 로컬 캐시(Caffeine Cache)를 통해 조회 성능을 개선했습니다.
로컬 캐시는 별도의 네트워크를 타지 않기 때문에 빠르다는 장점이 있지만 다음과 같은 단점이 있습니다.
- 고정적으로 일정 공간의 램을 점유하고 있다.
- 서버가 여러개가 된다면, 다른 서버와의 데이터 정합성에 문제가 발생할 수 있다. (확장성)
특히, 저의 상황과 같은 경우에는 데이터 당 평균 한글 2000자 내외의 긴 문자열로 로컬 캐시의 저장 단위가 커지고 있었습니다.
자바는 한글 데이터를 UTF-16 인코딩 방식으로 저장합니다. UTF-16에서 한글은 2byte를 차지합니다.
따라서 계산하면 데이터의 크기는 다음과 같습니다.
글자 수: 2000자
한글 1자당: 2 Byte (UTF-16 기준)
⇒ 1개 데이터 크기: 2000 * 2 = 4000 Byte ≒ 4KB
데이터 수: 테이블당 1,000,000개
테이블 수: 3개
⇒ 총 데이터 수: 1,000,000 * 3 = 3,000,000개
총 메모리 = 3,000,000 * 4KB = 12,000,000KB = 약 12GB
모든 데이터를 캐시에 올리려면 단순 계산으로 약 12GB의 메모리가 필요합니다.
하지만 당연히 세상 누구도 모든 데이터를 캐시로 올리는 사람은 없을 것입니다.
그래서 저는 파레토의 법칙에 따라 캐시의 크기를 정했습니다.
전체 결과의 80%는 20%의 원인에서 비롯된다
이 법칙을 전체 요청의 80%는 상위 20% 데이터에 집중된다고 해석하여 캐시의 크기를 정해보았습니다.
12GB * 20 / 100 = 2.4GB -> 약 2.4GB의 캐시 공간 필요
하지만, 2.4GB의 데이터를 로컬 캐시에 담기에는 너무 큰 크기입니다.
2.4GB의 데이터를 캐시에 올리게 되면 다음과 같은 이펙트가 발생합니다.
- GC 성능 저하
- Out of Memory 위험 증가
- JVM 운영 여유 메모리 부족
더군다나 Scale-out을 고려하면 로컬 캐시가 증가한다면 동일한 데이터를 별도로 캐싱하게 되어 비싼 자원인 메모리가 중복 낭비되는 구조가 됩니다.
이를 해결하기 위해 글로벌 캐시를 도입하여 중복되는 데이터를 중앙 집중하고, 각 서버의 로컬 메모리 사용량을 줄일 수 있도록 별도의 외부 메모리 리소스에 캐시를 분리 운영하고자 합니다.

빈도를 기반으로 한 데이터 접근 패턴인 Zipf 분포를 통해 캐시 히트율 분포 그래프입니다.
그래프를 보면 알 수 있 듯 약 5%를 기준으로 기울기가 급격하게 완만해지는 것을 볼 수 있습니다.
즉, 5%의 데이터를 로컬 캐시에 캐싱해두는 것이 가장 효율적이라고 할 수 있습니다.
따라서 2단계로 캐시를 구분하여 5%의 데이터인 600MB의 데이터를 로컬 캐시로 두고, 15%의 데이터인 1.8GB를 글로벌 캐시로 관리하고자 합니다.
Redis
레디스(Redis)는 Remote Dictionary Server의 약자로, "키-값" 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비관계형 데이터베이스 관리 시스템(DBMS)입니다.
레디스는 모든 데이터를 메모리로 불러와서 처리하는 메모리 기반 DBMS입니다.
| In-memory 기반 | 모든 데이터를 메모리에 저장해 매우 빠른 응답 속도 제공 (us~ms 단위). |
| Key-Value 구조 | 단순 문자열부터 리스트, 해시, 셋, 정렬된 셋 등 다양한 자료구조 지원. |
| Persistence(영속성) | 메모리 DB지만 RDB 스냅샷 저장, AOF(Append Only File) 방식으로 디스크에 저장 가능. |
| Single-thread 구조 | 기본적으로 단일 쓰레드로 작동하여 동시성 이슈 없음 (하지만 I/O 블로킹에 유의). |
| Atomic 연산 보장 | 하나의 명령어 단위는 원자적으로 실행되어 race condition 방지. |
| Pub/Sub 지원 | 메시지 브로커로도 활용 가능, 실시간 알림 시스템 등에 사용. |
| TTL 기능 | 키마다 만료시간 설정 가능 → 캐시, 세션 스토리지에 적합. |
| Cluster & Sentinel | 분산 환경 지원 (샤딩), 고가용성 구성(Sentinel으로 failover 지원). |
| Lua 스크립트 지원 | 서버에서 직접 실행되는 스크립트로 성능/트랜잭션 제어 강화 가능. |
| Lightweight & 빠른 속도 | 100K ~ 1M QPS도 거뜬히 처리 가능, 성능 병목 제거에 탁월. |
저의 프로젝트에서는 다음과 같은 이점을 얻을 수 있어 사용하게 되었습니다.
- In-memory 기반 DBMS이기 때문에 캐시 저장소로 사용하기에 적합
- 동시성 이슈가 없으므로 글로벌 캐시로 사용하기에 적합
- Atomic 연산을 보장하므로 race condition이 방지됨 -> 글로벌 캐시로 사용 적합
- 빠른속도
물론 서버의 로컬 캐시보다는 네트워크 비용이 발생하므로 느리겠지만, 캐시로 사용하기에는 적합한 속도라고 생각했습니다.
또한, 네트워크로 발생하는 비용은 scale-out을 통해 평균적으로 낮출 수 있기 때문에 애플리케이션 속도를 생각하면 가장 적합한 시스템이라고 생각했습니다.
Redis Client
다음은 레디스를 사용하기 위한 자바 클라이언트 결정입니다.
자바에는 크게 두가지 Redis 구현체가 있습니다.
| 항목 | Jedis | Lettuce |
| 기반 방식 | 블로킹 I/O (BIO) | 넌블로킹 I/O (NIO / Netty 기반) |
| 멀티스레드 환경 | 연결 객체를 여러 스레드가 공유 불가 (커넥션 당 1스레드) |
Thread-safe (커넥션 공유 가능) |
| 비동기 지원 | 없음 (동기 전용) | 완전 지원 (async, reactive, future) |
| Spring 기본 클라이언트 | 수동 설정 필요 | Spring Data Redis 기본 클라이언트 |
| 성능 (동시성) | 적은 스레드, 단순 구조에선 빠름 | 고부하 환경에서 우수한 확장성 |
| 커넥션 풀 | 필요 (JedisPool) | 기본적으로 필요 없음 (Netty event loop) |
이 중, 논블로킹 I/O를 통해 빠른 성능을 기대할 수 있는 Lettuce를 채택하여 사용했습니다.
궁금한 점
현재 사용중인 WAS는 Blocking I/O 기반의 Tomcat 서버인데,
Non-Blocking I/O 기반인 Lettuce의 Netty event loop는 어떻게 동작하는 걸까?
Tomcat의 Blocking I/O와 Lettuce의 Non-Blocking I/O
- Tomcat (Blocking I/O)
- Tomcat은 기본적으로 블로킹 I/O 방식으로 요청 처리
- 클라이언트 요청을 수신하면, 해당 요청을 처리하는 스레드가 블로킹되어 그 작업을 마칠 때까지 다른 요청을 처리 불가
- 따라서, Redis에 데이터를 요청하고 응답을 받을 때까지 기다려야 하므로, 이 기간 동안 Tomcat의 해당 스레드는 다른 요청을 처리할 수 없는 상태가 됩니다.
- Tomcat은 기본적으로 블로킹 I/O 방식으로 요청 처리
- Lettuce (Non-Blocking I/O)
- Lettuce는 Netty의 Event Loop를 사용하여 비동기적으로 Redis와 통신
- Netty의 Event Loop는 단일 스레드가 다수의 네트워크 요청을 비동기적으로 처리가능
- 따라서, Lettuce는 Redis 서버에 요청을 보내고 비동기적으로 응답을 기다리며, 응답이 준비되면 콜백을 통해 결과를 처리 -> 논블로킹 I/O로 다른 작업이 차단되지 않음
- Lettuce는 Netty의 Event Loop를 사용하여 비동기적으로 Redis와 통신
두 시스템의 상호작용
- Tomcat은 Blocking I/O 방식으로 요청을 받지만, Lettuce는 비동기적으로 Redis와 통신
- Tomcat의 스레드는 Lettuce 클라이언트와 통신하는 동안 블로킹되지만, Lettuce는 내부적으로 비동기 방식으로 Redis에 요청을 보내고 응답 대기
- 이때 Tomcat의 스레드는 기다리지 않으며, 다른 요청 처리
- Tomcat에서 Lettuce를 사용할 경우 실제로 Redis와의 네트워크 통신이 비동기적으로 처리되기 때문에 Tomcat의 블로킹 I/O로 인한 대기 시간 절약
캐시 전략
요청: 데이터 조회
├── 로컬 캐시(Caffeine)에 key 존재?
│ ├── Yes: 캐시 히트
│ │ └── 로컬 캐시 데이터 반환
│ └── No: 캐시 미스
│ ├── 글로벌 캐시(Redis)에 key 존재?
│ │ ├── Yes: 캐시 히트
│ │ │ ├── 로컬 캐시에 값 저장
│ │ │ └── 글로벌 캐시 데이터 반환
│ │ └── No: 캐시 미스
│ │ ├── MySQL에서 데이터 조회
│ │ ├── 글로벌 캐시에 값 저장 (TTL 포함)
│ │ ├── 로컬 캐시에 값 저장
│ │ └── DB 조회 결과 반환
앞서 이야기 한 것처럼 2단계로 캐시를 사용하여 로컬 캐시로 Caffeine Cache를 사용하고,
글로벌 캐시로 Redis Cache를 사용합니다.
Caffeine Cache의 TTL은 1분, Redis Cache의 TTL은 5분으로 설정되어 있어
Caffeine Cache -> Redis Cache -> MySQL로 이루어진 폴백 구조로 되어있습니다.
테스트 진행

응답 속도는 622ms로 앞서 진행했던 캐시 유틸, Caffeine 캐시보다 조금 더 느리게 측정 되었다.

초반 램프업 구간을 제외하면, 평균 TPS는 약 90으로 나타나는 것을 알 수 있습니다.
결과 분석
당연한 말이지만, 폴백이 일어난다면 처음부터 해당 저장소로 조회를 요청한 것보다 비용이 더 높게 들 수 밖에 없습니다.
더군다나, 다시 상위 단계의 저장소에 데이터를 넣어야 하기 때문에 시간이 더 소모될 수밖에 없습니다.
실제로 TPS 그래프를 보면 약 1분마다 로컬 캐시의 한번씩 TPS 값이 저점을 치는 것을 볼 수 있습니다.
그럼에도 불구하고, 2단계 캐시를 구성하여 폴백 구조를 설계한 이유는 다음과 같습니다.
- 성능 향상: 데이터베이스나 외부 API와의 호출을 최소화하여 성능을 향상시키고, 빠른 응답을 제공
- 데이터 가용성: 캐시가 비어 있더라도 폴백 메커니즘을 통해 데이터의 가용성을 확보
- 비용 절감: 외부 시스템과의 호출을 줄여, 시스템 부하를 줄이고, 비용을 절감
거기에 지속적으로 캐시를 갱신하기 때문에 만약 로컬 캐시 TTL 1분에 MySQL이 다음 폴백이었다면
현재 검색 쿼리가 10초 이상이 걸리기 때문에 1분마다 이보다 심한 병목을 야기할 수도 있었습니다.
그렇다고 로컬 캐시의 TTL을 늘리는 것은 앞서 이야기 했던 램 점유율과 확장성에 문제가 발생하기에
어느정도의 트레이드-오프를 가져갈 수 밖에 없다고 생각합니다.
정리
지금까지 캐시에 대해 정리해보는 시간을 가졌습니다. 정리해보면 다음과 같습니다.

- 직접 구현한 Cache Util: 로컬 캐시이지만 글로벌 캐시와 비슷할 정도의 아쉬운 성능
- Cafeine Cache: 동시성에 대한 최적화가 이루어져 빠른 성능을 보여줌
- Redis + Cafeine Cache: 로컬 캐시와 글로벌 캐시를 혼용하여 어느정도의 트레이드 오프를 감수하고 안정성과 확장성 획득
느낀점
지금까지 캐시를 적용하면 무조건 Redis 써야지! 하는 사람들을 많이 봤었고, 저도 아니라고는 할 수 없을겁니다.
당연히 이게 이래서 좋고, 저게 저래서 좋으니 처음부터 좋은거 쓰면 되는거 아니냐고 생각할 수도 있을 것 같습니다.
하지만, 개발에 있어서 오버 엔지니어링이 가장 경계해야하는 대상인 것 같습니다.
Redis를 무조건 도입한다면 발생하는 막대한 비용을 감수해야 할 것이고, 뚜렷한 목적이 있지 않는 한
위와 같은 결과처럼 비용을 더 내고 오히려 성능이 저하되는 경험을 할 수 있을 것입니다.
이번 경험으로 기술을 도입한다면 어떤 기준으로 도입해야 하는지, 비교를 어떻게 해야하는지에 대해 조금 감을 잡은 것 같아 공유하게 되었습니다.
긴글 읽어주셔서 감사합니다!
Ref.
'Tech > Develop' 카테고리의 다른 글
| 검색엔진 적용까지의 기록 - Elasticsearch (0) | 2025.05.07 |
|---|---|
| 검색 API 최적화 여행기 (0) | 2025.04.11 |
| [Spring Boot] '멀티 모듈이.. 뭐지..?' - 멀티 모듈 적용기(1) (0) | 2025.02.26 |
| MSA 환경 CQRS 패턴 적용기 - (2) 프로젝트에 적용해보기 (0) | 2024.08.07 |
| MSA 환경 CQRS 패턴 적용기 - (1) CQRS (0) | 2024.08.04 |