Spring에서 비동기 프로그래밍을 활용하면, 메인 스레드가 직접 작업을 수행하지 않고, 별도의 스레드를 활용하여, 작업을 처리하여, 성능을 최적화하고 응답속도를 개선할 수 있습니다. 아래에서 Spring의 @Async , ThreadPool 개념 및 다양한 예제 등을 통해 이해해보겠습니다
1. 비동기 프로그래밍이란?
Main Thread가 직접 작업을 처리하지 않고, Sub Thread에게 작업을 위임하여 병렬적으로 실행하는 프로그래밍 방식
- 작업이 끝날 때까지 기다리지 않고, 다른 작업을 동시에 진행할 수 있습니다.
✅ 언제 사용하면 좋을까?
-
- 실시간 응답이 필요하지 않은 작업을 처리할 때 비동기(Async) 방식을 사용하면 효율적입니다.
- 예시 : 이메일 전송, 알림(Notification) 처리, Push 알림, 파일 업로드/다운로드, 데이터 백업/배치 작업 등등
2. 스레드(Thread)와 스레드풀(Thread Pool)의 필요성
✅ 스레드를 직접 생성할 경우 문제점
- `new Thread()`를 사용할 경우 매번 새로운 스레드가 생성되므로, 과도한 스레드 생성은 CPU/메모리 과부하를 유발할 수 있습니다.
- 너무 많은 스레드가 생성되면 컨텍스트 스위칭(Context Switching) 비용 증가로 인해 성능 저하가 발생합니다.
- 또한 각 스레드는 메모리를 차지하며, 과도한 스레드 생성은 OutOfMemoryError를 유발할 가능성이 있습니다
✅ 스레드풀(Thread Pool) 도입
미리 일정 개수의 스레드를 생성해놓고, 작업 요청이 들어오면 해당 스레드를 재사용하여 처리
- 불필요한 스레드 생성을 줄이거나, 스레드 개수를 제한하여 시스템 리소스를 보호하고, 안정적인 성능을 유지 할 수 있습니다.
- 또한 대량의 요청을 효과적으로 처리할 수 있도록 스레드 대기열(Work Queue)을 활용할 수 있습니다.
- Java에서는`ThreadPoolExecutor`를 사용하여 관리하고, Spring에서는`ThreadPoolTaskExecutor`를 통해 손쉽게 스레드풀을 활용할 수 있습니다. `ThreadPoolExecutor` 와 `ThreadPoolTaskExecutor` 의 차이는 아래와 같습니다.
비교 항목 ThreadPoolExecutor (Java 기본) ThreadPoolTaskExecutor (Spring 제공) 제공 주체 Java (JDK) Spring Framework Spring과 연동 ❌ (Spring과 무관) ✅ (Spring과 통합) Bean 등록 ❌ (직접 관리) ✅ (@Bean으로 관리 가능) 예외 처리 ❌ 직접 처리 필요 ✅ Spring이 감지 가능 로깅 (MDC) ❌ 지원 안함 ✅ 로깅 시스템과 연동 가능 설정 방식 직접 인스턴스 생성 @Bean으로 설정 가능 사용 용도 일반적인 멀티스레딩 Spring의 비동기 처리 (@Async)
3. ThreadPoolExecutor
스레드풀의 동작 원리는 스레드 개수, 대기열(Queue), 최대 스레드 개수, 스레드 유지 시간 등에 따라 결정
✅ThreadPoolExecutor의 주요 옵션
옵션 | 설명 |
CorePoolSize | 기본적으로 유지할 최소 스레드 개수 |
MaxPoolSize | 허용할 최대 스레드 개수 |
KeepAliveTime | `CorePoolSize` 초과 스레드가 유휴 상태일 경우 얼마나 유지할지 설정 |
WorkQueue (대기열, BlockingQueue) | 처리할 작업을 보관하는 큐 (대기 작업 저장소) |
KeepAliveTime | `CorePoolSize` 초과 스레드가 유휴 상태일 경우 얼마나 유지할지 설정 |
ThreadFactory | 새로운 스레드를 생성할 때 사용되는 팩토리 객체를 지정. (기본적으로 `Executors.defaultThreadFactory()`)를 사용 |
RejectedExecutionHandler | 큐가 가득 찼을 때의 처리 방법 설정. ( 기본적으로 AbortPolicy(예외 발생)로 설정) |
✅예제 코드 분석
ThreadPoolExecutor executorPool =
new ThreadPoolExecutor(5, 10, 3, TimeUnit.SECONDS, new ArrayBlockingQueue<>(50));
- 최대 5개의 스레드를 생성하여 기본 작업을 처리합니다. `CorePoolSize (5)`
- 추가적인 작업이 들어오면 50개까지 대기열(Queue)에 저장합니다. `ArrayBlockingQueue<>(50)`
- 대기열이 가득 차면, 10개까지 추가적인 스레드를 생성합니다. `MaxPoolSize (10) `
- 10개의 스레드도 모두 바쁘고, 대기열이 가득 찼다면 작업이 거부됩니다.
- 추가로 생성된 (5개 초과) 스레드는 3초 동안 유휴 상태가 유지되면 제거됩니다. ` KeepAliveTime (3초), TimeUnit.SECONDS `
4. Spring @Async와 ThreadPool 설정
Spring에서 `@Async`를 활성화하고,`ThreadPool` 설정을 해주지 않으면, 기본적으로 `SimpleAsyncTaskExecutor `가 사용
✅ 기본 스레드풀 SimpleAsyncTaskExecutor의 특징
- 작업이 들어올 때마다 새로운 스레드를 생성
- 스레드 풀링(재사용) 기능 없음
- 스레드 개수 제한 없음 → CPU 및 메모리 과부하 위험
- 빠르게 실행되는 작은 작업에 적합
그러나 대량의 요청이나 복잡한 계산 작업 등의 조건을 고려하여 처리하려면 `ThreadPool`을 설정해주어야 합니다.
Spring에서는 `ThreadPoolTaskExecutor`를 통해 스레드풀을 손쉬운 설정이 가능합니다.
✅ Spring ThreadPool 설정 예제
기본적인 @Async 설정
@Configuration
@EnableAsync // 비동기 기능 활성화
public class AsyncConfig {
}
- `@EnableAsync`를 선언하면 Spring이 자동으로 비동기 실행을 지원.
ThreadPool 설정 예제
@Configuration
public class AppConfig {
@Bean(name = "taskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); // 최소 10개의 스레드 유지
executor.setMaxPoolSize(50); // 최대 50개까지 확장 가능
executor.setQueueCapacity(100); // 대기 큐 크기 설정
executor.setKeepAliveSeconds(30); // 유휴 스레드 유지 시간
return executor;
}
}
- `@Bean`으로 등록해서 `taskExecutor`이라는 스레드 풀 이름을 지정해줄 수 있습니다.
- 최소 10개, 최대 50개 스레드 사용
- 대기 큐(WorkQueue) 크기: 100
- 30초 동안 사용하지 않으면 유휴 스레드 제거
✅ @Async 사용 예제
@Service
public class EmailService {
@Async("taskExecutor") // taskExecutor 스레드풀 사용
public void sendEmail() {
System.out.println("Sending email on thread: " + Thread.currentThread().getName());
}
}
- `@Async("taskExecutor")`를 사용하면 설정한 스레드풀을 사용하여 비동기 실행
5. @Async와 SpringAOP
✅ @Async가 동작하는 원리
- `@Async`는 Spring AOP를 기반으로 동작합니다.
- `@Async`가 선언된 메서드는 Spring AOP 프록시를 통해 비동기 실행을 담당하는 별도 스레드에서 실행됩니다.
- 내부적으로는 Spring의 TaskExecutor(기본적으로 SimpleAsyncTaskExecutor 또는 사용자가 설정한 ThreadPoolTaskExecutor)를 활용하여 비동기 처리를 수행합니다.
✅ Spring AOP의 프록시 방식
- Spring AOP는 프록시(Proxy) 패턴을 사용하여 메서드 실행을 가로채서 추가적인 기능(예: 비동기 실행, 트랜잭션 관리 등)을 적용합니다. Spring AOP는 다음 두 가지 방식 중 하나를 사용하여 프록시를 생성합니다.
- JDK 동적 프록시 (인터페이스 기반)
- 클래스가 인터페이스를 구현하고 있다면, Spring은 JDK 동적 프록시(java.lang.reflect.Proxy)를 생성합니다.
- 이 경우, 프록시는 인터페이스를 기반으로 동작하므로, 인터페이스를 통해서만 해당 객체에 접근할 수 있습니다.
- CGLIB 프록시 (클래스 기반)
- 클래스가 인터페이스를 구현하지 않으면, Spring은 CGLIB(Code Generation Library) 프록시를 사용하여 클래스를 직접 상속하는 방식으로 프록시 객체를 생성합니다.
- CGLIB는 바이트코드를 조작하여 원본 클래스를 상속하는 새로운 클래스를 동적으로 생성합니다.
✅ @Async 주의점
`@Async`는 Spring AOP를 기반으로 동작하기때문에, 다음과 같은 상황을 주의해서 사용해야합니다.
🚨@Async 메서드 내 예외 처리 주의
- `@Async` 메서드에서 예외가 발생해도 호출자에게 예외가 전파되지 않습니다.
- 이는 `@Async` 메서드가 다른 스레드에서 실행되기 때문에, 호출자와 예외 컨텍스트가 분리되어 있기 때문입니다.
- 즉 예외가 발생해도 로그에만 남고, 호출자에서는 예외를 감지할 수 없습니다.
- 예외를 처리하기 위해서는 `UncaughtExceptionHandler`를 등록하여 비동기 예외를 전역적으로 처리해야합니다.
🚨같은 클래스 내에서 @Async 메서드를 직접 호출X
- `@Async`는 Spring AOP 프록시를 통해 실행될 때만 비동기 실행됩니다.
- 같은 클래스 내부에서 `@Async`가 선언된 메서드를 직접 호출하면, 프록시 객체가 아닌 원본 객체의 메서드를 호출하므로, 비동기 실행되지 않고 동기로 실행되어 `@Async`가 동작하지 않습니다.
🚨@Async와 @Transactional을 함께 사용할 때의 문제
- `@Transactional`은 트랜잭션 컨텍스트를 현재 실행 중인 스레드에 바인딩합니다.
- 하지만 `@Async`는 새로운 스레드에서 실행되므로, 기존 트랜잭션 컨텍스트를 상속받지 않습니다.
- 즉, 비동기 실행된 메서드에서는 기존 트랜잭션과 분리되어, `@Transactional`이 적용되지 않거나, 데이터베이스 변경 사항이 커밋되지 않는 등의 문제가 발생할 수 있습니다.
🚨new 키워드로 객체를 생성하여 @Async 메서드 호출
- Spring의 `@Async`는 Spring 컨테이너가 관리하는 Bean에만 적용됩니다.
- `new` 키워드로 직접 객체를 생성하면 Spring이 제공하는 AOP 프록시를 거치지 않으므로, @Async가 동작하지 않습니다.
🚨ThreadPool 설정 고려
- `@Async`를 사용할 때, 스레드풀을 설정하지 않으면 앞에서 알아본 것 처럼 Spring의 기본 `SimpleAsyncTaskExecutor`가 사용됩니다.
- `SimpleAsyncTaskExecutor`는 새로운 요청이 들어올 때마다 새로운 스레드를 생성하므로 대량의 요청이나 복잡한 계산 작업 등에서 스레드 과부하 발생 가능성이 있습니다.
- 따라서 상황에따라, `ThreadPoolTaskExecutor`를 통해 스레드풀을 설정하여, 스레드 개수를 제한하고, 효율적인 리소스 관리를 고려해야합니다.
'BackEnd' 카테고리의 다른 글
계층적 캐싱(Caffeine + Redis)을 도입하여 쿠폰 발급 시스템 성능 최적화하기 (0) | 2025.03.20 |
---|---|
Redis Lua Script를 활용한 쿠폰발급 동시성 제어 및 성능 개선기 (0) | 2025.03.19 |
트래픽을 고려한 쿠폰 발급 서버 아키텍처 개선기 (0) | 2025.03.17 |
스프링 IoC와 DI (0) | 2025.03.07 |
스프링에서 SOLID 원칙 알아보기 (0) | 2025.03.06 |