성장, 그리고 노력

부족하더라도 어제보다 더 잘해지자. 노력은 절대 배신하지 않는다.

Javascript

2부 - 자바스크립트 비동기적 프로그래밍(콜백, 프라미스)

제이콥(JACOB) 2020. 1. 28. 01:59

동기적 프로그래밍

 동기적 프로그래밍 모델에서는 일(task)은 한 번에 하나씩 일어난다. 그러다 보니 오랫동안 실행되는 동작을 수행하는 함수를 호출했다면, 그 함수가 종료될 때까지 그 프로그램은 중지되게 된다.


 이제 1부에 이어, 2부에서는 비동기적 프로그래밍에 대해 알아보자.

 

1부 - 자바스크립트 함수 표현식, IIFE 그리고 비동기

함수 표현식 함수 표현식(function expression)은 값이 될 수 있고, 함수 역시 값이 될 수 있다. 이는 함수를 선언하는 한 가지 방법일 뿐이며, 그 함수가 익명이 될 수도 있을 뿐이다. 함수 표현식은 식별자에 할..

code-masterjung.tistory.com

비동기적 테크닉을 사용해야 하는 경우

  • 사용자의 행동과 관련된 것(입력, 클릭 등)
  • Ajax 호출을 비롯한 네트워크 요청
  • 파일을 읽고 쓰는 등의 파일 시스템 작업
  • 의도적으로 시간 지역을 사용하는 기능(알람 등)

 1부에서도 setTimeout()을 통해 살짝 맛을 봤지만, 직접적으로 언급을 하진 않았다. 하지만 우린 이미 많이 비동기에 대해서 많이 들어봤을 것이다. 이미 웹 개발을 해봤다면 사용자의 행동은 전적으로 비동기적이기 때문이다. 사용자가 언제 클릭하고 터치하며, 타이핑할지는 알 수가 없다. 

 그렇다고 사용자의 행동 때문에 비동기가 필요한 건 아니고, 사실 자바스크립트의 본성 때문에 필요하다.

 

자바스크립트 애플리케이션은 단일 스레드에서 동작한다. 그러다 보니 자연스럽게 동작하는 애플리케이션을 만들기 위해서는 사용자의 입력뿐만 아니라 여러 문제를 비동기적 관점에서 생각해야 한다. 그리고 우리는 비동기적 프로그래밍에 필요한 몇몇 가지에 대해서 살펴봐야 한다. 

 

 이번 글에서는 콜백(callback)과 프라미스(promise)에 대해 알아보겠다. 물론 누군가는 async await나 제너레이터 같은 가장 나중에 나온 개념들만 알면 되지 않겠냐고 물을 수 있겠지만, 이것들도 결국은 프라미스를 알아야 하고, 프라미스 또한 콜백에 의존한다. 이 부분은 차근차근 알아가면 되므로 일단 아래 글을 계속 읽어보자.

 

Callback vs Promise

책을 읽던 중 콜백과 프라미스에 대해 재밌게 비유(analogy)해 놓은 부분이 있어서 이 부분을 가져와 본다.

더보기

The analogy I like to use for both callbacks and promises is getting a table at a busy restaurant when you don’t have a reservation. So you don’t have to wait in line, one restaurant will take your mobile phone number and call you when your table is ready. This is like a callback: you’ve provided the host with something that allows them to let you know when your table is ready. The restaurant is busy doing things, and you can busy yourself with other things; nobody’s waiting on anyone else. Another restau‐ rant might give you a pager that will buzz when your table is ready. This is more like a promise: something the host gives to you that will let you know when your table is ready.

- Learning JavaScript 3rd Edition -

위 예는 예약하지 않고 사람 많은 음식점에 방문한 경우를 비유 글인데, 요약하면 아래와 같다.

 

 첫번째 음식점은 줄을 서서 기다리지 않도록, 손님의 전화번호를 받아서 자리가 나면 전화를 준다. 이것은 콜백(callback)과 비슷하다. 자리가 나면 손님이 알 수 있도록 하는 수단을 손님음식점 주인에게 넘겨준다.

 

 두번째 음식점은 자리가 났을 때 진동하는 호출기를 손님에게 넘겨준다. 이것은 프라미스(promise)와 비슷하다. 자리가 나면 손님이 알 수 있도록 하는 수단을 음식점에서 손님에게 넘겨준다.

 

일단 이런 비유로 전체적인 느낌을 가지고 아래 글을 계속 봐보자.


콜백(callback)

콜백은 자바스크립트에서 가장 오래된 비동기적 메커니즘이며, 간단히 말해 나중에 호출할 함수이다. 콜백 함수 자체에는 특별한 것이 전혀 없다. 대부분 콜백은 익명 함수로 사용한다(아마도 1부에서 다뤘듯이, 일회용 함수에 이름을 일일이 붙여주는 건 생산적이지 못해 그러는 거 같다). 

 

 먼저 setTimeout을 사용한 예제부터 보자.

아래 읽기 전에 콘솔 결과를 예측해 보세요

콘솔에 찍히는 결과는 아래와 같다.

타임아웃 전: Mon Jan 27 2020 22:06:57 GMT+0900 (GMT+09:00)
setTimeout 다음에 호출!
하나 더!
타임아웃 후: Mon Jan 27 2020 22:07:57 GMT+0900 (GMT+09:00)

 사실 1부 글을 이해하고 왔다면 이 부분은 너무 당연하게 여겨질 수도 있겠지만, 현재 글부터 보거나 초심자라면, 직접 작성한 코드와 실제 실행되는 순서가 다르다는 사실에 당황할 수도 있다. 만약 위 코드가 정말 위에서부터 순서대로 실행되었다면 이는 비동기적이지 않다. 

 

 비동기 실행의 가장 큰 목적이자 요점은 어떤 것도 차단하지 않는다는 것이다. 자바스크립트는 싱글 스레드를 사용하므로, 우리가 컴퓨터에 60초 동안 대기한 후 코드를 실행하라고 지시한다면, 그리고 그 실행이 동기적으로 이루어진다면 어떤 일이 벌어질까? 프로그램이 1분 동안 멈추고, 사용자 입력을 받아들이지도 않고, 화면도 업데이트하지 않을 것이다.

이런 화면... 분명 유쾌한 경험은 아닐 것이다

 참고로 위 예제에서는 명확한 표현을 위해 이름 붙은 함수 f를 setTimeout에 넘겼지만, 위에서 언급했듯이 보통은 익명함수로 사용한다.

setTimeout(function() {
  console.log("타임아웃 후: " + new Date());
}, 60 * 1000); // 1분

 

스코프와 비동기적 실행

 비동기적 실행에서 혼란스럽고 에러도 자주 발생하는 부분은 스코프(scope)와 클로저(closure)가 비동기적 싱행에 영향을 미치는 부분이다. 함수를 호출하면 항상 클로저가 만들어진다. 매개변수를 포함해 함수 안에서 만든 변수는 모두 무언가가 자신에 접근할 수 있는 한 계속 존재한다.

 

 1부에서 사용했던 예제를 다시 봐보자. 아래 함수의 목적은 5초 카운트 다운을 만드는 것이다.

function countdown() {
  let i; // loop 밖에서 선언

  console.log("Countdown:");

  for (i = 5; i >= 0; i--) {
    setTimeout(function() {
      console.log(i === 0 ? "GO!" : i);
    }, (5 - i) * 1000);
  }
}

countdown();

 5에서부터 카운트 다운을 할거 같지만, -1이 여섯번 호출될 뿐이다. 왜냐하면 이번에는 let을 사용하긴 했지만, 변수를 for 루프 밖에서 선언했으므로 같은 문제가 벌어진다. for 루프가 실행을 마치고 i의 값이 -1이 된 다음에서야 콜백이 실행되기 때문에, 콜백이 실행될 때 i의 값은 이미 -1인 것이다.

 

 스코프와 비동기적 실행이 어떻게 연관되는지 이해하는 것이 중요하다. countdown을 호출하면 변수 i가 들어있는 클로저가 생성되며, for 루프 안에서 만드는 콜백은 모두 i에 접근할 수 있고, 같은 i에 접근하게 되는 것이다. 

 

하지만 눈여겨 볼 것이 하나 더 있다. 타임아웃을 계산하는 (5-i)*1000은 예상대로 동기적으로 동작했다는 것이다(예상: 0, 1000, 2000,...). 비동기적인 부분은 setTimeout에 전달된 함수인 것이다.

 

오류 우선 콜백

이 부분은 Node.js를 사용해봤다면 경험해봤을 수도 있는 부분이다. 오류 우선 콜백(error-first callback)이란 콜백을 사용하면 예외 처리가 어려워지므로 콜백의 첫 번째 매개변수에 에러 객체를 쓰자는 것이다. 만약 에러가 null이거나 undefined이면 에러가 없는 것이다.

 

 오류 우선 콜백을 다룰 때 가장 먼저 생각할 것은 에러 매개변수를 체크하고 그에 맞게 로직을 구성하는 것이다. 아래 노드에서 파일을 읽는 예제를 살펴보자.

import fs from "fs";

const fileName = "존재하거나 존재 안할수도 있는 파일.txt";
fs.readFile(fileName, (err, data) => {
  if (err) return console.err(`파일을 읽던 중 ${err} 발생!`);

  console.log(`${fileName} contents: ${data}`);
});

 콜백에서 가장 먼저 하는 일은 err이 참인지 확인하는 것이다. err가 참이라면 파일을 읽는데 문제가 있다는 뜻으로 콘솔에 오류를 출력하고 해당 로직을 빠져나온다. 하지만 여기서 많은 개발자들은 실수를 종종 한다. 콜백을 사용하는 함수는 대개 콜백이 성공적이라고 가정하고 만들어지다 보니, err를 기록하기만 하고(위에서는 console.error()), return문 통해 로직을 빠져나오지 않는 경우가 많다. 물론 콜백을 만들 때 실패하는 경우도 염두에 두고 만들었다면 에러를 기록하기만 하고 계속 진행해도 된다.

 

 만약 프라미스를 사용하지 않는다면 오류 우선 콜백은 노드 개발의 표준이나 다름없다. 따라서 콜백을 사용하는 인터페이스를 만들 때는 오류 우선 콜백을 사용하는 것이 좋다.

 

콜백 헬(Callback Hell)

 콜백을 사용해 비동기적으로 실행할  수 있긴 하지만 단점도 존재한다. 만약 한 번에 여러 가지 작업을 기다려야 한다면 콜백을 관리하기 상당히 어려워진다.

 아래 예제는 노드로 파일의 콘텐츠를 읽고, 60초가 지난 다음 이들을 결합해 네 번째 파일에 기록하는 앱이다.

 프로그래머들은 이런 코드를 콜백 헬이라고 부른다. 이 예제는 에러를 기록하기만 했지만, 예외를 발생시키려 한다면 더더욱 복잡해 진다. 아래 예제를 보자.

얼핏 보면 괜찮아 보이는 코드지만 실제로 작동하지 않는다. 왜냐하면 try~catch 블록은 같은 함수 안에서만 동작하기 때문이다. try~catch 블록 readSketchyFile 함수 안에 있지만, 정작 예외는 fs.readFile이 콜백으로 호출하는 익명 함수에서 일어난다.

 

 또한 콜백이 우연히 두 번 호출되거나, 아예 호출되지 않는 경우를 방지하는 안전장치도 없으며 자바스크립 또한 그것을 보장해 주지 않는다. 물론 이러한 문제를 해결할 수 없는 건 아니지만, 비동기적 코드가 늘어날수록 버그가 없고 관리하기 쉬운 코드를 작성하기는 매우 어려워질 것이다. 그래서 프라미스가 등장했다.


프라미스(Promise)

 프라미스는 콜백을 대체하는 것은 아니며, 콜백의 단점을 해결하려는 시도속에서 만들어졌다. 그리고 프라미스에서도 콜백을 사용한다. 프라미스는 콜백을 예측 가능한 패턴으로 사용할 수 있게 하며, 프라미스 없이 콜백만 사용했을 때 나타날 수 있는 예상치 못한 현상이나 찾기 힘든 버그를 상당수 해결해준다.

 

 프라미스의 기본 개념은 간단하다. 프라미스 기반 비동기 함수를 호출하면, 그 함수는 Promise 인스턴스를 반환한다. 프라미스는 성공(fullfilled)하거나 실패(rejected)하거나 단 두 가지뿐이다. 그래서 프라미스는 성공 또는 실패 중 하나만 일어난다고 확신할 수 있다. 성공한 프라미스가 나중에 실패하는 일 같은 건 절대 없다. 또한 성공이든 실패든 단 한 번만 일어난다. 프라미스가 성공하거나 실패하면 그 프라미스를 결정됐다(settled)고 한다.

 

 프라미스는 객체이므로 어디든 전달할 수 있다는 점도 콜백에 비해 장점이다. 비동기 처리를 해당 함수에서 하기 싫다고 하면 다른 함수로 프라미스를 넘기기만 하면 된다. 음식점에서 받은 예약 호출기를 친구에게 맡기는 것과 비슷하다. 예약한 인원이 때맞춰 오기만 한다면, 누가 호출기를 들고 있든 상관이 없는 것이다.

프라미스 만들기

 프라미스를 만드는 방법은 resolve와 reject 콜백이 있는 함수로 새 Promise 인스턴스를 만들기만 하면 된다. 위에서 작성한 카운트 다운 예제를 수정해 보자. (일부러 중간에 13이라는 숫자에서는 에러가 나오게 수정했다).

function countdown(seconds) {
  return new Promise(function(resolve, reject) {
    for (let i = seconds; i >= 0; i--) {
      setTimeout(function() {
        if (i === 13) return reject(new Error("이 숫자는 잘못되었어요!"));
        if (i > 0) console.log(i + "...");
        else resolve(console.log("GO!"));
      }, (seconds - i) * 1000);
    }
  });
}

이 장황한 함수는 좋은 함수는 아니지만, 프라미스를 어떻게 만들어야 되는지는 잘 나와 있다.

프라미스를 무시하고 countdown(5)로 호출해도 작동된다

물론 new 키워드 없이도 프라미스 생성이 가능하다.

프라미스 사용

위 사진처럼 바로 호출해도 작동하나 프라미스의 장점을 이용하고 싶다면 아래와 같이 코드을 구성하면 된다.

프라미스는 then과 catch 핸들러를 지원

1- 변수에 할당하지 않고 then 핸들러 바로 사용하기

countdown(5).then(
  function() {
    console.log("It's fullfilled! 잘 작동해요!");
  },
  function(err) {
    console.log("It's rejected! 에러에요: " + err.message);
  }
);

프라미스를 변수에 할당하지 않고 then 핸들러를 바로 호출했다. then 핸들러는 성공 콜백과 에러 콜백을 받으며 경우의 수는 성공 콜백, 에러 콜백 단 두 가지이다. 그리고 프라미스는 catch 핸들러도  지원하므로 핸들러를 둘로 나눠 써도 된다.

 

2- 변수에 할당하여 then, catch 핸들러 사용하기(+ 화살표 함수)

const promise = countdown(5);

promise
  .then(() => console.log("It's fullfilled! 잘 작동해요!"))
  .catch(err => console.log("It's rejected! 에러에요: " + err.message));

 

두 방법 모두 잘 작동되는 것을 확인할 수 있다. 근데 숫자를 바꿔가며 테스트를 해보면 신기한 점을 발견할 수 있다. 위 로직상 13보다 작은 숫자는 에러없이 카운트다운이 잘 되는 것을 알 거 같다. 근데 13 이상의 수를 넣으면 13에서 에러가 일어나지만, 콘솔에는 12부터 다시 카운트를 기록한다. reject나 resolve가 함수를 멈추지 않는다. 이들은 그저 프라미스의 상태를 관리할 뿐이다.

// cf) Promise에 여러가지 도움을 주는 라이브러리
https://www.npmjs.com/package/q
반응형