-
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을 같은 것으로 취급하면 안 된다는 것, 그리고 의미 없는 데이터는 캐싱하지 않아야 한다는 것이다. 캐싱 로직을 짤 때는 항상 "이 데이터가 없는 경우에는 어떻게 동작하는가"를 먼저 생각해야 할 것 같다.
'프로젝트, 트러블슈팅' 카테고리의 다른 글
실시간 할인 알림 구현기 — Spring SSE + Scheduler (0) 2026.03.04 도움돼요 토글, 비관적 락 대신 DB 원자적 UPDATE를 선택한 이유 (0) 2026.03.04 복합 유니크 제약조건으로 중복 데이터 막기 (0) 2026.03.01 JPA N+1 문제와 @EntityGraph로 해결하기 (0) 2026.03.01 JWT 인증에서 Access Token, Refresh Token, Redis가 필요한 이유 (0) 2026.03.01