비동기 프로그래밍 글을 읽어보신 분이면 맨 마지막에 미래의 본인에게 미룬 async/await 라는 짐을 알 수 있을 것이다.
콜백 핼을 지워냈지만 결국 then으로 떡칠이 되는 코드를 해결하는 방법인 async/await를 이번 기회에 알아보려고 한다.
처음에는 단순하게 async/await만 알아보려 했으나 이게 왠걸 제너레이터라는 개념도 알아보면 좋다고 한다고 한다… 한번 같이 알아보자
자바스크립트 개념을 나름은 알고있다고 생각했지만 진짜로 처음 들어보는 개념이다.
제너레이터란 무엇일까?
우선 제너레이터라는 개념은 ES6에 추가된 개념이고, 아래와 같은 정의, 특징이 있다고 한다.
코드 블록의 실행을 일시 중지했다가 필요한 시점에 재개할 수 있는 특수한 함수를 제너레이터 라고 한다.
제너레이터와 일반 함수의 차이
1. 제너레이터 함수는 함수 호출자에게 함수 실행의 제어권을 양도할 수 있다.
2. 제너레이터 함수는 함수 호출자와 함수의 상태를 주고 받을 수 있다.
3. 제너레이터 함수를 호출하면 제너레이터 객체를 반환한다.
이야 정말 하나도 이해 안간다 😂
일단 특징 3가지에 대해서 조금이나마 풀어서 알아보자
이 말은 무엇이냐?
보통 우리는 일반 함수를 실행시키면 함수 실행 컨텍스트가 스택에 푸쉬되고 제어권이 함수에게 넘어간다. 이후 당연하게 함수를 일괄적으로 다 실행한다.
이 말은 함수를 호출한 Caller가 제어할 수 있는 것이 아닌 함수가 알아서 실행이 통으로 된다는 말이다.
그러면 제너레이터는 어떤가?
제너레이터 함수는 함수 실행을 호출자가 일시정지하고 실행하고를 마음대로 할 수 있다는 것이다. 이 말은 함수의 제어권을 함수 호출자에게 양도(yield)할 수 있다는 것이다.
동일하게 일반 함수를 생각해보자.
일반 함수는 간단하다. 일반 함수를 호출해 매개변수를 통해서 값을 넣고, 실행을 다 완료한 함수의 결과값을 받는다. 너무 당연하다.
그러면 제너레이터는 어떤가?
제너레이터의 경우 함수 호출자와 양방향으로 함수의 상태를 주고 받을 수 있다.
한 마디로 제너레이터 함수는 호출자에게 상태를 알려줄 수도 있고, 함수 호출자로 부터 상태를 다시 전달 받을 수도 있다는 것이다.
위 두가지 특징을 구현하기 위해서 제너레이터 함수는 호출되면 함수 코드를 실행한느 것이 아니라 이터러블이면서 이터레이터인 제너레이터 객체를 반환한다.
음.. 그렇다 막 와닿는 말도 아니고 백문이 불여일타 라고 한 번 코드로 보고 어떻게 쓰이는 것인지 알아봐야 할 것 같다.
천천히 하나하나 봐보자
정의 부터 알아보자.
제너레이터 함수는 function* 키워드로 선언하고, 하나 이상의 yield 표현식을 포함한다.
// 함수 선언문
function* genDecFunc(){
yield 1;
}
// 함수 표현식
const genExpFunc = function* () {
yield 1;
}
// 메서드
const obj = {
* genObjMethod() {
yield 1;
}
}
위 코드는 선언문 방식, 표현식 방식, 메서드 방식을 모두 적어두었다.
어 근데 뭔가 빠진 것 같기도… 하다.
맞다 제너레이터 함수는 우리가 사랑하는 화살표 함수 방식을 지원하지 않는다.
또한 new 를 통한 생성자 함수로도 호출할 수 없다.
위에서 잠깐 본 것 처럼 제너레이터 함수는 제너레이터 객체를 반환한다 했다.
제너레이터 함수를 호출하면 일반 함수처럼 실행하는 것이 아니라, 제너레이터 객체를 생성해서 반환한다.
이때 제너레이터 객체는 이터러블이면서 이터레이터이다.
ES6에서 도입된 이터레이션 프로토콜이란 것이 있다.
이는 순회 가능한 데이터 자료구조를 만들기 위해 ECMAScript 사양에 정의하여 약속한 규칙이다.
이 이터레이션 프로토콜은 크게 이터러블 프로토콜, 이터레이터 프로토콜로 나뉜다.
이터러블 프로토콜: Symbol.iterator 메서드를 호출하면 이터레이터를 반환하는 것을 말하고, 이 프로토콜을 준수한 객체를 이터러블 이라 한다.
이터레이터 프로토콜: 반환된 이터레이터는 next 메서드를 소유하며 next 메서드를 호출하면 이터러블을 순회하며 value와 dome 프로퍼티를 가지는 이터레이터 리절트 객체를 반환한다. 이런 프로토콜을 준수한 객체를 이터레이터라 한다.
제너레이터 객체는 next 메서드를 갖는 이터레이터이지만, return, throw 메서드를 추가로 가지는 객체이다.
각각 무엇을 하는 역할인지 확인해보자
next: 호출시 제너레이터 함수의 yield 표현식까지 코드 블록을 실행하고 yield된 값을 value 프로퍼티 값으로, false를 done 프로퍼티 값으로 갖는 객체를 반환한다.
return: 인수로 전달받은 값을 value 프로퍼티 값으로, true를 done 프로퍼티 값으로 갖는 객체를 반환한다.
throw: 인수로 전달받은 에러를 발생시키고 undefined를 value 프로퍼티 값으로, true를 done 프로퍼티 값으로 갖는 객체를 반환한다.
function* genFunc() {
try{
yield 1;
yield 2;
yield 3;
} catch(e) {
console.error(e);
}
}
const generator = genFunc();
console.log(generator.next()); // { value: 1, done: false }
// 이후에 return을 할 경우
console.log(generator.return('End')); // { value: "End", done: true }
// return 대신 throw를 할 경우
console.log(generator.throw('Error')); // { value: undefinde, done: true }
뭔가 이제서야 감이 좀 온다.
함수에 특정 구간까지 실행될 것을 정의해두고, 반환된 객체를 next 등의 메서드를 통해서 실행! -> 멈춰! -> 다음 실행! -> 멈춰! 등의 일을 할 수 있겠다는 생각이 든다.
이에 대해서 조금 더 살펴보자
앞서 감이 왔던 그 것이 맞다.
yield 와 next 가 그 동작 방식인 것이다.
yield 키워드는 제너레이터 함수의 실행을 일시 중지 시키거나 yield 키워드 뒤에 오는 표현식의 평가 결과를 제너리에터 호출자에 반환한다.
function* gneFunc() {
yield 1;
yield 2;
yield 3;
}
// 이터러블이면서 이터레이터인 제너레이터 객체를 받아온다.
const generator = genFunc();
// next 를 호출하면 첫 번째 yield까지 실행하고 중지된다.
// 이때 value는 yield 된 값, done은 아직 끝난게 아니기에 false 이다.
console.log(generator.next()); // { value: 1, done: false }
// 다시 next 를 호출하면 다음 yield 까지 실행한다.
console.log(generator.next()); // { value: 2, done: false }
console.log(generator.next()); // { value: 3, done: false }
// yield 가 다 빠진 상태에서 실행하면 어떨까?
// 이 경우에는 제너레이터 함수의 마지막까지 실행이 된다.
// 이후에는 value에는 undefined, done에는 true가 들어있는 객체를 반환한다.
console.log(generator.next()); // { value: undefined, done: true }
한가지 떠오르는 것이 있다.
우리는 분명히 값을 다시 전달해 줄 수 있다고 했는데 그거는 어떻게 하는 것일까?
바로 next 메서드에 인수로 전달해주는 것이다.
아래 코드를 보면 한번에 이해할 수 있을 것이다.
function* genFunc() {
// next를 처음 했을 때는 yield 표현식까지 실행하고 중지된다.
// 이 말은 반환 객체에는 yield의 1 값이 넘어가기는 하지만
// x 안에는 아직 아무 것도 할당되지 않는 것이다.
// 이 x 값은 두 번째 next 메서드가 호출될 때 결정된다.
const x = yield 1;
// 이 또한 동일하다. 두 번째 next 메서드가 호출될 때 전달받은 값이
// x 에 존재하는 상황이고 그것에 10을 더한 값이 반환 객체에 들어간다.
// 이 y는 세 번째 next 시에 전달받은 값이 들어올 것이다.
const y = yield ( x + 10 );
// 일반적으로 제너레이터의 반환값은 의미가 없다.
// 따라서 제너레이터는 값을 반환할 필요가 없고 단지 return을 종료의 의미로만
// 사용하는 것이 좋다.
return x + y;
}
cosnt generator = genFunc();
// yield 값인 1이 들어있다.
console.log(generator.next()); // { value: 1, done: false }
// 이제 인수로 전달한 값이 x에 할당되고 다음 yield까지 동작한다.
console.log(generator.next(10)); // { value: 20, done: false }
// 이번 인수는 y에 할당될 것이고 다음 yield가 없으니 반환 값을 줄 것이다.
console.log(generator.next(20)); // { value: 30, done: true }
그러면 이 제너레이터라는 것을 비동기 처리에 적용할 수 있지 않을까?
한번 코드로 봐보자
const async = generatorFunc => {
// 제너레이터 함수를 인자로 받는다.
const generator = generatorFunc();
// next 에 인자로 넣어줄 것이 있으면 받는다.
const onResolved = arg => {
// 제너레이터 함수의 next를 통해서 결과를 받는다.
// 이 경우에는 fetch 결과가 될 것이다.
const result = generator.next(arg);
// 해당 결과의 done을 통해서 제너레이터 함수가 끝났는지 확인한다.
// 이 경우에는 하나 더 있을 것이니 처음에는 false이다.
// 이로 인해서 재귀적으로 돌 것이고
// 다시 이곳에 돌아올 때는 true일 것이다.
// 그리고 리턴 값으로 json 처리가 된 결과를 줄 것이다.
return result.done
? result.value
: result.value.then(res => onResolved(res));
};
return onResolved;
};
(async(function* fetchTodo() {
const url = 'https://test.com/todo/1';
const response = yield fetch(url);
const todo = yield response.json();
console.log(todo);
})());
// { 객체 형식 결과 내용 }
위에 코드의 주석으로도 잠시 적어두었지만
한 번 순차적으로 글을 쓰면 다음과 같다.
제너레이터 함수 fetchTodo를 async 함수의 인자로 넣어줬다.
내부적으로 onResolved 라는 클로저를 만들어서 반환한다.
즉시 실행 함수라 onResolved 함수가 실행이된다.
next 메서드로 인해서 result에 처음 yield까지의 결과가 result에 담긴다. 이 경우에는 fetch 결과 값이 담길 것이다.
이제 result의 done을 살펴볼 것이다. 이 때는 아직 제너레이터 코드가 끝난 것이 아닌 것이기 때문에 false 일 것이고 재귀적으로 onResolved가 호출 될 것이다.
이때 arg는 fetch의 결과 값일 것이고 이는 다시 next의 인자로 들어간다.
fetchTodo의 response에 해당 값이 들어가고 다시 yield의 결과 값인 json화 된 결과를 result에 리턴한다.
이 result 또한 done이 false일 것이다. 따라서 인자로 result 값이 들어가고 재귀적으로 동작한다.
인자로 받은 result 가 다시 next의 인자로 들어간다. 이로 인해서 todo에 해당 result 값이 담길 것이고, 다음 yield가 없으니 끝까지 실행된다.
console.log로 인해서 값이 출력된다. 이후 { value: undefined, done: true } 인 객체가 반환된다.
드디어 done이 true다. value 값인 undefined가 반환될 것이다.
드디어 이 주제를 다뤄볼 수 있다. 너무 긴 여정이었다.
위 마지막으로 본 코드가 제너레이터를 이용한 비동기 처리 코드였다.
내부 동작을 하나씩 살펴본 글만 보더라도 조금… 더럽다는 생각도 들고 어렵다.
이러한 방식을 간단하고 가독성 좋게 하면서 비동기를 동기처럼 동작할 수 있도록 도와주는 것이 async/await이고 ES8에 도입되었다.
async/await 는 앞선 글에서 살펴본 promise를 기반으로 동작한다.
다른 점이라면 then, catch, finally 같은 키워드를 사용한 콜백 함수 전달로 비동기를 처리하는 것이 아닌 동기 방식처럼 프로미스 처리 결과 값을 처리할 수 있다는 것이다.
어떻게 사용하는지 한 번 살펴보자.
우선 await 키워드는 반드시 async 함수 내부에서 사용 가능하다.
async 함수는 async 키워드를 사용해 정의하며 언제나 promise를 반환한다.
async 함수는 암묵적으로 반환값을 resolve하는 프로미스를 반환한다.
const getGithubUserName = async id => {
const res = await fetch(`https://test.com/user/${id}`);
const { name } = await res.json();
console.log(name);
};
getGithubUserName();
확실히 async/await를 사용한 방식이 깔끔하고 동기처럼 코드를 작성할 수 있는 장점이 있는 것을 볼 수 있다.
근데 await 키워드는 어떻게 동작하는 것일까?
await 키워드는 promise가 settled(비동기 처리가 수행된 상태)가 될 때까지 대기하다가 settled 상태가 되면 promise가 resolve한 처리 결과를 반환한다.
예외처리는 그러면 어떻게 할까?
간단하게 try catch 문을 이용해서 해결할 수 있다!
이는 콜백 함수를 인수로 전달 받는 비동기 함수와는 달리 프로미스를 반환하는 비동기 함수는 명시적으로 호출을 할 수 있기 때문이다.
const foo() = async () => {
try {
const wrongUrl = 'https://wrongUrl.com';
const response = await fetch(wrongUrl);
const data = await response.json();
console.log(data);
} catch (err) {
console.error(err);
}
};
foo();
간단하게 처리도 가능할 뿐더러 HTTP 통신에서 발생한 에러 뿐만 아니라 try 블록 내의 모든 에러를 캐치할 수 있다는 점까지 매력적이다.
근데 생각을 해보면 catch 라는 구문을 사용할 수도 있지 않을까? 하는 생각도 든다.
왜냐하면 async 함수가 반환하는 결과는 promise 이기 때문이다.
이 생각도 맞다. catch나 then을 사용해서 처리도 가능하다.
async 함수는 함수 내에서 catch 문을 사용해서 에러 처리를 하지 않으면 async 함수는 발생한 에러를 reject 하는 프로미스를 반환한다.
이 점을 이용해서 아래와 같은 예외처리도 가능하다.
const foo = async () => {
const wrongUrl = 'https://wrongUrl.com';
const response = await fetch(wrongUrl);
const data = await response.json();
return data;
};
foo()
.then(console.log)
.catch(console.error);
뭔가 비동기 처리만 학습을 하려다 많이 알게 된 것 같다.
이전 글만 생각해도 비동기 처리에 대한 전반적인 개념, 프로미스 방식, Ajax, Fetch 등에 대해서 알아보았고
이번 글에서는 async/await 를 알아보려다 제너레이터 방식의 비동기 처리도 알아보았다. (사실 이 글은 제너레이터 글이 아닐까?..)
긴 여정 끝에 나름은 정리를 마쳤는데 내부적으로 더 학습 할 내용은 있어보인다.
보완할 부분이 있다면 더 정리를 하고 새로운 글을 써볼까나~ 싶다.
이번에도 장황한 글을 읽어주신 분들께 감사드리고, 이상한 점 있으면 알려주시기 바란다.