문제상황
프로젝트에서 게시글 목록을 보여줄 때, 각 게시글마다 통계 정보(답변 수, 댓글 수, 좋아요 수, 조회수)를 함께 표시해야 했다.
문제
- 게시글 10개가 조회된다면, 통계 조회를 위해 MongoDB에 10번 쿼리를 날려야 했다.
- 사용자가 늘어날 수록 DB 서버에 부하가 계속 증가했다.
- 좋아요나 조회수는 실시간으로 바뀌는데, 매번 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;
}
}
기존 방식의 장단점
장점
- 구현이 간단하고 직관적이다.
- multiGet으로 여러 객체를 한번에 조회 가능하다.
- 캐시 히트 시 매우 빠른 응답이 가능하다.
단점
- 전체 통계 중 하나의 통계만 업데이트돼도 전체 객체를 다시 저장해야 한다.
- 동시 업데이트 시 데이터 일관성 문제가 발생할 수 있다.
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가 좋아 쓰는 것이 아닌 우리 상황에서 어떤지 비교하면서 적용해봤다.
그 결과 우리 상황에서 최적이였고, 아직까지는 "개별 키로 분리했을 때가 더 나을 수가 있나?" 라는 생각이 든다.
또한 앞으로 캐시 전략을 선택할 때 데이터 일관성의 중요도와, 확장성을 고려해야 된다는 것을 배웠다.
'개발' 카테고리의 다른 글
| 통계 데이터 비동기로 관리하기 (2) | 2025.08.01 |
|---|---|
| 개발 환경 통일을 위한 Docker Compose 도입 (5) | 2025.07.30 |
| MongoDB 도큐먼트 분리와 Redis 캐싱으로 게시판 성능 최적화 (2) | 2025.05.26 |
| Redis 캐싱 직렬화 실패 해결 과정 (LocalDateTime 대응) (2) | 2025.05.06 |
| 대형 Document 분리와 비지니스 로직 개선 중 발생한 성능이슈 : [MongoDB 성능 리팩토링] (1) | 2025.04.29 |