TL;DR
Kafka 기반 이벤트 파이프라인 위에 Redis ZSET을 활용한 실시간 랭킹 시스템을 구축했습니다. 단순한 집계 테이블에서 벗어나 실시간 가중치 조절, 콜드 스타트 문제 해결, 그리고 배치 처리를 통한 성능 최적화까지 - 운영 환경에서 실제로 마주할 수 있는 문제들을 어떻게 해결했는지 공유합니다.
문제 정의: 단순한 집계에서 실시간 랭킹으로
지난 주차에서 Kafka 기반 이벤트 파이프라인을 구축하면서 product_metrics 테이블에 상품별 집계 데이터를 쌓고 있었습니다:
-- 기존: 단순한 집계 테이블
SELECT product_id, view_count, like_count, sales_count
FROM product_metrics
WHERE metric_date = CURRENT_DATE
ORDER BY like_count DESC;
하지만 새로운 요구사항이 들어왔습니다:
- 실시간 랭킹: 좋아요가 눌리는 순간 랭킹에 반영
- 가중치 조절: 시간대별, 이벤트별로 점수 비중 변경 가능
- 순위 조회: 특정 상품의 현재 순위 확인
- 콜드 스타트 완화: 매일 0점에서 시작하는 문제 해결
해결책: Redis ZSET으로 실시간 랭킹 파이프라인
Redis ZSET(Sorted Set)의 특성을 활용하면 이 문제들을 우아하게 해결할 수 있습니다:
- O(log N) 시간복잡도: 점수 업데이트와 순위 조회가 빠름
- 자동 정렬: 점수에 따라 자동으로 순위 유지
- 원자적 연산: 동시성 문제 없이 점수 증감 가능
1단계: 랭킹 시스템 아키텍처 설계
Kafka Event → Consumer → Score Calculator → Redis ZSET → Ranking API
↓ ↓ ↓ ↓ ↓
좋아요 이벤트 배치 처리 가중치 적용 실시간 저장 순위 조회
핵심 설계 원칙:
- Key 전략: ranking:all:{yyyyMMdd} - 날짜별 분리
- TTL 관리: 2일로 설정하여 메모리 효율성 확보
- 가중치 시스템: 실시간으로 조절 가능한 점수 계산
2단계: 가중치 기반 점수 계산 시스템
"진짜 인기 있는 상품"을 정의하는 것이 가장 어려운 부분이었습니다.
@Component
public class ScoreCalculator {
private final WeightConfigService weightConfigService;
public double calculateViewScore() {
return weightConfigService.getCurrentWeights().getViewWeight(); // 기본: 0.1
}
public double calculateLikeScore(boolean isLikeEvent) {
double score = weightConfigService.getCurrentWeights().getLikeWeight(); // 기본: 0.2
return isLikeEvent ? score : -score; // 좋아요 취소 시 점수 차감
}
public double calculateOrderScore() {
return weightConfigService.getCurrentWeights().getOrderWeight(); // 기본: 0.6
}
}
가중치 설정의 고민:
- 조회 (0.1점): 많은 양이지만 가벼운 액션
- 좋아요 (0.2점): 의도적인 관심 표현
- 주문 (0.6점): 실제 구매 의도가 있는 강한 신호
처음에는 고정값으로 시작했지만, 운영하면서 실시간 조절의 필요성을 느꼈습니다.
3단계: 배치 처리로 성능 최적화
단건 처리는 너무 많은 Redis 연산을 발생시킵니다:
// ❌ 비효율적인 단건 처리
@KafkaListener(topics = "product-like-events")
public void handleSingle(ProductLikeEvent event) {
// 메시지 하나당 Redis 연산 한 번 = 높은 오버헤드
rankingService.updateScore(event.getProductId(), calculateScore(event));
}
배치 리스너로 처리량을 대폭 개선했습니다:
@Component
public class ProductLikeEventConsumer extends BatchConsumerTemplate<ProductLikeEventDto> {
@KafkaListener(
topics = "${app.kafka.topics.product-like-events:product-like-events}",
containerFactory = KafkaConfig.BATCH_LISTENER,
groupId = "${app.kafka.consumer-groups.product-like-collector:product-like-collector}"
)
public void onMessage(@Payload List<ProductLikeEventDto> events, /* ... */) {
handleBatch(topic, events, keys, partitions, offsets, ack);
}
@Override
protected void process(ProductLikeEventDto dto) {
// 배치 단위로 처리하여 Redis 연산 최적화
processor.processEvent(dto.toCommand());
}
}
배치 처리의 장점:
- 네트워크 오버헤드 감소: 여러 메시지를 한 번에 처리
- 트랜잭션 효율성: DB와 Redis 연산을 묶어서 처리
- 에러 핸들링: 배치 단위로 실패 시 재처리
4단계: Redis ZSET 연산 구현
Redis ZSET의 핵심 연산들을 래핑했습니다:
@Component
public class RankingCacheImpl implements RankingCache {
private final RedisZSetOperations redisZSetOperations;
private final RankingKeyGenerator keyGenerator;
private static final Duration TTL = Duration.ofDays(2);
@Override
public void incrementScore(Long productId, double score, LocalDate date) {
String key = keyGenerator.generateDailyRankingKey(date); // "ranking:all:20250911"
// ZINCRBY 명령어로 점수 증가 (원자적 연산)
redisZSetOperations.incrementScore(key, productId.toString(), score);
redisZSetOperations.expire(key, TTL);
}
}
ZSET 연산의 장점:
- ZINCRBY: 점수 증감이 원자적으로 처리됨
- ZREVRANGE: 순위별 상품 목록을 빠르게 조회
- ZRANK: 특정 상품의 순위를 O(log N)으로 조회
5단계: 실시간 가중치 조절 시스템
운영 중에 가중치를 조절해야 하는 상황이 생겼습니다:
- 이벤트 시즌: 주문 가중치를 일시적으로 높이기
- 브랜드 데이: 좋아요 가중치를 강화하기
- 조정 필요: 특정 지표가 너무 지배적일 때
이벤트 기반 가중치 동기화:
// API 모듈에서 가중치 변경 요청
@PutMapping("/admin/ranking/weights")
public ApiResponse<Object> updateWeights(@RequestBody WeightUpdateRequest request) {
weightFacade.updateWeights(request.toCommand());
// Kafka로 이벤트 발행하여 Collector 모듈에 전파
weightConfigEventPublisher.publish(weightChangedEvent);
return ApiResponse.success();
}
// Collector 모듈에서 가중치 변경 이벤트 수신
@Component
public class WeightConfigEventConsumer extends BatchConsumerTemplate<WeightConfigEventDto> {
@Override
protected void process(WeightConfigEventDto dto) {
// 새로운 가중치로 업데이트
processor.processEvent(dto.toCommand());
}
}
이벤트 기반 접근의 장점:
- 모듈 간 독립성: API와 Collector가 직접 연결되지 않음
- 신뢰성: Kafka의 At-Least-Once 보장 활용
- 확장성: 새로운 Collector 추가 시에도 자동으로 동기화
콜드 스타트 문제 해결: Score Carry-Over
매일 자정에 모든 상품이 0점에서 시작하는 것은 사용자 경험상 문제였습니다.
문제 상황:
- 어제까지 1위였던 상품이 오늘 아침에 순위권에서 사라짐
- 새로운 하루가 시작될 때마다 랭킹이 리셋되어 연속성 부족
해결책: 스케줄러를 통한 점수 이월
@Scheduled(cron = "0 50 23 * * *") // 콜드 스타트 완화를 위한 점수 Carry-Over 23시 50분 내일 랭킹판을 미리 생성
@ConditionalOnProperty(name = "scheduling.tasks.ranking-carry-over.enabled", havingValue = "true", matchIfMissing = true)
public void carryOverRankingScores() {
log.info("랭킹 점수 Carry-Over 스케줄러 시작");
distributedLock.executeWithLock(
RANKING_CARRY_OVER_LOCK_KEY,
LOCK_TIMEOUT,
this::doCarryOverScores
);
log.info("랭킹 점수 Carry-Over 스케줄러 완료");
}
private void doCarryOverScores() {
// 오늘 점수를 내일로 감쇠 적용하여 복사 (콜드 스타트 방지)
rankingService.carryOverTodayScoresToTomorrow(decayFactor);
}
Carry-Over 설계 원칙:
- 감쇠 계수 (0.3): 과거 점수가 너무 지배적이지 않도록 조절
- 분산 락: 여러 인스턴스에서 중복 실행 방지
- 점진적 페이드아웃: 며칠 지나면 자연스럽게 새로운 인기도 반영
랭킹 API: 성능과 사용성의 균형
페이지네이션과 상품 정보 조합
단순히 상품 ID만 반환하는 것이 아니라, 사용자가 바로 사용할 수 있는 형태로 데이터를 제공해야 했습니다:
@Service
public class RankingFacade {
public Page<RankingInfo> getRankings(LocalDate date, Pageable pageable) {
// 1. Redis에서 랭킹 데이터 조회
List<RankingItem> rankingItems = rankingService.getRankings(
date, pageable.getOffset(), pageable.getPageSize()
);
// 2. 상품 정보 배치 조회 (N+1 문제 방지)
List<Long> productIds = rankingItems.stream()
.map(RankingItem::getProductId)
.toList();
List<Product> products = productService.getProductsByIds(productIds);
// 3. 랭킹 + 상품 정보 조합
Map<Long, Product> productMap = products.stream()
.collect(Collectors.toMap(Product::getId, p -> p));
List<RankingInfo> rankingInfos = rankingItems.stream()
.map(item -> {
Product product = productMap.get(item.getProductId());
return new RankingInfo(
item.getRank(),
item.getProductId(),
product != null ? product.getName() : "상품명 없음",
product != null ? product.getImageUrl() : null,
product != null ? product.getBasicPrice() : null,
item.getScore()
);
})
.toList();
return new PageImpl<>(rankingInfos, pageable, totalCount);
}
}
상품 상세에서 랭킹 정보 포함
기존 상품 상세 API에 랭킹 정보를 자연스럽게 추가했습니다:
public class ProductInfo {
// 기존 필드들...
private final Integer currentRank; // 현재 순위 (순위권 밖이면 null)
}
API 응답 예시:
{
"productId": 1,
"name": "인기 상품",
"price": 29900,
"currentRank": 3
}
현실적인 고민들과 해결 과정
1. "Redis가 날아가면 어떡하지?"
가장 무서웠던 시나리오입니다. Redis는 메모리 기반이라 데이터 유실 위험이 있습니다.
현재 상황:
- Redis 장애 시 랭킹 데이터 완전 유실
- 별도 백업 메커니즘 미구현
- 장애 발생 시 랭킹 서비스 전면 중단
고민 중인 대응 방안:
// TODO: 향후 구현 예정
@Component
public class RankingBackupService {
@Scheduled(fixedRate = 300000) // 5분마다 혹은 23시 55분
public void backupCurrentRanking() {
// Redis → DB 백업 로직 구현 필요
}
}
앞으로 개선해야 할 부분:
- 정기적 DB 백업 메커니즘
- Redis 장애 감지 및 알람
- DB 기반 Fallback 서비스
- 장애 복구 후 데이터 재구축 프로세스
현재는 Redis 가용성에 전적으로 의존하고 있어, 이 부분이 가장 큰 리스크로 남아있습니다.
2. "정말 이 가중치가 맞을까?"
가중치 설정은 비즈니스 도메인에 따라 달라지는 매우 주관적인 영역입니다.
데이터 기반 접근:
@Component
public class WeightOptimizer {
public void analyzeUserEngagement(WeightConfig config) {
// 특정 가중치 설정으로 일주일 운영 후
// 사용자 체류시간, 클릭률, 구매전환율 분석
EngagementMetrics metrics = analyticsService.getMetrics(config);
log.info("가중치 {} 적용 결과: 체류시간={}s, 클릭률={}%, 전환율={}%",
config, metrics.avgStayTime, metrics.clickRate, metrics.conversionRate);
}
}
실험을 통한 최적화:
- 사용자 행동 분석: 어떤 랭킹이 더 높은 참여도를 보이는지 측정
- 점진적 개선: 급격한 변경보다는 단계적 조정
3. "동시성 문제는 없을까?"
같은 상품에 대해 여러 이벤트가 동시에 발생하는 상황:
시간: 10:00:01.001 - 좋아요 이벤트 (+0.2)
시간: 10:00:01.002 - 조회 이벤트 (+0.1)
시간: 10:00:01.003 - 주문 이벤트 (+0.6)
Redis의 원자적 연산 활용:
// Redis ZINCRBY는 원자적으로 실행됨
public void incrementScore(Long productId, double score, LocalDate date) {
String key = keyGenerator.generateDailyRankingKey(date);
// 이 연산은 원자적이므로 동시성 문제 없음
redisZSetOperations.incrementScore(key, productId.toString(), score);
}
Redis의 싱글 스레드 특성 덕분에 별도의 락 없이도 안전합니다.
구현하면서 고민했던 잠재적 문제들
1. "Long Tail 현상이 발생할 수 있지 않을까?"
구현하면서 가장 우려했던 부분은 시간이 지날수록 상위권이 고착화되는 문제였습니다.
예상되는 문제:
- 인기 상품은 더 많이 노출되어 더 많은 이벤트 발생
- 신규 상품이나 틈새 상품은 기회 자체가 적어질 가능성
- 랭킹 상위권의 순환이 어려워짐
대비책으로 구현한 부분:
- Carry-Over 감쇠 적용: 전날 점수의 30%만 이월하여 고착화 완화
향후 개선 아이디어:
- 신규 상품 부스팅 정책
- 카테고리별 랭킹 분리
- 더 강한 감쇠 정책 적용
2. "시간대별 편향 문제"
구현 과정에서 떠올린 또 다른 우려사항은 특정 시간대 활동 집중으로 인한 편향이었습니다.
예상 시나리오:
- 저녁 시간대(19:00-23:00)에 활동이 집중
- 해당 시간에 노출된 상품들이 상대적으로 유리
- 새벽이나 오전 시간대 상품들의 기회 부족
현재 구현에서는 미해결: 현재는 모든 시간대의 이벤트를 동일한 가중치로 처리하고 있어, 실제 서비스 시에는 이 부분에 대한 보완이 필요할 것 같습니다.
개선 방안 아이디어:
// 시간대별 가중치 적용 (미구현)
public double calculateTimeBasedWeight(LocalTime eventTime) {
// 활동이 적은 시간대의 이벤트에 더 높은 가중치
if (eventTime.isAfter(LocalTime.of(2, 0)) &&
eventTime.isBefore(LocalTime.of(6, 0))) {
return 1.5; // 새벽 시간대 부스팅
}
return 1.0;
}
배운 것들과 앞으로의 개선 방향
기술적으로 성장한 부분
1. Redis ZSET 기본 활용법 습득
- ZINCRBY, ZREVRANGE 등 기본 명령어 사용법
- 키 설계와 TTL 관리 방법
- 점수 기반 자동 정렬의 편리함 체험
2. 이벤트 기반 아키텍처 확장
- 기존 Kafka 파이프라인에 새로운 기능 추가하는 방법
- 모듈 간 이벤트 통신으로 실시간 설정 동기화
- 배치 처리와 실시간 처리를 함께 사용하는 경험
3. 스케줄러를 활용한 문제 해결
- 콜드 스타트 같은 비즈니스 문제를 기술적으로 접근
- 분산 락을 사용한 중복 실행 방지
- 시간 기반 데이터 이월 로직 구현
앞으로 개선하고 싶은 부분
1. 더 정교한 점수 계산
// 현재: 단순 가중치 합산
score = viewWeight * viewCount + likeWeight * likeCount + orderWeight * orderCount;
// 개선 방향: 시간 감쇠나 상품 카테고리 등을 고려
score = calculateComplexScore(events, categoryBonus, timeDecay);
2. 더 안정적인 장애 대응
- Redis 장애 시 백업/복구 메커니즘
- 서비스 중단 없는 Graceful Degradation
3. 성능 모니터링 강화
- Redis 메모리 사용량 추적
- 랭킹 업데이트 지연시간 모니터링
- 배치 처리 성능 지표 수집
마무리: 완벽하지 않지만 현실적인 해결책
이번 랭킹 시스템 구축을 통해 "기술적 완벽함"보다는 "비즈니스 문제 해결"에 집중하는 것이 얼마나 중요한지 깨달았습니다.
달성한 목표들:
- 실시간 랭킹 업데이트 구현
- 관리자 가중치 조절 기능
- 콜드 스타트 문제 완화
- 배치 처리를 통한 처리량 개선
아직 부족한 부분들:
- 개인화된 랭킹 부재
- 더 정교한 점수 계산 알고리즘 필요
- 카테고리별, 브랜드별 세분화 랭킹
- 실시간 이상 감지 및 알람
하지만 가장 중요한 건 **"왜 이렇게 구현했는가"**에 대한 명확한 답을 갖는 것이었습니다:
- 왜 Redis ZSET을 선택했는가? → O(log N) 성능과 자동 정렬 기능
- 왜 배치 처리를 도입했는가? → 네트워크 오버헤드 최소화와 처리량 향상
- 왜 이벤트 기반으로 가중치를 동기화하는가? → 모듈 간 독립성과 확장성
- 왜 콜드 스타트 문제를 해결해야 하는가? → 사용자 경험의 연속성
코드로 보는 핵심 구현
마지막으로 이번 프로젝트의 핵심이 되는 코드들을 정리해보겠습니다:
랭킹 점수 업데이트 (핵심 로직)
@Service
public class RankingService {
private final RankingCache rankingCache;
private final ScoreCalculator scoreCalculator;
public void updateProductLikeScore(Long productId, boolean isLikeEvent, LocalDate date) {
// 실시간 가중치를 반영한 점수 계산
double score = scoreCalculator.calculateLikeScore(isLikeEvent);
// Redis ZSET에 원자적으로 점수 업데이트
rankingCache.incrementScore(productId, score, date);
log.info("상품 좋아요 랭킹 점수 업데이트: productId={}, isLike={}, score={}, date={}",
productId, isLikeEvent, score, date);
}
}
Redis ZSET 연산 (성능 핵심)
@Component
public class RankingCacheImpl implements RankingCache {
private static final Duration TTL = Duration.ofDays(2);
@Override
public void incrementScore(Long productId, double score, LocalDate date) {
String key = keyGenerator.generateDailyRankingKey(date); // "ranking:all:20250911"
// ZINCRBY: O(log N) 시간복잡도로 점수 업데이트 + 자동 정렬
redisZSetOperations.incrementScore(key, productId.toString(), score);
redisZSetOperations.expire(key, TTL);
}
@Override
public void carryOverScores(LocalDate fromDate, LocalDate toDate, double decayFactor) {
String fromKey = keyGenerator.generateDailyRankingKey(fromDate);
String toKey = keyGenerator.generateDailyRankingKey(toDate);
// 전체 랭킹 데이터를 감쇠 적용하여 다음 날로 이월
Set<ZSetOperations.TypedTuple<String>> previousScores =
redisZSetOperations.reverseRangeWithScores(fromKey, 0, -1);
previousScores.forEach(tuple -> {
String productIdStr = tuple.getValue();
Double score = tuple.getScore();
if (productIdStr != null && score != null) {
double newScore = score * decayFactor; // 30% 감쇠
redisZSetOperations.add(toKey, productIdStr, newScore);
}
});
}
}
배치 컨슈머 (처리량 최적화)
@Component
public class ProductLikeEventConsumer extends BatchConsumerTemplate<ProductLikeEventDto> {
// 상속받은 템플릿이 멱등성 처리, 에러 핸들링, DLT 등을 자동으로 처리
@Override
protected void process(ProductLikeEventDto dto) {
processor.processEvent(dto.toCommand());
}
@KafkaListener(
topics = "${app.kafka.topics.product-like-events:product-like-events}",
containerFactory = KafkaConfig.BATCH_LISTENER, // 배치 처리 설정
groupId = "${app.kafka.consumer-groups.product-like-collector:product-like-collector}"
)
public void onMessage(@Payload List<ProductLikeEventDto> events, /* ... */) {
// 한 번에 여러 메시지를 배치로 처리하여 처리량 향상
handleBatch(topic, events, keys, partitions, offsets, ack);
}
}
가중치 실시간 조절 (운영 편의성)
// API 모듈: 관리자가 가중치 변경
@PutMapping("/admin/ranking/weights")
public ApiResponse<Object> updateWeights(@RequestBody WeightUpdateRequest request) {
weightFacade.updateWeights(request.toCommand());
return ApiResponse.success();
}
// Collector 모듈: 변경된 가중치를 이벤트로 수신
@Component
public class WeightConfigEventConsumer extends BatchConsumerTemplate<WeightConfigEventDto> {
@Override
protected void process(WeightConfigEventDto dto) {
// 새로운 가중치로 실시간 업데이트
processor.processEvent(dto.toCommand());
}
}
마무리: 아직 갈 길이 먼 랭킹 시스템
이번 랭킹 시스템 구축을 통해 Redis ZSET의 기본적인 활용법과 이벤트 기반 아키텍처 확장 경험을 쌓을 수 있었습니다.
하지만 여전히 부족한 부분들이 많습니다:
1. 개인화 부재
- 모든 사용자에게 동일한 랭킹 제공
- 사용자별 취향이나 구매 이력을 반영하지 못함
- 진정한 "맞춤형 추천"과는 거리가 멀음
2. 시간 단위의 한계
- 하루 단위 랭킹만 구현
- 시간별, 실시간 트렌드 반영 부족
- 급상승 상품이나 순간적 인기도 포착 어려움
3. 단순한 점수 계산
- 기본적인 가중치 합산에 그침
- 시간 가중치, 사용자 신뢰도, 상품 카테고리 등 고려 부족
- 더 정교한 랭킹 알고리즘 필요
앞으로의 학습 목표:
개인화된 추천 시스템, 실시간 스트리밍 처리, 그리고 더 정교한 점수 계산 알고리즘까지 - 아직 배워야 할 것들이 산더미입니다.
이번 프로젝트는 시작점일 뿐이고, 앞으로 더 깊이 있는 학습과 경험을 통해 사용자에게 정말 의미 있는 랭킹 시스템을 만들 수 있는 개발자로 성장하고 싶습니다.
'Spring' 카테고리의 다른 글
| Spring Batch로 대용량 데이터 처리하기: 학습 정리 (0) | 2025.10.19 |
|---|---|
| Kafka와 아웃박스 패턴으로 이벤트 파이프라인 구축하기 (0) | 2025.09.05 |
| ApplicationEvent로 비즈니스 경계 나누기: 고민과 선택 (3) | 2025.08.28 |
| 결제 시스템에 Circuit Breaker를 도입하며 – 계층별 책임 분리와 안정성 확보 전략 (1) | 2025.08.22 |
| DB 인덱스 설계와 Redis 캐싱(Port-Adapter) 적용기 (2) | 2025.08.15 |