async / await 패턴에 대해서 알아보자

youseock·2024년 2월 6일

[JS] 비동기 처리

목록 보기
3/3
post-thumbnail

async/ await를 알아보자

async/await는 ECMAScript 2017에 추가된 문법으로 프로미스를 기반으로 동작한다. async/await를 사용하면 동기 작업처럼 비동기 코드를 짤 수 있는 장점이 있다.

💡 async/await는 비동기 작업을 콜백 함수나 Promise의 then/catch/finally와 같은 후속 메서드 없이 쉽게 코드를 짤 수 있는 장점이 있다.

비동기 작업에 가장 중요한 부분인 에러처리에 대해서는 글 아래에서 알아보기로 한다.

지금은 Promise와 async/await가 같은 작업을 얼마나 다르게 표현할 수 있는지를 집중하자.

async/await와 Promise 패턴의 표현 차이

async/await 문법이 순차적으로 비동기 코드를 실행하는 상황에서 Depth가 얕다

공통으로 사용될 프로미스 제네레이터의 코드

function 목욕() {
  const 순서 = (function* 씻는순서() {
    yield "양치끝";
    yield "머리감기";
    yield "비누칠";
    yield "세안";
  })()
  return function (prev) {
    return new Promise((res) => setTimeout(() => res(`${prev} => ${순서.next().value}`), 1000));
  };
}

Promise

const fn1 = 목욕();
fn1('씻기 시작').then((r1) => {
  fn1(r1).then((r2) => {
    fn1(r2).then((r3) => {
		  fn1(r3).then((r4) => {
				console.log(r4);
			})
		});
  });
});
// "씻기 시작 => 양치끝 => 머리감기 => 비누칠 => 세안"

async/await

const fn2 = 목욕();
(async () => {	
  const p1 = await fn2('씻기 시작');
  const p2 = await fn2(p1);
  const p3 = await fn2(p2);
  const p4 = await fn2(p3);
	console.log(p4)
})();
// "씻기 시작 => 양치끝 => 머리감기 => 비누칠 => 세안"

위의 코드는 정확히 같은 동작을 하며, 같은 시간이 걸린다. 순차적으로 일어나야 하는 비동기 작업을 Promise 패턴으로 구현하는 과정에서 코드의 depth가 깊어지는 일이 발생한다. 그에 반해 async/await는 비동기 작업의 순차와 관계 없이 1 depth로 구현할 수 있는 장점이 있다.

async/await를 전역에서 실행하는 경우 즉시 실행 함수로 구현해야 한다

await 비동기 작업() // XX 
(async function(){
	await 비동기 작업() // OO
}())

await는 async를 붙인 함수 내부에서 사용할 수 있는 keyword다. await를 붙이면 해당 코드가 Promise 객체일 경우 Promise의 status가 pending에서 settled 상태가 될 경우에만 아래 코드가 실행된다. await는 꼭 Promise 객체 앞에 붙여야 한다. Promise 객체가 아닐 경우에 아무 의미없는 키워드가 된다.

await가 항상 async로 감싼 함수 안에서만 작성할 수 있다는 점에서 Promise 패턴과 다른 점이 생긴다. 전역에서 호출하기 위해서는 즉시 실행 함수로 구현해야 한다.

💡 await가 async 없이 사용할 수 있다면 전역에서 사용한 await new Promise()는 while(true){}와 같은 동작을 할수 있다. while(true){}보단 발열은 덜 하겠지만..

에러처리 관점에서 비교해보자

비동기 작업의 핵심은 에러처리라고 생각한다. 먼저 왜 에러처리가 중요한 지를 알아보고, async/await가 어떤식으로 에러를 핸들링할 수 있는지 알아보자.

비동기 작업의 에러처리가 중요한 이유

대부분의 비동기 작업은 통제할 수 없다. 통제할 수 없는 비동기 작업의 대표적인 예인 통신(API 호출)에 대해서 살펴보자.

API 호출은 선언적으로 실행할 수 있어 단순해 보이지만, 내부적으로는 여러 단계를 거치게 된다. API를 호출하는 첫 단계만 호출자가 관여할 수 있다. 즉, 올바른 파라메타를 넣어주는 것만이 호출자가 통제할 수 있는 부분인 것이다. 올바른 파라메타를 넣어주는 행위도 시간에 따라 달라질 수 있다. 서버의 API 설계가 변경된다면 올바른 파라메타도 잘못된 파라메타가 되는 것이다.

API 호출 과정에서 호출자가 관여할 수 없는 부분

  • 네트워크 오류
    • 심지어 응답이 일정시간 이상 지연 되는것도 통신 오류로 판단한다.
  • CORS (only 브라우저)
    • CORS는 철저하게 통신 보안을 유지하기 위한 브라우저 정책이지만 Server에서 통제한다.
    • 클라이언트가 브라우저일 경우 아무리 청렴을 주장해도 서버가 허락하지 않으면, 자원을 얻을 수 없다.
  • Server 오류
    • 서버 컴퓨터 다운이나 트래픽 폭주 등이 있다.
  • API 설계 변경
    • 대부분은 구버전 API를 유지하는 쪽으로 개발 하지만, 갑작스레 변경되는 경우도 있다.

🎯 수많은 이유로 비동기 작업은 실패할 수 있다. 비동기 작업은 올바른 수행을 확신하기 어렵기 때문에, 에러처리가 중요하다.

❓ 선언적이면 뭐가 가능해

펼치기
 정해진 규격에 맞추기만 한다면 내부 로직을 몰라도 원하는 결과를 얻을 수 있다.
 fetch로 HTTP 통신을 이용해 API를 호출한다고 가정한다면 크게 3가지 파라메타가 필요하다. 
1. method ⇒ HTTP 메소드 2. headers ⇒ HTTP 헤더 3. body ⇒ HTTP 바디
해당 요소만 넣어준다면 내부적으로 수많은 작업들을 알아서 수행한다.

💡 통제할 수 있는 비동기 작업도 있다.

펼치기

내장 모듈인 crypto를 사용해서 암호화 하는 작업은 동기적으로 구현해도 비동기적으로 처리해준다. 해당 작업의 성공 여부는 호출자에 달려있다. 올바른 파라메타, 호출자 컴퓨터 성능 등등..

간단한 예시로 알아보자

const crypto = require("crypto");
function sha256(data) { const hash = crypto.createHash("sha256"); hash.update(data); return hash.digest("hex"); }
(function () { console.time("sha256 열 개 돌릴 때 결과"); for (let i = 0; i < 10; i++) { sha256("비밀"); } console.timeEnd("sha256 열 개 돌릴 때 결과"); console.time("sha256 한 개 돌릴 때 결과"); sha256("비밀"); console.timeEnd("sha256 한 개 돌릴 때 결과"); })();

sha256 열 개 돌릴 때 결과: 0.716ms
sha256 한 개 돌릴 때 결과: 0.337ms

만약 위 코드가 동기적으로 실행된다면 열 개를 돌린 시간이 하나를 돌릴 때 보다 약 10배 정도 느려야 하지 않을까? V8 엔진의 최적화를 고려하더라도, 약 8배 정도 걸려야 할 것 같다. 반복적으로 측정해도 3배를 넘는 경우는 없었다. 아마 기본적으로 4개의 서브 스레드를 사용하기 때문이 아닐까라고 생각한다. 스레드 수도 직접 조절할 수 있는데, 이는 추후에 다루도록 하자.

async/await의 에러처리

async/await 문법이 Promise 패턴보다 나은 점은 동기처럼 코드를 작성할 수 있다는 것이다. 에러 핸들링도 동기 코드처럼 에러를 핸들링할 수 있다.

async/await의 에러처리를 제대로 이해하기 위해서는 async/await의 두 가지 특징을 알아야한다.

  1. async 함수의 리턴은 항상 프로미스이다.
  2. async 함수를 함수 내에서 사용하기 위해서는 상위 함수에 async keyword가 필요하다.

하나씩 살펴보자.

async 함수의 리턴은 항상 프로미스다

async로 만든 함수의 경우 항상 프로미스를 리턴하게 된다. 당연히 async와 Promise 메서드를 함께 사용하는 것도 가능하다. 코드의 유효성은 고려하지 말고 예를 통해 알아보자. 중요한 것은 async는 항상 Promise를 리턴한다는 것이다. 재밌는 사실은 return 문이 없을 경우엔 Promise 타입의 프로미스를 리턴하게 된다.

async function 나는프로미스를항상리턴해요(){
	return 3;
}

나는프로미스를항상리턴해요()
.then()
.catch()

❓ 내부적으로는 어떻게 돌아가는 것일까 ? 추측해보자

아래와 같은 로직을 가지고 있지 않을까

function asyncWrapper (fn){
   const result = fn() as unknown;
   if(result instanceof Promise) return result;
   else return Promise.resolve(result)
}
async 함수를 함수 내에서 사용하기 위해서는 상위 함수에 async keyword가 필요하다

자바스크립트가 어떻게 비동기 로직을 처리하는 지에 대해서 알고 있다면, async로 작성된 함수를 일반 함수에서 실행하고, 실행 결과를 사용할 때 어떤 일이 발생할 지 예측할 수 있다.

프로미스를 리턴하는 함수

  • 리턴하는 프로미스는 1초 뒤에 fulfilled되며 값으로 뻥이야~!를 넘겨준다
async function 일초_뒤에_비밀_알려줄게() {
  return new Promise((res) => {
    setTimeout(() => {
      res("뻥이야~!");
    }, 1000);
  });
}

일반 함수로 호출

(function 일반함수(){
	const result = 일초_뒤에_비밀_알려줄게();
  console.log(`소중한 정보 : ${result}`)
}());
// "소중한 정보 : [object Promise]"

async로 감싼 함수로 호출

 (async function 호출자(){
	const result = await 일초_뒤에_비밀_알려줄게();
   console.log(`소중한 정보 : ${result}`)
}())
// "소중한 정보 : 뻥이야~!"

async로 만든 함수를 함수 내부에서 사용하고 해당 리턴값을 이용하고 싶다면 꼭 async를 붙여야한다.

💡 위 예시의 결과물에 대해서 이해하기 힘들다면, 자바스크립트 런타임 환경이 어떻게 비동기 로직을 처리하는 지에 대해서 학습해 볼 필요가 있다.

💡 주의할 점은 async 함수 실행 자체는 호출자가 async 함수든 아니든 상관이 없다는 점이다. 결과 값을 받아 이용하고 싶다면 async와 await를 덕지덕지 붙이자.

async/await의 에러처리 예시

async/await는 비동기 작업을 동기처럼 작성할 수 있다. 에러처리도 마찬가지다.

  
  (async function (){
  	try {
  	await fetch();
    await fetch();
    await fetch();
  } catch(err){
    // fetch 함수가 던진 Erorr를 캐치하는 곳
  	console.error(err)
  }
  })()
  

async/await에서는 비동기 함수를 명시적으로 호출할 수 있기 때문에, 비동기 함수에서 발생하는 에러를 try catch문을 통해 잡아낼 수 있다.

조금 더 복잡한 예를 알아보자

function only실패(sec, msg) {
  return new Promise((_, rej) => {
    setTimeout(() => {
      rej(msg);
    }, sec * 1000);
  });
}

async function 하단() {
  await only실패(2, "하단에서 발생한 에러입니다~!");
}

async function 중단() {
  await 하단();
}

async function 상단() {
  await 중단();
}

(async function () {
  try {
    await 상단();
  } catch (e) {
    console.log(e);
  }
})();
// 하단에서 발생한 에러입니다~!

위 코드를 해석해보자. 즉시 실햄함수가 상단 함수를 호출한다. 상단 함수는 중단 함수를 호출한다. 중단 함수는 하단 함수를 호출한다. 하단 함수는 only실패 함수를 호출한다. only실패 함수는 2초 뒤에 rejected되는 Promise를 리턴한다. 2초가 지나면 rejected 되면서 에러가 발생하고 하단 함수에서 중단 함수로 Error가 올라간다. 중단 함수의 Error는 다시 상단으로 올라가고 최종적으로 즉시 실행함수까지 올라간 뒤에 catch에 의해서 Error가 잡히게 된다.

에러 처리에 대해서 크게 고려할 필요없이 최상단에서 catch해서 핸들링할 수 있는 장점이 있다.

async/await를 사용할 때 주의할 점

마지막으로 주의할 점에 대해서 얘기하고 글을 마치려고 한다. async/await 문법은 비동기 작업을 동기 작업처럼 수행한다. async로 함께 묶여있는 코드의 경우 await 키워드가 붙은 Promise 객체가 이행되지 않는다면 아래 코드가 실행되지 않는다. 반드시 비동기 코드가 수행되고 나서 아래 코드가 돌아가야 한다면 매우 바람직하지만, 그런 이유가 아니라면 성능상의 이슈를 낳을 수 있다. async로 묶은 함수 내부에는 반드시 순차적으로 수행되어야하는 로직만 포함하자.

profile
자바스크립트 애호가

0개의 댓글