- Published on
Redis를 활용한 조회수, 인기글 구현과 성능 개선 경험
- Authors
- Name
- 김민석
Introduction
상황
초기에는 단순히 DB의 조회수 컬럼을 증가시키는 방식이었기 때문에, 같은 사용자가 새로고침만 반복해도 조회수가 계속 올라갔고
, 트래픽이 몰릴 경우 DB에 부담이 있을 수 있는 구조
였다.
인기글 기능 또한 DB에서 조회수를 기준으로 정렬하여 상위 5개를 조회
하는 방식이라 실시간성이 부족하여 정확한 인기글 반영이 어려웠다.
무엇보다 조회수 증가와 인기글 조회가 완전히 분리된 구조였기 때문에, 조회수가 늘어나도 인기글 순위에는 즉각 반영되지 않았고, 두 기능을 연동하거나 확장하는 데에 구조적인 제약이 많았다.
문제
Redis를 기반으로 구조를 변경한 후, 다음과 같은 문제들을 겪었다.
view:article:*
패턴으로 Redis 키를 조회할 때 성능 저하 발생
첫번째 문제) 
Redis를 기반으로 조회수 기능을 구현하면서, 동기화 대상 키를 찾는 방식에 대해 잘못된 가정을 하고 있었다.
처음에는 view:article:1
, view:article:2
와 같이 키가 일관된 prefix로 구성되어 있으니, view:article:*
와일드카드 패턴으로 조회하면 Redis 내부에서도 효율적으로 이 키들만 탐색할 것이라 생각했다.
예를 들어 view:article:
로 시작하는 키만 빠르게 찾을 것이다라는 착각이 있었던 것이다. 하지만 실제로 Redis에서 KEYS view:article:*
명령을 실행하면, 전체 키 공간을 전수 스캔하며, 모든 키에 대해 매칭을 수행한다.
- 저장된 키들: view:article:1, view:article:2, article:top:list, user:session:abc, ...
- KEYS view:article:* 명령 실행 시
=> Redis는 모든 키를 순회하며, view:article:* 패턴과 매칭되는지 확인
=> O(N) 시간 복잡도, 대량의 키가 존재할수록 Redis 성능 저하
잘못 생각했던 것.
어차피 중복 체크도 다 끝났고, 조회수도 이미 증가한 애들이니까,
굳이 하나하나 따로 관리할 필요 없이 view:article:*로 전체 조회해서 DB에 반영해버리면 되지 않나?
하지만 실제 운영 환경에서 키 수가 많아지자, 이 방식은 Redis의 응답 지연과 CPU 부하를 유발하는 병목 지점이 되었다.
두번째 문제) 인기글 조회 로직에서 반복적인 Redis 조회로 성능 저하 발생

초기에는 인기글 API는 Redis ZSet을 활용해 조회수가 높은 게시글 ID Top 5를 먼저 조회한 뒤, 각 게시글의 Redis 조회수를 별도로 조회해 DB 값과 합산하는 방식으로 구성되어 있었다.
인기글 API는 Redis ZSet을 이용해 조회수가 높은 게시글 ID Top 5를 조회한 뒤, 각 게시글의 조회수를 다시 Redis에서 별도로 조회해 DB 값과 합산하는 방식으로 구현했다.
- ZSet에서 인기글 ID 조회 → Redis 요청 (1회)
“인기글 ID 5개 주세요” 하고 Redis에 물어본다.
ZREVRANGE article:top:list 0 4
- 각 게시글의 조회수 조회 → Redis 요청 (5회)
이 5개 ID 각각에 대해 “이 글의 조회수 몇이야?” 하고 Redis에 또 물어본다
GET article:view:1
GET article:view:2
GET article:view:3
GET article:view:4
GET article:view:5
결과적으로, 인기글 API 요청 한 번마다 최소 6회의 Redis 네트워크 호출이 발생했다. 트래픽이 많아질수록 Redis 부하와 응답 지연 가능성이 커지는 구조였다.
또한, 조회수 데이터가 DB와 Redis에 분산되어 있어 이를 합산하고 응답을 조립하는 과정 자체도 복잡했다.
원인
첫번째) Redis 와일드카드 패턴에 대한 오해
처음에는 view:article:1
, view:article:2
처럼 키가 정형화되어 있으니 view:article:*
로 Redis에서 조회할 수 있을 거라 생각했다. 하지만 Redis 내부 동작 방식에 대한 오해였다.
- KEYS
view:article:*
명령은 Redis가 전체 키 공간을 순회하며 매칭을 수행하는 O(N) 연산이다. - 키 수가 많아질수록 Redis의 응답 시간과
CPU 사용량이 증가
하고, 운영 환경에서 성능 병목을 초래했다. - 즉, 조회수 동기화를 위한 키 필터링 로직이 아니라
전체 키 스캔 기반
이었기 때문에 구조적으로 위험했다.
이 문제는 단순히 코드가 잘못된 것이 아니라, Redis 키 탐색 방식 자체에 대한 이해 부족에서 생긴 오류 설계
였다.
두번째) 인기글 API에서의 반복적 Redis 요청
ZSet으로 인기글 ID를 가져오고, 각 게시글의 조회수를 Redis에서 따로 GET 요청하여 DB 값과 합산하는 구조
였다.
- 초기 구조는 조회수와 랭킹 데이터가 물리적으로 분리되어 있어 발생한 문제였다.
- 조회수는
article:view:{id}
에, 랭킹은article:top:list
ZSet에 저장되는데, 이 둘이 분리되어 있다 보니조회 → 조회수 조회 → 합산 → 응답 조립
이라는 흐름이 발생했다. - 게시글 5개에 대해
총 6회의 Redis 네트워크 요청
이 발생하며, 트래픽 증가에 따라 Redis 병목이 생길 수밖에 없는 구조였다.
해결
동기화 대상 키 관리를 Set으로 분리하여 KEYS 명령 제거
초기에는 조회수 증가 시점에 Redis에 다음과 같은 키가 생성되고 있었다.
view:article:1
view:article:2
view:article:3
...
이 키들은 Redis 내에 일정 기간 유지되며, 실제 DB에는 비동기적으로 반영되도록 설계했는데, 문제는 어떤 게시글이 DB로 동기화되어야 하는지를 판단할 기준이 없었다
는 점이었다.
그 결과, 조회수 동기화 스케줄러는 매 실행마다 밑에 같은 방식으로 동기화 대상을 찾아야 했다
// 잘못된 방식
Set<String> keys = redisTemplate.keys("view:article:*");
위 코드는 view:article:
로 시작하는 모든 키를 찾기 위해 와일드카드 패턴을 사용하고 있지만, Redis의 KEYS 명령어는 전체 키 공간을 전수 스캔 하며 매칭을 수행하는 구조이다.
Redis 내에 수천개의 키가 존재하게 되면, 이 작업은 O(N) 복잡도를 가지며, 성능 저하 및 Redis CPU 사용량 급증의 원인이 될 수 있다.
// 조회수 증가 시점에 다음과 같은 작업을 함께 수행
redisTemplate.opsForValue().increment("view:article:" + articleId);
redisTemplate.opsForSet().add("articles:save:db", articleId);
조회수가 증가할 때마다 해당 게시글 ID를 articles:save:db
라는 Redis Set에 추가한다. Set은 단순한 중복 허용 없이 집합(Set) 형태로 ID만 관리되기 때문에 조회 시 중복 걱정 없고, 삽입/조회 연산도 평균 O(1) 성능을 보장한다.
그리하여 스케줄러는 전체 키를 순회하지 않고, 이 Set에 포함된 게시글 ID만 선택적으로 조회한다.
// 개선된 방식
Set<String> articleIds = redisTemplate.opsForSet().members("articles:save:db");
for (String id : articleIds) {
String key = "view:article:" + id;
String count = redisTemplate.opsForValue().get(key);
// DB 반영 및 키 삭제 로직 수행
}
인기글 API에서 Redis 반복 조회 제거
초기에는 인기글 정렬 기능을 Redis 없이 DB 단독으로 처리하거나, 조회수 기준 정렬을 실시간으로 반복 계산하는 방식이었다.
하지만 이 방식은 문제가 있었다.
- 트래픽 증가 시마다
정렬 쿼리가 DB에 부담
을 준다. - 실시간 조회수는 Redis에 저장되어 있으나,
인기글 정렬에 반영되기까지는 지연이 존재
한다. - 결과적으로 정렬 기준이 일관되지 않거나,
매 요청마다 부하가 커지는 구조
인 것이다.
이 문제를 해결하기 위해 인기글 랭킹을 실시간 계산 → 주기적 캐싱으로 구조 변경하였다.

- 게시글 조회수는 Redis에 저장 (예:
article:view:{id}
) - 인기글 랭킹은 조회수 기준으로 주기적으로 DB에서 계산
- 정렬된 인기글 ID 목록을 Redis List에 캐싱 (예:
article:top:list
) - 이후 API는 Redis List에 저장된 ID 목록을 기준으로 정렬된 게시글 정보를 반환
// ArticleRankingService - 스케줄러에서 캐싱
@Transactional(readOnly = true)
public void updateRedisTopArticles() {
// DB에서 조회수 상위 5개 게시글 조회
List<Article> topArticles = articleRepository.findTopByViews(
PageRequest.of(RedisConstants.START_INDEX, RedisConstants.TOP_LIMIT));
String key = RedisKey.getTopArticleListKey();
// 기존 캐시 삭제 후 새로운 인기글 ID 목록을 Redis List에 저장
redisTemplate.delete(key);
for (Article article : topArticles) {
redisTemplate.opsForList().rightPush(key, article.getIdAsString());
}
}
// API 조회 로직 - 핵심은 Redis List에서 ID만 조회
@Transactional(readOnly = true)
public List<ArticleListResponse> getTopRankedArticles() {
// Redis List에서 캐시된 인기글 ID 목록 조회 (1회 Redis 호출)
List<Long> topIds = getTopArticleIds();
// ... DB에서 게시글 정보 조회 및 응답 구성
}
인기글 정렬 정보를 Redis에 주기적으로 저장함으로써, API 요청 시 Redis에서 ID 목록만 조회한 후, 최소한의 DB 조회로 응답을 구성하게 되었다.
배치 업데이트로 DB 성능 개선

기존에는 조회수 동기화 시 각 게시글마다 개별 UPDATE 쿼리를 실행하는 방식이었다.
// 기존 방식 - 개별 쿼리 실행
redisViewCounts.forEach(articleRepository::bulkAddViewCount);
문제점
- 게시글 수만큼 DB 커넥션과 쿼리 실행이 반복된다
- 트랜잭션 오버헤드가 누적되어 전체 처리 시간 증가한다.
- DB 부하 증가한다.
해결 방법: 배치 로직을 별도로 분리하여 진짜 배치 업데이트를 구현했다.
@Component
@RequiredArgsConstructor
public class ArticleViewBatchUpdater {
private static final int BATCH_SIZE = 500;
private final NamedParameterJdbcTemplate namedJdbcTemplate;
public void batchUpdate(List<ViewCountUpdateDto> updateList) {
if (updateList.isEmpty()) return;
String sql = """
UPDATE article
SET view_count = view_count + :viewCount
WHERE id = :articleId
""";
List<MapSqlParameterSource> parameterSources = new ArrayList<>();
for (ViewCountUpdateDto dto : updateList) {
MapSqlParameterSource source = new MapSqlParameterSource()
.addValue("viewCount", dto.getViewCount())
.addValue("articleId", dto.getArticleId());
parameterSources.add(source);
}
// 생략.. 배치 사이즈 단위로 나누어 처리
}
}
개선 효과
개별 쿼리 실행 → 배치 쿼리 실행으로 DB 호출 횟수 감소했다.
배치 사이즈(500) 단위로 처리하여 DB 부하를 메모리 사용량 제어 (DB 부하 방지)
- 1000건 처리 시:
1000번 -> 2번 요청으로 감소
배치 사이즈로 나누어 처리.
- 1000건 처리 시:
결과

전체적인 조회수 + 인기글 Top5
스케줄러라고 생각하면 될것 같다.
Redis 서버의 평균 CPU 사용률이 17% 감소

기존에는 조회수 동기화 스케줄러가 실행될 때마다 Redis에서 전체 키를 탐색하는 작업으로 인해, 스케줄러 실행 시점에 Redis CPU 사용률이 40-50%까지 급증
하는 현상이 발생하고는 했다.
구조 개선 이후에는 전체 키를 탐색하는 방식 대신, 동기화 대상 게시글 ID만을 조회하는 방식으로 변경되었고, 그 결과 불필요한 O(N) 연산이 제거되면서 스케줄러 실행 중에도 Redis CPU 사용률이 25% 이하
로 개선되었다.
조회수 동기화 처리 속도 70% 단축

기존 구조에서는 조회수 동기화를 위해 Redis의 모든 조회수 키를 KEYS 명령어로 탐색하고, 각 키의 값을 조회한 뒤 데이터를 정제해 데이터베이스에 반영했다.
이 방식은 대상이 많을수록 키 탐색 → 값 조회 → 가공 → DB 저장
이라는 복잡한 작업이 계속해서 수행되어 전체 처리 시간이 증가하는 구조였다. 실제로 동기화 대상이 500개일 때 약 2.8초, 1000개일 때 약 4.5초가 소요되었다.
하지만 개선된 구조
에서는 조회수가 증가한 게시글 ID만 Set에 저장하여 O(N) 키 스캔을 O(1) Set 조회로 변경 배치 업데이트를 통해 개별 쿼리 실행을 배치 처리로 변경
그 결과, 동기화 대상 500개 기준 약 0.8초
, 1000개 기준 약 1.2초
로 처리 시간이 약 70% 단축
되었다.