[JS] 비동기 (promise / async / await)

전상욱·2021년 5월 16일
2

JavaScript

목록 보기
10/17
post-thumbnail

자바스크립트의 특징, 비동기 처리

비동기 처리란?

특정 코드의 연산이 끝날 때까지 코드의 실행을 멈추지 않고 다음 코드를 먼저 실행하는 것을 말한다.

예시

console.log('양말을 신는다.');
setTimeout(( ) => console.log('신발을 신는다.'), 1000);
console.log('밖으로 나간다.');

예측 결과

양말을 신는다.
// 1초 후
신발을 신는다.
밖으로 나간다.

콘솔창 결과

양말을 신는다.
밖으로 나간다.
신발을 신는다.

자바스크립트의 대표적인 내장 비동기 함수인 setTimeout( ) 이다.
첫번째 인자로 실행할 콜백함수를 담고 두번째 인자로 들어온 시간만큼 기다린 후에 콜백함수를 실행한다.

동기(Synchronous) VS 비동기(Asynchronous)

특정 로직의 실행이 끝날 때까지 기다려주지 않고 나머지 코드를 먼저 실행하는 것이 비동기 처리이다.
만약 동기적으로만 코드가 실행된다면, 앞에서 실행된 코드가 모두 실행되는 것을 기다리고 다음 코드가 실행되기 때문에 유저에게 좋지 못한 사용자 경험을 제공하게 된다.
(자바스크립트가 싱글쓰레드이기 때문에 그 체감을 더 클 것이다.)

비동기 처리의 문제점

결국 요약하면, 싱글 쓰레드인 자바스크립트에서 좋은 사용자 경험과 성능적으로 유리하게 이끌어가기위해 비동기 처리라는 방식을 채택하고 있다고 볼 수 있다.

하지만 개발자는 자바스크립트 엔진이 아니기 때문에 순서가 뒤죽박죽인 코드의 실행이 직관적이지 않아 불편할 수도 있고, 무엇보다 api call과 같이 서버에 보낸 요청에 대한 응답이 오고 그 응답을 통해 다음 코드가 실행되어야 하는 경우엔 동기적으로 실행되도록 처리를 해줘야 한다.

콜백 함수

콜백 함수란?

다른 코드(함수 또는 메서드)에게 인자로 넘겨줌으로써 그 제어권(실행 시점)도 함께 위임한 함수

var poo = ( ) => {
  console.log('신발을 신는다.');
}
setTimeout(poo, 1000);

위에서 봤던 예시 중 setTimeout( ) 의 첫번째 인자로 들어온 함수가 콜백함수이다.
정의에 코드를 대입해보면 setTimeout( ) 이라는 함수에 poo를 인자로 넘겨줬고, poo의 제어권을 넘겨받은 setTimeout이 원하는 시점인 1초 후에 poo를 실행하게 된다.

이때 인자는 poo( )가 아닌 poo여야 한다. 함수를 즉시 실행하는 것이 아닌,
제어권을 넘겨준 함수가 원하는 시점에 실행시켜야 하기 때문이다.
(인자로 함수를 넘겨주는 것, 그게 콜백함수이다.)

콜백함수는 전통적이고 효과적인 비동기 처리를 도와주지만, 다음과 같이 콜백함수가 이어지는 즉, 콜백의 깊이가 깊어지면 가독성이 크게 떨어지는 콜백지옥에 빠지는 문제점이 발생하게 된다.

콜백 지옥
setTimeout(( ) => {
  console.log(''첫 번째);
  setTimeout(( ) => {
    console.log(''두 번째);
    setTimeout(( ) => {
      console.log(''세 번째);
      setTimeout(( ) => {
        console.log(''네 번째);
        setTimeout(( ) => {
          console.log(''다섯 번째);
        }, 5000);
      }, 5000);
    }, 5000);
  }, 5000);
}, 5000);

5초마다 콘솔을 띄우는 코드이다. 보기만 해도 가독성이 떨어진다.
이를 위해 Promise가 탄생하게 되었다.

Promise와 비동기 처리

Promise란?

현재에는 당장 얻을 수는 없지만 가까운 미래에는 얻을 수 있는 어떤 데이터에 접근하기 위한 방법을 제공하는 객체이다.

Promise 객체는 비동기 처리의 결과 값을 나타냅니다. 그 결과가 성공적으로 받아졌을 경우와 그렇지 않을 경우로 나눠서 에러처리 또한 가능하다.

Promise는 다음의 세 가지 상태를 가질 수 있다.

  • 대기 (pending) : 이행하거나 거부되지 않는 초기 상태
  • 이행 (fulfilled) : 연산이 성공적으로 완료됨
  • 거부 (rejected) : 연산이 실패함

  • 대기 (Pending)
var promise = ( ) => {
  return new Promise( (resolve, reject) => { ... } );
}

Promise 객체는 다음과 같이 생성이 가능하다. 이때 Prending 상태를 가진다.

  • 이행 (Fulfilled)
var promise = ( ) => {
  return new Promise( (resolve, reject) => {
    resolve( );
  }
}

여기서 Promise의 콜백 함수의 첫번째 인자로 전달된 resolve를 실행하면 Fulfilled상태가 된다.
('이행'은 '완료'와 같은 말이라고 생각하면 편하다.)

이 경우 resolve로 넘겨준 값을 .then( ) 을 통해 받을 수 있다.

var getData( ) => {
  return new Promise( function (resolve, reject) {
    var data = 100;
    resolve(data);
  }
}

// resolve( )의 결과 값 data를 resolvedData로 받음
getData( ).then( (resolvedData) => {
  console.log(resolvedData);   // 100
});
  • 실패 (Rejected)
new Promise( (resolve, reject) => {
  reject( );
});

이번엔 두번째 인자로 전달된 reject를 실행하면 Rejected 상태가 된다.
이 경우엔 두가지 방법을 통해 받을 수 있다.

  1. .then( ) 의 두번째 인자를 통한 에러처리
getData( ).then( ( ) => {
   // Success
}, (err) => {
   console.log (err);
});   // Error: Request is failed
```javascript
2. `catch( )`를 통한 에러처리 (추천)

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

방식은 다르지만, 두 방법 모두 Promise의 reject가 호출되었을 때 실행된다.
하지만 가급적 .catch( )를 사용하는게 좋다. then( )의 두번째 인자에서 에러를 핸들링 할 땐 첫번째 인자로 들어간 콜백함수 내부에서 오류가 나는 경우엔 reject 부분에서의 오류를 제대로 잡아내지 못하는 문제점이 있다.

Promise를 이요함으로써 좀 더 직관적으로 바꿀 수 있었고, .then( ).catch( ) 덕분에 에러 핸들링도 수월해졌다. 하지만, 다음과 같이 .then( )이 여러번 연결된 경우, Promise의 비동기 처리에선 .then( )의 스코프 내에서 코드를 작성해야하기 때문에 여전히 가독성에서 문제가 생긴다.

이렇게 .then( )을 통해서 Promise를 계속 연결하여 호출하는 것을 프로미스 체이닝(Promise chaining) 이라고 한다. 이 문제를 해결하기 위해 ES6 문법에 async/await이 추가되었다.

Promise 와 async / await

async 함수에는 await 식이 포함될 수 있다. 이 식은 async 함수의 실행을 일시 중지 하고 전달된 Promise의 해결을 기다린 다음 async 함수의 실행을 다시 시작하고 완료 후 값을 반환한다.

async

var promise = async ( ) => {
  return '끝이다.';
}

var message = promise( );
console.log(message);
// console: Promise { < fulfilled> : '끝이다.' }

new Promise( ) 생성자를 통해 Promise를 만들어 줄 필요가 없고,
async 키워드만 붙여주면 해당 함수에서 반환되는 값이 자동으로 Promise 객체가 된다.
이때, 안에 '끝이다.' 를 꺼내기 위해 아까는 .then( ) 을 사용했었는데, 여기서는 await 이라는 키워드를 사용할 수 있다.

await

var promise = () => {
  return new Promise((resolve, reject) => {
    resolve('끝이다.');
  });
}


var asyncFn = async () => {
  var awaitFn = await promise();
  console.log(awaitFn);
}

asyncFn();

콜백지옥

★ 10초마다 완료시키는 구문
const promise = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('시작');
      resolve();
    },1000)
  }
}

promise().then(() => {
  return new Promise((res, rej) => setTimeout(() => {
    console.log('완료1'); res();
  },1000));
}).then(() => {
  return new Promise((res, rej) => setTimeout(() => {
    console.log('완료2'); res();
  },1000));
}).then(() => {
  return new Promise((res, rej) => setTimeout(() => {
    console.log('완료3'); res();
  },1000));
}).then(() => {
  return new Promise((res, rej) => setTimeout(() => {
    console.log('최종완료'); res();
  },1000));
})

사용법은 정말 간단하다. Promise의 이행된, 그러니까 완료된 값을 얻기 위해 기다린다 라는 뜻의 await 키워드만 추가해주면 된다.
공식 문서의 설명에 대입시켜보면, awaitasync 함수 내의 Promise 앞에 쓰이면, async 함수의 실행을 일시중지하고 Promise 해결을 기다린 다음 함수의 실행을 재개하는 것이다.

주의할 점은, await 키워드는 async 함수 내에서만 사용할 수 있고, 반드시 Promise 앞에 왔을 때에만 코드가 의도한 것처럼 동기적으로 작동하게 된다.

위 설명같이 콜백지옥 또는 프로미스 체이닝 처럼 꼬리에 꼬리를 물고 깊어지는 구조가 나올때에는 비동기적 코드가 아니라 동기적 코드와 같은 구조인 Promise, async, await을 사용하면 가독성 측면에서 매우 유리하다.

profile
더 높은 곳으로

0개의 댓글