블로킹과 논블로킹, 그리고 동기와 비동기

kimgho
JavaScript
블로킹과 논블로킹, 그리고 동기와 비동기

자바스크립트를 배우다 보면, 동기(Synchronous) / 비동기(Asynchronous), 그리고 **블로킹(Blocking) / 논블로킹(Non-Blocking)**이라는 개념을 자주 접합니다. 처음에는 이 네 가지가 비슷해 보이지만, 사실 서로 독립적인 개념입니다. 예를 들어, "동기=블로킹, 비동기=논블로킹"이라고 단순화할 수 없습니다.

이 글에서는 이 개념들을 정리하고, 각각을 조합했을 때 어떤 상황이 나오는지 살펴보겠습니다.

블로킹과 논블로킹

제어권(Control)

블로킹과 논블로킹을 이해하기 위해 가장 먼저 알아야 할 개념은 제어권입니다. 제어권은 프로그램에서 다음 작업을 언제 실행할지를 결정하는 권한을 뜻합니다.
즉, 지금 누가 프로그램 실행의 흐름을 쥐고 있는지를 뜻합니다. 이 제어권이 호출자(요청한 쪽)에 있느냐 혹은 호출당한 함수(요청받은 쪽)에 있느냐에 따라
블로킹논블로킹으로 나눌 수 있습니다.

블로킹(Blocking)

블로킹은 작업 요청 시, 해당 작업이 끝날 때까지 다음 코드를 실행하지 않고 기다리는 방식입니다.

그림으로 먼저 보겠습니다. 블로킹 이미지 예시

두 함수 A와 B가 있을 때, 블로킹의 경우 A가 가지고 있는 제어권을 호출한 B에게 넘겨줍니다. 이렇게 되면 A는 제어권이 없기 때문에 실행이 중단됩니다. B는 제어권을 가지고 있기 때문에 자신의 코드를 실행할 수 있습니다. 이후 B의 실행이 끝나 다시 A에게 제어권을 주면 그때서야 A는 다시 실행됩니다.

파일 읽기를 예시로 들어보겠습니다.

  • 파일 읽기를 시작합니다.
  • 파일을 전부 읽을 때까지 다른 작업은 못하고 대기합니다.
  • 파일을 다 읽은 뒤에 다음 코드를 실행합니다. 코드로 본다면
const fs = require("fs");
const data = fs.readFileSync(file); // 이 라인에서 파일을 다 읽을 때까지 대기합니다.
console.log(data.toString());
console.log("다음 작업 실행");

실생활에서 비유를 해보자면 카페에서 커피를 주문하고 커피가 나올 때까지 계속 카운터 앞에서 기다리는 것이라고 볼 수 있습니다.

카페에서 커피를 주문하고, 커피가 나올 때까지 카운터 앞에서 계속 기다리는 것과 같습니다.
그동안 다른 할 일은 아무것도 못 하고, 커피만 기다려야 합니다.

논블로킹(Non-Blocking)

논블로킹은 블로킹과 반대로, 작업을 요청해도 그 작업이 끝날 때까지 기다리지 않고, 다음 코드를 바로 실행하는 방식입니다.

그림으로 먼저 보겠습니다. 논블로킹 이미지 예시

블로킹과 달리 A가 B를 호출해도, 제어권이 B에게 넘어가지 않습니다. 제어권을 가지고 있는 A는 그저 B를 실행만 시키고 자신의 코드를 계속 실행합니다.

파일 읽기를 또 예시로 들어보겠습니다.

  • 파일 읽기를 시작합니다.
  • 하지만 파일을 전부 읽을 때까지 기다리지 않고 바로 다음 작업을 실행합니다.
  • 파일 읽기가 끝나면, 등록한 Callback함수나 Promise로 후속 작업을 합니다.
const fs = require("fs");
fs.readFile(file, (err, data) => {
  if (err) throw err;
  console.log(data.toString());
});
console.log("다음 작업 실행");

실생활에서 이는 카페에서 커피를 주문하고 커피가 나오기 전 자리를 잡고 할 일을 준비하고, 진동벨이 울리면 커피를 받으러 가는 것입니다.

카페에서 커피를 주문하고, 바로 자리를 잡아 다른 할 일을 하는 것과 같습니다.
커피가 나오면 진동벨이 울려 알림을 받고, 그때 커피를 가지러 가면 됩니다.


여기까지 블락과 논블락이었습니다.

이제 **동기(Synchronous)와 비동기(Asynchronous)**에 대해 알아보겠습니다.

동기와 비동기

블로킹/논블로킹이 제어권 흐름을 다뤘다면, 동기/비동기는 작업의 처리 순서를 다루는 개념입니다.

동기(Synchronous)

동기 이미지 예시 동기 방식은 태스크를 순차적으로 (직렬) 처리하는 방식입니다. 하나의 작업이 실행되면 그 작업이 완전히 끝날 때까지 다음 작업은 대기하게 됩니다. 즉, 요청을 보내고 응답을 받아야지만 다음 작업을 실행할 수 있습니다. 이러한 방식에서는 프로그램의 실행 흐름이 작업의 처리 속도에 따라 중단될 수 있기 때문에 비효율적인 상황이 발생할 수 있습니다. 실제 CPU가 느려지는 것은 아니지만 프로그램의 전체적인 효율이 저하된다고 볼 수 있습니다.

비동기(Asynchronous)

비동기 이미지 예시 비동기는 요청을 보내고, 응답을 기다리지 않고 다음 작업을 즉시 수행할 수 있는 방식입니다. 즉, 어떤 작업이 실행되어도 프로그램의 실행 흐름이 중간에 끊기지 않고 계속 진행됩니다. 비동기 방식에서는 응답이 도착했을 때 실행할 콜백 함수를 등록해두고, 요청이 완료되면 그 콜백 함수가 호출되어 결과를 처리합니다. 따라서, 프로그램의 효율이 저하되는 일 없이 여러 작업을 병렬적으로 처리할 수 있습니다.

비동기 요청의 경우 응답 후 처리할 콜백 함수를 함께 등록합니다. 이후 작업이 완료되면 등록해둔 콜백 함수가 호출되어 결과를 처리하는 방식입니다. 하지만 기존 비동기 처리에서 콜백 패턴을 사용하면서 처리 순서를 보장하기 위해 콜백 함수가 중첩되는 문제가 발생했습니다. 콜백헬 이미지 이러한 중첩 구조를 콜백 헬이라고 부르며 다음과 같은 문제점이 있습니다:

  • 코드 가독성 저하
  • 유지 보수의 어려움
  • 복잡성 증가

이를 해결하기 위해 ES6에서 Promise라는 개념이 도입되었습니다.

Promise

Promise는 ES6에서 도입된 비동기 처리를 위한 새로운 패턴입니다. 기존 콜백 패턴이 가진 단점을 보완하며 비동기 처리의 성공/실패 시점을 명확하게 표현할 수 있습니다. Promise는 생성자 함수를 통해 인스턴스를 생성하며, 이때 비동기 작업을 수행할 콜백 함수를 인자로 받습니다.

  • resolve : 성공한 경우 수행할 함수
  • reject : 실패한 경우 수행할 함수

Promise 상태

Promise에는 4개의 상태가 존재합니다:

  • Pending : 비동기 처리가 아직 수행되지 않은 상태
  • Fulfilled : 비동기 처리가 수행된 상태 (성공)
  • rejected : 비동기 처리가 수행된 상태 (실패)
  • settled : 비동기 처리가 수행된 상태 (성공 또는 실패)

동작 과정

  1. 비동기 함수 내에서 Promise 객체를 생성하고 비동기 작업을 수행합니다.
  2. 비동기 작업에 성공하면 resolve 함수를 호출하며 결과를 전달합니다.
  3. 실패하면 reject 함수를 호출하며 에러 메시지를 전달합니다.
  4. 이후 Promise의 후속 처리 메소드가 호출됩니다.

후속 처리 메소드

후속 처리를 위한 메소드로 thencatch가 있습니다.

then

  • 2개의 콜백 함수를 인자로 받습니다.
  • 첫 번째 콜백 함수는 성공(fulfilled,resolve함수가 호출된 상태)시 호출됩니다.
  • 두 번째 콜백 함수는 실패(rejected,reject함수가 호출된 상태)시 호출됩니다.
  • Promise를 반환합니다.

catch

  • 예외(비동기 처리에서 발생한 에러와 then에서 발생한 에러)가 발생하면 호출됩니다.
  • Promise를 반환합니다.

Promise 체이닝

여러 비동기 작업을 순차적으로 실행할 때, Promise를 반환하는 then()메소드로 체이닝하여 콜백 헬 문제를 해결할 수 있습니다.

promiseAjax("GET", `url/${user_id}`)
  .then((res) => promiseAjax("GET", `${user_id}?${JSON.parse(res).user_id}`))
  .then(JSON.parse)
  .then(render)
  .catch(console.error(error));
  • 기존 콜백 헬 보다 코드가 깔끔하게 작성된 것을 볼 수 있습니다.

Async/Await

async/await은 ES8에서 도입된 문법으로, 비동기 처리를 더 간결하게 작성할 수 있습니다. 기존의 비동기 처리 방식인 콜백 함수와 프로미스의 단점을 보완하고 개발자가 읽기 좋은 코드를 작성할 수 있게 되었습니다.

async function fn() {
  await method();
}
  • async키워드는 function 앞에 사용하며 화살표 함수의 경우 ()앞에 사용합니다.
    • 이때 async가 붙은 함수는 항상 Promise를 반환합니다.
    • 만약 Promise가 아닌 값을 반환하더라도 이행 상태의 Promise(resolved Promise)로 감싸 Promise를 반환할 수 있습니다.
async function f1() {
  return 1;
}
f1().then(alert);
 
async function f2() {
  return Promise.resolve(1);
}
f2().then(alert);

await은 async 함수 안에서만 동작합니다. 이는 Promise가 처리될 때까지 함수 실행을 일시 중지했다가, 결과를 반환받고 다음 줄로 넘어갑니다.

async function f() {
  let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve("resolve"), 1000);
  });
  let res = await promise;
 
  alert(res);
}
f();

에러 처리

Promise가 거부되면 throw문을 작성한 것처럼 에러가 던져집니다.

async function f1() {
  await Promise.reject(new Error("에러 발생"));
}

위 코드는 아래와 동일합니다.

async function f1() {
  throw new Error("에러 발생");
}

실제 상황에서 Promise가 reject 되기 전 약간의 시간이 걸리는 경우도 있습니다. 이런 경우엔 await가 에러를 던지기 전에 지연이 발생합니다. await가 던진 에러는 throw가 던진 에러를 잡을 때처럼 try-catch로 처리할 수 있습니다.

async function f1() {
  try {
    let res = await fetch("~");
  } catch (e) {
    alert(e);
  }
}
f1();

정리해보면, 동기/비동기는 **"내가 언제까지 기다릴지"**를 결정하는 방식이었습니다. 반면, 블로킹/논블로킹은 **"제어권을 누가 가지고 있을지"**를 결정하는 방식입니다. 이들은 서로 독립적인 개념이기 때문에 조합하면 총 4가지 상황이 나올 수 있습니다. 그럼 하나씩 살펴보겠습니다.


동기 - 블로킹

동기 블로킹 이미지 A가 B를 호출하고, B가 작업을 끝낼 때까지 A는 아무 것도 못 하고 기다립니다. 제어권도 B로 넘어가 있고, A는 중단된 상태입니다. 코드로 보자면

const result = syncBlocking();
console.log(result);
console.log("다음 작업");

실생활에선

카페에서 커피를 주문하고, 커피가 나올 때까지 카운터에서 기다리는 것입니다. 그 동안 아무것도 못하고 대기하는 것이라고 볼 수 있습니다.

동기 - 논블로킹

A가 B를 호출해도, 제어권은 A에게 있습니다. B는 논블로킹 방식으로 즉시 반환하고, A는 다음 작업을 계속 진행합니다. 동기 논블로킹 이미지 코드로 보면

let isCompleted = false;
const result = startOperation(); // 작업 시작하고 바로 리턴
while (!isCompleted) {
  isCompleted = checkIfOperationCompleted(); // 완료 여부 계속 확인
  // 다른 작업 수행 가능
}
console.log("다음 작업");

실생활에선

카페에서 주문한 후 자리에 앉았지만, 계속 카운터를 쳐다보며 주문이 준비됐는지 확인하는 것입니다.

비동기 - 블로킹

A가 B에게 비동기 작업을 요청했으나 A는 계속 기다립니다. A는 결과가 올 때까지 다른 작업을 못하고 기다립니다. 비동기 블로킹 이미지 코드로 보면

const req = new XMLHttpRequest();
req.open("GET", "URL", false);
req.send();
console.log(req.responseText);
console.log("다음 작업");

실생활에선

커피를 주문했는데 점원이 진동벨도 안 주고 카운터 앞 의자에 앉아 기다리라는 상황입니다. 다른 일은 못하고 시간만 갑니다.

비동기 - 논블로킹

A가 B에게 비동기 작업을 요청하고, 제어권을 계속 가지고 다른 작업을 이어갑니다. 결과는 나중에 콜백이나 프로미스를 통해 받을 수 있습니다. 비동기 논블로킹 이미지 코드로 보면

console.log("시작");
fetch("URL")
  .then((response) => response.text())
  .then((data) => console.log(data));
console.log("다음 작업");

실생활에서

카페에서 커피를 시키고, 자리로 가서 개인 작업을 시작합니다. 진동벨이 울리면 커피를 받으러 갑니다. 커피가 나올 때까지 다른 일에 집중할 수 있게됩니다.


요약

지금까지 동기/비동기와 블로킹/논블로킹의 개념을 알아봤습니다. 처음에는 비슷해 보이지만 사실 완전히 다른 개념이라는 것을 확인했습니다. 블로킹/논블로킹은 제어권에 관한 이야기였습니다:

  • 블로킹: 작업이 끝날 때까지 제어권을 넘겨주고 기다립니다.
  • 논블로킹: 제어권을 유지하며 다른 작업을 계속할 수 있습니다.

동기/비동기는 작업 처리 순서와 방식에 관한 이야기였습니다:

  • 동기: 작업을 순차적으로 처리하며, 하나가 끝나야 다음으로 넘어갑니다.
  • 비동기: 작업의 완료를 기다리지 않고 다음 작업을 진행하며, 완료되면 콜백이나 프로미스로 처리합니다.

이 두 개념의 조합으로 총 4가지 방식이 탄생합니다:

  • 동기-블로킹: 작업을 요청하고 제어권도 넘기고 완료될 때까지 다른 일도 못합니다. (카페에서 커피 나올 때까지 카운터에서 기다리기)
  • 동기-논블로킹: 작업을 요청하고 제어권은 유지하지만 계속 완료 여부를 확인합니다. (카페에서 주문 후 자리에 앉아 계속 카운터를 쳐다보며 확인하기)
  • 비동기-블로킹: 작업을 요청하고 제어권은 넘기지만 나중에 결과를 받습니다. (커피 주문 후 의자에 앉아 기다리기만 하기)
  • 비동기-논블로킹: 작업을 요청하고 제어권도 유지하며 결과는 나중에 받습니다. (커피 시키고 진동벨 받아 다른 일 하다가 알림 오면 받으러 가기)

마치며

평소 블로킹과 논블로킹에 대해 동기와 비동기를 연결지어 생각을 했습니다. 거의 같은 개념이라 생각했고, 누가 물어본다고 하면 자세하게 설명을 못했던 것 같습니다. 이번 기회에 돌아보면서 무슨 차이가 있는지 확실히 알게 되었고 누군가에게 완벽하게 설명할 수 있을 때까지 공부를 이어나가려 합니다.

읽어주셔서 감사합니다! 🙇‍♂️ 궁금한 점이나 틀린 부분이 있다면 언제든지 댓글로 알려주세요 :)

참고

동기-비동기/블로킹-논블로킹 - Inpa 동기-비동기/블로킹-논블로킹 - 0soo 동기,비동기란? - khy__ 동기-비동기/블로킹-논블로킹 - 10분 테코톡(호기) 동기-비동기 - poiemaweb 프라미스와 async,await - 자바스크립트인포