모던 자바스크립트와 비동기 프로그래밍: Generator/Yield vs. Async/Await
원문: Modern Javascript and Asynchronous Programming: Generators/Yield vs. Async/Await
2015년 ECMAScript 명세의 6번째 버전이 릴리스 된 이래로, 자바스크립트에는 비동기 프로그래밍이나 객체의 컬렉션을 더 쉽게 다루는(예를 들면, iterables/iterators) 데 초점을 둔 여러 새로운 기능들이 소개되거나 제안되어 왔다. 이 두 토픽은 비동기 데이터 스트림처리, 옵저버블 패턴, 일반적인 리액티브 프로그래밍 등 데이터 제공자와 소비자 간의 모든 관계를 고려할 때, 겹쳐지는 주제이다. 이것은 매우 광범위한 주제인데, 먼저 제너레이터 함수/Yield와 Async/await에 대해 이해하는 것이 앞서 말한 개념 중 일부에 대한 좋은 가이드가 될 수 있다.
2015년, ES6(ES2015)는 지금은 매우 유명한 프로미스를 소개했다. 프로미스란 비동기 함수는 결괏값을 반환하거나 에러를 반환한다는 전제 하에 동작하는 객체이다. 이 글에서는 프로미스에 대해 자세히 다루지는 않는다. 하지만, 이 개념은 사실 앞으로 다룰 주제의 기반이다. 그래서 우선 이에 대해서 잘 알고 있어야 한다. 만약 그러지 않는다면, 프로미스에 대해 깊이 있게 설명하는 좋은 자료들이 많으니 그것들을 보는 것도 좋다. 그중에서 내가 좋아하는 것 중 하나는 Mattias Petter Johansson의 Fun Fun Function video about Promises이다.
ES2015는 또한 제너레이터 함수와 yield 키워드를 소개했다. 제너레이터는 동기와 비동기 목적으로 모두 사용 가능한 개념이다. 하지만 중요한 것은 이것이 더 유명한 async/await 구문이 동작하게 하는 배경임을 이해하는 것이다. 그래서 async/await에 대해 정말 이해하고 싶다면, 먼저 제너레이터 함수에 대해 알고, 이 개념이 비동기 함수 외에 다른 때에도 사용할 수 있다는 것을 이해해야 한다. 제너레이터 함수에 대한 튜토리얼 중에는 제너레이터 함수가 async/await처럼 동작하는 예제를 보여주면서 설명을 시작하는 경우가 종종 있다. 그러나 이것은 다음과 같은 이유에서 보면 바람직한 방법은 아니다. 첫째로, 제너레이터 함수는 다른 방식으로 사용할 수 있는 데, 이에 대해서도 반드시 언급해야 한다. 둘째로, 이렇게 설명하는 것이 제너레이터 함수에 대해 잘 알지 못하는 사람들에게 더 이해하기 어렵게 만들 수 있다. 이 글에서는 제너레이터 함수를 설명하기 위해 가장 간단한 예제로 시작하고, 점차 async/await 함수와 비슷한 동작을 하도록 할 것이다.
새로운 구문인 async/await은 2015년경 ES2017를 위해 처음으로 제안되었다. Async/await을 사용하면, 비동기 코드를 .then()이나 콜백함수 없이 순차적으로 동작하는 (동기식)코드처럼 보이도록 작성할 수 있다(역자주: 비동기 함수의 콜백 패턴은 코드를 이해하기 어렵게 만들곤 한다). 그리고 Async/await은 “기다리는 코드”라고 설명되기도 한다. Async/await은 비동기 코드를 작성하는 매우 유용한 방식이다. 일반적인 프로미스 처리를 이해하기보다 쉽고 간결하다. 하지만, async/await도 염두에 두어야 할 한계가 있어서, 어떤 경우에는 기존 방식대로 프로미스를 처리하는 것이 여전히 더 나은 방법이다.
제너레이터 함수 (ES2015)
제너레이터 함수는 yield 키워드로 표시된 값들을 중간에 “반환”하면서 하나의 함수에서 여러 개의 값을 반환할 수 있게 하는 함수이다. 제너레이터 함수는 yield 키워드로 표시된 각각의 시점에서 함수의 실행을 일시적으로 멈출 수 있다. 잘 알고 있는 “return” 키워드로 이 함수의 최종 반환 시점에 도달해서 함수가 종료하기 전까지 말이다. 제너레이터 함수를 선언할 때는 function 키워드 다음에 별표(*)를 붙이면 된다.
제너레이터 함수의 엄청 간단한 예. 제너레이터에 값이 요청되면(어떻게 하는지는 아래에서 다룬다) 처음에는 5를 반환하고, 다음에는 7을 반환한다, 그리고 마지막에는 11을 반환한다.
제너레이터의 흥미로운 점은 필요에 따라 여러 개의 값을 반환할 수 있도록 설정하고, 한 번에 한 개씩 반환한다는 것이다: 값을 요청할 때만, 다음에 오는 yield나 return 문까지 실행된다. 이것은 옵저버블, 옵저버 디자인패턴과 비슷한 개념이다. 제너레이터 함수가 적극적인 값 제공자가 아닌, 소극적인 값 제공자로 동작한다는 것만 제외하면 말이다. 적극적인 제공자는 필요할 때 값을 반환한다. 값을 다른 곳에서 요청할 때까지 기다리지 않는다. 제너레이터 함수에 값을 요청하는 코드는 적극적인 소비자이다. 왜냐하면 이 부분은 값을 요청하는 주도권을 갖고 있고, 소극적으로 값을 받을 때까지 기다리지 않기 때문이다.
그래서 제너레이터는 어떻게 실행될까?
제너레이터를 실행하기 위해, 제너레이터 함수를 호출해도, 제너레이터 안의 함수는 실행되지 않는다. 제너레이터 함수는 즉시(동기적으로) 제너레이터 객체라고 불리는 특별한 타입의 객체를 반환한다. 이 객체는 다른 것들 사이에서 이터레이터로서 동작한다. 쉽게 설명하기 위해, 여기서는 제너레이터 객체를 “이터레이터”라고 부르자. 이름에서 알 수 있듯이, 이터레이터는 어떤 값들의 컬렉션을 순회할 수 있다. 이 경우에는 제너레이터 함수가 제공할 수 있는 (“yield”나 마지막 “return” 키워드와 함게 표시되는) 서로 다른 “반환 값”들을 순회한다. 제너레이터 함수를 “중간 반환 값”까지 실행하기 위해서는 이터레이터의 next() 메서드를 호출하면 된다.
일반적으로, iterator.next()는 제너레이터 함수를 다음의 정지 시점까지 실행한다. 중간의 yield 구문이든 마지막의 return이든 말이다. next()를 호출하면 항상 value 프로퍼티와 불린 값을 갖는 done 프로퍼티를 가진 객체를 만들어낸다. 이 객체를 통해 원하는 값을 얻을 수 있고, 제너레이터 함수가 실행이 종료되었는지 아닌지도 알 수 있다.
이 예제에서, 이터레이터 객체를 반환하는 제너레이터 함수가 실행됐다. 그 다음 반환 값을 얻기 위해 이터레이터의 next 메서드를 3번 호출했다. 필자는 변수의 값을 표시해주는 inline 실행 도구(Quokka)를 쓰고 있다. 세 번째에서 더 값을 요청할 수 없음을 가리키는 “done: true” 값을 받았다.
실제로는 제너레이터에 값을 몇 번 요청할 수 있는지 알 수 없다. 그래서 일반적으로는 “done:true” 값이 나오기 전까지 필요한 경우에 계속해서 요청한다. 예를 들어 while 문을 사용해서 말이다. 반복문은 물론 제너레이터 함수 안에서도 사용할 수 있다. 예를 들어 긴 목록에서 한 번에 하나씩 값을 반환하는 데 필요하다. 제너레이터 함수는 심지어 끝나지 않는 수열의 값을(예들 들자면, 피보나치 함수) 계속해서 제공할 수 있다.
제너레이터 함수는 실행 단계를 기억한다는 점에서 (자신의 상태를 계속해서 추적하는) 상태 머신과 비슷하다
. 이는 제너레이터를 유용하게 만드는 측면 중 하나이다. 제너레이터 함수는 클로저를 사용해서 ES5에서 구현되어 자신의 상태를 기억할 이터레이터 객체를 반환할 수 있다. 그러나 이 방식은 코드로 작성하기 어려울 뿐 아니라, 사용하는 경우에 맞게 매번 적용해야 한다. 여기 ES5 예제를 보자(const와 let을 var로 바꿔도 예제의 동작을 변화시키지 않는다).
“fakeGenerator” 함수는 우리의 제한된 코드에 대해서 실제 제너레이터 함수와 같이 동작한다. “next” 메서드를 가진 객체를 반환하는 데, 이것은 다음에 반환되어야 할 값을 기억하고 있다. 이 상황에서는 클로저에게 감사하다.
Async 함수 (ES2017)
자, 제너레이터 함수와 yield가 async/await과 어떤 관련이 있을까? Async/await은 ES2017에서 정식으로 채택된 Javascript에 새롭게 제안된 것이다. 제너레이터보다 더 특별한 방식으로 함수의 실행을 잠시 멈추는 함수를 작성할 수 있다. Async/await을 사용하면 제너레이터의 일부 사용 사례를 더 쉽게 구현할 수 있다는 것을 기억해라. 제너레이터 함수/yield와 Async 함수/await은 모두 “기다리는” 비동기 코드를 작성하는 데 사용된다. 그래서 비동기 함수이지만 동기 함수처럼 보이게 한다; 콜백을 사용하지도 않는다.
제너레이터에서부터 시작해보자: yield는 제너레이터의 실행을 어떤 시점에서 멈출 수 있기 때문에, 비동기 요청이 끝날 때까지 기다린 후 다음 코드가 실행되게 할 수 있다. 다음 예제에서 생각해보자:
우리의 애플리케이션이 백엔드로부터 필요한 정보를 받는 “init” 함수를 갖고 있다고 하자. 예를 들어 사용자 목록을 받아올 때는, XHR 요청을 필요로 하기 때문에 비동기 메서드일 것이다. 프로미스를 사용한다면, 우리는 반드시 프로미스가 끝났을 때 실행될 콜백함수를 정의해야 한다.
제너레이터 함수에서는 비동기 함수인 “getUsersFromDataBase”의 종료를 기다리기 위해 yield를 사용하면 된다. 그리고 users에 반환 값을 넣어주면 된다.
이 방식이 확실히 읽기 쉽지만, 엄청 단순하게 동작하는 것은 아니다. 용어가 암시하듯이, “yield” 키워드는 정말 실행 권한을 제너레이터 함수의 호출자에게 맡긴다. 이것은 프로미스를 양도받는 역할을 하는 외부 함수가 있어야 한다는 것을 의미한다. 프로미스가 끝날 때까지 기다렸다가 제너레이터 함수에 반환 값을 넘겨주어, 함수의 실행이 재개되고 이 값이 users라는 변수에 할당되도록 한다.
위 예제는 우리가 이전에 알지 못했던 제너레이터 함수의 특징을 알게 해준다: 제너레이터 함수는 “멈춤 지점”이라면 외부 함수로 값을 반환할 뿐만 아니라, 외부함수로부터 값을 받을 수 있다. 이는 두 코드 간의 back-and-forth 커뮤니케이션을 허용한다.
이터레이터의 “next” 메서드의 변수로 전달된 값은 제너레이터 함수로 전달된다. 다음 예제는 어떻게 제너레이터 함수를 호출하는 “외부” 코드가 프로미스를 끝내고, 반환된 값을 제너레이터 함수로 보내는 역할을 하는지에 대한 설명이다:
getUsersFromDatabase의 구현은 중요하지 않다. 이 함수는 2초 후에 “Test Users”라는 문자열을 반환하면서 종료되는 프로미스를 반환한다. 어떻게 외부 코드(6-30번째 줄)가 제너레이터의 마지막 값을 얻기 위해 제너레이터를 호출하고(6-7번째 줄), 프로미스가 반환한 값을 처리해서(16번째 줄에서 시작하는 콜백), 제너레이터에 전달하는지(20번째 줄)에 주목하자. 13, 18, 28번째 줄은 각 시점의 변수의 값을 보여주기 위한 곳으로, 그 값은 어두운 파란색 글자로 표시되며, 인라인 실행 도구에 의해 추가됐다.
이 예제는 제너레이터 함수가 하나의 값만 양도(yield)하는 특정한 경우에 대한 것으로 단순한 시나리오이다. 이론적으로, 외부 함수는 제너레이터의 마지막 return 구문에 다다르기 까지 지나치는 모든 프로미스를 반환해야 한다. 서드 파티 라이브러리에서도 같은 관계이다. 외부 함수가 프로미스를 어떻게 처리하고 해결하는지 상관하지 않고, 비동기로 기다리는 코드를 generators/yield 방식으로 작성할 수 있게 한다. 이 라이브러리들은 제너레이터 함수를 argument로 받아서, 제너레이터를 실행하고, yield 된 프로미스를 다루는 기능 제공한다.
이 긴 예제는 Async/await이 엄청 유용한 기능인지 보여주기 위한 코드이다: 비동기 코드를 generator 함수 예제(1-4번째 줄)와 비슷한 데, 심지어는 프로미스를 다루기 위한 외부의 헬퍼 함수가 필요하지도 않는다! async/await을 쓰면, 이렇게만 쓰면 된다:
제너레이터의 별표(*)는 함수 선언부 앞에 오는 async 키워드로, “yield” 키워드는 “await”으로 대체되었다. Await은 프로미스를 반환하는 구문이기만 하면 그 앞에 놓일 수 있다. 그리고 await 키워드는 그 자신보다 먼저 선언된, async 키워드가 있는 함수 안에서만 사용할 수 있다. 이제 테스트 함수가 실행될 때, 다른 함수의 도움없이 await 키워드에서 멈추고, 프로미스가 끝나길 기다렸다 자동으로 프로미스에서 반환된 값을 users라는 const 변수에 할당할 것이다.
Async/await은 프로미스를 .then() 메서드나 콜백 정의, 그리고 이것들의 중첩현상(악명높은 죽음의 피라미드) 없이 다루는 코드를 작성하게 한다. 좋은 해결방법인 것처럼 보이나 이것을 사용하기 전에 반드시 생각해야 할 몇 가지 중요한 점이 있다. 때때로, 이전의 .then()의 프로미스 방식을 고수하는 것이 더 좋은 방법일 수 있다. 다음에 오는 내용을 꼭 생각해봐야 한다:
1) 비동기 함수는 항상 프로미스를 반환한다: 비동기 함수는 모든 “await” 키워드에서 실행을 잠깐 멈추고 비동기 구문이 종료되길 기다린다. 그래서 await이 붙는 함수 자체가 비동기적이다(이 때문에 비동기 함수 앞에 async 키워드가 붙는 것이다). 이는 async 키워드를 가진 함수는 무엇을 반환하든 간에 항상 리졸브되거나 에러를 던지는 프로미스를 반환한다는 것이다. 이전 예제에서 “test” 함수는 문자열 “Test Users Correctly received”라는 문자열을 반환했다. 그러나 실제로는 이 문자열과 함께 해결되는 프로미스가 반환됐다. 그래서 코드를 설계할 때나 코드의 다른 부분이 주어진 함수와 어떻게 상호작용하도록 고민할 때, 프로미스를 받기 원하는지 아닌 지를 꼭 생각해야 한다.
2) Await은 항상 프로미스를 병렬적이 아닌, 순차적으로 기다린다 await 키워드는 한 번에 여러 개 아닌, 하나의 프로미스만 기다릴 수 있다. 그래서 만약 여러 개의 프로미스를 다루면서, 이 각각이 await 키워드를 통해 기다리길 바란다면, 하나의 동작이 완전히 끝나야 다음 동작으로 넘어갈 수 있다.
동시에 수백 개의 요청을 보내 네트워크에 부하를 주는 것을 방지하고 싶을 때처럼 이 방식이 최선일 때가 있지만, 이 방식은 동시에 여러 개의 프로미스를 처리하는 것보다 훨씬 느리다.
사용자 목록에 대한 배열인 “users”가 이전에 선언되어 있다고 가정하자. 그리고 “getProfileImage”가 프로필 이미지를 반환하는 프로미스를 반환한다고 가정하자. 이 예제는 각각의 사용자들을 순회하는데, “profileImages” 배열에 프로필 이미지를 넣기 위해 매 차례에서 잠깐 멈춘다. 이것은 현재 이터레이션의 프로미스가 끝났을 때만 다음 이터레이션으로 이동한다.
동시에 여러 프로미스를 처리하는 것에 대한 대안은 await과 프로미스를 같이 쓰는 것이다. 예를 들어, “Promise.all”로 프로미스 그룹을 관리할 수 있는 데, 이는 그룹의 모든 프로미스가 끝날(또는 실패 시 에러를 반환) 때 끝나는 하나의 프로미스를 반환한다. 그러면, 이 하나의 통합된 프로미스에 await을 사용하면 된다.
이 예제는 “map”을 사용해서 users 배열을 순회한다: 각각의 차례에서 “getProfileImage”가 실행되고, 실행되지 않는 프로미스를 반환한다. Map은 각 차례에서 일시정시하지 않고, 모든 프로미스를 가지는 배열을 반환한다는 것에 주목하자. 그러고 나서 우리는 이 모든 프로미스들을 “Promise.all”로 묶고, 이 한 개의 프로미스가 해결되는 것을 기다리기 위해 딱 이 시점에서만 await을 사용한다.
기억하자 - 제너레이터와 비동기 함수는 항상 특별한 타입의 객체를 반환한다.
- Generator 함수: 값 X를 yield/return하면, 이것은 항상 {value: X, done: Boolean} 형태의 이터레이션 객체를 반환한다.
- 비동기 함수: 값 X를 반환하면, X를 반환하면서 끝나거나 에러를 던지는 프로미스를 반환한다.
결론
제너레이터는 실행을 잠깐 멈출 수 있는 함수이다. 이터레이터 객체가 다음 값을 요청할 때마다 위임된(yield) 값을 생성한다. 이런 의미에서, 제너레이터는 수동적인 생산자인 반면 이터레이터는 적극적인 소비자이다(왜냐하면 값을 요청하는 것에 대한 주도권을 갖고 있기 때문이다). 이것은 일반적인 옵저버 패턴과 대조적이다. 옵저버 패턴에서는 적극적인 생산자(옵저버블, 주체)가 필요할 때 그 값을 반환하고, 하나 이상의 수동적인 소비자(옵저버)가 있어서 값이 반환되기를 기다리고 있다. 제너레이터 함수는 리스트에서 한 번에 하나의 값만 반환하는 데 사용할 수 있다. 아마도 필요에 따라 무한한 값을 생성하는 데 사용할 수 있다.
제너레이터 함수의 특별한 사용법 중의 하나는 프로미스를 양도하고(yielding) 동기식으로 동작하는 것처럼 보이는(“기다리는”) 비동기 코드를 작성하는 것이다. 그런데 이 방식은 반환되는 프로미스들을 다룰 다른 함수의 도움이 필요하다. 이런 경우에는 헬퍼 함수가 필요없는 async/await을 사용하는 것이 더 나은 방법이다.
비동기 함수와 await 키워드는 비동기 코드를 “기다리는” 방식으로 작성하기 위한 훌륭한 방법이다. 그러나 한 번에 여러 개의 프로미스를 기다릴 수 없기 때문에, 이런 한계 상황에서는 이전의 프로미스 폴백 방식을 사용하는 것이 낫다.