ExecutorService와 @Async
Java에서 비동기 처리를 처음 배울 때 가장 많이 접하는 것이 Thread, ExecutorService, @Async다.
그런데 실무에서는 직접 Thread를 생성하는 경우는 거의 없고 대부분 ExecutorService 또는 Spring의 @Async를 사용한다.
이번 글에서는
- ExecutorService가 왜 등장했는가
- @Async는 내부적으로 무엇을 하는가
- 둘의 관계는 무엇인가
- 실무에서는 어떻게 사용하는가
까지 정리해본다.
Thread 직접 생성의 문제점
가장 원시적인 방법은 Thread를 직접 만드는 것이다.
new Thread(() -> {
System.out.println("작업 실행");
}).start();
동작은 문제없다.
하지만 요청이 많아지면?
for(int i = 0; i < 10000; i++) {
new Thread(() -> {
작업수행();
}).start();
}
10,000개의 스레드가 생성된다.
스레드는 공짜가 아니다.
생성 비용이 존재한다.
- 메모리 사용
- OS 스케줄링 비용
- Context Switching 비용
이런 문제 때문에
"스레드를 미리 만들어두고 재사용하자"
라는 개념이 등장한다.
이것이 Thread Pool이다.
Thread Pool
예를 들어
스레드 10개 생성
해놓고
작업 100개가 들어오면
작업 → 대기열 저장
후
놀고 있는 스레드
가 하나씩 꺼내서 처리한다.
즉
작업 100개
↓
스레드 10개
↓
순차 처리
된다.
ExecutorService
Thread Pool을 관리하기 위한 표준 API가 ExecutorService다.
ExecutorService executor =
Executors.newFixedThreadPool(5);
5개의 스레드를 가진 Thread Pool 생성
작업 제출
executor.submit(() -> {
System.out.println("작업 실행");
});
실행 결과
메인 스레드
↓
작업 제출
↓
Thread Pool
↓
실제 실행
submit()과 execute()
많이 헷갈리는 부분
execute()
executor.execute(() -> {
작업수행();
});
반환값 없음
void
submit()
Future<String> future =
executor.submit(() -> {
return "완료";
});
반환값 있음
Future
결과 조회 가능
String result = future.get();
Future
비동기 결과를 담는 객체
Future<Integer> future =
executor.submit(() -> {
return 10 + 20;
});
결과 받기
Integer result = future.get();
30
문제는
future.get()
이 순간
현재 스레드가 대기한다.
즉
비동기 실행
↓
결과 기다림
↓
결국 동기
가 되어버릴 수 있다.
Callable
Runnable은 반환값이 없다.
Runnable
run()
void
반환값이 필요하면
Callable
사용
Callable<String> task = () -> {
return "Hello";
};
submit 내부는 사실상
Callable
을 처리한다.
ExecutorService 종료
실무에서 많이 놓치는 부분
executor.shutdown();
반드시 호출해야 한다.
안 하면
Thread Pool 살아있음
상태가 된다.
프로그램 종료가 안 될 수도 있다.
강제 종료
executor.shutdownNow();
현재 실행 중인 작업까지 중단 시도
Executor 종류
Fixed Thread Pool
가장 많이 사용
Executors.newFixedThreadPool(10);
항상 10개 유지
Single Thread
Executors.newSingleThreadExecutor();
스레드 하나
순서 보장
Cached Thread Pool
Executors.newCachedThreadPool();
필요하면 계속 생성
실무에서는 위험
왜 위험한가?
요청 폭주
↓
스레드 무한 증가
↓
OOM
가능
실무에서는 Executors를 잘 안 쓴다
실무에서는
Executors.newFixedThreadPool()
사용보다
ThreadPoolExecutor
직접 설정을 선호한다.
왜냐하면
큐 크기
최대 스레드 수
거부 정책
등을 제어해야 하기 때문이다.
예시
ThreadPoolExecutor executor =
new ThreadPoolExecutor(
10,
20,
60,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100)
);
설정 의미
10
Core Pool Size
기본 유지 스레드
20
Max Pool Size
최대 생성 가능 스레드
100
Queue Size
대기 작업 수
Spring @Async
Spring은 ExecutorService를 더 쉽게 사용할 수 있도록
@Async
를 제공한다.
예시
@Service
public class MailService {
@Async
public void sendMail() {
System.out.println(
Thread.currentThread().getName()
);
}
}
호출
mailService.sendMail();
개발자는 그냥 메서드 호출했지만
실제로는
Caller Thread
↓
Task 제출
↓
Thread Pool
↓
비동기 실행
된다.
@EnableAsync
반드시 활성화해야 한다.
@Configuration
@EnableAsync
public class AsyncConfig {
}
없으면
@Async
무시된다.
@Async 내부 동작
Spring이 Proxy를 생성한다.
원본 객체
↓
Proxy 객체 생성
↓
Proxy가 호출 가로챔
↓
Executor에 작업 제출
↓
실제 메서드 실행
즉
@Async
는 내부적으로
ExecutorService
를 사용한다.
@Async 반환 타입
void
@Async
public void sendMail() {
}
가장 흔함
Future
@Async
public Future<String> test() {
}
가능
CompletableFuture
실무 추천
@Async
public CompletableFuture<String> test() {
return CompletableFuture.completedFuture(
"success"
);
}
호출
CompletableFuture<String> result =
service.test();
@Async Self Invocation 문제
실무 면접 단골
@Service
public class UserService {
@Async
public void asyncMethod() {
}
public void test() {
asyncMethod();
}
}
예상
비동기
실제
동기
왜?
Proxy를 안 거치기 때문이다.
this.asyncMethod()
호출과 동일
Proxy 구조
외부 호출
↓
Proxy
↓
@Async 적용
내부 호출
this.method()
↓
Proxy 우회
↓
@Async 무시
실무에서 굉장히 자주 발생하는 버그다.
ThreadPoolTaskExecutor
Spring Boot에서는 이것을 가장 많이 사용한다.
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor =
new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.initialize();
return executor;
}
적용
@Async("taskExecutor")
public void process() {
}
실무에서 @Async 사용하는 대표 사례
이메일 발송
회원가입
직후
메일 발송
대기하지 않음
SMS 발송
주문 완료
후
문자 발송
비동기 처리
로그 적재
API 응답 먼저
후
로그 저장
외부 API 호출
알림 서버
통계 서버
Slack
호출
@Async 사용하면 안 되는 경우
트랜잭션 결과에 강하게 의존하는 작업
예시
@Transactional
public void save() {
repository.save();
asyncService.send();
}
문제
비동기 실행
↓
트랜잭션 아직 Commit 안됨
↓
조회 실패
가능
실무에서는
@TransactionalEventListener
또는
메시지 큐
(Kafka, RabbitMQ)
를 많이 사용한다.
정리
ExecutorService는 Java 표준 Thread Pool API다.
@Async는 Spring이 제공하는 비동기 추상화다.
내부적으로는 결국 ExecutorService(ThreadPool)를 사용한다.
실무에서는
Thread
↓
ExecutorService
↓
ThreadPoolExecutor
↓
Spring @Async
순으로 발전했다고 이해하면 된다.
그리고 중요한 것은
@Async = 마법
X
@Async = Proxy + ThreadPool
이라는 점이다.
다음 글에서는 Annotation 기반 동작 원리를 다룬다. 여기서 지금까지 배운 Reflection, Annotation, Proxy가 어떻게 Spring Bean 생성 과정과 연결되는지 실제 코드 흐름 기준으로 분석해본다.
'language > java' 카테고리의 다른 글
| Spring MVC 예외 처리 내부 구조 (0) | 2026.06.24 |
|---|---|
| Annotation 기반 동작 원리 (0) | 2026.06.24 |
| ThreadLocal과 Spring Security (0) | 2026.06.24 |
| ThreadLocal과 TransactionSynchronizationManager (0) | 2026.06.24 |
| Java Proxy 기반 트랜잭션 처리 (0) | 2026.06.17 |
댓글