노트

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

Synchronous vs Asynchronous, Blocking vs Non-blocking

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

쉽게 말하면

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

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

동기·비동기와 블로킹·논블로킹은 비슷해 보이지만 서로 다른 질문에 답한다. 동기(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 태스크 예외가 조용히 사라지는 문제에 있다.

출처: 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

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

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

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

  • 마이크로태스크

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

  • 요청 제한 (Throttling)

    요청 제한(rate limiting, throttling)은 일정 시간 동안 한 클라이언트가 보낼 수 있는 요청 수를 묶어 두는 장치다. 한도를 넘으면 429 Too Many Requests로 거절하고, 보통 Retry-After 헤더로 언제 다시 시도하면 되는지 알려 준다.

  • 행동 패턴

    객체 사이의 책임 분배와 대화 방식을 정리하는 패턴들. 요청 전달, 알고리즘 교체, 상태 변화, 알림, 작업 캡슐화 같은 문제를 다룬다.

  • 스프링 AOP

    AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 로깅·트랜잭션·보안·실행 시간 측정처럼 여러 클래스에 흩어져 반복되는 횡단 관심사(cross-cutting concern)를 한 곳에 모아, 비즈니스 코드를 건드리지 않고 메서드 호출 앞뒤에 끼워 넣는 방법이다. 스프링 AOP는 이를 프록시로 구현한다.

보기 옵션