본문 바로가기
language/java

ExecutorService와 @Async

by 죄니안죄니 2026. 6. 24.
반응형

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 생성 과정과 연결되는지 실제 코드 흐름 기준으로 분석해본다.

반응형

댓글