[SSA] 비동기 이벤트 발행기 테스트

2025. 9. 8. 04:38·SSA/Back

@Async를 통해 비동기 처리 진행

@Async를 통해 다른 쓰레드를 사용하여 비동기적으로 처리하게 되면,

쓰레드가 달라지기 때문에 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)를 사용하였을 때

트랜잭션이 커밋 되지 않는 문제는 발생하지 않게 됩니다.

 

REQUIRESE_NEW 를 사용하는 방식의 경우, 데드락 발생 위험이 생기기 때문에

@Async를 사용하는 것이 더 나은 방안이라 생각했습니다.

 

그런데 @Async를 사용하는 경우, 비동기로 작업을 처리할 쓰레드를 어떻게 설정할 것인지에 대해 고민해야 합니다.

지금 생각나는 방법들로는 다음과 같은 방법들이 있고,

  • 2.1 싱글 쓰레드로 설정
  • 2.2 쓰레드 풀을 사용하도록 설정
  • 2.3 가상 쓰레드를 사용하도록 설정

 

각 방식의 특징과 장단점을 비교해가며 선택하는 과정이 필요해 보입니다.

 

아래에서는 각 방법들을 테스트해 보고,

가장 적합한 방식을 취하는 것으로 결정합니다.

 

이때 애플리케이션에 적합한 설정을 위해서는 서버를 따로 띄운 후 실제 발생 가능한 시나리오를 적용하여 테스트하는 것이 좋겠지만,

비용 상의 문제도 있고 아직 실제 서비스까지는 계획하지 않았기에 로컬 기기에서 테스트 후 어느 정도의 경향성만 파악하는 것으로 합니다.

 

 

 

## 테스트 공통 사항

### 테스트 환경

개인 로컬 기기에서 테스트를 진행하는 관계로 다른 여러 프로세스에 의해 CPU 성능이나 효율이 잘 나오지 않을 수도 있습니다.

금전적인 사항도 있고 학습용 테스트의 편이성을 위해 현재 맥북으로 테스트를 진행하도록 하겠습니다.

  • 서버 사양
    1. core(물리): 8개 --> sysctl -n hw.physicalcpu로 확인
    2. core(논리): 8개 --> sysctl -n hw.logicalcpu로 확인
    3. RAM: 24GB --> expr $(sysctl -n hw.memsize) / 1024 / 1024 / 1024로 확인

 

 

 

## 공통 요청

 

 

### 측정 지표

  • TPS
  • 평균 응답 시간
  • CPU 사용량: top을 통해 확인
  • 메모리 사용량: top을 통해 확인
  • 예외 발생률
  • 이벤트 처리 소요 시간 (평균)
    • 객체 생성 시점 - DB 기록 시점
    • DB 기록 시점 - 처리 완료 시점
    • 객체 생성 시점 - 처리 완료 시점
    • DB 업데이트 시점 - 처리 완료 시점

사용할 쿼리는 다음과 같습니다.

SELECT SUM(initialized_date_to_created_date_duration) / COUNT(*) AS '객체 생성 시점 - DB 기록 시점',
       SUM(created_date_to_processed_date_duration) / COUNT(*) AS 'DB 기록 시점 - 처리 완료 시점',
       SUM(initialized_date_to_processed_date_duration) / COUNT(*) AS '객체 생성 시점 - 처리 완료 시점',
       SUM(TIMESTAMPDIFF(MICROSECOND, processed_date, updated_date))/ 1000 / COUNT(*) AS 'DB 업데이트 시점 - 처리 완료 시점'
FROM test_domain_event
WHERE id > 2000;

 

 

### 시나리오

import http from 'k6/http';
import { check, sleep } from 'k6';

// k6 테스트의 실행 옵션을 정의하는 부분입니다.
export const options = {
    // 'stages'는 시간에 따라 부하를 어떻게 조절할지 정의하는 시나리오입니다.
    stages: [
        // 1. 웜업 및 램프업(Ramp-up) 단계
        // 30초 동안 초당 요청 수를 0에서 2000까지 서서히 늘립니다.
        { duration: '30s', target: 2000 },

        // 2. 본 테스트 (Sustained Load) 단계
        // 초당 2000개의 요청을 3분 20초 동안 꾸준히 유지합니다.
        { duration: '3m20s', target: 2000 },

        // 3. 램프다운(Ramp-down) 단계
        // 10초 동안 초당 요청 수를 0으로 서서히 줄입니다.
        { duration: '10s', target: 0 },
    ],
};

export default function () {
    const res = http.get('http://host.docker.internal:8082/test/async/{요청 API별 수정}');

    // 응답 상태 코드가 200인지 확인합니다.
    check(res, { 'status is 200': (r) => r.status === 200 });

    // 요청 사이에 약간의 대기 시간을 줍니다.
    sleep(1);
}

 

초당 2,000개의 요청을 가정하는 이유는 강의 개설에 따라 한 과목당 대략 몇 백 명의 사용자가 몰릴 것으로 예상했기 때문입니다.

또한, 강의 개설 후 수강신청이 완료된 학생들이 마음이 바뀌어서 수강취소 요청을 하고 또 다른 학생들이 수강신청 요청을 하는 상황을 가정하여 대략 3분 정도 꾸준히 요청이 들어올 것이라 가정했습니다.

 

 

### Kafka Producer 설정

@Configuration
public class TestKafkaProducerConfig {

    @Value("${spring.kafka.producer.bootstrap-servers}")
    private String bootstrapServers;

    @Bean
    public KafkaTemplate<String, Object> kafkaTemplate() {
        return new KafkaTemplate<>(new DefaultKafkaProducerFactory<>(getDefaultConfigs()));
    }

    @Bean
    public Map<String, Object> getDefaultConfigs() {
        Map<String, Object> configs = new HashMap<>();
        configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        configs.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        configs.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
        return configs;
    }
}

 

Kafka에 메시지를 produce 하기 위해서는 KafkaTemplate.send() 메서드를 사용해야 합니다.

 

이 메서드의 반환값은 CompletableFuture 타입인데, 사용하는 방식에 따라

블로킹으로 처리할 수도 있고, 논블로킹으로 처리할 수도 있습니다.

 

  • 블로킹 방식
    1. 싱글 쓰레드
    2. 쓰레드 풀
    3. 가상 쓰레드
  • 논블로킹 방식
    1. 쓰레드 풀
    2. 가상 쓰레드

 

위 5개 모든 방식을 테스트한 후, 현재 서비스에 가장 적합한 처리 방법을 찾아 보겠습니다.

 

 

 

 

 

 

 

# 싱글 쓰레드

싱글 쓰레드 설정

@Configuration
public class SingleThreadAsyncConfig {

    public static final String SINGLE_THREAD_ASYNC_TASK_EXECUTOR = "singleThreadAsyncTaskExecutor";

    @Bean
    public Executor singleThreadAsyncTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(1);
        executor.setMaxPoolSize(1);
        executor.setQueueCapacity(10_000_000);
        executor.setThreadNamePrefix("ST-");
        executor.initialize();
        return executor;
    }
}
  • 큐 사이즈는 10,000,000입니다만, 처리량에 따라 실패가 발생할 수 있습니다.

 

  • grafana를 통해 스크립트 실행을 시각화한 내용입니다.

 

## 결과 분석

약 4분간 총 381,564건의 요청을 보냈습니다.

초당 평균 약 1583건(1583.75 req/s)의 트래픽을 처리했고 응답 시간이 지연되고 일부 요청이 실패하는 현상이 나타났습니다.

 

 

### 1. 성능 지표 (K6)

먼저 k6를 통해 확인한 API의 외부 성능 지표입니다.

  • 요청 처리량 (RPS): 초당 1,583건
  • 요청 성공률: 99.97% (381,564건 중 101건 실패)
  • 평균 응답 시간 (avg): 147.13ms
  • 중앙값 응답 시간 (med): 84.52ms
  • P(95) 응답 시간: 537.7ms

요청의 절반(median)은 84.5ms 안에 처리되었습니다. 평균(avg) 속도도 147ms로 준수합니다.

 

 

### 2. 내부 측정 지표 (DB)

 

  • 객체 생성 ~ DB 기록 시점: 84.25ms
  • DB 기록 ~ 처리 완료 시점: 738,754.19ms (약 738초)
  • 객체 생성 ~ 처리 완료 시점 (총 소요 시간): 738,838.95ms (약 738.8초)

 

 

### 3. 요약

1. k6 부하 테스트 지표

지표 결과 의미
요청 처리량 (RPS) 1,583.75 /s 초당 처리 가능한 요청 수
요청 성공률 99.97% 요청이 정상적으로 처리된 비율
평균 응답 시간 (avg) 147.13 ms 전체 요청의 평균적인 응답 시간
중앙값 응답 시간 (med) 84.52 ms 요청의 50%가 이 시간 내에 완료됨
P(95) 응답 시간 537.7 ms 요청의 95%가 이 시간 내에 완료됨

2. 데이터베이스 내부 측정 지표

측정 구간 평균 소요 시간 (ms) 분석
① 객체 생성 ~ DB 기록 84.25 ms API의 실제 응답 시간, k6 중앙값과 거의 일치
② DB 기록 ~ 처리 완료 738,754.19 ms 비동기 백그라운드 작업 시간
③ 객체 생성 ~ 처리 완료 738,838.95 ms 이벤트 하나에 대한 총 처리 시간

싱글 쓰레드 발행기에서는 이벤트를 발행하는 쓰레드의 처리 속도가

초당 동시 요청 수 이상의 이벤트 이상의 이벤트를 처리하지 못한다면

요청량이 많아질수록 이벤트를 처리(메세지 브로커에 발행)하는데 소요되는 시간이 선형적으로 증가하는 문제가 발생합니다.

 

 

# 쓰레드 풀 - 블로킹

쓰레드 풀 설정 (블로킹, 논블로킹 공통)

@Configuration
public class ThreadPoolAsyncConfig {

    public static final String THREAD_POOL_ASYNC_TASK_EXECUTOR = "threadPoolAsyncTaskExecutor";

    @Bean(name = THREAD_POOL_ASYNC_TASK_EXECUTOR)
    public Executor singleThreadAsyncTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(200);
        executor.setMaxPoolSize(200);
        executor.setQueueCapacity(10_000_000);
        executor.setThreadNamePrefix("TP-");
        executor.initialize();
        return executor;
    }
}
  • 큐 사이즈는 10,000,000이고, 쓰레드 풀 사이즈를 200으로 설정했습니다.
  • 풀 사이즈에 대해서는 수정의 여지가 있지만 단순히 톰캣의 쓰레드 풀과 동일하게 맞춰 보고자 했습니다.

 

  • grafana를 통해 스크립트 실행을 시각화한 내용입니다.

 

## 결과 분석

약 4분간 총 198,910건의 요청을 보냈습니다.

초당 평균 약 747건(747.42 req/s)의 트래픽을 처리했고 일부 요청이 실패하는 현상이 나타났습니다..

 

 

### 1. 성능 지표 (K6)

먼저 k6를 통해 확인한 API의 외부 성능 지표입니다.

  • 요청 처리량 (RPS): 초당 747건
  • 요청 성공률: 99.97% (198,910건 중 49건 실패)
  • 평균 응답 시간 (avg): 1.21s
  • 중앙값 응답 시간 (med): 1.13s
  • P(95) 응답 시간: 2.16ms

 

요청의 절반(median)은 1.13s 안에 처리되었습니다. 평균(avg) 속도는 1.21s로 싱글 쓰레드에 비해 느립니다.

 

 

### 2. 내부 측정 지표 (DB)

  • 객체 생성 ~ DB 기록 시점: 223.3769ms
  • DB 기록 ~ 처리 완료 시점: 40,027.3037ms (약 40초)
  • 객체 생성 ~ 처리 완료 시점 (총 소요 시간): 40,251.2004ms (약 40.25초)

 

 

### 3. 요약

1. k6 부하 테스트 지표

백그라운드 작업이 빨라졌으니, API 응답 속도도 좋아졌거나 최소한 유지될 것이라 예상했습니다.

하지만 k6로 비교 분석한 지표는 다음과 같습니다.

지표 개선 전 (Before) 개선 후 (After) 변화
처리량 (RPS) 1,583 /s 747 /s 🔻 52.8% 감소
평균 응답 시간 (avg) 147 ms 1,210 ms (1.21초) 🔺 723% 증가
중앙값 응답 시간 (med) 84 ms 1,130 ms (1.13초) 🔺 1245% 증가
P(95) 응답 시간 537 ms 2,160 ms (2.16초) 🔺 302% 증가

2. 데이터베이스 내부 측정 지표

측정 구간 평균 소요 시간 (ms) 분석
① 객체 생성 ~ DB 기록 223.3769 ms API의 실제 응답 시간
② DB 기록 ~ 처리 완료 40,027.3037 ms 비동기 백그라운드 작업 시간
③ 객체 생성 ~ 처리 완료 40,251.2004 ms 이벤트 하나에 대한 총 처리 시간

백그라운드 작업 시간이 12.3분에서 40초로 약 94% 이상 단축되었습니다.

 

그러나 예상과 반대로, API의 핵심 성능 지표들이 모두 악화되었습니다.

처리량은 절반이 되었고, 실제 체감 응답 시간(median)은 84ms에서 1.13초로 12배 이상 증가했습니다.

 

요청 처리가 싱글 쓰레드의 선형적 증가와는 달리 전체 부하의 초반부까지는 처리 향상을 보였으나,

중반부 처리 향상 이후 요청에 대해서는 싱글 쓰레드와 똑같이 증가하는 양상을 보였습니다.

 

비동기 작업은 기존보다 훨씬 더 CPU와 같은 시스템 자원을 많이 사용할 것입니다.

그 결과, 비동기 작업 쓰레드와 API 요청을 처리하는 쓰레드가 CPU 같은 자원을 놓고 경쟁하게 되었을 것입니다.

이 자원 경합 때문에 API 쓰레드의 작업(초기 DB 저장 등)이 지연되었고, 이것이 전체적인 API 응답 시간 증가와 처리량 감소로 이어졌을 것입니다.

 

 

 

# 쓰레드 풀 - 논블로킹

쓰레드 풀 설정은 "# 쓰레드 풀 - 블로킹"과 동일합니다.

 

  • grafana를 통해 스크립트 실행을 시각화한 내용입니다.

 

## 결과 분석

약 4분간 총 365,479건의 요청을 보냈습니다.

초당 평균 약 1516건(1516.67 req/s)의 트래픽을 처리했고, 일부 요청이 실패하는 현상이 나타났습니다.

 

 

### 1. 성능 지표 (K6)

먼저 k6를 통해 확인한 API의 외부 성능 지표입니다.

  • 요청 처리량 (RPS): 초당 1516건
  • 요청 성공률: 99.96% (365,479건 중 124건 실패)
  • 평균 응답 시간 (avg): 195.44ms
  • 중앙값 응답 시간 (med): 125.37ms
  • P(95) 응답 시간: 596.05ms

 

요청의 절반(median)은 125.37ms 안에 처리되었습니다. 평균(avg) 속도는 195.44ms로 쓰레드 풀(동기)에 비해 매우 빨라졌습니다.

 

 

### 2. 내부 측정 지표 (DB)

  • 객체 생성 ~ DB 기록 시점: 11.8689ms
  • DB 기록 ~ 처리 완료 시점: 14,950.0737ms (약 15초)
  • 객체 생성 ~ 처리 완료 시점 (총 소요 시간): 14,962.0087ms (약 15초)

 

 

### 3. 요약

1. K6 부하 테스트 지표

지표 쓰레드 풀 (동기) 쓰레드 풀 (비동기) 변화
처리량 (RPS) 747 /s 1,516 /s ✅ 103% 증가
중앙값 응답 시간 (med) 1,130 ms (1.13초) 125 ms ✅ 88.9% 감소
P(95) 응답 시간 2,160 ms (2.16초) 596 ms ✅ 72.4% 감소

2. 데이터베이스 내부 측정 지표

측정 구간 평균 소요 시간 (ms) 분석
① 객체 생성 ~ DB 기록 11.8689 ms API의 실제 응답 시간
② DB 기록 ~ 처리 완료 14,950.0737 ms 비동기 백그라운드 작업 시간
③ 객체 생성 ~ 처리 완료 14,962.0087 ms 이벤트 하나에 대한 총 처리 시간

처리량은 쓰레드 풀(블로킹)의 두 배 이상이고, 핵심 지표인 중앙값 응답 시간은 1.13초에서 125ms로 단축되었습니다.

현재로서는 그나마 좋은 지표를 보이고 있습니다.

 

 

 

다음은 현재까지의 추이입니다.

## 테스트 지표 변화

지표 구분 지표 싱글 쓰레드 쓰레드 풀 (동기) 쓰레드 풀 (비동기)
k6 (API 성능) 처리량 (RPS) 1,583 /s 747 /s 1,516 /s
  중앙값 응답 (med) 84 ms 1,130 ms 125 ms
DB (내부 성능) API 처리 시간 (①) 84 ms 223 ms 12 ms
  비동기 작업 시간 (②) 12.3분 40초 15초

 

 

 

 

 

# 가상 쓰레드 - 블로킹

가상 쓰레드 설정 (블로킹, 논블로킹 공통)

@Configuration
public class VirtualThreadAsyncConfig {

    public static final String VIRTUAL_THREAD_ASYNC_TASK_EXECUTOR = "virtualThreadAsyncTaskExecutor";

    @Bean(name = VIRTUAL_THREAD_ASYNC_TASK_EXECUTOR)
    public Executor virtualThreadAsyncTaskExecutor() {
        ExecutorService concurrentExecutor = Executors.newVirtualThreadPerTaskExecutor();
        return new TaskExecutorAdapter(concurrentExecutor);
    }
}

 

  • grafana를 통해 스크립트 실행을 시각화한 내용입니다.

 

## 결과 분석

4분 간 총 155,614건의 요청을 보냈습니다.

초당 평균 약 645건(약 645.8 req/s)의 트래픽을 처리했습니다.

요청 성공률 100%를 달성하며 안정성을 확보했지만, 이전 테스트에 비해 처리량(RPS)이 감소하고 API 응답 시간이 크게 증가했습니다.

 

 

### 1. 성능 지표 (K6)

먼저 k6를 통해 확인한 API의 외부 성능 지표입니다.

  • 요청 처리량 (RPS): 초당 645건
  • 요청 성공률: 100% (155,614건 중 0건 실패)
  • 평균 응답 시간 (avg): 1.84s
  • 중앙값 응답 시간 (med): 1.75s
  • P(95) 응답 시간: 3.16s

쓰레드 풀(논블로킹) 테스트에서 초당 1,516건을 125ms의 응답 속도로 처리했던 것과 비교하면, 처리량은 절반 이하로 감소했고 응답 시간은 14배가량 크게 증가했습니다. 하지만 실패율이 0%가 된 점은 긍정적인 지표라고 볼 수 있습니다.

 

 

### 2. 내부 측정 지표 (DB)

  • 객체 생성 ~ DB 기록 시점: 289.16ms
  • DB 기록 ~ 처리 완료 시점: 7.50ms
  • 객체 생성 ~ 처리 완료 시점 (총 소요 시간): 297.18ms

가장 큰 지연 지점이었던 DB 기록 ~ 처리 완료 시간이 이전 40초에서 7.5ms로 거의 실시간 수준으로 단축되었습니다.

 

 

### 3. 요약

1. K6 부하 테스트 지표

지표 결과 의미
요청 처리량 (RPS) 645.85 /s 초당 처리 가능한 요청 수 (이전 대비 감소)
요청 성공률 100% 모든 요청이 정상적으로 처리됨 (안정성 확보)
평균 응답 시간 (avg) 1.84 s 전체 요청의 평균적인 응답 시간 (이전 대비 증가)
중앙값 응답 시간 (med) 1.75 s 요청의 50%가 이 시간 내에 완료됨 (응답성 저하)
P(95) 응답 시간 3.16 s 요청의 95%가 이 시간 내에 완료됨

2. 데이터베이스 내부 측정 지표

측정 구간 평균 소요 시간 (ms) 분석
① 객체 생성 ~ DB 기록 289.16 ms API의 초기 DB 저장 시간
② DB 기록 ~ 처리 완료 7.50 ms 비동기 로직 처리 시간 (병목 해결)
③ 객체 생성 ~ 처리 완료 297.18 ms 이벤트 하나의 비즈니스 로직 총 처리 시간
④ 처리 완료 ~ DB 업데이트 335.29 ms 최종 상태를 DB에 업데이트하는 시간

가상 쓰레드(블로킹) 테스트 결과, 비즈니스 로직(7.5ms)은 빨라졌지만 API 응답 시간(1.75s)이 크게 늘어난 것은

병목 지점이 '비동기 로직'에서 '메시지 브로커로 이벤트를 발행하는 과정' 자체로 변경되었음을 보여 줍니다.

아마도 안정성을 위해 블로킹 방식으로 메시지 발행한 점이 성능 저하의 원인일 것 같습니다.

 

 

 

# 가상 쓰레드 - 논블로킹

가상 쓰레드 설정은 "# 가상 쓰레드 - 블로킹"과 동일합니다.

 

  • grafana를 통해 스크립트 실행을 시각화한 내용입니다.

 

## 결과 분석

마지막 테스트 결과입니다. 약 4분 30초간 총 379,249건의 요청을 보냈으며,

API 응답 속도와 백그라운드 처리 효율성 모두에서 상위권의 성능을 기록했습니다.

 

 

### 1. 성능 지표 (K6)

먼저 k6를 통해 확인한 API의 외부 성능 지표입니다.

  • 요청 처리량 (RPS): 초당 1,418건
  • 요청 성공률: 99.91% (379,249건 중 338건 실패)
  • 평균 응답 시간 (avg): 140.03ms
  • 중앙값 응답 시간 (med): 57.79ms
  • P(95) 응답 시간: 619.88ms

이전 테스트에서 동기 발행 방식으로 인해 1.75초까지 치솟았던 중앙값 응답 시간이 57.79ms로 단축되었습니다.

이는 싱글 쓰레드 테스트(84ms)보다도 빠른 속도입니다. 처리량 또한 최상이고, API가 빠르고 동작했다고 볼 수 있습니다.

다만, 실패율이 다른 테스트 상황에 비해 다소 높습니다.

 

 

### 2. 내부 측정 지표 (DB)

내부 지표에서는 API 응답 속도와 비동기 로직의 추가적인 성능 향상 결과를 확인할 수 있습니다.

  • 객체 생성 ~ DB 기록 시점: 7.58ms
  • DB 기록 ~ 처리 완료 시점: 17,059.55ms (약 17초)
  • 객체 생성 ~ 처리 완료 시점 (총 소요 시간): 17,067.20ms (약 17초)

객체 생성 ~ DB 기록 시간에 7.58ms가 걸렸고 API가 이벤트를 기록하는 데 빠르게 수행하는 것을 보여 줍니다.

또한, 이전 테스트에서 40초가 걸렸던 DB 기록 ~ 처리 완료 시간이 17초로 절반 이상 추가 단축되었습니다.

 

 

### 3. 요약

1. K6 부하 테스트 지표

지표 결과 의미
요청 처리량 (RPS) 1418.19 /s 최고 수준의 처리량
요청 성공률 99.91% 높은 안정성 유지
평균 응답 시간 (avg) 140.03 ms 전체 요청의 평균적인 응답 시간
중앙값 응답 시간 (med) 57.79 ms 가장 빠른 응답 속도 달성
P(95) 응답 시간 619.88 ms 일부 지연 요청도 양호한 수준으로 관리됨

2. 데이터베이스 내부 측정 지표

측정 구간 평균 소요 시간 (ms) 분석
① 객체 생성 ~ DB 기록 7.58 ms API 로직이 가벼워짐
② DB 기록 ~ 처리 완료 17,059.55 ms 비동기 로직 추가 최적화 성공 (40초 → 17초)
③ 객체 생성 ~ 처리 완료 17,067.20 ms 이벤트 하나의 비즈니스 로직 총 처리 시간
④ 처리 완료 ~ DB 업데이트 0.32 ms 최종 DB 업데이트는 거의 즉시 이루어짐

'매우 빠른 API 응답'과 '효율적인 백그라운드 처리'라는 두 가지 성능 지표를 달성했습니다.
그러나, 높은 실패율은 간과해서는 안 됩니다.
만약, 가상 쓰레드(논블로킹) 방식을 선택하는 경우에는 실패하는 요청에 대한 대비책을 마련해야 합니다.

 

# 테스트 지표 변화 최종 추이

지표 구분 지표 1. 싱글 쓰레드 (블로킹) 2. 쓰레드 풀 (블로킹) 3. 쓰레드 풀 (논블로킹) 4. 가상 쓰레드 (블로킹) 5. 가상 쓰레드 (논블로킹)
K6 (API 성능) 처리량 (RPS) 1,583 /s 747 /s 1,516 /s 645 /s 1,418 /s
  중앙값 응답 (med) 84 ms 1,130 ms 125 ms 1,750 ms 58 ms
  실패율 0.02% 0.02% 0.03% 0.00% 0.08%
DB (내부 성능) API 처리 시간 (①) 84 ms 223 ms 12 ms 289 ms 8 ms
  비동기 작업 시간 (②) 12.3분 40초 15초 7.5 ms 17초

 

# 결론

1. 블로킹(Blocking) vs 논블로킹(Non-Blocking) 발행

  • 블로킹 방식 (2, 4번): Kafka에 메시지를 발행하고 응답을 받을 때까지 대기(send().get())하는 방식입니다. 이로 인해 API 응답 시간은 길어지고 처리량은 낮아지지만, 실패율이 매우 낮거나 0%에 수렴하는 높은 안정성을 보였습니다.
  • 논블로킹 방식 (3, 5번): 메시지 발행 요청 후 응답을 기다리지 않는 방식입니다. API 응답 시간과 처리량 면에서는 압도적인 성능을 보여주었지만, Kafka 브로커의 처리 용량을 넘어서는 요청이 몰릴 경우 TimeoutException으로 인한 실패율 증가라는 명확한 단점이 있었습니다.

2. 플랫폼 쓰레드(싱글/쓰레드 풀) vs 가상 쓰레드

  • 플랫폼 스레드 (1, 2, 3번): OS 스레드를 직접 사용하는 방식입니다. 특히 쓰레드 풀(논블로킹) 방식은 균형 잡힌 성능을 보여 줬습니다.
  • 가상 스레드 (4, 5번): I/O 대기가 많은 작업에서 적은 OS 스레드로 높은 동시성을 처리하는 데 강점이 있습니다. 논블로킹 방식과 결합했을 때 가장 빠른 58ms의 응답 속도를 기록하며 최고의 성능을 보여 줬지만, 그만큼 Kafka 발행 실패율도 가장 높았습니다. 반면, 블로킹 방식과 결합했을 때는 실패율 0%라는 안정성을 보여 줬습니다.

3. 최종 선택: 가상 쓰레드 이벤트 발행 + 카프카 블로킹 발행

'수강 신청'이라는 서비스의 특성을 고려할 때, 약간의 응답 시간 지연을 감수하더라도 요청 실패가 없는 안정성과 데이터 정합성을 확보하는 것이 우선이라고 생각합니다.

따라서 최종적으로 '가상 쓰레드(블로킹)' 방식을 선택합니다. 이 방식은 비록 API 응답 시간은 다소 길지만, 실패율 0%를 달성할 수 있고 사용자의 입장에서 '요청이 누락되었다'는 불쾌감을 느끼지 않게 해 줄 수 있습니다. 향후 알림(Notification)과 같이 일부 유실이 허용되는 부가 서비스에 한해 '가상 쓰레드(논블로킹)' 방식을 도입하여 성능을 극대화하는 전략을 고려해 볼 수 있을 것 같습니다.

 

 

 

 

 

 

 

# 참고

카프카에 produce 하는 과정을 논블로킹으로 처리한 경우,
예외 발생 횟수가 급격하게 증가했습니다.

원인은 아래와 같습니다.

 

## 원인

TimeoutException이 발생했기 때문입니다. 즉, 애플리케이션이 Kafka 브로커가 처리할 수 있는 속도보다 훨씬 빠르게 메시지를 생산하면서 발생한 문제입니다.

 

이는 토픽이 카프카에 publish되는 속도보다 훨씬 빠른 속도로 레코드를 대기열(큐)에 넣기 때문에 발생합니다.

KafkaTemplate.send() 메서드를 호출하면 브로커로 전송하기 위해 ProducerRecord가 내부 버퍼에 저장됩니다.

kafkaTemplate.send()는 전송 여부에 관계없이 ProducerRecord가 버퍼에 저장되면 즉시 반환됩니다.

레코드는 메시지당 전송 오버헤드를 줄이고 처리량을 늘리기 위해,

브로커로 전송 전에 batch 처리됩니다. (아래 그림의 RecordBatch 참고)

 

https://d2.naver.com/helloworld/6560422

 

 

 

 

 

 

 

** 참고 **

https://ttl-blog.tistory.com/1366

 

https://ttl-blog.tistory.com/1322

 

https://one-armed-boy.tistory.com/entry/k6-Prometheus-Grafana-%EC%9E%90%EB%8F%99-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0-with-Docker-compose

 

 

'SSA > Back' 카테고리의 다른 글

[SSA] 수강 기능, 알림 기능을 이벤트 기반으로 분리  (0) 2025.09.09
[SSA] 이벤트 발행에 트랜잭셔널 아웃박스 패턴을 적용  (1) 2025.09.08
[SSA] 알림 모듈 구조 개선 및 간단한 동시성 제어 ..  (2) 2025.08.30
[SSA] 새로운 트랜잭션을 만들면 데드락이 발생하는 것을 테스트해 보자  (0) 2025.08.29
[SSA] 트랜잭션을 새로 만들어서 사용하면 ?  (0) 2025.08.29
'SSA/Back' 카테고리의 다른 글
  • [SSA] 수강 기능, 알림 기능을 이벤트 기반으로 분리
  • [SSA] 이벤트 발행에 트랜잭셔널 아웃박스 패턴을 적용
  • [SSA] 알림 모듈 구조 개선 및 간단한 동시성 제어 ..
  • [SSA] 새로운 트랜잭션을 만들면 데드락이 발생하는 것을 테스트해 보자
하가네
하가네
  • 하가네
    하 렌
    하가네
  • 전체
    오늘
    어제
    • 분류 전체보기 (128) N
      • Computer Science (27)
        • 운영체제 (7)
        • 데이터통신 (10)
        • 자료구조 (4)
        • 논리회로 (0)
        • 확률 및 통계 (0)
        • 데이터베이스 (2)
        • AI소프트웨어 (3)
        • 컴퓨터네트워크 (1)
      • Design (5) N
        • OOP - 객체 지향 프로그래밍 (3) N
        • DDD - 도메인 주도 개발 (데이터베이스 주도 .. (0)
        • EDA - 이벤트 기반 아키텍처 (1)
        • MSA - 마이크로서비스 아키텍처 (0)
        • ADD - AI 주도 개발 (1)
      • Language (2)
        • Java (0)
        • TypeScript (2)
      • Framework (12)
        • Spring (9)
        • NestJS (3)
      • Engine (3)
        • Elasticsearch (1)
        • GraphQL + Apollo Federation (2)
      • Plugin - Extension (1)
        • VS Code (1)
        • IntelliJ (0)
      • Tips (2)
        • 터미널 명령어 (1)
        • 우분투 명령어 에러 (1)
      • SSA (26)
        • Front (1)
        • Back (23)
        • DB (1)
        • 기획 (1)
      • CNU SW 아카데미 (43)
        • 1주차 (5)
        • 2주차 (5)
        • 3주차 (2)
        • 4주차 (1)
        • 5주차 (3)
        • 6주차 (2)
        • 7주차 (0)
        • 8주차 (2)
        • 9주차 (14)
        • 10주차 (0)
        • 11주차 (1)
        • 12주차 (0)
        • 13주차 (2)
        • 14주차 (2)
        • 15주차(최종 프로젝트) (3)
        • 최종 프로젝트 이후 (1)
      • 모각코 (6)
        • 2023 동계 (6)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    ci/cd
    프론트엔드/백엔드
    Husky
    생산성
    개발자경험(DX)
    ESLint
    Typescript
    아키텍처
    lint-staged
    DX(DeveloperExperience)
    릴리스엔지니어링
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.
하가네
[SSA] 비동기 이벤트 발행기 테스트
상단으로

티스토리툴바