본문 바로가기
👩‍💻 Programming/JavaScript

미래를 향한 함수_1. 제너레이터(generators) from Secrets of the JavaScript Ninja

by codingBear 2022. 4. 13.
728x90
반응형

이번 글은 'Secrets of the JavaScript Ninja'의 'Chapter 6. Funtions for the future: generators and promises'를 바탕으로 작성하였습니다.


핵심 Concepts

  • 제너레이터를 활용한 끊임 없는 함수 실행
  • 프로미스를 활용한 비동기적(asynchronous) 작업 수행
  • 제너레이터와 프로미스를 합쳐 유려한 비동기적 코드 작성

사전 지식 체크!

  • 제너레이터 함수의 일반적인 사용에는 어떤 것들이 있는가?
  • 비동기적 코드를 구현하는 데 일반적인 콜백보다 프로미스가 더 나은 이유?
  • Promise.race.를 활용하여 긴 코드 작업을 수행할 때 프로미스는 언제 해결되는가? 그리고 언제 해결되는 데 실패하는가?

들어가며

 일반적인 함수는 처음부터 끝까지 코드를 실행하면서 최대 한 가지의 값만을 반환한다. 제너레이터는 각 요청별로 여러 개의 값을 생성하고 이러한 요청 간에 코드 실행을 일시 중단한다. 

 이번 글을 통해 제너레이터를 활용하여 복잡한 for 반복문을 어떻게 간단히 작성할 수 있는지, 또 비동기적 코드를 간단하고 유려하게 작성하는 데 도움이 되는 실행을 중지했다 재개하는 제너레이터의 기능을 활용하는 방법을 알아볼 것이다.

 또한 비동기적 코드를 작성하는 데 도움을 주는 새로운 내부 객체인 프로미스도 살펴볼 것이다. 프로미스는 아직 없으나 추후에 갖게 될 값에 대한 자리 표시자(placeholder)이다. 이는 여러 단계로 작동하는 비동기적 작업을 수행하는 데 특히 뛰어나다.

 위 두 개념을 합쳐 어떻게 하면 비동기적 작업을 수행하는 코드를 간략히 작성할 수 있을지에 대해 이번 글에서 주로 살표보겠다.


Making our async code elegant with generators and promises

 상상을 하나 해보자. 우리는 freelanceninja.com의 개발자로 일하고 있다. 이 사이트는 고객들에게 비밀 임무를 수행하는 닌자를 고용하는 데 도움을 주는 사이트이다. 우리의 과업은 고객들로 하여금 가장 인기 있는 닌자가 수행한 최고 등급 임무에 대한 세부 정보를 얻게 하는 기능을 구현하는 것이다. 닌자를 나타내는 자료, 임무에 대한 요약, 임무에 대한 세부 사항 등이 원격 서버에 저장되어 JSON 형식으로 인코딩된다. 이 작업을 코드로 작성하면 아래와 같다.

 

 

 

 위 코드 실행 중 오류가 발생한다면 catch 블록에서 바로 잡아낼 것이다. 그러나 안타깝게도 위 코드에는 큰 문제가 있다. 서버에서 데이터를 받아오는 일은 매우 기나긴 작업인데 JavaScript는 싱글 스레드 실행 모델이라서 기나긴 데이터 받아오는 작업이 끝날 때까지 UI 구현 과정이 멈출 것이다. 이는 사용자에게 실망감을 제공함과 더불어 반응형 애플리케이션을 구현하는 데도 지장을 준다. 이 문제를 해결하기 위해 우리는 위 코드를 UI 구현 과정을 멈추는 일 없이 작업이 끝날 때마다 호출되는 콜백을 활용하여 다시 작성할 수 있다.

 

 

 아까 작성했던 코드보다는 훨씬 사용자가 받기 쉬운 코드이지만 보기에 복잡한 것은 사실이다. 여기서 제너레이터와 프로미스를 활용한다면 어색한 콜백 코드를 훨씬 더 유려하게 바꿀 수 있다.

 

 

 위 예제 코드에 활용된 제너레이터와 프로미스의 개념에 대해서 앞으로 더 자세히 살펴보겠다.


Working with generator funtions

 제너레이터는 연속적으로 값을 생성해내나 일반적인 함수처럼 한꺼번에 모든 값을 생성하지 않고 각 요청에 따라 값을 생성한다. 이러한 점에서 일반적인 함수와는 전혀 다른 형식의 함수이다. 개발자가 제너레이터에 값을 요청하면 제너레이터는 값을 반환하거나 생성할 값이 더 이상 없다는 사실을 알려준다. 여기서 이상한 점은 값을 반환하고 나면 작업을 끝내는 일반 함수와 달리 제너레이터는 작업을 중단하지 않는다. 대신 제너레이터는 작업을 연기한다(suspend). 그리고 다시 값에 대한 요청이 있으면 멈췄던 곳에서 작업을 재개한다. 아래 예제는 제너레이터를 활용하여 값을 연속적으로 반환하는 간단한 예이다.

 

 

 위 예제 코드에서는 우선 연속적으로 무기를 만들어내는 제너레이터를 정의하는 것부터 시작한다. 제너레이터 함수를 만드는 것은 간단하다. 바로 function 키워드 오른쪽에 별표(*)를 붙이면 된다. 이 별표는 제가끔의 값을 반환하기 위한 yield 키워드를 제너레이터 안에서 사용할 수 있게 해준다. 이 제너레이터를 통해 연속적으로 생성된 값들을 사용하는 방법은 바로 for-of 반복문이다. for-of 반복문을 실행한 결과는 다음과 같다.

 

 

 그런데 위 예제 코드의 WeaponGenerator 함수를 아무리 살펴보아도 return 구문이 보이지 않는다. 어떻게 값들이 반환되는 것일까? 해답은 바로 제너레이터는 일반 함수와 다르다는 점이다. 제너레이터를 호출하는 것은 제너레이터 함수를 실행시키지 않는다. 대신 반복자(iterator)라는 객체를 새로 생성한다. 이 반복자에 대해 더 살펴보자.


Controlling the generator through the iterator object

 앞서 언급했듯 제너레이터를 호출하는 것이 제너레이터 함수를 실행시킨다는 뜻은 아니다. 대신 반복자 객체를 생성하고 이 객체를 통해 제너레이터와 소통 가능하다. 예를 들어 추가적인 값을 요구하는 데 반복자를 쓸 수 있다.

 

 

 위 예제 코드에서 보듯 제너레이터를 호출하면 새로운 반복자가 생성된다.

 

 

 이 반복자는 제너레이터의 실행을 제어하는 데 쓰인다. 반복자 객체가 내보내는 근본적인 것들 중 하나는 바로 next 메소드이다. next 메소드로써 제너레이터로부터 값을 요청하는 방식으로 제너레이터를 제어한다.

 

 

 해당 호출에 대한 응답으로 제너레이터는 yield 키워드에 이를 때까지 코드를 실행한다. yield 키워드는 중간 결과(intermediary result)를 생성한다.(연속적으로 생성되는 요소 내의 한 가지 요소) 그리고 결과를 캡슐화하고 작업이 끝났다는 것을 알려주는 새로운 객체를 반환한다.

 현재 값이 생성되자마자 제너레이터는 블록킹되는 일 없이 실행을 멈추고 또 다른 값에 대한 요청을 기다린다. 이 같은 특징은 일반적인 함수에는 없는 매우 강력한 제너레이터만의 특징 중 하나이다.

 위 예제 코드의 경우 반복자의 next 메소드에 대한 첫 번째 호출은 첫 번째 yield 표현에 대한 제너레이터 코드를 실행하고 프로퍼티 값이 'Katana'로 설정된 객체를 반환한다. 그리고 프로퍼티 done의 값이 false로 설정되어 반환할 값이 더 남았음을 알린다.

 이후 weaponIterator의 next 메소드에 대한 또 다른 호출을 만들어 다른 값을 요청한다.

 

 

 위 코드는 제너레이터의 작업을 중단되었던 곳에서부터 재개하여 다른 중간 값인 yield 'Wakizashi'에 이를 때까지 실행된다. 이는 제너레이터의 작업을 연기함과 동시에 Wakizashi 값을 담은 객체를 생성한다.

 마지막으로 세 번째 next 메소드를 호출하면 제너레이터의 작업은 또 한 번 재개된다. 이번에는 더 이상 실행할 코드가 없기 때문에 제너레이터는 값이 undefined로 설정된 객체를 반환하고 프로퍼티 done의 값은 true가 되어 작업이 종료되었음을 알린다.

 

Iterating the iterator 

 제너레이터 호출에 의해 생성되는 반복자는 제너레이터에서 새 값을 요청할 수 있는 next 메소드를 내보낸다. next 메소드는 제너레이터에 의해 생성되는 값을 담은 객체를 반환함과 동시에 프로퍼티 done을 담는다. 프로퍼티 done은 추가적으로 생성해야 할 값이 있는지 제너레이터에 알려주는 역할을 한다.

 이 같은 이점을 활용하여 while 반복문으로 제너레이터에 의해 생성된 값을 반환하는 코드를 살펴보자.

 

 

  1. 제너레이터 함수를 호출하여 반복자 객체를 생성
  2. 제너레이터에 의해 생성될 값을 담는 item 변수를 선언
  3. while 반복문이 한 번 실행될 때마다 weaponsIterator의 next 메소드를 호출함으로써 제너레이터에서 생성된 값을 붙여넣고 item 변수에 해당 값을 저장
  4. done 프로퍼티를 활용하여 작업이 끝나지 않았다면 item의 value를 반환하고 더 이상 반환할 값이 없다면 작업을 종료한다.

 

 위 예제에서 while 반복문의 조건 부분이 조금 까다롭다. 이를 for-of 반복문을 활용한다면 코드를 더 간략히 작성하면서도 똑같은 기능을 구현할 수 있다.

 

Yielding to another generator

 일반적인 함수를 다른 일반적인 함수 내에서 호출하듯 제너레이터의 실행을 다른 제너레이터에 위임할 수 있다. 아래 예제를 통해 살펴보자.

 

 

 이 코드를 실행하면 인출값으로 'Sun Tzu', 'Hattori', 'Yoshi', 'Genghis Khan'을 보게 될 것이다. yield* 연산자를 사용함으로써 다른 제너레이터를 넘겨줄 수 있다. 여기서 'Hattori'와 'Yoshi'는 yield* 연산자로 양도된 NinjaGenerator에서 생성된 값이다. 

 우선 WarriorGenerator는 위에서부터 차례대로 값을 생성하다 yield* 키워드를 만나면 다른 제너레이터에서 양도된 값을 생성하고 해당 작업이 끝나면 다시 원래 제너레이터로 돌아가서 남은 값을 생성한다. 이때 for-of 반복문은 WarriorGenerator가 다른 제너레이터에 양도를 하는지 신경 쓰지 않고 그저 다음 값만을 반환한다.


Using generators

Using generators to generate IDs

 특정한 객체를 생성할 때 고유한 ID를 각 객체에 부여할 필요가 있다. 제너레이터를 활용하여 해당 기능을 구현해보자.

 

 

 위 예제 코드에서 변수 id는 IdGenerator에 대한 지역 변수이고 ID 카운터를 나타낸다. 이에 뒤따라 반복할 때마다 새로운 id값을 생성하는 while 반복문이 오고 다른 ID에 대한 요청이 올 때까지 작업이 연기된다. 여기서 일반 함수 내에 조건이 true인 동안 실행되는 while 반복문을 선언했다면 무한 루프에 빠지겠지만 제너레이터 안에 쓰게 된다면 얘기가 달라진다. 제너레이터는 yield 키워드로써 연속적인 작업을 단속적으로 변경 가능하기 때문에 다른 값을 얻으려면 반드시 해당 값에 대한 요청을 해줘야 한다.

 제너레이터를 정의하고 나서 반복자 객체를 생성한다. 이는 idIterator.next() 메소드로써 제너레이터를 제어할 수 있게 해준다. 반복자 객체는 yield 키워드를 마주칠 때까지 실행되고 객체에서 활용 가능한 새로운 ID 값을 반환한다.


Using generators to traverse the DOM

 웹 페이지의 레이아웃은 DOM에 기초한다. DOM은 나무 형상 같은 HTML의 노드(node) 구조로 뿌리 노드를 제외한 모든 노드는 오직 하나의 부모 노드만을 갖고 0개 혹은 그 이상의 자식 노드를 갖는다. DOM은 웹 개발의 근본적인 구조이기 때문에 많은 코드들이 DOM을 순회하는 것을 기반으로 한다. 이 같은 순회 작업을 수행하는 비교적 간단한 예는 바로 재귀 함수(recursive function)를 사용하는 것이다.

 

 

 위 코드를 실행하면 재귀 함수로써 조회한 subTree의 모든 하위 요소들이 출력된다. 위와 똑같은 기능을 제너레이터로도 작성할 수 있다.

 

 

 위 예제는 제너레이터를 사용하여 DOM 구조의 하위 요소들을 탐색할 수 있음을 보여준다. 앞서 재귀 함수를 활용한 코드와 기능적으로는 똑같지만 사용하기 복잡한 콜백 구문을 쓰지 않고 비교적 단순한 for-of 반복문을 통해 모든 하위 요소들을 출력할 수 있다는 장점이 있다.


Communicating with generator

 앞서 살펴본 활용법 외에도 제너레이터를 활용할 길은 다양하다. 예를 들어 제너레이터에 데이터를 보내어 양방향으로 소통 가능하다. 제너레이터를 활용하여 중간 결과를 생성해내고 그 결과를 활용하여 제너레이터 외부에서 무언가를 계산한 다음 준비가 될 때마다 완전히 새로운 데이터를 제너레이터로 보내어 실행을 재개할 수 있다.

 이를 활용하여 비동기적인 코드를 작성할 수 있는데 우선 간단한 예제부터 살펴보자.

 

 

 함수가 새로운 데이터를 입력 받는 것은 특별한 일이 아니다. 허나 제너레이터는 작업을 중단했다 다시 재개하는 강력한 기능을 가졌다. 따라서 제너레이터는 이 같은 특징을 통해 작업을 시작하고 나서도 데이터를 받을 수 있고 다음 값을 요청함으로써 해당 제너레이터 작업을 재개할 수 있다. 

 

Using the next mewthod to send values into a generator

 제너레이터가 처음 호출될 때 데이터를 제공하기 위해 next 메소드에 인수를 전달함으로써 제너레이터 안에 데이터를 보낼 수 있다. 아래 예제를 통해 살펴보자.

 

 

 위 예제에는 ninjaIterator의 next 메소드에 대한 호출이 두 개 있다. 첫 번째 호출은 제너레이터에서 첫 번째 값을 요청한다. 제너레이터는 아직 실행되지 않은 상태여서 이 호출로써 제너레이터가 실행되고 'Hattori' + action 표현식을 계산하여 Hattori skulk라는 값을 생성한 다음 제너레이터의 실행을 중단한다. 여기까지는 별반 특별한 점이 없다.

 재미난 일이 ninjaIterator의 두 번째 next 메소드 호출인 ninjaIterator.next('Hanzo')에서 생긴다. 이번에는 next 메소드를 제너레이터에 데이터를 전달하기 위해 사용한다. 제너레이터 함수는 작업이 중단된 yield 표현식('Hattori ' + action)에서 작업이 재개되기를 기다리다가 next 메소드의 인수로 전달된 Hanzo라는 값이 yield 표현식 전체에 대한 값으로 쓰인다. 즉 앞선 작업에서 쓰였던 변수 imposter의 값 yield ('Hattori ' + action)은 Hanzo라는 값으로 바뀐다.

 이것이 바로 제너레이터를 활용하여 양방향 소통을 하는 방법이다. yield 키워드를 사용하여 제너레이터에서 값을 생성하고 반복자의 next 메소드를 활용하여 제너레이터에 데이터를 전달한다.

 

next 메소드는 작업이 재개되기를 기다리는 yield 표현식에 값을 전달한다. 따라서 작업 재개를 대기 중인 yield 표현식이 없다면 값을 전달할 수 없다. 즉 첫 번째 next 메소드 호출에는 값을 전달할 수 없다는 말이다. 따라서 제너레이터에 초기값을 전달하고 싶다면 NinjaGenerator('skulk')와 같은 형식으로 값을 전달한다.

 

Throwing exceptions

 덜 전통적인 방식이기는 하나 예외를 throw함으로써(throwing exception) 제너레이터에 값을 보충할 수 있다. 각 반복자는 next 메소드를 갖는 것 이외에도 제너레이터에 예외를 throw하는 데 사용하는 throw 메소드를 가진다. 예제를 살펴보자.

 

 

 위 예제 코드는 앞서 살펴봤던 예제 코드와 비슷하게 시작하나 제너레이터 안의 내용이 전혀 다르다. 여기서는 try-catch 블록을 활용하여 함수 내용을 채웠다. 그러고 나서 반복자를 생성하고 제너레이터에서 한 가지 값을 가져온다. 이후 모든 반복자에 사용 가능한 throw 메소드를 활용하여 제너레이터에 예외를 다시 throw해 넣는다.

 어째서 굳이 제너레이터에 예외를 throw하는지는 나중에 차차 살펴보도록 하고 제너레이터가 어떻게 작동하는지 그 원리를 살펴보겠다.


Exploring generators under the hood

 이때까지 살펴보았듯 제너레이터를 호출한다고 해서 제너레이터가 실행되지는 않는다. 대신 새로운 반복자를 만들어 제너레이터로부터 값을 요청하는 데 쓴다. 값을 생성하고 난 후, 제너레이터는 하던 작업을 멈추고 다음 요청을 기다린다. 즉 제너레이터는 다음 단계를 오가는 작은 프로그램과 같다.

 

  • 시작 연기(Suspended Start): 제너레이터가 처음 생성되었을 때 이 단계에서 시작한다. 아직 어떤 제너레이터도 작동하지 않는다.
  • 실행(Excuting): 실행은 코드의 맨 처음 혹은 작업이 멈추었던 곳부터 이어진다. 일치하는 반복자의 netxt 메소드가 있으면 제너레이터는 이 상태로 이동한다.
  • yield 중단(Suspended yield): 제너레이터가 실행되는 동안 yield 표현식을 마주치면, 반환값을 담은 새로운 객체를 생성하고 실행을 중단한다. 이 단계는 제너레이터가 작업을 중단하고 실행이 재개되기를 기다리는 단계이다.
  • 완료(Completed): 작업 도중 return 문구를 만나거나 작업을 끝까지 다하면 이 단계에 이르게 된다.

 

 위 작업 단계를 그림으로 나타내면 아래와 같다.

 

 

Tracking generators with execution contexts

 일반적인 함수와 비교해서 제너레이터는 조금 특별하다. 하지만 제너레이터는 여전히 함수이기 때문에 제너레이터와 실행 컨텍스트 간의 상관 관계 위주로 살펴보면서 작동 원리를 자세히 알아보겠다.

 

 

 1번 그림은 NinjaGenerator 함수를 호출하기 전 애플리케이션의 실행 상태를 나타낸다. 전역 코드를 실행하기 때문에 실행 컨텍스트 스택에는 식별자가 담긴 전역 환경을 참조하는 전역 실행 컨텍스트만이 쌓여 있다. NinjaGenerator 식별자만이 함수를 참조하기 때문에 다른 모든 식별자들은 undefined이다.

 이후 2번 그림을 보면 NinjaGenerator를 호출한다. 제어 흐름은 제너레이터에 진입한다. 다른 함수에 진입했을 때와 마찬가지로 새로운 NinjaGenerator 일치하는 렉시컬 환경에 따라 실행 컨텍스트 요소가 생성되어 스택 위에 쌓인다. 제너레이터는 일반 함수와 다르기 때문에 이 단계에서 어떠한 함수 코드도 실행되지 않는다. 대신 코드 안에서 ninjaIterator로서 참조할 새로운 반복자가 생성되어 반환된다. 반복자는 제너레이터를 제어하는 데 쓰이며 자신이 생성된 실행 컨텍스트에 대한 참조를 얻는다.

 여기서 프로그램 실행이 제너레이터에서 벗어났을 때 재미난 일이 생긴다. 아래 예제와 함께 살펴보자.

 

 

 일반적인 함수의 경우 값이 반환되어 실행이 끝나면 해당 함수의 실행 컨텍스트와 일치하는 실행 컨텍스트는 스택에서 제거된다. 하지만 제너레이터는 다르다.

 NinjaGenerator와 일치하는 스택 요소가 스택에서 제거되어도 여전히 존재하며 ninjaIterator는 해당 제너레이터에 대한 참조를 유지한다. 이는 클로저와 유사하다고도 볼 수 있다. 클로저에서 함수 클로저가 생성되는 시점에 살아 있는 변수의 활성화를 유지할 필요가 있다. 따라서 함수는 자신이 생성된 환경에 대한 참조를 유지한다. 이렇게 하여 함수가 존재하는 한 해당 환경과 더불어 변수는 활성화 상태를 유지한다. 반면 제너레이터는 작업 실행을 나중에 재개할 수 있어야 한다. 왜냐하면 모든 함수의 실행은 실행 컨텍스트에 의해 다뤄지고 반복자는 그 실행 컨텍스트에 대한 참조를 유지하므로, 반복자가 제너레이터를 필요로 하는 한 활성화가 유지되는 것이다. 

 또 다른 흥미로운 일이 반복자의 next 메소드를 사용했을 때도 일어난다. 아래 예제와 함께 살펴보자.

 

 

 만약 일반적인 함수 호출이라면 새로운 next() 실행 컨텍스트 요소가 생성되어 스택 위에 쌓일 것이다. 하지만 제너레이터의 반복자는 다르게 작동한다. 제너레이터는 일치하는 컨텍스트를 재활성화시키고(여기서는 NinjaGenerator 컨텍스트) 스택 맨 위에 쌓은 다음 작업이 중단되었던 곳에서 작업을 이어간다. 

 위 예제는 일반 함수와 제너레이터 간의 차이점을 보여준다. 일반 함수는 오로지 새로운 것만 호출할 수 있으며 각 호출은 새 실행 컨텍스트를 생성한다. 반대로 제너레이터의 실행 컨텍스트는 잠시 작업을 멈췄다가 재개하는 방식이다.

 예제의 경우 next 메소드가 처음 호출되었기 때문에 제너레이터는 실행을 시작하지 않아 우선 실행을 먼저 시작하고 실행 상태(Executing state)로 이동한다. 여기서 yield 'Hattori ' + action에 이르렀을 때 주목할 만한 일이 생긴다.

 

 

 제너레이터는 표현식이 'Hattori skulk'와 같고 해당 평가가 yield 키워드에 다다랐다고 판단한다. 이는 'Hattori skulk'가 제너레이터의 첫 번째 중간 결과이며 제너레이터의 실행을 멈추고 해당 값을 반환한다는 것을 뜻한다. 애플리케이션 상태 측면에서 보면 전과 비슷한 일이 일어난다. NinjaGenerator 컨텍스트가 스택에서 제거되지만 ninjaIterator가 해당 참조를 유지하고 있으므로 완전히 삭제되지는 않는다. 제너레이터의 작업은 중단되어 yield 중단 상태로 들어간다. 프로그램 실행은 result1에 생성된 값을 저장함으로써 전역 코드 안에서 재개된다. 이와 같은 과정이 위 그림에 나타나 있다.

 코드 실행은 나머지 반복자 호출에 이르러 재개된다. 이 시점에서 모든 절차를 한 번 더 반복한다. ninjaIteratro에 의해 참조되는 NInjaGenerator 컨텍스트를 재활성화하여 스택 위에 쌓는다. 그리고 작업이 중단되었던 곳부터 작업을 재개한다. 이 경우 제너레이터는 표현식을 'Yoshi' + action이라고 평가한다. 이 표현식은 'Yoshi skulk'라는 값을 반환하고 완료 단계에 접어듦으로써 제너레이터의 실행은 종료된다.


함께 보기

  • 제너레이터(generators)

https://javascript.info/generators

 

Generators

 

javascript.info

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator

 

Generator - JavaScript | MDN

The Generator object is returned by a generator function and it conforms to both the iterable protocol and the iterator protocol.

developer.mozilla.org

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Iterators_and_Generators

 

Iterators and generators - JavaScript | MDN

Iterators and Generators bring the concept of iteration directly into the core language and provide a mechanism for customizing the behavior of for...of loops.

developer.mozilla.org

  • yield

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/yield

 

yield - JavaScript | MDN

The yield keyword is used to pause and resume a generator function.

developer.mozilla.org

 

728x90
반응형

댓글