Development/Etc

[모던 자바스크립트] Promise 한 방에 뿌수기

bbubbush 2022. 9. 10. 01:00

들어가며

아마 ES6의 내용 중 이해하기 가장 어려운 내용이 프로미스가 아닐까 생각한다. 다른 변경사항은 기능에 충실한 반면, 프로미스는 특정한 상황을 해결하기 위해 등장했기 때문이라 생각한다.

 

예를 들면 배열과 객체의 새로운 활용방법은 그냥 기능이다. 그렇게 외워서 쓰면 누구나 금방 사용한다. 근데 프로미스는 사용해야 하는 상황이 있어야 한다. 비동기 처리가 업무에 포함된 상황이다. 그래서 이해하기도 어렵고, 응용해서 실무에 적용시키기는 더욱 어렵다. 그래서 처음 프로미스를 접하는 개발자에게 한방에 이해시키는 것을 목표로 내용을 최대한 간추리고 비유해서 설명하고자 한다.

 

우선 전체 내용을 정리하면 다음과 같다. 상황별 코드 예제는 아래 있다. 숲을 보고 나무를 보고자 함이니 흐름만 눈에 넣어두면 된다.

✅ 요약
비동기 처리의 태생적인 문제점
  → 비동기처리의 동기 처리를 위해 콜백 함수를 필요
    → 콜백함수에서 콜백 함수를 호출하는 콜백 지옥의 탄생

명시적 콜백함수 선언을 통해 콜백 지옥을 해결
  → 콜백지옥은 벗어났지만 함수 간의 호출에 정신이 혼미

프로미스 객체로 콜백지옥과 가독성 해결
  → 비즈니스 로직을 감싸서 방식으로 성공 콜백과 실패 콜백을 제공하는 객체

 

 

비동기 통신의 태생적인 문제

프로젝트를 하다 보면 조건에 따라 동적으로 자바스크립트 파일을 추가해야 하는 경우가 있다. 가령 인터넷이 차단된 내부망에서 카카오맵 API를 사용하려면 문제가 생긴다. CDN 방식은 웹에서 내려받아야 하는데 인터넷을 할 수 없기 때문이다.

// 카카오맵 API 가이드 문서 중
<script type="text/javascript" src="//dapi.kakao.com/v2/maps/sdk.js?appkey=발급받은 APP KEY를 넣으시면 됩니다."></script>
const container = document.getElementById('map'); //지도를 담을 영역의 DOM 레퍼런스
const options = { //지도를 생성할 때 필요한 기본 옵션
  center: new kakao.maps.LatLng(33.450701, 126.570667), //지도의 중심좌표.
  level: 3 //지도의 레벨(확대, 축소 정도)
};
let map = new kakao.maps.Map(container, options); //지도 생성 및 객체 리턴

 

카카오맵 API를 통해 지도를 호출하는 코드다. 이 코드가 실행되자마자 오류가 발생했다면 두 가지 정도 원인을 예상할 수 있다.

  1. 인터넷이 안되는 경우라면(이하 개발서버) sdk.js를 받을 수 없기 때문에 new kakao.maps.Map() 객체가 없어 오류가 난다.
  2. 인터넷이 되는 상황이라면(이하 스테이지서버) sdk.js를 제대로 불러오지 않은 상태에서 new kakao.maps.Map() 객체에 접근해 오류가 난다.

1번 문제는 개발 환경에 따라 카카오맵 스크립트의 호출 여부를 정하여 해결할 수 있다.

// 스테이지서버에서 동작할 때만 호출되도록 수정
if (isStageServer) {
  let scriptFile = document.createElement('script');
  scriptFile.setAttribute('src', '//dapi.kakao.com/v2/maps/sdk.js?appkey=발급받은 APP KEY를 넣으시면 됩니다.');
  document.head.append(scriptFile);
}
const container = document.getElementById('map'); //지도를 담을 영역의 DOM 레퍼런스
const options = { //지도를 생성할 때 필요한 기본 옵션
  center: new kakao.maps.LatLng(33.450701, 126.570667), //지도의 중심좌표.
  level: 3 //지도의 레벨(확대, 축소 정도)
};
let map = new kakao.maps.Map(container, options); //지도 생성 및 객체 리턴

 

이렇게 1번 문제를 해결했지만 여전히 스크립트 로딩이 끝나는 시점을 알 수 없다. 그래서 스크립트 로딩이 끝나면 실행되는 콜백 함수를 사용해야 한다. 스크립트 로딩이 완료되는 시점을 콜백 함수를 통해서 알 수 있기 때문이다.

 

if (isStageServer) {
  let scriptFile = document.createElement('script');
  scriptFile.setAttribute('src', '//dapi.kakao.com/v2/maps/sdk.js?appkey=발급받은 APP KEY를 넣으시면 됩니다.');
  scriptFile.load = function () {  // 콜백을 추가했다.
    const container = document.getElementById('map'); //지도를 담을 영역의 DOM 레퍼런스
    const options = { //지도를 생성할 때 필요한 기본 옵션
      center: new kakao.maps.LatLng(33.450701, 126.570667), //지도의 중심좌표.
      level: 3 //지도의 레벨(확대, 축소 정도)
    };
    let map = new kakao.maps.Map(container, options); //지도 생성 및 객체 리턴
  }
  document.head.append(scriptFile);
}

 

‘지도 호출하기’ 기능 개발이 끝났다. 개발서버에서 실행하면 지도를 호출하는 로직이 동작하지 않고 다른 코드를 정상적으로 실행한다. 반면 스테이지서버에서는 카카오맵 스크립트의 로딩이 끝나는 시점에 지도를 띄우므로 지도가 예쁘게 그려질 것이다.

다시 코드를 천천히 살펴보자. 기능의 문제는 없지만 코드의 흐름이 뒤죽박죽이다. 콜백 함수가 익명함수로 코드 중간에 있어서 위에서 아래로 분석하는 흐름이 끊기게 된다. 만약 카카오맵 스크립트 로딩이 끝난 후에 네이버 지도 스크립트를 불러야 한다면? 콜백이 꼬리에 꼬리를 물면서 더욱 복잡한 코드가 된다.

 

 

콜백 지옥의 예시

이번에는 스크립트를 불러오는 기능이 비동기 처리라는 것에 착안해 예제를 준비했다. 구현할 기능은 택시, 버스, 지하철 스크립트 중 출발지부터 목적지까지 가장 빨리 도착하는 대중교통을 찾는 것이다. 대신 각각의 대중교통은 별도의 자바스크립트 파일로 관리된다. 코드를 보자.

function scriptOnLoad() {
  let scriptFile = document.createElement('script');
  scriptFile.setAttribute('src', '/taxi.js');
    scriptFile.load = function () {
    console.log('택시 스크립트 로딩 끝');
    let scriptFile = document.createElement('script');
    scriptFile.setAttribute('src', '/bus.js');
    scriptFile.load = function () {
      console.log('버스 스크립트 로딩 끝');
      let scriptFile = document.createElement('script');
      scriptFile.setAttribute('src', '/subway.js');
      scriptFile.load = function () {
        console.log('지하철 스크립트 로딩 끝');
        // TODO 택시와 버스와 지하철 중 출발지부터 목적지까지 가장 빠른 대중교통 찾기
      }
      document.head.append(scriptFile);
    }
    document.head.append(scriptFile);
  }
  document.head.append(scriptFile);
}

택시, 버스, 지하철 순으로 스크립트를 불러오고 모든 스크립트가 로드되면 도착지까지 걸리는 시간을 비교하는 코드를 실행한다. 콜백지옥이라 불리는 이유가 바로 여기에 있다. 콜백 함수 안에서 또 다른 비동기 처리가 발생하면 추가로 콜백 함수가 필요하다. 이것이 계속 반복될수록 코드는 ‘>’ 형태로 깊이가 깊어진다. 깊이가 깊은 코드는 분석에 어려움을 주고, 개발자의 실수 가능성을 높인다.

 

‘만약 버스 스크립트가 로딩된 후에 버스의 가격을 alert()으로 보여줘야 한다면 어느 위치에 코드가 들어가야 적절할까?‘ 깊은 코드는 개발자의 고민을 깊게 만든다.

 

다행히도 코딩 스타일을 통해 콜백 지옥을 해결하는 방법이 있다. 각 콜백을 익명 함수로 사용하지 않고, 이름을 주고 함수로 선언해서 사용하는 방식이다.

function scriptOnLoad(src, callback) {
  let scriptFile = document.createElement('script');
  scriptFile.setAttribute('src', src);
  scriptFile.load = callback;
  document.head.append(scriptFile);
}
function taxiScriptCallback() {
  console.log('택시 스크립트 로딩 끝');
  scriptOnLoad('/bus.js', busScriptCallback);
}
function busScriptCallback() {
  console.log('버스 스크립트 로딩 끝');
  scriptOnLoad('/subway.js', subwayScriptCallback);
}
function subwayScriptCallback() {
  console.log('지하철 스크립트 로딩 끝');
  // TODO 택시와 버스와 지하철 중 출발지부터 목적지까지 가장 빠른 대중교통 찾기
}

// 스크립트 호출 시작
scriptOnLoad('/taxi.js', taxiScriptCallback);

 

각 함수가 집중하려는 업무가 명확하게 보인다. 상대적으로 가독성이 좋아지고 디버깅도 편해졌다. 코드의 흐름도 위에서 아래로 흐르면서 깊이 또한 균일하기 때문에 분석이 쉬워졌다. 하지만 개선된 만큼 아쉬움 부분이 두드러진다. 콜백 함수에서 호출하는 각각의 콜백 함수를 쫓아다녀야 한다는 부분은 여전히 개발자의 코드 분석을 방해한다. 아직은 개선할 여지가 있음을 뜻하니 한 번 더 리팩토링을 해보자.

 

 

반응형

 

프로미스의 등장

프로미스 객체는 콜백 지옥에서 헤매는 불쌍한 어린양을 구하기 위해 ES6부터 등장했다. 먼저 선언적 콜백 함수를 이용한 방법이 어떻게 변경되는지 코드를 보자.

function scriptOnLoad(src) {
  return new Promise((resolve) => {
    let scriptFile = document.createElement('script');
    scriptFile.setAttribute('src', src);
    scriptFile.load = resolve();
    document.head.append(scriptFile);
  })
}
function findFastestVehicle() {
  console.log('지하철 스크립트 로딩 끝');
  // TODO 택시와 버스와 지하철 중 출발지부터 목적지까지 가장 빠른 대중교통 찾기
}

// 스크립트 호출 시작
scriptOnLoad('/taxi.js')
  .then(() => scriptOnLoad('/bus.js'))
  .then(() => scriptOnLoad('/subway.js'))
  .then(() => findFastestVehicle());

이렇게 변경된다고 하니 놀랍기는 한데 코드를 봐도 이해가 안 갈 것이다. 프로미스가 어떤 객체인지를 모르기 때문이다. 프로미스를 학습할 때 가장 중요한 것은 비즈니스 로직을 감싸는 객체라고 생각하는 것이다. 이번에는 예제에서 벗어나 프로미스 객체를 설명하기 위한 코드를 준비했다.

/** 프로미스 객체의 생성
  - 프로미스 객체는 성공에 대한 콜백함수와 실패에 대한 콜백함수, 두 가지 객체를 지원해준다.
  - 첫 번째 객체인 resolve가 성공에 대한 콜백함수이며 이름은 자유롭게 변경 가능하다. ex) res
  - 두 번째 객체인 reject는 실패(에러)에 대한 콜백함수이며 이름은 자유롭게 변경 가능하다. ex) rej
  - 성공에 대한 프로세스만 필요하다면 첫 번째 파라미터(resolve)만 선언하면 된다.
  - 프로미스 내부에서는 resolve()나 reject() 둘 중 먼저 호출되는 객체만 사용되고 이후 호출되는 resolve(), reject() 함수는 무시된다.(1등만 선택받는다)
  - 결과적으로 resolve() 혹은 reject() 함수는 둘 중 하나만 실행될 수 있다.
*/
const promiseObject = new Promise((resolve, reject) => {
  // 비즈니스 로직
  if (Math.random() > 0.5) {
    resolve('운이 좋아 성공했습니다');	
  } else {
    reject("운이 나빠 실패했습니다");
  }
});

/** 프로미스 객체의 처리결과를 사용
  - then의 첫 번째 파라미터는 resolve()의 결과를, 두 번째 파라미터는 reject()의 결과를 받아 파라미터로 사용한다.
  - catch는 then의 두 번째 파라미터를 별도로 분리해서 사용한다. reject() 즉, 예외가 발생한 결과를 처리하는 콜백함수이다.
  - finally는 성공하든, 실패하든 결과에 상관없이 무조건 실행한다. 대신 프로미스의 처리여부와 상관없이 동작하기 때문에 결과를 전달받지 못한다.
*/ 
promiseObject.then(successParam => console.log(successParam))
  .catch(errorParam => console.log(errorParam))
  .finally(() => console.log('어떤 결과든 결국 끝이 났습니다'));

위 코드가 프로미스를 처음 만난 개발자에게 필요한 전부다. 더 상세한 내용은 향후 각자의 노력에 달려있다. 결국 프로미스는 내가 처리하려고 하는 비즈니스 코드에게 성공 콜백과 실패 콜백을 제공하는 객체다.

 

 

프로미스의 상태

프로미스의 전체 흐름 &nbsp; 출처 : &nbsp; https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise

 

조금 더 깊은 이해를 위해 프로미스의 전체 흐름을 도식화한 그림을 넣었다. 세 가지 상태 값에 따라 프로미스는 행동이 달라진다.

  1. pending: 객체가 생성될 때 갖는 상태다. 아직은 실행이 끝나지 않아 결과를 기다리고 있는 상태다.
  2. fulfill: resolve() 콜백 함수가 실행되면 fulfill 상태로 변하면서 then()을 실행한다. 성공 프로세스에 해당한다.
  3. reject: reject() 콜백함수가 실행되는 경우를 말한다. then()의 두 번째 파라미터를 실행하거나, catch()를 실행한다. 예외 프로세스에 해당한다.

만약 비즈니스 로직이 10초 정도 걸리는 프로미스 객체가 있다면 10초간 pending 상태로 대기하다 처리가 끝나면 fulfill 혹은 reject 상태가 되면서 해당하는 콜백을 실행해준다.

여기까지가 프로미스의 입문 내용이다. 입문 내용이라는 뜻은 아직 동기 처리를 지원하는 await & async 키워드와 프로미스 체이닝 혹은 여러 개의 콜백을 하는 방법, 더 나아가 프로미스 객체의 다섯 가지 정적 메서드까지 공부할 영역이 많이 남아있다는 뜻이다. (사실 예제 코드에 프로미스 체이닝이 있었다!)

 

 

마치며

콜백 지옥을 벗어나기 위한 긴 여정이 끝났다. 내용을 정리해보자.

먼저 비동기 처리의 태생적인 문제점을 확인했다. 처리가 끝나는 시간을 보장되지 않는 비동기처리의 문제점을 콜백 함수로 극복할 수 있었다. 하지만 콜백 함수에서 콜백 함수로 이어지는 프로세스는 콜백지옥 만들었다. 그래서 콜백 지옥이 어떤 상황인지 택시, 버스, 지하철 스크립트를 로딩하는 예제를 통해 ‘>’ 모양으로 깊어지는 코드를 확인했다. 해결책으로 등장한 명시적 콜백 함수는 코드를 평평하게 만들었지만 각 콜백 함수로 요리조리 왔다 갔다 하며 여전히 한눈에 읽히지 않는 코드가 남게 되었다. 마지막으로 ES6부터 등장한 프로미스 객체를 통해 성공 콜백과 실패 콜백으로 손쉽게 분리해서 처리하는 방법을 알아보았다. 세 가지 상태에 따라 다르게 동작하는 프로미스 객체는 코드의 가독성과 콜백 지옥 해결이라는 두 가지 문제를 쿨하게 처리했다. 그래 봤자 비즈니스 로직을 감싸는 객체일 뿐이었다.

 

주변 개발자들이 모던 자바스크립트의 변경사항 중 유독 프로미스에 대해 어렵게 생각하는 경향이 있어 처음 접하는 개발자가 쉽게 이해하도록 비유적으로 설명하다 보니 사실과 다른 내용이 있을 것이다. 어디가 잘못되었는지 알아가는 건 이제 여러분이 하면 된다. 입문을 위한 내용과 학문을 위한 내용이 같을 순 없으니까.(그래도 잘못된 내용은 언제나 피드백 환영합니다 🙂 )

 

끝으로 글 처음에 있던 정리 내용을 보고 프로미스 객체로 개선된 코드를 살펴보자. 스크롤 올리기 귀찮을까 봐 여기로 가져왔다.

✅ 요약
비동기 처리의 태생적인 문제점
  → 비동기처리의 동기 처리를 위해 콜백 함수를 필요
    → 콜백 함수에서 콜백함수를 호출하는 콜백지옥의 탄생

명시적 콜백함수 선언을 통해 콜백 지옥을 해결
  → 콜백 지옥은 벗어났지만 함수 간의 호출에 정신이 혼미

프로미스 객체로 콜백 지옥과 가독성 해결
  → 비즈니스 로직을 감싸서 방식으로 성공 콜백과 실패 콜백을 제공하는 객체
function scriptOnLoad(src) {
  return new Promise((resolve) => {
    let scriptFile = document.createElement('script');
    scriptFile.setAttribute('src', src);
    scriptFile.load = resolve();
    document.head.append(scriptFile);
  })
}
function findFastestVehicle() {
  console.log('지하철 스크립트 로딩 끝');
  // TODO 택시와 버스와 지하철 중 출발지부터 목적지까지 가장 빠른 대중교통 찾기
}

// 스크립트 호출 시작
scriptOnLoad('/taxi.js')
  .then(() => scriptOnLoad('/bus.js'))
  .then(() => scriptOnLoad('/subway.js'))
  .then(() => findFastestVehicle());

 

 

 

[모던 자바스크립트 관련 글]

 

[모던 자바스크립트] var를 사용하지 않아야 하는 이유

2022.09.03 - [Development/Etc] - [모던 자바스크립트] var를 사용하지 않아야 하는 이유 들어가며 ES6에서는 변수를 사용하기 위해 새로운 문법인 let과 const를 지원하면서 동시에, var의 사용을 지양하라고

bbubbush.tistory.com

 

[모던 자바스크립트] Array 스마트하게 사용하기

들어가며 배열(Array)은 맵과 함께 데이터를 관리하기 위한 가장 효율적인 자료구조다. 이번에는 배열로 무엇을 할 수 있는지 보면서 for 구문의 지옥에서 벗어날 수 있는 것을 목표로 한다. 고전

bbubbush.tistory.com

 

[모던 자바스크립트] Object 기깔나게 사용하기

들어가며 자바스크립트에서 맵(Map)은 ES6가 되어서야 등장했다. 다른 언어에 비하면 상당히 늦은 편이다. 왜일까? 바로 객체(Object)라는 대안이 있었기 때문이다. 따라서 맵을 어떻게 사용하는지

bbubbush.tistory.com

 

[모던 자바스크립트] 어썸한 Funtion 변경사항

들어가며 자바스크립트는 함수로 대표된다고 해도 과언이 아니다. 이제는 객체지향적인 방식으로 작업하는 개발자도 많지만 과거부터 함수를 정의하고 사용해왔기에 아직까지 함수 지향적인

bbubbush.tistory.com

 

[모던 자바스크립트] 이름은 Optional, 적용은 Required

들어가며 ES6부터 객체의 값을 안정적으로 가져오는 옵셔널이 도입됐다. 개념도 쉽고 적용하기도 쉽기 때문에 활용도가 높다. 더 이야기할 게 없으니 바로 알아보자 🙂 전통적인 객체 프로퍼티

bbubbush.tistory.com