ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [CS] Blocking / Non-Blocking, Sync / Async 구분하기
    CS 2025. 12. 6. 16:53

    개발자로 일하다 보면 Blocking과 Synchronous, Non-Blocking과 Asynchronous라는 용어를 자주 접하게 됩니다. 많은 경우 이 용어들이 혼용되어 쓰이곤 하지만, 엄밀히 말해 이들은 서로 다른 차원의 개념입니다.

    오늘은 백엔드 개발자가 I/O 모델을 설계하거나 고성능 애플리케이션을 이해하기 위해 반드시 구분해야 할 이 네 가지 개념을 '제어권'과 '결과 처리'라는 핵심 키워드로 명확히 정리해 보겠습니다.

    1. 두 개념의 결정적 차이: "관심사(Focus)가 다르다"

    이 용어들이 헷갈리는 이유는 결과적으로 비슷해 보이기 때문입니다. 하지만 이들을 구분하는 기준, 즉 관심사가 다릅니다.

    1) Blocking / Non-Blocking (제어권의 관점)

    • 핵심 질문: 호출된 함수가 제어권(Control)을 바로 돌려주는가?
    • Blocking: 호출된 함수가 작업을 마칠 때까지 제어권을 가지고 놓아주지 않습니다. 호출한 함수는 그동안 아무것도 하지 못하고 대기합니다.
    • Non-Blocking: 호출된 함수가 작업 완료 여부와 상관없이 제어권을 즉시 반환(Return)합니다. 호출한 함수는 멈추지 않고 다음 로직을 실행할 수 있습니다.

    여기서 말하는 제어권은 무엇일까요?

    단순히 추상적인 권한을 말하는 것이 아니라, "현재 스레드(Thread)가 CPU를 점유하여 자신의 코드(명령어)를 계속 실행할 수 있는 상태"를 의미합니다.

    • 제어권을 가졌다: 내 함수(Caller)가 CPU를 쓰고 있으며, 내 코드의 다음 줄(Next Line)을 실행할 수 있다는 뜻입니다.
    • 제어권을 뺏겼다: 호출된 함수(Callee)나 OS가 CPU를 가져가 버려, 내 함수는 실행 흐름이 멈추고(Blocked) 대기해야 한다는 뜻입니다.

    2) Sync / Async (결과 처리의 관점)

    • 핵심 질문: 작업의 완료 여부(Result)를 누가, 언제 신경 쓰는가?
    • Synchronous (동기): 호출한 함수가 작업의 완료를 직접 확인하거나 기다린 후 처리합니다. 요청과 결과가 순차적으로 흐릅니다.
    • Asynchronous (비동기): 호출한 함수는 작업 시작만 요청하고, 결과는 신경 쓰지 않습니다. 작업이 끝나면 콜백(Callback)이나 이벤트 등을 통해 결과가 전달됩니다. 요청된 작업은 별도의 쓰레드나 커널(OS)에게 위임되어 수행됩니다.

    2. 4가지 조합 (2x2 Matrix)

    이 두 개념을 조합하면 총 4가지 케이스가 나옵니다. 개발자가 실무에서 마주하는 상황들을 대입해 보겠습니다.

    ① Sync + Blocking (동기 + 블로킹)

    • 상황: 가장 일반적인 함수 호출.
    • 설명: 함수를 호출하면 결과가 나올 때까지 제어권을 뺏기고(Blocking), 결과가 나오면 받아서 다음 줄을 실행합니다(Sync).
    • Java 예시: JDBC를 이용한 DB 조회, InputStream.read().
    import java.util.Scanner;
    
    public class SyncBlockingExample {
        public static void main(String[] args) {
            System.out.println("[Start] 메인 스레드 시작");
    
            // 1. Blocking: 사용자가 입력을 마칠 때까지 제어권이 여기서 멈춥니다.
            // 2. Sync: 입력값을 리턴받아야만 다음 라인(result 변수 할당)으로 넘어갑니다.
            Scanner scanner = new Scanner(System.in);
            System.out.print("입력하세요: ");
            String result = scanner.nextLine(); 
    
            System.out.println("[Result] 입력값: " + result);
            System.out.println("[End] 메인 스레드 종료");
        }
    }

    ② Async + Non-Blocking (비동기 + 논블로킹)

    • 상황: 고성능 서버 프레임워크나 자바스크립트 엔진.
    • 설명: 함수를 호출하자마자 제어권을 돌려받고(Non-Blocking), 다른 일을 하다가 작업이 끝났다는 신호(Callback)가 오면 결과를 처리합니다(Async).
    • Java 예시: Spring WebFlux, CompletableFuture (thenApply).
    import java.util.concurrent.CompletableFuture;
    
    public class AsyncNonBlockingExample {
        public static void main(String[] args) {
            System.out.println("[Start] 메인 스레드: " + Thread.currentThread().getName());
    
            // 1. Non-Blocking: supplyAsync 호출 즉시 제어권이 메인 스레드로 돌아옵니다.
            CompletableFuture.supplyAsync(() -> {
                // 별도의 쓰레드(ForkJoinPool)에서 실행됨
                try { Thread.sleep(2000); } catch (InterruptedException e) {}
                return "Hello, Async!";
            })
            // 2. Async: 작업 완료 후 실행될 콜백을 미리 정의합니다.
            .thenAccept(result -> {
                System.out.println("[Callback] 결과 처리 스레드: " + Thread.currentThread().getName());
                System.out.println("[Result] 결과값: " + result);
            });
    
            System.out.println("[End] 메인 스레드는 멈추지 않고 종료됩니다.");
            
            // (테스트를 위해 메인 스레드가 바로 죽지 않게 대기)
            try { Thread.sleep(3000); } catch (InterruptedException e) {}
        }
    }

     

    ③ Sync + Non-Blocking (동기 + 논블로킹)

    • 상황: Polling(폴링) 방식.
    • 설명: 함수는 즉시 리턴되지만(Non-Blocking), 호출자가 "끝났어?"라고 계속 물어보며 확인합니다(Sync).
    • Java 예시: Future.isDone()을 루프 돌며 확인하는 경우.
    import java.util.concurrent.ExecutionException;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.Future;
    
    public class SyncNonBlockingExample {
        public static void main(String[] args) throws InterruptedException, ExecutionException {
            ExecutorService executor = Executors.newSingleThreadExecutor();
    
            System.out.println("[Start] 작업 요청");
    
            // 1. Non-Blocking: submit하자마자 Future 객체를 받고 제어권이 돌아옵니다.
            Future<String> future = executor.submit(() -> {
                Thread.sleep(2000);
                return "Finished";
            });
    
            // 2. Sync: 결과가 나왔는지 호출자(Main)가 계속 확인합니다. (Polling)
            while (!future.isDone()) {
                System.out.println("[Polling] 아직 안 끝났어? 딴짓 하는 중...");
                Thread.sleep(500); // 다른 작업 수행 시뮬레이션
            }
    
            // 결과가 준비된 것을 확인하고 가져옴
            String result = future.get();
            System.out.println("[Result] 결과값: " + result);
            
            executor.shutdown();
        }
    }

    ④ Async + Blocking (비동기 + 블로킹)

    • 상황: 안티 패턴(Anti-Pattern) 또는 실수.
    • 설명: 비동기로 작업을 시켜놓고, 정작 결과는 멈춰서 기다리는 상황입니다. 비동기의 이점을 살리지 못합니다.
    • Java 예시: CompletableFuture를 사용하고 바로 .get()을 호출하여 스레드를 멈추게 하는 경우.
    import java.util.concurrent.CompletableFuture;
    
    public class AsyncBlockingExample {
        public static void main(String[] args) {
            System.out.println("[Start] 메인 스레드 시작");
    
            // 1. Async: 작업을 별도 스레드에 맡겼습니다. (여기까진 좋음)
            CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
                try { Thread.sleep(2000); } catch (InterruptedException e) {}
                return "Database Result";
            });
    
            System.out.println("[Wait] 결과를 기다리는 중... (여기서 멈춤)");
    
            // 2. Blocking: Async로 던졌는데, 결과가 필요해서 join()을 호출하는 순간 
            // 메인 스레드는 아무것도 못 하고 대기 상태가 됩니다.
            String result = future.join(); 
    
            System.out.println("[Result] 결과값: " + result);
            System.out.println("[End] 메인 스레드 종료");
        }
    }

    3. 스레드(Thread)와 제어권

    많은 개발자가 "Async나 Non-Blocking을 쓰면 무조건 새로운 스레드가 생성되는가?"에 대해 혼동합니다. 결론부터 말하면 작업의 종류에 따라 다릅니다.

    Case A: CPU 연산 작업 (Async)

    • 계산 작업은 CPU가 수행해야 하므로, 메인 스레드가 아닌 별도의 스레드(Worker Thread)가 반드시 필요합니다.
    • Java에서는 ThreadPool에서 스레드를 하나 빌려와 작업을 수행합니다.

    Case B: I/O 작업 (Non-Blocking)

    • 네트워크나 파일 입출력은 CPU가 아니라 커널(OS)과 하드웨어(랜카드 등)가 수행합니다.
    • 따라서 Java 애플리케이션 레벨에서 별도의 스레드를 만들 필요가 없습니다.
    • 스레드는 커널에 "데이터 오면 알려줘"라고 등록만 하고 다른 일을 합니다. 이것이 Node.js나 Spring WebFlux가 적은 수의 스레드로 대용량 트래픽을 처리하는 비결입니다.

    4. Java 코드로 비교하기

    Blocking (Java IO)

    // 데이터를 읽을 때까지 스레드가 여기서 멈춤 (Blocking)
    // 결과가 나와야 다음 라인 실행 (Sync)
    InputStream in = socket.getInputStream();
    int data = in.read(); 
    System.out.println("데이터 도착: " + data);

    Async + Non-Blocking (Java CompletableFuture)

    // 비동기 작업 요청 (즉시 리턴 - Non-Blocking)
    CompletableFuture.supplyAsync(() -> {
        // 별도 스레드 혹은 I/O 처리
        return "Result";
    }).thenAccept(result -> {
        // 작업이 끝나면 실행될 콜백 (Async)
        System.out.println("결과 처리: " + result);
    });
    
    // 메인 스레드는 멈추지 않고 계속 실행됨
    System.out.println("다른 작업 수행 중...");

     

    5. 결론: 무엇을 써야 할까?

    Spring 생태계로 본다면, 전통적인 Spring MVCBlocking + Sync 모델을 기반으로 합니다. 요청당 하나의 스레드를 할당하므로 코드를 작성하기 쉽고 디버깅이 직관적입니다.

    반면, Spring WebFluxNon-Blocking + Async 모델을 기반으로 합니다. 적은 수의 스레드로 엄청난 양의 동시 접속을 처리할 수 있지만, 학습 곡선이 높고 디버깅이 어렵습니다.

    단순히 "Non-Blocking이 빠르다"라고 이해하기보다, "내 애플리케이션이 I/O 대기 시간이 긴가, CPU 연산이 많은가?"를 파악하여 적절한 모델을 선택하는 것이 백엔드 개발자의 핵심 역량일 것입니다.

     

Designed by MSJ.