Skip to content

Conversation

@GitJIHO
Copy link
Member

@GitJIHO GitJIHO commented Aug 4, 2025

개요

부하 테스트를 통한 메일수신 및 DB저장, FCM전송까지의 병목 지점을 찾고 성능 개선

배경

일반적인 순차 동기 처리방식으로 메일 수신 및 DB저장, FCM전송의 전 과정을 수행함에 있어서 부하 테스트를 진행해보니 평균 3.2초, P95 6.6초, 실패율 2%의 성능을 보였다.
이를 개선하기 위해 비동기 배치처리를 도입하였고, 이에 대한 부하테스트를 진행하니 성공율 100%를 기록하였지만 FCM을 배치단위로 발행만 하고 실제 소비자가 FCM을 전송하는 로직은 순차적이라 1000개중 5~600개의 FCM 전송 이후 Redis 타임아웃이 빈번하게 발생하였다.
또한, Redis를 활용한 중복처리단과 캐시 업데이트단에서도 병목 현상이 일어나 성능을 악화시킴을 확인했다.

따라서, 피크 부하 상황에서 사용자 경험 저하 및 시스템 불안정성을 극복하기 위한 성능 개선이 필요했다.

변경된 점

완전 비동기 아키텍처 도입

  • HTTP 계층: 즉시 응답 후 백그라운드 처리
  • Redis Stream 기반 메시지 큐: 안정적인 배치 처리 파이프라인
  • DB/FCM 완전 분리: 각각 독립적인 배치 처리 워커

Article 중복 처리 조회 Mutil Get 적용

// 기존: 개별 Redis 조회 (N번의 네트워크 호출)
for (String cacheKey : cacheKeys) {
    String cachedValue = redisTemplate.opsForValue().get(cacheKey);  // N번 호출
}

// 개선: multiGet을 활용한 배치 조회 (1번의 네트워크 호출)
for (int i = 0; i < cacheKeys.size(); i += REDIS_BATCH_SIZE) {
    List<String> batchKeys = cacheKeys.subList(i, Math.min(i + REDIS_BATCH_SIZE, cacheKeys.size()));
    List<String> cachedValues = redisTemplate.opsForValue().multiGet(batchKeys);  // 50개씩 배치 조회
    
    for (int j = 0; j < batchKeys.size(); j++) {
        String cacheKey = batchKeys.get(j);
        String cachedValue = cachedValues.get(j);
        // 중복 확인 로직
    }
}

FCM 병렬 처리 구현

// 50개 스레드 풀로 병렬 FCM 전송
this.fcmExecutor = Executors.newFixedThreadPool(PARALLEL_THREADS, r -> {
    Thread t = new Thread(r, "fcm-turbo-" + System.nanoTime());
    t.setDaemon(true);
    t.setPriority(Thread.NORM_PRIORITY + 1);
    return t;
});

// CompletableFuture 기반 비동기 병렬 처리
CompletableFuture<?>[] futures = fcmNotifications.stream()
    .map(notification -> CompletableFuture.runAsync(() -> {
        processSingleNotificationFast(notification, successCount, failCount, invalidTokenCount);
    }, fcmExecutor))
    .toArray(CompletableFuture[]::new);

동시성 안전 로깅 시스템

// AtomicInteger를 활용한 스레드 안전 결과 집계
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);
AtomicInteger invalidTokenCount = new AtomicInteger(0);

// 병렬 처리에서도 정확한 통계 수집
successCount.incrementAndGet();  // 스레드 안전한 증가

Redis 캐시 비동기 업데이트

private void updateRedisCacheAsync(Map<String, String> toCache) {
    if (toCache.isEmpty()) return;
    
    // ReactiveRedisTemplate으로 비동기 배치 업데이트
    reactiveRedisTemplate.opsForValue()
        .multiSet(toCache)  // 100개 캐시를 1번에 배치 업데이트
        .doOnSuccess(result -> log.debug("Cache updated for {} keys", toCache.size()))
        .doOnError(error -> log.error("Cache update failed: {}", error.getMessage()))
        .subscribe();  // 논블로킹 비동기 실행
}

참고자료

순차 동기처리 부하테스트 결과 (동시 1000회 요청)
image

기존 비동기 배치처리 부하테스트 결과 (동시 1000회 요청, Redis 타임아웃 발생)
image
image

최종 비동기 배치처리 부하테스트 결과 (동시 1000회 요청)
image

성능 개선 결과

k6 부하 테스트 비교

동기 처리 방식:

  • 성공률: 98.30%
  • 평균 응답시간: 3,148ms
  • P95 응답시간: 6,116ms
  • Redis 타임아웃: 600개 처리 후 발생

최종 비동기 배치 방식:

  • 성공률: 100.00% (+1.7% 향상)
  • 평균 응답시간: 2,715ms (-433ms, 13.8% 향상)
  • P95 응답시간: 4,907ms (-1,209ms, 19.8% 향상)
  • Redis 타임아웃: 완전 해결 (0개)

리소스 효율성 개선

CPU 사용률:

  • 기존: 순차 처리로 단일 코어 사용
  • 개선: 50개 스레드로 멀티코어 활용
  • CPU 활용률 증가: 50배

메모리 사용 최적화:

  • Redis 연결 수: 개별 호출 대비 98% 감소
  • 네트워크 버퍼: 배치 처리로 효율적 사용
  • GC 압박: AtomicInteger로 객체 생성 최소화

네트워크 최적화:

  • Redis 네트워크 호출: 98% 감소 (MGET 배치)
  • FCM API 호출: 병렬 처리로 처리량 50배 증가
  • 전체 네트워크 레이턴시: 평균 200ms 단축

관련 이슈

close #107

Copy link
Contributor

@anaconda77 anaconda77 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

성능 개선에 대한 지식이 적어 변경 사항 이해하는데 초점을 맞추다 보니 피드백 드릴 여지가 많이 부족하네요..배치 및 병렬 처리로 I/O 병목을 개선한 덕에 유의미한 성능 개선 지표가 나와 인상깊게 보았습니다!

} catch (Exception e) {
log.error("Cache update ERROR for key '{}': {}", entry.getKey(),
e.getMessage());
private void updateRedisCacheAsync(Map<String, String> toCache) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

캐시 업데이트를 비동기적으로 수행하도록 변경하였다고 하셨는데, 순차적으로 처리되는 다음 배치 작업 이전에는 캐시가 업데이트 되는건가요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100프로 보장하지는 않습니다..! 비동기의 한계가 아닌가 싶네요 😢

private void processSingleNotificationFast(FcmNotification fcmNotification,
AtomicInteger successCount, AtomicInteger failCount, AtomicInteger invalidTokenCount) {

if (!fcmNotification.isValid()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invalid한 토큰 값이 정확히 무엇인지에 대한 로그를 남길 필요는 없나요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로그를 정리하는 과정에서 실수로 삭제한 것 같네요. 다시 로그 추가하였습니다 👍

long duration = System.currentTimeMillis() - startTime;
logBatchSummary(fcmNotifications.size(), result, duration);
CompletableFuture.allOf(futures)
.get(BATCH_TIMEOUT_SECONDS, TimeUnit.SECONDS);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get 메서드의 역할은 모든 작업 결과를 기다리는 것인가요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네네 맞습니다. 비동기긴 하지만 일정 시간동안 대기하지 않고 트랜잭션이 끝나버린다면 fcm 전송 자체가 안될수도 있기 때문에 사전 설정한 시간만큼은 블로킹합니다!

FcmSenderOrchestrator fcmSenderOrchestrator) {
super(redisTemplate, reactiveRedisTemplate, batchConfig, optionalObjectMapper);
this.fcmSenderOrchestrator = fcmSenderOrchestrator;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

실제 소비자가 FCM을 전송하는 로직은 순차적이라 1000개중 5~600개의 FCM 전송 이후 Redis 타임아웃이 빈번하게 발생하였다.

이 이유는 아티클 배치 처리부터 fcm 전송까지의 전체 흐름에서, fcm 순차 전송 과정의 병목 때문에 시간이 많이 소요되어 Redis와의 연결이 끊어졌다고 이해하면 될까요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 정확합니다! 병목으로 인해 500ms로 설정된 타임아웃이 걸려 1000ms로 늘려도 걸리는 경우가 있더라구요
현재 방식으로는 타임아웃이 걸리지 않습니다 :)

@GitJIHO
Copy link
Member Author

GitJIHO commented Aug 27, 2025

변경사항

article 저장 로직 배치단위 원자성 부여로 ack 처리 개선 및 멱등성 보장

기존 saveAll 메서드, 배치 저장 프로세서의 try-catch문을 제거하여 배치사이즈의 저장 로직의 원자성을 부여, exception이 발생 시 ack를 보내지 않아 Pending Entity List에 남아있도록 한다

비동기 배치처리 재시작시 failover 처리

비동기 배치처리를 재시작할 경우 Pending Entity List에 남아있는 redis Stream이 있는지 우선적으로 확인하고 있을경우 consumer가 소비하게 하여 failover처리가 가능하도록 구현

@GitJIHO GitJIHO merged commit 2a01b8e into dev Sep 9, 2025
1 check passed
@GitJIHO GitJIHO deleted the Refactor/issue-#107 branch September 9, 2025 11:49
@GitJIHO GitJIHO removed the request for review from Gyu-won September 10, 2025 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

refactor refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Refactor] 부하 테스트를 통한 메일수신 및 DB저장, FCM전송까지의 병목 지점을 찾고 성능 개선

3 participants