<필수 선행 지식>
본 글을 읽기 전에 Promise 에 대해 미리 알고 계셔야 합니다.
안녕하세요 파일입니다. 이전에 Promise를 통해 CallBack 지옥을 .then() 과 .catch() 를 이용해 개선시킬 수 있었습니다. 이제는 조금 더 나아가서 async await를 통해 Promise로 작성된 코드들을 한번 더 개선시켜 보겠습니다.
* 여기서 개선되는 것은 대부분 가독성 입니다.
Async & Await이란?
async 와 await은 Promise를 조금 더 간편하게 사용 할 수 있도록 도와주며 동기적으로 실행되는 것 처럼 보이게 하는 문법입니다.
async와 await은 새로운 것이 추가된 것이 아니며 기존에 존재하는 Promise를 쉽게 사용할 수 있게 해줄뿐인, 즉 Promise의 Syntatic sugar 입니다.
그렇기에 Promise를 잘 모르는데 C# 같은걸로 개발하다가 넘어와 JS를 겉핥기로 배워서 async await 을 남발하면 문제가 생기는 경우가 있을 수 있습니다. (사실 예전 제 얘기입니다 ㅎㅎ;;) 그러므로 Promise를 잘 이해하고 와서 async await의 달달한 문법을 사용해봅시다.
Syntatic sugar(문법 설탕) 이란?
문법적 기능은 그대로인데(같은데) 사람이 사용하기 편하게 제공되는 프로그래밍 문법입니다.
기존에 존재하던 문법을 함수 등으로 감싸서 조금 더 간편하게 바꿔서 사용할 수 있도록 합니다.
문법에 설탕을 뿌려 사람에게 더 달달하게 하는 것이죠.
ex) i++ 는 실제로 i = i +1 과 동일한 표현이며 Syntatic Sugar 라고 할 수 있습니다. (코드를 더 간편하게 작성 가능)
Async & Await 사용 방법
아까도 말했듯이 async await은 Promise를 깔끔하게 사용할 수 있는 방법입니다.
그렇다고 해서 무조건 Promise를 다 없애고 async 와 await으로 대체해야 한다는 뜻은 아닙니다.
Promise를 유지해야 좋은 경우도 있고 async await으로 변환해서 써야 좋은 경우도 있습니다.
우선 async await 도입전에 Callback 과 Promise 를 사용했던 모습을 먼저 간략하게 알아보겠습니다.
setTimeout(() => console.log("job complete"), 1000);
비동기 처리에서 단골 손님으로 등장하는 setTimeout() 을 가져왔습니다.
setTimeout() 은 JS 환경 대부분에서 지원하는 것으로 일종의 타이머라고 보시면 되는데, 위 코드를 해석해보면 1초 있다가 (1000ms == 1sec) 들어온 함수 () => console.log("job complete") 를 실행하라는 의미가 됩니다!
setTimeout(() => {
console.log("job1");
setTimeout(() => {
console.log("job2");
setTimeout(() => {
console.log("job3");
}, 1000);
}, 1000);
}, 1000);
setTimeout() 은 비동기적으로 작동하는 녀석입니다. 만약에 1초 단위로 job1, job2, job3을 차례대로 출력하고 싶다면 위와 같은 코드를 작성해야 합니다.
기본적으로 "비동기 작업" 이란 결과를 기다리지 않고 그대로 넘어가기 때문에 순서를 제어하려면 콜백 함수를 제공해서 그 함수가 나중에 실행될 수 있도록 하고, 또 그 콜백 함수 안에 비동기 함수와 콜백함수 , 다시 또 콜백함수 안에 비동기 함수와 콜백함수... 를 등록해서 작업 순서를 제어해야 합니다.
위 코드의 경우 정말 간단한 코드인데도 가독성이 끔찍해진 것을 볼 수 있습니다.
JS는 Promise 라는 도구를 제공하여 이런 상황을 타파하고자 하였습니다.
function Timer(time) {
return new Promise((resolve, reject) => {
setTimeout(resolve, time);
});
}
Timer(1000)
.then(() => {
console.log("job1");
return Timer(1000);
})
.then(() => {
console.log("job2");
return Timer(1000);
})
.then(() => {
console.log("job3");
});
setTimeout() 은 기본적으로 Promise를 반환하지 않으므로 Timer 라는 함수를 만들어서 setTimeout() 을 new Promise() 로 감쌌습니다. 이제 저 Timer라는 함수는 Timer(time) 으로 호출하면 time 이라는 시간이 지난 뒤 resolve() 함수를 호출해 이행된 Promise를 반환하게 됩니다.
그러면 우리는 단순히 then과 콜백 함수로 받아서 작업을 지시하고, return으로 다시 Promise를 던져서 다음 then에 작업을 지시하면 됩니다. (Promise Chanining)
* 외계어처럼 들린다면 이전 Promise 편에서 이미 다 설명한 내용이니 이 글을 참고해주세요! 다시 한번 말씀드리지만 async await은 Promise를 어느정도는 다 알고 계셔야 합니다.
이렇게 해서 Promise 를 통해서 아까와 동일한 동작을 하나 훨씬 더 가독성이 개선된 코드를 얻게 되었습니다.
하지만 이것도 코드가 너무 거추장스럽다는 생각은 안드시나요? 1초씩 대기하면서 job1, job2, job3을 찍는 코드가 이렇게나 길어야 한다니요.. 사람의 욕심은 끝이 없으니 ㅎㅎ.. 저 then이랑 콜백함수좀 안쓸 수 없을까요?
개발자들은 더 깔끔한 코드의 꿈을 가지게 되었습니다.
그리고 이제 async await을 통해 한번 그 꿈을 이뤄내봅시다!
async function async_run() {
//await 키워드는 async 함수 안에서만 사용할 수 있다 (제약 조건)
console.log("job1");
await Timer(1000); //비동기 함수가 실행되길 기다려라
console.log("job2");
await Timer(1000);
console.log("job3");
await Timer(1000);
}
async_run();
Promise로 작성된 코드를 async await 를 이용해서 위처럼 바꿨습니다.
우선 이 코드는 아까전의 코드와 완전히 똑같이 동작합니다.
우선 바뀐점이 몇가지 보이는데 코드들이 함수로 감싸졌으며 함수 앞에 async 라는 키워드가 붙어있습니다.
또 거추장스럽던 then() 과 CallBack 함수들은 사라지고 await 라는 키워드가 새로 도입되었습니다.
우선 await 키워드에 대해 알아봅니다.
await라는 단어는 (…을) 기다리다 라는 뜻인데, 단어 그대로 Promise가 끝날때까지 기다리는 키워드 입니다.
await는 Promise가 이행(fulfilled)되던지, 거부(rejected) 되던지 간에 우선은 끝날때까지 기다리는 녀석입니다.
코드를 간단히 읽어보면 job1 출력, Timer(1000) 에서 Promise 결과 값이 올때까지 기다리고 (1초 기다리고), 다시 job2 출력, 다시 1초 기다리고 job 3 출력과 같은 형태로 진행이 됩니다.
아까전의 코드와 동일하게 동작하지만, 코드를 읽을때 만큼은 동기적으로 읽어낼 수 있다는 큰 장점이 생기게 되었습니다.
이 await라는 키워드를 사용하려면 한 가지 제약조건이 있는데 바로 async 함수 안에서만 사용이 가능하다는 점입니다.
아까 Promise의 경우 그런 제약이 없었으나 await의 경우 이런 제약이 있기 때문에 async 함수로 감싸고 그 함수를 호출해서 실행하는 형태로 코드가 바뀐 것입니다.
function async_run()
만약에 같은 코드에서 이렇게 async function이 아닌 일반 function 으로 선언을 한다면
'await' 식은 비동기 함수 내부 및 모듈의 최상위 수준에서만 사용할 수 있습니다.
라는 오류를 만나면서 사용할 수 없습니다. 저는 타입스크립트로 코딩하고 있는데 그래서 그런가 친절하게 async 함수로 바꿔준다고 까지 나오네요.
//생략...
console.log("job1");
await Timer(1000); //Error!
console.log("job2");
await Timer(1000);
console.log("job3");
await Timer(1000);
그렇기에 async 함수로 감싸지 않고 이렇게 바로 코드 최상위에서 await를 사용하면 오류가 납니다.
물론 JS 아주 최신 규격에선 Top-level await 라고 해서 최상위에서 await를 사용할 수 있게 되었지만 우선 그 내용은 제외하고 글을 작성하도록 하겠습니다.
await의 특징과 용래를 조금더 자세히 알아보자면 아래와 같습니다.
1. 문법적으로 await [[Promise 객체]] 와 같은 형태로 사용합니다.
2. await는 Promise가 완료될 때까지 기다립니다. 물론 동기적으로 코드가 멈춰버려서 기다리는게 아닌, await 또한 콜백으로 동작하는것과 같아서 Promise가 처리되길 기다릴동안 엔진이 다른 일(이벤트 처리, 다른 스크립트를 실행)을 처리할 수 있습니다.
async function fn() {
let promise = new Promise((resolve, reject) => {
setTimeout(() => resolve("완료!"), 1000)
});
let result = await promise; // 프라미스가 이행될 때까지 기다림 (*)
}
fn();
3. await는 Promise가 정상적으로 이행(fulfilled)되면 resolve한 값을 내놓습니다.
예를 들어서 위 코드에서 resolve() 를 통해 "완료" 라는 값을 전달하고 있는데 await promise 로 기다린 후 result엔 "완료" 라는 값이 저장되게 됩니다.
4. await로 기다린 promise가 거부(rejected) 되면 마치 throw 문을 작성한 것 처럼 에러(예외) 가 던져집니다.
async function f() {
await Promise.reject(new Error("에러 발생!"));
}
예를 들어서 위 코드는
async function f() {
throw new Error("에러 발생!");
}
이 코드와 동일합니다.
이렇게 await가 던진 에러들은 try ~ catch 구문을 이용해 예외처리를 함으로써 제어할 수 있습니다.
await는 이렇게 then, catch 의 동작을 자기 나름대로 처리하기 때문에 async / await를 사용하면 then, catch 를 사용하지 않아도 되며, try ~ catch 로 거대한 코드 블럭을 잡아서 오류를 모두 catch 에서 모아서 처리할 수 있는 장점 또한 얻게 됩니다. (뭐 그렇지만.. 항상 상황에 맞게 사용해야 한다는 점은 아시죠?)
이제 await 얘기는 충분해보이니, async 함수에 대해서도 알아보겠습니다.
Async 함수
아까전에도 언급했듯이 await를 사용하려면 async 함수 안에서 사용해야 합니다.
그러면 async function 은 단순히 await 를 쓰기 위한 키워드, 셔틀에 불과하냐 하면 그건 아닙니다.
async function 도 자신의 특징(기능)을 가지고 있습니다.
async function fn() {
return 1;
}
다음과 같은 코드를 가져왔습니다.
async function 이 정확히 어떻게 동작하는지 모르는 상태에선 그냥 일반 함수처럼 1을 반환하는건가? 착각할 수 있습니다.
async function fn() {
return 1;
}
const result = fn();
console.log(result);
async 함수를 모르는 A라는 사람이 코드를 위와 같이 작성했다고 해봅시다.
출력결과는 어떤가요? 1인가요. 물론 아니니깐 물어본거겠죠 ㅎㅎㅎ
실행해보면 아시겠지만 1이 아닌 Promise {<fulfilled>: 1} 가 나옵니다.
정확히는 이행된 Promise 1이 나오네요. 즉 이 값은 resolve() 로 넘겨서 then 으로 받아내야 했던 이행된 Promise 값입니다.
개념 확립을 위해 미리 말씀드리자면 function 앞에 async를 붙이면 해당 함수는 항상 Promise를 반환합니다. 정확히는 Promise가 아닌 값을 반환하더라도 이행 상태의 프로미스(resolved promise) 로 값을 감싸서 이행된 Promise가 반환되도록 합니다.
async function fn() {
return 1;
}
즉 이 함수는 사실
function fn() {
return new Promise((resolve, reject) => {
resolve(1);
});
}
와 동일한 역할을 한다는 것이겠죠.
예전에는 Promise 안에서 작업을 하고 제대로 처리가 이루어지면 결과 값은 resolve() 콜백 함수로, 문제 발생시 에러 값은 reject() 함수로 넘겼는데
async 함수에서 간단하게 return 만 해주면 저렇게 제대로 Promise 처리가 이루어져서 (이행된 Promise) 나온 결과값을 resolve(1) 로 전달하게 하는 것과 동일한 코드가 되게 된겁니다.
보시면 이제 return new Promise() 부터 시작해서 복잡한 코드를 작성해주지 않아도 된다는 말이죠.
async function fn() {
return 1;
}
fn().then((result) => console.log(result)); // output : 1
async 함수가 return 하는 값은 이행된 Promise 라는걸 알았습니다.
제대로 resolve(이행) 되어서 들어온 값은 Promise 에서 뭘로 받았나요? 바로 then() 과 콜백함수였죠!
위 코드를 실행해보면 1이 정상적으로 출력된답니다.
타입스크립트를 쓰신다면 async 함수에서 값을 반환시 똑똑하게 함수의 반환값이 Promise<number> 라고 추론되는걸 확인할 수 있습니다.
async function fn() {
return 1;
}
async function print_async() {
const result = await fn();
console.log(result);
}
print_async()
또 then() 으로 결과값을 받는거 이외에도 아까전에 Promise를 끝날때까지 기다리는 await 키워드를 통해서 다음과 같이 작성도 가능합니다.
await를 쓰고 싶어서 이렇게 매번 함수로 감싸줘야 하는게 조금 귀찮긴 한데 보통 프로그램 개발시 함수 단위로 개발을 많이 할거라 크게 상관은 없습니다.
async function fn() {
return Promise.resolve(1);
}
fn().then((result) => console.log(result)); // output : 1
또 이렇게 명시적으로 Promise를 반환하는 것도 가능한데 결과는 동일합니다.
function fn2() {
return new Promise((resolve, reject) => {
reject(new Error("something unexpected"));
});
}
프로미스 작업 중 오류 발생시 return new Promise() 를 이용해 기존에 Promise를 반환 하던 코드로는 reject(new Error("에러 메세지")) 와 같이 reject() 콜백 함수를 이용해서 에러를 넘겨주면 됩니다.
async function fn() {
try {
const res = await fetch("incorrect-url");
const json = await res.json();
return 200;
} catch (error) {
return 400; //이행된 Promise 반환
}
}
fn().then((result) => console.log(result)); //output : 400
그럼 만약에 async 함수 안에서 오류가 발생해서 Promise를 거부(rejected) 처리 해야 한다면 어떻게 될까요?
처음에 저도 이것이 제대로 명시되어 있는 글이 적어서 골머리를 앓았습니다만 역시 없는게 없는 Stack Over Flow 커뮤니티에서 해답을 찾을 수 있었습니다. (글 원문 링크)
위 코드는 fetch() API 를 통해 잘못된 url로 접근해서 try ~ catch 문의 오류에 걸려서 오류가 발생한 경우입니다. 저 코드를 작성한 사람은 분명 에러 발생시 HTTP 에러 코드인 400을 반환하고 싶은 의도였으나 async 함수는 아까도 확인했듯이 Promise가 아닌 값을 반환해도 이행된 프로미스로 값을 감싸서 이행된 프로미스를 반환합니다.
그렇기 때문에 fn() 을 호출했을때 catch() 에 잡히는게 아니라 제대로 처리가 이루어진 것으로 인식이 되어 then() 에 400 이란 값이 잡혀버립니다.
async function fn() {
try {
const res = await fetch("incorrect-url");
const json = await res.json();
return 200;
} catch (error) {
throw new Error("400");
}
}
fn()
.then((result) => console.log(result))
.catch((err) => console.log(err)); // Error : 400 !
이것을 해결하는 가장 좋은 방법은 위처럼 throw 를 통해 에러를 던지는 것입니다.
결과적으로 거부된 Promise와 함께 에러 값이 나온다고 합니다.
} catch (error) {
throw 400;
}
이런식으로 에러로 감싸지 않고 그냥 throw를 통해 예외를 발생시킬 수도 있는데 스택 추적 정보를 얻을 수 없다고 합니다.
} catch (error) {
return Promise.reject(new Error(400));
}
대신에 이렇게 아까 명시적으로 async 함수 안에서 return으로 Promise.resolve 를 던졌듯이 reject 를 던질 수도 있는데 거의 쓰이지 않는다고 합니다. (비 관용적) - 추가적으로 return Promise.reject(400);처럼 Error 로 감싸지 않고도 사용 가능한데 똑같이 스택 추적 정보를 얻을 수 없습니다.
결론적으로 throw new Error(...) 형태를 사용하시면 되겠습니다.
그래서 정리하자면..
>> 우선 await 키워드를 사용하면 기존에 then() 과 catch() 없이 Promise 를 반환하는 함수들을 간편하게 사용할 수 있습니다. => 물론 await 를 사용하다가 Promise가 거부되면 예외가 발생할 수 있으니 try ~ catch 를 이용해서 await 를 사용하는 코드 블럭을 감싸서 예외를 처리해주셔야 합니다.
>> new Promise() 를 return 하는 일반 function의 경우 async function 으로 리팩토링해서 코드를 간편하게 줄일 수 있습니다.
방법은 아래와 같습니다
1. 함수에 async 키워드를 붙입니다.
2. new Promise 부분을 지우고 함수 안의 내용만 남깁니다
3. resolve(result); 부분은 return result 로 대체합니다.
4. reject(new Error(...)); 부분은 throw new Error(...) 로 대체합니다.
* 사실 Promise를 반환하는 코드들을 위 규칙에 따라 async await로 자동 변환해주는 기능이 VSCode에 잠깐 있었던 거 같은데 요새 최신 버전에선 도저히 찾아볼 수가 없네요. 혹시 이에 대해 아시는 분들 있으면 댓글 부탁드립니다.
이 정리한 부분이 핵심인데, 글로만 설명하면 어려우니깐 마지막으로 모던 JavaScript 튜토리얼 글에서 가져온 Promise를 async await 로 리팩토링 하는 문제를 풀어보면서 글을 마치겠습니다.
Async & Await 활용 문제
function loadJson(url) {
return fetch(url)
.then(response => {
if (response.status == 200) {
return response.json();
} else {
throw new Error(response.status);
}
})
}
loadJson('no-such-user.json')
.catch(alert); // Error: 404
Q1. 다음 loadJson() 함수를 async function과 await를 통한 코드로 바꿔보세요.
코드 구조나 해석엔 집중하지 마시고 return response.json() 이 실제로 resolve 로 반환해야 하는 값, throw new Error이 오류가 나서 reject 로 던지는 값이라고 생각하시고 진행하시면 됩니다.
Q1. 솔루션 (정답)
async function loadJson(url) {
let response = await fetch(url);
if (response.status == 200) {
return response.json();
}
throw new Error(response.status);
}
loadJson("no-such-user.json").catch(alert); // Error: 404
1. async 함수로 바꿉니다.
2. fetch() 의 값을 await 를 통해 기다립니다. (Promise)
3. 요청 성공시(200) return 을 통해 이행된 Promise를 던집니다.
4. 실패시 throw 를 통해서 reject 된 Promise를 던집니다. (예외를 던집니다)
* HTTP 200 OK는 요청이 성공했음을 나타내는 성공 응답 상태 코드입니다.
class HttpError extends Error {
constructor(response) {
super(`${response.status} for ${response.url}`);
this.name = 'HttpError';
this.response = response;
}
}
function loadJson(url) {
return fetch(url)
.then(response => {
if (response.status == 200) {
return response.json();
} else {
throw new HttpError(response);
}
})
}
// 유효한 사용자를 찾을 때까지 반복해서 username을 물어봄
function demoGithubUser() {
let name = prompt("GitHub username을 입력하세요.", "iliakan");
return loadJson(`https://api.github.com/users/${name}`)
.then(user => {
alert(`이름: ${user.name}.`);
return user;
})
.catch(err => {
if (err instanceof HttpError && err.response.status == 404) {
alert("일치하는 사용자가 없습니다. 다시 입력해 주세요.");
return demoGithubUser();
} else {
throw err;
}
});
}
demoGithubUser();
Q2. demoGithubUser() 함수를 async function 과 await를 통한 코드로 바꿔보세요. * 다른 함수들은 건들지 않아도 됩니다.
Q2. 솔루션 (정답)
class HttpError extends Error {
constructor(response) {
super(`${response.status} for ${response.url}`);
this.name = "HttpError";
this.response = response;
}
}
function loadJson(url) {
return fetch(url).then((response) => {
if (response.status == 200) {
return response.json();
} else {
throw new HttpError(response);
}
});
}
// 유효한 사용자를 찾을 때까지 반복해서 username을 물어봄
async function demoGithubUser() {
try {
let name = prompt("GitHub username을 입력하세요.", "iliakan");
const user = await loadJson(`https://api.github.com/users/${name}`);
alert(`이름: ${user.name}.`);
return user;
} catch (err) {
if (err instanceof HttpError && err.response.status == 404) {
alert("일치하는 사용자가 없습니다. 다시 입력해 주세요.");
return demoGithubUser();
} else {
throw err;
}
}
}
demoGithubUser();
1. async 함수로 변경합니다.
2. await를 통해서 loadJson() 의 Promise 결과를 기다립니다.
3. 코드 블럭 전체를 try catch 로 잡습니다.
=> try 안 코드 실행 과정에서 오류 발생시 try catch에서 잡힐 것 입니다.
4. catch (err) 에서 오류에 관한 내용을 작성합니다.
function random_box() {
return new Promise((resolve, reject) => {
const zero_or_one = Math.round(Math.random()); //0또는 1 랜덤으로 얻음
if (zero_or_one === 0) {
reject(new Error("I don't want zero :("));
} else {
resolve("You win! this is the value i want :)");
}
});
}
random_box()
.then((result) => console.log(result))
.catch((err) => console.log(err));
Q3. 다음 함수를 async await로 바꿔보세요 (실행했을때 1이 나와야 이길 수 있는 재미(?)있는 게임입니다.)
Q3. 솔루션 (정답)
async function random_box() {
const zero_or_one = Math.round(Math.random()); //0또는 1 랜덤으로 얻음
if (zero_or_one === 0) {
throw new Error("I don't want zero :(");
} else {
return "You win! this is the value i want :)";
}
}
random_box()
.then((result) => console.log(result))
.catch((err) => console.log(err));
1. async 함수로 변경합니다.
2. new Promise() 안의 코드 내용만 남깁니다. -- new Promise((resolve, reject)) 부분은 날려서 없앱니다.
3. resolve 는 return 으로, reject 는 throw 로 대체합니다.
참고
https://www.youtube.com/watch?v=aoQSOZfz3vQ
https://ko.javascript.info/async-await
https://www.youtube.com/watch?v=1z5bU-CTVsQ
https://www.youtube.com/watch?v=CA5EDD4Hjz4
https://stackoverflow.com/questions/42453683/how-to-reject-in-async-await-syntax
'웹 > JS' 카테고리의 다른 글
[JS] 난독화 풀기 - Deobfuscate (1) | 2024.09.09 |
---|---|
[JS] "==" 와 "===" 연산자의 차이점 (0) | 2022.08.23 |
웹 프로젝트 리팩토링 -> TS(타입 스크립트) 도입했습니다 (0) | 2022.08.21 |
[JS] Promise란? (then, catch, ...) - 프로미스 쉽게 이해하기 (0) | 2022.07.11 |
[JS] 콜백 함수(Callback Function)란? [콜백 함수를 쓰는 함수 직접 만들어보기] (0) | 2022.07.05 |