RedisTemplate vs Lettuce, Kafka StickyPartitioner 버그 — 랭킹 시스템 개선 중 만난 두 가지 이슈
랭킹 시스템 개선 중 Kafka 메시지가 특정 파티션에만 몰리는 현상과, Redis 100만 건 처리에 4분이 걸리던 문제를 Datadog으로 발견하고 해결한 과정을 기록합니다.
참고: 이 글의 코드는 실제 업무에서 경험한 내용을 바탕으로 개념적으로 재작성한 것입니다. 실제 회사 코드와는 무관합니다.
개요: 두 가지 이슈의 해결 흐름
들어가며
레거시 광고 랭킹 시스템을 Spring Webflux로 재설계하는 과정에서, 예상치 못한 두 가지 이슈를 마주쳤습니다. 하나는 Kafka 메시지가 특정 파티션에만 집중되는 현상이었고, 다른 하나는 Redis 대량 처리 속도가 기대치의 4분의 1밖에 나오지 않는 문제였습니다.
두 이슈 모두 표면적으로는 "성능 저하"처럼 보였지만, 원인을 파고들면 아주 구체적인 버그와 라이브러리 내부 동작의 문제였습니다.
이슈 1: Kafka 메시지가 특정 파티션에만 몰린다
현상 발견
랭킹 갱신 트리거를 Kafka로 처리하도록 전환한 이후, 모니터링을 통해 이상한 패턴을 발견했습니다.
Partition 0: ████████████████████ 48%
Partition 1: ██ 4%
Partition 2: ████████████████████ 46%
Partition 3: ▏ 2%
총 4개의 파티션 중 파티션 0과 2에만 메시지가 집중되고, 나머지 파티션은 거의 비어 있었습니다. 파티션이 불균형하게 사용되면 특정 컨슈머에만 부하가 집중되고, 처리 지연이 발생합니다.
원인 분석: StickyPartitioner 버그
처음에는 파티션 키 설정 문제라고 생각했습니다. 하지만 파티션 키를 명시적으로 지정하지 않은 경우에도 동일한 현상이 반복됐습니다.
Kafka 클라이언트 코드를 파고들면서 원인을 찾았습니다.
Kafka 2.4 이후 기본 파티셔너 변경
Kafka 2.4부터 기본 파티셔너가 RoundRobinPartitioner에서 StickyPartitioner로 변경됐습니다. StickyPartitioner는 배치 효율을 높이기 위해 배치가 채워질 때까지 같은 파티션에 계속 메시지를 보내는 방식입니다.
그런데 Kafka Client 3.3 미만 버전의 StickyPartitioner에는 버그가 있었습니다. 낮은 linger.ms 설정과 특정 브로커 지연 조건이 겹칠 때, 파티션 선택 로직이 짝수 파티션에만 집중되는 문제가 발생합니다.
게다가 RoundRobinPartitioner 역시 Kafka 2.4 이상에서 partition() 메서드가 두 번씩 호출되는 버그가 있어 짝/홀수 파티션에만 번갈아 분배되는 현상이 나타났습니다.
저희가 사용하던 버전은 3.3 미만이었고, linger.ms가 낮게 설정되어 있었습니다. 정확히 버그가 발생하는 조건이었습니다.
해결
해결책은 비교적 간단했습니다. Kafka Client 버전을 3.3 이상으로 업그레이드했습니다.
<!-- 기존 -->
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.2.x</version> <!-- 버그 있는 버전 -->
</dependency>
<!-- 업그레이드 -->
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.4.0</version> <!-- 버그 수정된 버전 -->
</dependency>
3.3부터는 StickyPartitioner와 RoundRobinPartitioner가 DefaultPartitioner로 통합되며 버그가 수정됐습니다.
업그레이드 후 파티션 분배:
Partition 0: █████████████ 26%
Partition 1: ████████████ 24%
Partition 2: █████████████ 25%
Partition 3: ████████████ 25%
균등하게 분배됩니다.
이슈 2: Redis 100만 건 처리에 왜 4분이나 걸리나?
현상 발견
랭킹 갱신 배치에서 약 100만 건의 랭킹 데이터를 Redis에 업데이트하는 작업이 있었습니다. 예상 처리 시간은 1분 이내였는데, 실제 측정해보니 240초(4분) 가 걸렸습니다.
Datadog으로 Redis 연결 수를 모니터링하다가 이상한 점을 발견했습니다.
배치 시작 전: 연결 수 ~5
배치 실행 중: 연결 수 폭발적으로 증가 (수천 개)
배치 완료 후: 연결 수 ~5로 복귀
배치가 실행되는 동안 Redis 연결 수가 비정상적으로 많아지고 있었습니다.
원인 분석: RedisTemplate 내부 동작
RedisTemplate의 구현을 살펴보니 문제를 찾을 수 있었습니다.
RedisTemplate 기본 설정의 함정
// 이 코드가 100만 번 실행되면?
redisTemplate.opsForValue().set(key, value);
기본 설정의 RedisTemplate은 각 명령어 호출 시마다 새로운 TCP 연결을 수립하고 해제합니다. 즉:
set(key, value)호출- TCP 연결 수립 (3-way handshake)
- Redis 명령 전송
- 응답 수신
- TCP 연결 해제
이 과정이 100만 번 반복되면 TCP 연결/해제 오버헤드만으로도 엄청난 시간이 소비됩니다.
해결: Lettuce 네이티브 API
Spring Data Redis의 기본 클라이언트는 Lettuce입니다. RedisTemplate은 이 위에 편의성을 위한 추상화 레이어를 얹은 것인데, 이 추상화가 성능 문제를 만들어낸 것입니다.
Lettuce 네이티브 API를 직접 사용하면 커넥션 풀링과 파이프라이닝을 활용할 수 있습니다.
// RedisTemplate 방식 (비효율)
redisTemplate.opsForValue().set(key, value); // 매번 새 연결
// Lettuce 네이티브 방식 (효율적)
@Autowired
private RedisConnectionFactory connectionFactory;
public void batchUpdate(Map<String, String> data) {
try (StatefulRedisConnection<String, String> connection =
((LettuceConnectionFactory) connectionFactory).getNativeClient().connect()) {
RedisAsyncCommands<String, String> commands = connection.async();
commands.setAutoFlushCommands(false); // 파이프라이닝 시작
List<RedisFuture<?>> futures = new ArrayList<>();
for (Map.Entry<String, String> entry : data.entrySet()) {
futures.add(commands.set(entry.getKey(), entry.getValue()));
}
commands.flushCommands(); // 일괄 전송
LettuceFutures.awaitAll(5, TimeUnit.SECONDS, futures.toArray(new RedisFuture[0]));
}
}
핵심은 setAutoFlushCommands(false) 설정입니다. 이를 통해 명령어를 버퍼에 모아두다가 flushCommands()에서 한 번에 전송합니다. 이것이 파이프라이닝입니다.
성능 비교
| 방식 | 100만 건 처리 시간 |
|---|---|
RedisTemplate (기본) |
240초 |
Lettuce 네이티브 API |
60초 |
4배 빨라졌습니다. 같은 데이터, 같은 Redis 서버에서 코드 변경만으로 얻은 결과입니다.
교훈
두 이슈에서 공통적으로 배운 점이 있습니다.
1. 추상화 레이어의 비용을 인식하라
RedisTemplate은 사용하기 편리하지만, 내부 동작을 모르고 사용하면 예상치 못한 성능 문제가 생깁니다. 대량 처리가 필요한 경우에는 추상화를 걷어내고 네이티브 API를 직접 사용하는 것을 고려해야 합니다.
2. 라이브러리 버전과 버그를 추적하라
Kafka StickyPartitioner 버그는 공식 이슈 트래커에 등록된 알려진 버그였습니다. 의존 라이브러리의 changelog와 알려진 이슈를 주기적으로 확인하는 것이 중요합니다.
3. 모니터링이 있어야 문제를 찾을 수 있다
특히 Redis 이슈는 Datadog의 연결 수 그래프가 없었다면 원인을 찾는 데 훨씬 오래 걸렸을 것입니다. 처음부터 모니터링을 붙여두는 것이 중요합니다.