네로개발일기

개발자 네로의 개발 일기, 자바를 좋아합니다 !

'programming language/ECMAScript6'에 해당되는 글 13건


반응형

1. 이터레이션 프로포콜

ES6에서 도입된 이터레이션 프로토콜(iteration protocol)은 데이터 컬렉션을 순회하기 위한 프로토콜이다. 이터레이션 프로토콜을 준수한 객체는 for...of문으로 순회할 수 있고 Spread 문법의 피연산자가 될 수 있다.

이터레이션 프로토콜에는 이터러블 프로토콜(iterable protocol)과 이터레이터 프로토콜(iterator protocol)이 있다.

이터레이션 프로토콜(Iteration Protocol)

1) 이터러블

이터러블 프로토콜은 준수한 객체를 이터러블이라 한다. 이터러블은 Symbol.iterator 메서드를 구현하거나 프로토타입 체인에 의해 상속한 객체를 말한다. Symbol.terator 메서드는 이터레이터를 반환한다. 이터러블은 for...of 문에서 순회할 수 있으며 Spread 문법의 대상으로 사용할 수 있다.

배열은 Symbol.iterator 메서드를 소유한다. 따라서 배열은 이터러블 프로토콜을 준수한 이터러블이다.

const array = [1, 2, 3];

// 배열은 Symbol.iterator 메서드를 소유한다.
// 따라서 배열은 이터러블 프로토콜을 준수한 이터러블이다.
console.log(Symbol.iterator in array); // true

// 이터러블 프로토콜을 준수한 배열은 for...of 문에서 순회 가능하다.
for (const item of array) {
  console.log(item);
}

일반 객체는 Symbol.iterator 메서드를 소유하지 않는다. 따라서 일반 객체는 이터러블 프로토콜을 준수한 이터러블이 아니다.

const obj = { a: 1, b: 2 };

// 일반 객체는 Symbol.iterator 메서드를 소유하지 않는다.
// 따라서 일반 객체는 이터러블 프로토콜을 준수한 이터러블이 아니다.
console.log(Symbol.iterator in obj); // false

// 이터러블이 아닌 일반 객체는 for...of 문에서 순회할 수 없다.
// TypeError: obj is not iterable
for (const p of obj) {
    console.log(p);
}

일반 객체는 이터레이션 프로토콜을 준수(Symbol.iterator 메서드를 소유)하지 않기 떄문에 이터러블이 아니다. 따라서 일반 객체는 for...of 문에서 순회할 수 없으며 Spread 문법의 대상으로 사용할 수도 없다. 하지만 일반 객체도 이터러블 프로토콜을 준수하도록 구현하면 이터러블이 된다.

 

2) 이터레이터

이터레이터 프로토콜은 next 메서드를 소유하며 next 메서드를 호출하면 이터러블을 순회하며 value, done 프로퍼티를 갖는 이터레이터 리절트 객체를 반환하는 것이다. 이 규약을 준수한 객체가 이터레이터이다.

이터러블 프로토콜을 준수한 이터러블은 Symbol.iterator 메서드를 소유한다. 이 메서드를 호출하면 이터레이터를 반환한다. 이터레이터 프로토콜을 준수한 이터레이터는 next 메서드를 갖는다.

// 배열은 이터러블 프로토콜을 준수한 이터러블읻.
const array = [1, 2, 3];

// Symbol.iterator 메서드는 이터레이터를 반환한다.
const iterator = array[Symbol.iterator]();

// 이터레이터 프로토콜을 준수한 이터레이터는 next 메서드를 갖는다.
console.log('next' in iterator); // true

이터레이터의 next 메서드를 호출하면 value, done 프로퍼티를 갖는 이터레이터 리절트(iterator result) 객체를 반환한다.

// 배열은 이터러블 프로토콜을 준수한 이터러블이다.
const array = [1, 2, 3];

// Symbol.iterator 메서드는 이터레이터를 반환한다.
const iterator = array[Symbol.iterator]();

// 이터레이터 프로토콜을 준수한 이터레이터는 next 메서드를 갖는다.
console.log('next' in iterator); // true

// 이터레이터의 next 메서드를 호출하면 value, done 프로퍼티를 갖는 이터레이터 리절트 객체를 반환한다.
let iteratorResult = iterator.next();
console.log(iteratorResult); // { value: 1, done: false }

이터레이터의 next 메서드는 이터러블의 각 요소를 순회하기 위한 포인터의 역할을 한다. next 메서드를 호출하면 이터러블을 순차적으로 한 단계씩 순회하며 이터레이터 리절트 객체를 반환한다.

// 배열은 이터러블 프로토콜을 준수한 이터러블이다.
const array = [1, 2, 3];

// Symbol.iterator 메서드는 이터레이터를 반환한다.
const iterator = array[Symbol.iterator]();

// 이터레이터 프로토콜을 준수한 이터레이터는 next 메서드를 갖는다.
console.log('next' in iterator); // true

// 이터레이터의 next 메서드를 호출하면 value, done 프로퍼티를 갖는 이터레이터 리절트 객체를 반환한다.
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: undefined, done: true }

이터레이터의 next 메서드가 반환하는 이터레이터 리절트 객체의 value 프로퍼티는 현재 순회 중인 이터러블의 값을 반환하고 done 프로퍼티는 이터러블의 순회 완료 여부를 반환한다.

3) 빌트인 이터러블

ES6에서 제공하는 빌트인 이터러블은 아래와 같다.

Array, String, Map, Set, TypedArray(Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array), DOM data structure(NodeList, HTMLCollection), Arguments

// 배열은 이터러블 프로토콜을 준수한 이터러블이다.
const array = [1, 2, 3];

// 이터러블은 Symbol.iterator 메서드를 소유한다.
// Symbol.iterator 메서드는 이터레이터를 반환한다.
const iterator = array[Symbol.iterator]();

// 이터레이터는 next 메서드를 갖는다.
// 이터레이터의 next 메서드를 호출하면 value, done 프로퍼티를 갖는 이터레이터 리절트 객체를 반환한다.
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: undefined, done: true }

// 이터러블은 for...of문으로 순회 가능하다.
for (const item of array) {
    console.log(item);
}

// 문자열은 이터러블이다.
const string = 'hi';

iterator = string[Symbol.iterator]();

console.log(iterator.next()); // { value: "h", done: false }
console.log(iterator.next()); // { value: "i", done: false }
console.log(iterator.next()); // { value: undefined, done: true }

for (const letter of string) {
	console.log(letter);
}

// arguments 객체는 이터러블이다.
(function() {
    iterator = arguments[Symbol.iterator]();
    
    console.log(iterator.next()); { value: 1, done: false }
    console.log(iterator.next()); { value: 2, done: false }
    console.log(iterator.next()); { value: undefined, done: true }
    
    for (const arg of arguments) {
        console.log(arg);
    }
    
}(1, 2));

4) 이터레이션 프로토콜의 필요성

이터레이션 프로토콜은 다양한 데이터 소스가 하나의 순회 방식을 갖도록 규정하여 데이터 소비자가 효율적으로 다양한 데이터 소스를 사용할 수 있도록 데이터 소비자와 데이터 소스를 연결하는 인터페이스의 역할을 한다.

이터러블을 지원하는 데이터 소비자는 내부에서 Symbol.iterator 메서드를 호출해 이터레이터를 생성하고 이터레이터의 next 메서드를 호출하여 이터러블을 순회한다. 그리고 next 메서드가 반환한 이터레이터 리절트 객체의 value 프로퍼티 값을 취득한다.

 

2. for...of 문

for...of 문은 내부적으로 이터레이터의 next 메서드를 호출하여 이터러블을 순회하며 next 메서드가 반환한 이터레이터 리절트 객체의 value 프로퍼티 값을 for...of 문의 변수에 할당한다. 그리고 이터레이터 리저르 객체의 done 프로퍼티 값이 false 이면 이터러블의 순회를 계속하고 true이면 이터러블의 순회를 중단한다.

// 배열
for (const item of ['a', 'b', 'c']) {
	console.log(item);
}

// 문자열
for (const letter of 'abc') {
	console.log(letter);
}

// Map
for (const [key, value] of new Map([['a', '1'], ['b', '2'], ['c', '3']])) {
	console.log(`key: ${key} value: ${value}`); // ke: a value: 1...
}

// Set
for (const val of new Set([1, 2, 3])) {
	console.log(val);
}

for...of 문이 내부적으로 동작하는 것을 for문으로 표현하면 아래와 같다.

// 이터러블
const iterable = [1, 2, 3];

// 이터레이터
const iterator = iterable[Symbol.iterator]();

for (;;) {
    // 이터레이터의 next 메서드를 호출하여 이터러블을 순회한다.
    const res = iterator.next();
    
    // next 메서드가 반환하는 이터레이터 리절트 객체의 done 프로퍼티가 true가 될 때까지 반복한다.
    if (res.done) break;
    
    console.log(res);
}

3. 커스텀 이터러블

1) 커스텀 이터러블 구현

일반 객체는 이터러블이 아니다. 일반 객체는 Symbol.iterator 메서드를 소유하지 않는다. 즉, 일반 객체는 이터러블 프로토콜을 준수하지 않으므로 for...of 문으로 순회할 수 없다.

// 일반 객체는 이터러블이 아니다.
const obj = { a: 1, b: 2 };

// 일반 객체는 Symbol.iterator 메서드를 소유하지 않는다.
// 따라서 일반 객체는 이터러블 프로토콜을 준수한 이터러블이 아니다.
console.log(Symbol.iterator in obj); // false

// 이터러블이 아닌 일반 객체는 for...of 문에서 순회할 수 없다.
// TypeError: obj is not iterable
for (const p of obj) {
	console.log(p);
}

하지만 일반 객체가 이터레이션 프로토콜을 준수하도록 구현하면 이터러블이 된다. 피보나치 수열(1, 2, 3, 5 ...)을 구현한 간단한 이터러블을 만들어보자.

const fibonacci = {
    // Symbol.iterator 메서드를 구현하여 이터러블 프로토콜을 준수
    [Symbol.iterator]() {
        let [pre, cur] = [0, 1];
        // 최대값
        const max = 10;
        
        // Symbol.iterator 메서드는 next 메서드를 소유한 이터레이터를 반환해야 한다.
        // next 메서드는 이터레이터 리절트 객체를 반환
        return {
            // fibonacci 객체를 순회할 때마다 next 메서드가 호출된다.
            next() {
                [pre, cur] = [cur, pre + cur];
                return {
                    value: cur,
                    done: cur >= max
                };
            }
        };
    }
};

// 이터러블의 최대값을 외부에서 전달할 수 없다.
for (const num of fibonacci) {
    // for...of 내부에서 break는 가능하다.
    // if (num >= 10) break;
    console.log(num); // 1 2 3 5 8
}

Symbol.iteraotr 메서드는 next 메서드를 갖는 이터레이터를 반환하여야 한다. 그리고 next 메서드는 done과 value 프로퍼티를 가지는 이터레이터 리절트 객체를 반환한다. for...of 문은 done 프로퍼티가 true가 될 때까지 반복하며 done 프로퍼티가 true가 되면 반복을 중지한다.

이터러블은 for...of 문뿐만 아니라 spread 문법, 디스트럭쳐링 할당, Map과 Set의 생성자에도 사용된다.

// spread 문법과 디스트럭쳐링을 사용하면 이터러블을 손쉽게 배열로 변환할 수 있다.
// spread 문법
const arr = [... fibonacci];
console.log(arr); // [1, 2, 3, 5, 8]

// 디스트럭쳐링
const [first, second, ... rest] = fibonacci;
console.log(first, second, rest); // 1 2 [3, 5, 8]

2) 이터러블을 생성하는 함수

위 fibonacci 이터러블에는 외부에서 값을 전달할 수 없다. fibonacci 이터러블의 최대값을 외부에서 전달할 수 있도록 수정해보자. 이터러블의 최대 순회수를 전달받아 이터러블을 반환하는 함수를 만들면 된다.

// 이터러블을 반환하는 함수
const fibonacciFunc = function(max) {
  let [pre, cur] = [0, 1];

  return {
    // Symbol.iterator 메소드를 구현하여 이터러블 프로토콜을 준수
    [Symbol.iterator]() {
      // Symbol.iterator 메소드는 next 메소드를 소유한 이터레이터를 반환해야 한다.
      // next 메소드는 이터레이터 리절트 객체를 반환
      return {
        // fibonacci 객체를 순회할 때마다 next 메소드가 호출된다.
        next() {
          [pre, cur] = [cur, pre + cur];
          return {
            value: cur,
            done: cur >= max
          };
        }
      };
    }
  };
};

// 이터러블을 반환하는 함수에 이터러블의 최대값을 전달한다.
for (const num of fibonacciFunc(10)) {
    console.log(num); // 1 2 3 5 8
}

3) 이터러블이면서 이터레이터인 객체를 생성하는 함수

이터레이터를 생성하려면 이터러블의 Symbol.iterator 메서드를 호출해야 한다. 이터러블이면서 이터레이터인 객체를 생성하면 Symbol.iterator 메서드를 호출하지 않아도 된다.

// 이터러블이면서 이터레이터인 객체를 반환하는 함수
const fibonacciFunc = function (max) {
  let [pre, cur] = [0, 1];

  // Symbol.iterator 메소드와 next 메소드를 소유한
  // 이터러블이면서 이터레이터인 객체를 반환
  return {
    // Symbol.iterator 메소드
    [Symbol.iterator]() {
      return this;
    },
    // next 메소드는 이터레이터 리절트 객체를 반환
    next() {
      [pre, cur] = [cur, pre + cur];
      return {
        value: cur,
        done: cur >= max
      };
    }
  };
};

// iter는 이터러블이면서 이터레이터이다.
let iter = fibonacciFunc(10);

// iter는 이터레이터이다.
console.log(iter.next()); // {value: 1, done: false}
console.log(iter.next()); // {value: 2, done: false}
console.log(iter.next()); // {value: 3, done: false}
console.log(iter.next()); // {value: 5, done: false}
console.log(iter.next()); // {value: 8, done: false}
console.log(iter.next()); // {value: 13, done: true}

iter = fibonacciFunc(10);

// iter는 이터러블이다.
for (const num of iter) {
  console.log(num); // 1 2 3 5 8
}

아래의 객체는 Symbol.iterator 메서드와 next 메서드를 소유한 이터러블이면서 이터레이터이다. Symbol.iterator 메서는 this를 반환하므로 next 메서드를 갖는 이터레이터를 반환한다.

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

4) 무한 이터러블과 Lazy evaluation(지연 평가)

무한 이터러블(infinite sequence)을 생성하는 함수를 정의해보자. 

// 무한 이터러블을 생성하는 함수
const fibonacciFunc = function () {
  let [pre, cur] = [0, 1];

  return {
    [Symbol.iterator]() {
      return this;
    },
    next() {
      [pre, cur] = [cur, pre + cur];
      // done 프로퍼티를 생략한다.
      return { value: cur };
    }
  };
};

// fibonacciFunc 함수는 무한 이터러블을 생성한다.
for (const num of fibonacciFunc()) {
  if (num > 10000) break;
  console.log(num); // 1 2 3 5 8...
}

// 무한 이터러블에서 3개만을 취득한다.
const [f1, f2, f3] = fibonacciFunc();
console.log(f1, f2, f3); // 1 2 3

이터러블은 데이터 공급자(Data provider)의 역할을 한다. 배열, 문자열, Map, Set 등의 빌트인 이터러블은 데이터를 모두 메모리에 확보한 다음 동작한다. 하지만 이터러블은 Lazy evaluation(지연 평가)를 통해 값을 생성한다.

728x90
반응형

'programming language > ECMAScript6' 카테고리의 다른 글

[ES6] 7번째 타입 심볼(Symbol)  (0) 2021.12.29
[ES6] 프로미스 (Promise)  (1) 2021.12.27
[ES6] 모듈 (Module)  (0) 2021.12.20
[ES6] 클래스(2)  (0) 2021.12.16
[ES6] 클래스(1)  (2) 2021.12.15
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

1. Symbol이란?

자바스크립트는 6개의 타입을 가지고 있다.

- 원시 타입(primitive data type)

1) Boolean

2) null

3) undefined

4) Number

5) String

- 객체 타입 (Object type)

6) Object

 

심볼(symbol)은 ES6에서 새롭게 추가된 7번째 타입으로 변경 불가능한 원시 타입의 값이다. 심볼은 주로 이름의 충돌 위험이 없는 유일한 객체의 프로퍼티 키(property key)를 만들기 위해 사용한다.

 

2. Symbol의 생성

Symbol은 Symbol() 함수로 생성한다. Symbol() 함수는 호출될 때마다 Symbol 값을 생성한다. 이때 생성된 Symbol은 객체가 아니라 변경 불가능한 원시 타입의 값이다. 

// 심볼 mySymbol은 이름의 충돌 위험이 없는 유일한 프로퍼티 키
let mySymbol = Symbol();

console.log(mySymbol); // Symbol()
console.log(typeof mySymbol); // symbol

Symbol() 함수는 String, Number, Boolean과 같이 래퍼 객체를 생성하는 생성자 함수와는 달리 new 연산자를 사용하지 않는다.

new Symbol(); // TypeError: Symbol is not a constructor

Symbol() 함수에는 문자열을 인자로 전달할 수 있다. 이 문자열은 Symbol 생성에 어떠한 영향을 주지 않으며 다만 생성된 Symbol에 대한 설명(description)으로 디버깅 용도로만 사용된다.

let symbolWithDesc = Symbol('jiyoon');

console.log(symbolWithDesc); // Symbol(jiyoon)
console.log(symbolWithDesc === Symbol('jiyoon')); // false

3. Symbol의 사용

객체의 프로퍼티 키는 빈 문자열을 포함하는 모든 문자열로 만들 수 있다.

const obj = {};

obj.prop = 'myProp';
obj[123] = 123; // 123은 문자열로 변환된다.
// obj.123 = 123; // SyntaxError: Unexpected number
obj['prop' + 123] = false;

console.log(obj); // { '123': 123, prop: 'myProp', prop123: false }

Symbol 값도 객체의 프로퍼티 키로 사용할 수 있다. Symbol 값은 유일한 값이므로 Symbol 값을 키로 갖는 프로퍼티는 다른 어떠한 프로퍼티와도 충돌하지 않는다.

const obj = {};

const mySymbol = Symbol('jiyoon');
obj[mySymbol] = 123;

console.log(obj); // { [Symbol(jiyoon)]: 123 }
console.log(obj[mySymbol]); // 123

 

4. Symbol 객체

Symbol()함수로 Symbol 값을 생성할 수 있었다. 이것은 Symbol이 함수 객체라는 의미이다. 브라우저 콘솔에서 Symbol을 참조해 보자.

Symbol 객체

위 참조 결과에서 알 수 있듯이 Symbol 객체는 프로퍼티와 메서드를 가지고 있다. Symbol 객체의 프로퍼티 중에 length와 prototype을 제외한 프로퍼티를 Well-Known Symbol 이라 부른다.

 

1) Symbol.iterator

Well-Known Symbol은 자바스크립트 엔진에 상수로 존재하며 자바스크립트 엔진은 Well-Known Symbol을 참조하여 일정한 처리를 한다. 예를 들어 어떤 객체가 Symbol.iterator를 프로퍼티 key로 사용한 메서드를 가지고 있으면 자바스크립트 엔진은 이 객체가 이터레이션 프로토콜을 따르는 것으로 간주하고 이터레이터로 동작하도록 한다.

Symbol.iterator를 프로퍼티 key로 사용하여 메서드를 구현하고 있는 빌트인 객체(빌트인 이터러블)는 아래와 같다. 아래의 객체들은 이터레이션 프로토콜을 준수하고 있으며 이터레이터를 반환한다.

Array
Array.prototype[Symbol.iterator]

String
String.prototype[Symbol.iterator]

Map
Map.prototype[Symbol.iterator]

Set
Set.prototype[Symbol.iterator]

DOM data structures
NodeList.prototype[Symbol.iterator] HTMLCollection.prototype[Symbol.iterator]

arguments
arguments[Symbol.iterator]
// 이터러블
// Symbol.iterator를 프로퍼티 key로 사용한 메서드를 구현해야 한다.
// 배열에는 Array.prototype[Symbol.iterator] 메서드가 구현되어 있다.
const iterable = ['a', 'b', 'c'];

// 이터레이터
// 이터러블의 Symbol.iterator를 프로퍼티 key로 사용한 메서드는 이터레이터를 반환한다.
const iterator = iterable[Symbol.iterator]();

// 이터레이터는 순회 가능한 자료 구조인 이터러블의 요소를 탐색하기 위한 포인터로서 value, done 프로퍼티를 갖는 객체를 반환하는 next() 함수를 메서드로 갖는 객체이다. 이터레이터의 next() 메서드를 통해 이터러블 객체를 순회할 수 있다.
console.log(iterator.next()); // { value: 'a', done: false }
console.log(iterator.next()); // { value: 'b', done: false }
console.log(iterator.next()); // { value: 'c', done: false }
console.log(iterator.next()); // { value: undefined, done: true }

2) Symbol.for

Symbol.for 메서드는 인자로 전달받은 문자열을 키로 사용하여 Symbol 값들이 저장되어 있는 전역 Symbol 레지스트리에서 해당 키와 일치하는 저장된 Symbol 값을 검색한다. 이때 검색에 성공하면 검색된 Symbol 값을 반환하고, 검색에 실패하면 새로운 Symbol 값을 생성하여 해당 키로 전역 Symbol 레지스트리에 저장한 후, Symbol 값을 반환한다.

// 전역 Symbol 레지스트리에 foo라는 키로 저장된 Symbol이 없으면 새로운 Symbol 생성
const s1 = Symbol.for('foo');
// 전역 Symbol 레지스트리에 foo라는 키로 저장된 Symbol이 있으면 해당 Symbol을 반환
const s2 = Symbol.for('foo');

console.log(s1 === s2); // true

Symbol 함수는 매번 다른 Symbol 값을 생성하는 것에 반해, Symbol.for 메서드는 하나의 Symbol을 생성하여 여러 모듈이 키를 통해 같은 Symbol을 공유할 수 있다.

Symbol.for 메서드를 통해 생성된 Symbol 값은 반드시 키를 갖는다. 이에 반해 Symbol 함수를 통해 생성된 Symbol 값은 키가 없다.

const shareSymbol = Symbol.for('myKey');
const key1 = Symbol.keyFor(shareSymbol);
console.log(key1); // myKey

const unsharedSymbol = Symbol('myKey');
const key2 = Symbol.keyFor(unsharedSymbol);
console.log(key2); // undefined
728x90
반응형

'programming language > ECMAScript6' 카테고리의 다른 글

[ES6] 이터레이션과 for...of 문  (0) 2022.01.03
[ES6] 프로미스 (Promise)  (1) 2021.12.27
[ES6] 모듈 (Module)  (0) 2021.12.20
[ES6] 클래스(2)  (0) 2021.12.16
[ES6] 클래스(1)  (2) 2021.12.15
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

1. 프로미스란?

자바스크립트는 비동기 처리를 위한 하나의 패턴으로 콜백 함수를 사용한다. 하지만 전통적인 콜백 패턴은 콜백 헬로 인해 가독성이 나쁘고 비동기 처리 중 발생한 에러의 처리가 곤란하며 여러 개의 비동기 처리를 한번에 처리하는 데도 한계가 있다.

ES6에서는 비동기 처리를 위한 또 다른 패턴으로 프로미스(Promise)를 도입했다. 프로미스는 전통적인 콜백 패턴이 가진 단점을 보완하며 비동기 처리 시점을 명확하게 표현할 수 있다는 장점이 있다.

 

2. 콜백 패턴의 단점

1) 콜백 헬

동기식 처리 모델과 비동기식 처리 모델에 대해 간단히 살펴보자.

동기식 처리 모델(Synchronous processing model)은 직렬적으로 태스크(task)를 수행한다. 즉, 태스크는 순차적으로 실행되며 어떤 작업이 수행 중이면 다음 태스크는 대기하게 된다. 예를 들어서 서버에서 데이터를 가져와서 화면에 표시하는 태스크를 수행할 때, 서버에 데이터를 요청하고 데이터가 응답될 때까지 이후의 태스크들은 블로킹된다.

동기식 처리 모델(Synchronous processing model)

비동기식 처리 모델(Asynchronous processing model 또는 Non-Blocking processing model)은 병렬적으로 태스크를 수행한다. 즉, 태스크가 종료되지 않은 상태라 하더라도 대기하지 않고 즉시 다음 태스크를 실행한다. 예를 들어 서버에서 데이터를 가져와서 화면에 표시하는 태스크를 수행할 때, 서버에 데이터를 요청한 이후 서버로부터 데이터가 응답될 때까지 대기하지 않고(Non-Blocking) 즉시 다음 태스크를 수행한다. 이후 서버로부터 데이터가 응답되면 이벤트가 발생하고 있는 이벤트 핸들러가 데이터를 가지고 수행할 태스크를 계속해 수행한다. 자바스크립트의 대부분의 DOM 이벤트와 Timer 함수(setTimeout, setInterval), Ajax 요청은 비동기식 처리 모델로 동작한다.

비동기식 처리 모델(Asynchronous processing model)

자바스크립트에서 빈번하게 사용되는 비동기식 처리 모델은 요청을 병렬로 처리하여 다른 요청이 블로킹(blocking, 작업 중단)이 되지 않는 장점이 있다. 

하지만 비동기 처리를 위해 콜백 패턴을 사용하면 처리 순서를 보장하기 위해 여러 개의 콜백 함수가 네스팅(nesting, 중첩)되어 복잡도가 높아지는 콜백 헬(Callback Hell)이 발생하는 단점이 있다. 콜백 헬은 가독성을 나쁘게 하며 실수를 유발하는 원인이 된다. 아래는 콜백 헬이 발생하는 전형적인 사례이다.

step1(function(value1) {
  step2(value1, function(value2) {
    step3(value2, function(value3) {
      step4(value3, function(value4) {
        step5(value4, function(value5) {
            // value5를 사용하는 처리
        });
      });
    });
  });
});

# 콜백 헬이 발생하는 이유

비동기 처리 모델은 실행 완료를 기다리지 않고 즉시 다음 태스크를 실행한다. 따라서 비동기 함수(비동기를 처리하는 함수) 내에서 처리 결과를 반환(또는 전역 변수에의 할당)하면 기대한 대로 동작하지 않는다.

<!DOCTYPE html>
<html>
<body>
    <script>
    	// 비동기 함수
        function get(url) {
            // XMLHttpRequest 객체 생성
            const xhr = new XMLHttpRequest();
            
            // 서버 응답 시 호출될 이벤트 핸들러
            xhr.onreadystatechange = function () {
            	// 서버 응답 완료가 아니면 무시
                if (xhr.readyState !== XMLHttpRequest.DONE) return;
                
                if (xhr.status === 200) {
                    console.log(xhr.response); 
                    // 비동기 함수의 결과에 대한 처리는 반환할 수 없다.
                    return xhr.response;
                } else {
                    console.log('Error: ' + xhr.status);
                }
            };
            // 비동기 방식으로 Request 오픈
            xhr.open('GET', url); 
            // Request 전송
            xhr.send();
        }
        
        // 비동기 함수 내의 readystatechange 이벤트 핸들러에서 처리 결과를 반환하면 순서가 보장되지 않는다.
        const res = get('http://frogand.tistory.com/105');
        console.log(res); // undefined
    </script>
</body>
</html>

비동기 함수 내의 readystatechange 이벤트 핸들러에서 처리 결과를 반환하면 순서가 보장되지 않는다. 즉 get 함수가 반환한 값을 참조할 수 없다.

get 함수가 호출되면 get 함수의 실행 컨텍스트가 생성되고 호출 스택(실행 컨텍스트 스택)에서 실행된다. get 함수가 반환하는 xhr.response는 readystatechange 이벤트 핸들러가 반환한다.

readystatechange 이벤트는 발생하는 시점을 명확히 알 수 없지만 반드시 get 함수가 종료한 이후 발생한다. get 함수의 마지막 문인 xhr.send(); 가 실행되어야 request를 전송하고 request를 전송해야 readystatechange 이벤트가 발생할 수 있기 때문이다.

get 함수가 종료하면 곧바로 console.log(res);가 호출되어 호출 스택에 들어가 실행된다. console.log가 호출되기 직전에 readystatechange 이벤트가 발생할 수 있기 때문이다.

 

get함수가 종료하면 곧바로 console.log()가 호출되어 호출 스택에 들어가 실행된다. console.log가 호출되기 직전에 readystatechange 이벤트가 이미 발생했다하더라도 이벤트 핸들러는 console.log보다 먼저 실행되지 않는다.

 

readystatechange 이벤트의 이벤트 핸들러는 이벤트가 발생하면 즉시 실행되는 것이 아니다. 이벤트가 발생하면 일단 태스크 큐로 들어가고 호출 스택이 비면 그때 이벤트 루프에 의해 호출 스택으로 들어가 실행된다. console.log 호출 시점 이전에 readystatechange 이벤트가 이미 발생했다하더라도 get 함수가 종료하면 곧바로 console.log가 호출되어 호출 스택에 들어가기 때문에 readystatechange 이벤트의 이벤트 핸들러는 console.log가 종료되어 호출 스택에 빠진 이후 실행된다. 만약 get 함수 이후에 console.log가 100번 호출된다면 readystatechange 이벤트의 이벤트 핸들러는 모든 console.log가 종료한 이후에나 실행된다.

때문에 get 함수의 반환 결과를 가지고 후속 처리를 할 수 없다. 즉, 비동기 함수의 처리 결과에 대한 처리는 비동기 함수의 콜백 함수 내에서 해결해야 한다. 이로 인해 콜백 헬이 발생한다. 

만일 비동기 함수의 처리 결과를 가지고 다른 비동기 함수를 호출해야 하는 경우, 함수의 호출이 중첩(nesting)되어 복잡도가 높아지는 현상이 발생하는데 이를 Callback Hell이라 한다.

 

2) 에러처리의 한계

콜백 방식의 비동기 처리가 갖는 문제점 중에서 가장 심각한 것은 에러 처리가 곤란하다는 것이다.

try {
    setTimeout(() => { throw new Error('Error!'); }, 1000);
} catch(e) {
    console.log('에러를 캐치하지 못한다.');
    console.log(e);
}

try 블록 내에서 setTimeout 함수가 실행되면 1초 후에 콜백함수가 실행되고 이 콜백함수는 예외를 발생시킨다. 하지만 이 예외는 catch 블록에서 캐치되지 않는다.

비동기 처리 함수의 콜백 함수는 해당 이벤트(timer 함수의 tick 이벤트, XMLHttpRequest의 readystatechange 이벤트 등)가 발생하면 태스크 큐(Task Queue)로 이동하 후 호출 스택이 비어졌을 때, 호출 스택으로 이동되어 실행된다. setTimeout 함수는 비동기 함수이므로 콜백함수가 실행될 때까지 기다리지 않고 즉시 종료되어 호출 스택에서 제거된다. 이후 tick 이벤트가 발생하면 setTimeout 함수의 콜백함수는 태스크 큐로 이동한 후 호출 스택이 비어졌을 때 호출 스택으로 이동되어 실행된다. 이때 setTimeout 함수는 이미 호출 스택에서 제거된 상태이다. 이것은 setTimeout 함수의 콜백 함수를 호출 한 것은 setTimeout 함수가 아니라는 것을 의미한다. setTimeout 함수의 콜백함수 호출자(caller)가 setTimeout 함수라면 호출 스택에 setTimeout 함수가 존재해야 하기 때문이다.

예외(exception)는 호출자(caller) 방향으로 전파된다. 하지만 setTimeout 함수의 콜백 함수를 호출한 것은 setTimeout 함수가 아니기 때문에 setTimeout 함수의 콜백 함수 내에서 발생시킨 에러는 catch 블록에서 캐치되지 않아 프로세스는 종료된다.

 

이런 문제를 극복하기 위해 Promise가 제안되었다. Promise는 ES6에 정식 채택되어 IE를 제외한 대부분의 브라우저가 지원하고 있다.

3. 프로미스의 생성

프로미스는 Promise 생성자 함수를 통해 인스턴스화한다. Promise 생성자 함수는 비동기 작업을 수행할 콜백 함수를 인자로 전달받는데 이 콜백 함수를 resolve와 reject 함수를 인자로 전달받는다.

// Promise 객체의 생성
const promise = new Promise((resolve, reject) => {
    // 비동기 작업을 수행한다.
    
    if (/* 비동기 작업 수행 성공 */) {
        resolve('result');
    } else { /* 비동기 작업 수행 실패 */
        reject('failure reason');
    }
});

Promise는 비동기 처리가 성공(fulfilled)하였는지 또는 실패(rejected)하였는지 등의 상태(state) 정보를 갖는다. 

상태 의미 구현
pending 비동기 처리가 아직 수행되지 않은 상태 resolve 또는 reject 함수가 아직 호출되지 않은 상태
fulfilled 비동기 처리가 수행된 상태 (성공) resolve 함수가 호출된 상태
rejected 비동기 처리가 수행된 상태 (실패) reject 함수가 호출된 상태
settled 비동기 처리가 수행된 상태 (성공 또는 실패) resolve 또는 reject 함수가 호출된 상태

Promise 생성자 함수가 인자로 전달받은 콜백 함수는 내부에서 비동기 처리 작업을 수행한다. 이때 비동기 처리가 성공하면 콜백 함수의 인자로 전달받은 resolve 함수를 호출한다. 이때 프로미스는 'fulfilled' 상태가 된다. 비동기 처리가 실패하면 reject 함수를 호출한다. 이때 프로미스는 'rejected' 상태가 된다. 

 

Promise를 사용하여 비동기 함수를 정의해보자.

const promiseAjax = (method, url, payload) => {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open(method, url);
        xhr.setRequestHeader('Content-type', 'application/json');
        xhr.send(JSON.stringify(payload));
        
        xhr.onreadystatechange = function() {
            // 서버 응답 완료가 아니면 무시
            if (xhr.readyState !== XMLHttpRequest.DONE) return;
            
            if (xhr.status >= 200 && xhr.status < 400) {
                // resolve 메서드를 호출하면서 처리 결과를 전달
                resolve(xhr.response); // Success!
            } else {
                // reject 메서드를 호출하면서 에러 메시지를 전달
                reject(new Error(xhr.status)); // Failed ...
            }
        }
    });
};

비동기 함수 내에서 Promise 객체를 생성하고 그 내부에서 비동기 처리를 구현한다. 이때 비동기 처리에 성공하면 resolve 메서드를 호출한다. 이때 resolve 메서드의 인자로 비동기 처리 결과를 전달한다. 이 처리 결과는 Promise 객체의 후속 처리 메서드로 전달된다. 만약 비동기 처리에 실패하면 reject 메서드를 호출한다. 이때 reject 메서드의 인자로 에러 메시지를 전달한다. 이 에러 메시지는 Promise 객체의 후속 처리 메서드로 전달된다.

4. 프로미스의 후속 처리 메서드

Promise로 구현된 비동기 함수는 Promise 객체를 반환해야 한다. Promise로 구현된 비동기 함수를 호출하는 측(promise consumer)에서는 Promise 객체의 후속 처리 메서드(then, catch)를 통해 비동기 처리 결과 또는 에러 메시지를 전달받아 처리한다. Promise 객체는 상태를 갖는다고 하였다. 이 상태에 따라 후속처리메서드를 체이닝 방식으로 호출한다. Promise의 후속 처리 메서드는 다음과 같다.

* then

then 메서드는 두 개의 콜백 함수를 인자로 전달받는다. 첫 번째 콜백 함수는 성공(fulfilled, resolve 함수가 호출된 상태)시 호출되고 두 번째 함수는 실패(rejected, reject 함수가 호출된 상태)시 호출된다. then 메서드는 Promise를 반환한다.

* catch

예외(비동기 처리에서 발생한 에러와 then 메서드에서 발생한 에러)가 발생하면 호출된다. catch 메서드는 Promise를 반환한다.

 

프로미스로 정의한 비동기 함수 get을 사용해보자. get 함수는 XMLHttpRequest 객체를 통해 Ajax 요청을 수행하므로 브라우저에서 실행하여야 한다.

<!DOCTYPE html>
<html>
<body>
  <pre class="result"></pre>
  <script>
    const $result = document.querySelector('.result'); 
    const render = content => { $result.textContent = JSON.stringify(content, null, 2); };
    
    const promiseAjax = (method, url, payload) => {
      return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open(method, url);
        xhr.setRequestHeader('Content-type', 'application/json');
        xhr.send(JSON.stringify(payload));
      
        xhr.onreadystatechange = function() {
          if (xhr.readyState !== XMLHttpRequest.DONE) return;
          
          if (Xhr.status >= 200 && xhr.status < 400) {
            resolve(xhr.response); // Success!
          } else {
            reject(new Error(xhr.status)); // Failed ...
          }
        };
      });
    };
    
    // 비동기 함수 promiseAjax는 Promise 객체를 반환한다. 
    // Promise 객체의 후속 메서드를 사용하여 비동기 처리 결과에 대한 후속 처리를  수행한다.
    promiseAjax('GET', 'http://frogand.tistory.com/105')
      .then(JSON.parse)
      .then(
        // 첫 번째 콜백 함수는 성공(fulfilled, resolve 함수가 호출된 상태) 시 호출된다.
        render,
        // 두 번째 함수는 실패(rejected, reject 함수가 호출된 상태) 시 호출된다.
        console.error
      );
  </script>
</body>
</html>

5. 프로미스의 에러 처리

비동기 함수 get은 Promise 객체를 반환한다. 비동기 처리 결과에 대한 후속 처리는 Promise 객체가 제공하는 후속 처리 메서드 then, catch, finally를 사용하여 수행한다. 비동기 처리 시에 발생한 에러는 then 메서드의 두 번째 콜백 함수로 처리할 수 있다.

const wrongUrl = 'https://frogand.tistory.com/hi';

// 부적합한 URL이 지정되었기 때문에 에러가 발생한다.
promiseAjax(wrongUrl)
  .then(res => console.log(res), err => console.log(err)); // ERROR: 404

비동기 처리에서 발생한 에러는 Promise 객체의 후속 처리 메서드 catch를 사용해서 처리할 수 있다.

const wrongUrl = 'https://frogand.tistory.com/hi';

// 부적합한 URL이 지정되었기 때문에 에러가 발생한다.
promiseAjax(wrongUrl)
  .then(res => console.log(res))
  .catch(err => console.log(err)); // ERROR: 404

 

catch 메서드를 호출하면 내부적으로 then(undefined, onRejected)을 호출한다. 위 예제는 내부적으로 다음과 같이 처리된다. 

const wrongUrl = 'https://frogand.tistory.com/hi';

// 부적합한 URL이 지정되었기 때문에 에러가 발생한다.
promiseAjax(wrongUrl)
  .then(res => console.log(res))
  .then(undefined, err => console.error(err)); // Error: 404

단, then 메서드의 두 번째 콜백 함수는 첫번째 콜백 함수에서 발생한 에러를 캐치하지 못하고 코드가 복잡해져서 가독성이 좋지 않다. 

promiseAjax('https://frogand.tistory.com/105')
  .then(res => console.xxx(res), err => console.error(err)); 
  // 두 번째 콜백 함수는 첫 번째 콜백 함수에서 발생한 에러를 캐치하지 못한다.

catch 메서드를 모든 then 메서드를 호출한 이후에 호출하면 비동기 처리에서 발생한 에러(reject 함수가 호출된 상태)뿐만 아니라 then 메서드 내부에서 발생한 에러까지 모두 캐치할 수 있다.

promiseAjax('https://frogand.tistory.com/105')
  .then(res => console.xxx(res)
  .catch(err => console.error(err)); 
  // TypeError: console.xxx is not a function

또한 then 메서드에서 두 번째 콜백 함수를 전달하는 것보다 catch 메서드를 사용하는 것이 가독성이 좋고 명확하기 때문에 에러 처리는 catch 메서드를 사용하는 것을 권장한다.

6. 프로미스 체이닝 

비동기 함수의 처리 결과를 가지고 다른 비동기 함수를 호출해야 하는 경우, 함수의 호출이 중첩(nesting)되어 복잡도가 높아지는 콜백 헬이 발생한다. 프로미스는 후속 처리 메서드를 체이닝(chaining)하여 여러 개의 프로미스를 연결하여 사용할 수 있다. 

 

서버로부터 특정 포스트를 가져와서 그 포스트를 작성한 사용자의 아이디로 작성된 모든 포스트를 검색하는 예이다.

<!DOCTYPE html>
<html>
<body>
  <pre class="result"></pre>
  <script>
    const $result = document.querySelector('.result');
    const render = content => { $result.textContent = JSON.stringify(content, null, 2); };
    
    const promiseAjax = (method, url, payload) => {
      return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open(method, url); 
        xhr.setRequestHeader('Content-type', 'application/json');
        xhr.send(JSON.stringify(payload));
        
        xhr.onreadystatechange = function() {
          if (xhr.readyState !== XMLHttpRequest.DONE) return;
          
          if (xhr.status >= 200 && xhr.status < 400) {
            resolve(xhr.response); // Success!
          } else {
            reject(new Error(xhr.status)); // Failed ...
          }
        };
      });
    };
    
    const url = 'http://frogand.tistory.com/';
    
    promiseAjax('GET', `${url}/1`)
      .then(res => promiseAjax('GET', `${url}?userId=${JSON.parse(res).userId}`))
      .then(JSON.parse)
      .then(render)
      .catch(console.error);
  </script>
</body>
</html>

7. 프로미스의 정적 메서드

Promise는 주로 생성자 함수로 사용되지만 함수도 객체이므로 메서드를 갖을 수 있다. Promise 객체는 4가지 정적 메서드를 제공한다.

1) Promise.resolve/Promise.reject

Promise.resolve와 Promise.reject 메서드는 존재하는 값을 Promise로 래핑하기 위해 사용한다.

정적 메서드 Promise.resolve 메서드는 인자로 전달된 값을 resolve하는 Promise를 생성한다.

const resolvedPromise = Promise.resolve([1, 2, 3]);
resolvedPromise.then(console.log); // [1, 2, 3]

// 위와 동일하게 동작한다.
const resolvedPromise = new Promise(resolve => resolve([1, 2, 3]));
resolvedPromise.then(console.log); // [1, 2, 3]

Promise.reject 메서드는 인자로 전달된 값을 reject하는 프로미스를 생성한다.

const rejectedPromise = Promise.reject(new Error('Error!'));
rejectedPromised.catch(console.log); // Error: Error!

2) Promise.all

Promise.all 메서드는 프로미스가 담겨있는 배열 등의 이터러블을 인자로 전달받는다. 그리고 전달받은 모든 프로미스를 병렬로 처리하고 그 처리 결과를 resolve하는 새로운 프로미스를 반환한다. 

Promise.all([
  new Promise(resolve => setTimeout(() => resolve(1), 3000)), // 1
  new Promise(resolve => setTimeout(() => resolve(2), 2000)), // 2
  new Promise(resolve => setTimeout(() => resolve(3), 1000)) // 3
 ]).then(console.log) // [1, 2, 3]
   .catch(console.log);

Promise.all 메서드는 3개의 프로미스를 담은 배열을 전달받았다. 각각의 프로미스는 아래와 같이 동작한다.

- 첫 번째 프로미스는 3초 뒤에 1을 resolve하여 처리 결과를 반환한다.

- 두 번째 프로미스는 2초 뒤에 2을 resolve하여 처리 결과를 반환한다.

- 세 번째 프로미스는 1초 뒤에 3을 resolve하여 처리 결과를 반환한다.

Promise.all 메서드는 전달받은 모든 프로미스를 병렬로 처리한다. 이때 모든 프로미스의 처리가 종료될 때까지 기다린 후 아래와 모든 처리 결과를 resolve 또는 reject한다.

- 모든 프로미스의 처리가 성공하면 각각의 프로미스가 resolve한 처리 결과를 배열에 담아 resolve하는 새로운 프로미스를 반환한다. 이때 첫번째 프로미스가 가장 나중에 처리되어도 Promise.all 메서드가 반환하는 프로미스는 첫번째 프로미스가 resolve한 처리 결과부터 차례대로 배열에 담아 그 배열을 resolve하는 새로운 프로미스를 반환한다. 즉, 처리 순서가 보장된다.

- 프로미스의 처리가 하나라도 실패하면 가장 먼저 실패한 프로미스가 reject한 에러를 reject하는 새로운 프로미스를 즉시 반환한다.

Promise.all([
  new Promise((resolvee, reject) => setTimeout(() => reject(new Error('Error1!')), 3000)),
  new Promise((resolvee, reject) => setTimeout(() => reject(new Error('Error2!')), 2000)),
  new Promise((resolvee, reject) => setTimeout(() => reject(new Error('Error3!')), 1000))
]).then(console.log)
  .catch(console.log); // Error: Error 3!

세 번째 프로미스가 제일 먼저 실패하므로 세번째 프로미스가 reject한 에러가 catch 메서드로 전달된다.

Promise.all 메서드는 전달받은 이터러블의 요소가 프로미스가 아닌 경우, Promise.resolve 메서드를 통해 프로미스로 래핑된다.

Promise.all([
  1, // => Promise.resolve(1)
  2, // => Promise.resolve(2)
  3  // => Promise.resolve(3)
]).then(console.log) // [1, 2, 3]
.catch(console.log)
const githubIds = ['jeon9825', 'annae96'];

Promise.all(githubIds.map(id => fetch(`http://api.github.com/users/${id}`)))
    // [Response, Reponse] => Promise
    .then(response => Promise.all(response.map(res => res.json())))
    // [user, user] => Promise
    .then(users => users.map(user => user.name))
    // ['Jiyoon Jeon', 'Anna Lee']
    .then(console.log)
    .catch(console.log);

위 예제의 Promise.all 메서드는 fetch 함수가 반환한 3개의 프로미스 배열을 인수로 전달받고 이 프로미스들을 병렬처리 한다. 모든 프로미스의 처리가 성공하면 Promise.all 메서드는 각각의 프로미스가 resolve한 3개의 Response 객체가 담긴 배열을 resolve하는 새로운 프로미스를 반환하고 후속 처리 메서드 then에는 3개의 Response 객체가 담긴 배열에 전달된다. 이때 json 메서드는 프로미스를 반환하므로 한번 더 Promise.all 메서드를 호출해야 하는 것에 주의하자. 두번째 호출한 Promise.all 메서드는 github로 부터 취득한 2개의 사용자 정보 객체가 담긴 배열을 resolve하는 프로미스를 반환하고 후속 처리 메서드 then에는 2개의 사용자 정보 객체가 담긴 배열이 전달된다.

3) Promise.race

Promise.race 메서드는 Promise.all 메서드와 동일하게 프로미스가 담겨있는 배열 등의 이터러블을 인자로 전달받는다. 그리고 Promise.race 메서드는 Promise.all 메서드처럼 모든 프로미스 병렬 처리를 하는 것이 아니라 가장 먼저 처리된 프로미스가 resolve한 결과를 resolve하는 새로운 프로미스를 반환한다.

Promise.race([
    new Promise(resolve => setTimeout(() => resolve(1), 3000)), // 1
    new Promise(resolve => setTimeout(() => resolve(2), 2000)), // 2
    new Promise(resolve => setTimeout(() => resolve(3), 1000)), // 3
]).then(console.log) // 3
  .catch(console.log);

에러가 발생한 경우는 Promise.all 메서드와 동일하게 처리된다. 즉, Promise.race 메서드에 전달된 프로미스 처리가 하나라도 실패하면 가장 먼저 실패한 프로미스가 reject한 에러를 reject하는 새로운 프로미스를 즉시 반환한다.

Promise.race([
  new Promise((resolve, reject) => setTimeout(() => reject(new Error('Error 1!')), 3000)),
  new Promise((resolve, reject) => setTimeout(() => reject(new Error('Error 2!')), 2000)),
  new Promise((resolve, reject) => setTimeout(() => reject(new Error('Error 3!')), 1000))
]).then(console.log)
  .catch(console.log); // Error: Error 3!

 

728x90
반응형

'programming language > ECMAScript6' 카테고리의 다른 글

[ES6] 이터레이션과 for...of 문  (0) 2022.01.03
[ES6] 7번째 타입 심볼(Symbol)  (0) 2021.12.29
[ES6] 모듈 (Module)  (0) 2021.12.20
[ES6] 클래스(2)  (0) 2021.12.16
[ES6] 클래스(1)  (2) 2021.12.15
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

모듈이란 애플리케이션을 구성하는 개별적 요소로서 재사용 가능한 코드 조각을 말한다. 모듈은 세부 사항을 캡슐화하고 공개가 필요한 API만을 외부에 노출한다.

 

일반적으로 모듈은 파일 단위로 분리되어 있으며 애플리케이션은 필요에 따라 명시적으로 모듈을 로드하여 재사용한다. 즉, 모듈은 애플리케이션에 분리되어 개별적으로 존재하다가 애플리케이션의 로드에 의해 비로소 애플리케이션의 일원이 된다. 모듈은 기능별로 분리되어 작성되므로 코드의 단위를 명확히 분리하여 애플리케이션을 구성할 수 있으며 재사용성이 좋아서 개발 효율성과 유지보수성을 높일 수 있다.

 

C언어는 #include, Java는 import 등 대부분의 프로그래밍 언어는 모듈 기능을 가지고 있다. 하지만, 클라이언트 사이드 자바스크립트는 script 태그를 사용하여 외부의 스크립트 파일을 가져올 수는 있지만, 파일마다 독립적인 파일 스코프를 갖지 않고 하나의 전역 객체(Global Object)를 공유한다. 즉, 자바스크립트 파일을 여러 개의 파일로 분리하여 script 태그로 로드하여도 분리된 자바스크립트 파일들이 결국 하나의 자바스크립트 파일 내에 있는 것처럼 하나의 전역 객체를 공유한다. 따라서 분리된 자바스크립트 파일들이 하나의 전역을 갖게 되어 전역 변수가 중복되는 등의 문제가 발생할 수 있다. 이것으로 모듈화를 구현할 수 없다.

 

자바스크립트를 범용적으로 사용하고자하는 움직임이 생기면서 모듈 기능이 필요하게 되었고 CommonJS와 AMD(Asynchronous Module Definition)이다.

 

서버 사이드 자바스크립트 런타임 환경인 Node.js는 모듈 시스템의 사실상 표준(de facto standard)인 CommonJS를 채택하였고 독자적인 진화를 거쳐 현재는 CommonJS 사양과 100% 동일하지는 않지만 기본적으로 CommonJS 방식을 따르고 있다. 즉, Node.js에서는 표준은 아니지만 모듈이 지원된다. 따라서 Node.js 환경에서는 모듈 별로 독립적인 스코프, 즉 모듈 스코프를 갖는다.

이러한 상황에서 ES6에서는 클라이언트 사이드 자바스크립트에서도 동작하는 모듈 기능을 추가하였다. 2019년 11월 현재, 모던 브라우저(Chrome 61, FF 60, SF 10.1, Edge 16 이상)에서 ES6 모듈을 사용할 수 있다.

 

script 태그에 type="module" 어트리뷰트를 추가하면 로드된 자바스크립트 파일은 모듈로서 동작한다. ES6 모듈의 파일 확장자는 모듈임을 명확히 하기 위해 mjs를 사용하도록 권장한다.

<script type="module" src="lib.mjs"></script>
<script type="module" src="app.mjs"></script>

ES6를 사용하여 프로젝트를 진행하려면 ES6로 작성된 코드를 IE를 포함한 모든 브라우저에서 문제없이 동작시키기 위한 개발환경을 구축하는 것이 필요하다. 이 문제에 대해서는 Babel과 Webpack을 이용한 ES6 개발환경 구축에 대해서 알아봐야 한다.

 

1. 모듈 스코프

ES6 모듈 기능을 사용하지 않으면 분리된 자바스크립트 파일에 독자적인 스코프를 갖지 않고 하나의 전역을 공유한다.

// foo.js
var x = 'foo';

// 변수 x는 전역 변수이다.
console.log(window.x); // foo
// bar.js
// foo.js에서 선언한 전역 변수 x와 중복된 선언이다.
var x = 'bar';

// 변수 x는 전역 변수이다.
// foo.js에서 선언한 전역 변수 x의 값이 재할당되었다.
console.log(window.x); // bar
<!DOCTYPE html>
<html>
<body>
  <script src="foo.js"></script>
  <script src="bar.js"></script>
</body>
</html>

HTML에서 2개의 자바스크립트 파일을 로드하면 로드된 자바스크립트는 하나의 전역을 공유한다. 위 2개의 자바스크립트 파일은 하나의 전역 객체를 공유하며 하나의 전역 스코프를 갖는다. 따라서 foo.js에서 선언한 변수 x와 bar.js에서 선언한 변수 x는 중복 선언되며 의도치 않게 변수 x의 값이 덮어써진다.

ES6 모듈은 파일 자체의 스코프를 제공한다. 즉, ES6 모듈은 독자적인 모듈 스코프를 갖는다. 따라서, 모듈 내에서 var 키워드로 선언한 변수는 더 이상 전역변수가 아니며 window 객체의 프로퍼티도 아니다.

// foo.mjs
var x = 'foo';

console.log(x); // foo
// 변수 x는 전역 변수가 아니며 window 객체의 프로퍼티도 아니다.
console.log(window.x); // undefined
// bar.mjs
// 변수 x는 foo.mjs에서 선언한 변수 x와 스코프가 다른 변수이다.
var x = 'bar';

console.log(x); // bar
// 변수 x는 전역 변수가 아니며 window 객체의 프로퍼티도 아니다.
console.log(window.x); // undefined
<!DOCTYPE html>
<html>
<body>
  <script type="module" src="foo.mjs"></script>
  <script type="module" src="bar.mjs"></script>
</body>
</html>

모듈 내에서 선언한 변수는 모듈 외부에서 참조할 수 없다. 스코프가 다르기 때문이다.

// foo.js
const x = 'foo';

console.log(x); // foo
// bar.js
// 다른 모듈에서 선언한 변수는 모듈 외부에서 참조할 수 없다. 스코프가 다르기 때문이다.
console.log(x); // ReferenceError: x is not defined
<!DOCTYPE html>
<html>
<body>
  <script type="module" src="foo.js"></script>
  <script type="module" src="bar.js"></script>
</body>
</html>

 

2. export 키워드

모듈은 독자적인 모듈 스코프를 갖기 때문에 모듈 안에서 선언한 모든 식별자는 기본적으로 해당 모듈 내부에서만 참조할 수 있다. 만약 모듈 안에 선언한 식별자를 외부에 공개하여 다른 모듈들이 참조할 수 있게 하고 싶다면 export 키워드를 사용한다. 선언된 변수, 함수, 클래스 모두 export할 수 있다.

모듈을 공개하려면 선언문 앞에 export 키워드를 사용한다. 여러개를 export할 수 있는데 이때 각각의 export는 이름으로 구별할 수 있다.

// lib.mjs
// 변수의 공개
export const pi = Math.PI;

// 함수의 공개
export function square(x) {
    return x * x;
}

// 클래스의 공개
export class Person {
	constructor(name) {
    	this.name = name;
    }
}

선언문 앞에 매번 export 키워드를 붙이는 것이 싫다면 export 대상을 모아 하나의 객체로 구성하여 한번에 export할 수도 있다.

// lib.mjs
const pi = Math.PI;

function square(x) {
    return x * x;
}

class Person {
	constructor(name) {
    	this.name = name;
    }
}

// 변수, 함수, 클래스를 하나의 객체로 구성하여 공개
export { pi, square, Person };

3. import 키워드

모듈에서 공개(export)한 대상을 로드하려면 import 키워드를 사용한다.

모듈이 export한 식별자로 import하며 ES6 모듈의 파일 확장자를 생략할 수 없다. 

// app.mjs
// 같은 폴더 내의 lib.mjs 모듈을 로드
// lib.mjs 모듈이 export한 식별자로 import한다.
// ES6 모듈의 파일 확장자를 생략할 수 없다.
import { pi, square, Person } from './lib.mjs';

console.log(pi);    // 3.141592653589793
console.log(square(10));    // 100
console.log(new Person('Lee'));    // Person { name: 'Lee' }

모듈이 export한 식별자를 각각 지정하지 않고 하나의 이름으로 한꺼번에 import할 수도 있다. 이때 import되는 식별자는 as 뒤에 지정한 이름의 객체에 프로퍼티로 할당된다.

// app.mjs
import * as lib from './lib.mjs';

console.log(lib.pi);         		// 3.141592653589793
console.log(lib.square(10)); 		// 100
console.log(new lib.Person('Lee'));	// Person { name: 'Lee' }

이름을 변경하여 import할 수도 있다.

// app.mjs
import { pi as PI, square as sq, Person as P } from './lib.mjs';

console.log(PI);    		// 3.141592653589793
console.log(sq(2)); 		// 4
console.log(new P('Kim')); 	// Person { name: 'Kim' }

모듈에서 하나만을 export할 때는 default 키워드를 사용할 수 있다.

// lib.mjs
export default function(x) {
    return x * x;
}

다만, default를 사용하는 경우, var, let, const는 사용할 수 없다.

// lib.mjs
export default () => {};
// OK

export default const foo = () => {};
// SyntaxError: Unexpected token 'const'

default 키워드와 함께 export한 모듈은 {} 없이 임의의 이름으로 import한다.

// app.mjs
import square from './lib.mjs';

console.log(square(3)); // 9

브라우저가 지원하는 ES6 모듈 기능을 이용하여 import와 export가 동작하는지 확인해보자.

// lib.mjs
export default x => x * x;
// app.mjs
// 브라우저 환경에서는 모듈의 파일 확장자를 생략할 수 없다.
// 모듈의 파일 확장자는 .mjs를 권장한다.
import square from './lib.mjs';

console.log(square(10)); // 100
<!DOCTYPE html>
<html>
<body>
  <script type="module" src="./lib.js"></script>
  <script type="module" src="./app.js"></script>
</body>
</html>

위 HTML을 실행햐면 콘솔에 100이 출력된다.

728x90
반응형

'programming language > ECMAScript6' 카테고리의 다른 글

[ES6] 7번째 타입 심볼(Symbol)  (0) 2021.12.29
[ES6] 프로미스 (Promise)  (1) 2021.12.27
[ES6] 클래스(2)  (0) 2021.12.16
[ES6] 클래스(1)  (2) 2021.12.15
[ES6] 디스트럭처링 (Destructuring)  (0) 2021.12.14
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

5. Class field declarations proposal

- Field declarations

- private field

- static public fields

 

class Foo {
    x = 1;				// Field declaration 
    #p = 0; 			// Private field
    static y = 20;		// Static public field
    static #sp = 30;	// Static private field
    
    bar() {
    	this.#p = 10; 	// private 필드 참조
        // this.p = 10;	// 새로운 public p 필드를 동적 추가한다.
        return this.#p;
    }
}

const foo = new Foo();
console.log(foo); // Foo { #p: 10, x: 10 }

console.log(foo.x); // 1
// console.log(foo.#p); // SyntaxError: Undefined private field #p: must be declared in an enclosing class
console.log(Foo.y); // 20
// console.log(Foo.#sp); // SyntaxError: Undefined private field #sp: must be declared in an enclosing class
console.log(foo.bar()); // 10

위 예제는 최신 브라우저(Chrome 72 이상) 또는 최신 Node.js(버전 12 이상)에서 정상 동작한다.

 

6. getter, setter

1) getter

getted는 클래스 필드에 접근할 때마다 클래스 필드의 값을 조작하는 행위가 필요할 때 사용한다. getter는 메서드 이름 앞에 get 키워드를 사용해 정의한다. 이 때 메서드 이름은 클래스 필드 이름처럼 사용된다. 다시 말해 getter는 호출하는 것이 아니라 프로퍼티처럼 참조하는 형식으로 사용하며 참조 시에 메서드가 호출된다. getter는 이름 그대로 무언가를 취득할 때 사용하므로 반드시 무언가를 반환해야 한다.

class Foo {
    constructor(arr = []) {
    	this._arr = arr
    }
    
    // getter: get 키워드 뒤에 오는 메서드 이름 firstElem은 클래스 필드 이름처럼 사용된다.
    get FirstElem() {
    	// getter는 반드시 무언가를 반환해야 한다.
        return this._arr.length ? this._arr[0] : null;
    }
}

const foo = new Foo([1, 2]);
console.log(foo.firstElem); // 1

2) setter

setter는 클래스 필드에 값을 할당할 때마다 클래스 필드의 값을 조작하는 행위가 필요할 때 사용한다. setter는 메서드 이름 앞에 set 키워드를 사용해 정의한다. 이때 메서드 이름은 클래스 필드 이름처럼 사용된다. 다시 말해 setter는 호출하는 것이 아니라 프로퍼티처럼 값을 할당하는 형식으로 사용하며 할당 시에 메서드가 호출된다. 사용 방법은 아래와 같다.

class Foo {
    constructor(arr = []) {
    	this._arr = arr;
    }
    
    // getter: get 키워드 뒤에 오는 메서드 이름 firstElem은 클래스 필드 이름처럼 사용된다.
    get firstElem() {
    	// getter는 반드시 무언가를 반환해야 한다.
        return this._arr.length ? this._arr[0] : null;
    }
    
    set firstElem(elem) {
    	// ... this._arr은 this._arr를 개별요소로 분리한다.
        this._arr = [elem, ...this._arr];
    }
}

const foo = new Foo([1, 2]);

// 클래스 필드 firstElem에 값을 할당하면 setter가 호출된다.
foo.firstElem = 100;

console.log(foo.firstElem); // 100

getter와 setter는 클래스에서 새롭게 도입된 기능이 아니다. getter와 setter는 접근자 프로퍼티(accessor property)이다.

// _arr은 데이터 프로퍼티이다.
console.log(Object.getOwnPropertyDescriptor(foo, '_arr'));
// {value: Array(2), writable: true, enumerable: true, configurable: true}

// firstElem은 접근자 프로퍼티이다. 접근자 프로퍼티는 프로토타입의 프로퍼티이다.
console.log(Object.getOwnPropertyDescriptor(Foo.prototype, 'firstElem'));
// {get: ƒ, set: ƒ, enumerable: false, configurable: true}

7. 정적 메서드

클래스의 정적(static) 메서드를 정의할 때 static 키워드를 사용한다. 정적 메서드는 클래스의 인스턴스가 아닌 클래스 이름으로 호출한다. 따라서 클래스의 인스턴스를 생성하지 않아도 호출할 수 있다.

class Foo {
    constructor(prop) {
    	this.prop = prop;
    }
    
    static staticMethod() {
    	/*
       	정적 메서드는 this를 사용할 수 없다.
        정적 메서드는 내부에서 this는 클래스의 인스턴스가 아닌 클래스 자신을 가리킨다.
        */
        return 'staticMethod';
    }
    
    prototypeMethod() {
    	return this.prop;
    }
}

// 정적 메서드는 클래스 이름으로 호출한다.
console.log(Foo.staticMethod());

const foo = new Foo(123); 
// 정적 메서드는 인스턴스로 호출할 수 없다.
console.log(foo.staticMethod()); // Uncaught TypeError: foo.staticMethod is not a function

클래스의 정적 메서드는 인스턴스로 호출할 수 없다. 이것은 정적 메서드는 this를 사용할 수 없다는 것을 의미한다. 일반 메서드 내부에서 this는 클래스의 인스턴스를 가리키며, 메서드 내부에서 this를 사용한다는 것은 클래스의 인스턴스의 생성을 전제로 하는 것이다.

정적 메서드는 클래스 이름으로 호출하기 때문에 클래스의 인스턴스를 생성하지 않아도 사용할 수 있다. 단, 정적 메서드는 this를 사용할 수 없다. 달리 말하면 메서드 내부에서 this를 사용할 필요가 없는 메서드는 정적 메서드로 만들 수 있다. 정적 메서드는 Math 객체의 메서드처럼 애플리케이션 전역에서 사용할 유틸리티(Utility) 함수를 생성할 때 주로 사용한다.

정적 메서드는 클래스의 인스턴스 생성없이 클래스의 이름으로 호출하며 클래스의 인스턴스로 호출할 수 없다고 하였다. 

사실 클래스도 함수고 기존 prototype 기반 패턴의 Syntactic sugar일 뿐이다.

class Foo {}

console.log(typeof Foo); // function

위 코드를 ES5로 표현해보면 아래와 같다. ES5로 표현한 아래의 코드는 ES6의 클래스로 표현한 코드와 정확히 동일하게 동작한다.

var Foo = (function () {

    // 생성자 함수
  	function Foo(prop) {
    	this.prop = prop;
    }
    
    Foo.staticMethod = function() {
    	return 'staticMethod';
    };
    
    Foo.prototype.prototypeMethod = function() {
    	return this.prop;
    }
    
    return Foo;
}());

var foo = new Foo(123);
console.log(foo.prototypeMethod()); // 123
console.log(Foo.staticMethod()); // staticMethod
console.log(foo.staticMethod()); // Uncaught TypeError: foo.staticMethod is not a function

함수 객체(자바스크립트의 함수는 객체이다.) 는 prototype 프로퍼티를 갖는데 일반 객체와는 다른 것이며 일반 객체는 prototype 프로퍼티를 가지지 않는다.

함수 객체만이 가지고 있는 prototype 프로퍼티는 함수 객체가 생성자로 사용될 때, 이 함수를 통해 생성된 객체의 부모 역할을 하는 프로토타입 객체를 가리킨다. 위 코드에서 Foo는 생성자 함수로 사용되므로 생성자 함수 Foo의 prototype 프로퍼티가 가리키는 프로토타입 객체는 생성자 함수 Foo를 통해 생성되는 인스턴스 foo의 부모 역할을 한다.

console.log(Foo.prototype === foo.__proto__); // true

그리고 생성자 함수 Foo의 prototype 프로퍼티가 가리키는 프로토타입 객체가 가지고 있는 constructor 프로퍼티는 생성자 함수 Foo를 가리킨다.

console.log(Foo.prototype.constructor === Foo); // true

정적 메서드인 staticMethod는 생성자 함수 Foo의 메서드(함수는 객체이므로 메서드를 가질 수 있다.)이고, 일반 메서드인 prototypeMethod는 프로토타입 객체 Foo.prototype의 메서드이다. 따라서 staticMethod는 foo에서 호출할 수 없다.

 

프로토타입과 정적 메서드

8. 클래스 상속

클래스 상속(Class Inheritance)은 코드 재사용 관점에서 매우 유용하다. 새롭게 정의할 클래스가 기존에 있는 클래스와 매우 유사하다면, 상속을 통해 그대로 사용하되 다른 점만 구현하면 된다.

1) extends 키워드

extends 키워드는 부모 클래스(base class)를 상속받는 자식 클래스(sub class)를 정의할 때 사용한다. 부모 클래스 Circle을 상속받는 자식 클래스 Cylinder를 정의하자.

// 부모 클래스
class Circle {
    constructor(radius) {
    	this.radius = radius;
    }
    
    getDiameter() {
    	return 2 * this.radius;
    }
    
    getPerimeter() {
    	return 2 * Math.PI * this.radius;
    }
    
    getArea() {
    	return Math.PI * Math.pow(this.radius, 2);
    }
}

// 자식 클래스
class Cylinder extends Circle {
    constructor(radius, height) {
    	super(radius);
        this.height = height;
    }
    
    getArea() {
    	return (this.height * super.getPerimeter()) + (2 * super.getArea());
    }
    
    getVolume() {
    	return super.getArea() * this.height;
    }
}

const cylonder = new Cylinder(2, 10);

// 원의 지름
console.log(cylinder.getDiameter());  // 4
// 원의 둘레
console.log(cylinder.getPerimeter()); // 12.566370614359172
// 원통의 넓이
console.log(cylinder.getArea());      // 150.79644737231007
// 원통의 부피
console.log(cylinder.getVolume());    // 125.66370614359172

// cylinder는 Cylinder 클래스의 인스턴스이다.
console.log(cylinder instanceof Cylinder); // true
// cylinder는 Circle 클래스의 인스턴스이다.
console.log(cylinder instanceof Circle);   // true

* 오버라이딩(Overriding)

상위 클래스가 가지고 있는 메서드를 하위 클래스가 재정의하여 사용하는 방식

* 오버로딩(Overloading)

매개변수의 타입 또는 갯수가 다른, 같은 이름의 메서드를 구현하고 매개변수에 의해 메서드를 구별하여 호출하는 방식이다. 자바스크립트는 오버로딩을 지원하지 않지만 arguments 객체를 사용하여 구현할 수 는 있다.

 

위 코드를 프로토타입 관점으로 표현하면 아래와 같다. 인스턴스 cylinder는 프로토타입 체인에 의해 부모 클래스 Circle의 메서드를 사용할 수 있다.

클래스 상속

console.log(cylinder.__proto__ === Cylinder.prototype); // true
console.log(Cylinder.prototype.__proto__ === Circle.prototype); // true
console.log(Circle.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__ === null); // true

프로토타입 체인은 특정 객체의 프로퍼티나 메서드에 접근하려고 할 때 프로퍼티 또는 메서드가 없다면 [[Prototype]] 내부 슬롯이 가리키는 링크를 따라 자신의 부모 역할을 하는 프로토타입 객체의 프로퍼티나 메서드를 차례대로 검색한다. 그리고 검색에 성공하면 그 프로퍼티나 메서드를 사용한다.

2) super 키워드

super 키워드는 부모 클래스를 참조(Reference)할 때 또는 부모 클래스의 constructor를 호출할 때 사용한다.

 

3) static 매서드와 prototype 메서드의 상속

프로토타입 관점에서 바라보면 자식 클래스의 [[Prototype]] 내부 슬롯이 가리키는 프로토타입 객체는 부모 클래스이다.

class Parent {}
class Child extends Parent {}

console.log(Child.__proto__ === Parent); // true
console.log(Child.prototype.__proto__ === Parent.prototype); // true

자식 클래스의 Child의 프로토타입 객체는 부모 클래스의 Parent이다.

이것을 프로토타입 체인(Prototype Chain)에 의해 부모 클래스의 정적 메서드도 상속됨을 의미한다.

class Parent {
    static staticMethod() {
    	return 'staticMethod';
    }
}

class Child extends Parent {}

console.log(Parent.staticMethod()); // 'staticMethod'
console.log(Child.staticMethod()); // 'staticMethod'

자식 클래스의 정적 메서드 내부에서도 super 키워드를 사용하여 부모 클래스의 정적 메서드를 호출할 수 있다. 자식 클래스는 프로토타입 체인에 의해 부모 클래스의 정적 메서드를 참조할 수 있기 때문이다.

하지만 자식 클래스의 일반 메서드(프로토타입 메서드) 내부에서는 super 키워드를 사용하여 부모 클래스의 정적 메서드를 호출할 수 없다. 이는 자식 클래스의 인스턴스는 프로토타입 체인에 의해 부모 클래스의 정적 메서드를 참조할 수 없기 때문이다.

class Parent {
    static staticMethod() {
    	return 'Hello';
    }
}

class Child extends Parent {
	static staticMethod() {
    	return `${super.staticMethod()} world`;
    }
    
    prototypeMethod() {
    	return `${super.staticMethod()} world`;
    }
}

console.log(Parent.staticMethod()); // 'Hello'
console.log(Child.staticMethod()); // 'Hello world'
console.log(Child.prototypeMethod()); // TypeError: {intermediate value).staticMethod is not a function

prototype chain에 의한 메서드 상속

728x90
반응형

'programming language > ECMAScript6' 카테고리의 다른 글

[ES6] 프로미스 (Promise)  (1) 2021.12.27
[ES6] 모듈 (Module)  (0) 2021.12.20
[ES6] 클래스(1)  (2) 2021.12.15
[ES6] 디스트럭처링 (Destructuring)  (0) 2021.12.14
[ES6] 객체 리터럴 프로퍼티 기능 확장  (0) 2021.12.13
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !