Js 초보가 쓰는 Async Await 처음부터 이해하기
JS 초보가 쓰는 async await 처음부터 이해하기
글을 쓴 이유
이미 인터넷에 async와 await에 관련된 글들이 많습니다. 그럼에도 불구하고 이 글이 쓰는 이유는 2가지 입니다.
- 기존 글들은 js를 기준으로 쓰여있기 때문에 논블로킹을 기본으로 설정하고 블로킹으로 바꾸는 방법에 집중하는 경우가 많았습니다.
- js가 어떻게 논블로킹io를 구현했는지(콜백모델), 기존 언어와 차이점을 제시하고자 합니다.
Blocking IO와 Non-Blocking IO의 차이
컴퓨터가 하는 일은 크게 2가지로 나뉩니다. 바로 CPU가 하는 작업과 I/O 작업입니다. 먼저 CPU 작업은 객체 생성, 연산 등의 작업을 수행합니다. IO작업은 파일 읽기 및 쓰기, 네트워크 요청 등 메모리(RAM) 외부에서 데이터를 읽어오고 (Input) 외부로 쓰는(Out) 하는 작업을 말합니다. 여기서 중요한 점은 이 2개의 작업은 동시에 진행이 가능하다는 점입니다. 이를 테면 컴퓨터에서 네트워크 요청을 할 때는 그 응답을 받을 때 까지 cpu는 다른 일을 해도 상관없습니다. 할 일이 없다면 그냥 놀아도 됩니다. js에서는 fetch
와 같은 작업이 I/O 특히 Network I/O 작업에 속합니다.
Blocking IO와 Non-Blocking IO는 이 IO 작업을 처리하는 방식이 다릅니다. 먼저 Blocking IO를 사용하는 방법에 대해 살펴보겠습니다.
f = open('example.txt', 'r') # example.txt를 읽어서 파일 객체 f를 리턴해준다
print(f.readline()) # 한 줄을 읽어서 화면에 출력한다
파이썬의 open 함수는 blocking io를 따르는 함수입니다. 그 말은 open
함수는 example.txt 파일을 읽을 때(IO 작업)까지 기다렸다가 (Blocking) 다 읽으면 f
에 파일 객체를 돌려준다는 뜻입니다. 저 파일이 커서 읽는데 10초가 걸린다면 python은 10초 동안 저 위치에서 기다립니다. js를 제외한 많은 언어들은 기본적으로 blocking io를 많이 사용하기 때문에 당연하게 느껴질 것입니다.
그렇다면 Non-Blocking IO는 무엇일까요? Blocking IO가 IO 작업이 끝날때 까지 기다렸다가 결과값을 리턴했었다면, Non Blocking IO는 IO작업이 끝날 때까지 기다리지 않고 값을 리턴해줍니다. 어떻게 IO 작업이 안끝났는데 값을 리턴해 줄 수 있을까요? 바로 실제 결과값 아니라 쿠폰 같은 것을 리턴해줍니다. js서는 이게 바로 Promise
이고 자바나 파이썬에서는 Future
라는 이름으로 불립니다. 미래에 결과값을 돌려주기로 약속한 쿠폰이니, 둘 다 적절한 이름이라고 생각합니다. 이 글에서는 앞으로 Promise
와 Future
같은 객체를 ‘쿠폰객체’, 그 객체들이 실제로 가지고 있는 값을 ‘상품값'이라고 부르겠습니다. 편의를 위한 이름이고 실제로는 이렇게 부르지 않으니 주의해주세요.
Promise와 Future가 ‘상품값'을 돌려받는 방법의 차이
그렇다면 그 ‘쿠폰객체'를 이용해서 실제 ‘상품값'도 받아야겠죠? Promise
와 Future
는 ‘상품값'을 돌려 받는 방법이 다릅니다. 결론부터 말하자면 Future
에는 상품값을 돌려받는(return) 방법이 있지만, Promise
에는 상품값을 돌려받는(return) 방법이 없습니다. 그리고 그 차이가, js에서 Non-Blocking IO를 구현한 방법입니다.
Future의 result 메서드
먼저 파이썬의 Future
부터 알아보겠습니다
from concurrent.futures import *
with ThreadPoolExecutor(max_workers=1) as excutor:
future = excutor.submit(open, "example.txt", "r") # example.txt를 다 읽을 때 까지 기다리지 않고 Future 객체를 돌려준다 : Non-Blocking IO
f = future.result() # future에게 example.txt 의 파일 객체를 달라고 요청한다. example.txt를 다 읽을 때 까지 기다린다 : Blocking IO
print(f.readline())
왜 자바스크립트 이야기는 안하고 파이썬만 주구장창 이야기하나 싶겠지만, js에는 없는 것, 그리고 왜 없는지을 설명드리기 위한 것이니 파이썬을 모르시는 분이라면 코멘트 위주로 읽고 조금만 참아주세요.
위 코드에서 future 객체는 Non-Blocking IO의 결과로 쿠폰객체입니다. future.result()
는 쿠폰을 이용해서 실제 ‘상품값'을 돌려받는 방법입니다. 만약 result
메서드가 실행될 때 아직도 example.txt를 읽는 IO작업이 끝나지 않았다면 어떻게 될까요? result
메서드는 Blocking IO이기 때문에 다 읽을 때 까지 기다렸다가 결과값 f
를 리턴해 줍니다.
그렇다면 여기서 의심이 생깁니다. 아니 어차피 기다릴거면 왜 Future
와 같이 귀찮은 일을 할까요? 심지어 Blocking IO보다 이해하기 더 어려워보이기까지 합니다. 이유는 아까 위에서 말한 것 처럼 IO작업과 CPU 작업은 동시에 진행할 수 있기 때문입니다. IO 작업이 끝날 때까지 기다리는 게 아니라, 그 사이에 IO작업의 결과값이 필요없는 다른 CPU 작업을 하는겁니다. 그러면 프로그램을 더 일찍 끝나게 할 수 있습니다.
future = excutor.submit(open, "example.txt", "r") # Non-Blocking IO로 IO 작업 시작
some_cpu_job1() # cpu 작업을 실행한다. example.txt는 필요 없는 작업
some_cpu_job2() # 이 때 IO가 example.txt를 읽는 작업을 하고있지만 동시에 cpu 작업을 한다
f = future.result() # 상품값을 가져온다
print(f.readline()) # result의 결과값이 필요하기 때문에 항상 future.result 뒤에 실행
이 코드에서 example.txt를 읽는 IO작업을 하면서 동시에 some_cpu_job
도 실행하기 때문에 IO가 끝날 때 까지 아무것도 안하고 기다리는 Blocking IO보다 빨리 끝나게 됩니다. 그치만 이 방법에도 단점이 존재합니다. 바로 개발자가 IO 작업이 언제 끝날지 예측해야 한다는 점입니다. 이것이 단점인 이유는
future.result
를 너무 빠르게 호출해서 IO 작업이 끝나기도 전이라면 Blocking이 되어 CPU가 쉬게 됩니다.(좀 더 정확하게 말하자면 CPU가 아니라 쓰레드가 쉬게 되는 것이지만, 쉬운 이해를 위해 CPU가 쉰다고 표현했습니다. 쓰레드의 개념을 아신다면 쓰레드가 쉰다고 생각하면 됩니다.)
future = excutor.submit(open, "example.txt", "r") # Non-Blocking IO로 IO 작업 시작
some_cpu_job1() # cpu 작업이 IO작업이 끝나기 전에 끝난다
f = future.result() # 결과값을 가져올 떄까지 쓰레드가 Blocking된다 -> 비효율적
print(f.readline())
some_cpu_job2()
some_cpu_job3()
future.result
를 너무 늦게 호출한다면, 이 결과를 출력하는게 더 중요할 수도 있지만 뒤로 미루어지게 됩니다. 예를 들어 js에서 fetch를 통해 결과를 가져왔음에도 이를 출력하지 않고 기다린다면 사용자가 답답할 수 있을겁니다
future = excutor.submit(open, "example.txt", "r") # Non-Blocking IO로 IO 작업 시작
some_cpu_job1() # cpu 작업이 IO작업이 끝나기 전에 끝난다
some_cpu_job2() # 이 시점에서 IO 작업이 끝났다면
some_cpu_job3() # 그럼에도 불구하고 이 cpu 작업을 기다려야 한다
f = future.result() # 출력이 Blocking일 때보다 늦어진다 -> 응답성이 떨어진다
print(f.readline())
그럼 언제 Future
와 같은 쿠폰객체한테 상품값을 가져오는게 가장 좋을까요? 바로 ASAP, IO 작업이 끝나자 마자 값을 가져오는게 가장 좋은 방법일 것입니다. 드디어 js가 나옵니다. js는 이렇게 IO 작업이 끝나고 최대한 빠르게 실행하기 위해 콜백모델을 도입합니다. 개발자가 IO 작업이 언제 끝날지 추측하는게 아니라, IO 작업이 끝날 때 자동으로 실행할 함수(콜백함수)를 등록합니다. 그러면 js가 알아서 IO 작업이 끝날 때 이 함수를 실행해줍니다. 이게 바로 js promise
의 then
메소드 입니다. (어떻게 자바스크립트가 이벤트루프 모델을 구현하려 Never Blocking을 달성했는지는, 또 매우 복잡한 주제이므로 여기서 다루지 않겠습니다)
Promise의 then 메서드
위에서도 이야기했지만, Promise
는 상품값을 return 받는 방법이 없습니다. Future
에서 살펴보았듯이 상품값을 돌려받는 방법은 어쩔 수 없이 Blocking IO
를 유발합니다. Never Blocking을 표방하는 js에서는 구현할 수 없는 방법입니다. 따라서 js는 삼품값을 돌려주는 방법을 포기하고, 상품값을 매개변수로 사용하는 함수(콜백함수)를 넘겨주는 방식을 선택합니다. 이로써 js는 IO 작업이 끝났을 때, 그 상품값을 콜백함수에 매개변수로 넣어 실행해 줍니다. 개발자는 이 ‘상품값'을 직접적으로 돌려받는 방법을 포기한 대신, 간접적으로 이 ‘상품값'을 사용할 수 있는 방법(콜백함수)를 얻었습니다. 덕분에 가능한 빨리 값을 활용할 수 있게 되었습니다.
function returnDataAfter2Seconds() { // 2초 뒤에 값을 돌려주는 IO 작업
return new Promise(resolve => { // 2초 동안 Blocking 되지 않기 위해 Promise 객체를 return 한다
setTimeout(() => {
resolve('data');
}, 2000);
});
}
f = returnDataAfter2Seconds();
// const data = f.result(); Blocking IO이기 때문에 js에는 존재하지 않는다
f.then((data) => console.log(data)); // 대신 콜백 함수를 넘겨서, 결과값을 매개변수로 받는다
// > "data"
Promise
의 then
메서드 안에서 return 하면 되지 않냐구요? 아쉽지만 then
메서드 안에서 return
할 때는 새로운 Promise
를 만들어서 돌려줍니다. 그렇기 때문에 Promise
가 then
으로 체이닝이 가능한 것입니다.
function returnDataAfter2Seconds() { // 2초 뒤에 값을 돌려주는 IO 작업
return new Promise(resolve => { // 2초 동안 Blocking 되지 않기 위해 Promise 객체를 return 한다
setTimeout(() => {
resolve('data');
}, 2000);
});
}
f = returnDataAfter2Seconds();
const dataExpected = f.then((data) => {return data;}); // 결과값을 돌려받기 기대한다
console.log(dataExpected) // Promise를 리턴한다
// > [object Promise]
await
으로 돌려받으면 되지 않냐구요? await
은 문법적으로 Blocking IO처럼 보이게 할 뿐 실제로는 Promise
를 이용합니다. 이 부분은 뒤에서 다시 살펴볼 것입니다.
이렇게 js가 알아서 결과값을 가져와서 실행하기 때문에 future의 result
와 같이 개발자가 직접 결과를 가져오게 요청할 필요가 없어졌습니다. 그래서 promise
에는 result
와 같이 직접 결과값을 개발자가 가져오는 메소드가 없습니다. 이 것이 Future와 Promise가 값을 가져오는 차이입니다. js에는 개발자가 요청할 수 있는 Blocking IO 메서드가 존재하지 않습니다!. 혹시 지금 ‘await은 blocking하는거 아닌가?’ 라고 생각하셨나요? Await은 JS를 Blocking하지 않습니다. JS에 Blocking IO를 할 수 있는 방법은 존재하지 않습니다. 이와 관련된 부분은 뒤에 ‘await을 top level에서 쓰지 못하는 이유'와 함께 이야기 하겠습니다.
Async와 Await은 syntatic sugar일 뿐이다.
앞에서 js가 Promise
와 python의 Future
의 차이를 살펴보며 Non Blocking IO
를 어떻게 구현했는지 살펴봤습니다. 그럼 Async와 Await는 무엇일까요? 답부터 말하자면 async, await은 promise의 syntatic sugar일 뿐입니다.
기존의 Promise
와 Then
을 간단하게 사용하기 위해 만들어졌습니다. 여기서 간단하게 사용한다는 것은 Blocking IO처럼 보이게 한다는 것입니다. 그렇기 때문에 await
이 Blocking IO 처럼 오해하게 만듭니다. 그러나 실제로는 js에 Blocking IO는 존재하지 않습니다.
먼저 async
부터 이야기해보겠습니다. async
는 promise
를 리턴하는 함수입니다. async
를 붙이면 함수는 Non Blocking이 되어 promise
를 리턴합니다. 따라서 async 함수를 실행할 때는 then
과 함께 사용해야 합니다.
async function returnPromiseResolveOne() {
return 1;
}
returnPromiseResolveOne().then(alert);
위에 코드를 보면 함수가 return 1
로 1을 리턴하는 것 처럼 보이지만 실제로는 reslove될 때 1을 돌려주는 promise
를 리턴해 줍니다. 따라서 복잡하게 Promise 객체를 만들 필요 없이 쉽게 promise
를 사용할 수 있게 되었습니다.
다음은 await
입니다. 먼저 await
은 async
함수 안에서만 사용되어야 합니다. 이 이유는 잠시 후에 설명드리겠습니다. await
은 promise
를 리턴하는 함수와 함께 사용되어야 합니다. 즉 async
함수 앞에도 붙여서 쓸 수 있습니다. await
은 기존에 then
을 붙여 처리해야 할 코드를 Blocking IO
처럼 보이도록 합니다. 따라서 다음의 2개 코드는 동일한 코드입니다.
async function alertResult() {
// Blocking IO처럼 보이게 한다.
const result = await callApi(); // callApi는 async 함수이거나, promise를 리턴하는 함수다.
console.log(result); // 결과를 출력한다
}
async function alertResult() {
callApi() // Promise를 이용한 Non Blocking IO 처리
.then(result => {
console.log(result)
})
}
이렇게 실제로는 함수가 리턴한 promise
가 then
을 호출할 때 사용될 함수(result => {console.log(result)
) 를, 그냥 연속적으로 작성하기만 해도 알아서 만들어 줍니다. 덕분에 코드가 훨씬 간결해졌습니다. 하지만 await
은 promise
와 then
을 쉽게 사용하기 위해, Blocking IO처럼 보이기 위해 만들었기 때문에 항상 promise
를 리턴하는 함수 앞에 사용되어야 합니다.
왜 await은 탑레벨에서는 사용할 수 없을까?
await
은 async
함수 내에서만 사용하도록 약속 되어있습니다. 다음과 같은 코드는 작동할까요?
function returnResult() { // 안됨
const result = await callApi();
return result + 10;
}
네 안됩니다. 그리고 이 약속은 합당한 이유로 느껴집니다. 위의 코드는 아래의 코드와 같습니다.
async function returnResult() { // Promise를 리턴
const result = await callApi();
return result + 10;
}
function returnResult() { // Promise를 리턴
return callApi()
.then(function(result) {
return result + 10
});
}
이처럼 실제로 await을 사용한 코드는 항상 Promise
를 리턴하게 됩니다. 앞에서 async
가 붙은 함수는 모든 값을 Promise
로 리턴해준다고 말했습니다. 그러니 await
을 사용하는 함수는 async
를 붙이자는 약속은 합리적으로 느껴집니다.
그럼 await
는 왜 탑레벨에서 사용할 수 없을까요? 물론 ‘탑레벨에 async
를 붙일 수 없으니, await
을 사용할 수도 없다’ 라고 생각해도 좋습니다. 그렇지만 다시 한 번 js에는 Blocking IO가 없다는 점을 떠올려 봅시다.(여기서는 Node.js가 아닌 js를 이야기 하고있습니다)
다음 예시처럼 만약 탑레벨에서 await
을 썼다고 가정해봅시다
function resolveAfter3Seconds() { // 3초 동안 대기하게 함
return new Promise(resolve => {
setTimeout(() => {
resolve('resolved1');
}, 3000);
});
}
function resolveAfter2Seconds() { // 2초 동안 대기하게 함
return new Promise(resolve => {
setTimeout(() => {
resolve('resolved2');
}, 2000);
});
}
async function printAsync1() {
console.log('calling1');
const result = await resolveAfter3Seconds();
return result;
}
async function printAsync2() {
console.log('calling2');
const result = await resolveAfter2Seconds();
return result;
}
const result1 = await printAsync1(); // 실제로는 불가능함
console.log(result1);
const result2 = await printAsync2(); // 실제로는 불가능함
console.log(result2);
// > "calling1"
// > "resolved1"
// > "calling2"
// > "resolved2"
아까 python에서 future.result()
와 같이 await
이 작용하는 것을 알 수 있을 있습니다. 그치만 future.result()
는 Blocking IO이고 js는 Non Blocking IO 모델이기 때문에 이렇게 사용이 불가능합니다. 그래서 top level에서 await
을 쓸 수도 없습니다. 스택오버플로우에도 비슷한 질문들이 올라왔지만, 답변은 항상 같습니다. 그런 방법은 없습니다 (https://stackoverflow.com/questions/29440632/how-to-block-for-a-javascript-promise-and-return-the-resolved-result). 탑 레벨에서는 항상 then
을 사용해서 Non-Blocking IO로 Promise
를 처리해야 합니다.
printAsync1().then(function(result) {
console.log(result);
}
printAsync2().then(function(result) {
console.log(result);
}
또 말하지만 js에는 Blocking IO가 없습니다. async
함수 내에서 await
을 사용할 떄는 Blocking처럼 보이지만, 실제로는 Promise를 사용하기 때문에 js에서는 Blocking 되지 않고 다른 함수를 실행합니다.
그럼 Async, Await이 있으니 Promise를 사용할 일이 없을까?
아니요, 안타깝게도 아직까지는 그렇지 않은 것 같습니다. 대표적인 예시는 async
함수내에서 concurrent하게 여러 작업을 진행하고 싶다면, promise
의 all()
을 사용해야 합니다.
async function printResult() {
const data1 = wait callApi1(); // 5초 걸림
const data2 = wait callApi2(); // 3초 걸림
console.log(data1, data2); // 8초 걸림
}
이런 작업은 callApi2가 data1을 사용하지 않기 때문에, callApi1을 대기할 필요가 없음에도, 대기하게 됩니다. 이럴 경우 callApi1와 callApi2를 동시에 대기하기 위해서는 Promise.all()
을 사용해야 합니다
async function printResultConcurrent() {
const data = wait Promise.all([callApi1(), callApi2()]); // 둘다 동시에 기다리기 때문에 가장 긴 5초를 대기
console.log(data); // 5초 걸림
}
이처럼 Async
, Await
은 단순히 Promise의 syntatic sugar이며, Promise의 모든 기능을 가지고 있지 않기 때문에, 실제로는 Promise
역시 사용할 줄 알아야 합니다.
정리
- js에는 Blocking IO 함수가 존재하지 않고, 따라서
Promise
와then
이라는 콜백모델을 선택했다 async
,await
은 이 Promise를 쉽게 사용할 수 있게 만든 syntatic sugar이다- 결국
promise
도 알아야 한다.
참고자료
- https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise/all
- https://medium.com/@constell99/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EC%9D%98-async-await-%EA%B0%80-promises%EB%A5%BC-%EC%82%AC%EB%9D%BC%EC%A7%80%EA%B2%8C-%EB%A7%8C%EB%93%A4-%EC%88%98-%EC%9E%88%EB%8A%94-6%EA%B0%80%EC%A7%80-%EC%9D%B4%EC%9C%A0-c5fe0add656c
- https://ithub.tistory.com/223
- https://medium.com/javascript-in-plain-english/async-await-javascript-5038668ec6eb
- https://stackoverflow.com/questions/29440632/how-to-block-for-a-javascript-promise-and-return-the-resolved-result