어떻게 자바스크립트는 동작하는가?

2024년 4월 2일

[번역]어떻게 자바스크립트는 동작하는가

원 글 링크

자바스크립트와 그 구성 요소들을 탐색하는 시리즈의 네 번째 포스트에 오신 것을 환영합니다.

이 시리즈에서는 자바스크립트의 핵심 요소들을 식별하고 설명합니다.

또한 개발자들이 웹 앱의 버그를 식별하고, 시각화하며, 픽셀 단위로 완벽한 세션 재현을 통해 문제를 해결할 수 있도록 돕는 자바스크립트 도구인 세션스택을 구축하는 과정에서 우리가 사용하는 몇 가지 지침을 공유합니다.

이 전의 3개의 챕터글을 보지 못하셨다면, 아래 링크에서 찾아보실수 있습니다.

  1. 엔진, 런타임, 콜스택에 대해 살펴보기

  2. 구글의 V8엔진을 살펴보고 어떻게 코드를 최적화할수 있는지 알아보기

  3. 4가지의 메모리 누수를 방지하는 메모리 관리 방법

이번 시간에, 우리는 첫번째 글을 확장하여 단일 스레드 환경에서 프로그래밍을 할때의 단점과 이를 극복하기 위해서 이벤트 루프, async/await을 사용하는 방법,

그리고 이를 통해 자바스크립트 UI를 구축하는 방법을 살펴볼 것입니다.

글의 마지막에는 async/await을 사용하여 더 깨끗한 코드를 작성하는데에 도움이 되는 5가지 팁을 공유할 것입니다.

- 왜 싱글 스레드는 한계를 가지고 있을까요 ?

우리가 첫번째글에서, 호출스택에 있는 함수 호출이 처리 되는데에 엄청난 시간이 걸릴때 어떤 일이 일어나는지에 대해서 고민해보았습니다.

예를 들어서, 브라우저에서 실행되는 복잡한 이미지 변환 알고리즘이 있다고 가정해봅시다.

호출스택에 만약 실행할 함수가 있는 동안에는, 브라우저가 다른 작업을 할수 없게 됩니다.

이는 브라우저가 차단 됐다는 것을 의미합니다.

이는 브라우저가 렌더링 할 수 없고, 다른 코드도 실행 할 수 없으며, 그냥 멈추게 되는 것을 의미합니다.

그리고 이러한 부분에서 우리의 어플리케이션이 더이상 효율적이지 않고 만족스럽지 않게 되는 문제가 발생합니다.

몇몇 경우에서, 이러한 부분들은 크게 문제가 되지 않을수도 있긴합니다.

하지만 이 보다 더 큰 문제가 발생합니다.

브라우저가 호출스택에서 너무 많은 작업을 처리하기 시작한다면, 오랜 시간 동안 반응하지 않게 될 수 있습니다.

그 시점에 많은 브라우저들은 페이지를 종료할것인지 말것인지 묻는 에러창을 띄워줄 것입니다.

이건 UX를 굉장히 망치는 길입니다.

- 자바스크립트 프로그램의 블록 단위를 구성하는 것

자바스크립트 프로그램의 구성 요소들에 대해서 이야기 할때, 단일 js파일을 이야기 할수도 있지만, 우리의 대부분 프로그램은 여러 모듈들로 나누어져 있습니다.

또한 그 모듈들중에 하나만 현재 실행 될 것이고, 나머지는 나중에 실행 됩니다.

가장 일반적인 블록 단위는 함수입니다.

대부분의 자바스크립트를 새롭게 접하는 개발자가 가지고 있는 문제는, 나중지금 바로 이후에 엄격하게 발생하지 않는다는 것을 이해하는 것입니다.

즉, 자바스크립트에서 모든 코드가 작성된 순서대로 바로 실행되지 않을 수 있습니다.

아래의 예를 살펴보겠습니다.

// ajax(..) is some arbitrary Ajax function given by a library
var response = ajax('https://example.com/api');

console.log(response);
// `response` won't have the response

당신은 아마 표준 Ajax 요청은 동기적으로 완료되지 않는다는 것을 알수도 있습니다.

이는 코드 실행 시점에서 ajax 함수는 아직 반환 값이 없어서 response 변수에 값을 할당할 수 없다는 것을 의미합니다.

기다리고 있는 비동기 함수가 결과를 반환할수 있도록 하는 방법은 콜백 함수를 사용하는 것입니다.

ajax('https://example.com/api', function (response) {
  console.log(response); // `response` is now available
});
  • 알아둘 점 : 실제로 동기적인 Ajax 요청을 할수 있습니다. ❗️절대 절대 그렇게 하지 마세요❗️

만약 동기적으로 Ajax 요청을 하게 되면, 자바스크립트의 앱의 UI가 차단 됩니다.

사용자는 클릭이나 데이터 입력, 탐색, 스크롤을 할수 없게 됩니다.

이는 사용자의 모든 인터렉션을 막는것이기 때문에 절대 하면 안됩니다.

// This is assuming that you're using jQuery
jQuery.ajax({
  url: 'https://api.example.com/endpoint',
  success: function (response) {
    // This is your callback.
  },
  async: false, // And this is a terrible idea
});

우리는 Ajax요청을 예시로 사용했지만, 비동기적으로 사용되는 함수는 무엇이든 사용될 수 있습니다.

이는 setTimeout(callback, milliseconds) 함수를 사용해서도 수행할 수 있습니다.

setTimeout 함수가 하는 일은 나중에 발생할 이벤트(타임아웃)을 설정하는 것입니다.

한번 살펴보겠습니다.

function first() {
  console.log('first');
}
function second() {
  console.log('second');
}
function third() {
  console.log('third');
}
first();
setTimeout(second, 1000); // Invoke `second` after 1000ms
third();

위 코드의 결과값은 이렇습니다.

first
third
second

- 이벤트 루프란 무엇인가 ?

우리는 다소 이상한 주장으로 이야기를 시작할 것입니다.

ES6가 등장하기 이전까지, 자바스크립트 자체는 setTimeout 같은 비동기 코드를 허용함에도, 실제적인 비동기성에 대한 개념을 내장하고 있지 않았습니다.

자바스크립트 엔진은 언제나 해당 시점에 해당하는 조각의 프로그램을 실행하는 것 이상을 한적이 없습니다.

자바스크립트 엔진(특히 구글의 V8)이 어떻게 작동하는지에 대한 자세한 내용은, 이 주제에 관한 우리의 이전 글 중 하나를 확인하세요.

그렇다면 자바스크립트 엔진에게 프로그램의 조각들을 실행하라고 지시하는 주체는 누구일까요 ?

실제로, JS 엔진은 고립되어 실행되지 않습니다.

대부분의 호스팅 환경 내에서 자바스크립트는 실행 되는데, 이는 일반적으로 웹브라우저나 Node.js 입니다.

실제로, 요즘에는 자바스크립트가 로봇부터 전구에 이르기까지 모든 종류의 장치에 내장되어 있습니다.

각각의 장치는 자바스크립트 엔진에 대한 다른 유형의 호스팅 환경을 대표합니다.

(즉 로봇내에서 자바스크립트가 실행된다면 해당 로봇을 조종할수 있는 환경이 로봇 내에 갖추어져있을 것이다.)

모든 환경에서 공통적인 요소는 이벤트 루프라고 불리는 내장 메커니즘으로, 이는 시간이 지남에 따라서 여러 조각들의 실행을 처리하며 매번 자바스크립트 엔진을 호출합니다.

이는 자바스크립트 엔진이 단지 임의의 자바스크립트 코드를 요청에 따라서 실행하는 환경일 뿐이라는 것을 의미합니다.

이벤트(자바스크립트 코드 실행)를 스케줄링 하는 것은 주변 환경입니다.

예를 들어서, 자바스크립트 프로그램이 서버에서 어떤 데이터를 가져오기 위해 Ajax 요청을 할때, 응답 코드를 콜백 함수 안에 설정하고, 자바스크립트 엔진은 호스팅 환경에게 다음과 같이 알립니다.

이제 실행을 잠시 중단할거야. 하지만 네트워크 요청을 마치고 데이터가 준비 되면, 이 함수를 다시 호출해줘.

브라우저는 네트워크로부터의 응답을 기다리도록 설정이 되어있고, 반환할 데이터가 있을때 이벤트 루프에 콜백 함수를 삽입하여 실행되도록 스케줄링합니다.

브라우저는 네트워크로부터의 응답을 기다리도록 설정이 되어있고, 반환할 데이터가 있을때, 이벤트 루프에 콜백 함수를 삽입하여 실행되도록 스케줄링합니다.

아래 다이어그램을 살펴봅시다.

메모리 힙과 콜스택에 관해서는 이전 글에서 읽어 보실 수 있습니다.

그리고 Web APIs는 뭘까요 ? 본질적으로, Web API들은 직접 접근할수 없는 스레드들입니다.

단지 이들을 호출 할 수 있을 뿐입니다.

이들은 브라우저의 일부분으로, 여기에서 동시성이 발생합니다.

만약에 당신이 Node.js 개발자라면 이러한 API들은 C++ API들입니다.


- 원 글외 정리 ❓ 동시성이란 ❓

동시성이란 여러 작업이 겹치는 시간 동안 병렬로 실행 되거나, 동시에 실행 되는 것처럼 처리되는 것을 의미한다.

즉, 여러 작업이 동시에 진행 되고 있는 것처럼 보이며, 이를 통해 어플리케이션이 더욱 효율적으로 작동할 수 있도록 한다.

예로 들면, DOM 조작이나 Fetch, setTimeout 같은 기능들은 자바스크립트 엔진 외부에서 처리되고, 이러한 기능들을 웹 API라고 부른다.

이러한 API들은 브라우저가 제공하는 별도의 스레드에서 실행이 되기 때문에, JS의 주 실행 스레드와는 독립적으로 작동할수 있고 동시성을 가능하게 한다.

function timeOutTest() {
  setTimeout(() => {
    console.log('timeOut');
  }, 1000);
}

timeOutTest();

예로 들면 위의 코드는 JS 코드이지만, setTimeout웹 API의 일부이므로 자바스크립트 엔진과는 독립적으로 작동한다.

또 Node.js 에서도 웹 API 기능들을 동일하게 사용할수 있다.

하지만 동일한 기능이더라도 그 기능이 구현되는 환경이 다르다. (Node.js는 Node.js 환경)


- 그렇다면 결국 이벤트 루프란 무엇인가요 ?

이벤트 루프는 매우 단순한 업무를 가지고 있습니다.

바로 호출 스택콜백 큐를 모니터 하는 것입니다.

호출 스택이 비어있으면, 이벤트 루프는 큐에서 첫번째 이벤트를 가져와서 호출 스택으로 밀어 넣습니다.

이것은 효과적으로 그 이벤트를 실행시키는 것입니다.

이렇게 이벤트 루프에서 하나의 순환을 틱(tick)이라고 부릅니다.

각 이벤트는 그저 함수 콜백일 뿐입니다.

console.log('Hi');
setTimeout(function cb1() {
  console.log('cb1');
}, 5000);
console.log('Bye');

이 코드를 실행 시키고 무슨 일이 벌어지는지 살펴봅시다.

1. 상태는 깨끗하고, 브라우저 콘솔, 콜스택도 전부 비워져 있습니다.

2. console.log('Hi')가 콜스택에 추가 됩니다.

3. console.log('Hi')가 실행 됩니다.

4. console.log('Hi')가 콜스택에서 삭제 됩니다.

5. setTimeout(function cb1(){ ... }) 가 콜스택에 추가됩니다.

6. setTimeout(function cb1(){ ... })이 실행됩니다. 브라우저는 타이머를 실행시키기 위해 Web API 타이머를 생성합니다.

7. setTimeout(function cb1(){ ... })이 실행되고 콜스택에서 삭제 됩니다.

8. console.log('Bye')이 콜스택에 추가 됩니다.

9. console.log('Bye')가 실행됩니다.

10. console.log('Bye')가 콜스택에서 제거됩니다.

11. 5초가 지난 후에, 타이머가 끝나고 이를 cb1 콜백 함수를 콜백 큐에 집어 넣습니다.

12. 이벤트루프가 콜백 큐에 있는 cb1을 다시 콜스택으로 넣습니다.

13. cb1이 실행되고, console.log('cb1')이 콜스택에 추가 됩니다.

14. console.log('cb1')이 실행됩니다.

15. console.log('cb1')가 콜스택에서 제거 됩니다.

16. cb1이 콜스택에서 제거 됩니다.


ES6가 이벤트 루프의 작동 방식을 명시하고 있어서, 기술적으로 JS 엔진의 책임 범위 안에 들어가게 되었습니다.

이는 더이상 호스팅 환경의 역할만 하는것이 아니라는 것을 의미합니다.

이러한 변화의 주된 이유 중 하나는, ES6에서 프로미스(Promises)의 도입 때문입니다.

프로미스는 이벤트 루프 큐에서의 작업 스케줄링을 직접적이고, 세밀하게 제어할 수 있는 접근을 요구하기 때문입니다. (이에 대해서는 나중에 더 자세히 논의할 예정입니다.)

- setTimeout은 어떻게 작동하는가 ?

setTimeout이 작동하는 방식을 알아두는것은 중요합니다.

setTimeout은 자동으로 콜백을 이벤트 루프 큐에 넣지 않습니다.

setTiemout은 타이머를 설정합니다.

타이머가 만료 되었을때, 환경이 당신의 콜백을 이벤트 루프에 배치하여, 미래의 어떤 틱(tick)이 그것을 가져가서 실행하게 됩니다.

이 코드를 살펴보세요.

setTimeout(myCallback, 1000);

이는 1초후에 myCallback 함수가 실행된다는 의미가 아니라, 1초후에 이벤트 루프 큐에 myCallback이 추가 된다는 의미입니다.

그러나 이 대기열에는 이전에 추가된 다른 이벤트가 있을 수 있으므로 콜백은 대기해야합니다.

자바스크립트에서 비동기 코드를 시작하는 방법에 대한 글과 튜토리얼에는 setTimeout(callback, 0)을 실행할 것을 제안하는 글과 튜토리얼이 꽤 많이 있습니다.

이제 이벤트 루프가 무엇을 하고 setTimeout이 어떻게 작동하는지 알았습니다.

두번째 인수로 0을 사용하여 setTiemout을 호출하면 호출 스택이 비워질때까지 콜백 함수를 연기합니다.

이 코드를 한번 살펴봅시다.

console.log('Hi');
setTimeout(function () {
  console.log('callback');
}, 0);
console.log('Bye');

대기 시간은 0초임에도 불구하고 브라우저의 콘솔창은 이렇게 나옵니다.

Hi
Bye
callback

- ES6의 작업이란 무엇인가요 ?

ES6에는 작업 대기열이라는 새로운 개념이 도입되었습니다.

이는 이벤트 루프 큐 위에 있는 레이어입니다.

프로미스 비동기 동작을 다룰때 가장 많이 접하게 될것 입니다. (이에 대해서도 설명할 예정입니다.)

나중에 프로미스의 비동기 동작에 대해 논의할때 이러한 동작이 어떻게 예약되고 처리되는지 이해할 수 있도록 지금 이 개념만 다루겠습니다.

작업 대기열은 이벤트 루프 대기열의 모든 틱 끝에 첨부되는 대기열이라고 상상해보세요.

이벤트 루프 틱중에 발생할 수 있는 특정 비동기 작업은 이벤트 루프 대기열에 완전히 새로운 이벤트를 추가하는것이 아니라, 현재 틱의 작업 대기열 끝에 항목(일명 작업)을 추가합니다.

즉, 나중에 실행 될 다른 기능을 추가할 수 있으며. 다른 기능들보다 먼저 실행될 것이므로 안심할 수 있습니다.

하나의 작업으로 인해 대기열 끝에 더 많은 작업들이 추가 될수도 있습니다.

이론상으로는 작업 순환(하나의 작업이 다른 작업들을 계속 추가하는)이 발생해서 다음 이벤트 루프 틱으로 넘어가는데 필요한 리소스가 프로그램에 고갈 될 수 있습니다.

개념적으로 이것은 코드가 오래 실행 되거나 무한 루프(while(true)같은)를 표현하는 것과 비슷합니다.

작업은 setTimeout 해킹과 비슷하지만, 훨씬 더 잘 정의되고 보장된 순서(나중에, 가능한 빨리)를 도입하는 방식으로 구현 됩니다.

비동기에서 .then()이나 .catch()를 예로 들수 있다.

asyncFunction()
  .then((data) => console.log(data))
  .catch((err) => {
    console.log(err);
  });

- 콜백

이미 아시다시피 콜백은 자바스크립트 프로그램에서 비동기성을 표현하고 관리하는 가장 일반적인 방법입니다.

실제로 콜백은 자바스크립트 언어에서 가장 기본적인 비동기 패턴입니다.

수많은 JS 프로그램, 심지어 매우 정교하고 복잡한 프로그램도 콜백 이외의 다른 비동기 기반 위에 작성 되었습니다.

다만 콜백에 단점이 없는 것은 아닙니다.

많은 개발자가 더 나은 비동기 패턴을 찾기 위해 노력하고 있습니다.

그러나 실제로 내부에 무엇이 있는지 이해하지 못하면, 어떤 추상화도 효과적으로 사용할 수 없습니다.

다음 장에서는 이러한 추상화 몇 가지를 심층적으로 살펴보고 더 정교한 비동기 패턴(다음 글에서 설명합니다.)이 왜 필요하고 심지어 권장되는지 설명하겠습니다.

- 중첩 콜백

아래의 코드를 살펴보겠습니다.

listen('click', function (e) {
  setTimeout(function () {
    ajax('https://api.example.com/endpoint', function (text) {
      if (text == 'hello') {
        doSomething();
      } else if (text == 'world') {
        doSomethingElse();
      }
    });
  }, 500);
});

여기에는 비동기 계열의 한 단계를 나타내는 세 개의 함수가 중첩된 체인이 있습니다.

이런 종류의 코드는 흔히 콜백 지옥이라고 불립니다.

하지만 콜백 지옥은 사실 중첩/내포와 거의 아무 관련이 없습니다.

그보다 훨씬 깊은 문제가 있습니다.

먼저 클릭 이벤트를 기다린 후, 타이머가 실행 되기를 기다린 다음 Ajax 응답이 돌아오기를 기다리는데, 이 모든 과정이 다시 반복될 수 있습니다.

언뜻 보기에 이 코드는 비동기성을 다음과 같이 순차적인 단계에 자연스럽게 매핑하는 것처럼 보일 수 있습니다.

listen('click', function (e) {
  // ..
});

그 후에

setTimeout(function () {
  // ..
}, 500);

그리고,

ajax('https://api.example.com/endpoint', function (text) {
  // ..
});

마지막으로

if (text == 'hello') {
  doSomething();
} else if (text == 'world') {
  doSomethingElse();
}

이렇게 순차적으로 비동기 코드를 표현하는 것이 훨씬 자연스럽지 않나요 ?

그런 방법이 있을것 같지 않나요 ?

- Promise

밑의 코드를 한번 살펴볼까요

var x = 1;
var y = 2;
console.log(x + y);

이는 매우 간단합니다.

x와 y의 값을 합산하여 콘솔에 출력합니다.

하지만 x 또는 y값이 누락 되어 아직 확인해야하는 경우에는 어떻게 해야할까요 ?

예를 들어서, 표현식을 사용하기 전에 서버에서 x와 y값을 모두 검색해야한다고 가정해보겠습니다.

서버에서 각각 x와 y의 값을 로드하는 loadX 및 loadY 함수가 있다고 가정해보겠습니다.

그런 다음 그 두개의 값이 모두 로드된 후 x와 y의 값을 합산하는 sum 함수가 있다고 가정해보겠습니다.

아마 이렇게 될거에요(많이 지저분합니다.)

function sum(getX, getY, callback) {
  var x, y;
  getX(function (result) {
    x = result;
    if (y !== undefined) {
      callback(x + y);
    }
  });
  getY(function (result) {
    y = result;
    if (x !== undefined) {
      callback(x + y);
    }
  });
}
// A sync or async function that retrieves the value of `x`
function fetchX() {
  // ..
}

// A sync or async function that retrieves the value of `y`
function fetchY() {
  // ..
}
sum(fetchX, fetchY, function (result) {
  console.log(result);
});

위 코드에서 x와 y를 미래의 값으로 취급하고, x나 y 또는 둘다 당장 사용할 수 있는지 여부에 상관 없는 연산 합계를 표현했는데, 여기서 매우 중요한 것이 있습니다.

물론 이러한 대략적인 콜백 기반 접근 방식은 개선해야할 점이 많습니다.

하지만 언제 사용할 수 있을지에 대한 시간적 측면에 대한 걱정 없이 미래 값을 추론하는 것의 이점을 이해하기 위한 작은 첫걸음 일 뿐입니다.

- Promise 값

Promise를 사용하여 x + y 예제를 표현하는 방법을 간단히 살펴보겠습니다.

function sum(xPromise, yPromise) {
  // Promise.all은 promise 들에 대한 배열을 인자로 받는다.
  // 그리고 그들이 끝날때까지 기다리는 새로운 프로미스를 반환한다.

  return (
    Promise.all([xPromise, yPromise])

      // 프로미스가 resolve 되면, `X`와 `Y`를 더한다.
      .then(function (values) {
        // 여기서 values는, 이전에 resolve된 프로미스들에 존재하는 배열값이다.
        return values[0] + values[1];
      })
  );
}

// `fetchX()` 와 `fetchY()` 는 그들의 값을 담은 각각의
// 프로미스를 반환한다.
// 이는 현재 준비가 됐거나, 나중에 준비가 될수도 있다.
sum(fetchX(), fetchY())
  // 우리는 이러한 2개으
  // we get a promise back for the sum of those
  // two numbers.
  // now we chain-call `then(...)` to wait for the
  // resolution of that returned promise.
  .then(function (sum) {
    console.log(sum);
  });

위 코드에서는 두개의 Promise 레이어가 존재합니다.

fetchX(), fetchY()가 직접 호출 되고, 이들이 반환하는 값이 sum() 함수로 전달 됩니다.

이러한 프로미스가 나타내는 기본 값은 지금 또는 나중에 준비될 수 있지만, 각 Promise 는 그에 관계 없이 동작이 동일하도록 정규화 합니다.

우리는 시간에 대해서 독립적인 방식으로 x와 y 값을 추론합니다.

이 값은 미래의 값입니다.

두번째 레이어는 sum이 생성하는 프로미스 입니다.

Promise.all([])을 통해 생성하고 반환하며, then을 호출하여 기다립니다.

sum 함수의 실행이 끝나면 sum 의 미래 값이 준비 되고 이를 출력할 수 있습니다.

내부에서 xy의 미래값을 기다리는 로직을 숨겼습니다.

내부의 sum(...) 에서, Promise.all([...]) 호출은 프로미스(promiseX와 promiseY가 해결될때까지 기다리는)를 생성합니다.

연결된 .then(...) 호출은 또다른 프로미스를 생성하는데, 이는 return values[0] + values[1] 라인이 즉시 해결하는 결과(두 값의 합)로 resolve 됩니다.

따라서, 스니펫 끝에 있는 sum(...) 호출 끝에 연결된 .then(...) 호출은 사실 첫번째로 생성된 프로미스가 아닌, 두번째로 반환 된 프로미스에 작동하고 있습니다.

또한, 우리가 그 두번째 .then(...)의 끝에 연결하지 않았음에도 불구하고, 그것 역시 우리가 관찰하거나 사용하기로 선택했다면 또다른 프로미스를 생성 했을 겁니다.

이러한 프로미스 체이닝에 대한 자세한 설명은 후반부에 더 자세히 설명 될 것입니다.

Promise를 통해서 2개의 함수를 사용할수 있는데, 첫번째는 fulfillment, 이행 이고 두번째는 rejection 입니다.

sum(fetchX(), fetchY()).then(
  // fullfillment handler
  function (sum) {
    console.log(sum);
  },
  // rejection handler
  function (err) {
    console.error(err); // bummer!
  },
);

x 또는 y를 가져올때 문제가 발생하거나, 더하는 동안 어떤 식으로든 실패하면 sum이 리턴하는 프로미스가 reject 되고, then으로 전달 된 두번째 콜백 에러 핸들러가 promise에서 rejection을 받습니다.

프로미스는 기본적으로 시간에 종속적인 상태 — 즉, 내부 값의 이행(fulfillment)이나 거부(rejection)를 기다리는 상태 — 를 캡슐화합니다.

그래서 바깥쪽에서 보면, 프로미스 자체는 시간에 독립적이며, 이로 인해 내부의 타이밍이나 결과와 관계없이 예측 가능한 방식으로 프로미스를 조합(결합)할 수 있습니다.

게다가, Promise가 resolve 가 되면, 그때부터는 불변한 값이 될수 있습니다.

그리고 필요한 만큼 관찰 될수도 있습니다.

이는 Promise 체인을 만들수 있기에 굉장히 유용합니다.

function delay(time) {
  return new Promise(function (resolve, reject) {
    setTimeout(resolve, time);
  });
}

delay(1000)
  .then(function () {
    console.log('after 1000ms');
    return delay(2000);
  })
  .then(function () {
    console.log('after another 2000ms');
  })
  .then(function () {
    console.log('step 4 (next Job)');
    return delay(5000);
  });

delay(2000)은 2초후에 이행 될 프로미스를 생성하고, 그것을 첫번째 .then(...)의 fulfillment 콜백에서 반환합니다.

이는 두번째 .then의 프로미스가 그 2초짜리 프로미스를 기다리게 만듭니다.

❗️프로미스가 한번 resolve되면, 외부에서 변경할수 없다는 점을 알아두세요❗️

이로 인해 해당 값을 어느 곳에서나 안전하게 전달할 수 있으며, 그 값이 우연히나 악의적으로 수정될 가능성이 없다는 것을 알고 있습니다.

이는 특히 여러 당사자가 프로미스의 해결을 관찰하는 상황에서 특히나 돋보입니다.

한 부분이 다른 부분의 프로미스 해결 관찰 능력에 영향을 줄 수 없습니다.

불변성은 지루한 학술적 주제처럼 들릴 수 있지만, 사실 프로미스 설계의 가장 기본적이고 중요한 측면 중 하나이며, 결코 가볍게 넘겨서는 안됩니다.


- 프로미스이거나 아니거나

프로미스에 관한 중요한 세부사항 중 하나는, 어떤 값이 실제 프로미스인지 아닌지 확실히 아는 것입니다.

즉, 그값이 프로미스처럼 행동할 것인가? 하는 점입니다.

프로미스는 new Promise(...) 구문으로 생성된다는 것을 알고 있으며, p instanceof Promise가 충분한 검사가 될 것이라고 생각할 수 있습니다.

하지만 이 방법만으로는 완전히 충분하지 않습니다.

주로 다른 브라우저창 (예를 들면 iframe)에서 프로미스 값을 받을 수 있는데, 그 창은 현재 창이나 프레임과 다른 자체 프로미스를 가지고 있을 수 있으며, 그러한 경우 instanceof 검사는 프로미스 인스턴스를 식별하는데 실패할 수 있습니다.

또한, 라이브러리나 프레임워크는 자체 프로미스를 제공하고 ES6에서 제공하는 프로미스를 사용하지 않을수도 있습니다.

실제로, 프로미스를 전혀 지원하지 않는 오래된 브라우저에서 라이브러리와 함께 프로미스를 사용하고 있을 수 있습니다.

- 예외 피하기

프로미스의 생성 과정이나, 그 해결을 관찰하는 과정에서 JS 예외 오류 (예 : TypeError나 ReferenceError)가 발생하면, 해당 예외는 잡히고(caught), 문제의 프로미스는 거부(rejected) 상태가 됩니다.

var p = new Promise(function (resolve, reject) {
  foo.bar(); // foo 가 정의되어 있지 않기 때문에 에러가 발생합니다.
  resolve(374); // 이 resolve까지 실행되지 못해요
});

p.then(
  function fulfilled() {
    // 여기 fulfilled 까지 못옵니다
  },
  function rejected(err) {
    // foo.bar()에서 발생한 에러가 이곳에서 처리 됩니다.
  },
);

프로미스가 이행되었지만 관찰하는 과정(즉, then(...)에 등록된 콜백 내부)에서 JS 예외 오류가 발생한 경우에는 어떻게 될까요?

예외는 손실되지 않지만, 예외가 처리되는 방식을 다소 놀랍게 여길 수 있습니다. 좀 더 깊이 파고들면:

var p = new Promise(function (resolve, reject) {
  resolve(374);
});

p.then(
  function fulfilled(message) {
    foo.bar();
    console.log(message); // 이곳엔 도착하지 않습니다.
  },
  function rejected(err) {
    // 여기도 닿지 않아요
  },
);

foo.bar()에서 발생한 예외가 정말로 무시된 것처럼 보입니다.

하지만 실제로는 그렇지 않습니다.

더 깊은 문제가 있었지만, 우리가 그것을 감지하는 데 실패했습니다.

p.then(...) 호출 자체가 다른 프로미스를 반환하며, 바로 그 프로미스가 TypeError 예외로 거부됩니다.

즉, fulfilled 이행된 함수 안에서 예외가 발새한다면, 이를 catch 에서 처리해주거나 해야하는데, 예외처리 해주는 부분이 없어서 해당 에러를 감지 못한다는 뜻입니다.

- 잡히지 않는 예외 처리 하기

많은 사람들이 더 나은 방법이라고 말하는 다른 접근 방식도 있습니다.

일반적으로 제안되는 바는, 프로미스에 done(...)이 추가되어야 한다는 것입니다.

이는 본질적으로 프로미스 체인을 "완료됨"으로 표시합니다.

done(...)은 새로운 프로미스를 생성하고 반환하지 않으므로, done(...)에 전달된 콜백들은 명백하게 존재하지 않는 체인된 프로미스에 문제를 보고하도록 연결되어 있지 않습니다.

done(..) 거부 핸들러 내부에서 발생하는 예외는 일반적으로 처리되지 않은 오류 상황에서 예상할 수 있듯이, 전역 처리되지 않은 오류로 던져집니다(기본적으로 개발자 콘솔에서 보게 됩니다)

var p = Promise.resolve(374);

p.then(function fulfilled(msg) {
  // numbers don't have string functions,
  // so will throw an error
  console.log(msg.toLowerCase());
}).done(null, function () {
  // If an exception is caused here, it will be thrown globally
});

- ES8에서의 Async/Await

JavaScript ES8은 프로미스를 다루는 작업을 더 쉽게 만들어주는 async/await를 도입했습니다.

우리는 async/await가 제공하는 가능성을 간략히 살펴보고, 이를 활용하여 비동기 코드를 작성하는 방법을 알아볼 것입니다.

- 어떻게 async/await을 사용하나요 ?

비동기 함수는 async function 선언을 사용하여 정의합니다.

이러한 함수들은 AsyncFunction 객체를 반환합니다.

AsyncFunction 객체는 그 함수 내에 포함된 코드를 실행하는 비동기 함수를 대표합니다.

비동기 함수는 실행을 일시 중지하고 전달된 프로미스의 해결을 기다린 후 비동기 함수의 실행을 다시 시작하고 해결된 값을 반환하는 await 표현식을 포함할 수 있습니다.

JavaScript에서 프로미스는 자바의 Future나 C#'s Task와 동등한 것으로 생각할 수 있습니다.

async/await 의 목적은, 프로미스를 더 간단히 사용하기 위함입니다.

예시를 한번 살펴보겠습니다.

// 기본적인 자바스크립트 함수
function getNumber1() {
  return Promise.resolve('374');
}
// 이 함수는 getNumber1과 똑같습니다.
async function getNumber2() {
  return 374;
}

마찬가지로, 예외를 던지는 함수들은 거부된 프로미스를 반환하는 함수와 동등합니다.

function f1() {
  return Promise.reject('Some error');
}
async function f2() {
  throw 'Some error';
}

await 키워드는 async 함수 내에서만 사용할 수 있으며, Promise를 동기적으로 대기하게 해줍니다.

만약 async함수 밖에서 Promise를 사용한다면, 여전히 then 콜백을 사용해야 합니다.

async function loadData() {
  // rp는 Promise 함수입니다.
  var promise1 = rp('https://api.example.com/endpoint1');
  var promise2 = rp('https://api.example.com/endpoint2');

  // 현재 각각의 요청이 동시에 발생하고 있으며, 그것들의 요청이 끝날때까지 기다려야합니다.
  var response1 = await promise1;
  var response2 = await promise2;
  return response1 + ' ' + response2;
}
// 우리가 더 이상 async 함수 안에 있지 않기 때문에
// `then`을 사용해야합니다.
loadData().then(() => console.log('Done'));

비동기 함수 표현식을 사용하여 비동기 함수를 정의할 수도 있습니다.

비동기 함수 표현식비동기 함수문과 매우 유사하며 거의 동일한 문법을 가지고 있습니다.

비동기 함수 표현식비동기 함수문의 주요 차이점은 함수 이름이며,

이는 비동기 함수 표현식에서 익명 함수를 생성하기 위해 생략될 수 있습니다.

비동기 함수 표현식은 정의되자마자 실행되는 IIFE(즉시 실행 함수)로 사용될 수 있습니다.

var loadData = async function () {
  var promise1 = rp('https://api.example.com/endpoint1');
  var promise2 = rp('https://api.example.com/endpoint2');

  var response1 = await promise1;
  var response2 = await promise2;
  return response1 + ' ' + response2;
};

더 중요한건, async/await은 거의 모든 주요한 브라우저에서 지원 됩니다.

결국 중요한 것은 비동기 코드를 작성하는 데 있어서 "최신" 접근 방식을 맹목적으로 선택하는 것이 아닙니다.

비동기 JavaScript의 내부 작동 원리를 이해하고, 왜 그것이 중요한지, 그리고 선택한 방법의 내부를 깊이 있게 이해하는 것이 필수적입니다.

프로그래밍의 다른 모든 것과 마찬가지로, 모든 접근 방식에는 장단점이 있습니다.

- 유지보수에 용이한 비동기 코드 작성하는 5가지 방법

1. 깔끔한 코드

클린 코드 작성시, async/await을 사용하면 훨씬 적은 코드를 작성할 수 있습니다.

async/await를 사용할 때마다 불필요한 몇 가지 단계를 건너뛸 수 있습니다.

then을 작성하고, 응답을 처리하기 위해 익명 함수를 생성하고, 그 콜백에서 응답에 이름을 지어주는 등의 작업입니다.

  • 예시
// `rp` 는 비동기 함수
rp(‘https://api.example.com/endpoint1').then(function(data) {
 // … 콜백 함수 작성
});

var response = await rp(‘https://api.example.com/endpoint1');

2. 에러 처리

Async/await은 동기 및 비동기 에러를 동일한 코드 구조 — 잘 알려진 try/catch 문을 사용하여 처리할 수 있게 합니다.

Promise와 함께 사용되는 모습을 살펴보겠습니다:

  • 예시
function loadData() {
  try {
    // 동기적인 에러를 잡아냅니다.
    getJSON()
      .then(function (response) {
        var parsed = JSON.parse(response);
        console.log(parsed);
      })
      .catch(function (e) {
        // 비동기적인 에러를 잡아 냅니다.
        console.log(e);
      });
  } catch (e) {
    console.log(e);
  }
}

async function loadData() {
  try {
    var data = JSON.parse(await getJSON());
    console.log(data);
  } catch (e) {
    console.log(e);
  }
}

3. async/await를 사용하여 조건부 코드를 더욱 직관적으로 작성합니다.

function loadData() {
  return getJSON().then(function (response) {
    if (response.needsAnotherRequest) {
      return makeAnotherRequest(response).then(function (anotherResponse) {
        console.log(anotherResponse);
        return anotherResponse;
      });
    } else {
      console.log(response);
      return response;
    }
  });
}

// async await을 사용한 코드
async function loadData() {
  var response = await getJSON();
  if (response.needsAnotherRequest) {
    var anotherResponse = await makeAnotherRequest(response);
    console.log(anotherResponse);
    return anotherResponse;
  } else {
    console.log(response);
    return response;
  }
}

4. 스택프레임 : async/await과 달리, 프로미스 체인에서 반환된 에러스택은 에러가 발생한 위치에 대한 단서를 제공하지 않습니다.

function loadData() {
  return callAPromise()
    .then(callback1)
    .then(callback2)
    .then(callback3)
    .then(() => {
      throw new Error('boom');
    });
}
loadData().catch(function (e) {
  console.log(err);
  // Error: boom at callAPromise.then.then.then.then (index.js:8:13)
});

// async await

async function loadData() {
  await callAPromise1();
  await callAPromise2();
  await callAPromise3();
  await callAPromise4();
  await callAPromise5();
  throw new Error('boom');
}
loadData().catch(function (e) {
  console.log(err);
  // output
  // Error: boom at loadData (index.js:7:9)
});

5. 디버깅

프로미스를 사용해본 적이 있다면, 디버깅이 얼마나 악몽인지 알고 있을겁니다.

예를 들어, .then 블록 안에 중단점을 설정하고 stop-over 같은 디버그 단축키를 사용할 경우, 디버거는 동기 코드를 "단계별로" 진행하기 때문에 다음 .then으로 이동하지 않습니다.

async/await을 사용하면 await 호출을 마치 일반 동기 함수처럼 단계별로 진행할 수 있습니다.

비동기 자바스크립트 코드를 라이브러리에 통합하는 것이 중요한 이유

저희 제품인 Sesssio Stack을 예로 들어보겠습니다.

SessionStack 라이브러리는 웹/앱사이트에서 일어나는 모든 것을 기록합니다.

모든 DOM 변경사항, 사용자 상호작용, Javascript 예외, 스택 트레이스, 실패한 네트워크 요청 그리고 디버그 메세지들까지요.

그리고 이 모든것이 사용자 경험에 영향을 주지 않으면서, 생산 환경에서 이루어져야합니다.

이벤트 루프가 처리하는 이벤트의 수를 늘릴 수 있도록, 저희는 코드를 최적화하고 가능한 한 비동기적으로 만들어야합니다.

라이브러리뿐만 아니라, SessionStack에서 사용자 세션을 재생할 때도 마찬가지입니다.

문제가 발생한 시점에 사용자의 브라우저에서 일어난 모든 것을 렌더링하고, 전체 상태를 재구성하여 세션 타임라인에서 앞뒤로 이동할 수 있게 해아합니다.

이를 가능하게 하기 위해, 저희는 자바스크립트가 제공하는 비동기 기능을 적극적으로 활용하고 있습니다.