노트

동기·비동기와 블로킹·논블로킹

Synchronous vs Asynchronous, Blocking vs Non-blocking

CS#js#python#spring · 연결된 개념 13개

쉽게 말하면

라면 물이 끓기를 냄비 앞에 서서 기다리면 블로킹, 그동안 설거지를 하면 논블로킹이에요. 끓었는지 내가 직접 챙기면 동기, 타이머가 울려 알려 주면 비동기라서, 두 질문은 따로 따져야 해요.

비유의 한계 설거지를 한다고 라면이 빨리 끓지는 않죠. 비동기는 기다리는 시간을 다른 일에 쓰게 할 뿐 계산을 빠르게 하지 않아요. 이벤트 루프에서 블로킹 호출을 하면 모든 요청이 함께 멈춰요.

동기·비동기와 블로킹·논블로킹은 비슷해 보이지만 서로 다른 질문에 답한다. 동기(synchronous)·비동기(asynchronous)는 결과를 언제 어떻게 받느냐의 문제다. 호출한 쪽이 끝날 때까지 기다려 결과를 직접 받으면 동기, 일단 다음 일을 하다가 콜백·Promise·이벤트로 나중에 받으면 비동기다. 블로킹(blocking)·논블로킹(non-blocking)은 기다리는 동안 호출한 스레드가 멈추느냐의 문제다. 결과가 준비될 때까지 제어권을 돌려주지 않으면 블로킹, 준비가 안 됐어도 곧바로 돌려주면 논블로킹이다.

두 축 조합

블로킹논블로킹
동기가장 흔한 형태. requests.get(), JDBC 쿼리, fs.readFileSync바로 돌아오는 호출을 되풀이하며 준비됐는지 확인(폴링)
비동기비동기 API를 부르고 곧바로 결과를 기다림. future.get(), WebClient의 .block(). 결국 동기 호출과 다를 바 없다이벤트 루프 방식. fetch().then(), await, Netty·WebFlux

이 용어들은 운영체제·언어·프레임워크마다 조금씩 다르게 쓰인다. 위 표는 엄밀한 정의라기보다 생각을 정리하고 대화를 맞추는 틀이다.

서버가 동시 요청을 다루는 세 방식

  • 요청마다 스레드(thread-per-request): Spring MVC(Spring MVC 요청 흐름), 동기 Django처럼 요청 하나를 스레드 하나가 처음부터 끝까지 맡는다. 코드는 위에서 아래로 읽히지만, DB·외부 API를 기다리는 동안 스레드가 멈춰 있다. 스레드는 메모리를 차지하므로 풀 크기에 상한이 있다(Spring Boot 내장 Tomcat 기본 최대 200개). 느린 외부 호출 하나가 풀을 다 붙잡으면 다른 요청까지 멈춘다(스프링에서 외부 API 호출에서 타임아웃을 꼭 정해야 하는 이유)
  • 이벤트 루프(event loop): Node.js, Python asyncio, Spring WebFlux(Netty)는 소수의 스레드가 논블로킹 I/O를 걸어 두고 완료 이벤트가 오면 이어서 처리한다(이벤트 루프). 기다리는 요청이 많아도 스레드가 늘지 않는다. 대신 코드 전체가 비동기여야 한다
  • 가상 스레드(virtual thread): Java 21에 정식으로 들어왔다(JEP 444). 코드는 요청마다 스레드 방식처럼 블로킹으로 쓰고, 가상 스레드가 I/O를 기다리면 JVM이 그 가상 스레드를 실제 OS 스레드에서 떼어 내고, 비어 있는 OS 스레드에 다른 가상 스레드를 올린다. Spring Boot 3.2부터 spring.threads.virtual.enabled=true로 켠다

이벤트 루프를 막는 블로킹 호출

이벤트 루프 방식에서는 스레드 하나가 수많은 요청을 나눠 맡는다. 그래서 그 스레드를 블로킹 호출이나 무거운 계산으로 붙잡으면 모든 요청이 함께 멈춘다.

async def handler():
    time.sleep(1)                 # 루프 전체가 1초 멈춤
    requests.get(url)             # 동기 HTTP 클라이언트도 마찬가지
 
async def handler_fixed():
    await asyncio.sleep(1)                       # 비동기 대기
    await asyncio.to_thread(requests.get, url)   # 피할 수 없는 동기 호출은 스레드로 넘김 (3.9+)
  • JavaScript: Node.js 서버가 요청을 처리하다 fs.readFileSync를 부르거나 큰 JSON을 파싱하면 다른 요청이 모두 기다린다. 브라우저에서는 긴 동기 루프가 메인 스레드를 붙잡아 INP를 나쁘게 만든다
  • WebFlux: 이벤트 루프 스레드에서 .block()을 부르면 Reactor가 IllegalStateException을 던진다
  • async를 붙였다고 비동기가 되지 않는다. 안에서 부르는 라이브러리가 논블로킹이어야 한다

비동기는 병렬이 아니다

비동기는 기다리는 시간을 다른 일에 쓰게 해 줄 뿐, 계산을 빨라지게 하지는 않는다. 자바스크립트는 스레드 하나로 여러 일을 번갈아 진행하는 동시성(concurrency)이지, 여러 CPU(Central Processing Unit) 코어에서 동시에 계산하는 병렬성(parallelism)이 아니다. CPU를 오래 쓰는 작업은 Web Worker, Node.js worker_threads, 파이썬 ProcessPoolExecutor처럼 다른 스레드나 프로세스로 보낸다.

작업 성격맞는 선택
I/O 대기가 길고 동시 요청이 많음이벤트 루프 또는 가상 스레드
I/O가 있지만 동시 요청이 많지 않음요청마다 스레드(가장 단순)
CPU 계산이 무거움워커 스레드·프로세스로 병렬 처리

같은 두 축은 시스템 사이에서도 나타난다. DB의 비동기 복제(데이터베이스 다중화)나 메시지 큐(Kafka)는 "결과를 지금 받지 않는다"는 선택이고, 그 대가로 최종 일관성을 다뤄야 한다. 파이썬 비동기에서 예외가 사라지는 함정은 asyncio 태스크 예외가 조용히 사라지는 문제에 있다.

책: 3장. 프로세스와 스레드: 메인 스레드가 막히면

출처: MDN — Introducing asynchronous JavaScript · Node.js — Overview of Blocking vs Non-Blocking · Node.js — Don't Block the Event Loop · Python 문서: Developing with asyncio — Running Blocking Code · Spring Framework 문서: WebFlux Overview · JEP 444: Virtual Threads · Spring Boot 문서: Common Application Properties

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • 롱 태스크

    메인 스레드를 50ms 넘게 붙잡는 작업. W3C Long Tasks 명세는 이벤트 루프 태스크(뒤따르는 마이크로태스크 처리 포함), 렌더링 갱신 단계, 이벤트 루프 밖에서 브라우저가 UI 스레드로 하는 일 가운데 50ms를 넘는 것을 롱 태스크로 본다. 태스크는 도중에 끊기지 않으므로 그동안 클릭·키 입력 처리와 화면 갱신이 모두 밀린다(이벤트 루프).

  • 커넥션 드레이닝과 무중단 재시작

    배포 중 서버를 재시작하는 몇 초 동안 로드밸런서가 그 서버로 요청을 보내면 502가 난다. 먼저 로드밸런서에서 빼고(드레이닝), 진행 중인 요청을 마친 뒤 재시작하고, 준비되면 다시 넣는다.

  • 디바운스

    디바운스(Debounce)는 짧은 간격으로 이어지는 호출을 한 묶음으로 보고, 호출이 멈춘 뒤 정해진 시간이 지나면 한 번만 실행하는 기법이다. 입력처럼 잦게 일어나는 이벤트에 무거운 작업(결과 필터링, 추천어 요청)이 붙을 때 쓴다.

  • Promise continuation

    continuation은 "프로그램의 나머지 계산"을 값처럼 다루는 개념이다. JavaScript에서는 Promise가 settle된 뒤 이어서 실행될 코드를 가리킨다. await 다음 줄부터 함수 끝까지가 대표적인 continuation이다.

  • 마이크로태스크

    현재 실행 중인 코드가 끝난 직후, 다음 태스크로 넘어가기 전에 몰아서 처리되는 작은 작업. 이벤트 루프는 태스크 하나를 끝낼 때마다 마이크로태스크 큐가 빌 때까지 전부 실행한다.

보기 옵션