<aside>
스프링에서 비동기 프로그래밍을 위한 어노테이션
▶ 원래 호출 : mail.sendMail("[email protected]")
│
▼
프록시 내부
new Callable<Object>() { //또는 Runable
public Object call() {
return mail.sendMail("[email protected]");
}
}
즉 이렇게 코드가 Callable 또는 Runable하게 바뀌는 것이다.
이렇게 감싸는 이유는 @Async가 비동기적으로 동작하기 위해서는 작동하던 스레드가 아닌 다른 스레드에 작업을 위임하는데, 그때 위힘할 수 있는 유일한 방법이 Runnable/Callable 이기 때문이다.
스프링은 기본적으로 스레드 풀을 사용한다. 비동기의 핵심은 메인 스레드는 비동기 논블록킹으로 다른 작업을 진행할 수 있어야 하는 것이다. 그렇다면 @Async 요청을 다른 메인 스레드에서 처리하면 사실 큰 의미가 없다. 어쩌피 메인스레드 중 하나가 요청을 처리하는 것이기 때문에 기존에 요청을 처리하던 쓰레드가 요청을 계속 처리해도 상관없는 것이다.
따라서 스프링은 비동기 스레드 풀을 별도로 설정할 수 있다. 별도의 설정이 없다면 SimpleAsyncTaskExecutor 을 사용하는데 비동기 전용 스레드를 생성하는 것이다. 즉 말 그대로 스레드를 생성하고 요청이 끝나면 삭제하는 것인데 이는 컨텍스트 비용에 문제가 있다.
하지만 스프링 부트가 개발자의 편의성을 위해서 ThreadPoolTaskExecutor 을 자동으로 설정해준다. 즉 비동기 처리를 위한 기본 워커 스레드 풀을 자동으로 만들어주는 것이다.
따라서 스프링에서 메인 스레드 풀 이외에 비동기 처리를 할 수 있는 워커 스레드 풀이 필요하다. 아래 코드가 워커 스레드 풀을 생성하는 코드이다.
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(8); // 항상 살아 있는 워커
exec.setMaxPoolSize(32); // 피크 시 여기까지 증가
exec.setQueueCapacity(500); // 큐가 찰 때까지 스레드 안 늘어남
exec.setKeepAliveSeconds(60); // 유휴 워커를 줄이는 시간
exec.setThreadNamePrefix("async-");
exec.initialize();
return exec;
}
@Async는 별도의 워커 스레드를 사용하므로 기존의 스레드의 스레드 로컬에 접근할 수 없다. 따라서 @Transactional을 별도로 명시하여 완전히 새로운 트랜잭션에서 처리해야 한다.
</aside>
<aside>
Runnable과 Callable은 새로운 스레드에서 실행할 코드를 작업으로 감싼다.
Runnable은 인자가 없고 반환값도 없지만 Callable는 결과를 반환한다.이러한 특성 때문에, 반환값이 필요 없는 작업은 Runnable로 표현하고, 작업 결과가 필요한 경우 Callable을 사용한다.
// 1. Runnable 구현체 정의
Runnable task = () -> {
System.out.println("작업 스레드 이름: " + Thread.currentThread().getName());
System.out.println("별도 스레드에서 실행 중입니다.");
};
// 2. Thread 객체 생성 시 Runnable 전달
Thread thread = new Thread(task, "MyWorkerThread");
// 3. 새 스레드 시작
System.out.println("메인 스레드에서 새로운 스레드 시작 요청");
thread.start();
// (메인 스레드의 다른 작업 수행 가능)
System.out.println("메인 스레드 이름: " + Thread.currentThread().getName());
Executor는 개발자가 개별 Thread를 직접 생성하고 관리하지 않고 스레드 풀을 통해 스레드를 사용한다.
ExecutorService로 Runnable 또는 Callable를 제출면, 스레드 풀의 스레드들이 그 작업을 실행하도록 하는 기능을 제공한다.
ExecutorService는 미리 생성해둔 워커 스레드들을 가지고 있고 execute 또는 submit으로 작업 객체를 넘기면 알아서 빈 스레드에 작업을 할당하여 실행해준다. 여기서 작업 객체가 Runnable 또는 Callable이다.
execute는 주어진 Runnable을 비동기적으로 실행합니다. 새 작업을 스레드 풀에 등록만 하고 즉시 리턴되며, 실행 자체는 백그라운드에서 이루어진다
submit은 Runnable을 실행시킨 후 Future를 반환한다. 다만 실제 결과값이 없으므로 null을 반환한다.
submit(Callable<V>)는 작업을 스레드 풀에서 실행하고, 결과를 받을 수 있는 Future
를 반환한다. 개발자는 이 Future를 통해 나중에 반환값을 얻거나 작업이 완료될 때까지 대기하거나, 타임아웃을 지정하거나, 작업을 취소하는 등의 제어를 할 수 있다.
ExecutorService.submit()을 호출하면 스레드 풀 구현체는 내부적으로 큐에 작업을 넣고, 풀 내의 어떤 스레드가 그 작업을 가져가 실행하는 것이다. 어떤 스레드가 해당 작업을 실행할지는 개발자가 제어하지 않고, 스레드 풀과 스케줄러가 알아서 관리한다.
Future는 작업 하나 끝나면 결과를 블로킹해서 받아오는 최소 인터페이스다. 반면 CompletableFuture는 작업 결과를 논블로킹으로 다루고 여러 비동기· 작업을 조합하는 방식이다. 즉 여러 API를 제공하는 것이다.
Future가 get만 가능하다고 한다면 CompletableFuture는 get 뿐 아니라 thenApply, thenAccept같은 콜백 체이닝을 쓸 수 있다.
CompletableFuture.supplyAsync(() -> loadUser(id)) // A
.thenApply(this::enrichProfile) // B
.thenAccept(this::sendEmail); // C
CompletableFuture<User> a = getUserAsync(); // A
CompletableFuture<Order> b = getOrderAsync(); // B
CompletableFuture<Pair> pair =
a.thenCombine(b, Pair::new); // C
// 1. 고정 크기의 스레드 풀 생성 (스레드 2개)
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
// 2. Runnable 작업 제출 (execute 사용, 반환값 없음)
executor.execute(() -> {
String tName = Thread.currentThread().getName();
System.out.println(tName + " - Runnable 작업 수행 중");
});
// 3. Callable 작업 제출 (submit 사용, 반환 Future 획득)
Future<Integer> future = executor.submit(() -> {
String tName = Thread.currentThread().getName();
System.out.println(tName + " - Callable 작업 수행 중");
// 예시: 1부터 5까지 합계 계산 후 반환
int sum = 0;
for (int i = 1; i <= 5; i++) {
sum += i;
}
return sum; // Callable의 반환값
});
// 4. 필요한 경우 다른 작업을 수행하다가, Future에서 결과 얻기
Integer result = future.get(); // 작업 완료까지 대기 후 결과 획득
System.out.println("Callable 결과: " + result);
} finally {
// 5. 더 이상 사용할 작업이 없다면 스레드 풀 종료
executor.shutdown();
}
</aside>
<aside>
@Service
public class FileService {
@Async
public CompletableFuture<String> processFile(String path) {
heavyFileProcessing(path);
return CompletableFuture.completedFuture("처리 완료");
}
}
public String handleRequest() {
CompletableFuture<String> future = fileService.processFile("data.txt");
return "파일 처리 시작됨!";
}
@EnableAsync
@Configuration
public class MultiExecConfig {
@Bean(name = "cpuExecutor")
public Executor cpuBoundExecutor() { /* CPU 작업용 ThreadPoolTaskExecutor 생성 */ }
@Bean(name = "ioExecutor")
public Executor ioBoundExecutor() { /* IO 작업용 ThreadPoolTaskExecutor 생성 */ }
}
</aside>
<aside>
자바스크립트 코드를 직접 실행하며, 이 코드 역시 JIT 컴파일 방식을 사용해 네이티브 코드로 변환한다. V8은 성능 최적화를 위해 내부적으로 여러 가지 기법을 사용한다.
자바스크립트에서 객체의 프로퍼티나 메서드에 접근할 때, 첫 호출 이후에 해당 접근 경로를 캐시해두어 이 후 호출 시 빠른 접근이 가능하다.
초기에는 인터프리터를 사용하여 실행하고, 코드의 실행 패턴과 타입 정보를 수집한 후, 최적화 컴파일러가 이 정보를 바탕으로 자주 사용되는 코드들을 네이티브 코드로 재컴파일한다.
libuv는 비동기적이고 논블로킹 방식으로 실행할 수 있게 해주는 라이브러리이다. 백그라운드에서 I/O 작업을 처리함으로써 서버가 하나의 쓰레드만 사용해도 많은 클라이언트 요청을 효율적으로 처리할 수 있도록 한다.
클라이언트의 요청을 받으면 메인 쓰레드가 이를 처리하고 비동기 작업 시 I/O 작업은 별도의 쓰레드 풀에서 작업한다. 이로 인해 메인 쓰레드를 통한 비동기 프로그래밍이 완성된다.
함수가 실행되면 콜스택에 쌓이고 실행이 완료되면 콜스택에서 제거 된다.
콜백 큐는 아직 실행되지 않는 콜백 함수가 대기하는 곳이다. 즉, 이벤트 루프는 콜 스택이 비어있거나 전부 대기 상태일때 콜백 큐에서 함수를 가지고 와 콜 스택에 전달하여 실행한다.
마이크로태스크 큐는 주로 프로미스와 관련된 then, catch, finally 콜백 함수를 포함하며 콜백 큐보다 우선적으로 처리된다. 즉 호출 스택이 비어있으면 먼저 마이크로태스크의 큐의 모든 작업을 처리하고 콜백 큐의 작업을 실행한다.
<aside>
</aside>