ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Redis 캐시에 빈 배열이 영구 저장되는 버그 트러블슈팅
    프로젝트, 트러블슈팅 2026. 3. 4. 15:08

    시작하면서

    프로젝트에서 카테고리와 브랜드 조회 API에 Redis 캐싱을 붙였다. 자주 바뀌지 않는 데이터라 캐싱하면 성능이 좋아질 거라 생각했다. 근데 배포하고 나서 API를 호출하면 데이터가 있는데도 빈 배열이 계속 반환됐다. 원인을 찾아보니 Redis가 빈 배열을 영구 저장하고 있었다.


    Redis란?

    Redis는 메모리 기반 Key-Value 저장소다. 데이터를 디스크가 아닌 메모리에 저장하기 때문에 조회 속도가 극도로 빠르다.

    MySQL → 디스크 저장 → 조회 50~100ms
    Redis → 메모리 저장 → 조회 1~5ms
    

    캐싱에 많이 쓰이는 이유가 여기 있다. 자주 조회되는 데이터를 Redis에 저장해두면 DB까지 가지 않아도 되니까 응답 속도가 훨씬 빨라진다.

    Redis 캐싱의 기본 흐름은 이렇다.

    첫 번째 요청
    API 호출 → Redis 확인 (없음) → DB 조회 → Redis 저장 → 응답
    
    두 번째 요청 이후
    API 호출 → Redis 확인 (있음) → 즉시 응답 (DB 조회 X)
    

    첫 번째 요청만 DB를 조회하고, 이후부터는 Redis에서 바로 가져온다. 단점은 Redis에 잘못된 데이터가 저장되면 그 데이터를 계속 반환한다는 것이다. 이번 문제가 딱 그 경우였다.

    TTL(Time To Live)이라는 자동 만료 기능도 있다. 저장할 때 만료 시간을 설정하면 그 시간이 지나면 자동으로 삭제된다. 이번 문제에서는 TTL을 무한으로 설정했던 것도 원인 중 하나였다.


    문제 상황

    카테고리 조회 API를 호출했는데 DB에 데이터가 32개나 있는데도 빈 배열이 반환됐다.

    GET /api/categories
    {
      "status": "OK",
      "data": []
    }
    

    DB를 직접 확인하면 데이터가 멀쩡히 있었다. 근데 API는 계속 빈 배열만 뱉었다.


    근본 원인

    문제는 배포 타이밍이었다. Redis 캐싱 기능을 배포했을 때 DB에 카테고리 데이터가 아직 없는 상태였다. 그 타이밍에 누군가 API를 호출했고, DB를 조회하니 데이터가 없으니까 빈 배열이 반환됐다. 문제는 그 빈 배열이 Redis에 그대로 캐싱된 것이다.

    1. Redis 캐싱 기능 배포 (이때 DB에 데이터 없음)
    2. 누군가 API 호출
    3. Redis 확인 → 없음
    4. DB 조회 → 데이터 없으니까 빈 배열 []
    5. 빈 배열을 Redis에 캐싱 (TTL 무한)
    6. 나중에 DB에 데이터 INSERT
    7. 이후 API 호출 → Redis에서 빈 배열만 반환 (DB 조회 안 함)
    

    DB가 빈 배열을 반환한 건 잘못이 아니다. 그 시점에 실제로 데이터가 없었던 것이고, 진짜 문제는 그 빈 배열을 Redis에 영구 저장해버린 코드였다.

    코드를 보면 이유가 바로 보인다.

    public List<CategoryResponse> getAllCategories() {
        List<CategoryResponse> cached = redisCacheService.getList(
            CacheKeyConstants.CATEGORIES_ALL,
            CategoryResponse.class
        );
    
        if (cached != null) {  // 문제! 빈 배열 []도 null이 아님
            return cached;     // 빈 배열 그대로 반환
        }
    
        List<Category> categories = categoryRepository.findAll();
        List<CategoryResponse> response = categoryConverter.toResponseList(categories);
    
        redisCacheService.save(
            CacheKeyConstants.CATEGORIES_ALL,
            response  // 빈 배열도 저장
        );
    
        return response;
    }
    

    cached != null 체크는 빈 배열 []도 통과한다. 빈 배열이 한 번 캐싱되고 나면 이후 모든 요청에서 DB 조회 없이 빈 배열만 반환하게 된다. TTL이 무한이라 Redis를 직접 건드리기 전까지 절대 사라지지 않는다.


    즉시 해결 - Redis 캐시 수동 삭제

    일단 서버에 접속해서 잘못 저장된 캐시를 직접 삭제했다.

    redis-cli
    
    # 저장된 키 확인
    keys *
    # 1) "RT:6"
    # 2) "BRANDS:ALL"      ← 문제의 키
    # 3) "CATEGORIES:ALL"  ← 문제의 키
    
    # 삭제
    del CATEGORIES:ALL
    del BRANDS:ALL
    
    exit
    

    삭제하고 나서 API를 다시 호출하니 정상적으로 데이터가 반환됐다. Redis에 캐시가 없으니 DB를 조회했고, 이번엔 데이터가 있으니까 정상 반환된 것이다.


    재발 방지 - 코드 수정

    프로젝트 마감일이 얼마 안남아서 수동 삭제로 급한 불은 껐는데, 근본적인 코드 문제를 고쳐야 재발을 막을 수 있다. 두 가지를 수정했다.

    public List<CategoryResponse> getAllCategories() {
        List<CategoryResponse> cached = redisCacheService.getList(
            CacheKeyConstants.CATEGORIES_ALL,
            CategoryResponse.class
        );
    
        // 수정 1: 빈 배열은 캐시 히트로 보지 않음
        if (cached != null && !cached.isEmpty()) {
            return cached;
        }
    
        List<Category> categories = categoryRepository.findAll();
        List<CategoryResponse> response = categoryConverter.toResponseList(categories);
    
        // 수정 2: 빈 배열은 Redis에 저장하지 않음
        if (response.isEmpty()) {
            log.warn("조회된 카테고리가 없습니다.");
            return response;
        }
    
        redisCacheService.save(
            CacheKeyConstants.CATEGORIES_ALL,
            response,
            CacheKeyConstants.MASTER_DATA_TTL_HOURS
        );
    
        return response;
    }
    

    수정 후 흐름이 이렇게 바뀐다.

    1. DB에 데이터 없는 상태에서 API 호출
    2. Redis 확인 → 없음
    3. DB 조회 → 빈 배열 []
    4. isEmpty() 체크 → Redis에 저장 안 함
    5. 빈 배열 반환
    
    6. DB에 데이터 INSERT
    7. 다시 API 호출
    8. Redis 확인 → 없음 (저장 안 했으니까)
    9. DB 조회 → 데이터 있으니까 정상 반환
    10. Redis에 캐싱
    

    빈 배열은 Redis에 저장을 안 하니까, 다음 요청에서 다시 DB를 조회한다. DB에 데이터가 생기면 그 다음 요청부터 바로 정상 동작한다.


    마무리

    Redis 캐싱은 성능을 크게 높여주는 강력한 도구인데, 잘못 쓰면 오히려 잘못된 데이터를 빠르게 반환하는 문제가 생긴다. 이번 경험으로 두 가지를 배웠다. 빈 배열과 null을 같은 것으로 취급하면 안 된다는 것, 그리고 의미 없는 데이터는 캐싱하지 않아야 한다는 것이다. 캐싱 로직을 짤 때는 항상 "이 데이터가 없는 경우에는 어떻게 동작하는가"를 먼저 생각해야 할 것 같다.

Designed by Tistory.