Redis 캐시 구조 개선 : Redis Hash 적용하기

문제상황

프로젝트에서 게시글 목록을 보여줄 때, 각 게시글마다 통계 정보(답변 수, 댓글 수, 좋아요 수, 조회수)를 함께 표시해야 했다.

문제

  1. 게시글 10개가 조회된다면, 통계 조회를 위해 MongoDB에 10번 쿼리를 날려야 했다.
  2. 사용자가 늘어날 수록 DB 서버에 부하가 계속 증가했다.
  3. 좋아요나 조회수는 실시간으로 바뀌는데, 매번 DB에서 가져오는 것도 비효율적이다.

이 문제를 해결하기 위해 Redis를 도입했다.

 

기존 캐시 구조 : 전체 객체 캐싱

처음엔 가장 직관적인 방법으로 BoardStats 객체 전체를 Redis에 저장했다.

@Component
@RequiredArgsConstructor
public class BoardStatsCache {
    private final RedisTemplate<String, BoardStats> redisTemplate;
    private static final String PREFIX = "board:stats:";
 
    public Map<String, BoardStats> getWithFallback(
            List<String> qnaIds,
            Function<List<String>, List<BoardStats>> fallbackFn
    ) {
        List<String> keys = qnaIds.stream().map(id -> PREFIX + id).toList();
        List<BoardStats> cached = redisTemplate.opsForValue().multiGet(keys);
 
        // 캐시 히트/미스 처리 로직
 
        return result;
    }
}

 

기존 방식의 장단점

장점

  1. 구현이 간단하고 직관적이다.
  2. multiGet으로 여러 객체를 한번에 조회 가능하다.
  3. 캐시 히트 시 매우 빠른 응답이 가능하다.

단점

  1. 전체 통계 중 하나의 통계만 업데이트돼도 전체 객체를 다시 저장해야 한다.
  2. 동시 업데이트 시 데이터 일관성 문제가 발생할 수 있다.

2가지 대안

위 문제를 해결하기 위해 2개의 대안을 가지고 고민했다.

개별 키로 분리

qna:stats:likeCount:123 → 45
qna:stats:viewCount:123 → 1203
qna:stats:answerCount:123 → 3
qna:stats:replyCount:123 → 12

분리하면 개별 필드 업데이트가 가능해지지만, 하나의 게시글 통계 정보를 조회할 때마다 4번의 Redis 호출이 필요하다.

Redis Hash

qnaStats:123 → {
"likeCount": "45",
"viewCount": "1203",
"answerCount": "3",
"replyCount": "12"
}

하나의 키에 여러 필드 저장이 가능하고, 각 필드에 개별 접근이 가능하다

이로인해 개별 키로 분리했을 때의 문제점이 개선돼 한 번의 Redis 호출로 통계 정보를 가져올 수 있다.

하지만 모든 값이 String이므로 타입 안정성이 떨어지고, 직렬화/역직렬화 시 변환 로직이 필요하다.

 

최종 결정 : Redis Hash

기존처럼 multiGet을 활용할 수 있어, Redis 호출을 최소화할 수 있는 성능상의 이점이 있다.

또한 개별 필드 업데이트가 가능해지면 비동기 처리 기반이 마련이 된다.

위에서 언급했던 타입 안정성도 HashOperations을 사용하면 컴파일 단계에서 타입 체크가 가능해진다.

 

현재 프로젝트에서 Redis Hash가 더 적합한 이유

현재 프로젝트는 조회가 업데이트보다 자주 일어나고, 좋아요나 조회수 같은 덜 중요한 통계는 약간 지연되도 큰 문제가 없다.

또한 책임에 따라 클래스를 나눴는데, 이때 HashOperations을 다음과 설정과 같이 빈등록을 해두면 모든 클래스가 똑같은 타입 설정을 보장받기 때문에 일관성을 유지하기 쉽다.

 

HashOperations 적용

@Component
@RequiredArgsConstructor
public class BoardStatsRedisCache {
    private static final String HASH_PREFIX = "qnaStats:";
    private static final Duration CACHE_TTL = Duration.ofHours(1);
    private final HashOperations<String, String, String> hashOperations;


    /**
     * Redis에서 BoardStats를 조회하고, 없으면 fallback으로 MongoDB에서 조회 후 캐시
     */
    public Map<String, BoardStats> getWithFallback(
            List<String> qnaIds,
            Function<List<String>, List<BoardStats>> mongoFallback
    ) {
        // 구현 로직
    }

    public void cacheStats(BoardStats stats) {
        String key = HASH_PREFIX + stats.getQnaId();
        hashOperations.putAll(key, mapToHash(stats));
        hashOperations.getOperations().expire(key, CACHE_TTL);
    }
    
    public void incrementLike(String qnaId) {
        String key = HASH_PREFIX + qnaId;     // 개별 필드 업데이트
        hashOperations.increment(key, "likeCount", 1);
    }
}

 

마무리

이번 캐시 구조 개선에서는 단순히 Redis Hash가 좋아 쓰는 것이 아닌 우리 상황에서 어떤지 비교하면서 적용해봤다.

그 결과 우리 상황에서 최적이였고, 아직까지는 "개별 키로 분리했을 때가 더 나을 수가 있나?" 라는 생각이 든다.

또한 앞으로 캐시 전략을 선택할 때 데이터 일관성의 중요도와, 확장성을 고려해야 된다는 것을 배웠다.