[JavaScript] Asynchronous(비동기) 방법을 알아보자 - Callback , Promise, Async - Await

알락·2022년 10월 5일
0
post-thumbnail

비동기


프로그래밍을 C로 시작한 사람은 자바스크립트가 살짝 혼란스럽다. 여러 이유가 있는데 그 중 하나는 이 비동기에 대한 개념에 직면했을 때다. 먼저 다음의 예시를 살펴보자.

console.log("Hello");

setTimeout(()=>{console.log("JavaScript")})

console.log("Nice to meet you!")

위의 코드가 어떻게 작동할지 예상을 해보기 바란다. 바로 다음과 같이 출력된다.

/* 출력예시 */

Hello

Nice to meet you!

JavaScript

C에서는 sleep()이라는 함수를 쓰면 프로그램이 그 명령 자리에서 일정 시간 멈춘 이후 바로 그 지점에서 다시 명령을 수행한다. 하지만 JavaScript에서 비슷한 기능을 수행하는 것 같은 함수 SetTimeout()은 그렇게 작동하지 않는 것처럼 보인다. 위의 예시가 대표적인 비동기 프로그래밍의 예시라고 할 수 있겠다.

As an asynchronous event-driven JavaScript runtime, Node.js is designed to build scalable network applications.

출처 : Node.js 웹페이지

위 인용 문구는 Node.js를 간단히 한 줄로 요약하여 설명하였다. 그 짧은 문장 중에서도 asynchronous라는 단어를 사용하였다는 것을 눈여겨보아야 한다.


비동기가 왜 필요할까

위와 같이 혼란스럽게만 하는 개념인 비동기를 JavaScript에서는 왜 채택해서 사용하는지 궁금해진다. 비동기의 필요성을 이해하기 위해서는 Blocking 상황을 알아야 한다.

Blocking

블로킹은 Node.js 프로세스에서 추가적인 JavaScript의 실행을 위해 JavaScript가 아닌 작업이 완료될 때까지 기다려야만 하는 상황입니다.

출처 : Node.js 블로킹 vs 논블로킹

카페로 예를 들면 다음과 같다.

블로킹 카페 예시

  1. 손님 1의 아메리카노 주문
    1.1. 아메리카노 음료 제작
    1.2. 손님 1에 아메리카노 제공
  1. 손님 2의 카페라떼 주문
    2.1 카페라떼 음료 제작
    2.2 손님 2에 카페라떼 제공

위와 같이 카페 시스템이 돌아간다면, 손님 2는 손님 1의 음료 주문부터 제작, 제공까지 모든 과정을 기다리고 난 이후에야 겨우 주문을 시작할 수 있다. 그럼 손님 2가 음료를 받기까지 시간이 오래 걸린다.

논블로킹 카페 예시

  1. 손님 1의 아메리카노 주문
  2. 손님 2의 카페라떼 주문
    1.1. 아메리카노 음료 제작
    1.2. 손님 1에 아메리카노 제공
    2.1. 카페라떼 음료 제작
    2.2. 손님 2에 카페라떼 제공

앞서 소개한 카페 시스템을 위와 같이 바꾼다면, 손님 2는 음료가 나오기 전까지 다른 일을 하거나 자리에서 쉴 수 있다. 블로킹 카페의 예시처럼 어떤 한 작업을 끝마치기 위해 다른 작업을 시작조차 못 하는 것이 블로킹 상황이다.
JavaScriptHTML/CSS로 만들어진 웹페이지를 동적으로 상호작용하기 위해 만들어졌다. 그리고 JavaScript를 통해 우리는 다른 서비스에서 데이터를 가져와서 처리하거나 현재 화면에 렌더링하여 볼 수 있게 하기까지 왔다. 이 때 웹페이지에 너무 많은 이벤트가 발생하거나, 데이터가 너무 커서 가져오는데 시간이 걸려 JavaScript의 실행이 멈추면 상당한 불편을 겪게 될 것이다. 이 때문에 JavaScript논블로킹을 지향하고, 비동기적으로 작동한다.

cf. 비동기를 대하는 자세(중요)

JavaScript가 애초에 비동기적으로 작동을 한다는 것을 이해했다. 그래서 지금 우리가 하고자 하는 것이 무엇인지 잘 이해하고 넘어가야 한다. 지금부터 여기서 소개하는 모든 방법은 비동기적으로 프로그램을 구현하는 것이 아니라, 비동기적으로 작동하는 환경(아마 JavaScript)에서 어떻게 순서를 제어하여 구현할 수 있는지를 알아보는 것이다.


콜백(Callback)

const printString = (string, callback) =>{
	setTimeout(
      () = > {
        console.log(string)
  		callback()
      },
      Math.floor(Math.random() * 100) + 1
    )
}

const printAll = () => {
	printString("A", () => {
    	printString("B", () => {
    		printString("C", () => {})
    	})
	})
}

printAll();

랜덤으로 setTimeout()에 시간을 설정하여 각각의 문자를 특정 시간 이후에 console에 띄우는 간단한 작업이다. 이런 식으로 콜백을 사용하면 함수를 호출한 이후에야 명령이 실행되니까 순서를 제어하여 사용할 수 있다.
하지만 이런 식으로 프로그램을 작성하면 큰 불편함이 생긴다. 바로 콜백 지옥이다. 위에서도 그 여지를 살펴볼 수 있는데, 만약 "Z"까지 순서대로 띄우게 되면 위에 코드는 어떻게 작성이 되어야 할까? "Z"를 호출하는 함수 실행을 작성할 때까지 계속해서 들여쓰기를 하며 작성을 해야 할 것이다. 이는 작성한 코드의 가독성을 해친다.


프로미스(Promise)

const printString = ()=>{
  return new Promise((resolve)=>{
    	console.log("A")
    	resolve();
	})
}

const printAll = () => {
	printString()
  	.then(()=>{console.log("B")})
  	.then(()=>{console.log("C")})
  	.then(()=>{console.log("D")})
}

printAll();

Promise(프로미스, 이하 Promise)는 콜백지옥을 벗어날 수 있는 구원이다. 아무 함수에 Promise를 지원하게 만들고 싶다면 반환값을 새로운 Promise로 주기만 하면 된다.
Promiseresolvereject라는 메소드를 사용할 수 있다. resolvePromise에 넘겨준 콜백함수의 실행이 잘 되었을 때 호출하는 Promise 메소드다. rejectresolve와 반대로 실행 중 에러나 문제가 생겼을 때 호출하는 메소드다.
Promise를 리턴하는 함수를 만들게 되면, 해당 함수를 실행하고 난 이후에 then메소드를 사용할 수 있다. then 메소드에는 2개의 콜백 함수를 넣을 수 있는데, 그 중 첫 번째 콜백함수가 앞선 promiseresolve를 통해서 인자를 전달할 때 실행되게 된다. 정상적으로 작동한다면 첫 번째 콜백함수의 명령들이 실행될 것이다.
위 코드는 굳이 Promise를 쓰며 우리가 원하는 데로 A B C D 를 출력시키는 코드다.


Async-Await

const printString = (string) =>{
    return new Promise(resolve=>{
        setTimeout(
          () => {
            console.log(string); 
            resolve();
          },
          Math.floor(Math.random() * 100) + 1
        )
    })
	
}

const printAll = async () => {
	await printString("A");
    await printString("B");
    await printString("C");
    await printString("D");
}

printAll();

Promise마저 반복되는 코드 작성이 생겨서 마련한 asyncawait. 하지만 작동 방식은 Promise의 원리를 그대로 따른다. async를 앞에 붙여서 생성한 함수는 무조건 Promise를 반환한다. 만약 만든 함수가 Promise가 아닌 값을 반환하여도 async는 리턴값을 Promise로 래핑하여서 반환한다.
awaitasync가 붙은 함수 내에서만 사용이 가능하다. awaitPromise가 처리될 때까지 기다리는 함수이다. Promise가 이행이 된 이후 await이 기다리고 있었던 실행문을 실행하게 된다.
또한 await의 가장 큰 장점은 Promise 안에 갇혀있는 값을 빼오기 좋은 방법이라는 것이다.
현재로서는 순서를 제어할 수 있게 하는 수단 중, 가장 가독성이 좋은 방법이라고 생각된다.


가장 많이 한 실수

/* 1. 식별자 선언 및 배열 초기화 */
let resultArr = []

/* 2. 값을 인자로 받고 프로미스를 반환하는 함수 */
const readyAddNum = (num)=>{
  return new Promise((resolve)=>{
    resolve(num);
  })
}

/* 3. 숫자 추가 후 현재 배열의 상태를 출력 */
readyAddNum(5)
.then(num=>{
  resultArr.push(num);
  return resultArr;
})
.then(resultArr => console.log(resultArr));

/* 4. 숫자 추가 후 현재 배열의 상태를 출력 */
readyAddNum(3)
.then(num=>{
  resultArr.push(num);
  return resultArr;
})
.then(resultArr => console.log(resultArr));

/* 5. 전역에서의 배열 상태 확인 */
console.log(resultArr)

내가 한참 헤매었던 코드 작성이다. resultArr에 숫자만 push하는 아주 간단한 구현이다. 결과적으로 나는 전역에서 숫자가 추가되어있는 resultArr을 사용할 수 있게 만들고 싶었다.
결과적으로 나는 전역에서 resultArr을 호출했을 때 빈 배열을 확인하게 되었다. 나머지 출력은 숫자가 추가된 두 번의 [5,3] 콘솔 출력이 이어졌다.
왜 이런 현상이 생각나는지는 생각보다 간단하다. JavaScript는 비동기적으로 작동하기 때문이다. 위에 쓰인 코드는 순서대로 실행이 된다. 그 중 Promise로 구현이 되어있는 함수의 실행은 잠시 뒤로 미룬다. 결국 여기서 실제로 실행되는 코드는 1번2번, 그리고 5번이 되겠다. 그러니 내가 마지막으로 확인한 배열은 아직 Promise에 의하여 추가되지 못한 배열이었다.
우리가 Promise를 쓰는 이유를 잘 생각해보자. Promise를 통해서 우리는 비동기 적인 코드 실행에 억지로 순서를 넣는다. 하지만 불가역적인 JavaScript의 큰 실행 흐름에서 Promise는 잠시 실행을 유보시킨다. 이후 처리가 끝난 Promise들부터 우리는 원했던 순서대로 코드를 실행할 수 있게 된다. PromiseAsync/Await을 사용하고 있을때는 꼭 우리가 JavaScript의 실행 순서에서 벗어나 있다는 것을 명심해야 한다.

profile
블록체인 개발 공부 중입니다, 프로그래밍 공부합시다!

0개의 댓글