현재 랭킹 산정 방식이 실제 사용자 요구와 부합 하는지 의문이 제기됩니다

배경

카프카에서 수신한 정보를 토대로 계산하여 랭킹을 산출하고 있었다. 지금 보여주고 있는것이 사용자가 원하는 랭킹일까?
잘 모르겠다. 실제로 운영이 되지 않는점도 크다고 생각한다. 만약 실제로 운영 된다면, 사용자들이 자주 들어오는 시간을 측정해서 가중치를 반영할 수도 있다 생각한다. 실제로 운영되는 환경이 아니기 때문에 부하테스트를 돌려서 확인하는것이 최선이지 않나 싶다.
랭킹 서비스를 만들기 위해서는 데이터를 저장을 해야 한다. 그래야 그걸 통해 정보를 보여줄 수 있기 때문이다. 그래서 최대한 예상을 해서 작성해보려구 한다.

수신 장치

현재 카프카를 통해 데이터를 수신하고 있다.
각 토픽에 맞게 메시지를 전송하고 있다.

@Component
@RequiredArgsConstructor
public class ProductKafkaPublisher implements ProductPublisher {
  private final KafkaTemplate<Object, Object> kafkaAtLeastTemplate;
  private final static String AGGREGATE_TOPIC = "PRODUCT_VIEWS_CHANGED_V1";

  @Override
  public void aggregate(@Payload RootMessage message, Long productId) {
    String key = LocalDate.now().toEpochDay() + ":" + productId;
    kafkaAtLeastTemplate.send(AGGREGATE_TOPIC, key, message);
  }
}

요런식으로 전송하게 되면 컨슈머를 통해 데이터를 받게 되어집니다.
그리고 나서

Long productId = entry.getKey();
Long sum = entry.getValue();
repository.upsertSales(productId, sum);
rankingRepository.increment(productId, 0.7 * value);

요로코롬 합산된 결과를 레디스에 넣는 형태로 작성하고 있다.

위에 0.7은 무엇을 뜻하고 왜 필요한걸까?
이것을 가중치라고 불리운다.

가중치를 왜 작성하는 걸까?

가중치와 랭킹은 큰 연관은 없다고 생각한다.다만, 여러가지 조건으로 랭킹을 산정하는 경우 도움이 될 수 있다고 생각한다.
현재 가중치를 다음과 같이 작성하였다.

판매:0.7
좋아요:0.3
읽기:0.1

으로 지정하였다. 상품이 판매가 되었다는것이 점수에 가장크게 반영이 된다는 뜻이 된다.
그렇다고 단정을 지을 수는 없다. 모니터링을 통해 미세하게 조정을 하면서 진행을 해야 되지 않을까 싶다.

요렇게 그래프가 나왔다고 가정하자.
만약, 저 그래프 수치가 현 비즈니스와 맞지 않는다고 판단하거나 소비자의 행동을 분석했을때, 위 에서 정한 가중치에 대한 값들을
변경을 시켜야 하는 경우는 어떻게 해야 할까? 유동적으로 가중치를 변경을 시켜야 하는데 어떻게 가중치를 변경할 수 있을까?
분명 어딘가에 가중치를 저장을 해야 하는데 RDB에 저장할지 캐시(레디스)에 저장할지 고민이 된다.

다음과 같은 장단점이 있다고 한다.

1️⃣ RDB (관계형 DB)에 저장

장점

1.  영속성 확보  
    서버가 재시작되어도 가중치 정보가 유지됨.
2.  수정 용이
    SQL로 UPDATE 하면 바로 값이 바뀌고, 관리도 쉬움.
3.  감사/로그 관리 용이 
    변경 이력, 버전 관리, 누가 언제 바꿨는지 추적 가능.
4.  조회 편리
    복잡한 조건이나 여러 가중치를 조합해서 조회 가능.

단점

1.  실시간 반영 제한
    랭킹 계산 시 DB를 매번 조회하면 성능 저하.  
    → 해결 방법: 캐시 + DB 조합 사용
2.  트래픽 집중 시 병목 가능
    조회 빈도가 높으면 DB 부하 증가

---
2️⃣ Redis (인메모리 캐시)에 저장

장점

1.  초고속 조회
    랭킹 계산에서 자주 참조되는 가중치를 바로 가져올 수 있음.
2.  실시간 변경 반영 가능
    운영 중 가중치 값을 바꾸면 바로 랭킹 계산에 반영됨.
3.  분산 환경 지원 용이
    여러 서버가 같은 Redis를 바라보므로, 변경 사항 즉시 동기화 가능.

단점

1.  영속성 불확실  
    Redis는 메모리 기반. AOF/RDB 백업을 하지 않으면 서버 재시작 시 소실 가능.
2.  변경 관리 복잡
    단순 값 변경은 쉽지만, 누가 언제 바꿨는지 추적이 어려움.
3.  운영 부담
    가중치가 많거나 복잡하면, 키/값 구조를 잘 설계해야 함.

요렇다는데 가중치라는 데이터가 영속화가 필요할까? 아니면 빠르게 조회가 되어야 하는 부분일까?
초반에야 가중치를 조작하면서 테스트를 한다고 하지만, 서비스가 안정화가 되어진다면 더 이상 가중치 변경을 할 이유가 없어진다고 생각한다. 그렇다면 서비스 초반을 위해 만드는 거라 생각이 드는데 서비스 안정화를 위해 레디스까지 써야 하는걸까.. 안정화가 되면 RDB는 지우고 하드코딩으로 작성해도 상관없지 않을까 생각이 든다.

INSERT INTO weight (created_at, deleted_at, updated_at, likes, sales, views)
VALUES ('2025-09-12 01:51:28.000000', null, '2025-09-12 01:51:30.000000', 0.3, 0.7, 0.1);

나는 RDB에 저장하는것을 선택했다. 선택 이유는 빠르게 만들수 있는 방법이 제일 좋은 방법이 아닌가 생각이 들었고
그렇게까지 할 필요는 없다고 생각이 들었다. 추후에 필요하면 추가하면 되지 않을까 생각이 든다.

Long productId = entry.getKey();
Long sum = entry.getValue();
repository.upsertSales(productId, sum);
increment(productId, weight().getSales(), sum);

요렇게만 사용해도 충분하다고 생각이 든다.
사용했을때와 사용하지 않을때를 비교했을때 캐시를 사용해야 할 만큼은 아닌거 같았다. (서비스마다 다르지 않을까 생각이 든다.)

유동적으로 가중치를 설정했으니 이제 랭킹을 적재해보자.

+) 데이터는 상품 100개를 기준으로 작성하였습니다.

RDB vs 레디스 적재?

랭킹을 적재하기 위해서는 어디에 저장하는것이 합당할까?
가장 간단한 방법인 RDB에 적재를 해보자.

그럼 위와 같이 나온다. 그러면 랭킹을 산정하려고 하면 어떻게 해야 할까?

다음과 같이 조회를 해야한다.

select * from event_ranking
order by score desc
limit 0,10;

현재는 데이터가 많지 않아 그렇게 느리지는 않지만, RDB에서 ORDER BY score DESC LIMIT n OFFSET m 같은 쿼리는
내부적으로 정렬 연산(특히 OFFSET 커질 때 full scan 위험)이 들어가게 된다.
또한, RDB는 동시에 여러 트랜잭션이 들어오면 락 경합, MVCC 비용 발생이 발생이 된다고 한다.

요러한 이유때문에 레디스를 사용하는거라고 한다.
찾아보니 “점수 갱신 후 바로 순위 조회했을 때 반영되는 속도” 측정을 해보라고 해서 점수 갱신 중에 조회를 해보았다.

2.54초가 걸렸다. 점수를 갱신을 시켜놓고 동시에 조회를 시킨결과다.
사실 좀 무섭긴한다.

이렇게까지 했는데 의미가 없다고 하면.. 슬퍼진다.. 아무튼 이번에는 레디스를 실행해보자.
놀랍게도 점수갱신과 상관없이 조회가 잘된다는것을 확인 할 수 있었다.

완료된 이후에야 조회를 하는건 레디스건 RDB건 느리지 않을 수 있지만,
계산 도중에 조회하는건 또 다른 성능 문제라 여겨진다. 이것이 레디스의 ZADD를 사용하는 이유가 아닌가 싶다. (아니다.)

(추후 알고보니 이게 ZADD를 사용하는 이유는 아니었고 쓰기와 읽기를 동시에 사용하는 경우  느려지는 문제는 여전했다.
아무래도 위는 운이 좋아서 발생이 된거로 추측한다.)

현재 코드는 상품별로 값을 계산하고 있었다. 

// 상품별로
for (Entry<Long, Long> entry : map.entrySet()) {
  Long productId = entry.getKey();
  Long sum = entry.getValue();
  repository.upsertLikes(productId, sum);
  increment(productId, weight().getLikes(), sum); //레디스 통신
}

특정 시간에 산정된 랭킹을 보여줘도 상관없다면, RDB로 해도 괜찮지 않을까??

지금에야 그리 느리지는 않지만, 상품갯수가 많아지면 요 부분에 네트워크 통신에 대한 비용은 증가 할 수 밖에 없다.
그래서 사용할 수 있는것이 레디스의 파이프라인이다.

파이프 라인

레디스는 파이프라인이라는것을 이용해서

개별 명령에 대한 응답을 기다리지 않고 한 번에 여러 명령을 실행하여 성능을 향상시키는 기술

이라고 합니다. 그러니까 파이프라인을 사용하게 되면, 반복적으로 동작하는 명령어들을 한 번에 발송을 시킬 수 있는 느낌이 든다.
그러면 코드를 다음과 같이 수정을 할 수 가 있었다.

  public void increment(Map<Long, Long> aggregate, double weight) {
    redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
      StringRedisConnection redisConnection = (StringRedisConnection) connection;

      for (Entry<Long, Long> entry : aggregate.entrySet()) {
        Long productId = entry.getKey();
        Long sum = entry.getValue();
        double score = sum * weight;
        redisConnection.zIncrBy(KEY, score, productId.toString());
      }

      return null;
    });


  }

위에서 파이프라인을 통해 데이터를 전송하면 redisConnection.zIncrBy(KEY, score, productId.toString());
이 명령어가 모든 요청이 끝날때까지 기다렸다가 한번에 적용이 되어지는 형태로 여겨진다.
이제 한번 테스트를 해보자.

레디스에 정상적으로 적재가 되었다는것을 알 수 있었다.

사실 속도로 보면 차이나는 결과를 보여주지는 못했다.
그러면 성공률을 한번 봐보자. 

이거는 ZADD한 결과다.

api_like_delete_duration.......: avg=10.46s min=3s       med=11.51s   max=13.72s p(90)=13.33s p(95)=13.52s
api_like_delete_errors.........: 7.69%  2 out of 26
api_like_delete_requests.......: 26     0.685717/s

api_like_post_duration.........: avg=8.78s  min=1.82s    med=8.99s    max=13.71s p(90)=13.32s p(95)=13.53s
api_like_post_errors...........: 1.75%  1 out of 57
api_like_post_requests.........: 57     1.503303/s

api_orders_duration............: avg=1.64s  min=8.09ms   med=993.62ms max=5.43s  p(90)=4.15s  p(95)=4.93s 
api_orders_errors..............: 0.00%  0 out of 47
api_orders_requests............: 47     1.239566/s

api_payment_duration...........: avg=1.71s  min=152.4ms  med=732.7ms  max=7.03s  p(90)=5.41s  p(95)=6.67s 
api_payment_errors.............: 48.93% 23 out of 47
api_payment_requests...........: 47     1.239566/s

api_products_view_duration.....: avg=1.13s  min=189.77ms med=1.1s     max=3.09s  p(90)=1.59s  p(95)=1.76s 
api_products_view_errors.......: 0.27%  2 out of 728
api_products_view_requests.....: 728    19.200085/s

api-payment는 의도적으로 에러가 발생하기 때문에 제외를 하고 봐도 7퍼까지 에러율이 존재하였다.

이제 다시 파이프라인으로 다시 테스트를 진행해보자.

api_like_delete_duration.......: avg=10.84s   min=8.72s    med=10.54s   max=13.41s p(90)=13.35s p(95)=13.38s
api_like_delete_errors.........: 0.00%  0 out of 28
api_like_delete_requests.......: 28     0.762203/s

api_like_post_duration.........: avg=7.79s    min=2.44s    med=7.38s    max=13.14s p(90)=12.63s p(95)=12.76s
api_like_post_errors...........: 0.00%  0 out of 50
api_like_post_requests.........: 50     1.361077/s

api_orders_duration............: avg=829.48ms min=7.76ms   med=388.07ms max=4.11s  p(90)=2.62s  p(95)=3.23s 
api_orders_errors..............: 0.00%  0 out of 53
api_orders_requests............: 53     1.442742/s

api_payment_duration...........: avg=1.09s    min=142.3ms  med=501.21ms max=5.76s  p(90)=2.73s  p(95)=4.61s 
api_payment_errors.............: 39.62% 21 out of 53
api_payment_requests...........: 53     1.442742/s

api_products_view_duration.....: avg=987.1ms  min=153.34ms med=1s       max=2.2s   p(90)=1.34s  p(95)=1.52s 
api_products_view_errors.......: 0.00%  0 out of 733
api_products_view_requests.....: 733    19.953396/s

payment를 제외한 모든 API에서 에러율이 상당이 낮아졌다는 사실을 알 수 가 있었다.
속도는 미세하게 빨라진거 같다는 느낌이든다. 그렇다고 해서 드라마틱하게 바뀌지는 않는 느낌이다.

결국, 네트워크를 한번에 가져가게 되면 에러율을 상당히 줄일수 있다는 사실을 알게 되었다.
이로써 알게된 사실은 레디스도 동작하는 도중(쓰기 작업)에 동시에 조회를 하게 되어도 네트워크 부하게 발생하는 사실을 알게 되었다.

RDB에서는 어떨까? 

api_like_delete_duration.......: avg=10.85s   min=3s       med=11.76s   max=13.64s p(90)=13.37s p(95)=13.61s
api_like_delete_errors.........: 4.34%  1 out of 23
api_like_delete_requests.......: 23     0.631041/s

api_like_post_duration.........: avg=8.43s    min=2.01s    med=8.42s    max=13.5s  p(90)=13.08s p(95)=13.27s
api_like_post_errors...........: 2.04%  1 out of 49
api_like_post_requests.........: 49     1.344391/s

api_orders_duration............: avg=1.45s    min=7.17ms   med=684.39ms max=5.41s  p(90)=5.03s  p(95)=5.07s 
api_orders_errors..............: 3.84%  2 out of 52
api_orders_requests............: 52     1.426701/s

api_payment_duration...........: avg=1.47s    min=122.39ms med=716.43ms max=6.15s  p(90)=3.68s  p(95)=4.87s 
api_payment_errors.............: 42.00% 21 out of 50
api_payment_requests...........: 50     1.371828/s

api_products_view_duration.....: avg=981.33ms min=150.59ms med=995.48ms max=2.45s  p(90)=1.37s  p(95)=1.49s 
api_products_view_errors.......: 0.00%  0 out of 761
api_products_view_requests.....: 761    20.879223/s

생각보다 에러율이 높지는 않는거 같다. 그래도 파이프라인을 사용해서 테스트한 결과가 제일 좋게 나온것은 변함없을거 같다.

결론

가중치는 이해관계자들이 어떻게 상품에 대한 가치를 어떻게 부여하는야에 따라 결과는 천차만별이라 생각이 든다. 어떤 곳에서는 판매량을 어떤곳에서는 좋아요 갯수를 더 우선순위를 둘수 있지 않을까? 판매량이 가치가 높은지 좋아요 갯수가 가치가 높은지는 알 수 가 없다고 생각한다. 또한 RDB와 레디스를 비교해서 진행을 해봤다. 초반에는 레디스를 사용하게 되면 쓰기 작업과 읽기 작업을 동시에 할수 있다고 생각했다. 그래서 실시간이 왜 불가능한지 이해를 하지못했다. 하지만 결국 이 사실은 진실이 아님을 깨닳았다. 아마 그 당시에 쓰기 작업이 우연히 되지 않았나 생각이 든다. 솔직히 말하면, 레디스(ZADD)와 RDB의 성능 차이에 대해 확실하게 테스트하지는 못했다. 나는 100건 기준으로 테스트했지만 이걸로는 양이 부족한듯 싶다. 하지만 다행스럽게도 레디스에는 네트워크 부하를 줄일수 있는 '레디스 파이프라인'이 존재한다. 이 기능을 사용하게 되면 에러율이 현저하게 낮출수 있었다. 여기에서도 쓰기작업과 읽기 작업을 동시에 하게되면 부하는 발생하였다. 만약에 실시간 랭킹을 만들려면, 쓰기 작업과 읽기 작업이 동시에 발생해도 큰 무리가 가지 않아야 할거 같은데 가능할지는 모르겠다.
레디스 파이프라인 도입으로 네트워크 부하와 에러율이 현저히 감소함을 확인했으며, 향후 대규모 서비스 적용 시 지속적 모니터링과 가중치 동적 조정이 필요하다.

레퍼런스

- https://junghyungil.tistory.com/220

'루퍼스 > 9주차' 카테고리의 다른 글

WIL  (0) 2025.09.14

댓글

Designed by JB FACTORY