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-blocking ↔ blocking
구분 | 의미 |
non-blocking | 호출한 뒤 대기하지 않고 즉시 다음 작업 수행 (ex: WebClient, NIO) |
blocking | 호출한 후 결과가 올 때까지 대기 (ex: RestTemplate, File I/O) |
구분 | 의미 |
concurrent execution | 여러 작업을 동시에 진행 (멀티스레드, 비동기) |
sequential execution | 하나씩 순서대로만 실행 (단일 스레드 루프) |
for 루프 안에서 한 명 처리하고, 그 다음 처리한다. 이 경우 sequential
3. high-throughput ↔ low-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. 도입 시 주의 사항
- @Async + @Transactional 혼용은 같은 클래스 내부에서는 제대로 작동하지 않을 수 있다. → 별도 클래스로 분리 할것
- DB 커넥션 풀 설정도 스레드 수에 맞게 조정이 필요하다. 예: hikari 최대 풀 수 증가
- 중복 발송 방지: notification_id, user_id 조합으로 유니크 키 설정하거나 멱등성 처리 필요
VI. 차후 확장 계획
비동기 처리는 현재 개발 단계에서 충분히 성능 개선 효과가 있지만, 운영 환경에 들어간 이후 더 복잡한 요구사항이 생기면 아래 구조로 확장 가능
- Spring Batch: 집계 작업을 분할 처리
- Kafka / Redis Queue(MQ): 메시지 기반 비동기 처리
MQ 등의 도입 시기는 언제가 바람직할까?
재시도, 장애 분리, 분산 처리가 필요해 질 때 도입이 권장된다.
VII. 나가며
단순하면서도 확장 가능한 구조를 선택하는 것이 장기적인 유지보수와 성능 측면에서 유리하다고 판단한다.
'배움 __IL > addtionalBackEnd' 카테고리의 다른 글
멀티모듈 아키텍처 설계 및 적용 (0) | 2025.03.09 |
---|---|
StreamAPI 의 실행순서와 병렬처리 (0) | 2025.02.07 |
스프링과 싱글톤: 효율성과 안정성의 조화 (0) | 2025.01.12 |
N+1 문제와 Fetch 전략을 모르면 JPA를 잘못 쓰고 있을수도... (0) | 2025.01.04 |