동기/비동기, 블로킹/논블로킹, Spring boot @Async

이의찬·2026년 2월 4일

Springboot

목록 보기
13/13

Event driven architecture를 학습하며 동기/비동기, 블로킹/논블로킹 학습이 필요하여 정리하게 되었다.

동기 비동기


동기(Synchronous)는 호출한 스레드가 작업이 완료될 때까지 결과를 직접 받아야 다음 작업을 진행하고,
비동기(Asynchronous)는 호출한 스레드가 기다리지 않고 다른 작업을 계속 수행하고 결과가 필요하다면 콜백/Future 등으로 받는다.

즉, 호출하는 스레드 관점에서 작업 결과를 신경쓰냐, 안 쓰냐의 차이다.

비동기는 항상 병렬?
비동기는 병렬과 동시 처리 둘 다 의미한다. 동시 처리는 대표적으로 이벤트 루프가 있다.

블로킹, 논블로킹


블로킹과 논블로킹은 호출된 메서드가 제어권을 바로 반환하는지 여부에 있다.

블로킹은 작업이 끝날 때까지 제어권을 돌려주지 않아서 스레드가 BLOCKED, WAITING 상태로 변경된다.

논블로킹은 작업 완료 여부와 관계없이 제어권을 바로 반환하여 다른 작업이 가능한 상태이다.

즉, 호출된 메서드가 제어권을 바로 반환하여 스레드가 대기(BLOCKED, WAIT) 여부 차이이다.

동기-블로킹


동기는 스레드가 직접 결과를 받고, 블로킹은 제어권을 완료될 때까지 돌려주지 않는다.

가장 일반적은 java 코드이다.

// main 스레드가 결과를 직접 받고(동기), 그동안 대기함(블로킹)
public String syncBlocking() {
    String result = apiCall();  // 완료될 때까지 main 스레드 BLOCKED
    return result;
}

동기-논블로킹


동기는 스레드가 직접 결과를 받고, 논블로킹은 제어권을 바로 반환한다.
대표적으로 폴링이 있다.

// main 스레드가 결과를 직접 받으려 하지만(동기), 대기하지 않음(논블로킹)
public String syncNonBlocking() {
    Future<String> future = executor.submit(() -> apiCall());
    
    // 계속 확인하면서 다른 작업도 가능 (폴링)
    while (!future.isDone()) {
        System.out.println("다른 작업 수행 가능");
        Thread.sleep(100);
    }
    
    return future.get();  // 이미 완료됨을 알고 결과 가져오기
}

비동기-블로킹


비동기는 스레드가 직접 결과를 받지 않고 이벤트나 콜백으로 받고, 블로킹은 제어권을 반환하지 않는다.

// 콜백으로 받을건데(비동기), 결과 올 때까지 대기함(블로킹)
public void asyncBlocking() {
    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> apiCall());
    
    future.get();  // 비동기로 실행했지만 여기서 블로킹으로 대기
}

따라서 비동기의 이점을 살리지 못한다.

비동기-논블로킹


비동기는 스레드가 직접 결과를 받지 않고, 논블로킹은 제어권을 바로 반환한다.
따라서 가장 효율적이다.

// 콜백으로 받고(비동기), 대기도 안 함(논블로킹)
public void asyncNonBlocking() {
    CompletableFuture.supplyAsync(() -> apiCall())
        .thenAccept(result -> {
            // 콜백으로 결과 처리
            System.out.println("결과: " + result);
        });
    
    // main 스레드는 바로 다음 작업 진행
    System.out.println("즉시 반환");
}

Thread 관점


1. 동기 + 블로킹

  • main 스레드: 블로킹될 동안 WAITING 상태로 변경된다.
  • 동기: 결과 직접 받음
  • 블로킹: 대기하는 동안 아무것도 못함
public class Main {
    public static void main(String[] args) {
        new Main().example();
    }

    public void example() {
        // main 스레드
        // RUNNABLE
        System.out.println("[" + Thread.currentThread().getName() + "] 시작");

        // TIMED_WAITING
        String result = blockingCall();  // 완료까지 대기

        // RUNNABLE
        System.out.println("[" + Thread.currentThread().getName() + "] 결과: " + result);
    }

    String blockingCall() {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return "완료";
    }
}

2. 동기 + 논블로킹 (폴링)

  • main 스레드: RUNNABLE 상태가 계속 유지된다. (폴링하며 다른 작업)
  • worker 스레드: 실제 작업 수행
  • 동기: 결과 직접 받음
  • 논블로킹: 대기하지 않고 계속 실행
public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        new Main().example();
    }

    public void example() throws ExecutionException, InterruptedException {
        ExecutorService executor = Executors.newSingleThreadExecutor();

        System.out.println("[" + Thread.currentThread().getName() + "] 시작");

        Future<String> future = executor.submit(() -> {
            Thread.sleep(2000);
            return "완료";
        });

        // 폴링하면서 다른 작업 가능
        while (!future.isDone()) {
            System.out.println("[" + Thread.currentThread().getName() + "] 다른 작업 중...");
            Thread.sleep(100);
        }

        String result = future.get();  // 이미 완료됨
        System.out.println("[" + Thread.currentThread().getName() + "] 결과: " + result);
    }
}

3. 비동기 + 논블로킹

  • main 스레드: RUNNABLE 계속 유지된다.
  • worker 스레드: 작업 수행 후 콜백도 수행
  • 비동기: 결과를 콜백으로 받음
  • 논블로킹: 즉시 반환
public class Main {
    public static void main(String[] args) throws Exception {
        new Main().example();
    }

    public void example() throws Exception {
        System.out.println("[" + Thread.currentThread().getName() + "] 시작");

        CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
            System.out.println("[" + Thread.currentThread().getName() + "] 작업 중");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "완료";
        }).thenAccept(result -> {
            System.out.println("[" + Thread.currentThread().getName() + "] 결과: " + result);
        });

        System.out.println("[" + Thread.currentThread().getName() + "] 즉시 다른 작업");

        future.join();  // 예시이기 때문에 동기로 결과 받음
        System.out.println("[" + Thread.currentThread().getName() + "] 모든 작업 완료");
    }
}

Spring boot의 @Async


@Async는 비동기 + 논블로킹이다.

따라서 void, Future, CompletableFuture 반환해야 정상적으로 동작한다.

일반 객체로 리턴할 경우, 리턴 값이 어디에도 전달되지 않는다.

    public void createOrder(Order order) {
        // @Async 메서드 호출
        PaymentResult result = paymentService.processPayment(order);
        System.out.println(result);  // null 출력
        // 별도 스레드에서 실행되고 있지만, 결과를 받을 수 없음
    }

기본 java 코드로 풀면 아래와 같다.

원본 코드

@Service
public class PaymentService {
    
    @Async
    public void processPayment(Order order) {
        System.out.println("[" + Thread.currentThread().getName() + "] 결제 처리");
        Thread.sleep(2000);
    }
}

위 코드를 스프링이 생성하는 프록시 코드 예제는 다음과 같다.

// Spring이 자동으로 생성하는 프록시
public class PaymentServiceProxy extends PaymentService {
    
    private PaymentService target;  // 실제 객체
    private ExecutorService executor;  // 스레드 풀
    
    @Override
    public void processPayment(Order order) {
        // 1. 별도 스레드에서 실행하도록 제출
        executor.submit(() -> {
            // 2. 실제 메서드 호출
            target.processPayment(order);
        });
        // 3. 즉시 반환 (논블로킹)
        return;
    }
}

따라서 예외처리가 현재 스레드에 영향을 미치지 못하고 로그만 남긴채 사라진다.
따로 처리가 필요할 경우 Spring boot에서는 AsyncUncaughtExceptionHandler 등 예외를 받는 설정이 필요하다.

Future
.get() 호출 전까지는 논블로킹으로 작업을 하다가 호출 시 블로킹되어 결과를 기다리게 된다. 또한 현재 스레드가 결과에 관심이 있다. 따라서 동기 + 논블로킹 -> 블로킹으로 생각할 수 있다.

CompletableFuture
최종 결과를 콜백으로 처리하기 때문에 비동기 + 논블로킹 방식이다.

0개의 댓글