[JS] 비동기처리, 흐름대로 이해하기

juno7803·2021년 4월 23일
38
post-thumbnail

😁 들어가기

 오늘은 Javascript에서 비동기 처리에 대한 방법, 콜백 함수와 Promise 그리고 async/await이 나온 배경까지 흐름대로 알아보겠습니다!

비동기 처리를 이해하기 쉬운 예시

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

  C++을 시작으로 프로그래밍을 배운 저는, 당연하게도 코드가 쓰여진 순서대로 동작하는 것에 익숙해 있었습니다. 하지만 자바스크립트에 존재하는 비동기 함수의 동작은 조금 다릅니다.

다음의 예시를 통해 설명해 보겠습니다.

// #1
console.log('양말을 신는다');
// #2
setTimeout(() => {
	console.log('신발을 신는다');
}, 1000);
// #3
console.log('밖으로 나간다');

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

자바스크립트를 잘 모르는 개발자에게 이정도의 설명만 해주고 코드의 실행을 예측한다면,

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

와 같이 동작하리라고 예측할 수 있습니다. 하지만,

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

와 같이 콘솔창에 나타나게 됩니다.

🙌 참고하기
 이는 타이머에 0초를 달아도 똑같은 결과를 초래합니다. 싱글스레드인 자바스크립트에서 이벤트 루프의 동작으로 인해 실행순위가 밀리기 때문인데, 타이머 함수의 타이머가 종료된후 콜백 큐에 담기고, 콜 스택이 비워졌을 때에서야 이벤트 루프에 의해 콜 스택으로 넘어간 뒤 실행되기 때문입니다.

// #1
console.log('양말을 신는다');
// #2
setTimeout(() => {
	console.log('신발을 신는다');
}, 0);
// #3
console.log('밖으로 나간다');
// result: 
// 양말을 신는다.
// 밖으로 나간다.
// 신발을 신는다.

Synchronous vs Asynchronous

 특정 로직의 실행이 끝날 때까지 기다려주지 않고 나머지 코드를 먼저 실행하는 것이 비동기 처리입니다.

만약 동기적으로만 코드가 실행된다면, 앞에서 실행된 코드가 모두 실행되는 것을 기다리고 다음 코드가 실행되기 때문에 유저에게 좋지 못한 사용자 경험을 제공하게 됩니다.(자바스크립트가 싱글쓰레드이기 때문에 그 체감은 더 클 것입니다.)

비동기 처리의 문제점

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

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

⏰ 콜백 함수

먼저 콜백함수의 정의를 알아보겠습니다.

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

const wearShoes = () =>{
	console.log('신발을 신는다');
}

setTimeout(wearShoes, 1000);

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

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

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

// 콜백 지옥
setTimeout(
  (garment) => {
    var ootd = garment;
    console.log(ootd);
    setTimeout(
      (garment) => {
        ootd += "," + garment;
        console.log(ootd);
        setTimeout(
          (garment) => {
            ootd += "," + garment;
            console.log(ootd);
            setTimeout(
              (garment) => {
                ootd += "," + garment;
                console.log(ootd);
              },
              500,
              "스니커즈"
            );
          },
          500,
          "블레이저"
        );
      },
      500,
      "데님팬츠"
    );
  },
  500,
  "반팔티"
);

5초마다 옷을 순서대로 입는 코드입니다. 함수에 콜백함수를 인자로 넘겼고, 그 콜백함수 안에 또 콜백함수를 넘기고... 보기만 해도 가독성이 떨어지죠? 이를 위해서 Promise가 탄생하게 되었습니다.

Promise와 비동기 처리

Promise도 우선 정의를 먼저 알아보겠습니다.

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

우리가 원하는 말로 다시 바꿔보면, Promise 객체는 비동기 처리의 결과 값을 나타냅니다. 그 결과가 성공적으로 받아졌을 경우그렇지 않을 경우로 나눠서 에러처리 또한 가능한 것이죠.

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

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

Promise의 흐름을 이해하기 쉬운 그림 : MDN

대기(Pending)

const promise = () => {
  return new Promise((resolve, reject) => { ... } );
}

Promise 객체는 다음과 같이 생성할 수 있는데, 이때 Pending 상태를 가집니다.

이행(Fulfilled)

const promise = () => {
	return new Promise((resolve, reject)=>{
    	resolve();
    })
}

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

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

const 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

2. .catch()를 통한 에러처리

getData()
  .then()
  .catch((err) => {
    console.log(err); // Error: Request is failed
  });

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

 이제 Promise의 개념을 조금 이해하시겠나요? 콜백지옥에 빠졌던 wearOotd() 함수를 Promise로 바꿔보겠습니다.

const wearOotd = (garment) => {
  return (prevGarment) => {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        var ootd = prevGarment ? prevGarment + "," + garment : garment;
        console.log(ootd);
        resolve(ootd);
      }, 500);
    });
  };
};

wearOotd('반팔티')()
	.then(wearOotd('블레이저'))
	.then(wearOotd('데님팬츠'))
	.then(wearOotd('스니커즈'));

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

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

Promise 와 async/await

 제가 포스팅을 작성하면, 항상 해당 기술이나 문법이 어떤 배경으로 나오게 되었는지를 설명하는편 입니다. 무조건 "이게 좋다" 라는 말을 듣고 쓰는게 아니라, 어떠한 필요에 의해서 나오게 되었는지를 알면, 이를 200% 활용할 수 있다고 생각하기 때문입니다.

 아무튼, 여기까진 async/await이 나오게 된 배경을 설명드렸으니 종착역인 async/await에 대해 간단히 알아보고 마치도록 하겠습니다! 위의 Promise 객체를 만들어서 비동기 처리를 하는 것과의 가장 큰 차이점은 resolve(), reject(), .then(), .catch() 를 사용하지 않고도 비동기 처리가 가능하다는 것 입니다. 무슨말일까요? 정의와 예시 코드를 통해 다시 설명드리겠습니다 😁

 먼저 공식 문서의 설명을 보겠습니다. (MDN)

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

async

const promise = async () => {
	return '드디어 끝이 보인다!';
}

const message = promise();
console.log(message);
// console: Promise {<fulfilled>: '드디어 끝이 보인다!'}

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

await

const promise = async () => {
	return '드디어 끝이 보인다!';
}

const message = await promise();
console.log(message);
// console: 드디어 끝이 보인다!

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

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

const asyncMessage = () => {
	const message = await promise();
  	console.log(message);
} // incorrect: await은 async 함수 내에서만 사용할 수 있다.

const asyncMessage = async () => {
	const message = await promise();
  	console.log(message);
} // correct: async 함수 안 이므로 await 키워드를 붙일 수 있다.

async/await을 이용한 에러처리

 async/await의 가장 큰 특징은, 콜백패턴 이라던지, .then().catch()를 이용한다던가의 특정한 문법을 이용하는게 아니라 일반적인 동기 코드를 짜는 것과 같은 방식으로 짤 수 있다는 점 입니다.

따라서, 에러처리도 일반적인 try/catch 문을 통해서 할 수 있는 것이죠!

const promise = async () => {
	return '드디어 끝이 보인다!';
}

const asyncMessage = async () => {
	try {
    	const message = await promise();
      	console.log(message);
    } catch(err) {
    	console.log(`에러 발생 ${err}`);
    }
}

코드에서 쉽게 볼 수 있듯이 성공 즉, fulfilled 인 경우엔 try 내부에 코드를 작성하고, rejected 인 경우엔 catch 내부에 코드를 작성하여 에러처리를 간편하게 할 수 있습니다!

진짜 마지막입니다. wearOotd를 Promise와 async/await 조합을 통해 작성해 보겠습니다.

const wearOotd = (garment) => {
	return new Promise((resolve)=>{
		setTimeout(() => {
	    resolve(garment);
	  }, 500);
	});
};

const wearTodaysLook = async () => {
  var ootd = "";
  const _wearOotd = async (garment) => {
    ootd += (ootd ? "," : "") + (await wearOotd(garment));
  };
  await _wearOotd("반팔티");
  console.log(ootd);
  await _wearOotd("블레이저");
  console.log(ootd);
  await _wearOotd("데님팬츠");
  console.log(ootd);
  await _wearOotd("신발");
  console.log(ootd);
};

wearTodaysLook();

훨씬 깔끔해진 코드를 확인할 수 있습니다. 콜백지옥이나 프로미스체이닝 처럼 꼬리에 꼬리를 물고 깊어지는 구조가 아니라 일반적인 동기적 코드와 같은 구조기 때문에 가독성 측면에서 매우 유리합니다.

🙏 마치며

 어쩌면 다들 알고있을 법한 개념에 대해 설명하더라도, 항상 포스팅이 길어지는 이유는 다음과 같습니다. 아무것도 모르고 혼자 공부하던 때 가장 힘들었던 점이, 새로운 어려운 문법들 자체가 아니라, 그 문법들이 왜 존재하는지 왜 써야하는지에 대한 이유를 모른 채 공부하는게 가장 어려웠던 것 같습니다.

 따라서, 좀 길어지더라도 꼭 흐름과 함께 이해하시면 해당 개념과 더불어 연관된 개념들까지 오래 기억된다고 생각한다고 믿기 때문에 제 포스팅을 읽으면서 공부하는게 아니더라도, 흐름을 이해하려고 노력하는 공부를 하는 것을 정말 정말 추천드리고 싶습니다! 👏

긴 글 읽어주셔서 감사합니다 😁

🔖 reference

[⭐️ 비동기 처리, 흐름대로 이해하기]
(https://www.cookieparking.com/share/U2FsdGVkX19OhyU_Xf3ozX04E6yX1YEQ7acWErGeu7n3WsqTviMVRXgXGGlqMYhDdq7lf6v-Qfnln2NO9WYJTN1MUWEsOPWA5DM0Ee-vIs0)

profile
사실은 내가 보려고 기록한 것 😆

4개의 댓글

comment-user-thumbnail
2021년 4월 23일

이해가 너무나 잘 되는 글이었습니다 :)

1개의 답글
comment-user-thumbnail
2021년 4월 24일

이해가 정말 잘 돼요 감사합니다

1개의 답글