Skip to content

Instantly share code, notes, and snippets.

@qodot
Last active July 31, 2023 01:30
Show Gist options
  • Star 21 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save qodot/ecf8d90ce291196817f8cf6117036997 to your computer and use it in GitHub Desktop.
Save qodot/ecf8d90ce291196817f8cf6117036997 to your computer and use it in GitHub Desktop.
ES6의 심볼, 이터레이터, 제네레이터에 대해 알아보자

심볼

심볼이 무엇인가? ES6에서 새로 선보인 원시 타입이다.

  • Undefined
  • Null
  • Boolean
  • Number
  • String
  • Object
  • Symbol
typeof Symbol();  // 'symbol'

심볼은 왜 필요한가? 객체에 Unique한 속성을 만들기 위해서이다. 그럼 Unique한 속성은 왜 필요한가? 다른 라이브러리와의 충돌을 막기 위함이다.

원래 객체의 속성 이름은 문자열로 표현했다. 그러나 새로 추가한 라이브러리가 Array 같이 범용적으로 쓰이는 타입을 확장했다고 했을 때,

  1. 내가 확장한 것과 같은 이름으로 확장했다면?
  2. 내가 Array의 속성의 갯수를 세고 있는 코드가 있었다면?

라이브러리를 추가한 것만으로도 기존의 코드가 정상적으로 작동하지 않는 상황이 충분히 벌어질 수 있다. 그러나 만약 심볼을 사용한다면,

  1. 심볼은 Unique하기 때문에, description이 같아도 충돌하지 않는다.
  2. 심볼은 객체의 속성을 순회하기 위한 for in, Object.keys(obj), Object.getOwnPropertyNames(obj)에 걸리지 않는다.
var mySymbol1 = Symbol('mySymbol');
var mySymbol2 = Symbol('mySymbol');
mySymbol1 === mySymbol2;  // false

var prop2 = Symbol('prop2')
var obj = {
    prop1: 1,
    [prop2]: 2,
};
for (var key in obj) {
    console.log(key);  // prop1 만 출력됨
}
obj[prop2];  // 2, 대괄호[]로만 접근 가능

Object.getOwnPropertySymbols(obj)를 사용하면 심볼 키들을 조회할 수 있고, Reflect.ownKeys(obj)에서는 문자열 키와 심볼 키를 모두 조회한다.

심볼의 생성

심볼은 다음과 같은 3가지 방법으로 생성할 수 있다.

  • Symbol('some'): 고유한 심볼을 리턴한다.
  • Symbol.for('some'): 심볼 레지스트리에서 lookup하여 description이 같으면 매번 같은 심볼을 리턴한다.
  • 상용 심볼: Symbol.iterator와 같이 표준에 정의된 심볼을 사용한다. 상용 심볼은 특별한 용도를 위해서 미리 만들어 놓은 것이다.

이해가 안되는 점: Symbol.for()로 심볼을 생성하게 되면 다른 라이브러리가 같은 이름으로 Symbol.for()를 사용했을 때 여전히 충돌할 가능성이 생길텐데, 충돌을 피하기 위해서 심볼을 도입해놓고 이런 기능을 왜 넣었는지 모르겠다.

대표적인 상용 심볼은 다음과 같다.

  • Symbol.iterator: 이터러블한 객체를 정의하기 위한 심볼
  • Symbol.hasInstance: instanceof를 확장하기 위한 심볼
  • Symbol.match: str.match(obj) 메소드는 obj를 정규표현식으로 변환해서 문자열 검색을 수행하는데, 이 기능을 확장하기 위한 심볼
  • 기타 등등

상용 심볼은 Symbol.match 대신 @@match로 표기할 수도 있다.


## `for of`

자바스크립트에는 많은 종류의 for 루프가 있다. 가장 원시적인,

for (var index = 0; index < myArray.length; index += 1) {
      console.log(myArray[index]);
}

가 있지만, 보통은 너무 장황하다. ES5 부터 들어간,

myArray.forEach(function (value) {
      console.log(value);
});

가 있지만, break, return 등으로 루프를 중단할 수가 없다. 그럼 많이 쓰는 for in은 어떨까?

for (var index in myArray) {
      console.log(myArray[index]);
}

for in 루프에서 index 값에 할당되는 것은 숫자가 아니라 문자열('0', '1', ....)이다. 게다가 이 루프는 배열의 인덱스만 순회하는 것이 아니라, 프로토타입 체인을 포함한 모든 속성을 순회한다. 원래 for in은 배열이 아니라 객체를 순회하기 위해서 만들어진 루프다.

그래서 for of 루프가 나왔다.

for (var value of myArray) {
    console.log(value);
}

for of 문은 for in으로 배열을 순회할 때 생기는 모든 단점들을 해결했다.


## Iterable, Iterator

그런데 더 중요한 것은, for of 문이 배열만을 위한 것이 아니라, 모든 '순회가능한(iterable)' 객체를 상대로 사용할 수 있다는 것이다.

파이썬의 for in과 같다.

Iterable

그렇다면 '순회가능한' 객체란 무엇일까? 바로 Symbol.iterator 심볼을 속성으로 가지고 있고, 이터레이터 객체를 반환하는 객체를 뜻한다. 이런 스펙을 이터러블 프로토콜 이라고 하고 이 프로토콜을 지킨 객체를 이터러블 객체라고 한다.

파이썬의 __iter__ 메소드와 같다. iter() 내장함수로 호출할 수 있다.

Iterator

그럼 이터러블 객체가 [Symbol.iterator]() 메소드로 반환하는 이터레이터 객체는 무엇일까? next() 메소드를 구현하고 있고, donevalue 속성을 가진 객체를 반환하는 객체이다. 이런 스펙을 이터레이터 프로토콜이라고 한다.

파이썬의 __next__ 메소드와 같다. next() 내장함수로 호출할 수 있다. 파이썬에서 done의 역할은 StopIteration 익셉션이 대신한다.

for of 루프는 순회를 시작하기 전, [Symbol.iterator]() 메소드를 호출하여 이터레이터 객체를 얻은 후, 순차적으로 next() 메소드를 호출하면서 하나씩 순회하는 것이다. 보통 이터러블 프로콜과 이터레이터 프로토콜을 하나의 객체에 모두 구현하는 것이 일반 적이다. 예를 들면 다음과 같다.

var zeroesForeverIterator = {
    [Symbol.iterator]: function () {
        return this;
    },
    next: function () {
        return {done: false, value: 0};
    }
};

영원히 0을 반환하는 무한 이터레이터이다.

이해가 안되는 점: 하위 호환성 때문에 iterator() 메소드를 추가하지 않고 심볼을 사용했다면, 왜 next() 메소드는 심볼을 사용하지 않았을까?

이터레이터 객체는 필요에 따라서 return() 메소드나 throw() 메소드를 구현할 필요도 있다.

  • return(): for of 루프가 break, return 문 때문에 정상 상황(donetrue인 상황)보다 먼저 루프를 빠져 나갈때 호출된다. 주로 이터레이터 객체에 정리할 자원이 있을 경우 상용된다.
  • throw(): for of 루프에서 발생한 예외를 이터레이터 안으로 전달하고 싶을 경우 사용한다.

이터레이터를 이용하면 다양한 타입의 객체를 하나의 프로토콜로 순회하며 다룰 수 있고, 엄청나게 커다란 크기의 데이터도 메모리의 부담 없이 Lazy-Evaluation이 가능하다는 성능상의 이점도 있다.


## 제네레이터

제네레이터를 한마디로 말하자면, 이터러블, 이터레이터 객체를 만드는 손쉬운 방법이다. 다음 예제를 보자.

class RangeIterator {
    constructor(start, stop) {
        this.value = start;
        this.stop = stop;
    }

    [Symbol.iterator]() {
        return this;
    }

    next() {
        var value = this.value;
        
        if (value < this.stop) {
            this.value += 1;
            return {done: false, value: value};
        } else {
            return {done: true, value: undefined};
        }
    }
}

function range(start, stop) {
    return new RangeIterator(start, stop);
}

방금 했던 이터러블/이터레이터 프로토콜을 구현해서 시작~끝 까지 1씩 더해나가면서 순회할 수 있는 객체를 만들어냈다. 그럼 똑같은 내용을 제네레이터를 이용해서 만들어보자.

function* range(start, stop) {
    for (var i = start; i < stop; i++) {
        yield i;
    }
}

자세히 살펴보자. 일단 제네레이터는 function*문으로 시작하고 yield문이 있는 함수다. 제네레이터 함수의 작동 순서는 다음과 같다.

  1. 제네레이터 함수를 일단 처음 실행해도 아무일도 일어나지 않는다. 그냥 제네레이터 객체가 리턴된다.
  2. 제네레이터 객체는 next() 메소드를 실행할 때마다 다음 yield 문까지 실행되고 정지한다. 다시 next()를 실행하면 아까 멈췄던 yield 부터 다음 yield까지 실행하고 다시 정지한다.
  3. 더 이상 yield가 없으면 제네레이터의 실행은 완전히 종료된다.

아마 바로 느낌이 왔겠지만, 제네레이터 객체는 이터레이터 객체이고(next() 메소드를 구현했음) 제네레이터 함수는 이터러블 객체이다(이터레이터를 반환함). 따라서 위의 예제에서, yield i의 반환 값은 실제로 이터레이터 프로토콜에 따라 {done: false, value: 1}이 된다. for loop을 다 순회하면 return undefined를 만나게 되므로, {done: true, value: undefined}을 반환하고 종료한다.

next() 메소드에 값을 전달 할 수 있다. 그러면 제네레이터 안으로 전달한 값이 주입된다. 다음 예제를 보자.

function *myGen() {
    const x = yield 1;       // x = 10
    const y = yield (x + 1); // y = 20  
    const z = yield (y + 2); // z = 30
    return x + y + z;
}

const myItr = myGen();
console.log(myitr.next());   // {value:1, done:false}
console.log(myitr.next(10)); // {value:11, done:false}
console.log(myitr.next(20)); // {value:22, done:false}
console.log(myitr.next(30)); // {value:60, done:true}

next() 메소드가 리턴하는 값은 제네레이터가 실행을 정지할 때 yield 문이 반환하는 값(yield문의 오른쪽)이고, next() 메소드의 파라메터는 제네레이터가 실행을 재개할 때 yield 문이 반환하는 값(yield 문의 왼쪽)이 된다.

제네레이터의 의의

이렇게 제네레이터를 사용하면, 이터레이터를 적은 코드로 가독성까지 높여서 손쉽게 만들 수 있다. 그런데 제네레이터는 단순히 이터레이터를 쉽게 만들기 위해서 생겨난 개념일까? 그렇지는 않다. 오히려 더욱 중요한 의의가 있는데 그것은 바로 동시성/비동기 프로그래밍이다.

코루틴

일반적인 함수(서브루틴: subroutine)가 콜 스택 위에서 어떻게 동작하는지는 다들 알고 있듯이, 실행될 때 콜 스택으로 올라오고 리턴하면 콜 스택에서 사라진다. 이 함수의 진입점은 항상 같은 곳(함수의 시작 부분)이고, 한번 콜 스택에서 사라진 함수를 다시 불러올 수 있는 방법은 없다.

그러나 제네레이터는, 제너레이터 객체가 yield 문에 도착하면, 제네레이터의 스택 프레임(각종 컨텍스트들)을 복사해놓고 콜 스택에서 일단 제거한다. 그러다가 next() 메소드가 호출되면 저장해 놓았던 스택 프레임을 다시 복원하고 실행한다. 즉 진입점을 개발자가 원하는 대로 설정할 수 있고 한번 올라온 컨텍스트를 원하는 만큼 유지시킬 수 있다. 이런 개념을 코루틴coroutine이라고 한다.

동시성

이 코루틴 형태를 잘 이용하면, 협력형 멀티태스킹 방식으로, 쓰레드 프로그래밍 없이 동시성 프로그래밍이 가능해진다. 쓰레드는 필요한 비용에 비해 신경써야 할 것들이 너무 많은 단점이 있지만, 코루틴은 OS의 암묵적인 스케쥴링이나 컨텍스트 스위칭 오버헤드, 세마포어 설정 같은 고민으로 부터 자유롭다.

쓰레드, 그린 쓰레드, 고루틴, 코루틴 등의 작동 방식과 장단점을 비교해 보는 것도 좋을 것 같다.

비동기

코루틴은 비동기로 돌아가는 코드를 작성하는데도 도움을 줄 수 있다. 실제로 코루틴 자체가 비동기로 작동하는 것은 아니다. (코루틴은 항상 동기적으로 작동한다) 비동기 동작 자체는 코루틴과 별개지만, 비동기 애플리케이션 프로그래밍에 항상 등장하는 콜백 함수와 그로 인한 콜백 지옥을 해결책으로 코루틴이 유효하다. 즉, 보기에는 동기 방식으로 돌아갈 것 처럼 생겼지만(그래서 보기 좋지만) 실제로는 비동기로 돌아가는 코드를 만들어 낼 수 있다.

다른 언어도 마찬가지지만, 특히 자바스크립트의 경우 비동기 프로그래밍이 일반화 되어 있기 때문에, 어떻게하면 보기도 좋고 관리도 잘 되는 비동기 코드를 짤 것인지에 대한 고민도 많았다.

먼저 콜백 지옥부터 보자. ID, Email, Name, Order를 차례대로 처리해야 하는 상황이라고 하자. 그냥 이렇게 하면 될 것이다.

function orderCoffee(phoneNumber) {
    const id = getId(phoneNumber);
    const email = getEmail(id);
    const name = getName(email);
    const result = order(name, 'coffee');
    return result;
}

너무 쉽다. 그러나 모든 코드는 동기식으로 처리되고 있다. 만약 각 정보를 읽어오는데 IO라도 발생한다면, 불필요하게 대기하는 시간이 늘어나 작업이 비효율적으로 진행될 수 밖에 없다. 그럼 가장 익숙한 콜백으로 비동기를 적용해보자.

function getId(phoneNumber, callback) { /* … */ }
function getEmail(id, callback) { /* … */ }
function getName(email, callback) { /* … */ }
function order(name, menu, callback) { /* … */ }

function orderCoffee(phoneNumber, callback) {
    getId(phoneNumber, function(id) {
        getEmail(id, function(email) {
            getName(email, function(name) {
                order(name, 'coffee', function(result) {
                    callback(result);
                });
            });
        });
    });
}

너무 보기 힘들다. 게다가 단순히 보기 안좋을 뿐 아니라 위험하기 까지 하다.

물론 ES6에서 나온 프로미스를 사용하면 한층 나은 코드를 짤 수 있다.

function getId(phoneNumber) { /* … */ }
function getEmail(id) { /* … */ }
function getName(email) { /* … */ }
function order(name, menu) { /* … */ }

function orderCoffee(phoneNumber) {
    return getId(phoneNumber).then(function(id) {
        return getEmail(id);
    }).then(function(email) {
        return getName(email);
    }).then(function(name) {
        return order(name, 'coffee');
    });
}

애로우 펑션까지 사용한다면?

function orderCoffee(phoneNumber) {
    return getId(phoneNumber)
        .then(id => getEmail(id))
        .then(email => getName(email))
        .then(name => order(name, 'coffee'));
}

많이 나아졌다. 그러나 역시 맨 처음 동기식 코드보다는 한눈에 들어오지 않는 것이 사실이다. 이 때, 제네레이터가 드디어 등장한다.

function* orderCoffee(phoneNumber) {
    const id = yield getId(phoneNumber);
    const email = yield getEmail(id);
    const name = yield getName(email);
    const result = yield order(name, 'coffee');
    return result;
}

비동기 함수 앞에 yield를 붙였을 뿐, 동기식 코드와 거의 유사한 모양으로 바뀌었다. 물론 이대로는 돌아가지 않는다. 제네레이터는 이터레이터이기 때문에 누군가가 next()를 호출해 주어야 하기 때문이다.

const iterator = orderCoffee('010-1234-1234');
iterator.next();

function getId(phoneNumber) {
    // …
    iterator.next(result);
}

function getEmail(id) {
    // …
    iterator.next(result);
}

function getName(email) {
    // …
    iterator.next(result);
}

function order(name, menu) {
    // …
    iterator.next(result);
}

물론 정말 이렇게 쓰라고 하면 아무도 쓰지 않을 것이다. 실제로는 계속 업데이트되는 ES의 문법(async, awiat과 다양한 라이브러리(co, koa 등)의 지원으로 코루틴 구현이나 프로미스와의 연동 등을 손쉽게 할 수 있게 되었다.


## 참조한 페이지
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment