배움 __IL/addtionalBackEnd

알림 발송 방식 비동기 방식으로의 개선의 건

Mo_bi!e 2025. 4. 20. 14:47

I. 들어가며

향후 수만 명의 사용자에게 알림을 보내야 할 수도 있는 시스템을 개발하고 있다면, 처음부터 확장성 있는 구조를 설계하는 것이 중요하다. Spring의 비동기 처리 방식을 활용하여 알림 발송 성능을 어떻게 개선할 수 있었는지 개발 단계에서 미리 적용할 것을 정리한다.

 

II. 왜 비동기 알림 방식이 필요했나?

개발 중인 시스템은 약 20,000명의 사용자에게 동시에 알림을 발송해야 할 요구사항이 있었다. 단순 for-loop 구조로는 충분할 것처럼 보이지만, 네트워크 I/O가 포함된 알림 발송은 한 명당 수백 ms의 대기 시간이 발생한다. 이런 구조는 향후 운영 환경에서 병목이 될 가능성이 높다.

 

아직 성능 이슈가 직접 드러나진 않았지만, 비동기 처리 기반의 구조를 선제적으로 도입을 고려하고 있다.

III. 알림 발송은 I/O Bound 작업이다

이메일, SMS 발송과 같은 작업은 외부 API나 게이트웨이와 통신해야 하므로 대부분의 시간이 네트워크 대기 시간(RTT) 으로 소비된다.

즉 CPU를 많이 사용하는 게 아니라 대부분의 시간 동안 스레드 리소스가 비효율적으로 블로킹되어 있는 구조이다.

이럴 때는 한 스레드가 순차적으로 처리하는 것보다, non-blocking, concurrent execution model 기반의 high-throughput I/O 처리(대기 없이 여러 알림을 병렬로 빠르게 처리) 구조가 효율적 일 수 있다.

 

참고

1. non-blockingblocking

구분 의미
non-blocking 호출한 뒤 대기하지 않고 즉시 다음 작업 수행 (ex: WebClient, NIO)
blocking 호출한 후 결과가 올 때까지 대기 (ex: RestTemplate, File I/O)
blocking은 응답이 올 때 까지 블로킹 하고, 그동안 스레드는 아무것도 하지 못한다.
2. concurrent executionsequential (or single-threaded) execution
구분 의미
concurrent execution 여러 작업을 동시에 진행 (멀티스레드, 비동기)
sequential execution 하나씩 순서대로만 실행 (단일 스레드 루프)

for 루프 안에서 한 명 처리하고, 그 다음 처리한다. 이 경우 sequential

 

3. high-throughputlow-throughput

구분 의미
high-throughput 단위 시간당 많은 처리량 (ex: 초당 500건 발송)
low-throughput 단위 시간당 적은 처리량 (ex: 초당 10건 발송)

IV. Spring에서 비동기 처리

1. 구성요소

Spring은 아래와 같은 구성 요소를 통해 간단하게 비동기 처리를 지원한다.

  • @EnableAsync: 애플리케이션에서 비동기 기능을 활성화
  • ThreadPoolTaskExecutor: 우리가 사용할 스레드 풀을 설정
  • @Async: 특정 메서드를 비동기로 실행하라고 지정

2. 실제 코드 적용 예시

(1) 기존방식

private void sendNotificationsByStatus(NotificationStatus status, boolean checkReserveTime) {
    List<ScheduledNotification> notifications = scheduledNotificationReader.getScheduledNotificationListByStatus(status);

    if (checkReserveTime) {
        notifications = notifications.stream()
            .filter(notification -> notification.reserveDate().isBefore(now()))
            .filter(notification -> Duration.between(notification.reserveDate(), now()).toHours() < MAX_ELAPSED_HOURS) // 1시간 넘으면 필터링
            .toList();
    }

// 순차처리
    for (ScheduledNotification notification : notifications) {
        scheduledNotificationProcessor.process(notification);
    }
}
  • 단일 스레드에서 순차적으로 각 알림을 처리한다. (process)
  • 사용자 수가 많을수록 전체 처리 시간은 선형 증가한다.
  • 스레드가 I/O 대기 중에도 스레드 리소스가 비효율적으로 블로킹되어 있는 구조 있다.

(2) 비동기 방식

- 비동기용 스레드풀 설정

@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean("notiExecutor")
    public ThreadPoolTaskExecutor notiExecutor() {
        ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
        ex.setCorePoolSize(8);
        ex.setMaxPoolSize(32);
        ex.setQueueCapacity(5000);
        ex.initialize();
        return ex;
    }
}

 

- 실제 적용

@Slf4j
@RequiredArgsConstructor
@Service
public class ScheduledNotificationService {

    private final ScheduledNotificationProcessor processor;

    // 비동기 호출용 메서드
    @Async("notiExecutor")
    public void sendNotification(ScheduledNotification notification) {
        processor.process(notification);
    }

    // 기존 동기 루프 → 비동기로 바꾸기
    public void sendNotificationsByStatus(NotificationStatus status, boolean checkReserveTime) {
        List<ScheduledNotification> notifications = scheduledNotificationReader.getScheduledNotificationListByStatus(status);

        if (checkReserveTime) {
            notifications = notifications.stream()
                .filter(notification -> notification.reserveDate().isBefore(now()))
                .filter(notification -> Duration.between(notification.reserveDate(), now()).toHours() < MAX_ELAPSED_HOURS) // 1시간 넘으면 필터링
                .toList();
        }
        
        for (ScheduledNotification notification : list) {
            sendNotification(notification); // 비동기 호출
        }
    }
}

 

sendNotification() 메서드는 별도 스레드에서 비동기적으로 실행된다.

  • @Async로 각 알림을 별도 스레드에서 병렬로 처리한다.
  • 동시에 여러 사용자에게 빠르게 알림을 보낼 수 있다.
  • 네트워크 지연 시간을 겹쳐 처리하므로 전체 시간 단축 효과가 있다.

3. 스레드풀 튜닝 포인트

성능 향상을 위해선 스레드풀 설정도 중요하다.

항목 추천 수치설명
corePoolSize 8 기본 스레드 수 (CPU 수 × 2 권장)
maxPoolSize 32 최대 스레드 수 (게이트웨이 QPS 고려)
queueCapacity 5000 대기 작업을 버퍼링할 수 있는 큐 크기

 

- corePoolSize

일반적으로 CPU 수 × 2로 시작하는 것을 권장한다.

알림 발송은 CPU 작업보다 I/O 대기 시간이 많아, 스레드 수를 조금 넉넉히 잡아야 효율적이다.

 

- maxPoolSize

게이트웨이(API, SMS 등)의 최대 동시 호출 수(QPS)를 고려하여 설정한다.

실제로는 시스템 부하와 게이트웨이 제한을 모니터링하며 조정한다.

 

- queueCapacity

비동기 작업이 몰릴 경우, 일시적으로 큐에 쌓이게 된다.

큐가 너무 작으면 스레드 풀이 꽉 찼을 때 작업이 거부될 수 있으므로 넉넉히 설정한다.

 

4. 성능 개선 시뮬레이션

방식 초당 발송 수 20,000명 소요 시간
단일 루프 15 TPS 약 15분 이상
@Async + 8스레드 120 TPS 약 2분

V. 도입 시 주의 사항

  1. @Async + @Transactional 혼용은 같은 클래스 내부에서는 제대로 작동하지 않을 수 있다. → 별도 클래스로 분리 할것
  2. DB 커넥션 풀 설정도 스레드 수에 맞게 조정이 필요하다. 예: hikari 최대 풀 수 증가
  3. 중복 발송 방지: notification_id, user_id 조합으로 유니크 키 설정하거나 멱등성 처리 필요

 

VI. 차후 확장 계획

비동기 처리는 현재 개발 단계에서 충분히 성능 개선 효과가 있지만, 운영 환경에 들어간 이후 더 복잡한 요구사항이 생기면 아래 구조로 확장 가능

  • Spring Batch: 집계 작업을 분할 처리
  • Kafka / Redis Queue(MQ): 메시지 기반 비동기 처리

MQ 등의 도입 시기는 언제가 바람직할까?

재시도, 장애 분리, 분산 처리가 필요해 질 때 도입이 권장된다.

 

VII. 나가며

단순하면서도 확장 가능한 구조를 선택하는 것이 장기적인 유지보수와 성능 측면에서 유리하다고 판단한다.