
많은 웹 서비스에서 '조회수'는 콘텐츠의 인기도를 나타내는 가장 기본적인 지표입니다. 가장 직관적인 구현 방법은 사용자가 게시물을 조회할 때마다 해당 게시물의 view_count 컬럼을 1씩 증가시키는 UPDATE 쿼리를 실행하는 것입니다.
UPDATE TABLE_A SET VIEW_COUNT = VIEW_COUNT + 1 WHERE ID = A;
이 방식은 구현이 매우 간단하지만, 서비스가 성장하고 트래픽이 증가함에 따라 심각한 성능 병목의 주범이 됩니다.
이 문제를 해결하기 위해, 우리는 In-Memory 데이터 저장소인 Redis를 도입하여 RDBMS의 부하를 덜어주는 하이브리드 아키텍처를 설계했습니다.
Redis는 Key-Value 기반의 고성능 데이터 저장소로, 모든 데이터를 디스크가 아닌 메모리에 저장합니다. 이 특징은 '조회수 집계'와 같은 시나리오에서 다음과 같은 결정적인 장점을 제공합니다.
Redis의 '속도'와 RDBMS의 '안정성'을 모두 보장하기 위해 아래와 같이 설계하였습니다.
실시간 조회수 기록 (Redis): 사용자가 뉴스를 조회하면, API 서버는 즉시 Redis의 INCR 명령어를 호출하여 news:view:{뉴스ID} 형태의 키 값을 1 증가시킵니다. 이 과정은 DB를 전혀 거치지 않으므로 매우 빠릅니다.
주기적인 DB 동기화 (Scheduler): Spring Scheduler를 이용한 배치 작업이 주기적으로(예: 5분마다) 실행됩니다.
최종 결과 업데이트 (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);
}
// 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 동기화 완료");
}
}
// 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와 스케줄러를 활용한 비동기 아키텍처를 도입함으로써, 대규모 트래픽 상황에서도 안정적으로 조회수를 집계하고 사용자에게 빠른 응답을 제공할 수 있는 견고한 기반을 마련했습니다.
이 아키텍처는 조회수뿐만 아니라, '좋아요 수', '실시간 랭킹', '재고 관리' 등 빈번한 쓰기 작업이 발생하는 다양한 시나리오에 효과적으로 적용될 수 있는 매우 유연하고 강력한 패턴입니다.