자가진단 관련 데이터를 캐시를 이용해 조회할 때 cache miss 문제가 발생했습니다. cache miss란 캐시에 필요한 데이터가 없는 것을 말합니다.
기존 로직은 DB에 있는 자가진단 관련 데이터를 한번에 캐시에 저장하고, 이후 조회할 때 캐시를 통해서만 데이터를 조회하는 로직이었습니다.
새로운 자가진단 데이터가 DB에만 등록되있을 경우 cache miss가 발생합니다.
//DiagnosisServiceImpl.class
@Override
public Diagnosis readOne(int diagnosisId) {
//자가진단 데이터를 캐시에서 가져옴
Diagnosis diagnosis = diagnosisDAO.findById(diagnosisId);
//자가진단 관련 질문들을 캐시에서 가져옴.
List<Question> questions = questionService.findAllByDiagnosisIdInCache(diagnosisId);
//자가진단 관련 질문 점수표를 캐시에서 가져옴.
List<QuestionBaseLine> questionBaseLines =
questionBaseLineService.readByDiagnosisIdInCache(diagnosisId);
//Diagnosis 엔티티로 반환.
return diagnosis.withQuestionAndBaseLine(questions, questionBaseLines);
}
위와 같은 자가진단 조회 코드가 있으며 다음과 같은 로직이 일어납니다.
1. 자가진단 데이터를 캐시에서 조회
2. 자가진단 관련 설문지 질문 데이터들을 캐시에서 조회
3. 자가진단 관련 질문 점수표를 캐시에서 가져옴.
4. 자가진단 엔티티로 변환
만약 새로운 자가진단 데이터가 DB에만 등록될 경우, 캐시에는 데이터가 없게 됩니다. 다시 캐시에 새 데이터를 넣는 갱신 로직이 필요할 듯합니다.
우아한 테크 세미나와 캐시 위키백과를 통해 Look aside 캐시 전략을 알게 되었습니다.
캐시와 DB에 항상 동시에 데이터를 기록하는 방식입니다. 항상 최신의 데이터가 유지되지만, 쓰기 작업이 많을 경우 캐시와 DB에 매번 통신하는 비용이 발생합니다.
데이터를 캐시에만 기록합니다. 이후에 캐시에 있는 데이터를 DB에 한번에 등록합니다.
장점은 쓰기가 많이 일어나도 캐시에만 업데이트 되기 때문에 빠른 처리가 가능합니다. 하지만 캐시 자체는 휘발성 메모리라서 데이터 유실 가능성이 있습니다.
캐시에 데이터가 저장되어있는지 보고 만일 저장되어 있다면 해당 데이터를 반환합니다. 캐시에 데이터가 없는 경우 DB 질의를 통해 데이터를 찾아 캐시에 저장한 뒤 클라이언트에 반환합니다. DB가 캐시에 데이터를 저장하기 때뭉네 애플리케이션단에서 관리할 수 없습니다.
이 방식은 캐시에 데이터가 있는 cache hit 시에는 캐시에서 데이터를 가져오고, 캐시에 데이터가 없는 cache miss 시에는 DB에서 데이터를 가져와 다시 캐시에 저장하고 결과를 반환하는 구조입니다.
cache miss가 일어나도, DB에서 다시 가져올 수 있기 때문에 현재 문제를 해결 할 수 있습니다. 하지만 캐시에 데이터가 있는 채로 DB 데이터가 변경되었다면, 캐시와 DB 데이터가 다른 불일치 문제가 발생합니다.
그렇기 때문에 Look aside 구조를 적용한다면 데이터 불일치 문제에 대한 해결 전략을 추가해야합니다. 스프링 데이터 레디스 문서에서는 TTL(Time to Live)이라는 기능을 제공합니다. redis 데이터의 만료기간을 설정하여, TTL기간이 지나면 캐시 데이터가 없어지게 됩니다. TTL을 설정함으로써 자연스럽게 캐시 값이 없어지고, 자가진단 관련 데이터 조회 시 DB 데이터를 캐시에 저장하게되면서 최신의 DB 데이터를 캐시에 담을 수 있게 됩니다.
결론적으로 문제 해결을 위해 Look Aside 구조와 TTL을 적용합니다.
//RedisConfiguration.class
...
@Bean
public RedisCacheManager redisCacheManager() {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration
.defaultCacheConfig()
.serializeKeysWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper()))
)
.entryTtl(Duration.ofDays(1L));//TTL 적용
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory())
.cacheDefaults(redisCacheConfiguration)
.build();
}
Redis Cache Manager는 스프링에서 캐시 추상화나 TTL 적용 등의 기능을 설정할 수 있도록 합니다.
//DiagnosisServiceImpl.class
@Override
public Diagnosis readOne(int diagnosisId) {
//자가진단 데이터를 캐시에서 조회
Diagnosis diagnosis = diagnosisDAO.findById(diagnosisId);
//만약 데이터가 없다면 DB에서 조회하고, 캐시에도 저장
if (isEmpty(diagnosis)) {
diagnosis = diagnosisRepository.findById(diagnosisId);
diagnosisDAO.save(diagnosis);
}
//질문 데이터를 캐시에서 조회
List<Question> questions = questionService.findAllByDiagnosisIdInCache(diagnosisId);
//질문 데이터가 없다면 DB에서 조회하고, 캐시에도 저장
if (questions.isEmpty()) {
questions = questionService.findAllByDiagnosisIdInDB(diagnosisId);
questionService.saveAllInCache(questions);
}
//질문 점수표를 캐시에서 조회
List<QuestionBaseLine> questionBaseLines =
questionBaseLineService.readByDiagnosisIdInCache(diagnosisId);
//질문 데이터가 없다면 DB에서 조회하고, 캐시에도 저장
if (questionBaseLines.isEmpty()) {
questionBaseLines = questionBaseLineService.readByDiagnosisIdInDB(diagnosisId);
questionBaseLineService.saveAllInCache(questionBaseLines);
}
return diagnosis.withQuestionAndBaseLine(questions, questionBaseLines);
}