주니어 개발자를 위한 블로킹과 논블로킹 개념 잡기
현대 소프트웨어 개발에서 효율성과 성능은 필수 요소입니다. 특히, 다양한 사용자 요청을 동시에 처리해야 하는 웹 애플리케이션이나 서버 환경에서는 블로킹과 논블로킹 모델을 제대로 이해하는 것이 매우 중요한데요. 이번 글에서는 동기와 비동기, 블로킹과 논블로킹의 개념을 예시를 통해 쉽게 소개해 드리겠습니다.
동기와 비동기
‘동기와 비동기’라는 개념은 프로그래밍 분야 외에 통신 분야 등에서도 매우 광범위하게 사용됩니다. 우리는 동기 또는 비동기를 이야기할 때, 항상 두 가지 대상을 언급합니다.
프로그래밍에서는 서로 상호 작용하는 모듈 두 개 또는 함수를 의미하며, 통신에서는 아래 그림과 같은 두 통신 당사자를 의미합니다.

즉, 동기는 A와 B라는 두 대상이 강하게 결합된 것을 의미합니다.
예를 들어, 아래 그림처럼 작업 A가 작업 B에 의존하는 경우를 말하죠. 이러한 의존 관계가 존재할 때, A와 B는 동기입니다.

반면 A와 B가 강한 결합과 같은 제약이 없어 각자 자신의 작업을 실행할 수 있을 때, 이를테면 아래 그림의 A와 B는 비동기입니다.

이처럼 동기와 비동기를 언급할 때에는 반드시 양쪽 모두를 의미하며, 이는 프로그래밍 영역에 국한되지 않습니다. 상사가 직원의 작업 완료를 기다리는 것, 양쪽 당사자가 전화를 걸거나 이메일을 보내는 것 등이 모두 전형적인 동기, 비동기 시나리오에 해당합니다.
그럼 동기와 비동기에 대한 이해를 두고 본격적으로 ‘블로킹과 논블로킹’이 무엇인지 알아볼게요.
블로킹과 논블로킹
블로킹(blocking)과 논블로킹(non-blocking)은 프로그래밍에서 함수를 호출할 때 주로 사용됩니다.
만약, 함수 A와 B가 있다고 가정해 봅시다. 함수 A가 함수 B를 호출할 때, 함수 B를 호출함과 동시에 운영 체제가 함수 A가 실행 중인 스레드나 프로세스를 일시 중지시킨다면 함수 B에 대한 호출 방식은 블로킹 방식이며, 그렇지 않다면 논블로킹 방식입니다.
아래 이미지는 함수 호출로 스레드가 일시 중지되는 경우입니다.

이와 같이 블로킹 호출의 핵심은 스레드 또는 프로세스가 일시 중지되는 것입니다. 물론 다음 코드에서 볼 수 있듯이, 모든 함수 호출이 호출자의 스레드를 일시 중지시키는 것은 아닙니다.

예를 들어, func 함수가 실행 중인 스레드는 sum 함수가 호출되더라도 운영 체제가 이를 일시 중지시키지 않습니다.
그렇다면 함수 호출로 인해 호출자의 스레드나 프로세스가 운영 체제에 의해 일시 중지될 수 있는 것은 어떤 경우일까요?
블로킹의 핵심 문제: 입출력
일반적으로 블로킹은 대부분 입출력과 관련이 있습니다.
그 이유도 매우 간단한데요. 디스크를 예로 들어보겠습니다. 일반적으로 디스크가 하나의 트랙 탐색 입출력 요청을 완료하는 데 소요되는 시간은 ms 단위 수준입니다. 반면에 CP의 클럭 주파수(clock rate)는 이미 GHz 단위 수준에 도달해 있기 때문에, 디스크가 하나의 작업을 수행할 수 있는 ms 단위 시간이 CPU에 주어지면 대량의 기계 명령어 실행 작업을 수행할 수 있습니다.
따라서 일단 프로그램이나 스레드, 또는 프로세스가 이런 입출력 작업을 할 때는 우리 스레드에서 입출력 과정이 실행되는 동안 CPU 제어권을 다른 스레드에 넘겨 다른 작업을 할 수 있도록 해야 합니다. 이후 입출력 작업이 완료되면 다시 CPU 제어권을 우리 스레드 또는 프로세스에서 넘겨받아 계속 다음 작업을 실행할 수 있도록 합니다.
이때는 아래 그림처럼 CPU 제어권을 상실했다가 되찾는 시간 동안 스레드나 프로세스는 블로킹되어 일시 중지됩니다.

그림처럼 스레드 A는 입출력 작업을 실행하다 블로킹되어 일시 중지 되며 CPU는 스레드 B에 할당됩니다. 그렇게 스레드 B가 실행되는 동안 운영 체제는 입출력 작업이 완료된 것을 확인하면 다시 스레드 A에 CPU를 할당합니다.
이처럼 운영 체제는 CPU의 리소스를 최대한 활용할 수 있도록 각 스레드 간에 CPU 사용 시간을 효율적으로 할당해야 하는데, 이것이 바로 블로킹 입출력 방식이 필요한 핵심적인 이유입니다.
다만 그 결과, 시간이 많이 걸리는 입출력 작업이 포함될 때 가끔 호출 스레드가 블로킹되며 일시 중지되는 일이 발생합니다. 입출력 작업이 너무 느려 관련 함수를 직접 호출하면 스레드 또는 프로세스가 블로킹되는 일이 생기는 것입니다.
그렇다면 호출 스레드가 일시 중지되지 않으면서 입출력 작업을 시작할 수 있는 방법은 없을까요?
당연히 있습니다. 바로 ‘논블로킹 호출’을 사용하는 것입니다.
논블로킹과 비동기 입출력
네트워크 데이터 수신을 예로 들어 논블로킹 호출을 살펴보겠습니다.
데이터를 수신하는 함수인 recv가 논블로킹이면 이 함수를 호출할 때 운영 체제는 스레드를 일시 중지시키는 대신 recv 함수를 즉시 반환합니다. 이후 호출 스레드는 자신의 작업을 계속 진행하며, 데이터 수신 작업은 커널이 처리합니다.
아래 그림에서 볼 수 있듯, 두 가지 작업은 병행 처리됩니다.

이제 요청은 전달되었으니 언제 데이터를 수신했는지 아는 방법이 필요하겠죠? 이를 확인할 수 있는 3가지 방법이 있습니다.
- 논블로킹 방식의 recv 함수 외에 결과를 확인하는 함수를 함께 제공하고, 해당 함수를 호출하여 수신된 데이터가 있는지 확인할 수 있습니다.
- 데이터가 수신되면 스레드에 메시지나 신호 등을 전송하는 알림 작동 방식을 사용합니다.
- recv 함수를 호출할 때, 데이터 수신 처리를 담당하는 함수를 콜백 함수에 담아 매개변수로 전달할 수 있습니다. 이때 recv 함수는 콜백 함수를 지원해야 합니다.
이것이 바로 논블로킹 호출이며, 이러한 유형의 입출력 작업을 ‘비동기 입출력(asynchronous input/output)’이라고도 합니다. 다만 블로킹 호출 방식과 비교해 본다면, 비동기 입출력 방식의 코드 작성이 그다지 직관적이지 않다는 것을 알 수 있어요.
피자 주문에 ‘블로킹과 논블로킹’ 비유하기
조금 더 쉽게, ‘블로킹과 논블로킹’ 개념을 피자 주문 상황에 빗대어 쉽게 설명 드릴게요. 이해한 개념과 비교해보며 헷갈리던 부분을 확실히 이해해 보세요.
‘블로킹 호출’은 피자 가게에 직접 가서 피자를 주문하는 것에 비유할 수 있습니다.
여러분은 피자가 완성될 때까지 가게 안에서 기다리고 있어야 합니다. 여러분이 피자를 주문했기에 이를 ‘블로킹’된 것으로 볼 수 있으며, 피자가 완성되어야만 그 피자를 들고 가서 다른 일을 할 수 있습니다.
‘논블로킹 호출’은 전화로 피자를 주문하는 것에 비유할 수 있습니다.
전화로 피자를 주문한 후, 현관문 앞에서 하염없이 피자를 기다리는 사람은 아무도 없습니다. 피자가 오기 전까지 다른 일을 할 수 있는 것이죠. 이렇게 전화 주문 방식으로 피자를 주문하는 것이 바로 논블로킹 호출입니다.
그렇다면 논블로킹 호출 상황에서는 피자가 완성되었는지 어떻게 알 수 있을까요? 여러분의 인내심에 따라 두 가지 상황이 있을 수 있습니다.
매우 인내심이 강한 경우
여러분은 피자가 언제 완성되는지, 언제 배달이 도착하는지 전혀 관심이 없습니다. 어찌 되었든 배달이 도착하면 전화가 올 것이기 때문에 여러분은 할 일을 하고 있으면 됩니다. 여기에서 여러분과 피자를 굽는 작업은 비동기입니다.
인내심이 부족한 경우
여러분은 5분마다 전화를 걸어 피자가 완성되었는지 물어봅니다. 물론 5분 마다 전화해야 한다는 불편함이 있지만, 여전히 여러분은 할 일을 할 수 있습니다. 이때 여러분과 피자를 굽는 작업은 여전히 비동기입니다.
인내심 부족을 넘어 아예 인내심이 없다면 어떨까요?
5분마다 전화를 걸어 피자가 완성되었는지 묻고, 5분마다 전화하는 일을 제외하고는 아무것도 하지 않는다면요? 이제 여러분과 피자를 굽는 작업은 더 이상 비동기가 아닌 동기가 되어 버립니다. 하단의 그림에서 볼 수 있듯이, 논블로킹이 반드시 비동기를 의미하지 않습니다.

동기와 블로킹
동기는 블로킹과 다소 유사합니다. 프로그래밍 관점에서 보면, 동기 호출은 반드시 블로킹이 아닌 반면에 블로킹 호출은 모두 확실한 동기 호출입니다. 가산 함수의 호출을 예로 들어 설명 드리겠습니다.

여기에서 sum 함수에 대한 호출은 동기이지만, funcA 함수가 sum 함수를 호출했다고 해서 블로킹되거나 스레드가 일시 중지되지는 않습니다. 반면에 어떤 함수가 블로킹 방식으로 호출된 경우, 이는 반드시 동기 호출입니다.
비동기와 논블로킹
이번에는 네트워크 데이터 수신을 예로 들어 ‘비동기와 논블로킹’을 정리해 보겠습니다. 데이터를 수신하는recv 함수를 논블로킹 호출로 설정하기 위해 NON_BLOCKING_FLAG 설정값(flag)을 추가하면, 다음과 같이 네트워크 데이터를 수신할 수 있습니다.


이제 recv 함수는 논블로킹 호출이므로, 네트워크 데이터를 처리해 주는 handler 함수를 recv 함수에 콜백으로 전달해야 합니다. 따라서 앞의 코드는 비동기이자 논블로킹입니다.
그러나 시스템이 네트워크 데이터의 도착을 감지하는 전용 함수인 check 함수를 제공한다면, 코드를 다음과 같이 변경할 수 있죠.

여기서도 recv 함수는 논블로킹으로 호출되지만, while 반복문에서 끊임없이 감지를 시도하여 데이터가 도착하기 전까지는 handler 함수를 사용할 수 없게 합니다. 따라서 recv 함수는 비록 논블로킹이지만, 전체적인 관점에서 보면 이 코드는 동기입니다. 이는 마치 전화로 피자를 주문했음에도 계속 전화로 끊임없이 확인하는 상황과 동일하며, 이 상황은 동기이자 논블로킹에 해당합니다.
물론 앞의 코드는 반복문에서 CPU 리소스가 쓸데없이 소모되어 매우 비효율적이므로 이런 코드는 작성해서는 안 됩니다. 이와 같이 논블로킹이더라도 전체적으로 반드시 비동기라는 의미는 아니며, 이는 코드 구현 방식에 따라 달라집니다.
여기까지, 우리가 알아본 모든 개념은 프로그래머에게 매우 중요한 개념인 만큼, 충분히 이해하고 실무에 적용해 본다면 업무 효율 상승을 기대해 보실 수 있을 거예요.

- 이 글은 길벗에서 출간된 책 <컴퓨터 밑바닥의 비밀>에서 발췌·편집한 글입니다. 원문은 [여기]에서 볼 수 있습니다.
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.