지난 글에서는 멀티스레드 환경에서 발생하는 동시성 문제와 이를 해결하기 위한 동기화 기법들을 알아보았습니다. 자바 백엔드 애플리케이션은 수많은 사용자 요청을 처리하기 위해 멀티스레드를 활용하지만, 요청이 들어올 때마다 스레드를 새로 생성하고 바꾸는 것은 서버에 엄청난 부담을 줍니다.
실무에서는 이러한 자원 낭비를 막고 대량의 요청을 효율적으로 관리하기 위해 ‘스레드 풀(Thread Pool)’을 사용합니다. 또한, 스프링 프레임워크에서는 이를 기반으로 무거운 작업을 백그라운드에서 실행할 수 있는 @Async 비동기 처리 기능도 제공합니다.
이번 글에서는 스레드 풀의 내부 원리부터 스프링에서 비동기 처리를 올바르게 구현하는 방법까지 심도 있게 파헤쳐 보겠습니다.
1. 스레드 직접 생성의 문제점과 스레드 풀(Thread Pool)의 등장
자바에서 new Thread()를 호출하여 스레드를 직접 생성하는 것은 생각보다 비용이 아주 큰 작업입니다.
- 메모리 소모: 자바 스레드는 생성될 때마다 운영체제(OS)로부터 독립적인 스택(Stack) 메모리 영역을 할당받습니다. 스레드가 무분별하게 늘어나면 메모리가 고갈되어
OutOfMemoryError가 발생합니다. - CPU 오버헤드: CPU 코어 수보다 많은 스레드가 생성되면, CPU는 스레드를 번갈아 실행하기 위해 컨텍스트 스위칭(Context Switching)을 자주 수행합니다. 이 과정에서 정작 비즈니스 로직을 처리하는 시간보다 스레드를 전환하는 데 더 많은 CPU 자원을 낭비하게 됩니다.
이러한 문제를 해결하기 위해 등장한 개념이 바로 스레드 풀(Thread Pool)입니다. 스레드 풀은 공장의 ‘상주 직원’과 같습니다. 필요한 만큼의 스레드를 미리 만들어 두고, 작업 요청이 들어오면 대기 중인 스레드에 일을 맡긴 뒤, 작업이 끝나면 스레드를 파괴하지 않고 다시 풀(Pool)로 회수하여 재사용하는 방식입니다.
2. 자바 ThreadPoolExecutor의 작동 원리와 핵심 요소
자바의 java.util.concurrent 패키지는 스레드 풀을 관리하는 핵심 클래스인 ThreadPoolExecutor를 제공합니다. 스레드 풀이 작업을 처리하는 메커니즘을 이해하려면 다음 4가지 핵심 설정을 알아야 합니다.
- CorePoolSize (기본 스레드 수): 스레드 풀이 유지할 최소한의 스레드 개수입니다. 일이 없어도 이만큼의 스레드는 항상 대기합니다.
- MaximumPoolSize (최대 스레드 수): 작업이 너무 많아질 때 스레드 풀이 최대로 늘릴 수 있는 스레드 개수입니다.
- WorkQueue (작업 대기 큐): 처리해야 할 작업(Task)들이 스레드가 배정되기 전까지 대기하는 공간입니다. 주로 선입선출(FIFO) 구조의 큐(Queue)를 사용합니다.
- KeepAliveTime (초과 스레드 유지 시간): 기본 스레드 수를 초과해서 생성된 스레드가 일을 하지 않고 대기할 때, 이 시간이 지나면 메모리 절약을 위해 해당 스레드를 제거합니다.
⚠️ 스레드 풀의 작업 처리 흐름 (매우 중요!)
많은 개발자가 “기본 스레드가 꽉 차면 최대 스레드까지 먼저 늘어난다”고 오해하곤 합니다. 하지만 실제 자바의 스레드 풀은 다음과 같은 순서로 동작합니다.
- 작업이 요청되면 현재 실행 중인 스레드 수가 CorePoolSize보다 작은지 확인하고, 작다면 새로운 스레드를 생성하여 즉시 작업을 할당합니다.
- CorePoolSize가 가득 찼다면, 새로운 스레드를 만드는 대신 작업을 WorkQueue(대기 큐)에 넣습니다.
- 만약 WorkQueue 마저도 가득 차서 더 이상 작업을 담을 수 없다면, 그제야 스레드 개수를 MaximumPoolSize까지 늘려서 새로운 스레드를 만들어 작업을 처리합니다.
- 만약 MaximumPoolSize까지 꽉 찬 상태에서 작업이 더 들어오면, 거절 정책(RejectedExecutionHandler)에 따라 예외를 발생시키거나 요청을 버립니다.
3. 스프링(Spring)의 @Async를 활용한 비동기 처리
웹 애플리케이션을 개발하다 보면 외부 API 호출, 대량의 메일 발송, 파일 업로드 등 시간이 오래 걸리는 작업을 마주하게 됩니다. 사용자의 요청 스레드가 이 무거운 작업이 끝날 때까지 마냥 기다리게(Blocking) 하는 것은 좋지 못한 사용자 경험을 줍니다.
스프링에서는 서비스 메서드 위에 @Async 어노테이션을 붙여주는 것만으로, 메인 요청 스레드가 아닌 스레드 풀의 별도 스레드에서 해당 작업을 백그라운드로 실행하게 만들 수 있습니다.
3-1. @Async 활성화 및 사용 예시
먼저 설정 클래스에 @EnableAsync를 붙여 비동기 기능을 활성화합니다.
@Configuration
@EnableAsync
public class AsyncConfig {
// 비동기 설정 활성화
}
그 후, 백그라운드에서 돌릴 서비스 메서드에 @Async를 적용합니다.
@Service
public class EmailService {
@Async
public void sendWelcomeEmail(String userEmail) {
// 3초 이상 걸리는 헤비한 메일 전송 로직
try { Thread.sleep(3000); } catch (InterruptedException e) {}
System.out.println("메일 전송 완료: " + userEmail);
}
}
사용자가 회원가입 버튼을 누르면 컨트롤러는 sendWelcomeEmail()을 호출한 뒤 메일이 실제로 가든 말든 곧바로 화면에 “회원가입 성공” 응답을 내려줄 수 있습니다. 실제 메일 발송은 별도의 스레드가 조용히 처리합니다.
4. 실무 적용 시 주의해야 할 @Async의 치명적인 함정
스프링의 @Async는 내부적으로 AOP(Aspect-Oriented Programming)와 프록시(Proxy) 패턴을 기반으로 동작합니다. 이 원리를 모르면 비동기가 동작하지 않는 버그를 겪게 됩니다.
4-1. 자가 호출(Self-Invocation) 불가 규칙
동일한 클래스 내의 메서드 A가 @Async가 붙은 메서드 B를 호출하면 비동기로 동작하지 않고 일반 동기 메서드처럼 작동합니다.
- 이유: 스프링은 외부에서 빈(Bean)을 호출할 때 프록시 객체를 거쳐 스레드 풀을 할당합니다. 하지만 클래스 내부에서 직접 호출(
this.method())하면 프록시를 거치지 않고 타겟 객체를 직접 바라보기 때문에@Async기능이 무시됩니다. - 해결책: 비동기 메서드는 반드시 별도의 서비스 클래스로 분리하여 주입받아 사용해야 합니다.
4-2. 접근 제어자 및 반환 타입 제한
@Async가 적용된 메서드는 반드시 외부에서 접근할 수 있도록public으로 선언되어야 합니다.- 반환 타입은 결과가 없는
void이거나, 결과를 반환받아야 한다면 비동기 전용 래퍼 클래스인CompletableFuture를 사용해야 합니다.
5. 실무 표준: 커스텀 ThreadPool TaskExecutor 설정하기
스프링 부트에서 구체적인 스레드 풀 설정을 하지 않고 @Async를 사용하면, 기본적으로 SimpleAsyncTaskExecutor라는 스레드 풀을 사용하게 될 수 있습니다. 이 기본 설정은 스레드를 재사용하지 않고 요청마다 새로 생성하는 매우 위험한 방식입니다.
따라서 실무 시스템을 구축할 때는 반드시 아래와 같이 ThreadPoolTaskExecutor를 직접 빈으로 등록해서 서비스 성격에 맞게 제한해야 합니다.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "mailExecutor")
public Executor mailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // 항상 유지할 기본 스레드 수
executor.setMaxPoolSize(20); // 최대 스레드 수
executor.setQueueCapacity(50); // 대기 큐 크기
executor.setThreadNamePrefix("MailAsync-"); // 스레드 이름 접두사
executor.initialize();
return executor;
}
}
이후 사용할 때는 @Async("mailExecutor")와 같이 특정 스레드 풀의 이름을 지정해 주면 안전하고 예측 가능한 시스템을 만들 수 있습니다.
6. 결론
자바의 스레드 풀은 서버 자원을 보호하고 동시 요청 처리량을 극대화하는 든든한 방어벽입니다. 그리고 스프링의 @Async는 이를 극도로 편리하게 쓰도록 도와주는 도구입니다.
하지만 스레드 풀의 실제 작업 배정 순서(Core -> Queue -> Max)를 혼동하거나 프록시 호출 제약 조건을 위반하면 시스템 장애나 먹통 현상이 발생할 수 있으므로, 내부 원리를 늘 유념하며 아키텍처를 설계해야 합니다.
이로써 자바 백엔드의 메모리, GC, 동시성, 그리고 스레드 풀까지 핵심 로드맵을 모두 짚어보았습니다. 긴 연재 글을 읽어주셔서 감사합니다.