패캠 인강/Vanilla JavaScript

[패캠 인강] 비동기적 처리 (Promise)

Je-chan 2021. 9. 10. 01:25

1. Promise 를 사용하는 이유

 

 

  예전에 비동기 작업을 할 때 콜백 함수를 사용했다. 하지만 양이 많아지기 시작하면 코드가 난잡해진다는 단점이 발생한다. 바로 위의 사진처럼 콜백 지옥이라는 걸 볼 수 있다. 그걸 막기 위해 도입된 문법이 Promise 다. 직접 코드를 작성하며 얼마나 깨끗해지는지 확인해보자. 우리는 코드를 통해 초를 세는 함수를 작성해보고자 한다. 

 

1) 콜백 지옥

  먼저, 콜백 함수로 비동기를 구현했을 때의 상황이다. 

 

function second (sec, callback) {
  setTimeout(() => {
    const now = sec + 1
    console.log(`${now}초`) 
    if (callback) {
      callback(now)
    } 
  }, 1000)
}

second(0, n => {
  second(n, n => {
    second(n, n => {
      second(n, n => {
        second(n, n => {
          console.log('끝')
        })
      })
    })
  })
})

 

  이러면 아래처럼 초를 세면서 콘솔에 찍힌다. 우리가 setTimeout 에 1000ms 를 추가했으므로 1초마다 setTimeout 안에 있는 함수가 실행됨을 알 수 있다. 

 

 

 

  하지만 현재 콜백으로 구성하다 보니 콜백 지옥이 형성된다. 이것을 해결하기 위해 같은 기능을 하는 Promise 로 사용해보자

 

 

2) Promise로 콜백 지옥 탈출

 

const second = (sec) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if(typeof sec === 'number') {
        const now = sec + 1
      	console.log(`${now}초`) 
        resolve(sec+1)
      } else {
        reject('숫자가 아닙니다')
      }
    }, 1000)
  }
)}

second(0)
.then(second)
.then(second)
.then(second)
.then(second)
.catch(console.log)
.finally(() => console.log('끝'))

 

 아직 아무 설명도 하지 않았으므로 이게 어떻게 작동하는 건지 본 후 개념을 파헤쳐 보자. 사실, 위의 코드는 제대로 작성된 코드라고 할 수는 없다. 그 이유도 조금 있다 reject 부분에서 설명하도록 하자

 

 

 
  밑에는 second 함수 인자에 0 이 아닌 문자열 '에잇' 을 넣었을 때 출력되는 모습이다.

 

 

 

  일단, 지금 챕터에서는 같은 기능을 구현하지만 콜백 함수에서 구현한 코드보다 Promise 를 활용한 코드가 더 가독성이 좋은 형태로 출력된다는 사실만을 알고 가면 될 것 같다. 

 

 

2. Promise 개념

  기초적인 개념으로는 Promise 가 new 라는 키워드를 사용하는 것을 보니 객체인 것을 확인할 수 있을 것이다. 그렇다면 추측할 수 있기로 then, catch, finally 는 Promise 의 내장된 method 일 것이다. 그리고 resolve랑 reject는 무슨 인자인 것 같은데 사용되는 꼴을 보니 함수인 것 같다. 그럼 지금부터 하나씩 개념을 확인해보자. 

 

const second = (sec) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 여기에서는 임시로, 숫자타입이 아닌 경우를 에러로 가정하고 작성했다.
      if(typeof sec === 'number') { 
        const now = sec + 1 
      	console.log(`${now}초`) // then에 인자로 `${now}초` 를 넘겨준다
        resolve(sec+1)
      } else {
        reject('숫자가 아닙니다') // catch에 인자로 '숫자가 아닙니다' 를 넘겨준다.
      }
    }, 1000)
  }
)}

// reject 기본적인 사용법

const rightUse = new Promise((resolve, reject) => {
  setTimeout(() => {
  	resolve('성공')
    reject(new Error())
  }, 1000)
})

 

1) Executor

  Executor 란 말은 "집행자" 라는 의미다. Promise 객체의 기능을 실질적으로 집행하는 함수라고 보면 될 것 같다. 그 executor 가 바로 resolve( ) 와 reject ( ) 다. 이렇게 두 개로 나뉘는 이유는 기능이 실질적으로 성공했느냐 아니면 성공하지 못하고 에러가 났느냐에 따라서 다르다. 성공했다면 resolve 를 호출하고, 실패했다면 reject 를 호출한다. 지금 내가 second 로 작성했을 때는 if 로 조건을 나눴지만 실제로는 저렇게 new Error 를 사용해서 핸들링한다. new Error 와 관련된 내용은 또 챕터를 따로 나눠야 하기에 여기서 자세하게 얘기하지는 않고, 다만 resolve는 성공했을 때 실행되고, reject는 에러가 났을 때 실행된다는 것만 확인하면 될 것 같다.

 

  그렇게 실행을 하게 되면 resolve 나 reject 가 받은 인자가 메소드의 인자로 넘어가게 된다. resolve 는 .then 메소드로 데이터(보통 promise에서 다루는 인자가 데이터)를 넘겨주고 reject는 .catch라는 메소드로 인자를 넘긴다

 

 

 

 

2) Method

  위에서 기본적인 method 는 모두 사용하고 있다.

 

  then, catch, finally 

  에러 없이 성공했을 때 resolve 값을 받는 then, 에러가 났을 때 reject 값을 받는 catch, 그리고 오류가 나든 안 나든 무조건 실행되는 finally 가 기본적 method 다. 이 모든 method 의 공통점은 새로운 Promise 를 반환한다는 점이다. 이건 마치 Array의 고차 함수 메소들과도 비슷하다. 아래의 예시를 살펴보자

 

// new Array 로 새로운 배열을 생성

let arr = new Array(5).fill(1)

// 메소드 map을 사용하면, map이 늘 새로운 배열을 반환한다.
// 그러면 그렇게 반환된 새로운 배열에 다시 배열 기본 내장 메소드인 map을 사용할 수 있다.
// 이런 과정을 메소드 체이닝이라고 한다.

let mapedArr = arr
.map((el, i) => el + i) // 새로운 배열 [1, 2, 3, 4, 5] 를 만들어냄
.map(el => el * 2) // 그 배열에 map을 한 번 더 하고 [2, 4, 6, 8, 10] 배열을 새로 만듦

console.log(mapedArr)
// [2, 4, 6, 8, 10]

 

  중요한 점은 배열의 메소드를 사용했을 때 그 사용한 메소드가 새로운 배열을 만들기 때문에 다른 메소드를 사용할 수 있다는 것이다. Promise 의 메소드들도 마찬가지다. then, catch, finally 는 새로운 Promise 를 생성하고 그렇기 때문에 체이닝을 할 수 있다.

 

 

  Promise chaining

 

const second = (sec) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if(typeof sec === 'number') {
        const now = sec + 1
      	console.log(`${now}초`) 
        resolve(sec+1)
      } else {
        reject('숫자가 아닙니다')
      }
    }, 1000)
  }
)}



second(0)// second(0) 을 받으면 위에서 resolve 함수의 인자로 1을 주게 된다. 
.then((data) => { // 그러면 data는 resolve의 인자 1을 받는다
 return second(data) // 그 인자 1을 다시 second에 넣는다 
 }) // 여기서 resolve(2)가 되고 그 2를 밑의 then의 인자로 다시 넘겨준다.
 
.then(data => second(data)) // 그러면 그 인자를 다시 받아 second()에 넘겨준다 (화살표 함수 축약 형태)

.then(second)// 여기서는 인자(data)가 3이 되고 resolve(4)를 한다
// Promise 메소드의 특징인데 만약 인자가 한 개, 그걸 사용하는 함수(로직)도 하나라면 인자와 호출을 생략할 수 있다.
// 즉 이렇게 second만 적어도 지금 위에서 표현한 모든 내용과 동일한 것

.then(second) // 여기엔 인자가 4가 되고, resolve(5)를 한다

.catch(console.log) // 얘는 reject가 실행되지 않았으므로 인자를 받지 않음
// catch 안의 형태를 풀면 data => console.log(data)

.finally(() => console.log('끝')) // 얘는 인자로 받는 것도 없으므로 인자 없는 함수를 실행함

 

  이렇게 then, catch, finally 로 체이닝 하는 과정을 메소드 체이닝처럼 "Promise 체이닝"이라고 한다. 

 

 

  reject 에서 오류를 잡아내는 법 (new Error)

  then에 대한 설명은 코드 스테이츠 48일차 복습 때 자세하게 했다.(https://je-developing.tistory.com/65) 여기서는 reject에 대해서 조금 더 언급하기로 한다.

 

  보통, error 발생은 new Error( ) 로 캐치한다. 에러 핸들링은 매우 중요한 이슈라서 여기에서 언급하긴 어렵고 다음에 기회 되면 작성하도록 한다. Error 라는 객체는 자반 스크립트 기본 내장 객체다. 보통 인자를 넣지 않거나 많이 넣어봐야 두 개까지만 넣는다. 세 개 이상은 MDN 에서도 추천하지 않는다. 다음 사례를 한 번 보자. value가 5가 되면 바로 에러를 내는 코드다

 

function fiveError (n) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const value = n + 1
      if (value === 5) { // value가 5일 때 error 를 낸다
        const error = new Error("value is five")
        // new Error의 인자 첫 번째 인자는 에러 메세지를 의미한다
        error.name = 'ValueIsFiveError'
        // 에러명을 기록한다
        reject(error)
        // 에러난 것을 reject로 넘긴다.
        return; // 이 다음의 작업을 하지 않겠다는 의미
      }
      console.log(value)
      resolve(value)
    }, 1000)
  })
}

fiveError(0)
.then(fiveError)
.then(fiveError)
.then(fiveError)
.then(fiveError)
.then(fiveError)
.catch(e => {
  console.error(e)
})
// 물론 .catch(console.error) 로 표현할 수 있다.

 

  이 상황을 콘솔에 찍으면 다음과 같다.

 

 

 

  이렇게 reject를 쓰는 경우는 보통 API 요청 등에서 많이 사용한다. 만약에 로딩 시간이 초과되거나 혹은 받은 파일에 문제가 있을 때, 클라이언트 쪽에서(클라이언트는 서버에서 데이터를 받아오는 우리, 브라우저를 의미한다고 지금은 간단하게 생각하면 된다.) 에러를 핸들링하기 위한 용도로 사용한다. 

3) Promise 의 한계점

  이렇게 콜백 함수를 간단하게 표현하면서 굉장히 좋아 보이는 Promise 에도 몇 가지 문제점이 존재한다.

  1. 어디에서 에러가 발생했는지 파악하기 어렵다.
    : Reject 가 된 곳을 찾기 어려움

  2. 특정 조건에 따라 분기를 나누는 작업도 어렵다
    : 모든 게 다 then 으로 이어지기에 결과에 따른 다른 작업으로 넘기기 어려움

  3. 어떤 특정 값을 공유하면서 작업하기 어렵다.

  물론 Promise 가 매우 좋은 문법이긴 하지만 위의 문제가 있어 다음 시간에 정리할 async, await 문법이 탄생했다. 그러나 무조건 async, await 를 써야 한다는 것은 아니다. 각자의 장단점이 확실하기에 지금 배우는 Promise 는 나중에 굉장히 많이 사용하게 된다. (근데, 비동기를 위한 콜백은 버려도 될 듯)