Redis를 활용한 조회수 집계 시스템 구축

dev.hyjang·2025년 9월 8일
post-thumbnail

많은 웹 서비스에서 '조회수'는 콘텐츠의 인기도를 나타내는 가장 기본적인 지표입니다. 가장 직관적인 구현 방법은 사용자가 게시물을 조회할 때마다 해당 게시물의 view_count 컬럼을 1씩 증가시키는 UPDATE 쿼리를 실행하는 것입니다.

	UPDATE TABLE_A SET VIEW_COUNT = VIEW_COUNT + 1 WHERE ID = A;

이 방식은 구현이 매우 간단하지만, 서비스가 성장하고 트래픽이 증가함에 따라 심각한 성능 병목의 주범이 됩니다.

  • 과도한 DB 쓰기 부하: 모든 조회 요청이 데이터베이스에 직접적인 쓰기(Write) 작업을 발생시킵니다. 인기 있는 게시물 하나에 트래픽이 집중되면, 해당 테이블의 특정 행(Row)에 엄청난 양의 UPDATE 쿼리가 몰리게 됩니다.
  • I/O 병목과 Lock 경쟁: 디스크 기반의 RDBMS에서 쓰기 작업은 비용이 높은 I/O를 동반합니다. 또한, 빈번한 UPDATE는 테이블 또는 행 수준의 락(Lock)을 유발하여, 다른 중요한 트랜잭션(예: 글 작성, 결제)의 성능까지 저하시킬 수 있습니다.
  • 응답 시간 저하: DB의 부하가 증가하면, 사용자가 게시물을 조회하는 API의 응답 시간 또한 길어져 전반적인 사용자 경험(UX)을 해치게 됩니다.

Redis + RDBMS 하이브리드 아키텍처

이 문제를 해결하기 위해, 우리는 In-Memory 데이터 저장소인 Redis를 도입하여 RDBMS의 부하를 덜어주는 하이브리드 아키텍처를 설계했습니다.

Redis는 Key-Value 기반의 고성능 데이터 저장소로, 모든 데이터를 디스크가 아닌 메모리에 저장합니다. 이 특징은 '조회수 집계'와 같은 시나리오에서 다음과 같은 결정적인 장점을 제공합니다.

  • 원자적 연산(Atomic Operation)과 엄청난 속도: Redis는 특정 키의 숫자 값을 1씩 증가시키는 INCR 명령어를 제공합니다. 이 명령어는 원자성(Atomicity)이 보장되므로, 여러 클라이언트가 동시에 요청해도 데이터의 정합성이 깨지지 않습니다. 또한, 모든 연산이 메모리 상에서 이루어지므로 RDBMS의 UPDATE와는 비교할 수 없는 속도를 자랑합니다.
  • DB 부하 분산: 빈번하게 발생하는 모든 조회수 증가 요청을 Redis가 먼저 처리하도록 하여, RDBMS는 더 이상 사소한 UPDATE 작업에 시달리지 않고 데이터의 영구 저장 및 복잡한 조회라는 본연의 역할에 집중할 수 있습니다.

실시간 처리와 비동기 동기화

Redis의 '속도'와 RDBMS의 '안정성'을 모두 보장하기 위해 아래와 같이 설계하였습니다.

  1. 실시간 조회수 기록 (Redis): 사용자가 뉴스를 조회하면, API 서버는 즉시 Redis의 INCR 명령어를 호출하여 news:view:{뉴스ID} 형태의 키 값을 1 증가시킵니다. 이 과정은 DB를 전혀 거치지 않으므로 매우 빠릅니다.

  2. 주기적인 DB 동기화 (Scheduler): Spring Scheduler를 이용한 배치 작업이 주기적으로(예: 5분마다) 실행됩니다.

  3. 최종 결과 업데이트 (RDBMS): 스케줄러는 Redis에 기록된 모든 뉴스의 최종 조회수 값을 가져와, RDBMS(PostgreSQL)의 view_count 컬럼에 한 번에 업데이트합니다.

실시간 처리는 Redis가 전담하고, 데이터의 영구 저장은 스케줄러를 통한 비동기 방식으로 처리함으로써, 시스템의 성능과 안정성을 모두 확보할 수 있습니다.


구현

@PostMapping("/news/{newsId}/view")
public ResponseEntity<Void> incrementViewCount(@PathVariable Long newsId) {
    newsService.incrementViewCount(newsId);
    return ResponseEntity.ok().build();
}
@Override
public void incrementViewCount(Long newsId) {
    String viewKey = "news:view:" + newsId;
    redisTemplate.opsForValue().increment(viewKey);
}

DB 동기화 스케줄러 구현

// ViewCountSyncScheduler.java

@Slf4j
@Component
@RequiredArgsConstructor
public class ViewCountSyncScheduler {

    private final RedisTemplate<String, String> redisTemplate;
    private final NewsService newsService;

    // 5분마다 실행
    @Scheduled(cron = "0 */5 * * * *")
    public void syncViewCountsToDb() {
        log.info("Redis '조회수' DB 동기화 시작");
        // 1. 'news:view:' 패턴의 모든 키를 조회
        Set<String> viewKeys = redisTemplate.keys("news:view:*");

        if (viewKeys == null || viewKeys.isEmpty()) {
            return;
        }

        for (String key : viewKeys) {
            try {
                // 2. 키에서 newsId 파싱
                Long newsId = Long.parseLong(key.split(":")[2]);
                // 3. Redis에서 최종 조회수 GET
                String viewCountStr = redisTemplate.opsForValue().get(key);

                if (viewCountStr != null) {
                    int viewCount = Integer.parseInt(viewCountStr);
                    // 4. 서비스 계층을 통해 DB에 업데이트
                    newsService.updateViewCountInDb(newsId, viewCount);
                }
            } catch (Exception e) {
                log.error("키 '{}' 처리 중 오류 발생: {}", key, e.getMessage());
            }
        }
        log.info("Redis '조회수' DB 동기화 완료");
    }
}

데이터 조회 로직

  • DB에 저장된 값이 기본 + Redis에 실시간 값이 존재하면 그 값으로 덮어쓰는 방식으로 구현하였습니다.
// NewsServiceImpl.java - matchEsResultsWithRedisData() 메소드 내부

private void matchEsResultsWithRedisData(List<NewsResponseDto> newsList, String userId) {
    for (NewsResponseDto news : newsList) {
        // ... (기존 좋아요 로직)

        // 실시간 조회수 보강
        String viewKey = "news:view:" + news.getNewsId();
        String viewCountStr = redisTemplate.opsForValue().get(viewKey);
        
        // Redis에 실시간 조회수 값이 있으면, 그 값으로 DTO의 viewCount를 덮어쓴다.
        if (viewCountStr != null) {
            news.setViewCount(Integer.parseInt(viewCountStr));
        } 
        // Redis에 값이 없으면, DB에서 조회해 온 기존 viewCount 값이 그대로 유지된다.
    }
}

결론

단순한 UPDATE 쿼리 방식에서 벗어나 Redis와 스케줄러를 활용한 비동기 아키텍처를 도입함으로써, 대규모 트래픽 상황에서도 안정적으로 조회수를 집계하고 사용자에게 빠른 응답을 제공할 수 있는 견고한 기반을 마련했습니다.
이 아키텍처는 조회수뿐만 아니라, '좋아요 수', '실시간 랭킹', '재고 관리' 등 빈번한 쓰기 작업이 발생하는 다양한 시나리오에 효과적으로 적용될 수 있는 매우 유연하고 강력한 패턴입니다.

profile
낭만감자

0개의 댓글