쿼카러버의 기술 블로그

[Javascript 비동기 처리 뿌시기 (3/4)] 비동기 처리 방식 : 자바스크립트 Promise 완전 쉽게 이해하기 (프라미스, 프라미스 체이닝) 본문

[Javascript]

[Javascript 비동기 처리 뿌시기 (3/4)] 비동기 처리 방식 : 자바스크립트 Promise 완전 쉽게 이해하기 (프라미스, 프라미스 체이닝)

quokkalover 2022. 1. 25. 23:23

Javascript 비동기 처리 뿌시기 시리즈에서 필자가 추천하는 학습방법은 아래와 같다.

 

학습방법

step1) 비동기 처리의 기본 개념을 이해하기 위한 Core개념 익히기 (https://etloveguitar.tistory.com/84)

step2) 콜백함수와 콜백 지옥의 맛을 보기 (https://etloveguitar.tistory.com/85)

step3) Promise로 살짝 콜백 지옥 해결하고 유연한 코드 짜기

step4) Async & Await를 이해하고 더 우아하게 Promise활용하기 (https://etloveguitar.tistory.com/87)

 

물론 이미 알고 있는 개념이면 이 시리즈를 전부 보지 않아도 되지만, 최대한 쉽게, 그리고 예시와 함께 설명했으니 복습차원에서 보는것도 추천한다. 한 개념만 보면 대충 뭔지 알겠다가도, 개념들을 연속적으로 이해하지 못하면 결국 벽에 부딪힌다. 따라서, 지금 필요한 특정 개념보다는 전반적인 흐름을 가지고 공부하는 것을 추천한다.

 

본 글의 제목에서 알듯이 이번 글은 3탄이다. Javascript에서 대표적으로 사용되는 비동기 처리에 사용되는 객체인 Promise에 대해 다룬다.

 

Promise란?

자바스크립트 대표적 비동기 로직에 사용되는 객체다. Promise를 사용하면 비동기 작업들을 쉽게 관리할 수 있다.

Promise는 주로 서버에서 받아온 데이터를 화면에 표시할 때 사용된다. 일반적으로 웹 어플리케이션을 구현할 때 서버에서 데이터를 요청하고 받아오기 위해 아래와 같은 API를 사용한다.

$.get('url 주소/products/1', function(response) {
  // ...
});

위 API가 실행되면 서버에 데이터를 요청하고, 서버에서 데이터를 받아오기 전에 화면에 데이터를 표시하려고하면 오류가 발생하거나 빈 화면이 뜬다. 이러한 문제를 해결하기 위한 방법 중 하나가 프로미스다.

 

간단하게 콜백 함수 코드를 Promise를 사용한 코드로 변환

Promise비동기 작업을 생성/시작하는 부분(new Promise(...))과 작업 이후의 동작 지정 부분(then, catch)을 분리함으로써 기존의 러프한 비동기 작업보다 유연한 설계를 가능하게 한다.

 

한번 아래 예시를 보자.

 

콜백 함수 코드

function getData(callbackFunc) {
  $.get('url 주소/products/1', function(response) {
    callbackFunc(response); // 서버에서 받은 데이터 response를 callbackFunc() 함수에 넘겨줌
  });
}

getData(function(tableData) {
  console.log(tableData); // $.get()의 response 값이 tableData에 전달됨
});

Promise로 짠 코드

function getData(callback) {
  // new Promise() 추가
  return new Promise(function(resolve, reject) {
    $.get('url 주소/products/1', function(response) {
      // 데이터를 받으면 resolve() 호출
      resolve(response);
    });
  });
}

// getData()의 실행이 끝나면 호출되는 then()
getData().then(function(tableData) {
  // resolve()의 결과 값이 여기로 전달됨
  console.log(tableData); // $.get()의 reponse 값이 tableData에 전달됨
});

콜백 함수로 처리하던 구조에서 new Promise(), resolve(), then()와 같은 프로미스 API를 사용한 구조로 바뀌었다. 여기서 new Promise()는 좀 이해가 가겠는데 resolve(), then()은 뭐 하는 애들일까? 궁금하면 아래 설명을 좀 더 읽어보자.

 

Promise 기초

우선 Promise로 관리할 비동기 작업을 만들 때는 Promise에서 요구하는 방법대로 만들어야 한다. 제일 정석적인 방법은 아래와 같다.

const promise1 = new Promise((resolve, reject) => {
  // 비동기 작업
});

promise에서 가장 중요한 함수는 executor, resolve, reject 3가지 함수가 있다.

executor

executor : resolve와 reject를 인수로 받는 함수

  • 위 코드를 보면 Promise객체를 만들 때 생성자가 특별한 함수하나를 인자로 받는다. 이를 executor함수라고 부른다.
    • ((resolve, reject) => {}) 이게 함수임.
    • (Javascript를 잘 모르는 독자는 화살표 함수에 대해 먼저 알아보길 추천한다.)
  • executor의 리턴 값은 무시된다.
  • resolve와 reject 둘 중 첫번째만 호출되고, 그 뒤는 무시된다.

resolve

  • executor 내에서 호출할 수 있는 또 다른 함수를 의미한다. resolve를 호출하게 된다면, 이 비동기 작업이 성공했음을 의미한다.

reject

  • executor 내에서 호출할 수 있는 또 다른 함수다. reject를 호출하면 이 비동기 작업이 실패했음을 의미한다.
  • executor 내부에서 에러가 throw되면, reject호출 여부와 관계없이 해당 에러로 reject가 수행된다.

 

Promise의 뒷처리 : then, catch

  • Promisenew Promise(...)생성자로 실행하는 순간 여기에 할당된 비동기 작업이 바로 시작된다는 점이다. 비동기 작업의 특징이 해당 작업이 언제 끝날지 모르기 때문에 일단 Task Queue(콜백큐)에 작업을 보낸다고 했다. 그럼 그 이후에 작업이 성공하거나 실패할 때 뒷처리를 해주어야한다. 그것이 바로 then 메소드와 catch메소드다.
  • then 메소드 : 해당 프로미스가 성공했을 때의 동작을 지정. 인자로 함수를 받는다.
  • catch 메소드 : 해당 프로미스가 실패했을 때의 동작을 지정. 인자로 함수를 받는다.
  • 위 함수들은 체인 형태로 활용할 수 있다.

예제로 보면 더 이해하기 쉽다.

const promise1 = new Promise((resolve, reject) => {
  resolve();
});
promise1
  .then(() => {
    console.log("then!");
  })
  .catch(() => {
    console.log("catch!");
  });

위처럼 Promise를 만든 다음 then과 catch를 이용해서 후속 동작까지 지정해줘야 Promise를 제대로 활용할 수 있다.

위 코드의 실행 결과는 다음과 같다.

then!

그럼 아래처럼 코드를 만들면 어떻게 될까?

const promise1 = new Promise((resolve, reject) => {
  resolve();
});
promise1
  .then(() => {
    console.log("then!");
  })
  .catch(() => {
    console.log("catch!");
  });

정답

catch!

 

Promise를 재사용 하는 법 : 함수 사용

  • Promise를 재사용 하려면 new Promise로 새로운 Promise를 생성하는 객체를 리턴하는 함수를 만들어 사용하면 된다. 아래 함수는 age 인자를 받아서 그 값에 따라 resolve 또는 reject를 호출한다.
function startAsync(age) {
    return new Promise((resolve, reject) => {
      if (age > 20) resolve();
      else reject();
    });
  }

// age가 20보다 높기 때문에 resolve될 거임
const promise1 = startAsync(25);
promise1
    .then(() => {
    console.log("1 then!");
    })
    .catch(() => {
    console.log("1 catch!");
    });

// age가 20보다 낮기 때문에 reject될 거임
const promise2 = startAsync(15);
promise2
    .then(() => {
    console.log("2 then!");
    })
    .catch(() => {
    console.log("2 catch!");
    });

이것의 실행 결과는 다음과 같다.

1 then!
2 catch!

 

작업 결과를 전달하기

앞에서 배운 resolve, reject함수에 인자를 전달해서 then 및 catch 함수에서 비동기작업으로부터 정보를 얻을 수 있다.

function startAsync(age) {
    return new Promise((resolve, reject) => {
      if (age > 20) resolve(`${age} success`);    
      else reject(new Error(`${age} is not over 20`));
    });
  }


const promise1 = startAsync(25);
promise1
    .then((value) => { // 여기! resolve
    // age가 20보다 높기 때문에 '25 success'가 value로 넘어올거임.
    console.log(value);
    })
    .catch((error) => {
    console.error(error);
    });

const promise2 = startAsync(15);
promise2
    .then((value) => {
    console.log(value);
    })
    .catch((error) => { // 여기! reject
    // age가 20보다 낮기 때문에 '25 success'가 value로 넘어올거임.
    console.error(error);
    });

위 코드의 실행 결과는 아래와 같다.

25 success
Error: 15 is not over 20
    at /Users/richetoh/Documents/tempbox/async/promise/promise.js:4:19
    at new Promise (<anonymous>)
    at startAsync (/Users/richetoh/Documents/tempbox/async/promise/promise.js:2:12)
    at Object.<anonymous> (/Users/richetoh/Documents/tempbox/async/promise/promise.js:19:18)
    at Module._compile (node:internal/modules/cjs/loader:1097:14)
    at Object.Module._extensions..js (node:internal/modules/cjs/loader:1149:10)
    at Module.load (node:internal/modules/cjs/loader:975:32)
    at Function.Module._load (node:internal/modules/cjs/loader:822:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
    at node:internal/main/run_main_module:17:47

 

Resolve, Reject, 그리고 throw

executor의 특징은 resolve, reject, throw error중 먼저 실행된것만 취급하고 그 뒤는 무시한다는 점이다. 이건 코드를 보고 이해하는게 가장 빠르다.

// catch 로 연결
const throwError = new Promise((resolve, reject) => {
    throw Error("error");
  });
  throwError
    .then(() => console.log("throwError success"))
    .catch(() => console.log("throwError catched"));

// 리턴이 무시됨
const ret = new Promise((resolve, reject) => {
return "returned";
});
ret
.then(() => console.log("ret success"))
.catch(() => console.log("ret catched"));

// resolve 만 실행
const several1 = new Promise((resolve, reject) => {
resolve();
reject();
});
several1
.then(() => console.log("several1 success"))
.catch(() => console.log("several1 catched"));

// reject 만 실행
const several2 = new Promise((resolve, reject) => {
reject();
resolve();
});
several2
.then(() => console.log("several2 success"))
.catch(() => console.log("several2 catched"));

// resolve 만 실행. throw는 무시됨.
const several3 = new Promise((resolve, reject) => {
resolve();
throw new Error("error");
});
several3
.then(() => console.log("several3 success"))
.catch(() => console.log("several3 catched"));

위처럼 then과 catch에 함수를 넣어주게 되는데, 이 함수를 핸들러라고 부른다.

위 코드를 실행하면 아래의 결과가 나온다.

several1 success
several3 success
throwError catched
several2 catched

 

프로미스의 3가지 상태

프로미스를 사용할 때 알아야 하는 기본적인 개념이 바로 프로미스의 상태(states)다.

여기서 말하는 상태란 프로미스의 처리 과정ㅇ르 의미하는데, new Promise()로 프로미스를 생성하고 종료될 때 까지 3가지 상태를 갖는다.

  • Pending(대기) : 비동기 처리 로직이 아직 완료되지 않은 상태
    • 처음 new Promise()메서드를 호출하면 대기 상태가 됨.
    • 이 때, 콜백함수로 resolve, reject를 선언할 수 있음
  • Fulfilled(이행) : 비동기 처리가 완료되어 프로미스가 결과 값을 반환해준 상태
    • 콜백 함수인 resolve가 실행되면 Fulfilled상태가 됨
    • 이 때, then로 등작한 동작들이 실행
  • Rejected(실패) : 비동기 처리가 실패하거나 오류가 발생한 상태
    • 콜백 함수인 reject가 실행되면 Rejected상태가 됨.
    • 이 때, catch로 등록한 동작들이 실행

 

프로미스 체이닝 : 여러 개의 프로미스 연결하기

프로미스의 또 다른 특징은 여러 개의 프로미스를 연결하여 사용할 수 있다는 점이다.

then()메소드를 한번 호출하고 나면 새로운 프로미스 객체가 반환되는 특징을 이용하는 것이다.

new Promise(function(resolve, reject){
  setTimeout(function() {
    resolve(1);
  }, 2000);
})
.then(function(result) {
  console.log(result); // 위 처음 resolve함수에서 1이 리턴됨.
  return result + 10;
})
.then(function(result) {
  console.log(result); // 11
  return result + 20;
})
.then(function(result) {
  console.log(result); // 31
});

resolve()가 호출되면 프로미스가 대기 상태에서 이행 상태로 넘어가기 때문에 첫 번째 .then()의 로직으로 넘어간다.

첫 번째 .then()에서는 이행된 결과 값 1을 받아서 10을 더한 후 새로운 프로미스 객체를 반환한다.

그리고이제 그 다음 .then()에서도 마찬가지로 바로 이전 프로미스의 결과 값 11을 받아서 20을 더하고 다음 .then()으로 넘겨준다.

. 마지막 .then()에서 최종 결과 값 31을 출력한다.

이게 무슨말인지 이해가 안된다면 먼저 첫번 째 Then을 확인해보자

a = new Promise(function(resolve, reject){
    setTimeout(function() {
      resolve(1);
    }, 2000);
  })
  .then(function(result) {
    console.log(result); // 위 처음 resolve함수에서 1이 리턴됨.
    return result + 10;
  })

console.log(a)

실행해보면 아래와 같이 새로운 Promise가 리턴된다. 그러고나서 2초뒤에 resolve를 통해 리턴된 값 1이 result로 넘어오게 된다.

Promise { <pending> }
1

그러고 나서 return result + 10을 하게되면 then에 이 값이 넘어가게 되고

new Promise(function(resolve, reject){
    setTimeout(function() {
      resolve(1);
    }, 2000);
  })
  .then(function(result) {
    console.log(result); // 위 처음 resolve함수에서 1이 리턴됨.
    return result + 10;
  })
  .then(function(result) {
    console.log(result); // 11
    return result + 20;
  })

다음 function의 result는 11이 넘어가게 되고, 그 다음에는 20이 더해져 31이 되는 것이다.

이 때는 처음 promise객체의 setTimeout과는 관계없다. promise.then을 호출하면 프라미스가 반환되고, 이때 핸들러가 값을 반환할 때엔 그 값이 promise의 result가 된다. 따라서 다음 .then은 이 값을 이용해 호출된다.

new Promise(function(resolve, reject){
    setTimeout(function() {
      resolve(1);
    }, 2000);
  })
  .then(function(result) {
    console.log(result); // 위 처음 resolve함수에서 1이 리턴됨.
    return result + 10;
  })
  .then(function(result) {
    console.log(result); // 11
    return result + 20;
  })
  .then(function(result) {
    console.log(result); // 31
  });

위에서는 특정 값을 result로 반환하는 프라미스가 바로 리턴됐지만, 핸들러가 프라미스를 다시 생성하고 반환하는 경우도 있다. 이 경우에 이어지는 핸들러는 프라미스가 처리될 때 까지 기다리다가 처리가 완료되면 그 결과를 받는다.

new Promise(function(resolve, reject) {

  setTimeout(() => resolve(1), 1000);

}).then(function(result) {

  alert(result); // 1

  return new Promise((resolve, reject) => { // (*)
    setTimeout(() => resolve(result * 2), 1000);
  });

}).then(function(result) { // (**)

  alert(result); // 2

  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(result * 2), 1000);
  });

}).then(function(result) {

  alert(result); // 4

});

예시에서 첫 번째 .then1을 출력하고 new Promise(…)를 반환((*))한다.

1초 후 이 프라미스가 이행되고 그 결과(resolve의 인수인 result * 2)는 두 번째 .then으로 전달된다.

두 번째 핸들러((**))는 2를 출력하고 동일한 과정이 반복된다.

따라서 얼럿 창엔 이전 예시와 동일하게 1, 2, 4가 차례대로 출력된다. 다만 얼럿 창 사이에 1초의 딜레이가 생긴다.

이렇게 핸들러 안에서 프라미스를 반환하는 것도 비동기 작업 체이닝을 가능하게 해준다.

요약

.then 또는 .catch, .finally의 핸들러(어떤 경우도 상관없음)가 프라미스를 반환하면, 나머지 체인은 프라미스가 처리될 때까지 대기한다. 처리가 완료되면 프라미스의 result(값 또는 에러)가 다음 체인으로 전달된다.

 

이를 그림으로 나타내면 아래와 같다.

 

 

실무에서 있을 법한 프로미스 연결 사례

실제 웹 서비스에서 있을 법한 로그인 인증 로직에 프로미스를 여러개 연결해보자.

getData(userInfo)
  .then(parseValue)
  .then(auth)
  .then(diaplay);

 

 

자 이제 마지막 탄만 남았다.. 좀만 더 힘내자 async & await은 더 멋있다. 

 

https://etloveguitar.tistory.com/87

 

[Javascript 비동기 처리 뿌시기 (4/4)] 비동기 처리 끝판왕 async & await 이해하기

Javascript 비동기 처리 뿌시기 시리즈에서 필자가 추천하는 학습방법은 아래와 같다. 학습방법 step1) 비동기 처리의 기본 개념을 이해하기 위한 Core개념 익히기 (https://etloveguitar.tistory.com/84) step2)..

etloveguitar.tistory.com

 

 

참고자료

https://ko.javascript.info/promise-chaining

https://joshua1988.github.io/web-development/javascript/javascript-asynchronous-operation/

https://it-eldorado.tistory.com/86

https://developer.mozilla.org/ko/docs/Web/JavaScript/EventLoophttps://velog.io/@thms200/Event-Loop-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A3%A8%ED%94%84https://www.youtube.com/watch?v=m0icCqHY39U&t=369s

Comments