JavaScript 비동기 처리

곽태욱·2020년 9월 8일
5

배경

컴퓨터에서 입력 및 출력 (I/O) 작업은 CPU 데이터 처리 작업에 비해 매우 느릴 수 있습니다. I/O 장치는 읽거나 쓸 트랙을 찾아야 하는 하드 디스크와 같이 물리적으로 움직여야하는 기계 장치를 의미하는데 일반적으로 CPU 논리회로의 전류 스위칭보다 수십 배 느립니다. 예를 들어, 입출력을 수행하는 데 10ms가 걸리는 하드 디스크가 하나의 작업을 하는 동안, 1GHz의 클럭을 가지는 프로세서는 천만 개의 명령을 수행할 수 있습니다.

이렇게 느린 I/O 작업을 간단하게 처리하는 방법은 I/O 요청을 보낸 후 응답을 받을 때까지 기다리는 것입니다. 그러나 이러한 접근 방식(동기 I/O, 블로킹 I/O)은 디스크 접근이나 네트워크 통신이 진행되는 동안 프로그램 진행을 차단하여 시스템 리소스를 낭비합니다. 예를 들어 주로 사용자 입력에 의존하는 프로그램과 같이 I/O 작업이 많은 프로그램에선, 프로세서는 사용자 입력을 기다리는 동안 아무 일도 안 하고 시간만 낭비할 수 있습니다.

그래서 I/O 작업을 처리하면서 기다리는 동안 해당 I/O 작업과 관련 없는 다른 작업을 처리하며 프로세스의 효율을 높일 수 있는데, 이러한 방식을 비동기 I/O라고 합니다. 그래도 해당 I/O 결과에 의존하는 작업은 여전히 처리할 수 없지만, 그 동안 다른 작업을 처리할 수 있기 때문에 프로세스의 처리 효율이 높아집니다.

웹 상에선 I/O 작업이 빈번하게 일어납니다. 단순히 디스크에 접근해 파일을 읽어오는 것뿐만 아니라 HTML이나 이미지를 다운받기 위해 외부 웹 서버나 API 서버로 요청을 보내고 응답을 기다리기도 합니다. 그래서 JavaScript는 이런 I/O 작업을 효율적으로 처리하기 위해 비동기 개념을 도입했습니다.

JavaScript는 싱글 스레드로 동작하며, 논블로킹, 비동기, 동시성을 지닌 동적 타입 언어입니다. 이러한 기능을 지원하기 위해 JavaScript는 스택, 큐, 힙, 이벤트 루프, 외부 API를 가지고 있습니다.

메모리 구조

위 그림과 같이 JavaScript 메모리는 스택 + 힙 + 큐로 이루어져 있습니다. 특히 JavaScript는 런타임 시 싱글 스레드로 동작하기 때문에 다른 언어와는 달리 스택을 1개만 가지고 있습니다.

스택 (Stack)

스택은 현재 JavaScript 코드가 어디까지 실행됐는지를 저장하는 공간으로, 특정 함수의 매개 변수와 지역 변수를 가지고 있는 프레임(Frame) 단위로 관리되고, 특정 함수가 반환되면 해당 프레임이 스택에서 제거됩니다. JavaScript에서 스택은 콜 스택(Call stack) 혹은 호출 스택으로 불리기도 합니다.

힙 (Heap)

힙은 객체, 배열 등 레퍼런스(포인터)로 참조할 데이터를 저장하는 공간입니다. C언어 등에선 프로그래머가 메모리 공간을 얼마나 사용한다고 명시해주고, 직접 할당 받은 메모리 공간에 데이터를 복사하고, 필요 없어지면 직접 메모리 공간 할당을 해제해야 하지만, JavaScript는 메모리 공간 관리를 자동으로 해주는 가비지 컬렉션 기능을 가지고 있습니다.

큐 (Queue)

JavaScript는 나중에 처리할 작업 목록이 기다리고 있는 메시지 대기열을 사용합니다. 메시지 대기열은 형태로 관리되고, 각 메시지에는 해당 작업을 처리하기 위해 필요한 함수(콜백 함수)를 가지고 있습니다.

만약 JavaScript의 콜 스택이 비게 되면 이벤트 루프가 JavaScript 큐에 메시지가 있는지 확인합니다. 만약 메시지가 있으면 콜 스택에 새로운 프레임을 생성하고 해당 메시지를 처리하는데 필요한 함수를 새로 생긴 프레임으로 옮깁니다.

큐는 여러 종류가 있으며 브라우저 화면을 1초에 60번씩 렌더링하는 작업만 존재하는 큐도 있습니다. JavaScript는 콜 스택이 비어있을 때만 16.6ms마다 화면을 렌더링하는 함수를 큐에서 콜 스택으로 불러온 후 실행합니다. 그래서 어떤 작업이 콜 스택에 오랫동안 존재하거나 시간이 오래 걸리는 작업을 동기적으로 실행하면, 브라우저 화면 렌더링이 수행되지 않아 응답 없음 메시지가 나타납니다. 그래서 일반적으로 브라우저 화면을 렌더링하는 작업이 담긴 큐는 다른 큐보다 우선순위가 높습니다. 그 외에도 비동기 함수(콜백 함수)를 보관하는 큐도 있습니다.

JavaScript의 큐는 콜백 큐(Callback queue), 이벤트 큐(Event queue), 태스크 큐(Task queue)로 불리기도 합니다.

싱글 스레드

JavaScript 엔진은 런타임 시 싱글 스레드로 동작하기 때문에 동시에 1개의 명령어만 실행할 수 있습니다. 다시 말해 JavaScript 엔진만으론 프로그램의 동시성(Concurrency)을 확보할 수 없습니다. 하지만 동시성이 보장되어야 여러 작업을 효율적으로 처리할 수 있기 때문에 일반적으로 JavaScript는 엔진과 별개로 작동하는 외부 프로그램의 힘을 빌려 프로그램 동시성을 확보합니다. 그래서 크게 보면 JavaScript 엔진을 기반으로 한 브라우저나 Node.js는 멀티 스레드로 동작한다고 볼 수 있습니다.

정확히 말하면 JavaScript 코드가 실행되는 부분은 싱글 스레드로 동작하지만, JavaScript 함수 중엔 외부 프로그램의 API를 호출하는 함수가 있어서 멀리서 보면 멀티 스레드로 동작하는 것처럼 보이기도 합니다. 예를 들면 setTimeoutfetch 함수 등이 외부 API를 호출하는 함수인데, 타이머가 동작하는 부분이나 외부로 웹 요청을 보내는 부분은 외부 API 스레드에서 실행됩니다. 그래서 JavaScript 코드와 타이머를 카운트하는 코드가 동시에 동작할 수 있는 것입니다.

외부 API

JavaScript 엔진 자체는 싱글 스레드로 실행되지만 JavaScript는 동시성을 확보하기 위해 외부 API를 이용합니다. 브라우저의 경우는 브라우저에 구현된 Web API를 이용하고, Node.js는 C++로 컴파일된 API를 이용합니다.

동시성

동시성(concurrency)이란 동시에 여러 작업을 수행할 수 있는 특성을 의미합니다. 동시성은 동시에 2개 이상의 명령어를 수행할 수 있는 특성을 말하는데, 브라우저의 경우 브라우저에 내장된 Web API를 통해 동시성을 확보합니다.

JavaScript가 Web API에 요청을 보내면, Web API가 내부에서 새로운 스레드를 생성하고 바로 반환하여 JavaScript로 실행 흐름을 넘겨주기 때문에 JavaScript는 논블로킹으로 동작합니다. 그리고 Web API 내부에서 생성된 스레드는 JavaScript에서 요청한 작업을 처리하고, 그 결과를 JavaScript가 건내준 콜백 함수와 함께 큐에 넣어줍니다. 그럼 JavaScript는 나중에 자신의 콜 스택이 비워지면 큐에 있는 콜백 함수를 실행하는 방식으로 요청 결과를 처리할 수 있습니다. 그렇기 때문에 JavaScript는 동시성을 지니면서 비동기로 동작할 수 있습니다.

어쨌든 이벤트 루프는 무엇입니까? | Philip Roberts | JSConf EU (YouTube)

블로킹·논블로킹

블로킹

네트워크 요청이나 디스크 I/O 요청 등 요청에 대한 결과를 기다리는 동안, I/O를 요청한 프로세스(스레드)가 아무 일도 하지 않고 기다리는 현상을 블로킹(Blocking)이라고 합니다. 블로킹은 I/O를 요청한 프로세스의 실행이 정지된다는 의미입니다. 이 현상은 프로그램의 처리 효율을 현저하게 떨어트리면서, I/O 요청을 받은 곳에서 응답을 하지 않으면 I/O를 요청한 프로세스는 계속 정지될 수 있다는 단점이 있습니다.

그래서 동시에 여러 명령을 수행할 수 있는 멀티 프로세싱이나 멀티 스레딩 개념이 등장했고, 이 방식은 Lock을 사용하여 프로세스나 스레드가 공유하는 리소스에 대한 접근을 올바르게 제어합니다. Mutex, Semaphore, Critical Section과 같은 개념은 모두 공유 리소스의 데이터를 유효하게 보호하기 위해 코드의 특정 영역이 동시에 실행되지 않도록 프로그래머가 설정할 수 있는 방식입니다. 한 스레드가 이미 다른 스레드가 보유한 Lock을 얻으려고 하면 해당 Lock이 해제될 때까지 스레드의 실행이 정지됩니다.

하지만 스레드의 실행을 정지시키는 것은 여러 가지 이유로 바람직하지 않을 수 있습니다. 왜냐하면 스레드가 정지되면 그동안 아무 명령도 수행할 수 없기 때문입니다. 정지된 스레드가 우선 순위 높은 작업이나 실시간 작업을 수행하는 스레드인 경우 실행을 중단하는 것은 바람직하지 않습니다.

그리고 Lock을 사용하면 특정 경우에 Deadlock, Livelock, Priority inversion과 같은 오류 상황이 발생할 수 있습니다. 그래서 Lock을 사용하면 병렬 처리가 크게 줄어드는 방식과, 신중한 설계가 필요하고 잠금 오버 헤드가 증가하며 버그가 발생하기 쉬운 방식을 적절하게 고려해서 설계해야 합니다.

논블로킹

그래서 이러한 불편을 줄이기 위해 어떤 스레드의 정지·종료가 다른 스레드를 정지·종료시키지 않는 논블로킹 개념이 등장합니다. 논블로킹 알고리즘은 Lock을 사용해서 생기는 단점이 없습니다.

JavaScript는 멀티 스레딩에서 Lock을 사용해서 생기는 문제점을 회피하기 위해 애초에 싱글 스레드로 설계되었고 블로킹 문제를 해결하기 위해 논블로킹 개념을 도입했습니다. 사실 싱글 스레드인 JavaScript 환경에선 블로킹 방식으로 동작할 수 없습니다. 블로킹은 한 스레드가 다른 스레드의 처리 결과를 기다리는 동안 일을 안 하고 쉬고 있는 현상인데, 싱글 스레드 환경에서 메인 스레드가 정지되면 프로그램이 정지되기 때문입니다.

JavaScript가 실행되는 브라우저나 Node.js 환경은 크게 보면 멀티 프로세스 환경입니다. 그래서 JavaScript 엔진이 다른 API로 요청을 보내면 효율적인 병렬 처리를 위해 API 내부적으로 스레드를 생성한 후 바로 반환하는 논블로킹으로 동작합니다. 그럼 JavaScript 엔진이나 외부 API 모두 기다리거나 정지되지 않고 동시에 실행될 수 있습니다.

만약 JavaScript가 블로킹 방식으로 동작하면, 네트워크 요청을 수행할 때마다 요청에 대한 응답을 기다려야 하기 때문에 브라우저가 화면을 렌더링하지 못 합니다. 즉, JavaScript에서 실행된 네트워크 요청 함수가 요청에 대한 결과를 받을 때까지 반환되지 않으니까, 그동안 JavaScript 호출 스택에 존재하는 코드를 실행할 수 없습니다. 그럼 JavaScript 호출 스택이 비워지지 않기 때문에 화면 렌더링 큐에 있는 작업이 실행되지 않아 브라우저는 응답 없음 상태가 됩니다.

동기·비동기

어떤 프로그램을 실행할 때 항상 한 작업을 끝낸 후 다음 작업을 시작하는 것을 동기(Synchronous) 프로그램이라고 하고, 그에 비해 어떤 작업을 처리하는 도중 다른 작업을 시작할 수 있으면 비동기(Asynchronous) 프로그램이라고 합니다.

그래서 동기 프로그램은 작업 처리 순서가 항상 일정하거나(일반적으로 위에서 아래로) 처리 순서를 예측할 수 있는데 비해, 비동기 프로그램은 처리 중인 작업의 처리 순서는 알기 어렵다는 점이 있습니다.

Asynchronous vs synchronous execution (Stackoverflow)

비동기 도입 배경

JavaScript가 사용되는 브라우저에선 웹 요청이 대다수를 차지합니다. 그런데 웹 요청에 대한 응답이 클라이언트로 도착하는 시간은 네트워크 지연이나 서버 사정에 따라서 매번 달라질 수 있어 JavaScript 코드를 동기로 작성하기가 까다롭습니다.

예를 들어 위 그림과 같이 요청 A에 대한 응답을 응답 A라고 했을 때, 요청 A를 보내고 그 다음에 요청 B를 보냈을 때 응답 A가 응답 B보다 먼저 도착하길 기대하고 코드를 작성하면 프로그램 처리 효율이 떨어지거나 오류가 발생할 수 있습니다. 왜냐하면 앞서 블로킹 상황처럼, 먼저 도착한 응답 B를 먼저 처리해도 되는데 계속 응답 A를 기다리는 상황이 발생할 수 있기 때문입니다. 또는 응답 A가 아직 도착하지 않았는데 왔다고 가정하고 응답 A를 처리하면 당연히 오류가 발생합니다.

그래서 JavaScript는 코드 실행 순서가 정해지지 않은 비동기 방식으로 동작합니다. 그리고 JavaScript의 비동기 코드 실행을 도와주기 위해 JavaScript 문법에는 콜백 함수나 프로미스가 존재하고 JavaScript 엔진엔 콜백 큐와 이벤트 루프가 존재합니다.

비동기 vs 병렬 처리

비동기 프로그램은 한 작업이 완료되기 전에 다른 작업을 처리할 수 있기 때문에 여러 작업을 한번에 처리할 수 있습니다. 여기서 '한번에' 라는 의미는 무엇일까요? 어떤 시점에 2개 이상의 작업을 동시에 처리한다는 병렬 처리의 개념과 유사한 것일까요?

앞서 말했듯이 JavaScript는 싱글 스레드로 동작합니다. 싱글 스레드로 동작하는 JavaScript는 작업을 병렬로 처리할 수 없습니다. 하지만 싱글 스레드로도 작업1을 1초 처리하다가 작업2를 1초 처리하는 방식으로 비동기를 구현할 수 있습니다. 그리고 비동기 작업은 스레드의 context switching이 빈번하게 일어나기 때문에 동기 작업에 비해 총 처리 시간이 길어질 수 있습니다.

결국 병렬 처리는 작업을 여러 스레드로 나눠 여러 프로세서에서 동시에 실행하는 개념이고, 비동기 처리는 한 작업이 완료되기 전에 다른 작업을 처리한다는 개념입니다. 즉, 작업을 비동기적으로, 병렬적으로, 또는 비동기적이면서 병렬적으로도 처리할 수도 있습니다.

How to articulate the difference between asynchronous and parallel programming? (Stackoverflow)

Callback vs Promise

(작성 중)

프로미스는 비동기로 실행, 콜백은 동기 또는 비동기로 실행

Array.prototype map, reduce, filter의 콜백 함수는 동기로 실행된다.

setTimeout, fetch 함수는 외부 API를 호출하기 때문에 이러한 함수의 콜백 함수는 비동기로 실행된다.

프로미스 콜백이 그냥 콜백보다 우선한다.

async · await

await 키워드는 Promise 객체를 반환하는 함수 앞에만 사용할 수 있습니다. 그리고 await 키워드는 그 Promise 객체 상태가 fulfilled 또는 rejected 될 때까지 async 함수의 실행을 일시정지합니다. await 키워드는 async 키워드가 붙은 함수 내에서만 사용할 수 있습니다.

이후 만약 resolve 함수 호출로 Promise의 상태가 fulfilled로 변경되면 일시정지된 async 함수를 일시정지됐던 부분부터 다시 실행합니다. 이때 Promise에서 resolve 함수 매개변수로 전달한 값이 await 앞으로 반환됩니다. 만약 Promise가 reject 되면 await 문은 reject된 값을 상위 try-catch 문으로 전달합니다.

async 함수가 반환하는 값은 항상 fulfilled된 Promise로 감싸져서 반환됩니다.

Promise 이해

const promise1 = new Promise((resolve) => resolve(true))
promise1.then((booleanTrue) => {
  console.log(booleanTrue) // true
})

위 코드와 같이 프로미스의 상태가 fulfilled로 변하면 then의 콜백 함수가 비동기로 실행되는데, resolve 함수 호출 시점의 인수값(true)이 then의 콜백 함수 매개변수에 전달됩니다.

const promise2 = new Promise((resolve) => true)
promise2.then((boolean) => {
  console.log(boolean) // 실행되지 않음
})

위 코드와 같이 프로미스의 상태가 항상 pending인 경우에는 then의 콜백 함수가 실행되지 않습니다.

const promise3 = new Promise((resolve) => {
  console.log('1. promise start, setTimeout start')
  setTimeout(() => {
    console.log('3. setTimeout end')
    resolve()
  }, 3000)
  console.log('2. promise end')
})
promise3.then(() => console.log('4. then start'))

위 코드와 같이 then의 콜백 함수는 해당 프로미스의 resolve 함수가 호출된 이후에 실행됩니다. 콘솔 출력 순서는 1, 2가 출력되고 약 3초 뒤에 3, 4가 출력됩니다.

async function a() {
  const number100 = await new Promise((resolve) => resolve(10000))
    .then((number10000) => {
      console.log(number10000) // 10000
      return new Promise((resolve) => resolve(1000))
    })
    .then((number1000) => {
      console.log(number1000) // 1000
      return new Promise((resolve) => resolve(100))
    })
  console.log('Async/await Data:', number100) // 100
}
a()

async/await 사용시 프로미스 객체 앞에 await를 붙이면 마지막 then까지 실행한 후 마지막 resolve 값이 await 앞으로 반환됩니다.

비동기 실행 순서

import { readFile } from 'fs'

const aaa = () => {
  setTimeout(() => {
    console.log('d')
  }, 0)
  console.log('c')
}

setTimeout(() => {
  readFile('any.txt', () => {
    setTimeout(() => {
      console.log('e')
    }, 0)
    setImmediate(() => {
      console.log('f')
    })
  })
}, 100)

setTimeout(() => {
  console.log('a')
  aaa()
}, 0)

Promise.resolve().then(() => {
  aaa()
  console.log('b')
})

GitHub Gist

우선 코드가 첫줄부터 끝줄까지 실행되면서 21번째 줄과 26번째 줄의 콜백 함수가 비동기 큐로 들어갑니다.

콜 스택이 모두 비워지면 이벤트 루프에 의해 비동기 큐의 작업이 실행되는데, 프로미스의 콜백이 우선으로 실행되기 때문에 26번째 줄의 콜백 함수가 실행되어 c, b가 콘솔에 출력됩니다. 이때 비동기 큐로 4번째 줄의 콜백 함수가 들어갑니다.

그리고 21번째 줄의 콜백 함수가 실행되면서 a, c가 콘솔에 출력됩니다. 이때 4번째 줄의 콜백 함수가 다시 비동기 큐로 들어갑니다.

그리고 10번째 줄의 콜백 함수는 WebAPI 에서 100ms 동안 기다려야 하기 때문에, 자신의 컴퓨터가 충분히 빠르다면 비동기 큐에 있는 4번째 줄의 콜백 함수 2개가 실행되어 d, d가 출력됩니다.

그 후 10번째 코드를 실행한지 100ms가 지나면 외부 API에 의해 그 결과가 비동기 큐에 삽입되고, 이벤트 루프에 의해 10번째 줄의 콜백 함수가 실행됩니다. 그리고 readFile 함수는 디스크로부터 파일을 논블로킹으로 읽기 때문에 11번째 줄의 콜백 함수가 비동기 큐로 들어갑니다.

파일 읽기가 끝나면 11번째 줄의 콜백 함수가 실행되고 12번째 줄과 15번째 줄의 콜백 함수가 비동기 큐로 들어갑니다. 그리고 11번째 줄부터 18번째 줄까지 실행되어 JavaScript 콜 스택이 비워지면 이벤트 루프에 의해 비동기 큐에 있는 함수가 실행되는데, 15번째 줄의 콜백 함수는 setImmediate 함수 특성상 바로 실행되기 때문에 f, e가 콘솔에 출력됩니다.

profile
이유와 방법을 알려주는 메모장 겸 블로그. 블로그 내용에 대한 토의나 질문은 언제나 환영합니다.

0개의 댓글