Please enable JavaScript to view the comments powered by Disqus.데이터베이스 및 서버 성능 최적화의 비밀: 동기/비동기와 블로킹/논블로킹 완벽 이해
Search

데이터베이스 및 서버 성능 최적화의 비밀: 동기/비동기와 블로킹/논블로킹 완벽 이해

태그
백엔드최적화
비동기프로그래밍
멀티스레드
동시성
I/O모델
공개여부
작성일자
2026/04/22

1. 서론: 왜 우리는 이 개념들을 헷갈려할까?

현대의 백엔드 개발자라면 Spring WebFlux, Node.js, 혹은 Kotlin 코루틴(Coroutines)을 도입하며 "논블로킹 비동기(Non-blocking Asynchronous) I/O가 성능에 좋다"는 말을 수없이 들어보았을 것입니다. 하지만 많은 개발자가 시스템에 병목이 발생하고 성능 튜닝이 필요한 시점이 되어서야 '동기(Synchronous)'와 '블로킹(Blocking)'이 완전히 다른 차원의 개념이라는 사실을 깨닫고 혼란에 빠집니다.
서버가 수만 건의 동시 접속을 처리해야 하는 C10K 문제 상황에서, 이 네 가지 개념(Sync, Async, Blocking, Non-blocking)의 조합을 정확히 이해하지 못하면 애플리케이션에 겉보기엔 비동기이지만 실제로는 스레드 풀을 고갈시켜 시스템을 마비시키는 '숨은 블로킹' 구간을 만들게 됩니다.
단순히 "비동기를 쓰면 빠르다"는 결론에 그치지 않고, 이 글에서는 운영체제의 스레드 제어권, 컨텍스트 스위칭(Context Switching), 그리고 작업 완료 통지 방식이라는 컴퓨터 과학의 근본적인 원리를 바탕으로 4가지 I/O 모델의 차이를 깊이 있게 서술합니다.

2. 두 가지 기준의 명확한 분리: 제어권과 관심사

이 복잡한 매트릭스를 이해하기 위해서는 두 가지 축을 철저하게 분리해서 생각해야 합니다.

첫 번째 축: 블로킹(Blocking) vs 논블로킹(Non-blocking)

이 축은 '호출된 함수가 호출한 함수에게 제어권을 언제 돌려주는가?'에 대한 이야기입니다. 즉, 스레드의 물리적인 실행 상태와 직결됩니다.
블로킹 (Blocking): A 함수가 B 함수를 호출했을 때, B 함수가 자신의 작업을 모두 마칠 때까지 A 함수에게 제어권을 돌려주지 않고 대기하게 만듭니다. 이때 호출한 스레드는 WAITING 또는 BLOCKED 상태로 전환되며, CPU는 이 스레드를 잠재우고 다른 스레드로 교체하는 무거운 컨텍스트 스위칭(Context Switching) 오버헤드를 발생시킵니다 [1].
논블로킹 (Non-blocking): A 함수가 B 함수를 호출하면, B 함수는 작업의 완료 여부와 상관없이 즉시 제어권을 A에게 돌려줍니다. 덕분에 A 함수가 실행 중인 스레드는 멈추지 않고 다른 명령을 계속 실행할 수 있습니다.

두 번째 축: 동기(Synchronous) vs 비동기(Asynchronous)

이 축은 '작업의 완료 여부와 결과를 누가 신경 쓰고 처리하는가?' (관심사와 시간의 일치)에 대한 이야기입니다.
동기 (Synchronous): A 함수가 B 함수를 호출한 뒤, B 함수의 작업 완료 여부를 A 함수가 계속 확인하거나 기다립니다. 즉, 호출한 쪽에서 결과 처리에 대한 책임을 직접 집니다.
비동기 (Asynchronous): A 함수는 B 함수에게 작업을 요청하면서 콜백(Callback) 함수나 이벤트 리스너를 함께 넘깁니다. B 함수의 작업이 끝나면 B(혹은 운영체제나 프레임워크)가 직접 콜백을 실행하여 결과를 처리합니다. A 함수는 요청만 해두고 결과가 언제 나오는지 더 이상 신경 쓰지 않습니다.

3. 실전 4가지 I/O 모델 매트릭스 파헤치기

이제 이 두 가지 축을 조합하여 파생되는 4가지 상황을 코드 레벨과 스레드 동작 관점에서 상세히 분석해 보겠습니다.

3.1. 동기 + 블로킹 (Sync - Blocking)

가장 직관적이고 우리가 코딩을 처음 배울 때 접하는 전통적인 모델입니다.
원리: 함수를 호출하면 결과를 반환할 때까지 스레드가 멈추고(Blocking), 호출한 쪽에서 그 결과를 받아 바로 다음 줄의 코드를 실행(Synchronous)합니다.
실제 사례: Java의 전통적인 JDBC를 이용한 데이터베이스 쿼리, 혹은 java.io 패키지를 이용한 파일 읽기가 대표적입니다.
문제점: 외부 API 호출이나 디스크 I/O를 수행할 때 CPU는 사실상 놀고 있지만 스레드는 멈춰 있습니다. 들어오는 요청마다 새로운 스레드를 생성하는 방식(Thread-per-request)을 사용하면, 수많은 스레드가 생성되어 메모리 누수와 엄청난 컨텍스트 스위칭 비용이 발생합니다 [2]. 암달의 법칙(Amdahl's Law)에 따르면 락과 블로킹으로 인해 직렬화(Serialized)된 구간이 많을수록, CPU 코어를 아무리 늘려도 시스템의 처리량(Throughput)은 늘어나지 않는 한계에 직면하게 됩니다 [3].

3.2. 동기 + 논블로킹 (Sync - Non-blocking)

스레드는 멈추지 않지만, 작업 완료 여부를 계속 확인해야 하는 모델입니다.
원리: B 함수를 호출하면 즉시 제어권이 반환되어(Non-blocking) A 함수의 코드가 계속 실행됩니다. 하지만 A 함수는 동기적이므로, 루프를 돌며 "작업 다 됐어?"라고 주기적으로 B 함수의 상태를 확인(Polling)합니다.
문제점: 끊임없이 상태를 확인하는 과정에서 스핀 대기(Spin-waiting) 또는 바쁜 대기(Busy-waiting)가 발생합니다. 이 방식은 스레드를 대기 상태로 빠뜨리지 않아 컨텍스트 스위칭 비용은 피할 수 있지만, 아무 의미 없는 루프를 돌며 CPU 사이클을 극도로 낭비하게 됩니다 [4, 5].

3.3. 비동기 + 블로킹 (Async - Blocking)

많은 개발자가 가장 이해하기 어려워하는 조합이며, 대개 "의도치 않게" 발생하여 시스템 성능을 저하시키는 주범이 됩니다.
원리: 비동기적으로 작업을 위임하여 콜백이나 퓨처(Future) 객체를 반환받았지만, 호출한 스레드가 결국 그 결과를 가져오기 위해 명시적으로 대기(Blocking)하는 상황입니다.
실제 사례 (Java의 Future): Java의 ExecutorService를 통해 별도의 스레드에 무거운 연산을 비동기적으로 제출(submit)하면 Future 객체를 받습니다(비동기). 하지만 즉시 다른 유용한 연산을 하지 않고 곧바로 Future.get()을 호출해버린다면, 메인 스레드는 결과를 얻을 때까지 완벽하게 블로킹됩니다 [6]. 비동기의 이점을 블로킹이 다 까먹어버린 셈입니다.
실제 사례 (I/O 멀티플렉싱의 함정): 운영체제의 select()poll() 같은 I/O 멀티플렉싱(Multiplexing) 모델을 이 분류에 넣기도 합니다. 소켓 자체는 논블로킹으로 설정해두고 비동기적으로 데이터가 들어오기를 기다리지만, 정작 여러 소켓의 변화를 감지하는 select() 시스템 콜 자체는 데이터가 준비될 때까지 사용자 스레드를 블로킹하기 때문입니다.
개발 시 주의점: Node.js나 Spring WebFlux처럼 논블로킹 비동기 아키텍처를 기반으로 하는 애플리케이션에서, 내부적으로 구형 JDBC 드라이버나 동기식 HTTP 클라이언트를 실수로 호출하는 순간 해당 워커 스레드는 블로킹에 빠집니다. 워커 스레드 수가 극도로 적은 이벤트 루프 환경에서는 단 하나의 블로킹 호출이 시스템 전체를 셧다운 시킬 수 있는 치명적인 문제를 낳습니다.

3.4. 비동기 + 논블로킹 (Async - Non-blocking)

현대적인 대용량 트래픽 처리 백엔드 프레임워크가 궁극적으로 지향하는 아키텍처입니다.
원리: A 함수가 B 함수를 호출하면서 완료 시 실행할 콜백을 전달합니다(Async). B 함수는 즉시 제어권을 A에게 반환하고(Non-blocking), A 스레드는 다른 요청을 처리하러 떠납니다. I/O 작업이 끝나면 운영체제 차원의 이벤트 통지(epoll, kqueue 등)를 통해 콜백이 별도의 스레드 혹은 이벤트 루프에서 실행됩니다.
수학적/구조적 이점: 이 모델에서는 대기 시간 동안 스레드가 잠들지 않으므로, 수십 개의 스레드만으로 수만 개의 동시 네트워크 연결을 완벽하게 처리할 수 있습니다. 스레드 풀을 작게 유지할 수 있으므로 메모리 사용량과 컨텍스트 스위칭 오버헤드가 극적으로 감소합니다.
Kotlin 코루틴(Coroutines)의 놀라운 해결책: 기존 비동기 프로그래밍은 콜백을 계속 중첩해야 하는 '콜백 지옥(Callback Hell)'으로 인해 코드를 읽기 어렵다는 단점이 있었습니다 [7]. Kotlin 코루틴은 이를 컨티뉴에이션(Continuation) 객체를 활용한 상태 머신(State Machine) 패턴으로 컴파일러 단에서 해결했습니다 [8]. 코루틴에서 delay(1000)를 호출하는 것은 Thread.sleep(1000)과 완전히 다릅니다 [9]. 스레드를 물리적으로 블로킹하는 대신, 현재 함수의 실행 상태(로컬 변수, 실행 위치 등)를 컨티뉴에이션 객체에 저장하고 스레드의 제어권을 즉각적으로 반납합니다 [10]. 1초 뒤에 운영체제의 타이머가 완료 이벤트를 발생시키면, 대기 중이던 아무 스레드나 이 컨티뉴에이션 객체를 받아 이전 중단점부터 코드를 자연스럽게 재개(Resume)합니다 [11]. 개발자는 마치 동기-블로킹 코드를 짜는 것처럼 직관적인 코드를 작성하면서도, 내부적으로는 완벽한 비동기-논블로킹의 폭발적인 성능을 누릴 수 있게 된 것입니다.

4. 결론: 어떤 방식을 선택해야 할까?

시스템 아키텍처에는 언제나 은탄환(Silver Bullet)이 존재하지 않으며 트레이드오프가 따릅니다.
단순하고 트래픽이 많지 않은 백오피스 시스템이라면 직관적이고 디버깅이 쉬운 동기-블로킹(Sync-Blocking) 기반의 전통적인 Spring MVC 모델이 유지보수 관점에서 훨씬 유리합니다. 멀티스레드 환경의 Thread-per-request 모델이 제공하는 스레드 로컬(ThreadLocal) 지원과 명확한 스택 트레이스(Stack Trace)는 오류 추적에 큰 도움이 됩니다.
그러나 수천만 사용자를 대상으로 하는 마이크로서비스 아키텍처, 혹은 수백 개의 외부 API를 오케스트레이션하여 결과를 취합해야 하는 API 게이트웨이를 설계한다면 이야기는 달라집니다. 하나의 응답 지연이 스레드 풀 전체의 기아 상태(Thread Starvation)를 유발하는 시스템 장애를 막으려면 [12, 13], 스레드 자원을 극한으로 절약하는 비동기-논블로킹(Async - Non-blocking) 아키텍처의 도입이 필수적입니다.
설계 단계에서부터 내가 사용하는 데이터베이스 드라이버, HTTP 통신 라이브러리, 그리고 프레임워크가 내부적으로 스레드의 '제어권'을 언제 반환하고 '결과'를 어떻게 기다리는지 정확히 추적하십시오. 겉으로는 비동기 API처럼 보이지만 이면에서 스레드를 멈춰 세우는 '비동기-블로킹'의 함정을 피하는 것, 그것이 진정한 백엔드 성능 최적화의 첫걸음입니다.

참고문헌

[1] Java Concurrency in Practice — tion; visibility; 64-bit operations nonatomic nature of; 36 and compound actions; 22–23 and multivariable invariants; 57, 67–68 and open call restructuring; 213 and service shutdown; 153 and state transition constraints; 56 caching issues; 24–25 client-side locking support for; 80 field updaters; 33…
[2] HTTP The Definitive Guide — If the request attempt results in temporary failure (HTTP status code 503), the robot should defer visits to the site until the resource can be retrieved. If the server response indicates redirection (HTTP status code 3XX), the robot should follow the redirects until the resource is found. robots.tx…
[3] Java Concurrency in Practice — tion. Don’t do this. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19 2.3 Race condition in lazy initialization. Don’t do this. . . . . . . . . . . 21 2.4 Servlet that counts requests using AtomicLong. . . . . . . . . . . . . 23 2.5 Servlet that attempts to cache its last result without …
[4] Java Concurrency in Practice — non-interruptable blocking rea-son; 148 solutions See also interruption; results; search; termination; SortedMap ConcurrentSkipListMap as concur-rent replacement; 85 SortedSet ConcurrentSkipListSet as concur-rent replacement; 85 space state; 56 specification See also documentation; correctness defin…
[5] Java Concurrency in Practice — non-interruptable blocking rea-son; 148 solutions See also interruption; results; search; termination; SortedMap ConcurrentSkipListMap as concur-rent replacement; 85 SortedSet ConcurrentSkipListSet as concur-rent replacement; 85 space state; 56 specification See also documentation; correctness defin…
[6] HTTP The Definitive Guide — Filter Dynamic URLs Usually, robots don’t want to crawl content from dynamic gateways. The robot won’t know how to properly format and post queries to gateways, and the results are likely to be erratic or tran-sient. If a URL contains “cgi” or has a “?”, the robot may want to avoid crawling the URL.…
[7] 코틀린 코루틴
[8] 코틀린 코루틴
[9] 코틀린 코루틴
[10] 코틀린 코루틴
[11] 코틀린 코루틴
[12] Java Concurrency in Practice — thread; 150 request interrupt strategies for handling; 140 requirements See also constraints; design; docu- mentation; performance; concrete importance for effective perfor-mance optimization; 224 concurrency testing TCK example; 250 determination importance of; 223 independent state variables; 66–6…
[13] Java Concurrency in Practice — Amdahl’s law insights; 229 as lock granularity reduction strategy; 235 ServerStatus examples; 236li ownership; 58 stack(s) address space thread creation constraint; 116fn confinement; 44, 44–45 See also confinement; encapsula- tion; nonblocking; 330 size search strategy impact; 184 trace thread dump…