๑ `⌃´ ๑

백엔드 개발을 하다 보면 "이 API는 논블로킹이에요", "동기 방식으로 처리했어요" 같은 말을 자주 듣게 된다. 그런데 막상 블로킹/논블로킹과 동기/비동기의 차이를 명확히 설명하려면 헷갈리는 부분이 있다. 이 글에서는 두 개념을 구분되는 기준점부터 정리하고자 한다.

 

핵심은 "누구 기준으로 보느냐"

블로킹/논블로킹과 동기/비동기는 서로 다른 질문에 대한 답이다.

  • 블로킹 / 논블로킹: 함수를 호출했을 때, 제어권이 바로 돌아오는가?
  • 동기 / 비동기: 작업이 끝났을 때, 결과를 누가 처리하는가?

이 둘은 독립적인 축이라서, 이론상 4가지 조합이 모두 가능하다.

 

 

블로킹 vs 논블로킹

블로킹(Blocking)

함수를 호출한 쪽(caller)이 그 함수가 끝날 때까지 제어권을 넘겨주고 기다리는 방식.

// 블로킹 예시
String data = readFromFile(); // 파일 다 읽을 때까지 여기서 멈춤
System.out.println("완료!");

readFromFile()이 끝나기 전까지는 다음 줄로 넘어가지 못한다.

은행 창구에서 번호표 없이 줄 서서 내 차례가 끝날 때까지 그 자리에 서 있는 것과 비슷하다.

 

논블로킹(Non-blocking)

함수를 호출해도 즉시 제어권이 돌아와서 다른 일을 계속할 수 있는 방식.

// 논블로킹 예시
readFromFileAsync(callback); // 바로 다음 줄로 넘어감
System.out.println("일단 이거 먼저 할게요");

호출한 쪽은 결과를 기다리지 않고 바로 다음 작업을 이어간다. 결과가 준비되면 나중에 알림을 받거나 확인하는 방식.

 

 

동기 vs 비동기

동기(Synchronous)

작업을 요청한 쪽이 결과를 직접, 순서대로 처리하는 방식이다. "내가 시킨 일이 끝났는지 내가 직접 확인한다"에 가깝다.

 

비동기(Asynchronous)

작업이 끝났을 때 알림(콜백, 이벤트 등)을 통해 결과가 전달되는 방식이다. "일이 끝나면 알아서 연락 줘"에 가깝다.

 

왜 헷갈릴까?

실무에서는 논블로킹과 비동기가 거의 항상 세트로 등장하기 때문이다.

예를 들어 Python의 asyncio, Java의 CompletableFuture 기반 코드는 대부분 "논블로킹 + 비동기" 조합을 사용한다.

하지만 개념적으로는 아래처럼 조합이 나뉜다.

  동기 (Synchronous) 비동기 (Asynchronous)
블로킹 일반적인 순차 코드 (예: readFile() 후 바로 다음 줄 실행) 흔치 않은 조합 (예: 콜백은 등록했지만 내부적으로 polling하며 대기)
논블로킹 호출 후 바로 리턴받지만, 결과가 준비됐는지 직접 반복 확인(polling) 호출 후 바로 리턴받고, 결과가 준비되면 알림으로 통보받음 (예: asyncio, Node.js 이벤트 루프)

특히 "논블로킹 + 동기(polling)" 조합이 낯설게 느껴질 수 있는데, 소켓 프로그래밍에서 select()나 non-blocking I/O 모드로 설정해두고 애플리케이션이 직접 반복문을 돌며 "다 됐어?"를 확인하는 경우가 여기에 해당한다.

 

 

예시

Java: JDBC(블로킹) vs WebClient(논블로킹)

Spring MVC + JDBC 조합은 대표적인 블로킹 방식이다.

// 블로킹 - JDBC 조회
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    // DB 응답이 올 때까지 이 스레드는 다른 일을 못 함
    User user = userRepository.findById(id);
    return user;
}

요청을 처리하는 스레드가 DB 응답을 받을 때까지 그대로 멈춰 있는다.

그래서 요청이 몰리면 스레드 풀이 금방 고갈된다.

반면 Spring WebFlux의 WebClient는 논블로킹 방식으로 동작한다.

 

// 논블로킹 - WebClient
@GetMapping("/users/{id}")
public Mono<User> getUser(@PathVariable Long id) {
    return webClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .bodyToMono(User.class);
    // 여기서 스레드는 응답을 기다리지 않고 바로 반납됨
    // 응답이 도착하면 이벤트 루프가 이어서 처리
}

 

Mono를 리턴하는 시점에 스레드는 바로 반납되고, 실제 응답이 도착했을 때 이벤트 루프가 나머지 처리를 이어간다.

하나의 스레드로 훨씬 많은 요청을 처리할 수 있는 이유이다.

 

Python: requests(블로킹) vs asyncio/httpx(논블로킹)

Python에서도 동일한 패턴이 반복된다.

# 블로킹 - requests
import requests

def get_user(user_id: int):
    # 응답 올 때까지 이 라인에서 멈춤
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()

FastAPI 환경에서 이런 블로킹 호출을 async def 안에 그대로 쓰면, 이벤트 루프 전체가 막혀버려서 다른 요청 처리까지 지연되는 문제가 생긴다.

 

# 논블로킹 - httpx (async)
import httpx

async def get_user(user_id: int):
    async with httpx.AsyncClient() as client:
        # await 지점에서 제어권을 이벤트 루프에 반환
        response = await client.get(f"https://api.example.com/users/{user_id}")
        return response.json()

await를 만나는 순간 현재 코루틴은 제어권을 이벤트 루프에 넘기고, 응답이 도착하면 다시 이어서 실행된다.

그 사이 이벤트 루프는 다른 요청을 처리할 수 있다.

 

오라클 DB를 async로 다뤄야 하는 경우에도 같은 원리이다.
동기 드라이버(cx_Oracle 등)를 async def 안에서 그대로 호출하면 이벤트 루프가 막히기 때문에,
oracledb의 async 모드처럼 논블로킹 드라이버를 써야 실제로 비동기의 이점을 살릴 수 있다.

 

한눈에 비교

 

  블로킹 논블로킹
Java JDBC (userRepository.findById()) WebClient (Mono, Flux)
Python requests.get() httpx.AsyncClient + await
특징 코드가 직관적, 스레드 점유 시간 김 코드 흐름은 복잡해지지만 스레드 자원을 효율적으로 사용

 

정리

  • 블로킹/논블로킹은 호출 시점에 제어권이 바로 돌아오는지의 문제
  • 동기/비동기는 결과 처리를 누가 어떻게 받는지의 문제
  • 실무에서는 "논블로킹 + 비동기" 조합이 가장 많이 쓰이지만, 둘은 원래 별개의 개념이라는 점을 기억하면 헷갈리지 않는다.
🎵 Playlist
loading...
00:00 / 00:00