[항해] 7주차, Redis를 활용한 캐싱 & 대기열 시스템 개선
항해 7주차에는 캐시의 개념과 다양한 전략에 대해 살펴보았다!
또한, 캐싱에 주로 사용되는 기술인 Redis를 공부하고,
기존 대기열 시스템의 성능을 향상시키기 위해 Redis를 활용한 개선 작업도 진행했다. 😎
목차
1. Cache란?
2. Caching Strategy
3. Cache Stampede 문제
4. 대기열 개선(feat. Redis)
5. 회고
1. Cache란?
캐시(Cache)란 자주 사용하는 데이터나 결과를 임시로 저장하여, 동일한 요청이 반복될 때 빠르게 제공하는 기술이다.
데이터를 매번 조회하거나 계산하는 대신, 캐시된 데이터를 활용하여 성능을 크게 향상시킬 수 있다.
원래 데이터를 접근하는 시간이 오래 걸리거나, 반복적으로 동일한 결과를 돌려주는 경우(ex 이미지, 썸네일)에 캐시를 사용해볼 수 있다.
캐시를 사용할 때 다음과 같은 두 가지 상황이 발생한다.
- ✅ Cache Hit : 원하는 데이터가 캐시에 존재할 경우 해당 데이터를 반환한다.
- ❌ Cache Miss : 원하는 데이터가 캐시에 존재하지 않을 경우 DBMS 또는 서버에 요청을 해야한다.
성능 향상을 위해 캐시의 적중률(Hit Ratio) 을 높이는 것이 중요하고, 캐시 미스가 많아지면 성능 저하(DB 부하 증가, 응답 속도 느려짐)가 발생한다.
2. Caching Strategy
캐시를 사용하면 데이터 정합성 문제가 발생할 수 있다. DB만 사용할 때는 문제가 없지만, 캐시와 DB에 같은 데이터가 서로 다를 가능성이 생긴다. 이를 해결하려면 적절한 캐시 읽기 및 쓰기 전략을 통해 데이터 일관성을 유지하면서 성능을 최적화해야 한다.
캐시 읽기 전략(Read Cache Strategy)
- Look Aside 패턴
- 데이터를 찾을 때 우선 캐시에 저장된 데이터가 있는지 우선적으로 확인하는 전략이다.
만일 캐시에 데이터가 없으면 DB에서 조회한다.
- 데이터를 찾을 때 우선 캐시에 저장된 데이터가 있는지 우선적으로 확인하는 전략이다.
- 장점 : 반복적인 읽기가 많은 호출에 적합하고, redis가 다운 되더라도 DB에서 데이터를 가져올 수 있어 서비스 자체에는 문제가 없다.
- 단점 : cache store와 DB간 정합성 유지 문제가 발생할 수 있다.
캐시에 붙어있던 connection이 많다면, redis가 다운됐을 때 순간적으로 DB로 조회가 몰리면서 부하가 발생한다.
(Cache Stampede 현상) - 캐시의 TTL 조정과 DB에서 캐시로 데이터를 미리 넣어주는 작업을 통해 데이터베이스 부하를 해결한다. (Cache Warming)
- Read Through 패턴
- 캐시에서만 데이터를 읽어오는 전략이다. 데이터 동기화를 캐시 제공자에게 위임한다.
- 장점 : 캐시와 DB간의 데이터 동기화가 항상 이루어져 데이터 정합성 문제에서 벗어날 수 있다.
- 단점 : 데이터를 조회하는데 전체적으로 속도가 느리고, 데이터 조회를 캐시에 의지하여 redis가 다운될 경우 서비스 전체 중단이 될 수 있다.
캐시 쓰기 전략(Write Cache Strategy)
- Write Back 패턴
- DB에 바로 데이터를 저장하지 않고, 캐시에 모아서 일정 주기마다 DB에 반영한다.
- 장점 : 쓰기 쿼리 회수 비용과 부하를 줄일 수 있다.
- 단점 : 캐시 장애 발생 시 데이터가 영구 소실된다.
- Write가 빈번하면서 Read를 하는데 많은 양의 Resource가 소모되는 서비스에 적합하다.
- Write Through 패턴
- 데이터베이스와 Cache에 동시에 데이터를 저장하는 전략이다.
- 장점 : DB와 캐시가 동기화 되어 있어, 캐시 데이터는 항상 최신으로 유지한다.
- 단점 : 매 요청마다 두번의 write가 발생하여, 빈번한 생성&수정이 발생하는 서비스에선 성능 이슈가 발생한다.
- Write Around 패턴
- 모든 데이터는 DB에 저장하고 캐시를 갱신하지 않는다.
- 장점 : Write Through 보다 훨씬 빠르다.
- 단점 : 캐시와 DB 내의 데이터가 다를 수 있다.(데이터 불일치)
캐시 읽기 & 쓰기 전략 조합
- Lock Aside + Write Around 조합
- 가장 일반적으로 자주 쓰이는 조합이다.
- Read Through + Write Around 조합
- 항상 DB에 쓰고, 캐시에서 읽을 때 항상 DB에서 먼저 읽어오므로 데이터 정합성 이슈에 대한 완벽한 안전 장치를 구성할 수 있다.
- Read Through + Write Through 조합
- 데이터를 쓸 때 항상 캐시에 먼저 쓰므로, 읽어올 때 최신 캐시 데이터를 보장한다.
- 데이터를 쓸 때 항상 캐시에서 DB로 보내므로, 데이터 정합성을 보장한다.
3. Cache Stampede 문제
- 모든 키에 대해 만료 시간을 동일하게 설정하는 경우, 대규모 트래픽 환경에서 캐시 스탬피드(Cache Stampede) 현상이 발생 가능하다.
- 많은 요청이 동시에 캐시 만료를 인식하고, DB에 접근하여 서버에 과부하를 일으키는 상황이 발생한다.
- 대규모 트래픽 환경에서 TTL 값을 너무 작게 설정하면 cache stampede 현상이 발생한다.
해결안
- 지터(Jitter)
캐시 만료 시간을 무작위로 조금 지연시켜, 데이터베이스의 부하를 균등하게 분산시킨다.
전자공학에서 사용되는 ‘지터(Jitter)’ 개념을 활용하여 짧은 시간을 캐시 만료 시간에 더해서 부하를 분산시킬 수 있다.
예를 들면 0~10초 사이의 무작위 지연 시간을 추가하면, 데이터베이스의 부담이 10초에 걸쳐 분산된다.
또한 지터가 길어질수록 더 오래된 정보를 볼 수 있으므로 서비스에 적절한 최대 지터 시간을 설정해야 한다.
4. 대기열 개선(feat. Redis)
- DB 저장 방식(기존)
Queue queue = Queue.builder()
.userId(1L)
.queueStatus(QueueStatus.WAIT)
.expiredAt(LocalDateTime.now().plusMinutes(10))
.build();
long startTime = System.nanoTime();
queueRepository.save(queue); // DB에 저장
Queue dbValue = queueRepository.findByUserId(queue.getUserId()); // DB 조회
long endTime = System.nanoTime();
- Redis 저장 방식(개선)
String token = UUID.randomUUID().toString();
long startTime = System.nanoTime();
zSetOperations.add("queue", token, System.currentTimeMillis()); // Redis에 저장
String redisValue = zSetOperations.range("queue", 0, 0).iterator().next(); // Redis 조회
long endTime = System.nanoTime();
Redis로 개선한 뒤에 스레드를 10번 호출하여 테스트 한 결과, DB 방식에 비해 약 5배 빠른 성능을 보여서 조회가 빈번히 일어날 때 Redis를 사용하는 것이 더 유리하다.
대기유저 (Waiting Tokens)
- sorted sets 활용 : 대기열에 진입할 때 순서대로 활성상태로 변경되어야 하므로 순차를 보장하는 sorted sets를 이용한다.
@Autowired
public QueueRedisRepositoryImpl(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
this.zSetOperations = redisTemplate.opsForZSet();
this.setOperations = redisTemplate.opsForSet();
}
@Override
public String createWaitingQueue(Long userId) {
String tokenId = UUID.randomUUID().toString();
double score = System.currentTimeMillis(); // 현재 시간을 점수로 사용
zSetOperations.add(WAITING_QUEUE_KEY, tokenId, score);
String userKey = "queue:token:" + tokenId;
redisTemplate.opsForHash().put(userKey, "userId", userId.toString());
// Redis에서 만료 시간 설정 (5분 후 자동 삭제)
redisTemplate.expire(WAITING_QUEUE_KEY, QUEUE_EXPIRE_SECONDS, TimeUnit.SECONDS);
return tokenId;
}
활성유저 (Active Tokens)
- sets 활용 : 활성 상태로 변경된 대기열 토큰은 순차를 보장할 필요가 없기 때문에 sets에 데이터를 저장한다.
@Override
public void createActiveQueue(String tokenId) {
setOperations.add(ACTIVE_QUEUE_KEY, tokenId);
}
로직 개선
- Active Scheduler : 활성 스케줄러를 돌면서 sets에는 만료 시간을 저장할 수 없기 때문에 hash를 이용하여 token의 만료 시간을 저장하였다.
public void activeToken() {
Set<String> tokens = queueRedisRepository.getWaitingQueues(10);
long expiryTime = System.currentTimeMillis() + (5 * 60 * 1000); // 만료시간 5분 설정
if (tokens != null && !tokens.isEmpty()) {
for(String tokenId : tokens){
queueRedisRepository.createActiveQueue(tokenId);
queueRedisRepository.putHashExpiryTime(tokenId, String.valueOf(expiryTime));
queueRedisRepository.removeWaitingQueue(tokenId);
}
log.info("활성화된 토큰 수:" + tokens.size());
}
}
- 대기 토큰을 10개 가져온다.
- 만료 시간을 5분으로 설정한다.
- 가져온 대기 토큰의 tokenId를 ActiveQueue에 담아준다.
- ActiveQueue는 Sets 자료 구조로 만료 시간을 담을 수 없기 때문에 hash에 담아준다.
- 해당 대기 토큰은 WaitingQueue에서 제거한다.
- Delete Scheduler : 만료 스케줄러를 돌면서 hash에 담긴 토큰이 만료되었는지 체크한다.
public int deleteToken() {
Map<String, String> tokenExpiryMap = queueRedisRepository.getHashExpiryTime();
int result = 0;
if(!tokenExpiryMap.isEmpty()){
long currentTime = System.currentTimeMillis();
List<String> expiredTokens = new ArrayList<>();
for (Map.Entry<String, String> entry : tokenExpiryMap.entrySet()) {
String tokenId = entry.getKey();
long expiryTime = Long.parseLong(entry.getValue());
// 만료 시간이 현재 시간보다 작으면 만료된 토큰 리스트에 추가
if (expiryTime <= currentTime) {
expiredTokens.add(tokenId);
}
}
if(!expiredTokens.isEmpty()){
for(String expiredToken : expiredTokens){
queueRedisRepository.removeActiveQueue(expiredToken);
queueRedisRepository.removeHashExpiryTime(expiredToken);
}
}
result = expiredTokens.size();
}
return result;
}
- ActiveToken의 만료 시간이 담긴 Hash를 모두 읽어온다.
- Hash에서 만료 시간을 가져와서 현재 시간보다 작으면 만료 토큰 리스트에 tokenId를 추가한다.
- 만료된 토큰이 있으면 ActiveQueue, Hash에서 해당 토큰을 지운다.
기타 로직 개선
- interceptor에서 대기열 토큰 검증을 할 때 ActiveQueue(Sets)에 값이 있는지 체크한다.
- 결제 후 tokenId에 해당하는 토큰을 ActiveQueue(Sets)에서 제거한다.
5. 회고
현업에서는 트래픽이 많이 발생하는 도메인이 아니다보니 캐시를 사용하여 성능을 높일 일이 없어서 DB에서만 조회하도록 하였다.
하지만, 조회 요청이 빈번해질수록 DB 부하가 증가하면서 속도가 느려지고, 결국 서비스 성능에 영향을 미친다는 문제를 경험하게 되었다.
이 과정에서 Redis가 Key-Value 저장 방식의 NoSQL DB이자, 인메모리 저장소로 동작하여 데이터 처리 속도가 매우 빠르며, 캐시로도 활용할 수 있다는 점을 알게 되었다.
다만, 많은 사람들이 "Redis = 캐시" 라고 단순히 오해하는 경우가 있지만, Redis는 캐싱뿐만 아니라 다양한 용도로 활용될 수 있는 데이터 저장소라는 점을 짚고 넘어가야 한다.
또한, 대기열 시스템에서는 스케줄러가 주기적으로 대기 토큰과 활성 토큰을 조회해야 하기 때문에, DB 조회가 잦아질수록 성능 저하가 발생한다. 이를 해결하기 위해 캐싱을 적용하면 시스템 성능을 효과적으로 개선할 수 있음을 확인할 수 있었다.