업무에서 캐시를 적용해보고 관련된 이슈사항이 뭐가 있을까 찾아보다가 각 회사에서
좋은 캐시문제 해결 방안들을 찾아서 간단히 정리를 해보았습니다.
토스에서 캐시에 관련된 너무 좋은 글 을 읽어 저만의 방식으로 정리를 해 보았습니다.
캐시 문제 해결 가이드 - DB 과부하 방지 실전 팁 (toss.tech)
캐시 쇄도 Cache Stampede
캐시 미스가 동시에 발생해 데이터베이스에 많은 부담이 가는 상황을 말합니다.
많은 캐시가 같은 시간에 만료되도록 구현을 하면 자주 발생하는 상황입니다.
이를 Jitter 를 활용해서 동시 요청을 분산 시키거나 스케줄링을 활용하여 캐시에 주기적으로 값을 갱신해 줄 수 있습니다.
Redis 와 MongoDB를 활용해 올리브영에서 구축한 내용입니다.
너무 좋은 글 이라 글의 내용으로 대체하겠습니다.
https://oliveyoung.tech/blog/2023-10-17/oliveyoung-mall-home-new-architecture/
캐시 관통 Cache Penetration
많은 곳에서 Look Aside 을 캐시 전략으로 많이 사용합니다.
만약 db 에서 null 값을 반환 한다면 캐시 안에 값을 넣어주지 않는 상황이 많습니다.
이 처럼 db에서 값을 가져와도 캐싱되지 않는 상황을 "캐시 관통" 이라고 합니다.
캐시 관통시 모든 요청이 db 에 가는 상황이 발생 합니다.
이는 대용량 트래픽 상황에 db 장애가 발생하는 지점일 수도 있습니다.
이런 상황에는 간단하게 Null Object Pattern 으로 캐시에 저장해
db로 가는 트래픽을 줄 일 수 있습니다.
@Cacheable(cacheNames = "example", key = "#id")
public ExampleResponse findExample(String id) {
return exampleRepository.find(id)
.orElse(ExampleResponse.getNullObject());
}
코드 레벨 에서는 이런 방식으로 DTO 값의 NullObject를 반환 해 주는 메소드를 추가해주는 것만으로 간단하게 구현을 할 수 있습니다.
캐시 장애
당연히 캐시 에서도 장애가 날 수 있습니다.
이는 캐시가 받던 트래픽을 다른 저장소 에서 트래픽을 나눠받을 수있다는 애기 이고 트래픽이 굉장히 많을 시 다른 저장소들도 차례대로 죽으며 장애가 전파 되는 상황이 발생할 수 있습니다.
이럴 시에는 Failover 를 활용하여 최소한의 핵심 기능만을 제공하는 상황을 만들면 좋습니다.
이는 ui에서 많은 부분이 필요하고 백엔드에서는 캐시서버와의 커넥션을 체크해주는 로직을 하나 만들어두면
각 사내 정책에 맞게 활용하면 좋습니다.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.stereotype.Service;
@Service
public class RedisConnectionService {
@Autowired
private RedisConnectionFactory redisConnectionFactory;
public boolean isRedisAvailable() {
try (RedisConnection connection = redisConnectionFactory.getConnection()) {
return !connection.isClosed();
} catch (Exception e) {
return false;
}
}
}
백엔드 에서 레디스와의 연결상태를 체크해주는 로직을 만들고 이를 api로 제공해주면 프론트엔드에서 주기적으로 helthcheck를 해 ui상으로 편하게 변경 가능합니다.
핫키 만료
캐시 에서도 많은 요청이 오는 키를 핫키라고 합니다. 그 핫키가 만료되는 순간
동시에 많은 요청이오면 캐시에 데이터를 넣기전까지 그 많은 요청들은 데이터베이스를 불필요하게 반복 조회를 하는 상황이 발생합니다. 이를 막기위해서는 Lock을 활용하면 됩니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
/**
* 락의 이름
*/
String key();
/**
* 락의 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 락을 기다리는 시간 (default - 5s)
* 락 획득을 위해 waitTime 만큼 대기한다
*/
long waitTime() default 5L;
/**
* 락 임대 시간 (default - 3s)
* 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
*/
long leaseTime() default 3L;
}
@Aspect
@Component
@RequiredArgsConstructor
@Sl4j
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(com.example.DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(
signature.getParameterNames(),
joinPoint.getArgs(),
distributedLock.key());
RLock rLock = redissonClient.getLock(key);
try {
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); // (2)
if (!available) { rock을 못 가져올시 return
return false;
}
return aopForTransaction.proceed(joinPoint); // 비즈니스 로직과의 트랜잭션 분리
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock(); // 무조건 트랜잭션의 종료 이후의 락을 해제 한다.
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {} {}",
kv("serviceName", method.getName()),
kv("key", key)
);
}
}
}
}
public class CustomSpringELParser {
private CustomSpringELParser() {
}
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
return parser.parseExpression(key).getValue(context, Object.class);
}
}
이는 컬리에서 Spring AOP와 redis를 활용해 분산락 (레드락)을 구현한 코드를 가져온 모습입니다
CustomAnootian까지 만들어서 구현을 한 코드가 너무 마음에 들어서 이글을 보시는 분들이 보기 편하게 보여드립니다.
블로그를 가시면 상세한 설명을 들으실 수 있으니 아래 있는 링크에 들어가 글을 한번 읽어보시는걸 추천드립니다!
풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson - 컬리 기술 블로그 (kurly.com)