기본 콘텐츠로 건너뛰기

동기와 비동기, 블로킹과 논블로킹을 호출 흐름으로 구분하기

동기와 비동기, 블로킹과 논블로킹을 호출 흐름으로 구분하기

빠른 답

  • 동기와 비동기는 결과를 호출 흐름 안에서 기다릴지, 나중에 별도 흐름으로 받을지의 차이다.
  • 블로킹과 논블로킹은 호출한 스레드가 멈춰서 기다리는지, 제어권을 돌려받아 다음 일을 할 수 있는지의 차이다.
  • 비동기 API를 써도 결과를 바로 get()이나 join()으로 기다리면 다시 블로킹 구간이 생긴다.
  • Spring @Async는 프록시와 Executor 위에서 동작하므로 내부 호출, 예외, 트랜잭션, 스레드풀 포화 상태를 함께 봐야 한다.

한눈에 비교

관계 기준
동기와 비동기는 호출자와 작업 결과가 같은 흐름에 묶이는지로 나뉜다.
제어권 기준
블로킹과 논블로킹은 호출 후 현재 스레드가 대기 상태에 들어가는지로 나뉜다.
값 기준
동기 호출은 대개 T 또는 예외를 돌려주고, 비동기 호출은 CompletableFuture<T> 처럼 나중의 완료 상태를 나타내는 핸들을 돌려준다.
오류 기준
동기 오류는 호출 지점의 try-catch 에서 보이고, 비동기 오류는 완료 핸들러, get() , join() 같은 관찰 지점에서 보인다.
Spring 기준
@Async 는 호출자 흐름을 먼저 반환시킬 수 있지만, 작업 스레드 안에서 JDBC, 파일, 외부 API를 호출하면 그 스레드는 여전히 블로킹될 수 있다.

시간 흐름으로 이해하기

호출 시작
호출자는 작업을 요청하고 결과가 필요한지, 나중에 받아도 되는지를 정한다.
제어권 반환
동기 호출은 보통 결과가 나올 때까지 같은 흐름에 머물고, 비동기 호출은 완료 전에도 핸들을 돌려줄 수 있다.
대기 구간
블로킹이면 현재 스레드가 멈추고, 논블로킹이면 즉시 상태값이나 미완료 신호를 받고 다음 판단을 한다.
결과 전달
동기는 반환값이나 예외로 바로 전달되고, 비동기는 콜백, Future 완료, 이벤트, 메시지 등으로 전달된다.
후속 처리
비동기는 결과를 어느 스레드에서 처리할지, 오류와 취소를 어떻게 기록할지 별도로 설계해야 한다.

왜 헷갈릴까

동기와 블로킹은 자주 함께 나타납니다. 예를 들어 서버 코드에서 JDBC로 데이터베이스를 조회하면 호출자는 조회 결과가 필요하고, 요청 스레드는 DB 응답이 올 때까지 기다립니다. 이 상황은 동기이면서 블로킹입니다. 그래서 두 단어가 같은 뜻처럼 쓰이기 쉽습니다.

하지만 두 개념은 보는 축이 다릅니다. 동기와 비동기는 “결과를 어느 흐름에서 처리하느냐”에 가깝고, 블로킹과 논블로킹은 “현재 실행 스레드가 멈추느냐”에 가깝습니다. @Async를 붙였다고 해서 I/O가 논블로킹이 되는 것은 아니고, 논블로킹 API를 쓴다고 해서 결과 처리와 오류 처리가 자동으로 단순해지는 것도 아닙니다.

언어 런타임 관점에서는 값과 상태의 의미도 달라집니다. 동기 함수는 이미 계산된 값을 반환하거나 예외를 던집니다. 비동기 함수는 아직 값이 없을 수 있으므로 “나중에 완료될 상태”를 반환합니다. Java의 CompletableFuture는 정상 완료, 예외 완료, 취소, 미완료 상태를 모두 표현합니다.

동기와 블로킹이 항상 같은 말은 아니다

동기 블로킹은 가장 익숙한 조합입니다. 일반 메서드 호출, 파일 읽기, JDBC 조회, 외부 HTTP 호출을 요청 스레드에서 바로 기다리는 코드가 여기에 가깝습니다. 호출 스택이 이어져 있어서 반환값과 예외 흐름을 읽기 쉽지만, 대기 시간이 길어지면 현재 스레드를 오래 붙잡습니다.

동기 논블로킹도 가능합니다. 예를 들어 논블로킹 채널에서 읽기를 시도했는데 아직 데이터가 없어서 즉시 0이나 “읽을 수 없음” 상태를 반환할 수 있습니다. 호출자는 지금 시점의 상태를 직접 확인하므로 동기적으로 판단하지만, 스레드는 멈추지 않습니다.

비동기 블로킹도 흔합니다. Spring @Async로 메일 발송을 별도 스레드에 넘기면 HTTP 요청 스레드는 먼저 반환될 수 있습니다. 그러나 메일 발송 스레드가 SMTP 서버 응답을 기다린다면 그 작업 스레드는 블로킹됩니다. 호출자 입장에서는 비동기지만, 작업 내부는 블로킹인 셈입니다.

비동기 논블로킹은 높은 동시성 I/O에 유리하지만, 후속 처리 위치와 오류 전파, 취소, 타임아웃, 컨텍스트 전달이 더 복잡해질 수 있습니다. 그래서 비동기는 “작업이 빨라진다”보다 “기다리는 위치와 결과를 받는 방식을 바꾼다”에 가깝습니다.

코드와 출력으로 보는 실행 시점

아래 예시는 동기 호출과 비동기 호출의 로그 순서를 비교합니다. 동기 호출은 blockingCall()이 끝나야 다음 줄로 넘어가고, 비동기 호출은 CompletableFuture를 먼저 받은 뒤 후속 작업을 이어갑니다.

import java.time.LocalTime;
import java.util.concurrent.CompletableFuture;

public class FlowDemo {
    public static void main(String[] args) {
        log("sync start");
        String syncResult = blockingCall();
        log("sync result=" + syncResult);

        log("async start");
        CompletableFuture<String> future = asyncCall();
        log("after async call, done=" + future.isDone());

        future.thenAccept(result -> log("callback result=" + result));
        log("main can do other work");

        sleep(700);
    }

    static String blockingCall() {
        sleep(500);
        return "sync-result";
    }

    static CompletableFuture<String> asyncCall() {
        return CompletableFuture.supplyAsync(() -> {
            sleep(500);
            log("async work finished");
            return "async-result";
        });
    }

    static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(e);
        }
    }

    static void log(String message) {
        System.out.printf("%s [%s] %s%n",
                LocalTime.now(), Thread.currentThread().getName(), message);
    }
}

실행 환경에 따라 시간 값은 달라지지만, after async call이 비동기 작업 완료보다 먼저 찍히는 순서는 유지됩니다.

10:15:00.001 [main] sync start
10:15:00.506 [main] sync result=sync-result
10:15:00.507 [main] async start
10:15:00.510 [main] after async call, done=false
10:15:00.511 [main] main can do other work
10:15:01.012 [ForkJoinPool.commonPool-worker-1] async work finished
10:15:01.013 [ForkJoinPool.commonPool-worker-1] callback result=async-result

여기서 비동기 호출 직후 future.join()을 호출하면 main 스레드는 결과가 올 때까지 기다립니다. 비동기 작업을 시작했다는 사실과 결과를 소비하는 지점에서 블로킹이 생기는지는 분리해서 봐야 합니다.

값, 상태, 오류의 의미

동기 코드는 반환값과 예외가 호출 스택에 직접 묶입니다. String result = call()은 성공하면 값이 들어오고, 실패하면 그 줄에서 예외가 던져집니다. 호출자는 같은 흐름 안에서 try-catch로 오류를 다룰 수 있습니다.

비동기 코드는 반환값이 실제 결과가 아니라 결과를 나타내는 상태 객체일 때가 많습니다. CompletableFuture<T>는 “이미 값이 있다”가 아니라 “언젠가 T로 완료되거나 실패할 수 있다”는 의미를 가집니다. Java API 문서도 CompletableFutureFuture이자 CompletionStage로 설명하며, 완료 후 후속 단계를 연결할 수 있는 타입으로 다룹니다. 참고: Java CompletableFuture API

  • 값의 의미: 동기 반환값은 계산이 끝난 값이고, 비동기 반환값은 완료를 추적하는 핸들이다.
  • 상태의 의미: 비동기는 미완료, 정상 완료, 예외 완료, 취소 상태를 함께 다룬다.
  • 오류의 의미: 동기 오류는 호출 지점에서 보이고, 비동기 오류는 get(), join(), whenComplete(), exceptionally() 같은 지점에서 드러난다.
  • 예외 타입의 의미: get()은 실패를 ExecutionException으로 감싸고, join()CompletionException 계열의 unchecked 예외로 드러낸다.
  • 후속 처리의 의미: thenApply(), thenAccept()는 완료 스레드에서 실행될 수 있고, thenApplyAsync()처럼 Executor를 지정하면 다른 실행 위치를 선택할 수 있다.

이 차이는 HTTP 응답과 비동기 작업을 분리할 때 중요합니다. 컨트롤러가 비동기 작업을 시작하고 이미 응답을 보냈다면, 이후 작업의 실패를 같은 HTTP 응답으로 되돌리기 어렵습니다. 실패 로그, 재시도, 보상 처리, 상태 저장, 알림 같은 흐름이 별도로 필요해집니다.

현재 기준 버전과 마이그레이션 포인트

2026년 4월 기준 Spring의 현재 안정 문서 흐름은 Spring Framework 7.0.x와 Spring Boot 4.0.x를 기준으로 볼 수 있습니다. Spring 프로젝트 페이지에는 Spring Framework 7.0.6, Spring Boot 4.0.5가 현재 릴리스로 안내되어 있습니다. 참고: Spring Framework, Spring Boot

오래된 Spring 4.x, 5.x 글에서는 @Async 반환 타입 예시로 ListenableFutureAsyncResult가 자주 등장합니다. 하지만 Spring 6.0부터 ListenableFutureCompletableFuture 사용을 권장하는 방향으로 deprecated 처리되었고, AsyncResult도 같은 흐름에 있습니다. 새 코드라면 ListenableFuture.addCallback() 대신 CompletableFuture.whenComplete(), handle(), exceptionally()를 우선 검토하는 편이 현재 API 흐름에 맞습니다. 참고: Spring ListenableFuture API, Spring Async API

Java 21 이후에는 가상 스레드도 함께 언급됩니다. Spring Boot는 spring.threads.virtual.enabled=true 설정으로 가상 스레드를 사용할 수 있습니다. 다만 가상 스레드는 논블로킹 I/O와 같은 개념이 아닙니다. 블로킹 코드를 더 많은 경량 스레드로 감당하기 쉽게 만드는 모델에 가깝고, 특정 동기화 구간이나 네이티브 호출에서는 pinned virtual thread 문제가 성능에 영향을 줄 수 있습니다. 참고: OpenJDK JEP 444 Virtual Threads, Spring Boot Task Execution and Scheduling

Spring Async 설정과 반환 타입

Spring에서 @Async를 사용하려면 @EnableAsync가 필요합니다. Spring Boot는 별도 Executor Bean이 없을 때 AsyncTaskExecutor를 자동 구성합니다. 운영 코드에서는 스레드 이름, 풀 크기, 큐 크기, 거부 정책이 드러나야 로그와 지표를 해석하기 쉽습니다.

아래 YAML은 Spring Boot가 자동 구성하는 작업 Executor 기준 예시입니다. 별도의 mailExecutor 같은 Executor Bean을 직접 만들면 그 Bean 설정이 우선합니다.

spring:
  task:
    execution:
      thread-name-prefix: "async-"
      pool:
        core-size: 4
        max-size: 16
        queue-capacity: 100
        keep-alive: "10s"

---
spring:
  config:
    activate:
      on-profile: virtual-threads
  threads:
    virtual:
      enabled: true

@Async 메서드는 void, Future, CompletableFuture 계열 반환을 사용할 수 있습니다. 실패를 호출자가 알아야 한다면 void보다 CompletableFuture가 다루기 쉽습니다. void 반환 메서드에서 발생한 예외는 호출자에게 직접 전파되지 않고, AsyncUncaughtExceptionHandler 또는 기본 로그로 관찰됩니다.

내부 호출, 예외, 트랜잭션 함정

@Async는 기본적으로 프록시 기반 AOP로 동작합니다. 외부 Bean이 Spring 프록시를 통해 호출할 때 Executor 제출이 일어나지만, 같은 클래스 안에서 this.someAsyncMethod()로 호출하면 프록시를 지나지 않습니다. 이 경우 @Async가 붙은 메서드라도 현재 스레드에서 그대로 실행됩니다.

아래 코드는 내부 호출과 다른 Bean 호출의 차이를 일부러 드러낸 예시입니다. sendReceipt()는 같은 클래스 내부 호출이라 요청 스레드에서 실행되고, writeAudit()는 다른 Bean 프록시를 통해 호출되므로 mailExecutor에서 실행될 수 있습니다.

@Configuration(proxyBeanMethods = false)
@EnableAsync
class AsyncConfig {
    @Bean("mailExecutor")
    ThreadPoolTaskExecutor mailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setThreadNamePrefix("mail-");
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(100);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }

    @Bean
    AsyncConfigurer asyncConfigurer(@Qualifier("mailExecutor") Executor executor) {
        return new AsyncConfigurer() {
            @Override
            public Executor getAsyncExecutor() {
                return executor;
            }

            @Override
            public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
                return (ex, method, params) -> System.err.printf(
                        "async failed method=%s params=%s error=%s%n",
                        method.getName(), Arrays.toString(params), ex);
            }
        };
    }
}

@Service
class OrderService {
    private final AuditService auditService;

    OrderService(AuditService auditService) {
        this.auditService = auditService;
    }

    @Transactional
    public void pay(long orderId) {
        log("pay start orderId=" + orderId);

        try {
            this.sendReceipt(orderId);
        } catch (RuntimeException e) {
            log("self-invocation caught: " + e.getMessage());
        }

        auditService.writeAudit(orderId)
                .exceptionally(ex -> {
                    log("audit failed: " + ex.getMessage());
                    return null;
                });

        log("pay returned to controller");
    }

    @Async("mailExecutor")
    public void sendReceipt(long orderId) {
        log("sendReceipt start orderId=" + orderId);
        throw new IllegalStateException("SMTP timeout");
    }

    static void log(String message) {
        System.out.printf("[%s] %s%n", Thread.currentThread().getName(), message);
    }
}

@Service
class AuditService {
    @Async("mailExecutor")
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public CompletableFuture<Void> writeAudit(long orderId) {
        System.out.printf("[%s] writeAudit start orderId=%d%n",
                Thread.currentThread().getName(), orderId);
        return CompletableFuture.completedFuture(null);
    }
}

ThreadPoolExecutor.CallerRunsPolicy는 큐가 가득 찼을 때 호출자 스레드가 직접 작업을 실행하게 합니다. 작업을 조용히 버리지 않는 장점이 있지만, 요청 스레드가 갑자기 오래 잡힐 수 있습니다. 따라서 이 정책을 쓰는 경우 “비동기 호출은 항상 즉시 반환된다”는 기대와 실제 동작이 달라질 수 있습니다.

트랜잭션도 스레드 경계를 함께 봐야 합니다. Spring의 일반적인 @Transactional은 현재 실행 스레드에 묶인 트랜잭션을 사용하며, 새로 시작한 스레드로 자동 전파되지 않습니다. 공식 Javadoc도 thread-bound 트랜잭션이 새 스레드로 전파되지 않는다고 설명합니다. 참고: Spring Transactional API

디버깅과 운영 로그

비동기 문제를 볼 때는 로그에 스레드 이름을 남기는 편이 좋습니다. http-nio-8080-exec-1, mail-1, ForkJoinPool.commonPool-worker-1처럼 실행 위치가 보이면 내부 호출인지, Executor를 탔는지, 콜백이 어느 스레드에서 실행되는지 빠르게 구분할 수 있습니다.

위 예시를 실행하면 다음과 비슷한 로그를 볼 수 있습니다. sendReceiptmail- 스레드가 아니라 요청 스레드에서 실행된 점이 내부 호출 문제를 보여줍니다.

[http-nio-8080-exec-1] pay start orderId=42
[http-nio-8080-exec-1] sendReceipt start orderId=42
[http-nio-8080-exec-1] self-invocation caught: SMTP timeout
[http-nio-8080-exec-1] pay returned to controller
[mail-1] writeAudit start orderId=42

디버깅할 때는 먼저 세 가지를 확인하면 흐름이 빨리 정리됩니다. 첫째, 호출이 Spring 프록시를 지나갔는지 봅니다. 같은 클래스 내부 호출이면 @Async가 적용되지 않을 수 있습니다. 둘째, 반환 타입이 void인지 CompletableFuture인지 봅니다. 호출자가 실패를 알아야 하는 작업이라면 완료 상태를 관찰할 수 있는 반환 타입이 필요합니다. 셋째, Executor의 큐와 거부 정책을 확인합니다. 큐가 무한이면 장애가 늦게 드러나고, 큐가 제한되어 있으면 거부 정책에 따라 호출자 흐름이 달라질 수 있습니다.

비동기 작업이 외부 시스템에 부수 효과를 남긴다면 요청 트랜잭션과 분리된 실패도 고려해야 합니다. 주문 저장 트랜잭션이 아직 커밋되기 전에 메일 발송이나 감사 로그가 실행될 수 있습니다. 커밋 이후 실행이 중요하다면 after-commit 이벤트, outbox 패턴, 메시지 큐처럼 전달 시점을 명확히 하는 구조를 함께 검토해야 합니다.

원문 참고

https://www.maeil-mail.kr/question/77

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...