동기와 비동기, Thread, Promise

쓰레드(Thread)에 대해서 알아보고, 동기와 비동기의 개념을 익힙니다. 또한 Javascript의 콜백 지옥을 해결하는 데 쓰이는 Promise에 대해서 공부합니다.
동기와 비동기, Promise, 쓰레드, Thread, 콜백 지옥


Thread

대부분의 OS는 멀티태스킹(Multi-tasking), 또는 멀티프로세싱(Multi-processing)을 지원합니다. 즉 아주 짧은 시간 단위로 각 프로세스의 코드를 조금씩 조금씩 번갈아가며 실행(Context Switch)하면서 여러 프로세스가 동시에 실행되는 것처럼 흉내를 낼 수 있습니다.

멀티 쓰레딩멀티 쓰레딩

나아가서 OS는 한 프로세스가 다수의 쓰레드(Thread)를 가질 수 있게합니다. 쓰레드는 프로세스의 안의 작은 프로세스, 쉽게는 함수와 같은 코드 블럭으로 생각 할 수 있습니다. 기본적으로 프로세스는 하나의 쓰레드를 기반으로 작동하지만, OS 위에서 프로그램을 작성하면 OS API를 통해 쓰레드를 직접 제어 할 수도 있습니다.

예를 들어 웹 브라우저가 싱글 쓰레드 기반이라면 HTML을 렌더링하다가 태그를 만나 그 이미지를 다운로드 받게 된다면 다운로드가 완료될 동안 HTML의 렌더링이 멈춰있을 것입니다. 이러한 이유로 사용자에 대한 응답성이 중요한 GUI 프로그램들은 대부분 다수의 쓰레드를 기반으로 작성됩니다. 멀티쓰레딩(Multi-threading)의 원리 역시 각 쓰레드의 코드를 조금씩 조금씩 번갈아가며 실행(Thread Switch)하는 것입니다.

동기와 비동기

동기와 비동기동기와 비동기

많은 플랫폼(Android, iOS, Windows, …)의 GUI 프로그램들은 보통 하나의 Main Thread (UI Thread)를 가지며 UI 작업 외의 처리를 위한 다수의 Background Thread를 가집니다. 이러한 쓰레드 모델을 통해 UI가 항상 갱신되기에, 사용자는 프로그램이(UI가) 멈췄다라는 느낌을 받지 않을 수 있습니다.

Synchronous/Blocking Code

for(var i=0; i < 100000; i++) Math.random();

안타깝게도 웹 브라우저의 JavaScript는 Main Thread에서 작동하며 쓰레드를 직접 제어 할 수가 없습니다. 때문에 처리가 오래걸리는 JavaScript 코드를 실행하게 되면 웹 페이지의 UI가 한동안 반응하지 않을 수 있습니다. 위와 같은 코드는 UI를 버벅거리게 합니다. 이렇게 Main Thread에서 작동하는 코드를 동기(Synchronous) 또는 봉쇄(Blocking) 코드라고 합니다.

Asyncronous/Non-blocking Code

// native
var req = new XMLHttpRequest();
req.open('GET', 'URL', true);
req.onreadystatechange = function(){
  // ...
};
req.send();

// jQuery
$.get('URL', function(response){
  // ...
});

다행히 웹 브라우저도 Background Thread에서 작동하는 코드를 제공하긴 합니다. 세세한 사항들이 있지만, 대표적으로== XHR (Ajax Request)는 비동기(Asynchronous) 또는 비봉쇄(Non-blocking)로 작동하는 코드==입니다. 이러한 이유로 Ajax를 이용 할 땐, Background Thread에서 Request와 Response를 주고 받은 후, Main Thread에서 실행 할 코드를 콜백이나 핸들러 형태로 제공하게 됩니다.

조금 더 저수준에서 엄밀히 따지자면, 동기와 비동기 그리고 봉쇄와 비봉쇄라는 개념에는 차이가 있습니다. 동기와 비동기 코드는 실행 결과가 도착한 시점과 후처리 코드가 연속적인지에 초점을 둔다면, 봉쇄와 비봉쇄 코드는 단순히 실행 시 결과를 CPU를 유휴 상태로 기다리게 하는가에 초점이 있습니다.

Node.js Processing ModelNode.js Processing Model

또한 우리가 지금 백엔드에서 사용하고 있는 Node.js 역시 그 구조상 콜백을 이용한 비동기 처리가 빈번합니다. 이는 Node.js의 구조가 Single-Thread-Event-Loop에 기반하기 때문입니다. Node.js는 I/O 처리를 담당하는 메소드를 Sync/Async 방식 두 가지로 제공하여, 개발자가 필요에 따라서 적절히 선택 할 수 있도록 합니다.

Node.js I/O Methods

const fs = require('fs');

// 동기 방식의 코드
try {
  let data = fs.readFileSync('/some/file/path');
  console.log(data); 

} catch (err) {
  console.error(err);
}

// 비동기 방식의 코드
fs.readFile('/some/file/path', (err, data)=>{
  if (err) {
    console.error(err);
  } else {
    console.log(data);
  }
});

비동기 코드의 문제

Callback HellCallback Hell

비동기 처리의 난관은 에러 처리를 위한 (1) try/catch가 작동하지 않으며, 비동기 로직이 조금만 복잡해지면 (2) 콜백 지옥(Callback Hell)이라고 불리는 가독성이 떨어지는 코드가 된다는 점입니다.

에러 처리를 위한 다중 콜백

// Background Thread에서 실행될 함수가 이런식으로 제공된다면
function asyncTask(successCallback, errorCallback){
  try {
    // ...작업 후
    successCallback(data);
  } catch(err) {
    // ...에러
    errorCallback(err);
  }
}

// 성공시, 오류시 각각의 콜백을 제공 할 수 있음
asyncTask(function(data){
  // .. 성공시
}, function(err){
  // .. 오류시
});

위 방법으로 에러 처리는 어느 정도 우아하게 해결 할 수 있으나, 콜백 지옥의 문제는 원칙적으로 해결하기가 힘듭니다.

Promise

JavaScript의 다양한 라이브러리에서 비동기 코드의 문제를 해결하기 위해서 Promise라고 불리는 개념을 도입하였으며, ES6에서는 Promise 객체가 표준으로 자리잡았습니다. Promise 객체는 추후에 어떤 데이터를 가지고 해결될 것이라는 약속을 추상화했습니다. 그리고 그 약속 객체에 대해서 성공 할 경우의 콜백, 실패 할 경우의 콜백 등을 등록 할 수 있습니다.

// Promise 객체를 리턴하는 asyncTask() 함수
asyncTask()
  .then(data => { // 성공 콜백
    console.log(data);
  })
  .catch(err => { // 에러 콜백
    console.error(err);
  });

// 여러 Promise 객체를 연쇄적으로 연결 할 수 있습니다.
asyncTask()
  .then(data => { return asyncTask2(data); }) // 역시 Promise 객체를 리턴
  .catch(err2 => { console.error(err2, "from ayncTask2"); }) // 중간 중간에 에러 제어 콜백을 연결 할 수 있습니다.
  .then(data2 => { return asyncTask3(data2); }) // 역시 Promise 객체를 리턴
  .then(data3 => { console.log(data3) })
  .catch(err => { console.error(err); });

// asyncTask, asyncTask2, asyncTas3가 모두 종료된 후에 실행 될 콜백을 연결 할 수 있습니다.
Promise
  .all([asyncTask(), asyncTask2(), asyncTask3()]);
  .then(results => { ... })
  .catch(err => { ... });

Promise 정의하기

직접 Promise 객체를 생성 할 수도 있습니다. 생성자의 인자로 함수를 받습니다.

var promise = new Promise(function(resolve, reject){
  // ... 작업
  
  // 성공시
  resolve(data);

  // 실패시
  reject(err);
});

promise.then(...)

Promise를 이용한 비동기 함수

JavaScript 뿐만 아니라, Node.js에서도 역시 Promise를 이용해 복잡한 비동기 처리를 우아하게 할 수 있습니다.
ES6 Promise API

비동기에 대한 오해

콜백을 받는 함수나, Promise를 이용한 함수는 항상 비동기로 처리된다는 오해를 할 수 있습니다. 하지만 코드가 동기/비동기로 실행되는지, 혹은 코드가 메인 쓰레드를 봉쇄(Blocking)하는 지의 여부는 콜백이나 Promise와 같은 형태에 달려있는 것이 아니라, 실제 그 코드가 어느 쓰레드에서 실행되는 지에 달려있습니다.

콜백을 받는 동기 코드

콜백을 받는 비동기 코드

Promise를 이용한 동기 코드

Promise를 이용한 비동기 코드

Thread를 제어 할 수 있는 API를 제공하는 Low Level의 플랫폼인 경우에는, 개발자 스스로가 Thread를 설계하고 코드를 분배 할 수가 있습니다. 하지만 JavaScript같이 native API가 Threading에 대한 계획을 내부적으로 처리하는 경우가 많습니다. 이런 경우엔 특정 native API를 사용할 때, 그 API가 어느 쓰레드에서 실행되는 지 확인이 필요하겠습니다.

JavaScript에서 Background Thread를 직접 제어 할 수 있는 Worker API
Node.js에서 Multi-threading을 구현 할 수 있는 threads 모듈

목차
4. 웹 백엔드
5. 데이터베이스
저자

김동욱

개발 경력 약 10년, 전문 분야는 웹 및 각종 응용 소프트웨어 플랫폼입니다. Codeflow를 운영하고 있습니다.

2018년 04월 10일 업데이트

지원되지 않는 웹 브라우저거나 예기치 않은 오류가 발생했습니다.