프로그래밍을 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
를 간단히 한 줄로 요약하여 설명하였다. 그 짧은 문장 중에서도 asynchronous
라는 단어를 사용하였다는 것을 눈여겨보아야 한다.
위와 같이 혼란스럽게만 하는 개념인 비동기를 JavaScript
에서는 왜 채택해서 사용하는지 궁금해진다. 비동기의 필요성을 이해하기 위해서는 Blocking
상황을 알아야 한다.
블로킹은 Node.js 프로세스에서 추가적인 JavaScript의 실행을 위해 JavaScript가 아닌 작업이 완료될 때까지 기다려야만 하는 상황입니다.
카페로 예를 들면 다음과 같다.
블로킹 카페 예시
- 손님 1의 아메리카노 주문
1.1. 아메리카노 음료 제작
1.2. 손님 1에 아메리카노 제공
- 손님 2의 카페라떼 주문
2.1 카페라떼 음료 제작
2.2 손님 2에 카페라떼 제공
위와 같이 카페 시스템이 돌아간다면, 손님 2는 손님 1의 음료 주문부터 제작, 제공까지 모든 과정을 기다리고 난 이후에야 겨우 주문을 시작할 수 있다. 그럼 손님 2가 음료를 받기까지 시간이 오래 걸린다.
논블로킹 카페 예시
- 손님 1의 아메리카노 주문
- 손님 2의 카페라떼 주문
1.1. 아메리카노 음료 제작
1.2. 손님 1에 아메리카노 제공
2.1. 카페라떼 음료 제작
2.2. 손님 2에 카페라떼 제공
앞서 소개한 카페 시스템을 위와 같이 바꾼다면, 손님 2는 음료가 나오기 전까지 다른 일을 하거나 자리에서 쉴 수 있다. 블로킹 카페의 예시처럼 어떤 한 작업을 끝마치기 위해 다른 작업을 시작조차 못 하는 것이 블로킹
상황이다.
JavaScript
는 HTML
/CSS
로 만들어진 웹페이지를 동적으로 상호작용하기 위해 만들어졌다. 그리고 JavaScript
를 통해 우리는 다른 서비스에서 데이터를 가져와서 처리하거나 현재 화면에 렌더링하여 볼 수 있게 하기까지 왔다. 이 때 웹페이지에 너무 많은 이벤트가 발생하거나, 데이터가 너무 커서 가져오는데 시간이 걸려 JavaScript
의 실행이 멈추면 상당한 불편을 겪게 될 것이다. 이 때문에 JavaScript
는 논블로킹
을 지향하고, 비동기적
으로 작동한다.
cf. 비동기를 대하는 자세(중요)
JavaScript
가 애초에 비동기적으로 작동을 한다는 것을 이해했다. 그래서 지금 우리가 하고자 하는 것이 무엇인지 잘 이해하고 넘어가야 한다. 지금부터 여기서 소개하는 모든 방법은 비동기적으로 프로그램을 구현하는 것이 아니라, 비동기적으로 작동하는 환경(아마JavaScript
)에서 어떻게 순서를 제어하여 구현할 수 있는지를 알아보는 것이다.
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"를 호출하는 함수 실행을 작성할 때까지 계속해서 들여쓰기를 하며 작성을 해야 할 것이다. 이는 작성한 코드의 가독성을 해친다.
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
로 주기만 하면 된다.
Promise
는 resolve
와 reject
라는 메소드를 사용할 수 있다. resolve
는 Promise
에 넘겨준 콜백함수의 실행이 잘 되었을 때 호출하는 Promise
메소드다. reject
는 resolve
와 반대로 실행 중 에러나 문제가 생겼을 때 호출하는 메소드다.
Promise
를 리턴하는 함수를 만들게 되면, 해당 함수를 실행하고 난 이후에 then
메소드를 사용할 수 있다. then
메소드에는 2개의 콜백 함수를 넣을 수 있는데, 그 중 첫 번째 콜백함수가 앞선 promise
가 resolve
를 통해서 인자를 전달할 때 실행되게 된다. 정상적으로 작동한다면 첫 번째 콜백함수의 명령들이 실행될 것이다.
위 코드는 굳이 Promise
를 쓰며 우리가 원하는 데로 A B C D
를 출력시키는 코드다.
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
마저 반복되는 코드 작성이 생겨서 마련한 async
와 await
. 하지만 작동 방식은 Promise
의 원리를 그대로 따른다. async
를 앞에 붙여서 생성한 함수는 무조건 Promise
를 반환한다. 만약 만든 함수가 Promise
가 아닌 값을 반환하여도 async
는 리턴값을 Promise
로 래핑하여서 반환한다.
await
은 async
가 붙은 함수 내에서만 사용이 가능하다. await
은 Promise
가 처리될 때까지 기다리는 함수이다. 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
들부터 우리는 원했던 순서대로 코드를 실행할 수 있게 된다. Promise
와 Async
/Await
을 사용하고 있을때는 꼭 우리가 JavaScript
의 실행 순서에서 벗어나 있다는 것을 명심해야 한다.