TIL

4_4.Redis와 캐싱 전략, 최적화 실습

꿀승 2025. 1. 16. 22:32
728x90
반응형
SMALL

학습내용

  1. Redis를 활용한 캐싱전략
  2. Redis 성능 최적화
  3. 데이터 일관성 문제 해결

학습정리

1. Redis를 활용한 캐싱전략

@Slf4j
@Service
@RequiredArgsConstructor
public class CategoryService {
  //상수로 redis키 설정
  //전역으로 쓰는 상수값은 constants 패키지에서 관리
  private static final String CACHE_KEY_CATEGORY_STRUCT = "category_struct";
  private static final String CACHE_KEY_PRODUCT_COUNT = "product_count";

  private final RedisService redisService;
  private final CategoryRepository categoryRepository;
  private final CategoryQueryRepository categoryQueryRepository;

  ...생략 (기본적인 CRUD)
  //Cache-aside 패턴 -> 조회시 캐시먼저 확인 후 없으면 db에서 가져오고 캐시저장
  public List<CategoryProductCountResponse> productCounts(CategoryProductCountRequest search) {
    //dto 해시코드와 결합해여 키 설정
    String cacheKey = CACHE_KEY_PRODUCT_COUNT + search.hashCode();
    //캐시에서 데이터 확인
    List<CategoryProductCountResponse> cachedResponse = redisService.getObject(cacheKey,
        new TypeReference<List<CategoryProductCountResponse>>() {
        });
    //해당 값이 비어있지 않으면 반환
    if (!ObjectUtils.isEmpty(cachedResponse)) {
      return cachedResponse;
    }
    //없을 경우 db에서 조회 후 캐시 저장
    List<CategoryProductCountResponse> responses = categoryQueryRepository.productCount(search);
    if (!ObjectUtils.isEmpty(responses)) {
      redisService.saveObject(cacheKey, responses);
    }

    return responses;
  }

  //카테고리 계층형 구조 가져오는 로직 Map을 활용
  public List<CategoryResponse> findStruct() {
    List<Category> categories = categoryRepository.findAll();

    Map<Long, CategoryResponse> categoriesResponseMap = new HashMap<>();
    for (Category category : categories) {
      CategoryResponse response = CategoryResponse.builder()
          .name(category.getName())
          .children(new ArrayList<>())
          .build();

      categoriesResponseMap.put(category.getId(), response);
    }

    List<CategoryResponse> rootCategories = new ArrayList<>();
    for (Category category : categories) {
      CategoryResponse categoryResponse = categoriesResponseMap.get(category.getId());
      if (ObjectUtils.isEmpty(category.getParent())) {
        rootCategories.add(categoryResponse);
      } else {
        CategoryResponse parentCategoryResponse = categoriesResponseMap.get(
            category.getParent().getId());
        parentCategoryResponse.getChildren().add(categoryResponse);
      }
    }
    return rootCategories;
  }
  //Cache-aside 패턴
  public List<CategoryResponse> findCategoryStructCacheAside() {
    List<CategoryResponse> cacheCategories = redisService.getObject(CACHE_KEY_CATEGORY_STRUCT,
        new TypeReference<List<CategoryResponse>>() {
        });

    if (!ObjectUtils.isEmpty(cacheCategories)) {
      return cacheCategories;
    }
    List<CategoryResponse> rootCategories = findStruct();

    if (!ObjectUtils.isEmpty(rootCategories)) {
      redisService.saveObject(CACHE_KEY_CATEGORY_STRUCT, rootCategories);
    }
    return rootCategories;

  }

  //write-through는 DB,cache 동시 업데이트
  public Boolean saveWriteThrough(CategoryRequest request) {
    Category category = Category.builder()
        .name(request.getName())
        .build();

    categoryRepository.save(category);
    //위에 save 한 값이 db에 저장되고
    //updateCache()를 통해 저장된 값을 가져와 캐시에 저장
    updateCache();
    return true;
  }

  public void updateCache() {
    //캐시가 저장 안됐다고 에러처리하기 애매해서 try catch로 잡음
    try {
      //새로 업데이트된 db에서 새로 조회
      List<CategoryResponse> rootCategories = findStruct();
      //redis에 해당키 업데이트
      redisService.saveObject(CACHE_KEY_CATEGORY_STRUCT, rootCategories);
    } catch (Exception e) {
      log.error("Error updating cache: {}", CACHE_KEY_CATEGORY_STRUCT, e);
    }
  }
  //write-back : 캐시먼저 저장 후에 DB 비동기로 저장
  public Boolean saveWriteBack(CategoryRequest request) {
    List<CategoryResponse> categories = redisService.getObject(CACHE_KEY_CATEGORY_STRUCT,
        new TypeReference<List<CategoryResponse>>() {
        });

    if (ObjectUtils.isEmpty(categories)) {
      //없으면 초기화 후에
      categories = new ArrayList<>();
    }
    CategoryResponse newCategory = CategoryResponse.builder()
        .name(request.getName())
        .children(new ArrayList<>())
        .build();

    categories.add(newCategory);
    redisService.saveObject(CACHE_KEY_CATEGORY_STRUCT, categories, 3600);

    saveToDatabaseAsync(request);
    return true;
  }
  //비동기 메서드는 Async 붙여주기
  @Async
  public void saveToDatabaseAsync(CategoryRequest request) {
    try {
      categoryRepository.save(
          Category.builder()
              .name(request.getName())
              .build()
      );
    } catch (Exception e) {
      //TODO : 캐시초기화나 예외 발생시 문제 해결 로직 필요
      log.error("Error updating data : {}", e.getMessage(), e);
    }
  }
}

2. Redis 성능 최적화

  • 만료시간 설정(TTL) : 기본적으로 해당 방식으로 성능 최적화 진행

    • 기존 RedisService 수정

      
      @Slf4j
      @Service
      @RequiredArgsConstructor
      public class RedisService {
      
        private final Jedis jedis;
        private final ObjectMapper objectMapper;
      
        public <T> void saveObject(String key, T object) {
          try {
            String jsonValue = objectMapper.writeValueAsString(object);
            jedis.set(key, jsonValue);
          } catch (JsonProcessingException e) {
            log.error("[RedisService] saveObject#{}", e.getMessage());
          }
        }
      //오버로딩으로 TTL설정 메서드추가
        public <T> void saveObject(String key, T object, Integer ttlInSeconds) {
          try {
            String jsonValue = objectMapper.writeValueAsString(object);
            jedis.setex(key, ttlInSeconds, jsonValue);
          } catch (JsonProcessingException e) {
            log.error("[RedisService] saveObject#{}", e.getMessage());
          }
        }
      
        //get 할때 반환 받고 싶은 타입을 같이 인자로 넣음
        public <T> T getObject(String key, TypeReference<T> type) {
          try {
            String jsonValue = jedis.get(key);
            return ObjectUtils.isEmpty(jsonValue) ? null : objectMapper.readValue(jsonValue, type);
          } catch (Exception e) {
            log.error("[RedisService] getObject #{}", e.getMessage());
            return null;
          }
        }
      }
      
  • LRU (Least Recently Used) 및 LFU (Least Frequently Used) 정책

    • LRU : 가장 오랫동안 사용 되지 않은 데이터를 우선적으로 제거
    • LFU : 사용 빈도가 가장 낮은 데이터를 제거
    • 설정방법 : maxmemory-policy 설정을 통해 정책 변경
      CONFIG SET maxmemory-policy allkeys-lru # 모든 키에 대해 LRU 정책 사용 
      CONFIG SET maxmemory-policy volatile-lru # 만료 시간이 설정된 키에만 LRU 사용 
      CONFIG SET maxmemory-policy allkeys-lfu # 모든 키에 대해 LFU 정책 사용

3. 데이터 일관성 문제 해결

  • Cache-Aside 와 Write-through를 함께 사용

    • 동작방식
      • 데이터 조회 -> Cache-Aside : 캐시 있으면 사용 없으면 데이터베이스에서 읽어서 캐시 저장
      • 데이터 쓰기 -> Write-through : 캐시와 데이터베이스 동시에 데이터를 갱신
  • Cache Eviction :

    • 동작 방식: 데이터가 업데이트 될 때, 해당 키의 캐시 데이터를 제거 후 새로 데이터 로드시 캐싱이 저장되도록 처리 방식

    • 예시

      public void eviction(String key){
          //해당 키에 대한 캐시데이터 삭제
          redisService.delete(key);
      
          ...이후 데이터베이스 업데이트 로직
      }
  • TTL 설정

    • 캐시 데이터 유효기간 설정
      • 데이터 갱신이 많을 경우 만료시간을 적게 해당 도메인에 맞게 처리
      • 데이터 갱신이 적을 경우 만료시간을 길게 해당 도메인에 맞게 처리

참고자료

ps. 앞에서 캐싱전략과 Redis에 대해서 배웠었는데 당시에 배우면서 이걸 어떤식으로 사용하나 싶었는데
이번 시간에 배우면서 큰 흐름을 깨닫게 된 것 같아서 보람찬 시간이었습니다. :)

728x90
반응형
LIST