HotSpot 환경에서의 Event Driven 아키텍처의 부하와 동시성 처리

개요

v0.4 에서는 분산락을 통해 DB로 집중되는 병목을 애플리케이션 레이어로 이동시켰다. 하지만 애플리케이션으로 이동시키며 전체 성능의 개선을 예상했던 것과 다르게 낮은 TPS를 기록했다.

결국, HotSpot 환경에서의 주요 병목으로 짚었던 DB 병목을 해결했으나, 그 결과로 락 획득 대기 시간과 네트워크 왕복 비용이라는 새로운 병목을 얻었다. v0.5 에서는 동기 요청인 채굴 기능을 비동기 처리로 처리하고, Event Driven 아키텍처를 적용하여 병목을 적절히 해소하고, 동시성 문제를 관리할 수 있는지 확인해보고자 한다.

Event Driven = Kafka?

v0.4 와 같이 Event Driven 아키텍처를 구현하는데는 Kafka 뿐 만이 답인가? 라고 한다면 그렇지 않을 것이다. 이벤트 기반 아키텍처를 구현할 수 있는 기술들은 Kafka 뿐 아닌 다른 대안들도 분명히 존재한다. 나는 이 실험의 개발에 들어가기전 원하는 기능을 제공하며, 더 빠르고, 더 비용이 싼 기술들이 존재하는지 다시한번 되짚어 보기로했다.

방식 특징 사용 사례
Spring ApplicationEvent JVM 내부 이벤트 단일 애플리케이션
Redis Streams Redis 기반 Event Streaming 간단한 비동기 처리
RabbitMQ Message Queue 작업 큐, 비동기 작업
Amazon SQS Managed Message Queue AWS 기반 비동기 처리
Kafka Distributed Event Streaming 대용량 이벤트 처리

우선 Spring ApplicationEvent는 바로 제외했다. 이벤트를 비동기로 처리할 수는 있지만, 하나의 JVM 내부에서만 동작하기 때문에 API 서버와 Consumer를 완전히 분리하는 구조를 실험하기에는 적합하지 않았다.

Redis Streams도 충분히 고려해볼 수 있다. 이미 v0.4에서 Redis를 사용하고 있었기 때문에 추가적인 인프라 없이 도입할 수 있다는 장점이 있었다. 하지만 Redis Streams는 Redis의 기능 중 하나로 제공되는 Event Streaming 기능이며, 프로젝트의 목적이 이벤트 스트리밍 자체를 검증하는 것이라기보다 이벤트 기반 아키텍처를 구성했을 때 시스템이 어떻게 변화하는지를 확인하는 것이었기 때문에 조금 더 범용적인 기술을 선택하고 싶었다.

RabbitMQ 는 Kafka를 도입하기 이전 분명히 함께 언급되는 강력한 후보 군이다.(내 경험 선에선) 메시지 전달과 작업 큐(Job Queue)에 특화되어 있고, ACK나 Retry, Dead Letter Queue와 같은 기능도 잘 갖추고 있다. 실제 업무에서도 비동기 작업 처리에 많이 사용된다. 하지만 Gold-Rush-Lab에서 검증하고 싶은 것은 단순히 요청을 큐에 넣는 것이 아니었다.

동일한 광산으로 순간적으로 몰리는 요청을 이벤트로 전환하여 저장하고, Consumer의 처리 속도에 맞춰 데이터를 소비하도록 만들어 API 서버와 데이터베이스의 처리 시점을 분리하는 것이 목적이었다. 또한 이후 Consumer 수를 늘리거나 Partition을 변경하며 시스템이 어떻게 변화하는지까지 함께 확인하고 싶었다.

또한 Amazon SQS는 당연하지만, 기존 실험들이 로컬 Hyper-V 환경에서 실험 되었기 때문에 동일한 실험을 재현하는 것을 목표로 했다. 또한 Consumer의 처리량 변화, 이벤트 순서 보장과 같은 이벤트 스트리밍 특성까지 함께 검증하고 싶었기 때문에, 단순한 메시지 큐보다는 이벤트 스트리밍 플랫폼인 Kafka가 더 적합하다고 판단했다.

Kafka는 이벤트를 로그 형태로 저장하고, Consumer Group을 통해 여러 Consumer가 이벤트를 분산 처리할 수 있다. 또한 Partition을 기준으로 이벤트 순서를 유지하면서도 병렬 처리를 지원하기 때문에, 향후 처리량 변화나 병목 위치를 관찰하기에도 적합했다.

물론 Gold-Rush-Lab의 Hot Spot 환경에서는 모든 요청이 동일한 광산으로 집중되기 때문에, 하나의 Partition으로 이벤트가 몰려 결국 순차 처리될 가능성이 높다. 하지만 오히려 이러한 특성을 통해 이벤트 기반 아키텍처가 모든 병목을 해결하는 것이 아니라, 어떤 병목은 그대로 남는다는 점까지 함께 검증할 수 있다고 판단했다. 따라서 이번 실험에서는 이벤트 기반 아키텍처를 가장 자연스럽게 구성할 수 있고, 향후 Consumer 확장과 Partition 전략까지 함께 실험할 수 있는 Kafka를 선택하기로 결정했다.

하지만, 요청을 비동기로 처리하게되면 사용자는 v0.1~v0.4 와 같이 요청에 대한 채굴 결과를 받지 못하게 된다. 사용자 경험을 저해할 수 있는 요소이다.

따라서 v0.5에서는 채굴 결과를 SSE(Server Send Event)를 통해 사용자에게 알리기로 결정하였다. 또한, 나는 Kafka에 대해서 능히 아는 수준은 아니다.(거의 알지 못한다고 보는것이 옳겠다)

이 실험을 진행하기에 앞서 약간의 공부를 했고, kafka의 기술에 대한 지식들을 쌓을 수 있었지만 가장 기억에 남는 문구는 다음과 같았다.

파티셔너는 메시지에 키가 없을 때에는 라운드 로빈(round-robin) 방식으로 메시지를 파티션에 분배하여 저장하고, 키가 있을 때에는 해당 키 값의 해시(hash) 를 구한 후에 그 값에 맞는 특정한 파티션에 메시지를 저장한다. 그렇기 때문에 메시지의 키 값이 동일하면 항상 같은 파티션에 메시지가 저장되게 된다.

Kafka 2.4 부터는 라운드 로빈이 아닌 작은 배치가 여러 파티션으로 흩어져 발생하는 지연과 전송 오버헤드를 줄이는 Sticky Partition으로 변경되었다. Gold-Rush-Lab에서는 Kafka 4.3.1 을 사용한다. Sticky Partition : Key가 없는 메세지가 매번 다른 Partition으로 이동하는 것이 아닌 일정 배치 동안 하나의 Partition에 저장된 뒤 다음 Partition으로 이동한다.

출처 : https://medium.com/@greg.shiny82/실무-관점에서의-apache-kafka-활용-023d468f9182

이 문구가 기억에 남는 이유는 함축적인 의미가 있는 듯 하게 느껴졌고, 이 전략으로 인해서 시스템의 성능이 크게 변할 것 같다는 생각에서였다.

우선, 메세지 키를 설정하지 않은 경우는 해당 토픽에 대해서 발행되는 메세지의 대상이 정해져있지 않은 경우일 것이다. 물론 Gold-Rush에서는 실험을 위해 하나의 mineId 가 실험 대상이 되기 때문에 충분히 맞을 수 있는 상황 이겠지만, 취지가 운영 서비스에서의 시스템 발전중 동시성과 부하 처리 이기 때문에 실제 운영 환경과 동떨어지는 설계는 지양하고자 한다. 따라서 메세지 키는 항상 존재해야 한다.

이제 무엇을 메세지 키로 설정하는가에 대한 고려가 필요했다. 학습한 내용에 따르면, 오류가 발생하지는 않지만 최소한 Partition 수 >= Consumer 수 를 유지해야 한다. 이는 Kafka에서 각 Consumer가 여러개의 Partition의 메세지를 읽을 수는 있지만 Partition은 하나의 Consumer에 의해서 밖에 메세지가 읽힐 수 없는 파티션 소유권 때문이다. 즉, Partition < Consumer 가 된다면 아무 작업을 처리하지 않고 유휴하는 Consumer가 발생한다는 것이다.

이로부터 추론 했을 때 메세지의 키 값이 동일하여 항상 같은 Partition에 저장된다면, Partition을 늘리는 것이 의미 없게 된다. Partition을 늘리는데 제한이 생긴다면, 파티션 소유권에 의해서 Consumer를 늘리는 것도 의미가 없어 질 것이다. 이렇게 된다면 채굴결과 전송이 SSE로 바뀌었다는 점만 뺀다면, v0.4에서의 분산락과 큰 차이를 보이지 않을 것이다.

반대로 항상 다른 값을 메세지의 키 값으로 활용한다면 Partition과 Consumer를 최대로 활용할 수 있게 되어 병렬성을 확보할 수 있겠지만, 이전 버전 들에서 발생되었던 동시성 문제 그 중에서도 Lost Update가 재현될 것이다. 따라서 v0.5에서는 키 선택 전략에 따라 성능에 미치는 영향을 중심으로 실험을 진행하고자 한다. 메세지 키의 후보는 아래의 사고를통해 선정했다. 실제로 유저 ID와 세션 ID는 Gold-Rush에서는 하나의 유저를 뜻하기 때문에 의미 차이는 없다.

  • 누가 ‘어떤’ 광산을 채굴하는가. (mineId)
  • ‘누가’ 어떤 광산을 채굴하는가. (userId/sessionId)

가설

가설 1. Event Driven 아키텍처를 적용하면 API의 응답 성능은 향상되지만, 시스템의 최종 처리량은 Consumer와 DB 처리 속도에 의해 결정될 것이다.

  • API는 채굴 요청을 Kafka에 발행한 뒤 즉시 응답하므로 기존 동기 처리 방식보다 평균 응답시간과 P95, P99는 감소할 것으로 예상하였다. 반면 실제 채굴 완료는 Consumer가 이벤트를 소비하여 DB 트랜잭션을 완료해야 이루어지므로, 시스템 전체의 처리량은 Consumer와 DB 처리 속도에 의해 제한될 것으로 예상된다.

가설 2. 동일한 mineId를 Kafka Key로 사용하면 Hot Spot 환경에서도 데이터 정합성은 유지되지만 Consumer의 병렬성은 활용되지 않을 것이다.

  • Kafka는 동일한 Key를 항상 같은 Partition으로 전달하며, 하나의 Partition은 동시에 하나의 Consumer만 처리한다고 학습했다. 따라서 동일 광산에 대한 모든 요청은 하나의 Consumer에서 순차적으로 처리되어 Lost Update는 발생하지 않을 것으로 예상되며, 반면 다른 Consumer는 실제 채굴 처리에 참여하지 못해 유휴 상태가 될 것이다.

가설 3. sessionId를 Kafka Key로 사용하면 Consumer의 병렬성은 증가하지만 동일 광산에 대한 DB 경합으로 인해 Lost Update가 다시 발생할 것이다.

  • sessionId는 사용자마다 서로 다른 값을 가지므로 이벤트가 최대 2개 파티션으로 분산(Gold-Rush-Lab에서는 자원의 한계로인해 Consumer를 많이 늘릴 수 없다 ㅜㅜ )되고 Consumer가 병렬로 처리할 수 있을 것으로 예상된다. 그러나 병렬성을 취함으로써 그 트레이드 오프로 기존에 해결했던 Lost Update가 다시 발생할 것이다.

가설 4. 병렬성을 유지한 상태에서 DB를 원자적으로 갱신하면 데이터 정합성을 유지하면서 Consumer의 병렬성을 모두 활용할 수 있을 것이다.

  • 실험을 설계하며, 이전 실험에서 얻어낸 결론과 연결된 한가지 생각이 들었다. v0.3 에서 얻어낸 결론 중 원자적 업데이트와 관련된 내용은 다음과 같았다.

    원자적 업데이트는 연산이 적용된 최신 데이터를 바로 반환할 필요가 없는 경우, 또는 비동기적 처리에 적합하겠다

    v0.5 에서는 채굴에 대한 결과를 SSE로 처리하여 클라이언트에게 알려주고, 채굴 자체는 비동기 적으로 일어난다. 그렇다면 현재 v0.5 의 상황은 위 결론과 정확히 맞아 떨어지는 상황이라는 생각이 들었다. 원자적 업데이트를 적용하면 병렬성을 활용하며 Lost Update를 방지할 수 있을 것이라고 생각했다.

가설 5. Event Driven 아키텍처와 적절한 DB 동시성 제어를 함께 적용하면 기존 분산락 기반 구조보다 더 높은 처리량을 달성할 수 있을 것이다.

  • Redis 분산락은 애플리케이션에서 요청을 직렬화하여 데이터 정합성을 확보했지만, 동시에 하나의 요청만 비즈니스 로직을 수행하므로 처리량에 한계가 있었다. 반면 Event Driven 아키텍처에서는 API와 실제 채굴 처리를 분리하고, Consumer의 병렬 처리와 DB의 원자적 갱신을 함께 적용하면 데이터 정합성을 유지하면서도 더 많은 요청을 동시에 처리할 수 있을 것으로 예상된다. 다만 채굴 결과 제공에 대한 로직이 바뀐 것을 고려해야할 것이다.

실험환경

Gold-Rush-Lab v0.5 실험 환경

Gold-Rush-Lab v0.5 실험 환경

  • Host Machine
    • OS: Windows 11
    • CPU: AMD Ryzen 5 5600X
    • Memory: 32 GB
  • Virtualization
    • Hyper-V
  • Network
    • External Virtual Switch, 모든 VM은 동일한 Hyper-V Virtual Switch를 사용

  • APP VM-01, APP VM-0
    • 실험에 따라 VM을 1대 감설
    • OS: Ubuntu Server 24.04
    • vCPU: 2
    • Memory: 2 GB
  • Consumer VM-01, Consumer VM-02
    • 실험에 따라 VM을 1대 감설
    • OS: Ubuntu Server 24.04
    • vCPU: 2
    • Memory: 2 GB
  • DB VM
    • OS: Ubuntu Server 24.04
    • vCPU: 2
    • Memory: 2 GB
  • LB(LoadBalancer) VM
    • OS: Ubuntu Server 24.04
    • vCPU: 2
    • Memory: 2 GB
  • Redis VM
    • OS: Ubuntu Server 24.04
    • vCPU: 2
    • Memory: 2 GB
  • Messaging VM (Kafka)
    • OS: Ubuntu Server 24.04
    • vCPU: 2
    • Memory: 2 GB
  • Monitoring VM
    • OS: Ubuntu Server 24.04
    • vCPU: 2
    • Memory: 2 GB

  • Load Generator
    • OS : Mac OS Tahoe(26)
    • CPU: M1
    • Memory: 8GB

실험 대상 로직

비동기 채굴 요청과 결과 전달 흐름

Kafka와 SSE를 이용한 비동기 채굴 처리 흐름

실험방법

Kafka 설정

Kafka 토픽과 파티션 구성

mining-requested와 mining-completed 토픽 구성

mining_requestedmining_completed를 서로 다른 역할의 Topic으로 분리하였다.

  • mining_requested : API 서버가 채굴 요청을 발행하는 이벤트
  • mining_completed : Consumer가 실제 채굴을 완료한 뒤 결과를 알리는 이벤트

각 Topic은 모두 2개의 Partition으로 생성하였다. 이는 이후 mineIdsessionId를 Kafka Key로 사용했을 때 Partition 분산 방식과 Consumer의 병렬성이 어떻게 달라지는지 비교하기 위함이다. 또한 App Server와 Worker(Consumer)는 서로 다른 Consumer Group으로 구성하였다. Consumer VM은 mining-group Consumer Group에 속하여 mining_requested를 소비하고 실제 채굴을 수행한다. 따라서 각 역할에 따라 Consumer Group을 분리하였다.

  • Consumer Server: 채굴을 수행하는 Worker
  • App Server: 채굴 결과를 사용자에게 SSE로 전달

Producer, Consumer 구현

App Server

mining-requested Producer

public void publish(MiningRequestedEvent event) {

    String payload = objectMapper.writeValueAsString(event);
    kafkaTemplate.send(
            "mining-requested",
//            event.mineId().toString()
            event.userSessionId().toString(),
            payload
    ).join();

}
  • mineId 또는 sessionId를 Key로 사용해 두 Partition으로 이벤트를 분산하였다.
  • Kafka 발행 완료를 확인한 뒤 API가 202 Accepted를 반환하도록 .join()을 사용하였다.
  • 발행 성공과 실패는 별도 메트릭으로 수집하였다.

mining-completed Consumer

@KafkaListener(topics = "mining-completed")
    public void consume(String payload) {
        MiningCompletedEvent event = objectMapper.readValue(payload, MiningCompletedEvent.class);
        miningMetrics.recordEndToEnd(event.requestedAt());
        publisher.publish(event);
    }
  • App Server는 mining-completed 이벤트를 소비한다.
  • 요청 이벤트 생성 시각부터 완료 이벤트를 수신한 시점까지의 E2E 시간을 기록한다.
  • 완료 이벤트는 Redis Pub/Sub으로 브로드캐스트한다.
  • 각 App Server는 자신의 메모리에 해당 요청의 SseEmitter가 존재하는지 확인하고, 실제 연결을 보유한 App만 사용자에게 결과를 전송한다.

Consumer Server

mining-requested Consumer

@KafkaListener(topics = "mining-requested")
public void consume(ConsumerRecord<String, String> record) {
    trackOrder(record.partition(), record.offset());

    MiningRequestedEvent event = deserialize(record.value());
    MiningProcessResult result = processor.process(event);

    completedPublisher.publish(result.event());
}
  • Partition과 Offset을 기록하여 Consumer별 처리 분포와 Partition 내부 순서를 확인하였다.
  • 실제 채굴 트랜잭션은 MiningProcessor에서 수행한다.
  • DB 반영이 끝나면 mining-completed 이벤트를 발행한다.

mining-completed Producer

@KafkaListener(topics = "mining-completed")

public void consume(String payload) {
    MiningCompletedEvent event = objectMapper.readValue(payload, MiningCompletedEvent.class);
    miningMetrics.recordEndToEnd(event.requestedAt());
    publisher.publish(event);
}
  • App Server가 완료 이벤트를 한 번 소비한다.
  • 요청 발생 시각부터 완료 이벤트 수신까지의 E2E 시간을 기록한다.
  • 이후 Redis Pub/Sub으로 전체 App Server에 브로드캐스트한다.

SSE 구현

@Component
public class MineSseEmitterManager {

    public static final long TIMEOUT_MILLIS = 60_000L;

    // 현재 App Server와 연결된 emitter를 관리하는 해시맵. 서버가 증설된다면..
    private final Map<UUID, SseEmitter> emitters = new ConcurrentHashMap<>();

    public SseEmitter subscribe(UUID requestId) {
        ...
    }
}

SSE 연결은 각 App Server의 메모리에 저장된다. 따라서 mining-completed 이벤트를 소비한 App Server가 실제 SseEmitter를 보유한 인스턴스라는 보장은 없다. 예를 들어 사용자가 APP-01과 SSE 연결을 맺었지만, Kafka의 완료 이벤트를 APP-02가 소비한다면 APP-02의 HashMap에는 해당 requestId에 대응하는 SseEmitter가 존재하지 않는다.

이를 해결하는 가장 단순한 방법은 각 App Server가 서로 다른 Kafka Consumer Group으로 mining-completed 이벤트를 모두 소비하는 방식이다. 우하지만 이 경우 App Server 수만큼 동일한 이벤트가 중복 소비되고, 서버가 증설될 때마다 Consumer Group과 Kafka 트래픽도 함께 증가한다.

반면 Redis Pub/Sub을 사용하면 mining-completed 이벤트를 Kafka Consumer Group에서 Consumer Group에서 하나의 Consumer가 완료 이벤트를 소비한 뒤, 해당 결과를 모든 App Server에 브로드캐스트할 수 있다. Gold-Rush-Lab에서는 이미 v0.4에서 Redis를 사용하고 있었기 때문에 새로운 인프라를 추가하지 않고 이 구조를 구현할 수 있었다.

또한 SSE 결과 알림은 DB 상태를 변경하는 핵심 이벤트가 아니라, 이미 완료된 채굴 결과를 사용자에게 전달하는 일시적인 알림이다. 따라서 메시지 저장과 재처리보다는 빠른 브로드캐스트와 단순한 구현이 더 중요하다고 판단하였다.

물론 Redis Pub/Sub은 메시지를 저장하지 않기 때문에 App Server가 구독하지 못한 시점에 발행된 결과는 유실될 수 있다. 하지만 이번 실험에서는 Kafka와 DB가 실제 채굴 결과를 보존하며, Redis Pub/Sub은 완료 결과를 모든 App Server에 브로드캐스트하며, 실제 SSE 연결을 보유한 App만 사용자에게 전달한다. 따라서 v0.5에서는 다음 이유로 Redis Pub/Sub 전체 브로드캐스트 방식을 선택하였다.

  • App Server마다 별도의 Kafka Consumer Group을 만들지 않고도 완료 결과를 전체 App에 전달할 수 있다.
  • 현재 SseEmitter를 보유한 App Server를 사전에 추적하지 않아도 된다.
  • 이미 운영 중인 Redis를 재사용할 수 있어 추가 인프라 비용과 구현 복잡도가 낮다.
  • 완료 결과 전달은 저장이 필요한 핵심 이벤트가 아니라 실시간 알림이므로 Redis Pub/Sub의 특성과 잘 맞는다.
  • 현재 App Server가 2대인 실험 환경에서는 전체 브로드캐스트에 따른 중복 수신 비용이 크지 않다.

결과적으로 Redis Pub/Sub은 연결 소유권을 별도로 관리하지 않으면서도 Scale-out된 App Server 중 실제 SseEmitter를 보유한 인스턴스에 결과를 전달할 수 있는 가장 단순한 방법이라고 판단하였다.

다만 App Server 수가 크게 증가하거나 SSE 연결 수가 많아진다면, 모든 App이 동일한 메시지를 수신하는 구조는 비효율적일 수 있다. 운영 규모가 커지는 경우에는 requestId → appInstanceId 형태의 연결 소유권을 Redis에 저장하고 특정 App으로만 라우팅하거나, SSE 전용 Gateway를 분리하는 방향을 고려할 수 있다.

방식 처리 흐름 장점 단점·트레이드오프 적합한 상황
Kafka 완료 이벤트 전체 브로드캐스트 Worker → mining-completed → App별 서로 다른 Consumer Group → emitter 보유 App만 SSE 전송 기존 Kafka만 사용하므로 구현이 단순함, 완료 이벤트 저장·재처리 가능, App Server 증설 가능 App 수만큼 동일 이벤트를 중복 소비, App 수 증가 시 Consumer Group과 트래픽 증가, 연결 소유권이 명시적으로 관리되지 않음 App Server 수가 적은 실험·소규모 서비스
Redis Pub/Sub 전체 브로드캐스트 Worker 또는 Notification Service → Redis Channel → 모든 App 구독 → emitter 보유 App만 SSE 전송 실시간 전달이 빠르고 구조가 단순함, Kafka Consumer Group을 App마다 만들 필요 없음, 브로드캐스트 용도에 자연스러움 메시지를 저장하지 않아 구독 App 장애 시 유실, 재처리 불가, Kafka 외 Redis 의존성 추가 알림 자체가 일시적이고 결과를 DB에서 복구할 수 있는 서비스
Redis 연결 소유권 + 선택적 라우팅 SSE 연결 시 requestId → appInstanceId 저장 → Kafka 완료 이벤트를 Notification Service가 한 번 소비 → 해당 App으로만 전달 완료 이벤트 중복 소비 제거, 연결 보유 App만 정확히 호출, App 수가 증가해도 효율적 Redis 소유권 등록·만료·정리 필요, App 장애 시 stale mapping 처리 필요, 내부 전달용 HTTP/gRPC/Pub/Sub 설계 필요 운영형 Scale-out App Server
SSE 전용 Gateway Client → SSE Gateway, Worker → Kafka 완료 이벤트 → Gateway → Client App Server를 무상태로 유지, 장기 연결을 한 계층에서 통합 관리, 대규모 연결 확장과 관측이 쉬움 별도 서비스 운영 필요, Gateway 자체의 확장·장애 대응 필요, 아키텍처와 배포 복잡도 증가 SSE 연결이 많고 App Server가 자주 증감하는 대규모 서비스
결과 저장 + Polling Worker → DB/Redis에 결과 저장 → Client가 상태 API 반복 조회 가장 안정적이고 구현이 명확함, App Server가 완전한 무상태, 연결 단절 문제 없음 완료까지 반복 요청 발생, 실시간성이 떨어짐, Polling 주기에 따라 불필요한 부하 또는 지연 발생 실시간 알림이 필수가 아닌 비동기 작업
결과 저장 + SSE 알림 Worker → 결과 저장 → SSE로 완료 신호 → 실패·재연결 시 상태 API 조회 실시간성과 신뢰성을 모두 확보, SSE 이벤트를 놓쳐도 결과 복구 가능 결과 저장소와 SSE 모두 구현해야 하므로 복잡도 증가 운영 환경에서 가장 안전한 사용자 결과 전달 방식

결과

App Server 결과 그래프(Client - App Server)

Event 결과 그래프 (App Server - Kafka - Consumer Server - DB)

SSE 결과 그래프 (Client 측 체감 그래프)

분석

가설 1. Event Driven 아키텍처를 적용하면 API의 응답 성능은 향상되지만, 시스템의 최종 처리량은 Consumer와 DB 처리 속도에 의해 결정될 것이다.

Client - App Server 사이에서의 응답 성능은 약 2,800 req/s 를 기록하며 성능이 향상되었다.

비관적 락과 분산 락의 TPS Peak

v0.3 비관적 락과 v0.4 분산 락의 TPS Peak

v0.5 각 버전별 App Server TPS Peak

v0.5 각 버전 별 App Server TPS Peak

하지만 실제로 요청 - 채굴 - 채굴 결과의 흐름에서는 v0.5에서의 모든 결과는 약 550 req/s(Load Balancer 기준) 를 기록했던 비관적 락 보다 낮은 성능을 보였다.

v0.5 각 버전별 Consumer Server TPS Peak

v0.5 각 버전 별 Consumer Server TPS Peak
sessionId > optimistic-lock > mineId각 VU 500 기준 약 180 req/s, 170 req/s, 100 req/s

병목 지점을 확인하기 위해 먼저 DB의 TPS를 확인해보니, Consumer Server TPS Peak 와 근사한 수치를 보이는 것을 확인했다.

v0.5 각 버전별 DB Commit TPS Peak

v0.5 각 버전 별 DB Commit TPS Peak
sessionId > optimistic-lock > mineId각 VU 500 기준 약 180 req/s, 170 req/s, 100 req/s

Consumer가 이벤트 하나를 처리할 때마다 하나의 채굴 트랜잭션을 Commit하는 구조이기 때문에, 이 수치 만으로는 사실 DB가 병목이라고 단정할 수 없다. 따라서 App Server의 이벤트 발행 속도와 Consumer의 실제 처리 속도를 확인해보기로 했다.

v0.5 각 버전별 Lag Peak

v0.5 각 버전 별 Lag Peak

VU가 증가할수록 Lag Peak 역시 요청 수에 비례하여 증가했다. 특히 VU 500에서는 App Server가 짧은 시간 동안 약 5만 건의 요청을 Kafka에 적재했지만, Consumer는 초당 약 100~180건만 처리할 수 있었기 때문에 대부분의 이벤트가 Kafka에 대기하게 되었다. 결과적으로 API 계층의 병목은 완화되었지만, 실제 채굴 처리량은 Consumer가 이벤트를 소비하고 DB 트랜잭션을 완료하는 속도에 의해 제한된다는 것을 확인할 수 있었다.

가설 2. 동일한 mineId를 Kafka Key로 사용하면 Hot Spot 환경에서도 데이터 정합성은 유지되지만 Consumer의 병렬성은 활용되지 않을 것이다.

mineId는 전체 실험동안 항상 1L 로 고정되었다. 이 때 의 각 파티션에 할당된 메세지 수를 먼저 확인해보았다.

mineId 사용 시 Partition별 메시지 수

v0.5_mineId 의 Partition Messages
Partition 0 은 메세지가 할당 된 적이 없다.

그래프를 통해 알 수 있는 사실은 다음과 같다.

메세지는 항상 Partition 1 에만 적재된다. 병렬적으로 처리되기 위해서는 Cosumer Server 1, Consumer Server 2가 Partition 1 의 메세지를 읽어 처리해야 할 것이다. 하지만 가설에서 예상한 것과 같이 하나의 Consumer Server는 메세지를 소비하지 않고 유휴상태에 있는 것을 확인할 수 있었다.

mineId 사용 시 Consumer별 처리 메시지 수

v0.5_mineId 의 Processed Messages

이와 같이 mineId 처럼 항상 같은 값을 가지는 값을 Message Key로 활용하면, 하나의 Partition에만 메세지가 적재되며, 파티션 소유권(Consumer Server 1 : Partition 1)에 의해 나머지 Consumer Server는 유휴한다는 사실을 확인할 수 있다.

즉, 192.168.0.48에 해당하는 Consumer Server는 Partition 1에 소유권을, 192.168.0.49 에 해당하는 Consumer Server는 Partition 0에 소유권을 가지고 있으며, Hash로 변경된 mineId=1L 은 항상 같은 Partition, Partition 1 에 할당되어 192.168.0.49 에 해당하는 Consumer Server는 유휴했다는 것이다.

가설 3. sessionId를 Kafka Key로 사용하면 Consumer의 병렬성은 증가하지만 동일 광산에 대한 DB 경합으로 인해 Lost Update가 다시 발생할 것이다.

Kafka의 메세지 키를 mineId에서 sessionId로 변경했다. 그로 인해 두 Consumer Server 모두 가동되고, 병렬성을 확보했음을 확인할 수 있었다.

mineId와 sessionId의 Consumer Server TPS

v0.5 mineId, sessionId 에서의 Consumer Server TPS
mineId에서 유휴했던 하나의 Consumer Server를 제외하고서는 모두 비슷한 TPS를 보이고있다.

하지만 병렬성을 확보함과 동시에 그로인한 Trade-Off로 v0.1 ~ v0.4 동안 방지하기 위해 노력했던 Lost Updater가 다시 문제로 떠올랐다.

mineId와 sessionId의 광산 실제 잔량

v0.5 mineId, sessionId 에서의 Mine 실제 잔량
sessionId 에서는 절반 이상 LostUpdate가 발생했다.

가설 4. 병렬성을 유지한 상태에서 DB를 원자적으로 갱신하면 데이터 정합성을 유지하면서 Consumer의 병렬성을 모두 활용할 수 있을 것이다.

가설 3 을 증명하며 sessionId를 Message Key로 변경했고, Lost Update 동시성 문제가 다시 발생했다. v0.3 에서 얻어낸 결론을 토대로 Lost Update를 방지하기 위해서 원자적 업데이트를 적용하기로 했었다.

먼저, 원자적 업데이트를 적용해 동시성 문제가 적절히 관리되어 실제 잔량이 모든 VU에서 0으로 고정되어 있는 것을 확인했다.

원자적 업데이트 적용 후 광산 실제 잔량

v0.5 sessionId, atomic-update 에서의 Mine 실제 잔량
원자적 업데이트를 적용 한 후 잔량이 0으로 고정되어 있다.

또한 병렬성이 확보되어있는 모습도 Consumer Server 의 TPS 를 통해 확인할 수 있었다.

원자적 업데이트 적용 후 Consumer Server TPS

v0.5 sessionId, atomic-update 에서의 Consumer Server TPS
근소한 차이로 atomic-update 가 낮다.

가설 5. Event Driven 아키텍처와 적절한 DB 동시성 제어를 함께 적용하면 기존 분산락 기반 구조보다 더 높은 처리량을 달성할 수 있을 것이다.

가설 2와 3을 통해 Kafka Message Key 선택이 정합성과 병렬성 사이의 Trade-Off를 만든다는 것을 확인했다.

mineId를 Key로 사용할 경우 동일 광산의 이벤트가 하나의 Partition으로 집중되어 데이터 정합성은 유지되었지만, Consumer의 병렬성을 활용하지 못해 처리량이 가장 낮았다.

반대로 sessionId를 Key로 사용할 경우 Consumer의 병렬성은 확보되었지만, 여러 Consumer가 동일 광산의 데이터를 동시에 갱신하면서 Lost Update가 다시 발생하였다. 가설 4에서는 이러한 문제를 해결하기 위해 DB의 원자적 업데이트를 적용하였다. 그 결과 Lost Update는 발생하지 않았으며, Consumer의 병렬성 역시 그대로 유지되는 것을 확인하였다.

v0.5 각 버전별 Consumer Server TPS Peak

v0.5 각 버전 별 Consumer Server TPS Peak

v0.5 각 버전별 광산 실제 잔량

v0.5 각 버전 별 Mine 실제 잔량

Consumer Server의 TPS를 비교하면 sessionId + Atomic UpdatesessionId와 유사한 처리량을 유지하면서도 데이터 정합성을 확보하였다. 즉, 병렬성을 희생하지 않고 동시성 문제를 해결할 수 있었다.

다만 이번 실험에서 기대했던 것처럼 기존 v0.3 비관적 락보다 높은 전체 처리량을 달성하지는 못했다. 이는 Event Driven 아키텍처를 적용하면서 API 계층의 병목은 크게 완화되었지만, 실제 채굴 처리는 여전히 Consumer와 DB가 담당하기 때문이다. 요청은 빠르게 Kafka에 적재되었지만 Consumer의 처리 속도가 이를 따라가지 못하면서 Kafka Lag이 지속적으로 증가했고, 결국 시스템의 최종 처리량은 Consumer 처리 속도에 의해 제한되었다.

즉 이번 실험에서는 Event Driven 아키텍처 자체가 처리량을 높여주는 것이 아니라, 병목의 위치를 변경한다는 점이었다. 점

Redis 분산락에서는 애플리케이션이 병목이었고, Event Driven 아키텍처에서는 Consumer와 DB 처리 구간이 새로운 병목이 되었다. 결과적으로, API의 요청 수용 능력은 크게 향상되었고 sessionId + Atomic Update를 통해 병렬성과 데이터 정합성을 동시에 확보하였다. 그러나 시스템 전체의 End-to-End 처리량은 Consumer 처리 속도에 의해 제한되어, 기존 비관적 락 기반 구조보다도 높은 처리량을 달성하지는 못하였다.

결론

1. 아키텍처 또한 문제를 마법같이 해결해주지 않는다.

이번 실험에서는 Event Driven 아키텍처를 적용하면서 API 계층의 처리 성능은 크게 향상되었다. App Server는 요청을 Kafka에 적재한 뒤 즉시 응답하기 때문에 Client가 체감하는 응답 성능은 기존 구조보다 크게 개선되었다.

하지만 시스템 전체의 처리량은 기대와 달랐다. Kafka에 적재된 이벤트는 Consumer가 처리할 수 있는 속도만큼만 소모되었고, VU가 증가할수록 Kafka Lag 역시 함께 증가하였다. 결국 실제 처리량은 Consumer와 DB 처리 속도에 의해 결정되었으며, 기존 비관적 락 기반 구조보다 더 높은 End-to-End 처리량을 얻지는 못했다.

또한 비동기 구조를 적용하면서 새로운 운영 문제도 함께 나타났다. SSE 연결을 유지하기 위한 Heartbeat와 Emitter 정리, Redis Pub/Sub을 이용한 결과 전달, Nginx의 File Descriptor 제한과 같은 문제는 기존 동기 구조에서는 고려하지 않았던 요소였다.

결국 Event Driven 아키텍처는 병목을 제거하는 기술이라기보다 병목의 위치를 변경하는 설계 방식이라는 것을 확인할 수 있었다.


2. Kafka 의 메세지 키는 비즈니스와 동시성 상황등을 고려해야 할 것이다.

Kafka Message Key는 단순히 이벤트를 어느 Partition으로 보낼지를 결정하는 값이 아니었다. mineId를 Key로 사용하면 동일한 광산의 이벤트가 하나의 Partition으로만 전달되어 데이터 정합성은 자연스럽게 유지되었지만, 하나의 Consumer만 동작하여 병렬성을 활용할 수 없었다.

반대로 sessionId를 Key로 사용하면 두 Consumer가 모두 동작하면서 처리량은 증가했지만, 동일한 광산을 동시에 갱신하게 되어 Lost Update가 다시 발생하였다. 마지막으로 sessionId와 DB 원자적 업데이트를 함께 적용하자 병렬성을 유지하면서도 데이터 정합성을 확보할 수 있었다.

즉, Kafka의 Message Key는 단순한 분산 전략이 아니라 시스템의 동시성 정책을 결정하는 요소였으며, Message Key와 DB 동시성 제어는 각각 독립적으로 설계할 것이 아니라 하나의 전략으로 함께 고려해야 한다.


이번 실험을 시작할 때는 Event Driven 아키텍처를 적용하면 기존 분산락보다 더 높은 처리량을 얻을 것이라 예상하였다. 그러나 실제 결과는 달랐다. API의 응답성은 크게 향상되었지만 시스템 전체 처리량은 오히려 감소하였다. 대신 병목의 위치와 Kafka Key 전략, DB 동시성 제어 사이의 관계를 직접 검증할 수 있었고, Event Driven 아키텍처가 만능 해결책이 아니라 시스템 구조를 바꾸는 설계 선택이라는 점을 확인할 수 있었다.

후속 실험에서는 병목을 다른 계층으로 이동시키는 것이 아니라, DB 쓰기 자체를 줄이거나 한 번에 처리할 수 있는 구조를 통해 병목 자체를 완화하는 방향을 검증해보고자 한다.

추가

SSE Emitter Time Out

VU 300 테스트를 진행하던 중 29,830 / 30,000 요청에서 TypeError: terminated가 발생하며 테스트가 종료되었다. 처음에는 애플리케이션 내부 문제를 의심했고, 로그를 확인해보니 Nginx에서 여러 SSE 연결이 동시에 종료되고 있었다.

192.168.0.20 - GET /api/events/832041f1-85a8-4545-ab62-b7b3765c6bb2 HTTP/1.1 status=200 request_time=294.251 upstream_addr=192.168.0.41:8080 upstream_status=200 upstream_response_time=294.251
192.168.0.20 - GET /api/events/8ea4152c-9b31-441b-bc7f-4ad3d1d68a83 HTTP/1.1 status=200 request_time=294.251 upstream_addr=192.168.0.41:8080 upstream_status=200 upstream_response_time=294.251
192.168.0.20 - GET /api/events/0008a5e7-73c2-4afd-b49c-b592aef69550 HTTP/1.1 status=200 request_time=294.251 upstream_addr=192.168.0.41:8080 upstream_status=200 upstream_response_time=294.251
...

또한 서버에서는 약 30분 후 AsyncRequestTimeoutException이 발생하고 있었는데, 두 현상이 같은 원인인지 확인하기 위해 순차적으로 추적하였다.

2026-08-03T23:56:05.707+09:00  WARN 1 --- [Gold-Rush-Lab] [o-8080-exec-521] i.d.G.c.e.GlobalExceptionHandler         : Response already committed. Ignoring: org.springframework.web.context.request.async.AsyncRequestTimeoutException
2026-08-03T23:56:05.730+09:00  WARN 1 --- [Gold-Rush-Lab] [o-8080-exec-432] i.d.G.c.e.GlobalExceptionHandler         : Response already committed. Ignoring: org.springframework.web.context.request.async.AsyncRequestTimeoutException
...

먼저 클라이언트 코드를 확인했다.

기존 구현에서는 하나의 SSE 연결에서 오류가 발생하면 Promise 전체가 실패하면서 나머지 연결까지 모두 abort()하는 구조였다. 즉, 하나의 연결 오류가 전체 테스트 종료로 이어질 수 있었다.

하지만 서버 로그를 비교해보니 AsyncRequestTimeoutException은 테스트가 종료된 직후가 아니라 약 30분 뒤 발생하였다. 이는 최초 연결 실패와는 다른 문제일 가능성을 의미했다. 동일한 조건으로 다시 테스트한 결과도 같은 결론을 뒷받침했다.

VU 300 Run 1은 30,000 / 30,000 요청을 모두 정상적으로 처리했고, 재연결 오류 역시 발생하지 않았다. 하지만 테스트 종료 약 30분 후 AsyncRequestTimeoutException이 3건 발생하였다.

즉, 클라이언트는 이미 연결을 종료했지만 일부 SseEmitter는 서버에 그대로 남아 Timeout까지 유지되고 있었다. 기존 구현에서는 Callback이 호출되거나 다음 send()가 수행되지 않는 이상 연결 종료를 감지할 방법이 없었다. 따라서 일정 주기로 Heartbeat를 전송하여 연결 종료를 직접 확인하도록 변경하였다.

@Scheduled(fixedDelayString ="${gold-rush.sse.heartbeat-interval-millis:15000}")
public void sendHeartbeat() {
		emitters.forEach((userSessionId, emitter) -> {
		try {
			emitter.send(
				SseEmitter.event().comment("heartbeat")
				);
		} catch (IOException | IllegalStateException exception) {
				remove(userSessionId, emitter);
				}
		});
}

Heartbeat는 15초마다 전송되며, 연결이 종료된 경우 다음 Heartbeat에서 예외를 감지하여 Emitter를 제거하도록 하였다. 또한 동일 사용자가 다시 연결하는 경우 기존 Emitter가 남지 않도록 기존 객체를 명시적으로 종료하도록 수정하였다.

SseEmitter previousEmitter = emitters.put(userSessionId, emitter);
if (previousEmitter != null) {
	previousEmitter.complete();
}

Heartbeat 를 통한 emitter 정리는 운영 환경과 비슷한 처리 방식을 모사하기 위해 추가했다. 각 VU에 따른 실험이 다른 실험에 영향을 주어서는 안되기 때문에, 명시적으로 emitter들을 처리할 수있는 API를 추가적으로 개발했다.

@RestController
@RequiredArgsConstructor
public class SseConnectionCleanupController {

    private final SseCleanupPublisher cleanupPublisher;

    @PostMapping("/internal/sse/connections/cleanup")
    @ResponseStatus(HttpStatus.ACCEPTED)
    public void cleanup() {
        cleanupPublisher.publish();
    }
}

clean up api는 현재 실험 세션을 정리하기 위한 API 임으로 하나의 앱서버에 직접 전송하기보다는, Redis 의 Pub/Sub 을 통해 이벤트를 발행하기로 결정했다. Redis의 Pub/Sub을 활용하는 경우 같은 채널을 구독한 모든 subscriber에게 메시지를 전달(broadcast)하기 때문에, 모든 서버에게 같은 명령을 내리기에 적절하다고 판단했다.

Nginx, File Discruptor

VU 500 환경에서 총 50,000건의 채굴 요청을 발생시켰지만, 일부 Run에서 Kafka 발행 성공 건수가 50,000건에 도달하지 못했다. 처음에는 Kafka 또는 Consumer 구간의 이벤트 유실을 의심했다. 그러나 확인 결과 k6는 모든 요청을 생성했고, Kafka에 발행된 이벤트 역시 Consumer와 SSE까지 모두 처리되었다.

k6 요청 시도:          50,000
Kafka 발행 성공:       49,911
Consumer 처리:         49,911
SSE 수신:              49,911

문제는 Kafka 이전 구간에 있었다. Nginx 로그에서 다음 오류가 확인되었다.

socket() failed (24: No file descriptors available)
while connecting to upstream

리눅스에서는 네트워크 소켓도 파일처럼 File Descriptor로 관리한다. SSE 연결 하나를 프록시하려면 Nginx 기준으로 클라이언트 연결과 App 서버 연결에 각각 하나의 FD가 필요하다.

SSE 500개
→ Client ↔ Nginx 500개
→ Nginx ↔ App 500개
→ 약 1,000 FD 사용

당시 Nginx 컨테이너의 Soft Limit은 1,024였다.

Max open files    1024    524288

SSE 연결만으로 제한에 거의 도달한 상태에서 /mine 요청이 추가되자, Nginx가 App 서버로 전달할 새로운 소켓을 생성하지 못했다. 이를 해결하기 위해 Docker와 Nginx의 FD 제한을 높였다.

ulimits:
  nofile:
    soft: 65535
    hard: 65535
worker_processes auto;
worker_rlimit_nofile 65535;

events {
    worker_connections 4096;
}

결과적으로 이번 누락은 Kafka나 Consumer의 문제가 아니라, 장기 SSE 연결로 인해 Nginx의 File Descriptor가 고갈된 문제였다. SSE나 WebSocket처럼 연결을 오래 유지하는 구조에서는 요청 수뿐 아니라, 연결 하나가 점유하는 소켓과 File Descriptor 수도 함께 고려해야 한다.

Ref.

Kafka

댓글남기기