개요

TL;DR "Redis는 '빠른 정렬'을 해결하지, '정합성'은 개발자 몫이다."

 

커머스 프로젝트에서 "오늘의 인기 상품" 랭킹 시스템을 Redis ZSET으로 설계했습니다. 

처음엔 ZINCRBY 한 줄이면 끝날 줄 알았는데, 실제로는 DB Ledger + ZADD + 합성 Score 인코딩 + 멱등 Carry-Over까지 발전했습니다. 

 

이 글은 ZSET 기반 랭킹 시스템을 4개 구간으로 나누어 Redis가 해결하는 것과 그렇지 않은 것을 분석합니다.


배경: 왜 Redis ZSET인가

상품 랭킹을 DB로 구현하면 매번 이런 쿼리가 필요합니다.

SELECT product_id, SUM(score) as total
FROM product_metrics
WHERE date = '2026-04-10'
GROUP BY product_id
ORDER BY total DESC
LIMIT 20;
 

트래픽이 적을 땐 문제없지만, 랭킹 페이지는 자주 호출되는 API입니다. 트래픽이 몰리면 매번 GROUP BY + ORDER BY를 하는 건 DB에 부담이 됩니다.

Redis의 Sorted Set(ZSET)은 이 문제를 위해 태어난 자료구조입니다. member(상품 ID)와 score(인기 점수)를 넣으면 score 기준으로 자동 정렬해주지요.

Top-N 조회는 ZREVRANGE, 특정 상품의 순위는 ZREVRANK 한 방이면 끝입니다.

하지만 ZSET을 "" 쓴다는 건, ZSET이 해결하지 않는 것의 목록을 알고 있다는 뜻이라고 생각했습니다.

 


[1] 클라이언트가 점수를 기록할 때

 

Redis가 해결하는 것

점수 기록 요청
  ├─ RESP 프로토콜: 텍스트 기반 직렬화, 파싱 오버헤드 최소
  ├─ Single-threaded: 모든 명령이 순차 실행 → 동시성 제어 불필요
  ├─ ZINCRBY: 기존 score에 delta를 원자적으로 누적
  │   └─ member가 없으면 0에서 시작
  └─ Pipelining: 여러 명령을 한 번에 묶어 RTT 절약

싱글 스레드의 힘: 인스턴스가 10대여도 동시에 ZINCRBY ranking:day:20260410 0.2 101을 보내면 Redis가 하나씩 순차 실행합니다. Lock이 필요 없습니다. DB에서 조회수 UPDATE할 때 겪는 Optimistic Lock 충돌이 여기선 발생하지 않습니다.

 

ZINCRBY vs ZADD: ZADDscore를 덮어씁니다. 좋아요 3번에 ZADD 0.2를 3번 넣으면 최종 score는 0.2입니다. 원하는 건 0.6이므로, 누적에는 ZINCRBY를 써야 합니다.

 

Redis가 해결하지 않는 것

1. 프로듀서 애플리케이션이 죽는 경우

Kafka Consumer가 이벤트를 받아서 ZINCRBY를 호출하기 전에 죽으면 어떻게 될까요? Kafka의 at-least-once 보장 덕분에 이벤트는 재배달되지만, 반대로 ZINCRBY 성공 후 offset commit 전에 죽으면 같은 이벤트가 두 번 반영됩니다.

랭킹이라서 0.1 정도의 중복은 사용자가 체감할 수 없습니다. 은행 잔고와는 다릅니다. 하지만 "괜찮다" "신경 안 써도 된다"는 다른 말입니다.

 

2. DB-Redis 간 원자성 부재

비즈니스 로직이 DB 트랜잭션과 Redis 쓰기를 모두 포함하면, 둘 사이의 atomicity는 보장되지 않습니다.

@Transactional
public void processEvent(Event event) {
    ledgerRepository.upsert(event);     // DB: 성공
    redisTemplate.opsForZSet()
        .incrementScore(key, member, delta);  // Redis: 실패하면?
    // DB는 이미 커밋 준비 중...
}

DB는 커밋됐는데 Redis가 실패하면 불일치가 생깁니다. 반대도 마찬가지입니다. 2PC(Two-Phase Commit)를 쓸 수도 있겠지만, 랭킹 시스템에 그 정도 복잡도를 들일 이유가 없습니다.

 

해결책: DB를 원장(Ledger)으로, Redis는 파생 뷰로

 
이벤트 → Kafka Consumer → DB Ledger upsert (dirty=true)
                                    ↓
                          5초 주기 SyncScheduler
                                    ↓
                          dirty 행 조회 → ZADD → dirty=false

DB ledger를 source of truth로 두고, 별도 스케줄러가 dirty 행만 읽어서 ZADD로 동기화합니다. Redis가 날아가도 ledger를 다시 sync하면 복구가 끝납니다. ZINCRBY 시절의 "Redis가 곧 원장" 문제가 사라집니다.

 

 

3. 합성 Score 인코딩 — ZINCRBY로는 불가능한 것

동점이면 누가 위에 올라갈까요? Redis 기본 동작은 member의 사전순(lexicographic) 정렬입니다. productId "100" "99"보다 앞서는데, 이건 비즈니스적으로 아무 의미가 없습니다.

 

정책을 정했습니다: 동점이면 더 최근에 갱신된 상품이 상위. "지금 뜨고 있는 상품"을 위로 올리는 효과입니다. 구현은 하나의 double 값에 두 가지 정보를 끼워 넣는 합성 score 인코딩입니다.

composite = basePoints + ((lastScoredEpochSec - bucketStartEpochSec) * 1e-9)
  • basePoints가 정수부를 차지하고, 시각 정보가 소수 8~9자리에 들어감
  • base points 차이가 1e-4 이상이면 tie-break 비트는 영향 없음
  • double 정밀도(약 15~16자리) 안에서 base points가 1e6 이하면 안전

문제는 ZINCRBY로는 이 합성값을 만들 수 없다는 것입니다. ZINCRBY는 단순 덧셈만 하므로, 합성값의 소수부(시각 정보)가 누적되어 망가집니다. 결국 application에서 합성 score를 계산한 뒤 ZADD로 덮어쓰는 구조가 필요합니다.

 


[2] Redis 서버가 명령어를 처리할 때

Redis가 해결하는 것

Skip List 기반 O(log N) 삽입/갱신

ZSET 내부는 두 자료구조의 조합입니다.

ZSET 내부 구조
  ├─ Hash Table: member → score 매핑 (O(1) lookup)
  └─ Skip List: score 기준 정렬 (O(log N) insert/delete/range)

Skip List는 Balanced Tree(Red-Black, AVL)보다 구현이 단순하면서 비슷한 성능을 냅니다. 10만 개 상품이 들어있어도 ZADD는 O(log 100,000)  17번의 비교로 끝납니다.

 

인메모리 — 디스크 IO 제로

모든 데이터가 메모리에 있으므로 디스크 탐색 시간이 없습니다. ZREVRANGE ranking:day:20260410 0 19 WITHSCORES는 마이크로초 단위로 응답합니다. DB에서 ORDER BY + LIMIT을 하면 인덱스가 있어도 디스크 IO가 개입하는 것과 대조적입니다.

 

자동 TTL 만료

ZADD ranking:day:20260410 ...
EXPIRE ranking:day:20260410 172800   // 2일 후 자동 삭제

일별 키에 TTL을 걸어두면 이틀 뒤에 알아서 사라집니다. 별도 정리 배치가 필요 없습니다. 시간별 키는 1일 TTL이면 충분합니다.

 

Redis가 해결하지 않는 것

1. 메모리는 유한하다

Redis는 인메모리 DB이므로, 모든 데이터가 RAM을 차지합니다. 10만 개 상품의 ZSET은 어떨까요?

  • member당 약 100바이트 × 100,000 = ~10MB

이 정도는 Redis에 전혀 부담이 아닙니다. 하지만 일별 + 시간별 키가 매일 쌓이고, 상품이 수백만 개로 늘어나면 이야기가 달라집니다. TTL 자동 만료로 오래된 키는 정리되지만, 하나의 ZSET이 수천만 member를 가지면 ZREMRANGEBYRANK로 하위권을 주기적으로 정리하는 전략이 필요합니다.

 

2. 주문 금액의 스케일 문제

이벤트 타입별 가중치를 정했습니다: 조회 0.1, 좋아요 0.2, 주문 0.7. 그런데 주문의 score는 price × quantity입니다.

조회 1회:  0.1
좋아요 1회: 0.2
주문 1회(100만원 × 10개): 0.7 × 10,000,000 = 7,000,000

조회수 7천만 회와 주문 1회가 같은 score라는 건 말이 안 됩니다. Redis는 score에 숫자만 넣으면 충실히 정렬해주지만, 그 숫자의 의미는 개발자가 설계해야 합니다.

 

해결책: log10 스케일링

score = orderWeight × log10(Σ price × quantity)
     = 0.7 × log10(10,000,000)
     = 0.7 × 7.0
     = 4.9
 

여기서 합산 시점도 중요합니다. 이벤트 단위로 log10을 적용한 뒤 합산하면(Σ log10(price_i × qty_i)), 같은 상품의 큰 주문이 작은 주문보다 지수적으로 유리해지는 부작용이 생깁니다. 먼저 raw value를 합산한 뒤 log10을 한 번만 적용해야 공정합니다.

 


[3] 클라이언트가 랭킹을 조회할 때

Redis가 해결하는 것

Top-N 조회: O(log N + M)

ZREVRANGE ranking:day:20260410 0 19 WITHSCORES

 

Skip List에서 가장 큰 score부터 M개를 뽑습니다. 인덱스는 0부터 시작하므로 페이징은 다음과 같습니다

page=1: start=0,  stop=19   (1~20위)
page=2: start=20, stop=39   (21~40위)
일반화: start = (page-1) * size, stop = start + size - 1
 

 

개별 순위 조회: O(log N)\

ZREVRANK ranking:day:20260410 101
→ 0  (0-based, +1 해서 사용자에게 1위로 표시)

특정 상품의 순위를 상수 시간에 가깝게 알 수 있습니다. DB에서 이걸 하려면 COUNT(*) WHERE score > ?인데, 인덱스가 있어도 느립니다.

Redis 위에 캐시를 또 올릴 필요가 없다

Redis 자체가 인메모리 캐시입니다. ZREVRANGE가 마이크로초 단위로 응답하는데, 그 위에 로컬 캐시를 한 겹 더 올리면 캐시의 캐시가 됩니다. 초당 수만 건의 극단적 트래픽이 아니면 직접 호출로 충분합니다.

 

Redis가 해결하지 않는 것

 

1. 상품 정보 조합

ZREVRANGE가 반환하는 건 [(productId, score), ...] 뿐입니다. 사용자에게 보여줄 상품 이름, 가격, 이미지 같은 정보는 없습니다.

ZREVRANGE 결과: [(101, 15.3), (205, 14.8), (42, 12.1)]
                     ↓
사용자가 보고 싶은 것:
  1위: 나이키 에어맥스 / 189,000원 / 별점 4.5
  2위: 아디다스 울트라부스트 / 219,000원 / 별점 4.3
  ...

 

productId 리스트를 받아서 RDB에서 상품 정보를 조회한 뒤 조합해야 합니다.  뷰 조합을 어느 계층에서 할 것인가가 중요한데, 도메인 로직이 아니라 표현(Presentation) 계층의 관심사이므로 Controller에서 처리합니다.

// Controller (Interfaces 계층)
List<ProductRanking> rankings = rankingQueryService.getDailyRanking(date, page, size);
List<Long> productIds = rankings.stream().map(ProductRanking::productId).toList();
Map<Long, Product> products = productService.getByIds(productIds);  // RDB 조회

return rankings.stream()
    .map(r -> RankingProductResponse.from(r, products.get(r.productId())))
    .toList();

 

2. score를 사용자에게 노출할 것인가

합성 score 인코딩을 쓰면 score 값이 15.000000042처럼 됩니다. 이걸 사용자에게 보여주면 혼란만 줍니다. 디코딩해서 base points만 보여줄 수도 있지만, 애초에 사용자에게 필요한 건 순위이지 점수가 아닙니다. 응답 DTO에서 score 필드를 빼면 합성 score 노출 문제 자체가 사라집니다.

 


[4] 장애가 발생했을 때

Redis가 해결하는 것

RDB 스냅샷 + AOF 로그

Redis는 두 가지 영속성 옵션을 제공합니다.

RDB: 특정 시점의 메모리 전체를 디스크에 덤프 (fork + COW)
AOF: 모든 쓰기 명령을 로그로 기록 (append-only)

         RDB                    AOF
   ┌──────────────┐      ┌──────────────┐
   │ 주기적 스냅샷   │      │ 모든 쓰기 기록  │
   │ 복구 빠름      │      │ 데이터 손실 최소 │
   │ 데이터 손실 가능 │      │ 파일 크기 큼    │
   └──────────────┘      └──────────────┘
 

Replication: Master-Replica 구조로 읽기 부하를 분산하고, Master 장애 시 Replica를 승격시킬 수 있습니다. 랭킹 조회(ZREVRANGE)는 Replica에서, 쓰기(ZADD)는 Master에서 처리하면 됩니다.

Redis가 해결하지 않는 것

1. Redis가 Source of Truth이면 복구가 어렵다

초기 구현처럼 Kafka Consumer가 직접 ZINCRBY로 Redis에 누적하면, Redis가 사실상 source of truth가 됩니다. Redis가 날아가면 어떻게 될까요?

  • AOF가 있으면 마지막 fsync 이후의 데이터만 손실
  • AOF가 없으면 마지막 RDB 스냅샷 이후의 모든 누적이 증발
  • Kafka offset을 되감아서 재처리? → 멱등성 보장이 또 필요

해결책: DB ledger를 원장으로 두면 Redis는 언제든 재구축할 수 있는 파생 뷰가 됩니다. ledger를 다시 sync하면 끝입니다.

 

2. 콜드 스타트

매일 자정에 새로운 일별 키가 시작되면 ZSET이 비어있습니다. 새벽 2시에 랭킹 API를 호출하면 빈 결과가 반환됩니다. Redis는 "데이터가 없으면 없다"고만 답할 뿐, 어제 데이터를 알아서 이월해주지 않습니다.

Redis에 ZUNIONSTORE 명령이 있어서 어제 키의 10%를 오늘 키로 복사할 수 있습니다.

ZUNIONSTORE ranking:day:20260411 1 ranking:day:20260410 WEIGHTS 0.1
 

하지만 합성 score 인코딩을 쓰면 이 명령이 깨집니다. (base + tsFraction) × 0.1 ≠ base×0.1 + tsFraction. 시각 정보까지 0.1배가 되어 의미가 망가집니다.

 

해결책: carry-over도 ledger 기반으로 처리합니다. 23:50에 오늘 ledger를 읽어서 basePoints × 0.1만큼 내일 ledger에 추가하고, 다음 날 첫 SyncScheduler 사이클에서 ZADD로 자연스럽게 반영됩니다.

23:50 → 오늘 ledger 읽기 → 내일 ledger에 10% 추가 (dirty=true)
                                      ↓
00:00 → SyncScheduler 첫 사이클 → ZADD ranking:day:20260411 → 빈 랭킹 방지

 

10%인 이유는 다음과 같습니다. 50%면 어제 순위가 오늘을 지배합니다. 1%면 새벽에 조회 1회(0.1점)에도 밀립니다. 10%가 오전 중 자연스럽게 오늘 점수가 주도하게 되는 수준입니다.

 

3. 분산 환경에서의 중복 실행

서버가 N대면 SyncScheduler와 CarryOverScheduler가 N번 동시에 돌 수 있습니다. SyncScheduler는 합성 score가 멱등적 ZADD라서 중복 실행돼도 결과가 같습니다. 하지만 CarryOverScheduler가 두 번 돌면 10%가 20%가 됩니다.

Redis가 제공하는 분산 락(SET key value NX EX ttl)으로 동시 실행을 차단할 수 있습니다.

Boolean acquired = redisTemplate.opsForValue()
    .setIfAbsent("ranking:lock:carry-over", uuid, 300, TimeUnit.SECONDS);

하지만 분산 락은 동시 실행만 막습니다. 락이 만료된 후 같은 작업이 다시 실행되면 어떻게 될까요? cron이 다음 날에 같은 날짜를 다시 처리하면요? carry-over가 두 번 누적됩니다.

 

해결책: DB unique 제약으로 이중 방어

CREATE TABLE ranking_carry_over_history (
    carry_over_date DATE NOT NULL UNIQUE
);

existsByCarryOverDate(today)로 99% 케이스를 차단하고, 동시에 가드를 통과한 race 케이스는 UNIQUE 충돌로 트랜잭션이 통째로 롤백됩니다. 분산 락(동시 실행 차단) + DB unique(재진입 멱등성), 이중 방어입니다.

 


결론

구간 Reedis가 해결 개발자 책임
[1] 점수 기록 싱글 스레드 원자성, ZINCRBY 누적 DB-Redis 원자성 → Ledger + ZADD 구조
[2] 명령 처리 Skip List O(log N), 인메모리, TTL score 스케일링 → log10, 합성 score 인코딩
[3] 랭킹 조회 ZREVRANGE/ZREVRANK, 마이크로초 응답 상품 정보 조합, score 노출 여부 판단
[4] 장애/복구 RDB/AOF, Replication Source of Truth 설계, 콜드 스타트, 멱등성

Kafka 때의 교훈과 같습니다. Redis를 "" 쓴다는 것은 "Redis가 해결하지 않는 것의 목록을 알고 있다"는 뜻입니다. ZSET은 O(log N) 정렬과 인메모리 속도를 공짜로 주지만, source of truth 설계, 합성 score 인코딩, 콜드 스타트 완화, 분산 환경의 멱등성은 전부 개발자의 몫입니다.

 

ZINCRBY 한 줄이면 될 줄 알았던 랭킹 시스템이 DB Ledger + ZADD + Tie-Break + Idempotent Carry-Over까지 발전한 건, 결국 Redis가 해결하지 않는 것들을 하나씩 메우는 과정이었습니다.

TL;DR
Redis 대기열은 ZSET 하나로 시작했는데, 고민 할 때마다 키가 늘어났습니다.. 최종적으로 4개

 

주문API 앞단에 대기열을 붙이는 과정에서, 처음에 생각한 과정은 아래와 같았습니다.

User -> ZADD waiting-queue -> Scheduler가 ZPOPMIN -> 토큰 발급 -> POST /orders

 

Sorted Set에 넣고, 앞에서부터 꺼내고, 토큰 주고, 주문하면 끝. Redis 하나면 되는 거 아닌가?

근데 설계 고민을 하나씩 하기 시작하면서, 이 단순한 그림에 구멍이 뚫리기 시작했습니다. 이 글은 그 구멍들을 하나씩 메워나가는 과정을 정리한 글입니다.

이 글은 아래 흐름을 따라갑니다.

1. score 하나 정하는 데 이렇게 깊어진다고?

2. "대기열에도 없고 토큰도 없는 유저"의 정체

3. 토큰 하나에 이렇게 많은 결정이

4. 스케줄러, 한 대만 돌려야 한다

5. 운영을 생각하면 또 다른 세계

6. 최종 설계와 회고


1. score 하나 정하는 데 이렇게 깊어진다고?

Q. ZADD waiting-queue {score} {userId} 에서 score를 뭘로 잡을 건가요?

 

선착순이니까 시간이 맞겠죠. 그래서 처음에는 나노초 기반 Instant를 쓰려고 했습니다.

같은 밀리초에 두 유저가 들어오면 나노초로 구분하면 되니까요.

근데 함정이 있었습니다. Redis Sorted Set의 score는 double(64비트 부동소수점)입니다.

유효 정밀도가 약 15~16자리인데, 나노초 epoch는 19자리입니다. double에 넣는 순간 뒷자리가 잘려서 서로 다른 나노초 값이 같은 score로 저장될 수 있습니다.

그럼 밀리초는? 밀리초는 13자리라 double 정밀도 안에 들어갑니다.

 

Q. "근데 같은 밀리초에 두 유저가 들어오면 순서가 뒤바뀔 수 있지 않나요? 선착순인데 불공정한 거 아닌가요?"

 

이 질문에 대한 답을 고민하다가, 대기열의 역할을 다시 생각하게 됐습니다. 대기열은 주문 API 앞단의 관문이지, 대기열 자체가 재고를 차감하는 게 아닙니다. 같은 밀리초에 진입한 두 유저가 512번, 513번으로 배정되든 반대로 배정되든, 어차피 둘 다 비슷한 시점에 주문 API에 진입합니다. 1ms 이내의 순서 역전이 유저에게 체감되는 불공정이 될 수 없습니다.(사용자는 어차피 실제 순서를 모름)

결론: score는 밀리초 단위 timestamp로 충분합니다.

 

여기서 하나 더. 유저가 대기 중에 새로고침을 누르면? ZADD는 기본적으로 이미 존재하는 member의 score를 덮어씁니다. 원래 100번째였던 유저가 새로고침 한 번에 맨 뒤로 밀릴 수 있습니다.

해결은 간단합니다. ZADD NX 옵션을 쓰면 member가 이미 존재할 때 score를 갱신하지 않습니다. 이미 대기열에 있는 유저가 다시 요청하면 에러 대신 기존 순번을 그대로 돌려주면 됩니다.

 


2. "대기열에도 없고 토큰도 없는 유저"의 정체

스케줄러가 ZPOPMIN으로 N명을 꺼내 입장 토큰을 발급합니다. ZPOPMIN은 꺼내는 순간 Sorted Set에서 해당 member를 삭제합니다.

 

유저는 2초마다 GET /queue/position을 polling하고 있는데, 대기열에서 빠졌으니 ZRANK가 null을 반환합니다. 그러면 토큰이 발급된 건지, 뭔가 잘못된 건지 유저는 알 수가 없습니다.

 

그래서 ZRANK가 null이면 entry-token:{userId} 키에서 토큰을 조회하는 로직을 추가했습니다. 토큰이 있으면 "입장 가능", 토큰이 없으면...?

여기서 진짜 문제가 터졌습니다. 대기열에도 없고 토큰도 없는 유저는 누구인가?

처음에는 "서버 에러거나 부정 접근이겠지"라고 생각했습니다. 근데 아닙니다. 토큰에는 TTL이 있거든요. 토큰을 발급받고 5분 동안 주문을 안 하면 TTL로 자동 만료됩니다. 이 경우에도 대기열에 없고 + 토큰도 없는 상태가 됩니다. 정상적인 시나리오인 겁니다.

가능한 상황이 최소 세 가지:

  1. 토큰 TTL 만료
  2. 주문 완료 후 토큰 삭제됨
  3. 애초에 대기열에 진입한 적 없음

이 셋을 구분하려면 "토큰이 발급된 적 있다"는 흔적이 필요합니다.

SET queue-status:{userId} TOKEN_ISSUED EX 420

토큰 발급 시 별도 키에 상태를 기록합니다. TTL은 토큰 TTL(5분)보다 약간 길게 7분으로 잡았습니다.

왜 토큰과 같은 TTL이면 안 되냐고요? 이 키의 존재 이유가 "토큰 만료 뒤에도 흔적을 남기는 것"인데, 토큰이랑 동시에 만료시키면 원래 문제가 그대로 남습니다.

 

polling 응답 로직은 이렇게 됩니다:

 

ZRANK → 순번 있음 → "N번째, 약 M분 소요"
     → null → GET entry-token → 토큰 있음 → "입장 가능"
                              → null → GET queue-status → TOKEN_ISSUED → "토큰이 만료되었습니다"
                                                        → null → "대기열에 없습니다. 진입해 주세요"

 

Redis 키 1개로 시작했는데, 이 문제 하나로 3개가 됐습니다.


3. 토큰 하나에 결정할게 이렇게 많다니요....

토큰을 가진 유저가 POST /orders로 주문 API에 진입합니다. 토큰 검증은 Interceptor에서 처리하기로 했습니다. @EntryTokenRequired 어노테이션 기반으로 다른 API에도 확장 가능하도록요.

여기서 핵심적인 결정이 두 개 있었습니다.

이중 주문 방지

토큰을 "검증 → 주문 처리 → 완료 후 삭제" 순서로 하면 주문 실패 시 토큰을 살릴 필요가 없어서 편합니다. 근데 문제가 있습니다. 토큰이 아직 살아있는 틈에 유저가 같은 토큰으로 POST /orders를 한 번 더 호출하면, Interceptor에서 토큰이 아직 있으니 통과 → 같은 주문이 두 번 처리될 수 있습니다.

그래서 검증과 동시에 삭제를 원자적으로 해야 합니다. Redis 6.2+의 GETDEL 명령어가 정확히 이 용도입니다. 값을 가져오면서 동시에 삭제합니다. 첫 번째 요청만 토큰을 받고, 이후 요청은 null을 받아 거부됩니다.

주문 실패 시에는? catch 블록에서 같은 키에 토큰을 다시 SET합니다. 재발급이 아니라 복원인 거죠. TTL은 5분으로 다시 주더라도, 주문 단계에서 실패한 유저라 금방 재시도하고 나갈 겁니다.

Thundering Herd

스케줄러가 한 번에 100명에게 토큰을 발급하면, 이 100명이 거의 동시에 주문 API를 호출합니다. 서버에 순간적으로 부하가 집중되는 Thundering Herd 현상입니다.

발급 시점 자체를 분산시키는 방법도 있지만, 그러면 스케줄러에 루프 + delay가 들어가면서 분산 락 점유 시간이 길어집니다.

더 나은 방법은 토큰에 activateAt을 분산 부여하는 겁니다. 100명에게 한 번에 발급하되, 각 토큰의 활성화 시각에 랜덤 Jitter를 넣습니다. Interceptor에서 activateAt 이전 요청은 거부하면 됩니다. 스케줄러는 빠르게 발급하고 락을 놓고, 주문 API 호출 시점이 자연스럽게 분산됩니다.

 


4. 스케줄러, 한 대만 돌려야 한다

서버가 3대면 스케줄러도 3개가 돕니다. 각각 N명씩 꺼내면 매 주기 3N명에게 토큰이 발급됩니다. TPS 175 기준인데 525명이 동시에 몰리는 거죠.

"3대면 175/3만큼씩 꺼내면 되지 않나요?" 서버가 5대로 늘거나 1대가 죽으면? 각 서버가 "지금 몇 대가 살아있는지"를 알아야 해서 복잡해집니다.

더 단순한 접근은 1대에서만 스케줄러를 실행하는 겁니다. 이걸 보장하는 방법이 분산 락입니다.

SET lock:scheduler NX EX {TTL}

NX로 키가 없을 때만 SET → 먼저 도착한 서버만 성공합니다. EX로 TTL을 주면 락을 잡은 서버가 죽었을 때 TTL 만료 후 자동으로 락이 해제됩니다. 다른 서버가 다음 주기에 락을 획득하면 별도 failover 로직 없이 자동 복구됩니다. 대기열 상태는 Redis에 있으니까 서버가 바뀌어도 데이터 유실이 없습니다.

여기서 락 TTL 설정이 미묘합니다. 스케줄러 주기가 100ms인데, 락 TTL도 100ms로 잡으면? 작업이 150ms 걸리는 경우 락이 먼저 만료돼서 다른 서버도 실행됩니다.

 

원칙: 락 TTL > 작업 최대 실행 시간.

 

빈 슬롯 보충도 깔끔하게 풀렸습니다. maxSlot(동시 주문 가능한 최대 인원)에서 현재 활성 토큰 수를 빼면, 그게 이번 주기에 발급할 수입니다. 토큰이 TTL로 만료되면 자연스럽게 활성 토큰 수가 줄어들고, 다음 주기에 빈 슬롯만큼 보충합니다.


 

5. 운영을 생각하면 또 다른 세계

설계의 뼈대가 잡힌 뒤에도 질문은 계속됐습니다.

대기열 최대 인원

무제한으로 받으면 안 됩니다. 유저가 기다릴 의사가 있는 시간으로 역산하면 됩니다.

최대 허용 대기 시간 × TPS = 최대 대기열 인원
예: 5분 × 175 TPS = 52,500명

ZADD 전에 ZCARD로 현재 인원을 확인하고, 초과하면 거부 응답을 내립니다.

어뷰징

같은 계정으로 여러 브라우저에서 진입하는 건 Sorted Set의 ZADD NX 특성상 이미 방어됩니다. userId가 member니까 중복 저장이 안 되거든요.

다중 계정 어뷰징은 완벽한 방어가 불가능합니다. IP 제한은 VPN으로 우회 가능하고, 디바이스 fingerprint는 시크릿 모드로 무력화됩니다. 100% 방어보다는 비용 대비 효과적인 억제 전략을 선택하는 게 현실적입니다.

Polling 부하

대기 인원 1만 명에 polling 주기 2초면 초당 5,000건의 요청이 발생합니다. SSE로 바꾸면 polling 자체는 없어지지만 1만 개의 커넥션을 유지해야 합니다.

polling을 유지하면서 부하를 줄이는 방법도 있습니다. 대기 순번이 10,000번인 사람과 10번인 사람이 같은 주기로 polling할 필요가 없으니까요. 서버가 polling 응답에 retryAfter를 함께 내려주면, 순번이 뒤일수록 긴 주기, 앞일수록 짧은 주기로 동적 조절이 가능합니다.

 


6. 최종 설계와 회고

Redis 키 구조

타입 용도 TTL
waiting-queue Sorted Set 대기열 (score=timestamp, member=userId) 없음
entry-token:{userId} String 입장 토큰 + activateAt 5분
queue-status:{userId} String 토큰 발급 이력 (TOKEN_ISSUED) 7분
lock:scheduler String 스케줄러 분산 락 100ms

API

API 설명
POST /queue/enter 대기열 진입. ZADD NX + ZRANK
GET /queue/position 순번 조회. ZRANK → 토큰 확인 → 상태 확인
POST /orders 주문. Interceptor에서 GETDEL로 토큰 검증

설정값

항목 근거
스케줄러 주기 100ms Thundering Herd 완화
배치 크기 ~18명/100ms TPS 175 기준 (p99)
토큰 TTL 5분 유저 행동 시간 고려
상태 기록 TTL 7분 토큰 TTL + α
Polling 주기 2초 서버 부하와 응답성 균형

회고

솔직히 대기열 설계를 시작할 때는 "Sorted Set에 넣고 빼면 끝이지 뭐가 어렵겠어"라고 생각했습니다.

근데 "score를 뭘로 잡을 건가요?" 라는 첫 질문부터 double 정밀도라는 생각지 못한 제약에 부딪혔고, "새로고침하면?" 이라는 단순한 질문이 ZADD NX라는 설계 결정으로 이어졌고, "대기열에도 없고 토큰도 없으면?" 이라는 질문이 queue-status라는 새로운 키를 탄생시켰습니다.

 

Redis 키 1개로 시작한 시스템이 4개가 된 건, 제가 처음부터 복잡하게 설계한 게 아닙니다. 질문 하나가 엣지 케이스 하나를 드러냈고, 그 엣지 케이스가 새로운 설계 결정을 요구한 것입니다.

분산 락이라는 개념도 이번에 처음 접했습니다. "스케줄러를 1대에서만 돌리고 싶은데 어떻게 하죠?" 라고 물었을 때 "요청을 서버 한 대로만 흘리면 되지 않나요?" 라고 답했던 게 좀 부끄럽습니다. 스케줄러는 유저 요청이 아니라 서버 내부에서 자동 실행되는 작업이라 요청 라우팅과는 다른 문제였거든요.

 

그리고 토큰 삭제 시점의 결정이 가장 인상 깊었습니다. "검증 후 바로 삭제 → 주문 처리"와 "검증 → 주문 처리 → 완료 후 삭제"는 순서 하나의 차이인데, 전자는 주문 실패 시 유저 구제 문제가 생기고, 후자는 이중 주문 문제가 생깁니다. 어느 쪽을 택하든 새로운 위험이 따라오고, 그 위험을 어떻게 메울 것인가가 설계의 본질이었습니다.

결국 GETDEL로 원자적 검증+삭제를 하고, 실패 시 토큰을 복원하는 방향으로 결론이 났는데, 이건 두 가지 선택지의 단점을 모두 메우는 조합이었습니다.

 

단순해 보이는 시스템일수록 엣지 케이스가 설계를 만든다. 큰 거 하나 배웠다...ㅜ

TL;DR
Kafka는 '전달'을 해결하지, 'exactly once'는 개발자 몫이다.

 

옛날 개발자들은 완전히 멘탈이 나가버렸습니다.

시스템끼리 직접 연결해야 했거든요. A -> B, B -> C ,B -> D, A->E , ....시스템이 하나 늘 때마다 연동 파이프라인이 폭발적으로 늘어났습니다.

 

5개 시스템이면 연동이 최대 20개, 10개면 90개.... 아키텍처 다이어그램은 거미줄이 되어버렸죠.

 

그래서 똑똑한 개발자들이 기발한 생각을 했는데요, 메시지 큐, 그러니까 시스템 사이에 우체통을 하나 놓자는 겁니다. 보내는 쪽은 큐에 넣고, 받는 쪽은 큐에서 꺼내면 되니까요. Rabbit MQ같은 전통적인 MQ가 이렇게 탄생했습니다.

 

그런데 진짜 문제는 여기서부터였습니다. 전통 MQ는 컨슈머가 메시지를 꺼내가면 그 메시지가 사라져버렸거든요. 장애가 터져서 "아까 그 메시지 다시 처리해야 하는데?" 하면, 이미 큐에서 사라진 뒤였습니다. 재처리를 하려면 로그를 다시 만들거나 DB를 뒤져야 했고, 시스템마다 재처리 방식도 제각각이였죠.

 

바로 그 순간, LinkedIn의 천재 엔지니어들이 미친 아이디어를 냈습니다. "메지시를 전달하는게 아니라, 로그에 쭉 쌓아놓고 읽게 하면 되잖아?" 이게 바로 Kafka의 탄생이었습니다.

 

Kafka의 본질은 Distributed Commit Log입니다. 프로듀서가 로그에 쓰면 브로커가 보관하고, 컨슈머는 메시지를 전달받는 게 아니라 로그를 직접 읽습니다. append-only라 뒤에 계속 쌓이기만 하고, 오프셋 기반으로 읽으니까 장애가 터져도 오프셋만 되돌리면 재처리가 가능해진 겁니다.

그런데 심각한 문제가 또 터졌습니다. 파티션 설계, 컨슈머 그룹 리밸런싱, 오프셋 커밋 전략... 개발자들의 멘탈은 또다시 완전히 나가버렸습니다.


 

Producer가 카프카에 메시지를 전달하고, Consumer가 읽기까지의 과정을 따라가며 Kafka가 해결해주는 것과 여전히 남아있는 것에 대해 살펴볼 예정입니다.

 

이번 글에서는 카프카를 실무에서 잘 사용하는 방법이나 설정값과 같은 실용적인 내용을 다루지 않습니다..

순수 호기심에서 출발했기 때문에 지엽적인 내용, 몰라도 Kafka 사용에 지장 없는 내용일수 있습니다.

 

목차는 아래와 같습니다.

 

1. 프로듀서가 send() 호출

2. 브로커에 도착해서 디스크에 기록

3. 컨슈머가 "offset 42를 읽고 싶다"

4. 컨슈머가 메지지를 받아서 처리

 


[1] 프로듀서가 send() 호출

Kafka가 해결하는 것

kafkaTemplate.send()를 호출하면 메시지가 즉시 네트워크로 나가는게 아닙니다. 내부적으로 여러 단계를 거치게 됩니다.

 

  • Serialization - 객체를 byte[]로 변환합니다. key-value각각에 대해 Serializer가 동작합니다. Json이면 JsonSerializer, Avro면 KafkaAvroSrializer등이 여기서 작동합니다.
  • Partitioning - 어떤 파티션에 보낼지 결정합니다. key가 있으면 hash(key) % 파티션 수, key가 null이면 Sticky Partitioner가 동작합니다. Sticky Partitioner는 Kafka 2.4부터 도입된 건데, 과거의 라운드로빈과 달리 하나의 배치가 채워질 때까지 같은 파티션에 계속 보내서 배치 효율을 높입니다.
  • RecordAccumulator에 적재 — 메시지가 바로 전송되지 않고, 파티션별 버퍼(Deque)에 쌓입니다. 여기서 batch.size(기본 16KB)만큼 모이거나 linger.ms(기본 0ms) 시간이 지나면 하나의 배치로 묶이게 됩니다.
send() 호출
  │
  ├─ Serializer: OrderEvent → byte[]
  ├─ Partitioner: hash("order-1001") % 3 = 1
  ├─ RecordAccumulator: Partition 1의 버퍼에 적재
  │   ├─ batch.size(16KB) 채워지면 전송 트리거
  │   └─ linger.ms(5ms) 지나면 미완성 배치도 전송
  │
  └─ Sender 스레드 (별도 스레드):
      ├─ 배치를 파티션의 leader 브로커로 전송
      ├─ max.in.flight.requests.per.connection(5)만큼
      │   응답 안 받고도 연속 전송 가능
      └─ 실패 시 retries 설정만큼 재시도
linger.ms=0이면 메시지가 들어오자마자 바로 전송하니까 지연은 최소지만 배치 효율이 낮습니다.
linger.ms=5로 설정하면 5ms 기다리면서 메시지를 모아 배치로 보내서 네트워크 왕복이 줄어듭니다.
5ms의 지연을 감수하고 처리량을 올리는 트레이드오프라고 볼 수 있겠습니다.

 

  • compression — 배치 단위로 압축이 일어납니다. compression.type=lz4로 설정하면 배치가 완성된 후 압축해서 네트워크 대역폭과 디스크 사용량을 줄여줍니다. 압축은 개별 메시지가 아니라 배치 단위라서 압축률이 높습니다. 비슷한 구조의 메시지가 배치 안에 여러 개 있으니까요.
  • idempotent producer — enable.idempotence=true이면 프로듀서가 각 메시지에 PID(Producer ID) + sequence number를 붙입니다. 브로커가 이걸 보고 "이 메시지는 이미 받았다"를 판단해서, 네트워크 타임아웃으로 재전송이 일어나도 중복 저장을 방지해줍니다.

Kafka가 해결하지 않는 것

프로듀서 애플리케이션 자체가 죽는 경우. send()를 호출했는데 RecordAccumulator에 적재된 상태에서, 아직 Sender 스레드가 전송하기 전에 JVM이 죽으면 그 메시지는 사라집니다. Kafka에 도달하지도 않았으니까요.

 

그리고, 비즈니스 로직(DB 저장)은 성공했는데 send() 호출 직전에 죽는 경우입니다. DB에는 주문이 있는데 Kafka에는 이벤트가 없는 상태가 됩니다.

@Transactional
public void createOrder(OrderRequest req) {
    orderRepository.save(order);          // 성공 → DB에 주문 있음
    // ← 여기서 JVM 죽으면?
    kafkaTemplate.send("order-events", ...); // 실행 안 됨 → Kafka에 이벤트 없음
}

 

이걸 해결하는 게 Transactional Outbox 패턴입니다. 비즈니스 데이터와 발행할 이벤트를 같은 DB 트랜잭션으로 묶으면, DB 커밋이 됐으면 이벤트도 반드시 존재하고, 롤백됐으면 둘 다 없습니다. 이벤트 발사대같은 역할을 하게 되는겁니다.

@Transactional
public void createOrder(OrderRequest req) {
    orderRepository.save(order);
    outboxRepository.save(new OutboxEvent("order-events", ...));
    // → 같은 트랜잭션. 둘 다 성공하거나 둘 다 실패
}

 

Kafka의 Transactional Producer(transaction API)로도 해결 가능하지 않나? 라는 의문이 들 수도 있습니다.

Kafka의 Transaction은 "Kafka 내부에서의 원자성" (여러 토픽/파티션에 대한 all or nothing)을 보장하지, "외부 DB와 Kafka사이의 원자성" 은 보장하지 않습니다. DB 커밋과 Kafka커밋은 서로 다른 시스템이니까, outbox같은 발사대가 필요한겁니다.

 

💡고민해볼 것
linger.ms와 batch.size의 최적값은 트래픽 패턴에 따라 다르다. 초당 수만 건이면 linger.ms=0 으로도 배치가 자연스럽게 차는데, 초당 수십 건이면 linger.ms를 올려야 배치 효율이 나온다. 자신의 시스템 트래픽 패턴에서 최적값을 어떻게 찾을 것인가?

 


[2] 브로커에 도착해서 디스크에 기록

Kafka가 해결하는 것

메시지가 브로커에 도착하면 해당 파티션의 leader가 처리합니다.

 

Append-only 쓰기 - 절대 중간에 끼워넣거나 수정하지 않고, 항상 active segment 끝에 추가합니다.

디스크의 sequential write는 랜덤 write보다 수백 배 빠르기 때문입니다.

 

실제 코드에서 벌어지는 일

브로커가 메시지를 받으면:
│
├─ 1) LogSegments.activeSegment()
│     → ConcurrentSkipListMap의 lastEntry() = 현재 쓰기 중인 세그먼트
│
├─ 2) LogSegment.shouldRoll() 체크
│     → 크기 > log.segment.bytes(1GB)? 
│     → 시간 > log.roll.ms(7일)?
│     → index 꽉 찼는가?
│     → 하나라도 true면 새 세그먼트 생성 후 전환
│
├─ 3) LogSegment.append()
│     ├─ FileRecords.append(records)
│     │   → .log 파일 끝에 바이너리 write
│     │   → java.nio.channels.FileChannel.write()
│     │   → OS가 page cache에 먼저 기록, 비동기로 디스크 flush
│     │
│     └─ bytesSinceLastIndexEntry > index.interval.bytes(4KB)?
│         ├─ Yes: OffsetIndex.append(offset, position)
│         │       TimeIndex.maybeAppend(timestamp, offset)
│         │       bytesSinceLastIndexEntry = 0
│         └─ No: bytesSinceLastIndexEntry += appendedBytes
│
├─ 4) ISR follower들이 leader에게 Fetch 요청
│     → leader의 .log 파일에서 읽어서 follower에게 전달
│     → follower도 자기 로컬에 같은 과정으로 append
│
└─ 5) acks 설정에 따라 프로듀서에게 응답
      ├─ acks=0: 기다리지 않고 즉시 다음
      ├─ acks=1: leader append 완료 시 OK
      └─ acks=all: ISR 전원 복제 완료 시 OK

 

 

 

Kafka가 해결하지 않는 것

  • leader가 메시지를 저장하기 전에 브로커가 죽으면 - acks설정과 무관하게 유실입니다. 프로듀서는 timeout이나 connection error를 받고, retries 설정에 따라 재전송을 시도합니다. 하지만 프로듀서 자체도 죽을 수 있으니 다시 Outbox패턴으로 돌아와야 합니다.
  • 디스크 수준의 데이터 손상 - Kafka는 레코드에 CRC 체크섬을 기록해서 무결성을 검증하지만, 디스크 자체가 고장나면 해당 브로커의 데이터는 손실됩니다. 이걸 ISR복제로 방어하는겁니다. replication-factor = 3이면 3대의 서로 다른 물리 디스크에 복사본이 있으니, 1대가 죽어도 데이터는 살아있게 됩니다.
  • Page cache flush 지연 - vm.dirty_ratio에 도달하면 커널이 dirty page를 강제로 flush하면서 모든 쓰기가 블로킹 될 수 있습니다. 근데 이건 Kafka문제가 아니라 OS 수준의 문제입니다.

[3] 컨슈머가 "offset 42를 읽고 싶다"

Kafka가 해결하는 것

컨슈머가 poll() 을 호출하면 내부적으로 Fetch 요청이 브로커로 가고, 브로커에서 2단계 탐색이 일어납니다.

 

1단계 : 어떤 세그먼트 파일인가?

LogSegments가 ConcurrentSkipListMap<Long, LogSegment> 로 세그먼트 목록을 관리합니다. floorEntry(42) 한 번에 offset 42가 속한 세그먼트를 O(logN)에 찾을 수 있습니다.

O(logN) 에서 N은 세그먼트 수입니다. 1GB 세그먼트에 retention이 7일이면 일반적으로 수십 개 수준이라 사실상 즉시 찾습니다.

ConcurrentSkipListMap을 사용하는 이유는 baseOffset으로 정렬된 상태에서 floorEntry()가 가능하고, 읽기/쓰기 동시 처리가 lock-free 수준이며, TreeMap보다 concurrent 환경에서 성능이 좋기 때문입니다.

 

 

2단계 : .log 파일의 몇 번째 바이트인가?

이걸 혼자 생각해봤을때 "당연히 이분탐색으로 찾지 않으려나" 라고 생각했습니다. 하지만 Kafka 개발진들은 좀 더 똑똑한 방식으로 이분탐색을 수행하도록 설계했습니다.

 

찾은 세그먼트의 OffsetIndex(.index파일)에서 이분탐색을 수행합니다.

.index 내용 (sparse):
  offset 0  → position 0
  offset 32 → position 4096
  offset 65 → position 8192
  offset 97 → position 12288

 

lookup(42)를 실행하면

-> binarySearch 실행

-> offset 32가 42보다 작거나 같은 수 중 가장 큰 수

-> offset 32부터 33, 34, 35, 36, ... 42까지 가면서 찾기 성공

 

이 이분탐색이 AbstractIndex.indexSlotRangeFor()에 구현되어 있어요. 핵심은 mmap 위에서 동작한다는 것과, warm/cold 영역 분리가 적용된다는 것입니다.

mmap의 역할 : .index 파일을 MappedByteBuffer로 메모리에 매핑하면, parseEntry(idx, mid)가 호출 될 때 파일 I/O 시스템콜 없이 메모리 접근으로 엔트리를 읽습니다. mmap자체는 "파일을 배열처럼 접근 가능하게 해주는것"일뿐, 탐색 알고리즘과는 무관합니다.

 

 

warm/cold 영역 분리 이유와 원리

https://github.com/apache/kafka/blob/trunk/storage/src/main/java/org/apache/kafka/storage/internals/log/AbstractIndex.java

 

kafka/storage/src/main/java/org/apache/kafka/storage/internals/log/AbstractIndex.java at trunk · apache/kafka

Apache Kafka - A distributed event streaming platform - apache/kafka

github.com

 

실제 코드를 확인해보자.

 

        // check if the target offset is in the warm section of the index
        if (compareIndexEntry(parseEntry(idx, firstHotEntry), target, searchEntity) < 0) {
            return binarySearch(idx, target, searchEntity,
                searchResultType, firstHotEntry, entries - 1);
        }

502 ~ 506라인을 보면 warm영역에 찾고자 하는 부분이 있는지 판단후 warm영역에서 탐색을 먼저 수행하는 것을 볼 수 있다. 

그리고 8192라는(8KB)숫자를 사용하고 이것을 엔트리 크기로 나눠서 이용을 하는데, 이와 관련된 내용은 

 

 

주석에서 너무너무너무너무너무 잘 설명해주고 있기 때문에 확인해보시길 바랍니다.

번역

더보기

Kafka 인덱스의 캐시 친화적 탐색 알고리즘

Kafka는 인덱스 파일을 메모리에 mmap하여, 인덱스의 모든 읽기/쓰기 연산이 OS 페이지 캐시를 통해 이루어집니다. 덕분에 대부분의 경우 디스크 I/O 블로킹을 피할 수 있습니다.


배경: LRU와 Kafka의 궁합

현대 운영체제는 페이지 캐시 관리에 LRU(Least Recently Used) 정책 또는 그 변형을 사용합니다. Kafka는 항상 인덱스 파일의 끝에 추가(append)하고, 대부분의 인덱스 조회(동기화 팔로워나 컨슈머)도 인덱스 끝부분에서 일어나기 때문에, LRU 정책과 Kafka의 접근 패턴은 아주 잘 맞습니다.


문제: 이진 탐색의 페이지 캐시 비친화성

그런데 표준 이진 탐색(binary search) 은 캐시 친화적이지 않아서, 불필요한 페이지 폴트(해당 인덱스 항목이 페이지 캐시에 없어 디스크에서 읽느라 스레드가 블로킹되는 현상)를 유발할 수 있습니다.

예를 들어, 13페이지짜리 인덱스에서 마지막 페이지(#12)의 항목을 찾을 때, 표준 이진 탐색은 다음 페이지들을 읽습니다:

 
 
페이지 번호: |0|1|2|3|4|5|6|7|8|9|10|11|12 |
탐색 순서:   |1| | | | | |3| | |4|  |5 |2/6|

각 페이지에는 수백 개의 로그 항목이 있고, 이는 수백~수천 개의 Kafka 메시지에 대응합니다. 인덱스가 12페이지 내에서 성장하는 동안에는 페이지 #0, 6, 9, 11, 12가 매 조회마다 사용되므로 캐시에 잘 남아 있습니다.

그런데 인덱스가 13페이지로 넘어가는 순간, 조회에 필요한 페이지가 #0, 7, 10, 12, 13으로 바뀝니다:

 
 
페이지 번호: |0|1|2|3|4|5|6|7|8|9|10|11|12|13 |
탐색 순서:   |1| | | | | | |3| | |4 |5 |6 |2/7|

페이지 #7과 #10은 오랫동안 사용되지 않았기 때문에 페이지 캐시에서 이미 밀려났을 가능성이 높습니다. 13페이지 첫 항목이 추가된 직후의 첫 조회에서 이 페이지들을 디스크에서 읽어야 하며, 이는 1초 이상 걸릴 수 있습니다. 실제 테스트에서 at-least-once 프로듀스 지연이 수 ms에서 약 1초로 급등하는 현상이 관찰되었습니다.


해결책: "웜 섹션" 탐색

이 문제를 해결하기 위해, 더 캐시 친화적인 탐색 알고리즘을 사용합니다:

 
 
if (target > indexEntry[end - N])    // 대상이 인덱스 마지막 N개 항목 안에 있으면
    binarySearch(end - N, end)       //   → 마지막 N개 안에서만 이진 탐색
else
    binarySearch(begin, end - N)     //   → 그 앞쪽에서 이진 탐색

가능하면 인덱스의 마지막 N개 항목 안에서만 탐색합니다. 적절한 N값을 선택하면, 동기화 조회는 거의 항상 첫 번째 분기로 들어갑니다. 이 마지막 N개 항목을 "웜(warm) 섹션" 이라고 부르며, 이 작은 영역을 자주 조회하므로 해당 페이지들이 페이지 캐시에 남아 있을 확률이 높아집니다.


N = 8192인 이유

N(_warmEntries)을 8192로 설정한 이유는 두 가지입니다:

  1. 충분히 작아서, 매 웜 섹션 조회 시 해당 섹션의 모든 페이지가 반드시 접촉(touch)됩니다. 웜 섹션 조회 시 항상 접촉되는 3개 항목은 indexEntry(end), indexEntry(end-N), indexEntry((end*2-N)/2)입니다. 페이지 크기가 4096바이트 이상이면 웜 섹션의 페이지(3개 이하)가 모두 접촉됩니다. 2018년 기준으로 모든 프로세서(x86-32, x86-64, MIPS, SPARC, Power, ARM 등)의 최소 페이지 크기는 4096바이트입니다.
  2. 충분히 커서, 대부분의 동기화 조회가 웜 섹션 안에서 이루어집니다. 기본 Kafka 설정에서 8KB 인덱스는 약 4MB(오프셋 인덱스) 또는 2.7MB(타임 인덱스)의 로그 메시지에 해당합니다.

N을 8192보다 크게 설정할 수는 없는데, 일반적인 4KB 페이지 호스트에서 웜 섹션의 모든 페이지가 실제로 "따뜻하게"(매 조회마다 접촉) 유지된다고 보장할 간단한 방법이 없기 때문입니다.


향후 개선 방향

백그라운드 스레드를 사용하여 웜 섹션 전체를 주기적으로 접촉(touch)하는 방식을 고려하고 있으며, 이를 통해 더 큰 웜 섹션을 지원하고, QPS가 낮은 토픽-파티션의 웜 섹션도 실제로 "따뜻하게" 유지할 수 있을 것입니다.

좀 더 캐시 친화적으로 빠르게 돌아가도록 했다는 내용입니다.

 

3단계: 메시지를 네트워크로 전송

위치를 찾으면 FileRecords가 FileChannel.transferTo() 를 호출합니다. 이게 리눅스의 sendfile() 시스템콜로 변환됩니다.

일반 전송은 디스크 -> 커널 버퍼 -> JVM 힙 -> 소켓 버퍼 -> NIC 까지 4번 복사하고, 2번 Context Switch가 일어나지만

카프카는 디스크 -> 커널 버퍼(page cahce) -> NIC로 복사 자체를 하지 않습니다. JVM을 타지도 않습니다.

 

이게 가능한 이유는, Kafka 브로커는 메시지 내용을 해석하지 않습니다. 프로듀서가 보낸 byte[]를 그대로 컨슈머에게 넘깁니다. 중간에 가공이 없으니까 JVM 힙으로 올릴 이유가 없는겁니다.

 

이 과정이 "kafka가 디스크 IO임에도 빠른 이유" 라고 검색했을때 나오는 Page cache, zero copy에 해당하는 내용입니다.

 

Kafka가 해결하지 않는 것

 

  • Page Cache miss시 성능 저하 - 컨슈머 lag이 크게 벌어져 한참 전 데이터를 요청한다면, 그 데이터는 page cache에 없어서 디스크에서 직접 읽어야 합니다. 이때 실시간 컨슈머까지 느려질 수 있습니다. 하나의 느린 컨슈머가 page cache를 오염시키기 때문입니다. 이 문제를 완화하려면 lag가 큰 컨슈머(: 분석용 배치 컨슈머)는 별도 브로커 그룹이나 follower replica에서 읽게 하는 방법이 있습니다. Kafka 2.4부터 replica.selector.class를 설정하면 follower에서 읽기가 가능
  • 파티션 간 순서는 보장하지 않는다 - offset 기반 순서 보장은 파티션 내에서만 동작합니다. 서로 다른 파티션에 들어간 메시지의 순서는 Kafka가 보장하지 않아요. 파티션 키 설계로 "비즈니스적으로 의미 있는 단위"의 순서만 지키는 것입니다.

[4] 컨슈머가 메시지를 받아서 처리

Kafka가 해결하는 것

  • 오프셋 관리 - 각 컨슈머 그룹이 각 파티션에 대해 "여기까지 읽었다"를 독립적으로 기록합니다. 이 offset은 Kafka 내부 토픽(`__consumer_offsets`)에 저장돼요. 컨슈머가 재시작되면 마지막 커밋된 offset부터 다시 읽습니다.
  • 되감기 - offset을 특정 시점으로 되돌리면 과거 메시지를 재처리할 수 있습니다. 이게 가능한 이유는 메시지가 소비 후에도 삭제되지 않고 retention 기간까지 남아있기 때문입니다.
  • 리밸런싱 - 컨슈머가 추가되거나 죽으면 Kafka가 파티션을 자동 분배합니다. 컨슈머 3개 중 1대가 죽으면, 그 컨슈머가 담당하던 파티션을 나머지 2대가 나눠 가집니다. 리밸런싱이 일어나는 조건은 3가지가 있습니다.
    • 컨슈머가 그룹에 참가하거나 떠날 때
    • session.timeout.ms 안에 하트비트가 안 올 때
    • max.poll.interval.ms 안에 poll() 호출이 되지 않을때 
      • max.poll.intervals.ms가 중요한 이유는 메시지 하나를 처리하는 데 시간이 오래걸려서 poll()이 늦어지면, Kafka는 이 컨슈머가 죽은 거라고 판단하고 리밸런싱을 트리거합니다. 그러면 해당 파티션이 다른 컨슈머에게 넘어가고, 원래 컨슈머가 처리를 마치고 커밋하려고 하면 CommitFailedException이 터지게 됩니다.

Kafka가 해결하지 않는 것

  • 중복 처리 방지(멱등성) - 되감기를 하든, 리밸런싱이 발생하든, 커밋 전에 컨슈머가 죽든, "같은 메시지를 두 번 받는 상황"은 구조적으로 발생합니다. Kafka는 "at-least-once delivery"를 보장하지, "exactly-once processing"은 보장하지 않습니다. 컨슈머가 구현해야 합니다.

멱등성 구현 방식에도 수준이 있습니다.

 

가장 단순한 방식 - eventId 기반 중복 체크:

@KafkaListener(topics = "order-events")
public void onMessage(OrderEvent event, Acknowledgment ack) {
    if (processedEventRepository.existsById(event.getEventId())) {
        ack.acknowledge();
        return;
    }
    processOrder(event);
    processedEventRepository.save(event.getEventId());
    ack.acknowledge();
}

하지만 이것도 완벽하지는 않습니다. processOrder() 와 processEventRepository.save() 사이에 죽으면 처리는 됐는데 기록이 안돼서 다음에 또 처리합니다. 이걸 완벽하게 하려면 비즈니스 로직과 processed_event 저장을 같은 DB 트랜잭션으로 묶어야 합니다.

@Transactional
@KafkaListener(topics = "order-events")
public void onMessage(OrderEvent event, Acknowledgment ack) {
    if (processedEventRepository.existsById(event.getEventId())) {
        ack.acknowledge();
        return;
    }
    processOrder(event);                              // 비즈니스 로직
    processedEventRepository.save(event.getEventId()); // 같은 트랜잭션
    ack.acknowledge();  // 트랜잭션 커밋 후 offset 커밋
}

 

더 자연스러운 방식 - 비즈니스 로직 자체를 멱등하게

// INSERT 대신 UPSERT
// "주문 상태를 PAID로 변경"은 두 번 실행해도 결과가 같다
UPDATE orders SET status = 'PAID' WHERE id = :orderId AND status = 'PENDING';
// → affected rows = 0이면 이미 처리된 것, 스킵

이렇게 하면 별도의 processed_event 테이블이 필요가 없습니다. 비즈니스 로직이 자연스럽게 멱등한 경우에 적합합니다. 하지만 "포인트 100 차감"처럼 본질적으로 멱등하지 않은 연산은 eventId 체크가 필수입니다.

 

  • 커밋 전략 선택 -  auto commit을 쓸지, 수동 커밋을 쓸지, 배치 커밋을 쓸지는 Kafka가 정해주지 않습니다. 데이터의 중요도에 따라 개발자가 판단해야 합니다.
  • DLQ 처리 - 파싱 불가능한 메시지가 들어왔을 때 컨슈머가 무한 실패하지 않도록 하는 것도 개발자 몫입니다. Spring Kafka@RetryableTopic을 쓰면 N번 실패 후 DLQ 토픽으로 보낼 수 있지만, DLQ에 빠진 메시지를 나중에 어떻게 재처리할지는 직접 설계해야 합니다.
정상 흐름: order-events → consumer → 처리 성공
실패 흐름: order-events → consumer → 3번 실패
→ order-events-retry-0 (1초 후 재시도)
→ order-events-retry-1 (2초 후 재시도)
→ order-events-retry-2 (4초 후 재시도)
→ order-events.DLQ (최종 실패, 수동 확인 필요)

 


4개 구간을 따라오면서 하나의 패턴이 보였을 겁니다.

[1] send() → Kafka가 배치, 압축, 파티셔닝을 해결 → 프로듀서가 죽으면? 개발자 몫

[2] 디스크 기록 → Kafka가 append-only, page cache, ISR을 해결 → 브로커가 죽으면? 개발자 몫 (acks + replication 설계)

 

[3] offset 42 읽기 → Kafka가 2단계 탐색, zero-copy를 해결 → lag으로 page cache miss? 개발자 몫 (모니터링)

[4] 메시지 처리 → Kafka가 offset 관리, 되감기를 해결 → 중복 처리? 개발자 몫 (멱등성) Kafka는 "A에서 B로 메시지가 전달되는 것"을 놀라울 정도로 잘 해결합니다. 하지만 "정확히 한 번 처리"는 Kafka의 영역 밖입니다. Outbox, 멱등성, 커밋 전략, DLQ...

 

국 Kafka를 "잘" 쓴다는 건, Kafka가 해결하지 않는 것의 목록을 알고 있다는 뜻일지도 모릅니다.

+ Recent posts