유랑하는 나그네의 갱생 기록

だけど素敵な明日を願っている -HANABI, Mr.children-

Devlog/SpringBoot

double 하나에 4차원을 우겨넣기 - Redis ZSET 실시간 랭킹의 스코어 인코딩

Madirony 2026. 4. 10. 01:03
반응형

tl;dr - Redis ZSET의 score는 double 하나다. 여기에 메인 점수(주문+조회+좋아요), 전일 계승, 그리고 동점 해소를 위한 3축 타이브레이커까지 - 4개 차원을 정수부와 소수부로 나눠 positional encoding했다. log1p로 주문 금액 100배 차이를 1.25배로 압축하고, z-score와 sigmoid로 이질적인 축을 0~999로 정규화했다. DB를 SSOT로 삼아 멱등성과 영속성을 확보하되, 읽기 경로는 Redis만 사용해 500 VU에서 p95 < 80ms를 달성했다.

 

 

랭킹 페이지


1. 1위가 50개면 랭킹이 아니다

예전에 Kafka 이벤트 파이프라인을 구축했었습니다. 유저가 상품을 조회하거나 좋아요를 누르거나 주문하면 이벤트가 발행되고, commerce-streamer가 소비해서 product_metrics 테이블에 누적하는 형태였습니다.

 

문제는 이 데이터가 전체 기간 누적이라는 겁니다. "오늘 인기 상품"을 알 방법이 없습니다. 클라이언트는 상품 목록을 정렬 없이 받고 있었습니다.

 

가장 심플한 접근은 이렇습니다. views × 0.1 + likes × 0.2 + orders × 0.6을 계산해서 Redis ZSET에 넣으면 됩니다.

ZADD ranking:all:20260409 5.53 "product:101"
ZADD ranking:all:20260409 5.53 "product:202"
ZADD ranking:all:20260409 5.53 "product:303"

 

여기서 3가지 문제가 발생합니다.

첫째, 동점 상품이 너무 많습니다. 10,000원짜리 상품이 하루에 1건 팔리면 점수가 5.53입니다. 같은 가격대 상품이 10개 있으면 1위가 10개입니다. ZSET은 score가 같으면 member를 사전순으로 정렬하니까, "101" < "202" < "303" 순서가 됩니다. productId 크기가 곧 순위가 되는 건 의미 없는 결과입니다.

 

둘째, 고가 상품이 독식합니다. 주문 금액을 그대로 반영하면 100,000원 상품 1건이 1,000원 상품 100건보다 높습니다. 가격대별 불균형이 심합니다.

 

셋째, 자정이 되면 랭킹이 텅 비어버립니다. 일간 랭킹인데 날짜가 바뀌는 순간 score가 0부터 시작합니다. 새벽 3시에 접속한 유저는 랭킹을 볼 수 없습니다.

 

핵심은 하나입니다. ZSET의 score는 double 하나인데, 이 모든 걸 어떻게 해결하는가?

 

 


2. 100배 가격 차이를 1.25배로 - log1p 정규화

먼저 정수부(메인 점수)를 구성하는 주문 점수부터 해결합니다.

 

방식 문제
주문 건수 × 가중치 1,000원 1건 = 100,000원 1건. 금액 정보가 완전히 소실
주문 금액 × 가중치 (linear) 100,000원 1건이 1,000원 100건보다 높음. 가격대별 불균형
log1p(금액) × 가중치 금액이 클수록 증가폭이 줄어듦. 가격대 간 균형

 

log1pln(1 + x)입니다. log(0)은 음의 무한대지만 log1p(0) = 0이라 금액이 0인 경우도 안전합니다. 구체적인 숫자를 보면 효과가 명확합니다.

 

금액           log1p(금액)   ×0.6     10,000원 대비
─────────────────────────────────────────────────
1,000원        6.91         4.14     0.75x
10,000원       9.21         5.53     1.00x (기준)
100,000원      11.51        6.91     1.25x
1,000,000원    13.82        8.29     1.50x

100배 가격 차이가 1.25배 점수 차이로 압축됩니다. 비싼 상품이 여전히 유리하지만 "압도적으로" 유리하지는 않습니다.

 

성공 기준을 확인합니다. 주문 10,000원 1건의 점수는 5.53. 좋아요 3건의 점수는 3 × 0.2 = 0.6. 주문이 높습니다. 가중치가 올바르게 작동합니다.

 

실제 스케줄러 코드에서 메인 점수를 계산하는 부분입니다.

 

double mainScore = dm.getViewCount() * VIEW_WEIGHT          // 0.1
        + dm.getLikeCount() * LIKE_WEIGHT                    // 0.2
        + Math.log1p(dm.getOrderAmount()) * ORDER_WEIGHT;    // 0.6

dmproduct_daily_metrics 테이블에서 읽어온 일간 메트릭입니다. 여기에 carry-over가 더해지는데, 이건 4장에서 다룹니다.


3. double 해부학 - 정수부와 소수부의 역할 분리

메인 점수가 해결되었으니 이제 핵심 문제입니다. 동점 상품 사이에서 어떻게 순위를 가를까요?

ZSET의 score는 IEEE 754 double입니다. 유효 자릿수가 15~16자리. 이 공간을 정수부와 소수부로 나눕니다.

score = 5.700500800
        │ │  │  │
        │ │  │  └── recencyNorm (800) / 1,000,000,000
        │ │  └───── likeNorm    (500) / 1,000,000
        │ └──────── viewNorm    (700) / 1,000
        └────────── floor(mainScore) = 5

정수부는 메인 점수의 floor값입니다. 이것이 순위의 큰 틀을 결정합니다. 메인 점수가 5.53이면 정수부는 5, 6.91이면 6입니다. 정수부가 다르면 소수부와 상관없이 순위가 갈립니다.

 

소수부는 타이브레이커입니다. 정수부가 같은 상품들 사이에서 미세한 차등을 둡니다. 3개 축(조회수, 좋아요 수, 최신성)을 각각 1/1,000 자릿수씩 차지하게 배치합니다.

 

핵심은 소수부가 정수부를 침범하지 않는다는 점입니다. viewNorm이 999(최대값)여도 999/1,000 = 0.999라 정수부에 1을 넘겨주지 않습니다. 3개 축의 최대 합산도 0.999 + 0.000999 + 0.000000999 = 0.999999999로 1 미만입니다.

 

이질적인 축을 어떻게 같은 0~999 범위로?

문제는 3개 축의 원시값(raw value)이 전혀 다른 스케일이라는 겁니다. 조회수는 0 ~ 10,000, 좋아요는 0 ~ 100, 최신성은 초 단위 타임스탬프. 이걸 그대로 쓰면 조회수가 소수부를 지배합니다.

 

정규화 파이프라인을 3단계로 구성했습니다.

raw value → z-score → sigmoid → 0~999

예시 (조회수 500, 평균 200, 표준편차 150):
  z-score = (500 - 200) / 150 = 2.0
  sigmoid = 1 / (1 + e^-2.0) = 0.881
  norm = round(0.881 × 999) = 880

 

z-score (v - mean) / stddev로 각 축을 평균 0, 분산 1로 변환합니다. 스케일이 달라도 "평균에서 얼마나 떨어져 있는가"라는 통일된 척도가 됩니다.

 

sigmoid 1/(1+e^-z)로 (0, 1) 범위에 매핑합니다. z-score가 극단적으로 크거나 작아도 0~1 안에 수렴하므로 아웃라이어가 다른 상품의 정규화를 왜곡하지 않습니다. min-max 정규화를 쓰면 아웃라이어 하나가 나머지를 전부 0 근처로 압축시킵니다.

 

private int normalize(double value, double mean, double stddev) {
    double z = (value - mean) / stddev;
    double sigmoid = 1.0 / (1.0 + Math.exp(-z));
    return (int) Math.round(sigmoid * 999);
}

최종 인코딩도 깔끔합니다.

 

int viewNorm = normalize(e.views, viewMean, viewStd);
int likeNorm = normalize(e.likes, likeMean, likeStd);
int recencyNorm = 999 - normalize(recencyArr[i], recencyMean, recencyStd);  // ← 역순

double tieBreaker = viewNorm / 1_000.0
        + likeNorm / 1_000_000.0
        + recencyNorm / 1_000_000_000.0;

double finalScore = Math.floor(e.mainScore) + tieBreaker;

recency는 "최근일수록 높아야 하는데" 원시값은 "경과 초수"라서 클수록 오래된 겁니다. 999 -로 뒤집습니다.

 

double 유효 자릿수 안전한가?

정수부가 최대 3자리(일간 랭킹에서 메인 점수 1,000 이상은 비현실적) + 소수부 9자리 = 총 12자리. double의 유효 자릿수 15~16자리 안에 여유 있게 들어갑니다. 메인 점수가 1,000,000을 넘기면 소수부 뒤쪽이 잘리기 시작하지만, 일간 랭킹에서 그런 점수는 나올 수 없습니다.

 

 


4. 새벽 3시의 텅 빈 랭킹 - Carry-Over로 콜드 스타트 완화

매일 자정에 새 ZSET 키(ranking:all:20260410)를 쓰기 시작합니다. 어제 인기 있던 상품의 점수가 0부터 다시 시작하면 새벽 시간대에 랭킹이 텅 비거나, 새벽에 조회수 1건 받은 상품이 1위가 됩니다.

 

방식 문제
전일 점수 전액 계승 과거 인기 상품이 영원히 상위 고착. 신규 상품이 올라갈 수 없음
ZUNIONSTORE로 전일 키 병합 전일 키 전체 복사라 메모리 2배. 병합 비율 조절이 키 단위로만 가능
스케줄러 내에서 전일 정수부 × 0.1 전일 인기도의 10%만 계승. 추가 Redis 키 불필요

 

스케줄러가 5분마다 재계산할 때, 어제 ZSET(ranking:all:yesterday)에서 해당 상품의 점수를 읽어와 10%를 더합니다.

 

Double yesterdayScore = yesterdayScores.get(pid);
if (yesterdayScore != null) {
    mainScore += Math.floor(yesterdayScore) * CARRY_OVER_WEIGHT;  // 0.1
}

floor를 취하는 이유는, 어제의 타이브레이커(소수부)를 오늘로 가져오면 안 되기 때문입니다. 정수부(메인 점수)만 10%를 가져옵니다.

 

지수적 감쇠가 자연스럽게 발생합니다.

Day 0: score = 10.0     carry = -
Day 1: carry = 10 × 0.1 = 1.0
Day 2: carry = 1  × 0.1 = 0.1
Day 3: carry = 0  × 0.1 = 0.0   ← 3일 만에 소멸

별도의 TTL이나 만료 로직 없이, 매일 10%씩 줄어들면서 3일이면 영향이 사라집니다. 새벽에도 어제 인기 상품이 랭킹에 남아 있지만, 오늘 새로운 이벤트가 들어오면 금방 밀려납니다.

 

 


5. Redis를 믿지 말자 - DB를 SSOT로

처음에는 일간 메트릭을 Redis Hash에 저장했습니다. HINCRBY로 빠르게 적재하고, 스케줄러가 Redis에서 읽어 점수를 계산하는 구조였습니다.

[AS-IS]
Kafka Consumer → Redis HINCRBY (ranking:raw:{date}) → 스케줄러 reads Redis → ranking:all ZSET

 

두 가지 문제를 발견했습니다.

 

멱등성이 없습니다. Kafka가 메시지를 재전달하면 HINCRBY가 중복 실행됩니다. 조회수 1건이 2건으로 뻥튀기됩니다.

eventHandledRepository.insertIgnore(eventId)로 멱등성을 구현해 놓았는데, Redis Hash 경로는 이 가드를 타지 않았습니다.

 

영속성이 없습니다. Redis가 재시작되면 당일 raw 데이터가 전부 날아갑니다. 스케줄러가 읽을 것이 없어 랭킹을 재생성할 수 없습니다. carry-over도 어제 ZSET이 없으면 작동하지 않습니다.

 

DB로 옮겼습니다.

[TO-BE]
Kafka Consumer ─┬─ DB: product_daily_metrics (SSOT, 멱등)
                ├─ DB: product_metrics (all-time)
                └─ Redis: ranking:hourly (TTL 2h, 실시간 근사치)

스케줄러 (5분 주기): DB product_daily_metrics → 점수 계산 → Redis ranking:all ZSET

읽기 API: Redis ranking:all (ZREVRANGE) ← DB를 안 건드림

핵심은 기존 멱등성 가드를 [상속] 한 것입니다. handleProductViewed() 코드를 보면, insertIgnore 가드 안에 all-time 메트릭과 daily 메트릭을 함께 넣었습니다.

 

@Transactional
public void handleProductViewed(String eventId, Long productId, ZonedDateTime occurredAt) {
    if (eventHandledRepository.insertIgnore(eventId, occurredAt) == 0) {
        log.info("이미 처리된 이벤트: eventId={}", eventId);
        return;  // ← 여기서 걸러짐. 아래 코드 전부 스킵
    }

    ensureExists(productId);
    int affected = productMetricsRepository.incrementViewCount(productId, occurredAt);
    if (affected == 0) {
        log.info("오래된 이벤트 무시: eventId={}, productId={}", eventId, productId);
    } else {
        productDailyMetricsRepository.upsertViewCount(productId, occurredAt.toLocalDate(), occurredAt);
        log.info("조회 메트릭 갱신: productId={}", productId);
    }
}

Kafka가 같은 메시지를 다시 보내면 insertIgnore가 0을 반환하고, daily UPSERT까지 도달하지 않습니다. 별도의 멱등성 로직을 추가할 필요가 없었습니다.

 

그럼 Hourly는 왜 Redis에 남겼나?

Hourly까지 DB에 넣으면 이벤트마다 DB UPSERT가 2회(daily + hourly)입니다. Hourly는 TTL 2시간이라 유실돼도 최대 2시간치 데이터만 소실됩니다. 그리고 hourly가 유실되어도 스케줄러가 5분 내에 DB 기반으로 ranking:all을 재생성하니까, 일간 랭킹에는 영향이 없습니다.

private void increaseHourlyScore(Long productId, double delta) {
    String hourlyKey = HOURLY_KEY_PREFIX + LocalDateTime.now().format(HOUR_FORMAT);
    redisTemplate.opsForZSet().incrementScore(hourlyKey, String.valueOf(productId), delta);
    redisTemplate.expire(hourlyKey, HOURLY_TTL);  // 2시간
}

읽기 경로는 Redis만 사용합니다. ZREVRANGEZREVRANK로 랭킹과 순위를 O(log N)에 조회합니다. DB를 건드리지 않으니 읽기 성능은 DB SSOT 마이그레이션의 영향을 받지 않습니다.

 

 


6. 숫자가 말해준 것 - k6 4-Case 부하 테스트

k6로 4개 시나리오를 돌려 검증했습니다.

Case VU p95 응답시간 TPS 에러율
1. 기능 검증 10~20 99ms 36 0%
2. 랭킹 정합성 50 485ms 54 0%
3. 읽기 부하 200~500 79ms 1,851 0%
4. 혼합 부하 (Redis SSOT) 500~1000 4.75s 169 0.28%
4. 혼합 부하 (DB SSOT, 건별) 500~1000 5.94s 153 1.32%
4. 혼합 부하 (DB SSOT, Bulk) 500~1000 5.54s~5.67s 158.9~170.9 1.23%~1.31%

Case 3이 핵심입니다. 500 VU가 동시에 랭킹을 조회해도 p95 = 79ms, 에러율 0%. Redis ZREVRANGE가 읽기 경로의 전부이기 때문입니다. DB를 안 타니까 HikariCP 커넥션 풀과 무관합니다.

 

Case 4 비교가 DB SSOT의 비용을 보여줍니다. Redis SSOT에서 DB SSOT로 바꾼 후 TPS가 169에서 153으로 9% 감소하고, p95가 4.75s에서 5.94s로 25% 증가했습니다. 처음엔 "Consumer가 이벤트당 @Transactional로 4~5회 쿼리를 돌리니 DB 왕복이 병목일 것"이라고 가정했고, 그 가정대로 최적화에 들어갔습니다.

 

 


7. 병목을 잘못 짚었다.. - Bulk UPSERT 이야기

Case 4에서 TPS가 9% 떨어진 걸 보고 가장 먼저 의심한 건 Consumer 쪽이었습니다. Kafka batch listener가 최대 3,000건을 한 번에 주는데, Consumer는 건별 for 루프에서 @Transactional 메서드를 호출하고 있었거든요. 이벤트 하나당 쿼리 4~5회, 3,000건이면 1만 2천 번. 배치로 묶어서 DB 왕복을 줄이면 회복될 거라 생각했습니다.

 

구현은 세 갈래로 나눴습니다. Consumer는 파싱만 담당하고 이벤트 타입별 리스트를 만듭니다. AppService에 배치 메서드를 추가해서 단일 트랜잭션 안에서 (productId, date) 기준으로 집계합니다. Repository에는 EntityManager로 동적 native query를 조립해 INSERT ... ON DUPLICATE KEY UPDATE를 한 번에 실행합니다.

 

멱등성은 배치 내 eventIdfindExistingEventIds로 선조회해 이미 처리된 것을 걸러내고, 남은 것만 bulk INSERT IGNORE로 이중 방어합니다.

 

음수 방어는... GREATEST(col + VALUES(col), 0)ON DUPLICATE KEY UPDATE에 넣으면 UPDATE는 잘 되는데, INSERT 브랜치에서 음수 델타가 그대로 row에 들어가는 케이스가 있었습니다. 그래서 2-phase로 나눠 해결했습니다. 먼저 bulkInsertIgnoreSeed(pid, date) 쌍마다 zero-row를 시딩하고, 그 다음 bulk UPSERT는 항상 UPDATE 브랜치만 타도록 유도하는 구조입니다.

 

쿼리 수는 줄었다

쿼리 수 감소는 로그로 확인했습니다.

catalog 배치 처리 완료: total=311, fresh=311, products=6

311건의 이벤트가 6개 distinct product로 집계됐고, DB 왕복은 6회(2-phase × 3 테이블)로 줄었습니다. 건별 처리 시절이었다면 311 × 4 = 1,244 쿼리. streamer의 DB 쿼리 수는 약 40~50배 줄었습니다.

 

측정 한 번 하고 결론 낼 뻔했다..?

여기까지 와서 K6 Case 4를 다시 돌렸는데, 첫 측정이 TPS 140, p95 6.57s. baseline(169)보다 오히려 나빠졌습니다. 저는 "가정이 틀렸다"는 결론으로 문서까지 쓰기 시작했는데, 돌아보니 측정 자체가 엉망이었습니다.

 

당일 오전에 Consumer의 ClassCastException 버그를 고치느라 수 분간 메시지 소비가 멈춰 있었고, 그 사이 Kafka backlog가 상당량 쌓였습니다. 저는 streamer를 재시작하자마자 바로 측정에 들어갔고, streamer는 기존 backlog와 신규 이벤트를 동시에 드레인하면서 MySQL을 공유 자원으로 긁고 있었습니다. 게다가 Case 4를 쉬는 시간 없이 4회 연속 돌렸으니 각 run의 잔여 이벤트가 다음 run까지 타고 들어갔습니다.

 

그래서 절차를 다시 세웠습니다. 두 앱 클린 재시작, Kafka consumer lag이 0이 될 때까지 대기, warmup 1회 버리고 측정 2회, 각 run 사이에도 lag 0 확인.

 

지표 Redis SSOT DB SSOT (건별) DB SSOT (Bulk, clean)
TPS 169 153 158.9 / 170.9 (2회)
p95 4.75s 5.94s 5.67s / 5.54s
에러율 0.28% 1.32% 1.31% / 1.23%

 

평균 ~165 TPS, baseline 169 대비 ±5% 안쪽. 크게 오르지도 내리지도 않았습니다. "Bulk가 Case 4 TPS를 망쳤다"는 첫 결론은 오염된 측정의 결과였고, "Bulk로 회복된다"는 원래 가설도 성립하지 않았습니다. 둘 다 틀렸습니다.

 

왜 틀렸나

K6 Case 4는 commerce-api의 혼합 부하 처리량을 잽니다. 랭킹 조회, 상품 상세, 좋아요 요청이 섞여 있고 그중 비싼 건 두 가지입니다.

  1. 랭킹 조회 — product_daily_metrics ← products JOIN으로 top-N을 읽고 상품 정보를 붙이는 쿼리
  2. 상품 상세 — products 테이블 조회 + 랭킹 위치(ZREVRANK)

이 경로 어디에도 "streamer가 이벤트를 얼마나 빨리 소비하는가"는 등장하지 않습니다. streamer는 별도 프로세스에서 Kafka를 비동기로 드레인할 뿐이고, K6가 때리는 commerce-api와는 Kafka라는 버퍼로 분리돼 있습니다. streamer UPSERT가 2초가 걸리든 0.1초가 걸리든 commerce-api 응답시간과는 무관합니다. Redis SSOT → DB SSOT로 넘어가면서 TPS가 떨어진 건 commerce-api의 랭킹 조회가 Redis ZSET에서 DB JOIN으로 바뀌었기 때문이지 streamer 쪽 UPSERT 때문이 아니었습니다. 그걸 streamer에서 해결하려 했으니 숫자가 안 움직이는 게 당연합니다.

 

+ Bulk INSERT IGNORE의 동시성 deadlock

Clean 측정 중에 streamer 로그에 Deadlock found when trying to get lock 에러가 반복해서 찍혔습니다. 80초짜리 run 하나 동안 44건 감지, 11개 배치가 전체 실패 → acknowledge() 미호출 → Kafka 재전달로 복구됐습니다. 건별 UPSERT에는 없던 문제입니다.

 

원인은 3개 consumer 스레드가 겹치는 product_id 집합에 대해 다음 쿼리를 동시에 던지는 구조입니다.

INSERT IGNORE INTO product_daily_metrics
  (product_id, metric_date, view_count, like_count, order_amount, updated_at)
VALUES (?, ?, 0, 0, 0, '2000-01-01 00:00:00'),
       (?, ?, 0, 0, 0, '2000-01-01 00:00:00'), ...

MySQL은 INSERT IGNORE에서 기존 row 존재 여부를 확인하며 gap lock을 잡는데, 배치 내 productId가 서로 다른 순서로 도착하면 스레드 A는 [3, 7] 순서로, 스레드 B는 [7, 3] 순서로 락을 잡으려 합니다. 순환 대기가 만들어지고 deadlock. 건별 처리일 때는 락 범위가 row 하나라 겹칠 일이 드물었는데, bulk로 묶는 순간 락 범위가 배치 전체로 늘어나면서 드러났습니다.

 

해결은 간단합니다. 배치 안의 productId를 정렬해서 락 획득 순서를 고정하면 순환 대기가 안 생깁니다. 이번 PR에는 문제 발견만 기록하고, 수정은 후속 과제로 뺐습니다.

 

그래도 Bulk는 남겨두자

K6 숫자는 못 바꿨지만, streamer의 쿼리 수가 40배 줄었다는 사실 자체는 쓸모가 있습니다. Consumer lag이 쌓였을 때 backlog 드레인 속도가 달라지고, Kafka 장애 복구나 이벤트 폭주 시나리오에서 차이가 납니다. commerce-api의 POST /like 경로가 같은 MySQL을 공유하니 streamer 쪽 커넥션/락 점유 시간이 줄어드는 것도 이론상 쓰기 경로 contention 완화에 도움이 될 수 있고요 (이번 측정에서는 노이즈에 묻혔지만).

 

진짜 병목은 commerce-api의 랭킹 JOIN 쿼리입니다. 인덱스, 커버링, 캐싱 중에 뭘 시도할지는 다음으로 남기겠습니다.

 

이번에 제가 챙긴 건 두 가지입니다.

첫째, "느려 보이는 곳"과 "측정이 느리다고 말하는 곳"이 다를 수 있다는 것. Kafka로 분리된 파이프라인에서 streamer를 튜닝하면서 commerce-api K6 TPS 회복을 기대한 건 구조적으로 앞뒤가 안 맞았습니다.

 

둘째, 성능 측정은 환경 상태에 예민하다는 것. backlog가 남은 상태, 재시작 직후, 연속 run 사이 드레인이 안 끝난 상태에서 낸 숫자는 최적화 판단의 근거가 되기 어렵습니다. 측정 전에 "이 숫자가 내가 보고 싶은 걸 정말 보고 있는가"를 먼저 점검해야 한다는 걸 알게 됐습니다.

 

포화 지점은 약 500~600 VU입니다. 이후로는 HikariCP 커넥션 풀 경합으로 응답시간이 급격히 올라갑니다. 기본 풀 사이즈(10)를 튜닝하면 나아질 여지가 있지만 이번에는 건드리지 않았습니다.

 

 


8. P.S.

타이브레이커 3축은 "정답"이 아니다

조회수, 좋아요 수, 최신성을 타이브레이커 축으로 선택한 건 가용한 이벤트 데이터를 기준으로 한 것이지, 도메인 전문가가 검증한 결과가 아닙니다. 검색 CTR, 장바구니 추가율, 체류 시간 같은 축이 더 의미 있을 수 있습니다. 축이 바뀌어도 인코딩 구조(정수부 + 소수부 positional encoding)는 동일하게 적용할 수 있습니다. 축은 교체 가능한 부품이고, 인코딩은 프레임워크입니다.

 

스케줄러 5분 주기의 체감

Hourly ZINCRBY로 "근사치 실시간"을 제공하지만, 타이브레이커와 carry-over가 반영된 정확한 daily 랭킹은 최대 5분 지연됩니다. 이벤트 기반 재계산(CDC 또는 Kafka trigger)으로 줄일 수 있지만, 현 단계에서는 과한 복잡도입니다. 5분 지연이 비즈니스에 문제가 되는 시점이 오면 그때 고려합니다.

 

아직 측정하지 못한 것

Case 4에서 500~600 VU가 포화 지점인 이유가 HikariCP 기본 풀 사이즈(10)일 가능성이 높습니다. 커넥션 풀을 늘리면 포화 지점이 올라갈 수 있지만, 그만큼 DB 부하가 늘어나므로 단순히 "풀 키우기"가 정답은 아닙니다. 이건 다음에 모니터링과 함께 다룰 영역입니다.

반응형
TOP