본문 바로가기

CS

블로킹 vs 논블로킹, 동기 vs 비동기

웹 백엔드 개발을 하다 보면 반드시 마주치는 개념이 있다. 바로 블로킹(Blocking), 논블로킹(Non-blocking), 동기 (Synchronous), 비동기(Asynchronous)이다.


용어는 익숙하지만 실제 프로젝트에서 어떻게 적용하고 구분해야 할지는 모호한 경우가 많다. 이 글에서는 이 네 가지 개념을 명확하게 비교하고, 비유적 예시로 설명한 뒤, Spring Boot의 이메일 인증 코드 발송 예제를 통해 실무에 어떻게 반영되는지 정리해본다.


 

 

개념 정리

 

동기

 

함수 A가 함수 B를 호출했을 때, A는 B의 작업이 끝날 때까지 기다린다. 즉, 작업이 순차적으로 직렬적으로 진행된다. 흐름이 직관적이고 예측 가능하다는 장점이 있다.


예시로 식당에서 손님이 주문을 하고 음식을 직접 기다렸다가 받는 상황을 떠올리면 이해가 쉽다. 손님은 주방이 요리를 끝낼 때까지 자리를 떠나지 않고 기다린다.

 

// 동기적으로 작동하는 코드의 예시
function run(a, b) {
    return a + b
}

const result = run(1, 2);

console.log("시작");
console.log("결과:", result);
console.log("끝");

/**출력 결과
 * 시작
 * 결과: 3
 * 끝
 */

 

비동기

 

함수 A가 함수 B를 호출하더라도, A는 B의 작업이 끝나기를 기다리지 않고 곧바로 다음 작업을 수행한다. 호출 결과는 콜백, 이벤트, Future 등으로 나중에 전달받는다.

 

이는 손님이 음식을 주문하고 자리로 돌아간 뒤, 음식이 완성되면 직원이 자리에 갖다 주는 상황과 같다. 즉, 주문과 수령이 분리되어 비동기적으로 처리된다.

 

function run(a, b) {
    return a + b
}

let result;
setTimeout(() => {
    result = run(1, 2);
}, 1000);

console.log("시작");
console.log("결과:", result);
console.log("끝");

/**출력 결과
 * 시작
 * 결과: undefined
 * 끝
 */

 

동기와 비동기는 작업 완료 여부에 따라 나뉘게 된다. 다음 그림은 동기적/비동기적 코드의 작동을 한 눈에 볼 수 있도록 나타낸 예시다.

 

 

 


 

블로킹

 

호출한 함수가 결과를 기다리는 동안, 해당 스레드는 아무 일도 하지 못한 채 멈춰 있다. 제어권이 반환되지 않으며, 해당 스레드는 대기 상태로 리소스를 점유하게 된다.

 

예시로는 셀프 빨래방에서 세탁이 끝날 때까지 세탁기 앞에서 멍하니 서 있는 경우를 생각해볼 수 있다. 다른 일을 하지 못하고 계속 그 자리에 있는 상태가 블로킹이다.

 

// 블로킹 예시
function run() {
    // 오래 걸리는 작업
    console.log("작업 끝");
}

console.log("시작");
run();
console.log("다음 작업");

/** 출력 결과
 * 시작
 * 작업 끝
 * 다음 작업
 */

 

 


 

논블로킹

 

호출한 함수가 결과를 기다리지 않고 다른 작업을 하거나 제어권을 바로 반환하는 방식이다. 같은 스레드가 더 많은 요청을 처리할 수 있으며 이벤트 기반 혹은 콜백 기반으로 처리된다.

 

이 경우는 세탁기를 돌려놓고 카페에 가서 다른 일을 하다가, 완료 알림을 받고 세탁물을 찾으러 가는 상황과 유사하다.

 

// 논블로킹 예시
function run() {
    // 오래 걸리는 작업
    console.log("작업 끝");
}

console.log("시작");
setTimeout(run, 0);
console.log("다음 작업");

/** 출력 결과
 * 시작
 * 다음 작업
 * 작업 끝
 */


 

블로킹/논블로킹은 주로 멀티 스레딩, I/O 등에서 사용되는 개념이며, 제어권에 따라 차이가 난다. 

 

제어권: 제어권은 자신(함수)의 코드를 실행할 권리 같은 것이다. 제어권을 가진 함수는 자신의 코드를 끝까지 실행한 후, 자신을 호출한 함수에게 돌려준다.

 

 


 

개념 조합: 크로스오버 방식

 


 

동기 + 블로킹

public class BossService {
    public void startWork() {
        System.out.println("사장: 출근");
        doTask(); // 이 작업이 끝날 때까지 기다림
        System.out.println("사장: 퇴근");
    }

    private void doTask() {
        for (int i = 1; i <= 100; i++) {
            System.out.println("직원: 인형 눈알 붙이기 " + i + "번 완료");
        }
    }
}

 

이 코드는 가장 일반적인 동기 + 블로킹 구조입니다. boss()는 employee() 작업이 전부 끝날 때까지 기다리며 아무 일도 하지 못합니다. 호출 흐름도 순차적이고, 실행 중에는 블로킹되어 다른 작업을 수행할 수 없습니다.


동기 + 논블로킹

public class BossService {
    public void startWork() {
        System.out.println("사장: 출근");
        for (int i = 1; i <= 100; i++) {
            System.out.println("직원: 눈알 붙이기 " + i + "번");
            System.out.println("사장: 이메일 확인 " + i + "번");
        }
        System.out.println("사장: 퇴근");
    }
}

 

이 구조는 순서대로 작업이 진행되지만, 작업 하나마다 CPU가 다른 로직도 처리합니다. 동기 방식이지만 각 단위 작업은 짧고, 실행 흐름이 빠르게 교차하며 논블로킹처럼 동작합니다.


비동기 + 논블로킹

@Service
public class EmailService {
    private final WebClient webClient = WebClient.create();

    public void sendEmail(String email) {
        webClient.post()
                .uri("https://api.email.com/send")
                .bodyValue(email)
                .retrieve()
                .bodyToMono(Void.class)
                .subscribe(); // 논블로킹 + 비동기 처리
    }
}

 

이 구조는 호출자도 기다리지 않고, 내부 작업도 논블로킹입니다. WebClient는 Netty 기반으로 논블로킹 I/O를 처리하고, .subscribe()를 통해 실제 처리 흐름은 콜백으로 흘러갑니다. 이 구조는 서버 리소스를 최소화하면서 높은 동시성을 확보할 수 있는 최적화된 방식입니다.