스프링


@Async


<aside>

스프링에서 비동기 프로그래밍을 위한 어노테이션

프로세스


  1. AsyncAnnotationBeanPostProcessor가 @Async가 붙은 메서드를 탐색 후 프록시로 생성, 이때 AOP와 같인 CGLIB 동적 프록시를 통해 구현
  2. ApplicationContext, 즉 스프링 빈 컨테이너에 실제 객체가 아닌 프록시 형태로 등록
  3. 메서드 호출 시 AsyncExecutionInterceptor가 이 요청을 가로채고 Callable 또는 Runnable로 감싼다. 이는 메서드 반환형에 따라 선택되는 것으로 결과가 필요하지 않으면 Runnable 결과가 필요하면 Callable 을 사용하면 된다.
  4. 이후 TaskExecutor.submit() 을 하면 ThreadPoolExecutor 에서 워커 스레드를 가지고 온 후 작업이 시작된다.

Callable/Runnable 로 감싸는 것이 무엇일까?


▶ 원래 호출 : mail.sendMail("[email protected]")
        │
        ▼
프록시 내부
new Callable<Object>() { //또는 Runable
    public Object call() {
        return mail.sendMail("[email protected]");
    }
}

즉 이렇게 코드가 Callable 또는 Runable하게 바뀌는 것이다.

이렇게 감싸는 이유는 @Async가 비동기적으로 동작하기 위해서는 작동하던 스레드가 아닌 다른 스레드에 작업을 위임하는데, 그때 위힘할 수 있는 유일한 방법이 Runnable/Callable 이기 때문이다.

SimpleAsyncTaskExecutor


스프링은 기본적으로 스레드 풀을 사용한다. 비동기의 핵심은 메인 스레드는 비동기 논블록킹으로 다른 작업을 진행할 수 있어야 하는 것이다. 그렇다면 @Async 요청을 다른 메인 스레드에서 처리하면 사실 큰 의미가 없다. 어쩌피 메인스레드 중 하나가 요청을 처리하는 것이기 때문에 기존에 요청을 처리하던 쓰레드가 요청을 계속 처리해도 상관없는 것이다.

따라서 스프링은 비동기 스레드 풀을 별도로 설정할 수 있다. 별도의 설정이 없다면 SimpleAsyncTaskExecutor 을 사용하는데 비동기 전용 스레드를 생성하는 것이다. 즉 말 그대로 스레드를 생성하고 요청이 끝나면 삭제하는 것인데 이는 컨텍스트 비용에 문제가 있다.

하지만 스프링 부트가 개발자의 편의성을 위해서 ThreadPoolTaskExecutor 을 자동으로 설정해준다. 즉 비동기 처리를 위한 기본 워커 스레드 풀을 자동으로 만들어주는 것이다.

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>

Runnable과 Callable의 차이점


<aside>

Runnable과 Callable은 새로운 스레드에서 실행할 코드를 작업으로 감싼다.

Runnable은 인자가 없고 반환값도 없지만 Callable는 결과를 반환한다.이러한 특성 때문에, 반환값이 필요 없는 작업은 Runnable로 표현하고, 작업 결과가 필요한 경우 Callable을 사용한다.

Runnable이 새로운 스레드 생성하는 방법


  1. Runnable 인터페이스를 구현하여, run 메서드 안에서 새 스레드에서 실행할 코드를 정의한다.
  2. Thread 클래스를 생성할때 Runnable 객체를 전달한다. 이를 통해 Thread는 Runnable 객체를 타겟으로 설정한다.
  3. 생성된 Thread가 start를 실행하면 run이 호출되고 타겟 Runnable의 메서드가 실행된다.
// 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());

ExecutorService와 스레드 풀을 통한 실행


Executor는 개발자가 개별 Thread를 직접 생성하고 관리하지 않고 스레드 풀을 통해 스레드를 사용한다.

ExecutorService로 Runnable 또는 Callable를 제출면, 스레드 풀의 스레드들이 그 작업을 실행하도록 하는 기능을 제공한다.

ExecutorService는 미리 생성해둔 워커 스레드들을 가지고 있고 execute 또는 submit으로 작업 객체를 넘기면 알아서 빈 스레드에 작업을 할당하여 실행해준다. 여기서 작업 객체가 Runnable 또는 Callable이다.

execute는 주어진 Runnable을 비동기적으로 실행합니다. 새 작업을 스레드 풀에 등록만 하고 즉시 리턴되며, 실행 자체는 백그라운드에서 이루어진다

submit은 Runnable을 실행시킨 후 Future를 반환한다. 다만 실제 결과값이 없으므로 null을 반환한다.

submit(Callable<V>)는 작업을 스레드 풀에서 실행하고, 결과를 받을 수 있는 Future를 반환한다. 개발자는 이 Future를 통해 나중에 반환값을 얻거나 작업이 완료될 때까지 대기하거나, 타임아웃을 지정하거나, 작업을 취소하는 등의 제어를 할 수 있다.

내부 동작


ExecutorService.submit()을 호출하면 스레드 풀 구현체는 내부적으로 큐에 작업을 넣고, 풀 내의 어떤 스레드가 그 작업을 가져가 실행하는 것이다. 어떤 스레드가 해당 작업을 실행할지는 개발자가 제어하지 않고, 스레드 풀과 스케줄러가 알아서 관리한다.

CompletableFuture


Future는 작업 하나 끝나면 결과를 블로킹해서 받아오는 최소 인터페이스다. 반면 CompletableFuture는 작업 결과를 논블로킹으로 다루고 여러 비동기· 작업을 조합하는 방식이다. 즉 여러 API를 제공하는 것이다.

CompletableFuture 콜백 체이닝


Future가 get만 가능하다고 한다면 CompletableFuture는 get 뿐 아니라 thenApply, thenAccept같은 콜백 체이닝을 쓸 수 있다.

CompletableFuture.supplyAsync(() -> loadUser(id))       // A
               .thenApply(this::enrichProfile)          // B
               .thenAccept(this::sendEmail);            // C
  1. supplyAsync가 즉시 큐로 등록이 되고 비동기로 실행
  2. A가 끝나면 B가 실행이 되고 B가 종료되어야 C가 실행이 된다.
  3. 이 모든 과정이 동일한 스레드에서 진행이 된다. 즉 동일한 워커 스레드에서 진행이 된다.

CompletableFuture 두 작업 병렬 + 결과 결합


CompletableFuture<User>  a = getUserAsync();   // A
CompletableFuture<Order> b = getOrderAsync();  // B

CompletableFuture<Pair> pair =
    a.thenCombine(b, Pair::new);               // C
  1. a와 b가 각자 비동기로 진행된다.
  2. thenCombine을 통해 두개가 다 완료될때까지 기다린다.

코드 예시


// 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 "파일 처리 시작됨!";
}
  1. FileService의 processFile이 비동기로 선언되어 있으므로 fileService.processFile("data.txt") 작업이 별도의 워커 스레드에서 생성되므로 사용자에게 응답을 먼저 반환한다.
  2. 다만 CompletableFuture.get 또는 CompletableFuture.thenAccept 등을 통해 완료를 기다리거나 후속 조치를 취할 수 있습다.

상황별 Executor 및 지정도 가능하다


@EnableAsync
@Configuration
public class MultiExecConfig {
    @Bean(name = "cpuExecutor")
    public Executor cpuBoundExecutor() { /* CPU 작업용 ThreadPoolTaskExecutor 생성 */ }

    @Bean(name = "ioExecutor")
    public Executor ioBoundExecutor() { /* IO 작업용 ThreadPoolTaskExecutor 생성 */ }
}

</aside>

Node.JS


아키텍처


<aside>

V8 엔진


자바스크립트 코드를 직접 실행하며, 이 코드 역시 JIT 컴파일 방식을 사용해 네이티브 코드로 변환한다. V8은 성능 최적화를 위해 내부적으로 여러 가지 기법을 사용한다.

인라인 캐시


자바스크립트에서 객체의 프로퍼티나 메서드에 접근할 때, 첫 호출 이후에 해당 접근 경로를 캐시해두어 이 후 호출 시 빠른 접근이 가능하다.

다단계 최적화


초기에는 인터프리터를 사용하여 실행하고, 코드의 실행 패턴과 타입 정보를 수집한 후, 최적화 컴파일러가 이 정보를 바탕으로 자주 사용되는 코드들을 네이티브 코드로 재컴파일한다.

libuv


libuv는 비동기적이고 논블로킹 방식으로 실행할 수 있게 해주는 라이브러리이다. 백그라운드에서 I/O 작업을 처리함으로써 서버가 하나의 쓰레드만 사용해도 많은 클라이언트 요청을 효율적으로 처리할 수 있도록 한다.

스레드 풀


클라이언트의 요청을 받으면 메인 쓰레드가 이를 처리하고 비동기 작업 시 I/O 작업은 별도의 쓰레드 풀에서 작업한다. 이로 인해 메인 쓰레드를 통한 비동기 프로그래밍이 완성된다.

Event Loop


함수가 실행되면 콜스택에 쌓이고 실행이 완료되면 콜스택에서 제거 된다.

콜백 큐는 아직 실행되지 않는 콜백 함수가 대기하는 곳이다. 즉, 이벤트 루프는 콜 스택이 비어있거나 전부 대기 상태일때 콜백 큐에서 함수를 가지고 와 콜 스택에 전달하여 실행한다.

마이크로태스크 큐는 주로 프로미스와 관련된 then, catch, finally 콜백 함수를 포함하며 콜백 큐보다 우선적으로 처리된다. 즉 호출 스택이 비어있으면 먼저 마이크로태스크의 큐의 모든 작업을 처리하고 콜백 큐의 작업을 실행한다.

Event Loop 동작 과정


  1. 함수가 호출되면 콜스택에 쌓임
  2. 함수에서 비동기 작업 시작
  3. 다른 동기 함수(함수2)가 호출되어 실행 후 콜스택에서 제거됨 </aside>

FAST API


<aside>

</aside>