포스트

대용량 트래픽 처리는 서버를 늘리는 것부터 시작하지 않는다

대용량 트래픽 처리는 서버를 늘리는 것부터 시작하지 않는다

대용량 트래픽을 처리해야 한다는 말을 들으면 먼저 서버 사양을 높이거나 인스턴스를 늘리는 방법을 떠올렸다. 캐시를 붙이고 비동기 처리를 적용하는 것도 빠지지 않았다.

하지만 Redis, Kafka, k6를 함께 학습하면서 질문이 조금 달라졌다.

대용량 트래픽을 처리한다는 것은 단순히 서버 성능을 높이는 일이 아니라, 데이터 처리 책임을 분리하고 장애가 전체 시스템으로 전파되지 않도록 구조를 설계하는 일이 아닐까?

이번 글에서는 실시간 커머스의 결제 완료 흐름을 예로 들어, Redis와 Kafka가 각각 어떤 역할을 맡는지, 이벤트 기반 구조에서 어떤 문제를 새로 해결해야 하는지, 그리고 k6 부하 테스트 결과를 어떻게 설계 개선으로 연결할지 정리해 본다.

1. 먼저 나눠야 할 질문

결제 하나가 발생했을 때 주문 상태 변경, 결제 기록 저장, 상품 판매량 증가, 랭킹 갱신, 배송 준비, 알림 전송을 모두 한 요청 안에서 처리한다고 가정해 보자.

기능은 구현할 수 있지만, 트래픽이 증가하면 다음 문제가 한꺼번에 나타난다.

  • 후속 작업이 많아질수록 사용자 응답 시간이 길어진다.
  • 배송이나 알림처럼 부가 작업 하나가 실패해도 결제 요청 전체가 실패할 수 있다.
  • 하나의 서비스가 다른 서비스의 구현과 트랜잭션에 강하게 결합된다.
  • 트래픽이 몰릴 때 모든 작업이 동시에 부하를 받는다.

그래서 구조를 바꾸기 전에 다음 질문을 먼저 나눠야 한다.

  • 어떤 작업은 사용자가 응답을 받기 전에 반드시 끝나야 하는가?
  • 어떤 작업은 잠시 늦어져도 되는가?
  • 모든 데이터가 즉시 최신이어야 하는가?
  • 이벤트가 중복되거나 다시 전달되면 어떻게 복구할 것인가?
  • 장애를 어느 범위까지 격리할 것인가?

이 질문의 답이 Redis와 Kafka를 사용할지, 어디에 사용할지를 결정한다. 기술을 먼저 선택하고 문제를 끼워 맞추는 순서와는 반대다.

2. 실시간 랭킹은 Redis ZSET으로 분리할 수 있다

상품 판매량이나 인기 검색어처럼 점수가 계속 바뀌고 순위 조회가 자주 발생하는 데이터는 매번 RDB에서 집계하지 않는 편이 유리할 수 있다. 이때 Redis의 Sorted Set, 즉 ZSET을 사용할 수 있다.

ZSET은 다음 두 요소로 이루어진다.

  • Member: 순위를 매길 대상. 예를 들면 product:42다.
  • Score: 대상의 점수. 판매 수량이나 조회 수가 될 수 있다.

Redis는 score를 기준으로 member를 정렬한다. 판매 이벤트가 발생할 때마다 다음과 같이 점수를 증가시킬 수 있다.

ZINCRBY product:ranking 1 product:42
ZREVRANGE product:ranking 0 9 WITHSCORES

ZINCRBY는 특정 상품의 점수를 증가시키고, ZREVRANGE는 높은 점수부터 원하는 구간을 조회한다. 삽입과 점수 변경, 순위 조회는 정렬 상태를 유지하는 자료구조의 특성을 활용하므로 점수가 지속해서 변하는 상위 목록을 조회하는 데 적합하다. 일반적으로 정렬된 집합의 갱신과 조회는 O(log N) 수준의 비용이 들며, 전체 데이터를 매번 정렬하는 방식보다 예측 가능한 조회 구조를 만든다.

반대로 RDB에서 매 요청마다 GROUP BY와 정렬로 판매량을 다시 계산하면 원본 거래 데이터가 많아질수록 조회 부하가 커진다. 결제 완료 이벤트를 받을 때 판매 점수를 Redis에 반영해 두면 랭킹 조회는 빠른 파생 데이터 조회로 바뀐다.

다만 Redis가 결제 원본을 대신하는 것은 아니다.

저장소맡는 책임
RDB주문·결제와 같은 거래 및 원본 데이터
Kafka이벤트 전달과 후속 처리의 분리
Redis빠른 조회를 위한 실시간 집계 결과

이전에 콘서트 랭킹 개발을 하며 느낀 것에서 Redis ZSET을 랭킹 자료구조로 선택할 때의 고민을 정리한 적이 있다. 이번에는 그 선택을 결제 이벤트 이후의 파생 데이터 처리라는 더 큰 흐름 안에서 바라보게 됐다. 랭킹이 원본 데이터가 아니라는 경계를 지키는 것이 중요하다.

3. Kafka는 결제 이후의 책임을 분리한다

결제 서비스가 결제 완료 이벤트를 Kafka에 발행하면, 후속 작업은 각 Consumer가 자신의 목적에 맞게 처리할 수 있다.

동기식 처리와 이벤트 기반 처리 비교

동기식 구조에서는 결제 서비스가 주문, 랭킹, 배송, 알림을 순서대로 직접 호출한다. 결제 서비스는 모든 작업의 성공 여부를 기다려야 하고, 하나의 느린 API나 장애가 전체 요청의 응답 시간과 성공 여부에 영향을 준다.

이벤트 기반 구조에서는 결제 서비스가 결제 완료라는 사실을 이벤트로 발행하는 책임에 집중한다. 랭킹 Consumer는 판매 점수를 갱신하고, 결제 이력 Consumer는 기록을 저장하며, 배송 Consumer는 배송 준비 데이터를 만든다. 알림 Consumer도 별도로 동작한다.

이 구조의 핵심은 “Kafka를 넣으면 빨라진다”가 아니다. 결제 완료라는 하나의 사실을 여러 후속 작업이 각자의 속도와 장애 범위 안에서 처리하도록 책임을 분리하는 것이다.

물론 Kafka를 도입하면 새로운 운영 책임이 생긴다.

  • 같은 메시지가 중복 전달될 수 있다.
  • 순서 보장은 파티션 내부에 한정된다.
  • offset과 commit 시점을 관리해야 한다.
  • Consumer Lag을 관찰해야 한다.
  • 재시도와 DLT를 운영해야 한다.
  • 이벤트 스키마가 바뀔 때 호환성을 고려해야 한다.

작은 서비스에서 후속 작업이 적고 즉시 처리 결과가 꼭 필요하다면 단순한 동기식 호출이 더 적절할 수도 있다. Kafka는 결합도와 장애 전파를 줄이는 대신, 메시징 운영 복잡도를 추가하는 선택이다.

4. Kafka의 구조를 커머스 흐름으로 이해하기

Kafka의 각 구성 요소는 다음처럼 실제 흐름에 연결할 수 있다.

  • Producer: 결제가 완료된 뒤 PaymentCompletedEvent를 발행하는 결제 서비스
  • Topic: 결제 완료 이벤트를 모아 두는 논리적 채널
  • Partition: 토픽을 나눈 저장·처리 단위이자 병렬성의 단위
  • Broker: 파티션 데이터를 저장하고 클라이언트 요청을 처리하는 Kafka 서버
  • Consumer: 이벤트를 읽어 랭킹, 결제 이력, 배송 등을 처리하는 애플리케이션
  • Consumer Group: 같은 목적의 Consumer들이 파티션을 나눠 처리하는 단위
  • Offset: Consumer Group이 각 파티션에서 어디까지 읽었는지 나타내는 위치
  • Replication: 브로커 장애에 대비해 파티션 데이터를 복제하는 구조

Kafka 핵심 구조

같은 Consumer Group 안에서는 하나의 파티션을 동시에 여러 Consumer가 처리하지 않는다. 파티션이 세 개라면 Consumer도 세 개까지 작업을 나눌 수 있지만, Consumer 수를 무작정 늘린다고 처리량이 계속 증가하지는 않는다. 파티션 수와 브로커 자원, 메시지 처리 시간, 운영 비용을 함께 봐야 한다.

반면 서로 다른 Consumer Group은 같은 이벤트를 독립적으로 소비할 수 있다. 랭킹 Consumer Group, 결제 이력 Consumer Group, 배송 Consumer Group이 각각 존재하면 동일한 결제 완료 이벤트가 서로 다른 목적에 맞게 전달된다.

Kafka의 순서 보장은 토픽 전체가 아니라 파티션 내부에서 제공된다. 주문 단위의 순서가 중요하다면 orderId를 메시지 key로 사용해 같은 주문의 이벤트가 같은 파티션에 들어가도록 고려할 수 있다. 그래도 재시도 토픽이나 외부 시스템을 거치면 도메인 상태가 역전될 수 있으므로, Consumer가 현재 상태를 검증하는 방어도 필요하다.

5. Spring Boot에서 이벤트를 발행하고 소비하기

Spring Boot에서는 KafkaTemplate로 이벤트를 발행하고, @KafkaListener로 이벤트를 소비하는 흐름을 구성할 수 있다. Producer serializer는 Java 객체를 메시지 형식으로 변환하고, Consumer deserializer는 메시지를 이벤트 객체로 복원한다. ConsumerFactoryConcurrentKafkaListenerContainerFactory는 Consumer 생성과 리스너 실행 환경을 구성한다.

결제 완료 이벤트는 Java 21의 record로 표현하면 불변 데이터의 의도를 간결하게 드러낼 수 있다.

1
2
3
4
5
6
7
8
9
public record PaymentCompletedEvent(
        Long paymentId,
        Long orderId,
        Long productId,
        Long userId,
        int quantity,
        LocalDateTime paidAt
) {
}

Producer의 핵심 흐름은 결제 트랜잭션이 성공한 뒤 이벤트를 발행하는 것이다.

1
2
3
public void publish(PaymentCompletedEvent event) {
    kafkaTemplate.send("payment-completed", event.orderId().toString(), event);
}

여기서 key는 파티션 선택과 순서 범위에 영향을 준다. 주문 단위 순서가 필요하다면 orderId를 key로 삼는 식이다. Value에는 후속 처리에 필요한 이벤트 데이터가 담긴다.

Consumer는 자신의 책임만 수행한다.

1
2
3
4
5
6
7
@KafkaListener(
        topics = "payment-completed",
        groupId = "ranking-consumer"
)
public void updateRanking(PaymentCompletedEvent event) {
    rankingRepository.increase(event.productId(), event.quantity());
}

이 코드는 흐름을 보여주기 위한 핵심 예시다. 실제 운영에서는 처리 완료 시점, 예외 분류, 멱등성, 모니터링을 함께 설계해야 한다. 설정을 모두 복사하는 것보다 각 구성 요소가 어떤 책임을 맡는지 이해하는 편이 먼저다.

6. Offset과 Commit은 처리 신뢰성과 연결된다

Offset은 단순히 “읽은 위치”가 아니라 Consumer Group의 처리 진행을 복구하기 위한 기준점이다. 처리 전에 offset을 commit하면 메시지 처리 도중 장애가 발생했을 때 이미 처리한 것으로 간주되어 메시지가 유실될 수 있다.

처리 후 commit하는 방식은 유실 가능성을 줄인다. 그러나 Consumer가 비즈니스 처리를 성공시킨 직후, commit 전에 장애가 발생하면 다음에 같은 메시지를 다시 읽을 수 있다.

1
2
3
4
5
메시지 수신
  -> 상품 판매량 증가 성공
  -> Offset Commit 전 Consumer 장애
  -> 재시작 후 같은 메시지 재수신
  -> 판매량이 다시 증가할 위험

자동 commit은 설정이 간단하지만 실제 비즈니스 처리가 끝났는지와 commit 시점이 어긋날 수 있다. 수동 commit이나 컨테이너 기반 commit은 처리 완료 시점을 더 명확히 조절할 수 있지만, 예외 상황과 비동기 처리의 경계를 세밀하게 관리해야 한다.

따라서 실무에서는 보통 at-least-once 전달을 전제로 한다. 메시지가 적어도 한 번 전달될 수 있다는 뜻은, 같은 메시지가 다시 전달될 수 있다는 뜻이기도 하다. 결국 offset 설정만으로 중복을 없애려 하기보다 Consumer를 멱등하게 만들어야 한다.

7. 중복 이벤트와 멱등성

결제 완료 이벤트가 두 번 처리되면 판매량이 두 번 증가하고, 배송 요청이 두 번 생성되며, 알림이 중복 발송될 수 있다. 중복은 네트워크 재전달이나 commit 전 장애처럼 정상적인 운영 과정에서도 발생할 수 있다.

대표적인 방어 방법은 다음과 같다.

  • 이벤트 ID나 결제 ID에 Unique Constraint를 적용한다.
  • 처리 완료 이벤트 ID를 별도 테이블에 기록한다.
  • 배송 요청처럼 비즈니스 데이터 자체에 중복 방지 키를 둔다.
  • 짧은 시간의 중복을 Redis로 빠르게 차단한다.
  • 처리 여부 기록과 비즈니스 변경을 하나의 DB 트랜잭션 안에서 저장한다.

Redis SETNX만으로 중요한 중복을 완전히 막는다고 단정해서는 안 된다. Redis 장애가 발생하거나 TTL이 만료되면 차단 정보가 사라질 수 있기 때문이다. 결제나 배송처럼 재처리 결과가 치명적인 영역에서는 DB 제약 조건과 트랜잭션을 최종 방어선으로 두는 편이 안전하다.

멱등성은 “두 번 실행해도 결과가 한 번 실행한 것과 같은 성질”이다. 다만 모든 작업이 같은 방식으로 멱등해지는 것은 아니다. 알림처럼 외부 발송이 포함된 작업은 발송 이력과 외부 API의 idempotency key를 함께 고려해야 한다.

8. 재시도와 DLT는 운영 흐름이다

Consumer 실패가 모두 같은 의미인 것은 아니다. 일시적인 DB 연결 실패나 외부 API timeout, 순간적인 네트워크 오류는 시간이 지나면 성공할 가능성이 있으므로 제한된 재시도를 적용할 수 있다.

반대로 역직렬화 실패, 필수 필드 누락, 유효하지 않은 스키마, 존재하지 않는 비즈니스 대상처럼 입력 자체가 잘못된 오류는 재시도만 반복해도 해결되지 않는다. 이런 메시지는 바로 격리하거나 별도 보정 절차로 보내야 한다.

실패 흐름은 다음처럼 설계할 수 있다.

  1. 오류가 일시적인지 판단한다.
  2. 재시도 가능한 경우 backoff를 두고 제한된 횟수만 재시도한다.
  3. 계속 실패하거나 재시도할 수 없는 오류라면 DLT로 이동한다.
  4. 운영자가 원본 이벤트와 예외 정보를 확인한다.
  5. 데이터나 코드를 수정한 뒤 재처리한다.

Kafka 실패 처리와 DLT

DLT는 실패 메시지를 버리는 장소가 아니다. 실패 이벤트를 정상 흐름에서 격리하고, 원인을 분석하고, 복구할 수 있게 만드는 운영 영역이다. 이를 위해 원본 Topic, Partition, Offset, Consumer Group, 예외 타입과 메시지, 실패 시간, 재시도 횟수, 원본 이벤트, 재처리 상태를 함께 남겨야 한다.

이전에 Kafka 재처리와 DLT는 예외 처리가 아니라 운영 설계다에서 retry topic, 파티션 순서, DLT 운영 프로세스를 더 자세히 정리했다. 이번 글에서는 그 세부 구현보다 결제 이벤트 흐름에서 DLT가 왜 필요한지에 초점을 맞췄다.

9. 결제 완료 이벤트가 만드는 전체 흐름

이제 전체 사례를 하나의 흐름으로 연결해 보자.

  1. 사용자가 상품을 결제한다.
  2. 결제 서비스가 거래를 RDB에 반영하고 결제 완료 이벤트를 Kafka에 발행한다.
  3. Ranking Consumer가 상품 판매량을 Redis ZSET에 반영한다.
  4. Payment History Consumer가 결제 이력을 RDB에 저장한다.
  5. Delivery Consumer가 배송 준비 데이터를 생성한다.
  6. Notification Consumer가 결제 완료 알림을 보낸다.

결제 완료 이벤트 전체 흐름

각 Consumer는 서로 다른 Consumer Group을 사용한다. 따라서 배송 Consumer가 일시적으로 실패해도 랭킹 Consumer가 멈추거나 결제 이력 Consumer의 처리까지 함께 실패하지 않는다. 물론 Kafka와 Consumer의 처리 상태를 모니터링해야 하며, 독립성이 곧 무제한 확장성을 의미하는 것은 아니다.

이 구조에서 저장소의 경계는 분명해야 한다.

  • RDB는 주문과 결제의 최종 원본이다.
  • Kafka는 이벤트를 전달하고 처리 시점을 분리한다.
  • Redis는 랭킹처럼 빠르게 조회할 파생 데이터를 둔다.

Kafka에 원본 거래를 맡기거나 Redis에 결제 원장을 맡기면 각각의 저장소가 책임을 벗어나기 시작한다. 장애가 발생했을 때 무엇을 기준으로 복구할지 모호해지는 이유다.

10. 부하 테스트는 설계 가설을 검증한다

기능 테스트가 성공했다고 해서 트래픽 상황에서도 안정적으로 동작하는 것은 아니다. 요청이 몰렸을 때 응답이 얼마나 느려지는지, Consumer가 메시지를 따라가지 못하는지, DB Connection Pool이 고갈되는지는 별도로 검증해야 한다.

k6 부하 테스트에서는 평균 응답 시간만 보지 않고 다음 지표를 함께 확인해야 한다.

  • 평균, p90, p95, p99 응답 시간
  • 처리량과 실패율
  • VU와 총 요청 수
  • Threshold 충족 여부
  • CPU와 메모리 사용량
  • DB Connection Pool 사용량
  • Consumer Lag
  • Redis 처리량

평균이 괜찮아도 일부 사용자의 응답이 길 수 있다. 그래서 p95나 p99처럼 상위 지연 구간을 함께 봐야 실제 사용자 경험에 가까운 판단을 할 수 있다. k6 시나리오를 코드로 관리하는 방법은 k6로 성능 테스트를 코드로 관리하기에 별도로 정리했다.

11. 결과를 보고 병목을 하나씩 줄인다

성능 테스트 결과를 보고 곧바로 서버 수를 늘리는 것은 원인을 가리는 처방이 될 수 있다. 먼저 어느 구간이 병목인지 확인해야 한다.

가능한 병목은 다양하다.

  • 인덱스가 없는 DB 조회
  • N+1 쿼리
  • 느린 외부 API
  • 작은 DB Connection Pool
  • 동기식 후속 처리
  • 과도한 로그
  • Consumer 처리 속도 부족
  • Kafka Partition 부족
  • Redis 대신 RDB에서 반복 집계
  • 이벤트 한 건당 지나치게 큰 트랜잭션

개선 과정은 다음 순서를 지키는 편이 좋다.

  1. 부하 테스트 시나리오를 정의한다.
  2. 동일 조건에서 기준 지표를 측정한다.
  3. 병목 구간을 확인한다.
  4. 한 번에 하나의 변경만 적용한다.
  5. 같은 조건으로 다시 측정한다.
  6. 개선 여부와 부작용을 함께 확인한다.

부하 테스트 기반 성능 개선 사이클

예를 들어 Consumer Lag이 계속 증가한다면 Consumer 처리 로직의 DB 호출 수, 파티션 수, Consumer 인스턴스 수를 함께 확인해야 한다. DB 조회가 병목이라면 인덱스나 쿼리 구조를 살펴보고, 파티션 부족이 원인이라면 병렬성 증가가 실제로 가능한지 검토해야 한다. 개선 전후 수치는 실제 측정 결과가 있을 때만 기록해야 하며, 현재 학습 자료에는 특정 성능 개선 수치가 없으므로 임의의 숫자는 제시하지 않는다.

12. 학습하며 바뀐 생각

처음에는 Redis, Kafka, k6를 서로 다른 기술로 생각했다. Redis는 빠른 저장소, Kafka는 메시지 브로커, k6는 테스트 도구라고만 이해했다.

지금은 세 기술이 하나의 설계 흐름 안에서 연결된다고 느낀다.

  • Redis는 빠른 조회와 실시간 집계를 담당한다.
  • Kafka는 작업을 분리하고 이벤트를 전달한다.
  • Consumer Group은 하나의 이벤트를 여러 목적에 맞게 확장한다.
  • Offset과 Commit은 이벤트 처리 신뢰성과 연결된다.
  • 멱등성과 DLT는 실패와 중복을 운영 가능한 상태로 만든다.
  • k6는 이런 구조가 실제 부하에서도 의미가 있는지 검증한다.

특히 대용량 트래픽 대응은 기술을 많이 붙이는 문제가 아니었다. 거래 원본과 조회용 파생 데이터, 이벤트 전달과 실제 처리, 정상 흐름과 실패 흐름의 경계를 명확히 나누는 문제에 가까웠다.

13. 마무리

이번 학습에서 가장 크게 남은 기준은 다음과 같다.

  • Kafka를 도입하는 것보다 실패와 중복을 어떻게 처리할지가 중요하다.
  • Redis를 사용하는 것보다 어떤 데이터를 Redis에 둘지 결정하는 것이 중요하다.
  • 부하 테스트를 실행하는 것보다 어떤 가설을 검증할지가 중요하다.

결국 대용량 아키텍처는 Redis, Kafka, k6라는 기술 목록이 아니다. 어떤 책임을 어디에 둘지, 데이터의 원본은 무엇인지, 장애가 어느 범위까지 전파되어도 되는지를 설계하는 과정이다.

관련 기술을 공부할수록 “무엇을 추가할까?”보다 “무엇을 분리해야 할까?”를 먼저 묻는 습관이 필요하다는 생각이 든다.

함께 읽으면 좋은 글

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.