본 글은 Modern JavaScript Deep-dive을 요약한 글입니다.
자세한 내용은 본 책을 읽으시기 바랍니다.

 

클로저는 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어에서 사용되는 중요한 특성이다.

24.1 렉시컬 스코프

 자바스크립트 엔진은 함수를 어디서 호출했는지가 아니라 함수를 어디에 정의했는지에 따라 상위 스코프를 결정한다. 이를 렉시컬 스코프(정적 스코프)라 한다.

 

const x = 1;

function foo() {
	const x = 10;
    bar();
}

function bar(){
	console.log(x)
}

foo() // ?
bar() // ?

 함수의 상위 스코프는 함수를 어디서 정의했느냐에 따라 결정되므로 foo 함수와 bar 함수의 상위 스코프는 전역이다. 즉, 함수의 상위 스코프는 함수를 정의한 위치에 의해 정적으로 결정되고 변하지 않는다. 스코프의 실체는 실행 컨텍스트의 렉시컬 환경이다. 이 렉시컬 환경은 자신의 "외부 렉시컬 환경에 대한 참조"를 통해 상위 렉시컬 환경과 연결된다. 렉시컬 환경의 "외부 렉시컬 환경에 대한 참조"에 저장할 참조값, 즉 상위 스코프에 대한 참조는 함수 정의가 평가되는 시점에 함수가 정의된 위치에 의해 결정된다. 이것이 바로 렉시컬 스코프다.

24.2 함수 객체의 내부 슬롯 [[Environment]]

 렉시컬 스코프가 가능하려면 함수는 자신이 호출되는 환경과는 상관없이 자신이 정의된 환경, 즉 상위 스코프를 기억해야한다. 이를 위해 함수는 자신의 내부 슬롯 [[Environment]]에 자신의 정의된 환경, 즉 상위 스코프의 참조를 저장한다. 

 이때 자신의 내부슬롯에 저장된 상위 스코프의 참조는 현재 실행중인 실행 컨텍스트의 렉시컬 환경을 가리킨다. 왜냐하면 함수 정의가 평가되어 함수 객체를 생성하는 시점은 함수가 정의된 환경, 즉 상위 함수가 평가 또는 진행되고 있는 시점이며, 이때 현재 실행 중인 실행 컨텍스트는 상위 함수의 실행 컨텍스트 이기때문이다.

 따라서 함수 객체의 내부 슬롯 [[Environment]]에 저장된 현재 실행 중인 시행 컨텍스트의 렉시컬 환경의 참조가 바로 상위 스코프다. 또한 자신이 호출되었을 때 생성될 함수 렉시컬 환경의 "외부 렉시컬 환경에 대한 참조"에 저장될 참조값이다. 함수 객체는 내부 슬롯 [[Environment]]에 저장된 렉시컬 환경의 참조, 즉 상위 스코프를 자신이 존재하는 한 기억한다.

 함수가 호출되면 함수 내부로 코드의 제어권이 이동한다. 그리고 함수 코드를 평가하기 시작한다. 함수 코드 평가는 아래 순서로 진행된다.

  1. 함수 실행 컨텍스트 생성
  2. 함수 렉시컬 환경 생성
    1. 함수 환경 레코드 생성
    2. this 바인딩
    3. 외부 렉시컬 환경에 대한 참조 결정

이때 함수 렉시컬 환경의 구성 요소인 외부 렉시컬 환경에 대한 참조에는 함수 객체의 내부 슬롯 [[Environment]]에 저장된 렉시컬 환경의 참조가 할당된다. 이것이 바로 함수 정의 위치에 따라 상위 스코프를 결정하는 렉시컬 스코프의 실체다.

24.3 클로저와 렉시컬 환경

다음 예를 살펴보자.

const x = 1;

// 1
function outer(){
	const x = 10;
    const inner = function () {console.log(x);}; // 2
    return inner;
}

// outer 함수를 호출하면 중첩 함수 inner를 반환한다.
// 그리고 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 팝되어 제거된다.

const innerFunc() = outer(); // 3
innerFunc(); // 4 10

 outer 함수를 호출하면 oute함수는 중첩 함수 inner를 반환하고 생명 주기를 마감한다. 즉, 함수의 실행이 종료되면 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거된다. 이때 outer 함수의 지역 변수 x와 값 10을 저장학호 있던 outer 함수의 실행 컨텍스트가 제거되었으므로 함수의 지역변수 또한 생명 주기를 마감한다. 따라서 함수의 지역 변수는 더는 유효하지 않게 되어 변수에 접근할 수 있는 방법은 달리 없어 보인다.

 그러나 위 예제의 실행 결과는 outer 함수의 지역 변수 x의 값인 10이다. 이처럼 외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있다. 이러한 중첩 함수를 클로저라고 부른다.

  • 클로저는 함수와 그 함수가 선언된 렉시컬 환경과의 조합이다.

 자바스크립트의 모든 함수는 자신의 상위 스코프를 기억한다고 했다. 따라서 함수를 어디서 호출하든 상관없이 함수는 언제나 자신이 기억하는 상위 스코프의 식별자를 참조할 수 있으며 식별자에 바인딩된 값을 변경할 수도 있다.

 

 outer 함수의 실행이 종료하면 inner 함수를 반환하면서 outer 함수의 생명주기가 종료된다. 즉 outer 함수의 실행 컨텍스트가 실행 컨텍스트 스택에서 제거된다. 이때 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거되지만 outer 함수의 렉싴러 환경까지 소멸하는 것은 아니다. outer 함수의 렉시컬 환경은 inner 함수의 [[Environment]] 내부 슬롯에 의해 참조되고 있고 inner 함수는 전역 변수 innerFunc에의해 참조되고 있으므로 가비지 컬렉터의 대상이 되지 않기 때문이다. 가비지 컬렉터는 누군가 참조하고 있는 메모리 공간을 함부로 해제하지 않는다. 

 중첩함수 inner는 외부 함수 outer 보다 더 오래 생존했다. 이때 외부 함수보다 더 오래 생존한 중첩 함수는 외부 함수의 생존여부(실행 컨텍스트의 생존 여부)와 상관없이 자신이 정의된 위치에 의해 결졍된 상위 스코프를 기억한다. 

 

 자바스크립트의 모든 함수는 상위 스코프를 기억하므로 이론적으로 모든 함수는 클로저다. 하지만 일반적으로 모든 함수를 클로저라고 하지는 않는다. 다음 예제를 살펴보자.

function foo() {
	const x = 1;
    const y = 1;
    
    // 일반적으로 클로저라고 하지않는다.
    function bar(){
    	const z = 3;
        
        debugger;
    	// 상위 스코프의 식별자를 참조하지 않는다
        console.log(z);
    }
    return bar;
}
const bar = foo();
bar()

이처럼 상위 스코프의 어떤 식별자도 참조하지 않는 경우 대부분의 모던 브라우저는 최적화를 통해 다음 그름과 같이 상위 스코프를 기억하지 않는다. 참조하지도 않는 식별자를 기억하는 것은 메모리 낭비이기 때문이다. 따라서 bar 함수는 클로저라고 할 수 없다.

 

또 다른 예제를 살펴보자.

function foo() {
	const x = 1;
    const y = 1;
    
    // 일반적으로 클로저라고 하지않는다.
    function bar(){
        // 클로저 였지만 곧바로 소멸한다.
        debugger;
    	// 상위 스코프의 식별자를 참조한다.
        console.log(x);
    }
    return bar;
}
foo();

 함수 bar는 상위 스코프의 식별자를 참조하고 있으므로 클로저다. 하지만 외부 함수 foo의 외부로 중첩 함수가 반환되지 않는다. 즉, 외부 함수 foo보다 중첩 함수 bar의 생명 주기가 짧다. 내부 함수가 외부 함수보다 일찍 소멸되기 때문에 생명 주기가 종료된 외부 함수의 식별자를 참조할 수 있다는 클로저의 본질에 부합하지 않는다.

 

마지막 예제를 알아보자.

function foo() {
	const x = 1;
    const y = 2;
    
    // 클로저
    function bar(){
        
        debugger;
    	// 중첩 함수 bar는 외부 함수보다 더 오래 유지되며 상위 스코프의 식별자를 참조한다.
        console.log(x);
    }
    return bar;
}
const bar = foo();
bar()

 이처럼 외부함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있다. 이러한 중첩 함수를 클로저라고 부른다. 클로저는 중첩 함수가 상위 스코프의 식별자를 참조하고 있고 중첩 함수가 외부 함수보다 더 오래 유지되는 경우에 한정하는 것이 일반적이다.

클로저에 의해 참조되는 상위 스코프의 변수를 자유 변수라고 부른다. 클로저란 "함수가 자유 변수에 대해 닫혀있다"라는 의미다. 의역하자면 "자유변수에 묶여있는 함수"라고 할 수 있다. 모던 자바스크립트 엔진은 최적화가 잘 되어 있어서 클로저가 참조하고 있지 않는 식별자는 기억하지 않는다. 즉, 상위 스코프의 식별자 중에서 기억해야 할 식별자만 기억한다.

24.4 클로저의 활용

클로저는 상태를 안전하게 변경하고 유지하기 위해 사용한다. 다시 말해, 상태가 의도치 않게 변경되지 않도록 상태를 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용하기 위해 사용한다.

let num = 0;;

const increase = function(){
	return ++num;
}

console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3

 위 코드는 오류를 발생시킬 가능성이 내포하고 있는 좋지 않은 코드다. 그 이유는 바르게 동작하려면 다음의 전제 조건이 지켜져야하기 때문이다.

  1. 카운트 상태(num 변수의 값)은 increase 함수가 호출되기 전까지 변경되지 않고 유지되어야 한다.
  2. 이를 위해 카운트 상태(num 변수의 값)는 increase 함수만이 변경할 수 있어야 한다.

하지만 카운트 상태는 전역 변수를 통해 관리되고 있기 때문에 언제든지 누구나 접근할 수 있고 변경할 수 있다. 만약 누군가에 의해 의도치 않게 카운트 상태를 변경하게 되면 이는 오류로 이어진다. 클로저를 활용해 보자.

const increase = (function() {
	 let num = 0;
     
     // 클로저
     return function() {
     	return ++num;
     };
}())

console.log(increase()); // 1
console.log(increase()); // 2
console.log(increase()); // 3

 즉시 실행 함수는 호출된 이후 소멸되지만 즉시 실행 함수가 반환한 클로저는 increase 변수에 할당되어 호출된다. 이때 즉시 실행 함수가 반환한 클로저는 자신의 정의된 위치에 의해 결정된 상위 스코프인 즉시 실행 함수의 렉시컬 환경을 기억하고 있다. 따라서 즉시 실행 함수가 반환한 클로저는 카운트 상태를 유지하기 위한 자유 변수 num을 언제 어디서 호출하든지 참조하고 변경할 수 있다.

즉시 실행 함수는 한 번만 실행되므로 increas가 호출될 때마다 num 변수가 재차 초기화될 일은 없을 것이다. 또한 num 변수는 외부에서 직접 접근할 수 없는 은닉된 private 변수이므로 전역 변수를 사용했을 때와 같이 의도되지 않은 변경을 걱정할 필요도 없기 때문에 더 안정적인 프로그래밍이 가능하다.

 이처럼 클로저는 상태가 의도치 않게 변경되지 않도록 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용하여 상태를 안전하게 변경하고 유지하기 위해 사용한다.

 

 변수 값은 누군가에 의해 언제든지 변경될 수 있어 오류 발생의 근본적인 원인이 될 수 있다. 외부 상태 변경이나 가변 데이터를 피하고 불변성을 지향하는 함수형 프로그래밍에서 부수 효과를 최대한 억제하여 오류를 피하고 프로그램의 안정성을 높이기 위해 클로저는 적극적으로 사용된다. 다음 함수형 프로그래밍에서 클로저를 활용하는 예제이다.

function makeCounter(aux){
	let counter = 0;
    
    // 클로저 반환
    return function() {
    	counter = aux(counter);
        return counter;
    };
    
}

function increase(n) {
	return ++n;
}

function decrease(n) {
	return --n;
}

const increaser = makeCounter(increase); // 1
console.log(increaser()); // 1
console.log(increaser()); // 2

}

// 별개의 독립된 럭시컬 환경을 갖기 때문에 카운터 상태가 연동되지 않는다.
const decreaser = makeCounter(decrease); // 2
console.log(decreaser()); // -1
console.log(decreaser()); // -2

 makeCounter 함수가 반환하는 함수는 자신이 생성됐을 때의 렉시컬 환경인 makeCounter 함수의 스코프에 속한 counter 변수를 기억하는 클로저다. makeCounter 함수는 인자로 전달받은 보조 함수를 합성하여 자신이 반환하는 함수의 동작을 변경할 수 있다. 이때 주의할 것은 makeCounter 함수를 호출해 함수를 반환할 때 반환된 함수는 자신만의 독립된 렉시컬 환경을 갖는다. 독립된 카운터가 아니라 연동하여 증감이 가능한 카운터를 만들려면 렉시컬 환경을 공유하는 클로저를 만들어야 한다. 이를 위해선 makeCounter 함수를 즉시실행 함수로 만들어 두번 호출 하지 말아야 한다.

24.5 캡슐화와 정보 은닉

캡슐화는 객체의 상태를 나타내는 프로퍼티와 프로퍼티를 참조하고 조작할 수 있는 동작인 메서드를 하나로 묶는 것을 말한다. 캡슐화는 객체의 특정 프러퍼티나 메서드를 감출 목적으로 사용하기도 하는데 이를 정보 은닉이라 한다.

 정보 은닉은 외부에 공개할 필요가 없는 구현의 일부를 외부에 공개되지 않도록 감추어 적절치 못한 접근으로부터 객체의 상태가 변경되는 것을 방지해 정보를 보호하고, 객체 간의 상호성, 즉 결합도를 낮추는 효과가 있다.

 자바스크립트는 pubilc, private, protected 같은 접근 제한자를 제공하지 않는다. 따라서 자바스크립트 객체의 모든 프로퍼티와 메서드는 기본적으로 외부에 공격되어 있다.

const Person = (function(){
	let _age = 0; // private
    
    // 생성자 함수
    function Person(name, age){
    	this.name = name; // public
        _age = age;
    }
    
    // 프로토타입 메서드
    Person.prototype.sayHi = function(){
    	conosole.log(`Hi! My name is ${this.name}. I am ${_age}.`);
    }
    
    // 생성자 함수 반환
    return Person;
}())

const me = new Person('Lee',20);

me.sayHi; / /Hi! My name is Lee. I am 20.
console.log(me.name); // Lee
console.log(me._age); // undefined

 위 패턴을 사용하면 public, private, protected 같은 접근 제한자를 제공하지 않는 자바스크립트에서도 정보 은닉이 가능한 것처럼 보인다. 하지만 위 코드도 문제가 있다. 생성자 함수가 여러 개의 인스턴스를 생성할 경우 다음과 같이 _age 변수의 상태가 유지되지 않는다는 것이다.

const me = new Person('Lee',20);
me.sayHi; / /Hi! My name is Lee. I am 20.

const you = new Person('Kim',30);
you.sayHi; / /Hi! My name is kim. I am 30.

// _age 변수 값이 변경된다!
me.sayHi; / /Hi! My name is Lee. I am 30.

 이는 Person.prototype.sayHi 메서드가 단 한번 생성되는 클로저이기 때문이다. 이 메서드는 즉시 실행 함수가 호출될 때 생성된다. 이때 자신의 상위 스코프인 즉시 실행 함수의 실행 컨텍스트의 렉시컬 환경의 참조를 [[Environment]]에 저장하여 기억한다. 따라서 Person 생성자 함수의 모든 인스턴스가 상속을 통해 호출할 수 있는 Person.prototype.sayH 메서드의 상위 스코프는 어떤 인스턴스로 호출하더라도 하나의 동일한 상위 스코프를 사용하게 된다. 

 이처럼 자바스크립트는 정보 은닉을 완전하게 지원하지 않는다. 인스턴스 메서드를 사용한다면 자유 변수를 통해 private를 흉내 낼 수 있지만 프로토타입 메서드를 사용하려면 이마저도 불가능해진다. 다행히도 2021년 1월에 TC39 프로세스의 stage 3에는 클래스에 private 필드를 정의할 수 있는 새로운 표준 사양이 제안되어 있다.

24.6 자주 발생하는 실수

var funcs = [];

for(var i = 0; i < 3; i ++){
	funcs[i] = function () {return i;}; // 1
}

for (var j = 0; i < funcs.length; i ++) {
	console.log(funcs[j]()); // 2
}

 for 문의 변수 선언문에서 var 키워드로 선언한 i 변수는 블록 레벨 스코프가 아닌 함수 레벨 스코프를 갖기 때문에 전역 변수다. 전역 변수 i에는 0, 1, 2가 순차적으로 할당된다. 따라서 funcs 배열의 요소로 추가한 함수를 호출하면 전역 변수 i를 참조하여 i의 값 3이 출력된다.

 

클로저를 사용해 위 예제를 바르게 동작하는 코드로 만들어보자.

var funcs = [];

for(var i = 0; i < 3; i ++){
	funcs[i] = (function (id) { // 1
    	return function(){
        	return id
        };
    }(i));
}

for (var j = 0; i < funcs.length; i ++) {
	console.log(funcs[j]()); // 2
}

 1에서 즉시 실행 함수는 전역 변수 i에 할당되어 있는 값을 인수로 전달받아 매개변수 id에 할당한 후 중첩함수를 반환하고 종료된다. 즉시 실행 함수가 반환한 함수는 funcs 배열에 순차적으로 저장된다.

 즉시 실행 함수가 반호나한 중첩 함수는 자신의 상위 스코프를 기억하는 클로저이고, 매개변수 id는 즉시 실행 함수가 반환한 중첩 함수에 묶여있는 자유 변수가 되어 그 값이 유지된다.

 위 예제는 자바스크립트의 함수 레벨 스코프 특성으로 인해 for 문의 변수 선언문에서 var 키워드로 선언한 변구가 전역 변수가 되기 때문에 발생하는 현상이다. ES6의 let 키워드를 사용하면 이 같은 번거로움이 깔끔하게 해결된다.

const funcs = [];

for (let i = 0; i < 3; i ++){
	funcs[i] = function () {return i;};
}

for (let i = 0; i < funcs.length; i ++){
	console.log(funcs[i]()); //  0 1 2
}

 for 문의 변수 선언에서 let 키워드로 선언한 변수를 사용하면 for 문의 코드 블록이 반복 실행될 때마다 for 문 코드 블록의 새로운 렉시컬 환경이 생성된다. 만약 for 문의 코드 블록 내에서 정의한 함수가 있다면 이 함수의 상위 스코프는 for 문의 코드 블록이 실행될 때마다 생성된 for문 코드 블록의 새로운 렉시컬 환경이다.

1 : for 문의 변수 선언문에서 let 키워드로 선언한 초기화 변수를 사용한 for 문이 평가되면 먼저 새로운 렉시컬 환경을 생성하고 초기화 변수 식별자와 값을 등록한다. 그리고 새롭게 생성된 렉시컬 환경을 현재 실행 중인 실행 컨텍스트의 렉시컬 환경으로 교체한다.

2, 3, 4 : for 문의 코드 블록이 반복 실행되기 시작되면 새로운 렉시컬 환경을 생성하고 for 문 코드 블록 내의 식별자와 값을 등록한다. 그리고 새롭게 생성된 렉시컬 환경을 현재 실행 중인 실행 컨텍스트의 렉시컬 환경으로 교체한다.

5 : for 문의 코드 블록의 반복 실행이 모두 종료되면 for 문이 실행되기 이전의 렉시컬 환경을 실행 중인 실행 컨텍스트의 렉시컬 환경으로 되돌린다. 단, 이는 반복문의 코드 블록 내부에서 함수를 정의할 때 의미가 있다. 반복문의 코드 블록 내부에 함수 정의가 없는 반복문이 생성성하는 새로운 렉시컬 환경은 반복 직후, 아무도 참조하지 않기 때문에 가비지 컬렉션의 대상이 된다.

 

또 다른 방법으로 함수형 프로그래밍인 고차 함수를 사용하는 방법이 있따. 이 방법은 변수와 반복문의 사용을 억제할 수 있기 때문에 오류를 줄이고 가독성을 좋게 만든다.

const funcs = Array.from(new Array(3),(_, i) => () => i); // (3) [f,f,f]

funcs.forEach(f => console.log(f())); // 0 1 2

'Javascript' 카테고리의 다른 글

Deep-dive .26 : ES6 함수의 추가기능  (1) 2024.07.24
Deep-dive .25 : 클래스  (0) 2024.07.09
Deep-dive .23 : 실행 컨텍스트  (2) 2024.07.02
Deep-dive .22 : this  (0) 2024.07.02
Deep-dive .19 : 프로토타입  (1) 2024.06.11
본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

 이번 11장에서는 타입을 쓰지 않는 언어, 즉 서드 파티 자바스크립트 라이브러리를 사용한다거나 빠른 패치를 위해 타입 안전성을 포기하는 등의 실전 상황에서 타입스크립트를 접목하는 방법을 알아보려한다.

  1. 타입 선언 사용하기
  2. 자바스크립트에서 타입스크립트로 점진적으로 마이그레이션하기
  3. 서드 파티 자바스크립트와 타입스크립트 사용하기

11.1 타입선언

타입 선언은 d.ts 확장자를 가진 파일이다. JSDoc 어노테이션과 더불어, 타입 선언은 타입이 없는 자바스크립트 코드에 타입스크립트 타입을 부여할 수 있는 수단이다. 타입 선언은 일반 자바스크립트와 비슷하지만 몇 가지 차이점이 존재한다.

  • 타입만 포함할 수 있고 값은 포함할 수 없다.
  • 값을 정의할 수 없지만, declare라는 특별 키워드를 사용해 다른 어딘가에 값이 있다는 사실을 선언할 수 있다.
  • 소비자가 볼 수 있는 대상에만 타입을 선언할 수 있다. 노출되지 않은 타입이나 함수 안에 선언된 지역 변수의 타입은 포함할 수 없다.
import {Subscriber} from './Subscriber'
import {Subscription} from './Subscription'
import {PartialObserver, Subscribable, TeardownLogic} from './types'

export declare class Observable<T> implements Subscribable<T> { // 1
	// ...
    // ...
    ):Subscription // 2
}
  1. 타입 선언에선 클래스를 직접 정의할 수는 없지만, 그 대신 .d.ts 파일에 대응하는 자바스크립트 파일 안에 정의했음을 선언(declare)할 수 있다.
  2. 타입 선언은 구현을 포함하지 않으므로 subscrive의 오버로드 두 개만 포함하고 구현 시그니처는 포함하지 않는다.

 자세히 보면 Observable.ts에서 구현을 빼면 Observable.d.ts가 된다는 사실을 확인할 수 있다. 라이브러리의 다른 파일에서는 직접 Observable.ts 타입스크립트 파일에 접근하고 필요한 기능을 사용할 수 있으므로 이런 타입 선언이 전혀 필요가 없다. 하지만 타입스크립트 응용 프로그램에서 라이브러리를 사용할 때는 상황이 다르다.

 

타입 선언 파일은 다음처럼 활용된다.

  1. 다른 사용자가 타입스크립트 응용 프로그램에서 우리가 만든 컴파일한 타입스크립트를 사용한다면 그들의 TSC 인스턴스는 우리의 타입스크립트로부터 생성된 자바스크립트 파일에 대응하는 d.ts 파일을 검색한다.
  2. 타입스크립트가 지원하는 코드 편집기는 이 .d.ts 파일들을 읽어 해석한 다음 우리가 코드를 작성할 떄 유용한 타입 힌트를 제공한다.
  3. 불필요한 재컴파일을 막아주어 컴파일 시간을 크게 줄여준다.

타입 선언은 타입스크립트에 "자바스크립트에는 이런 정보가 정의되어 있다"라고 알려주는 수단이다. 때로는 값을 포함하는 일반적인 선언과 구별하기 위해 타입 선언에 앰비언트라는 표현을 쓰기도 한다. 타입 선언은 다음과 같은 상황에서 사용할 수 있다.

  • 자바스크립트 어딘가에 전역 변수가 정의되어 있음을 타입스크립트에 알림
  • 프로젝트 어디에서나 전역으로 이용할 수 있는 타입을 정의하여 임포트 없이 바로 사용하고자 할 때
  • 타입스크립트에 NPM으로 설치한 서드 파티 모듈이 있음을 알릴 때

어떤 목적으로 사용하든 타입 선언은 스크립트 모드의 .ts나 .d.ts 파일 안에 위치해야 한다. 또한 타입 선언 파일에 선언된 최상위 수준 '값'에는 declare 키워드를 사용해야 하지만 최상위 수준 '타입'과 '인터페이스'에서는 사용하지 않아도 된다.

11.1.1 앰비언트 변수 선언

엠비언트 변수 선언은 한 프로젝트의 모든 .ts와 d.ts 파일에서 임포트 없이 사용할 수 있는 전역 변수의 존재를 타입스크립트에 알리는 수단이다.

declare let process: {
	env: {
    	NODE_ENV: 'development'|'production'
    }
}

process = {
	env: {
    	NODE_ENV: 'production'
    }
}

프로그램이 어느 순간 process.env.NODE_ENV를 확인한다고 가정할 때, process라는 전역객체가 있고, 이 객체는 한 개의 env 프로퍼티를 가지고 있으며 env는 NODE_ENV라는 프로퍼티를 갖고 있음을 타입스크립트에 선언하면 에러없이 처리할 수 있다.

11.1.2 앰비언트 타입 선언

엠비언트 타입 선언은 앰비언트 변수 선언과 같은 규칙을 준수한다. 즉, 선언은 스크립트 모드 파일의 .ts나 .d.ts 파일에 저장해야 하며 명시적으로 임포트 하지않아도 프로젝트의 모든 파일에서 전역으로 이용할 수 있다.

 예를 들어, 배열이 아니면 배열로 전환해주는 전역 유틸리티 타입 ToArray<T>가 있다고 가정할 때 같은 프로젝트의 모든 스크립트 모드 파일에서 이 타입을 정의할 수 있다. 최상우 수준의 types.ts 파일에 정의 한다면 다른 파일에서 명시적으로 임포트 하지 않고도 이타입을 사용할 수 있다.

// types.ts
type ToArray<T> = T extends unknown[] ? T : T[]


// 다른 파일
function toArray<T>(a:T): ToArray<T>{
	// ...
}

11.1.3 앰비언트 모듈 선언

자바스크립트 모듈을 사용하면서 그 모듈에서 사용할 일부 타입을 빠르게 선언하고 안전하게 사용하고 싶다면 앰비언트 모듈 선언을 사용하자. 앰비언트 모듈 선언은 평범한 타입 선언을 declare module이라는 특별한 문법으로 감싸는 것이 특징이다.

declare module 'module-name' {
	export type MyType = number
    export type MyDefaultType = { a : string}
    export let myExport : MyType
    // ...
}

 모듈명은 정확한 import 경로에 대응한다. 이 경로를 임포트하면 앰비언트 모듈 선언이 타입스크립트에 무엇을 이용할 수 있는지 알려준다. 중첩된 모듈이 있다면 선언에 import 경로 전체를 포함해야 한다.

import ModuleNmae from 'module-name'
ModuleName.a // string

declare module '@most/core' {
	// 타입 선언
}

모듈 선언은 와일드카드 임포트를 지원하므로 '주어진 패턴과 일치하는 모든 import 경로' 특정한 타입으로 해석하도록 할 수 있다. 와일드 카드를 이용해 import 경로에 패턴을 부여해보자

// 웹팩의 json 로더로 임포트한 JSON 파일의 타입 선언
declare module 'json!*' {
	let value: object
    export default value
}

// 웹팩의 스타일 로더를 임포트한 CSS 파일의 타입 선언
declare module '*.css' {
	let css: CSSRuleList
    export default css
}

// 다른 파일에서 json, css 파일을 로드 할 수 있디.
import a form 'json!myFile'
import b form './widget.css'
a // 객체
b // CSSRuleList

11.2 자바스크립트를 타입스크립트로 천천히 마이그레이션하기

이는 다음의 과정을 거쳐야한다.

  1. TSC를 프로젝트에 추가한다.
  2. 기존 자바스크립트 코드에 타입 확인을 시작한다.
  3. 한 번에 한 파일씩 자바스크립트를 타입스크립트로 마이그레이션한다.
  4. 의존하는 외부 코드용 타입 선언을 설치한다. 
    1. 아직 타입이 없는 외부 코드용 타입의 스텁 만들기
    2. 타입 선언을 만들어서 DefinitelyTyped에 기여하기
  5. 코드베이스에 strict 모드를 적용한다.

11.2.1 TSC 추가

 타입스크립트와 자바스크립트가 함께 사용된 코드베이스에서는 TSC가 타입스크립트뿐 아니라 자바스크립트 파일도 컴파일하도록 설정한다. tsconfig.json을 다음처럼 설정하자.

{
	"compilerOtions":{
    	"allowJS": true
    }
}

 이처럼 설정 하나만 바꾸면 TSC가 자바스크립트 파일도 컴파일한다.

11.2.2.1 자바스크립트에 타입 확인 활성화(선택사항)

TSC가 자바스크립트를 처리하기 시작했으므로 타입 확인 기능을 이용할 수 있다. tsconfig.json을 다음처럼 설정하자.

{
	"compilerOtions":{
    	"allowJS": true
        "checkJS": true
    }
}

이제 타입스크립트가 자바스크립트 파일을 컴파일할 때마다 마치 타입스크립트 코드를 다루듯 최선을 다해 타입을 추론하고 검증한다.

 

타입스크립트로 자바스크립트를 실행하면 타입스크립트 코드를 실할 때보다 더 관대한 추론 알고리즘을 적용한다.

  • 모든 함수 매개변수는 선택사항이다.
  • 함수와 클래스의 프로퍼티 타입은 어떻게 선언했는지가 아니라 어떻게 사용했는지에 의해 결정된다.
  • 객체, 클래스, 함수를 선언한 다음에 추가 프로퍼티를 할당할 수 있다.내부적으로 타입스크립트는 각 클래스와 함수 선언에 대응하는 네임스페이스를 생성하고 모든 객체 리터럴에 인덱스 시그니처를 자동 추가해서 이를 구현한다.

11.2.2.2 JSDoc 어노테이션 추가(선택사항)

 급한 상황에서는 기존의 자바스크립트 파일에 추가한 새 함수에만 타입 어노테이션을 추가하고 싶을 수 있다. 이럴 때는 이 파일을 타입스크립트로 변환하기 전까지는 JSDoc 어노테이션을 새 함수에 적용할 수 있다. 타입스크립트는 JSDoc을 이해하며, 타입 검사기에 타입 정보를 알려주는 용도로 쓰인다.

11.2.3 파일 이름을 .ts로 바꾸기

 한 번에 한 파일만 선택하여 확장자를 .js에서 .ts로 바꾼다. 이 순간 에러가 나타나면서 확인되지 않은 타입에러, 누락된 case 문, null 확인 누락, 잘못된 변수 이름 등을 알려준다. 이런 에러를 해결하는 방법은 두 가지다.

  1. 제대로 처리하기. 관련된 모든 파일에서 더 이상 에러가 발생하지 않도록 차분하게 형태, 필드, 함수의 타입을 올바르게 결정한다
  2. 빠르게 처리하기. 닥치는 대로 자바스크립트의 파일 확장자를 .ts로 바꾸되 tsconfig.json 설정은 느슨하게 유지해서 가능한 한 타입 에러가 적게 발생하도록 설정하자.

11.2.4 엄격하게 만들기

TSC의 엄격성과 관련된 플래그를 한 개씩 설정해가면서 코드를 최대한 안전하게 만들 차례다. 마지막에는 TSC의 자바스크립트 상호 운용 플래그를 꺼서 모든 코드가 엄격한 타입의 타입스크립트가 되도록 강제한다.

{
	"compilerOtions":{
    	"allowJS": false
        "checkJS": false
    }
}

11.3 자바스크립트의 타임 검색

타입스크립트 파일에서 자바스크립트 파일을 임포트할 떄 타입스크립트는 다음 알고리즘을 토대로 자바스크립트 코드에 필요한 타입 선언을 검색한다.

  1. js 파일과 이름이 같은 형제 .d.ts. 파일을 찾는다. 이 파일이 존재함녀 .js 파일의 타입 선언으로 사용한다.
  2. 적절한 d.ts 파일이 없고, 만약 allowJS와 checkJS가 true이면 .js 파일의 타입을 추론한다.
  3. 2에도 해당하지 않으면 전체 모듈을 any로 처리한다.

 

서드 파티 자바스크립트 모듈를 임포트 할 떄는 조금 다른 알고리즘을 사용한다.

  1. 모듈의 지역 타입 선언이 존재한다면 그 선언을 사용한다.
  2. 지역 타입 선언이 존재하지 않는다면 모듈의 package.json을 확인한다. types나 typigns라는 필드가 정의되어 있으며 해당 필드가 가리키는 .d.ts 파일을 모듈의 타입 선언 소스로 사용한다.
  3. 아니면 한 번에 한 단계씩 상위 디렉터리로 이동하면서 node_modules/@types 디렉터리를 찾는다.
  4. 그래도 타입 선언을 찾지 못했다면, 앞서 설명한 지역 타입 찾기 알고리즘을 수행한다.

11.4 서드 파티 자바스크립트 사용

서드 파티 자바스크립트 코드를 프로젝트에 설치할 때 다음처럼 세 가지 상황이 일어날 수 있다.

  1. 코드를 설치할 때 타입 선언이 함께 제공됨
  2. 코드를 설치할 때 타입 선언은 제공되지 않지만 DefinitelyTyped에서 선언을 구할 수 있음
  3. 코드를 설치할 때 타입 선언은 제공되지 않지만 DefinitelyTyped에서도 선언을 구할 수 없음

11.4.1 타입 선언을 포함하는 자바스크립트

설치하기만 하면 곧바로 타입 지워늘 완벽하게 받을 수 있다.

11.4.2  DefinitelyTyped에서 타입 선언을 제공하는 자바스크립트

임포트하는 서드 파티 코드가 자체적으로 타입 선언을 포함하지 않더라도 타입스크립트 커뮤니티를 관리하는 엠비언트 모듈 선언 중앙 저장소인 DefinitelyTyped에서 타입 선언을 제공하기도 한다. 설치한 패키지의 타입 선언이 DefinitelyTyped에 있는지 확인하려면 Typed Search에서 검색해보거나, 곧바로 선언이 설치되는지 시도해보자.

pnpm i lodash --save # lodash 설치
pnpm i @types/lodash --save-dev # lodash의 타입 선언 설치

--save-dev 플래그를 설정하면 설치한 타입 선언을 pak-age.json의 devDependencies 필드에 추가해준다.

11.4.3 DefinitelyTyped에서 타입 선언을 제공하지 않는 자바스크립트

 위 세 가지 상황 중 가장 드문 상황이다. 이를 해결하기 위한 다양한 선택지가 존재한다.

  1. 타입을 사용하지 않는 임포트 윗줄에 @ts-ignore 지시어를 추가해서 해당 임포트를 화이트리스트 처리한다.
  2. 빈 타입 선언 파일을 하나 만들어서 화이트리스트를 처리할 모듈을 적어 놓는다. 이렇게 해서 타입스크립트에 임포트 할 수 있는 모듈이 존재함을 알려주지만 어떤 타입을 포함하는지는 알려주지는 않는다.
    타입을 사용하지 않는 모든 모듈 정보를 한 파일에 둔다는 점에서 첫 번째 방법보다는 낫지만 그 안의 모든 export의 타입은 여전히 any이므로 안전성 면에서는 조금의 차이도 없다.
  3. 엠비언트 모듈 선언을 만든다. tpye.d.ts라는 파일을 만들고 빈 선언을 추가한다. 그리고 타입 선언을 채운다.
  4. 3번째 방법으로 만든 타입 선언을 다른 개발자들도 이용할 수 있도록 NPM에 제공한다.

서드 파티 자바스크립트의 타입 선언은 간단하게 구현할 수 있지만 어떻게 구현하는지는 모듈 유형에 따라 달라진다. 부록 D에서 알아보자

본 글은 Modern JavaScript Deep-dive을 요약한 글입니다.
자세한 내용은 본 책을 읽으시기 바랍니다.

 

 실행 컨텍스트를 바르게 이해하면 자바스크립트가 스코프를 기바느로 식별자와 식별자에 바인딩된 값(식별자 바인딩)을 관리하는 방식과 호이스팅이 발생하는 이유, 클로저의 동작방식, 그리고 태스크 큐와 함께 동작하는 이벤트 핸들러와 비동기 처리의 동작 방식을 이해할 수 있다.

23.1 소스코드의 타입

ECMAScript 사양은 소스코드를 4가지 타입으로 구분한다. 이들은 실행 컨텍스트를 생성한다. 4가지 타입을 구분하는 이유는 소스코드의 타입에 따라 실행 컨텍스트을 생성하는 과정과 관리 내용이 다르기 때문이다.

1. 전역 코드

 전역 코드는 전역 변수를 관리하기 위해 최상위 스코프인 전역 스코프를 생성해야 한다. var 키워드로 선언된 전역 변수와 함수 선언문으로 정의된 전역 함수를 전역 객체의 프로퍼티와 메서드로 바인딩하고 참조하기 위해 전역 객체와 연결되어야 한다.

2. 함수 코드

 함수 코드는 지역 스코프를 생성하고 지역 변수, 매개 변수, arguments 객체를 관리해야 한다. 그리고 생성한 지역 스코프를 전역 스코프에서 시작하는 스코프 체인의 일원으로 연결해야 한다.

3. eval 코드

 eval 코드는 strict mode 에서 자신만의 독자적인 스코프를 생성한다.

4. 모듈 코드

 모듈 코드는 모듈별로 독립적인 모듈 스콮를 생성한다.

 

23.2 소스코드의 평가와 실행

 자바스크립트 엔진은 소크드를 2개의 과정, 즉 "소스코드의 평가"와 "소스코드의 실행" 과정으로 나누어 처리한다.

  • 소스코드 평가 과정 : 실행 컨텍스트를 생성하고, 변수, 함수 등의 선언문만 먼저 실행하여 생성된 변수나 함수 식별자를 키로 실행 컨택스트가 관리하는 스코프(렉시컬 환경의 환경 레코드)에 등록한다.
  • 소스코드 실행 : 평가 과정이 끝나고 선언문을 제외한 소스코드가 순차적으로 실행되기 시작한다. 즉, 런타임이 시작된다. 이때 소스코드 실행에 필요한 정보, 즉 변수나 함수의 참조를 실행 컨택스트가 관리하는 스코프에서 검색해서 취득한다. 그리고 변수 값의 변경 등 소스코드의 실행 결과는 다시 실행 컨택스트가 관리하는 스코프에 등록된다.

23.3 실행 컨택스트의 역할

다음 예제는 전역코드와 함수코드로 구성되어 있다. 자바스크립트 엔진이 이를 어떻게 평가하고 실행하는 지 알아보자.

// 전역 변수 선언
const x = 1;
const y = 2;

// 함수 정의
function foo(a) {
	// 지역 변수 선언
    const x = 10;
    const y = 20;
    
    // 메서드 호출
    console.log(a + x + y); // 130
}

// 함수 호출
foo(100);

// 메서드 호출
console.log(x + y); // 3
  1. 전역 코드 평가
    전역 코드 실행에 앞서 평가 과정을 거치며 실행하기 위한 준비를 한다. 소스코드 평가 과정에서는 선언문만 먼저 실행한다. 따라서 전역 코드의 변수 선언문과 함수 선언문이 먼저 실행되고, 그 결과 생성된 전역 변수와 전역 함수가 실행 컨텍스트가 관리하는 전역 스코프에 등록된다.
  2. 전역 코드 실행평가 과정이 끝나면 런타임 시작되어 전역 코드가 순차적으로 실행되기 시작된다. 이때 전역 변수에 값이 할당되고 함수가 호출된다. 함수가 호출되면 전역 코드의 실행을 일시 중단하고 실행 순서를 변경하여 함수 내부로 진입한다.
  3. 함수 코드 평가
    함수 내부로 진입하면 내부의 문을 실행하기 앞서 함수 코드 평가 과정을 거친다. 이때 매개변수와 지역 변수 선언문이 먼저 실행되고, 그 결과 실행된 매개변수와 지역 변수가 실행 컨택스트가 관리하는 지역 스코프에 등록된다. 또한 arguments 객체가 생성되어 지역 스코프에 등록되고 this 바인딩도 결정된다.
  4. 함수 코드 실행
    함수 코드 평가 과정이 끝나면 런타임이 시작되어 함수 내부 코드가 순차적으로 실행되기 시작한다. 이때 매개변수와 지역 변수에 값이 할당되고 console.log 메서드가 호출된다. 
    1. console.log 메서드를 호출하기 위해서 먼저 식별자인 console를 스코프 체인을 통해 검색한다. 하지만 console 식별자는 스코프 체인에 등록되지 않고 전역 객체에 프로퍼티로 존재한다. 이는 전역 객체의 프로퍼티가 마치 전역 변수처럼 전역 스코프를 통해 검색 가능해야 한다는 것을 의미한다.
    2. 다음 log 프로퍼티를 console 객체의 프로토타입 체인을 통해 검색한다. 그후 console.log 메서드에 인수로 전달된 표현식 a+x+y가 평가된다. 세 식별자는 스코프 체인을 통해 검색한다.
    3. console.log 메서드의 실행이 종료되면 함수 코드 실행 과정이 종료되고 함수 호출 이전으로 되돌아가 전역 코드 실행을 계속한다.

 이처럼 코드가 실행되려면 다음과 같이 스코프, 식별자, 코드 실행 순서 등의 관리가 필요하다.

  1. 선언에 의해 생성된 모든 식별자(변수, 함수, 클래스 등)를 스코프를 구분하여 등록하고 상태 변화(식별자에 바인딩된 값의 변화)를 지속적으로 관리할 수 있어야 한다.
  2. 스코프는 중첩관계에 의해 스코프 체인을 형성해야 한다. 즉, 스코프 체인을 통해 상위 스코프로 이동하며 식별자를 검색할 수 있어야한다.
  3. 현재 실행 중인 코드의 실행 순서를 변경(예를 들어, 함수 호출에 의한 실행 순서 변경)할 수 있어야 하며 다시 되돌아갈 수도 있어야 한다.

 실행 컨택스트는 소스코드를 실행하는 데 필요한 환경을 제공하고 실행 결과를 실제 관리하는 영역이다. 좀 더 구체적으로 말해, 실행 컨택스트는 식별자(변수, 함수, 클래스 등의 이름)를 등록하고 관리하는 스코프와 코드 실행 순서 관리를 구현한 내부 메커니즘으로, 모든 코드는 실행 컨택스트를 통해 실행되고 관리된다.

 식별자와 스코프는 실행 컨택스트의 렉시컬 환경을 관리하고 코드 실행 순서는 실행 컨택스트 스택으로 관리한다.

23.4 실행 컨택스트 스택

 소스코드의 타입으로 분류할 때 전역 코드와 함수 코드로 이루어져 있다. 자바스크립트 엔진은 먼저 전역 코드를 평가하여 전역 실행 컨택스트를 생성한다. 그리고 함수가 호출되면 함수 코드를 평가하여 함수 실행 컨택스트를 생성한다.

 이때 생성된 실행 컨택스트는 스택 자료구조로 관리된다. 이를 실행 컨택스트 스택이라고 부른다. 코드가 실행되는 시간의 흐름에 따라 실행 컨택스트 스택에는 다음과 같이 실행 컨택스트가 추가되고 제거된다.

 실행 컨택스트 스택은 코드의 실행 순서를 관리한다. 소스 코드가 평가되면 실행 컨텍스트가 생성되고 실행 컨택스트 스택의 최상위에 쌓인다. 실행 컨택스트 스택의 최상위에 존재하는 실행 컨텍스트는 언제나 현재 실행 중인 코드의 실행 컨텐스트다. 따라서 실행 컨택스트 스택의 최상위에 존재하는 실행 컨택스트를 실행 중인 실행 컨택스트라 부른다.

23.5 렉시컬 환경

 렉시컬 환경은 식별자와 식별자에 바인딩된 값, 그리고 상위 스코프에 대한 참조를 기록하는 자료구조로 실행 컨택스트를 구성하는 컴포넌트다. 실행 컨텐스트 스택이 코드의 실행 순서를 관리한다면 렉시컬 환경은 스코프와 식별자를 관리한다.

 렉시컬 환경은 키와 값을 갖는 객체 형태의 스코프(전역, 함수, 블록 스코프)를 생성하여 식별자를 키로 등록하고 식별자에 바인딩된 값을 관리한다. 실행 컨택스트는 LexicalEnvironment 컴포넌트와 VariableEnvironment 컴포넌트로 구성된다. 

생성 초기에 LexicalEnvironment 컴포넌트와 VariableEnvironment 컴포넌트는 하나의 동일한 렉시컬 환경을 참조한다. 이후 몇 가지 상황을 만나면 VariableEnvironment 컴포넌트를 위한 새로운 렉시컬 환경을 생성하고, LexicalEnvironment 컴포넌트와 내용이 달라지는 경우도 있다. 하지만 이 책에서는 특수한 경우를 제외하고 이 둘을 구분하지 않고 설명한다.

 렉시컬 환경은 다음과 같이 두 개의 컴포넌트로 구성된다.

  1. 환경 레코드 : 스코프에 포함된 식별자를 등록하고 등록된 식별자에 바인딩된 값을 관리하는 저장소다. 환경 레코드는 소스코드의 타입에 따라 관리하는 내용에 차이가 있다.
  2. 외부 렉시컬 환경에 대한 참조 : 이는 상위 스코프를 가리킨다. 이때 상위 스코프란 외부 렉시컬 환경, 즉 해당 실행 컨택스트를 생성한 소스코드를 포함하는 상위 코드의 렉시컬 환경을 말한다. 외부 렉시컬 환경에 대한 참조를 통해 단방향 링크드 리스트인 스코프 체인을 구현한다.

23.6 실행 컨텍스트의 생성과 식별자 검색 과정

다음 예제를 통해 어떻게 실행 컨택스트가 생성되고 코드 실행 결과가 관리되는지, 그리고 어떻게 실행 컨텍스트를 통해 식별자를 검색하는 지 알아보자.

var x = 1;
const y = 2;

function foo(a){
	var x = 3;
    const y = 4;
    
    function bar(b){
    	const z = 5;
        console.log(a + b + x + y +z);
    }
    bar(10);
}

foo(20); // 42

23.6.1 전역 객체 생성

전역 객체는 전역 코드가 평가되기 이전에 생성된다. 이때 전역 객체에는 빌트인 전역 프로퍼티와 빌트인 전역함수, 그리고 표준 빌트인 객체가 추가 되며 동작환경에 따라 클라이언트 사이드 Web API 또는 특정 환경을 위한 호스트 객첼르 포함한다. 전역 객체도Object.prototype을 상속받는다. 즉, 프로토타입 체인의 일원이다.

23.6.2 전역 코드 평가

소스코드가 로드되면 자바스크립트 엔진은 전역 코드를 평가한다. 평가는 다음과 같은 순서로 진행된다.

  1. 전역 실행 컨텍스트 생성
  2. 전역 렉시컬 환경 생성
    1. 전역 환경 레코드 생성
      1. 객체 환경 레코드 생성
      2. 선언적 환경 레코드 생성
    2. this 바인딩
    3. 외부 렉시컬 환경에 대한 참조 결정

위 과정을 걸쳐 생성된 전역 실행 컨텍스트는 다음과 같다.

1. 전역 실행 컨텍스트 생성 

먼저 비어있는 전역 실행 컨텍스트를 생성하여 실행 컨텍스트 스택에 푸시한다. 이때 전역 실행 컨텍스트는 실행 컨텍스트 스택의 최상위, 즉 실행 중인 실행 컨텍스트가 된다.

 

2. 전역 렉시컬 환경 생성

 전역 렉시컬 환경을 생성하고 전역 실행 컨텍스트에 바인딩한다.

 

2.1 전역 환경 레코드 생성

전역 렉시컬 환경을 구성하는 컴포넌트인 전역 환경 레코드는 전역 변수를 관리하는 전역 스코프, 전역 객체의 빌트인 전역 프로퍼티와 빌트인 전역함수, 표준 빌트인을 제공한다. 기존의 var 키워드로 선언한 전역 변수와 ES6의 let, const 키워드로 선언한 전역 변수를 구분하여 관리 하기 위해 전역 스코프 역할을 하는 전역 환경 레코드는 객체 환경 레코드와 선언적 환경 레코드로 구성되어 있다.

  • 객체 환경 레코드 : 기존의 전역 객체가 관리하던 var 키워드로 선언한 전역 변수와 함수 선언문으로 정의한 전역 함수, 빌트인 전역 프로퍼티와 빌트인 전역함수, 표준 빌트인 객체를 관리한다
  • 선언적 환경 레코드 : let, const 키워드로 선언한 전역 변수를 관리한다.

2.1.1 객체 환경 레코드 생성

 객체 환경 레코드는 BindingObject라고 부르는 객체와 연결된다. BindingObject는 전역 객체 생성에서 생성된 전역 객체다. 전역 코드 평가 과정에서 var 키워드로 선언한 전역 변수와 함수 선언문으로 정의된 전역 함수는 전역 환경 레코드의 객체 환경 레코드에 연결된BindingObject를 통해 전역 객체의 프로퍼티와 메서드가 된다.

 이것이 var 키워드로 선언된 전역 변수와 함수 선언문으로 정의된 전역 함수가 전역 객체의 프로퍼티와 메서드가 되고 전역 객체를 가리키는 식별자 없이 전역 객체의 프로퍼틸르 참조할 수 있는 메커니즘이다.

 x 변수는 var 키워드로 선언한 변수다. 따라서 "선언 단계"와 "초기화 단계"가 동시에 진행된다. 다시 말해, 전역 코드 평가 시점에 객체 환경 레코드에 바인딩된 BindingObject를 통해 전역 객체에 변수 식별자를 키로 등록한 다음, 암묵적으로 undefined를 바인딩한다. 따라서 var 키워드로 선언한 변수는 코드 실행 단계에서 변수 선언문 이전에도 참조할 수 있다. 물론 변수 선언문 이전에 참조한 변수의 값은 undefined다.

 함수 선언문으로 정의한 함수가 평가되면 함수 이름과 동일한 이름의 식별자를 객체 환경 레코드에 바인딩된 BindingObject를 통해 전역 객체에 키로 등록하고 생성된 함수 객체를 즉시 할당한다. 이것이 변수 호이스팅과 함수 호이스팅의 차이다. 즉, 함수 선언문으로 정의한 함수는 함수 선언문 이전에 호출할 수 있다.

 

2.1.2 선언전 환경 레코드 생성

 let, const 키워드로 선언한 전역 변수(let, const 키워드로 선언한 변수에 할당한 함수 표현식 포함)는 선언적 환경 레코드에 등록되고 관리된다.

위 예제의 전역 변수 y는 let, const 키워드로 선언한 변수이므로 전역 객체의 프로퍼티가 되지 않기 때문에 window.y와 같이 전역 객체의 프로퍼티로서 참조할 수 없다. 또한 const 키워드로 선언한 변수는 "선언 단계"와 "초기화 단계"가 분리되어 진행된다. 따라서 초기화 단계, 즉 런타임에 실행 흐름이 변수 선언문에 도달하기 전까지 일시적 사각지대에 빠지게 된다.

 위 그림에서 y 변수에 바인딩되어 있는 <uninitialized>는 초기화 단계가 아직 진행되지 않아 변수에 접근할 수 없음을 나타내기 위해 사용한 표현이다.

 

2.2 this 바인딩

전역 환경 레코드의 [[GlobalThisValue]] 내부 슬롯에 this가 바인딩된다. 전역 코드에서 this를 참조하면 전역 환경 레코드의 [[GlobalThisValue]] 내부 슬롯에 바인딩되어 있는 객체가 반환된다.

 참고로 객체 환경 레코드와 선언적 환경 레코드에는 this 바인딩이 없다. this 바인딩은 전역 환경 레코드와 함수 환경 레코드에만 존재한다.

 

2.3 외부 렉시컬 환경에 대한 참조 결정

 외부 렉시컬 환경에 대한 참조는 현재 평가 중인 소스코드를 포함하는 외부 소스코드의 렉시컬환경, 즉 상위 스코프를 가리킨다. 이를 통해 단방햔 링크드 리스트인 스코프 체인을 구현한다. 현재 평가중인 소스코드는 전역 코드다. 전역 코드를 포함하느 소스코드는 없으므로 전역 렉시컬 환경의 외부 렉시컬 환경에 대한 참조에 null이 할당된다.

23.6.3 전역 코드 실행

 전역 코드가 순차적으로 실행된다. 변수 할당문이 실행되어 전역 변수 x,y에 값이 할당된다. 이를 실행하려면 먼저 변수 또는 함수의 이름이 선언된 식별자인지 확인해야한다. 선언되지 않은 식별자는 참조할 수 없으므로 할당이나 호출도 할수 없기 때문이다. 또한 식별자는 스코프가 다르면 같은 이름을 가질 수 있다. 따라서 어느 스코프의 식별자를 참조하면 되는지 결정할 필요가 있다. 이를 식별자 결정이라 한다.

 식별자 결정을 위해 식별자를 검색할 때는 실행 중인 실행 컨텍스트에서 식별자를 검색하기 시작한다. 선언된 식별자는 실행 컨텍스트의 렉시컬 환경의 환경 레코드에 등록되어 있다. 

 현재 실행 중인 실행 컨텍스트는 전역 실행 컨텍스트 이므로 전역 렉시컬 환경에서 식별자 x, y, foo를 검색하기 시작한다. 만약 실행 중인 실행 컨텍스트의 렉시컬 환경에서 식별자를 검색할 수 없으면 외부 렉시컬 환경에 대한 참조가 가리키는 렉시컬 환경, 즉 상위 스코프로 이동하여 식별자를 검색한다. 이것이 스코프 체인의 동작 원리다.

23.6.5 foo 함수 코드 실행

매개변수에 인수가 할당되고, 변수 할당문이 실행되어 지역 변수 x,y에 값이 할당된다. 그리고 함수 bar가 호출된다. 이때 식별자 결정을 위해 실행 중인 실행 컨텍스트의 렉시컬 환경에서 식별자를 검색하기 시작한다. 만약 실행 중인 실행 컨텍스트의 렉시컬 환경에서 식별자를 검색할 수 없으면 외부 렉시컬 환경에 대한 참조가 가리키는 렉시컬 환경으로 이동하여 식별자를 검색한다. 검색된 식별자에 값을 바인딩한다.

23.6.6 bar 함수 코드 평가

 bar 함수가 호출되면 bar 함수 내부로 코드의 제어권이 이동한다. 그리고 코드를 평가하기 시작한다. 실행 컨텍스트와 렉시컬 환경의 생성 과정은 foo 함수 코드 평가와 동일하다.

23.6.7 bar 함수 코드 실행

매개변수에 인수가 할당되고, 변수 할당문이 실행되어 지역 변수 z에 값이 할당된다.

 그리고 console.log(a + b + x + y + z); 가 실행된다. 이는 다음 순서로 실행된다.

 

1. console 식별자 검색

 bar 함수 실행 컨텍스트의 bar 함수 실행 렉시컬 환경에서 console 식별자를 검색하기 시작한다. 이곳에는 console 식별자가 없으므로 스코프 체인 상의 상위 스코프, 외부 렉시컬 환경에 대한 참조가 가리키는 foo 함수 렉시컬 환경으로 이동하여 식별자를 검색한다. 이곳에도 console 식별자가 없으므로 스코프 체인 상의 상위 스코프, 외부 렉시컬 환경에 대한 참조가 가리키는 전역 렉시컬 환경으로 이동하여 식별자를 검색한다.  console 식별자는 객체 환경 레코드의 BindingObject를 통해 전역 객체에서 찾을 수 있다.

2. log 메서드 검색

 console 객체의 프로토타입 체인을 통해 메서드를 검색한다. log 메서드는 상속된 프로퍼티가 아니라 console 객체가 직접 소유하는 프로퍼티다.

3. 표현식 a + b + x + y + z

 표현식 a + b + x + y + z를 평가하기 위해,  식별자를 검색한다. 식별자는 스코프 체인, 즉 현재 실행중인 실행 컨택스트의 렉시컬 환경에서 시작하여 외부 렉시컬 환경에 대한 참조로 이어지는 렉시컬 환경의 연속에서 검색한다.

 a 식별자는 foo 함수 렉시컬 환경에서, b 식별자는 bar 함수 렉시컬 환경에서, x와 y 식별자는 foo 함수 렉시컬 환경에서, z 식별자는 bar 함수 렉시컬 환경에서 검색된다.

4. console.log 메서드 호출

 표현식 a + b + x + y + z가 평가되어 생성한 값 42를 conosole.log 메서드에 전달하여 호출한다.

23.6.8 bar 함수 코드 실행 종료

 console.log 메서드가 호출되고 종료하면 더는 실행할 코드가 없으므로 bar 함수 코드의 실행이 종료된다. 이때 실행 컨텍스트 스택에서 bar 함수 실행 컨텍스트가 팝되어 제거되고 foo 실행 컨텍스트가 실행 중인 실행 컨텍스트가 된다.

 실행 컨텔스트 스택에서 bar 함수 실행 컨텍스트가 제거되었다고 해서 bar 함수 렉시컬 환경까지 즉시 소멸하는 것은 아니다. 렉시컬 환경은 실행 컨텍스트에 의해 참조되기는 하지만 독립적인 개체다. 객체를 포함한 모든 값은 누군가에 의해 참조되지 않을 떄 비로소 가비지 컬렉터에 의해 메모리 공간의 확보가 해체되어 소멸한다. 

23.6.9 foo 함수 코드 실행 종료

bar 함수가 종료하면 더 이상 실행할 코드가 없으므로 foo 함수 코드의 실행이 종료된다. 이때 실행 컨텍스트 스택에서 foo 함수 실행 컨텍스트가 팝되어 제거되고 전역 실행 컨텍스트가 실행 중인 실행 컨텍스트가 된다.

23.6.10 전역 코드 실행 종료

 foo 함수가 종료되면 더는 실행할 전역 코드가 없으므로 전역 코드의 실행이 종료되고 전역 실행 컨텍스트도 실행 컨텍스트 스택에서 팝되어 실행 컨텍스트 스택에는 아무것도 남아있지 않게된다.

23.7 실행 컨텍스트와 블록 레벨 스코프

let x = 1;

if (true) {
	let x = 10;
    console.log(x); // 10
}

console.log(x); // 1

 if 문의 코드 블록 내에서 let 키워드로 변수가 선언되었다. 따라서 if 문을 실행되면 if 문의 코드 블록을 위한 블록 레벨 스코프를 생성해야한다. 이를 위해 선언적 환경 레코드를 갖는 렉시컬 환경을 새롭게 생성하여 기존의 전역 렉시컬 환경을 교체한다. 이때 새롭게 생성된 if 문의 코드 블록을 위한 렉시컬 환경의 외부 렉시컬 환경에 대한 참조는 if 문이 실행되기 이전의 전역 렉시컬 환경을 가리킨다. 이는 if 문이 아니라 블록 레벨 스코프를 생성하는 모든 블록문에 적용된다.

'Javascript' 카테고리의 다른 글

Deep-dive .25 : 클래스  (0) 2024.07.09
Deep-dive .24 : 클로저  (0) 2024.07.09
Deep-dive .22 : this  (0) 2024.07.02
Deep-dive .19 : 프로토타입  (1) 2024.06.11
Deep-dive .18 : 함수와 일급 객체  (0) 2024.06.11
본 글은 Modern JavaScript Deep-dive을 요약한 글입니다.
자세한 내용은 본 책을 읽으시기 바랍니다.

22.1 this 키워드

 메서드가 자신이 속한 객체의 프로퍼티를 참조하려면 먼저 자신이 속한 객체를 가리키는 식별자를 참조할 수 있어야한다. 객체 리터럴 방식으로 생성한 객체의 경우 메서드 내부에서 메서드 자신이 속한 객체를 가리키는 식별자를 재귀적으로 참조할 수 있다.

 하지만 자기 자신이 속한 객체를 재귀적으로 참조하는 방식은 일방적이지 않으며 바람직하지도 않다. 생성자 함수 방식으로 인스턴스를 생성하는 경우를 생각해보자.

function Circle(radius){
	// 이 시점에서 생성자 함수 자신이 생성할 인스턴스를 가리키는 식별자를 알 수 없다.
    ????.radius = radius;
}

Circle.prototype.getDiameter = function() {
	// 이 시점에서 생성자 함수 자신이 생성할 인스턴스를 가리키는 식별자를 알 수 없다.
	return 2 * ????.radius
}

// 생성자 함수로 인스턴스를 정의하려면 먼저 생성자 함수를 정의해야 한다.
const circle = new Circle(5);

 생성자 함수를 정의하는 시점에는 아직 인스턴스를 생성하기 이전이므로 생성자 함수가 생성할 인스턴스를 가리키는 식별자를 알수 없다. 따라서 자신이 속한 객첼 또는 자신이 생성할 인스턴스를 가리키는 특수한 식별자가 필요하다.

 this는 자신이 속한 객체 또는 자신이 생성할 인스턴스를 가리키는 자기 참조 변수다. this를 통해 자신이 속한 객체 또는 자신이 생성할 인스턴스의 프로퍼티나 메서드를 참조할 수 있다. this는 자바스크립트 엔진에 의해 암묵적으로 생성되며, 코드 어디서든 참조할 수 있다. 함수를 호출하면 arguments 객체와 this가 암묵적으로 함수 내부에 전달된다. 함수 내부에서 arguments 객체처럼 this도 지역 변수처럼 사용할 수 있다. 단, this가 가리키는 값, 즉 this 바인딩은 함수 호출 방식에 의해 동적으로 결정된다.

  • 바인딩 : 식별자와 값을 연결하는 과정을 의미한다. this 바인딩은 this와 this가 가리킬 객체를 바인딩하는 것이다.

위 예제를 this를 사용해 수정해 보자.

function Circle(radius){
    this.radius = radius;
}

Circle.prototype.getDiameter = function() {
	return 2 * this.radius
}

// 생성자 함수로 인스턴스를 정의하려면 먼저 생성자 함수를 정의해야 한다.
const circle = new Circle(5);
console.log(circle.getDiameter(5)); // 10

22.2 함수 호출 방식과 this 바인딩

  자바스크립트의 this는 함수가 호출되는 방식에 따라 this에 바인딩될 값, 즉 this 바인딩이 동적으로 결정된다. 또한 strict mode 역시 this 바인딩에 영향을 준다.

  • 렉시컬 스코프와 this 바인딩은 결정 시기가 다르다. 함수의 상위 스코프를 결정하는 방식인 렉시컬 스코프는 함수 정의가 평가되어 함수 객체가 생성되는 시점에 상위 스코프를 결정한다. 하지만 this 바인딩은 함수 호출 시점에 결정된다.
  1. 일반 함수 호출
  2. 메서드 호출
  3. 생성자 함수 호출
  4. Function.protortype.apply/call/bind 메서드에 의한 간접 호출

22.2.1 일반 함수 호출

 기본적으로 this는 전역 객체가 바인딩 된다.

function foo() {
	conosle.log(this) // window
    
    function bar() {
		conosle.log(this) // window
	}
    bar()
}
foo()

function foo2() {
	'use strict';
	conosle.log(this) // undefined
    
    function bar2() {
		conosle.log(this) // undefined
	}
    bar2()
}
foo2()

 전역함수는 물론이고 중첩 함수를 일반 함수로 호출하면 함수 내부의 this에는 전역 객체가 바인딩된다.  다만 this는 객체의 프로퍼티나 메서드를 참조하기 위한 자기 참조 변수이므로 객체를 생성하지 않는 일반 함수에서의 this는 의미가 없다. 따라서 strict mode가 적용된 일반 함수 내부의 this에는 undefined가 바인딩된다.

 

 메서드 내에서 정의한 중첩 함수도 일반 함수로 호출되면 중첩 함수 내부의 this에는 전역 객체가 바인딩된다.

var value = 1; // var 키워드로 선언한 전역 변수는 전역 객체의 프로퍼티다.

const obj ={
	value = 100,
    foo() {
    	conosole.log(this); // {value: 100, foo: f}
        conosole.log(this.value) // 100
    }
    
    function bar() {
    	console.log(this) // window
        console.log(this.value) // 1
    }
	bar()
}
obj.foo()

콜백 함수가 일반 함수로 호출된다면 콜백 함수 내부의 this에도 전역 객체라 바인딩된다.

var value = 1; // var 키워드로 선언한 전역 변수는 전역 객체의 프로퍼티다.

const obj ={
	value = 100,
    foo() {
    	conosole.log(this); // {value: 100, foo: f}
        conosole.log(this.value); // 100
    }
    
    setTimeout(function(){
    	console.log(this); // window
        conosle.log(this.value); // 1
    },100)
}
obj.foo()

 이처럼 일반 함수로 호출된 모든 함수(중첩 함수, 콜백 함수 포함) 내부의 this에는 전격 객체가 바인딩된다. 하지만 메서드 내에서 정의한 중첩 함수 또는 메서드에게 전달한 콜백 함수가 일반 함수로 호출될 때  메서드 내의 중첩 함수 또는 콜백 함수의 this가 전역 객체를 바인딩하는 것은 문제가 있다. 외부 함수인 메서드와 중첩함수 또는 콜백 함수의 this가 일치하지 않는다는 것은 이들이 헬퍼 함수로 동작하기 어렵게 만든다. 메서드의 this 바인딩과 내부의 중첩, 콜백 함수의 this 바인딩을 일치시키는 방법은 다양하다.

var value = 1; // var 키워드로 선언한 전역 변수는 전역 객체의 프로퍼티다.

const obj ={
	value = 100,
    foo() {
    	const that = this;
        
    	setTimeout(function(){
            conosle.log(that.value); // 100
        },100)
    }
}
obj.foo()

 이외 Function.prototype.bind 메서드와 화살표 함수 등이 존재한다. 각 챕터에서 자세히 다뤄보자.

22.2.2 메서드 호출

메서드 내부의 this에는 메서드를 호출한 객체, 즉 메서드를 호출할 때 메서드 이름 앞의 마침표 연산자 앞에 기술한 객체가 바인딩된다. 주의할 것은 메서드 내부의 this는 메서드를 소유한 객체가 아닌 메서드를 호출한 객체에 바인딩된다는 것이다.

const person = {
	name : 'Lee',
    getName() {
    	// 메서드 내부의 this는 메서드를 호출한 객체에 바인딩된다.
    	return this.name;
    }
};

// 메서드 getName을 호출한 객체는 person이다.
conosole.log(person.getName()); // Lee

 person 객체의 getName 프로퍼티가 가리키는 함수 객체는 person 객체에 포함된 것이 아니라 독립적으로 존재하는 별도의 객체다. 즉, getName 메서드는 다른 객체의 프로퍼티에 할당한는 것으로 다른 객체가의 메서드가 될 수도 있고 일반 변수에 할당하여 일반 함수로 호출될 수도 있다.

const anothoerPerson = {
	name: 'Kim'
};

// getName 메서드를 anotherPerson 객체의 메서드로 할당
anothoerPerson.getName = person.getName

console.log(anothoerPerson.getName()); // kim

const getName = person.getName;

console.log(getName()) // ''
// 일반 함수로 호춣된 함수 내부의 this.name은 브라우저 환경에서 window.name과 같다.

 따라서 메서드 내부의 this는 프로퍼티로 메서드를 가리키고 있는 객체와는 관계가 없고 메서드를 호출한 객체에 바인딩된다.

22.2.3 생성자 함수 호출

생성자 함수 내부의 this에는 생성자 함수가 미래에 생성할 인스턴스가 반영된다.

function Circle(radius) {
	this.radius = radius;
    this.getDiameter = function() {
    	return 2 * this.radius
    };
}

const circle1 =new Circle(5);
console.log(circle1.getDiameter()); // 10

// new 연산자를 사용하지 않으면 생성자 함수로 동작하지 않는다. 일반함수로 동작한다.
const circle2 = Circle(15);

// 일반 함수로 호출된 Circle 내부에는 반환문이 없기에 암묵적으로 undefined를 반환한다.
console.log(circle2) // undefined
// 일반 함수로 호출된 Circle 내부에는 this는 전역 객체를 가리킨다.
console.log(radius) // 15

22.2.4 Function.prototype.apply/ call/ bind 메서드에 의한 간접 호출

Function.prototype.apply, Function.prototype.call 메서드는 this로 사용할 객체와 인스 리스트를 인수로 전달받아 함수를 호출한다.

function getThisBinding() {
	return this;
}

// this로 사용할 객체
const thisArg = { a : 1 };

console.log(getThisBinding()); // window

// getThisBinding 함수를 호출하면서 인수로 전달한 객체를 getThisBinding 함수의 this에 바인딩한다.
console.log(getThisBinding.apply(thisArg)); // {a:1}
console.log(getThisBinding.call(thisArg)); // {a:1}

 apply와 call 메서드의 본질적인 기능은 함수를 호출하는 것이다. 첫번째 인수로 전달한 특정 객체를 호출한 함수의 this에 바인딩한다. 이 둘은 호출한 함수에 인수를 전달하는 방식만 다를 뿐 동일하게 동작한다.

function getThisBinding() {
	return this;
}

// this로 사용할 객체
const thisArg = { a : 1 };

console.log(getThisBinding()); // window

// getThisBinding 함수를 호출하면서 인수로 전달한 객체를 getThisBinding 함수의 this에 바인딩한다.

// apply 메서드는 호출할 함수의 인수를 배열로 묶어 전달한다.
console.log(getThisBinding.apply(thisArg,[1,2,3])); // {a:1}

// call 메서드는 호출할 함수의 인수를 쉼표로 구분한 리스트 형식으로 전달한다.
console.log(getThisBinding.call(thisArg,1,2,3)); // {a:1}

 대표적인 용도는 arguments 객체와 유사 배열 객체에 배열 메서드를 사용하는 경우다. 

 

Function.prototype.bind 메서드는 apply와 call 메서드와 달리 함수를 호출하지 않는다. 다만 첫번째 인수로 전달한 값으로 this 바인딩이 교체된 함수를 새롭게 생성해 반환한다. bind 메서드는 메서드의 this와 메서드 내부의 중첩, 콜백 함수의 this가 불일치하는 문제를 해결하기 위해 유용하게 사용된다.

const person = {
	name: 'Lee',
    foo(callback){
    	// bind 메서드로 callback 함수 내부의 this 바인딩을 전달
        setTimeout(callback.bind(this), 100)
    }
}

person.foo(function(){
	console.log(`hi! my name is ${this.name}.`) // hi! my name is Lee.
})

 

지금까지 함수호출 방식에 따른 this 의 동적 바인딩을 정리하자면 다음과 같다.

함수 호출 방식 this 바인딩
일반 함수 호출 전역 객체
메서드 호출 메서드를 호출한 객체
생성자 함수 호출 생성자 함수가 미래에 생성할 인스턴스
Function.prototype.apply, call, bind 에 의한 간접 호출 첫번째 인수로 전달한 객체

'Javascript' 카테고리의 다른 글

Deep-dive .24 : 클로저  (0) 2024.07.09
Deep-dive .23 : 실행 컨텍스트  (2) 2024.07.02
Deep-dive .19 : 프로토타입  (1) 2024.06.11
Deep-dive .18 : 함수와 일급 객체  (0) 2024.06.11
Deep-dive .16 : 프로퍼티 어트리뷰트  (1) 2024.04.26
본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

 프로그램을 구현하면서 다양한 수준으로 캡슐화를 표현할 수 있다. 가장 저수준에서는 함수로 동작을 캡슐화할 수 있고, 객체와 리스트 같은 자료구조로 데이터를 캡슐화할 수 있다. 그런 다음 함수와 데이터를 클래스로 묶거나 네임스페이스로 구분된 유틸리티 형태로 별도의 데이터베이스나 저장소에 보관할 수 있다. 보통은 한 파일에 한 개의 클래스나 한 가지 유틸리티 집합을 담는다. 이런 클래스나 유틸리티가 많아지면 패키지로 묶어서 NPM으로 발행할 수도 있다.

 모듈을 이야기 하려면 세가지 방법이 존재하는데

  1. 컴파일러가 모듈을 해석하는 방법
  2. 빌드 시스템이 모듈을 해석하는 방법
  3. 모듈이 실제로 런타임에 응용 프로그램으로 로드되는 방법

등의 차이를 알아야한다. 이전에는 세 가지 일을 각각으로 처리해야됐지만 CommonJS와 ES2015의 모듈 표준이 나타나 세 프로그램을 연동하기 쉬워졌으며, 이제는 웹팩 같은 강력한 번들러가 내부적으로 알아서 추상화해 실행해주는 단계에 이르렀다. 본 장에서는 첫번째, 타입스크립트가 모듈을 어떻게 해석하고 컴파일하는지 알아본다. 앞서 내용을 들어가기 전 배경지식을 쌓아보자.

10.1 가볍게 살펴보는 자바스크립트 모듈의 역사

 처음(1995)에 자바스크립트는 모듈 시스템을 전혀 지원하지 않았다. 모듈이 없어서 모든 것을 네임스페이스에 정의했고, 이 때문에 응용프로그램을 만들고 확장하기 어려웠다. 

  • 변수명이 금방 고갈되면서 같은 변수명으로 인한 충돌 발생
  • 각 모듈의 API를 명시적으로 노출하지 않는다면 어떤 기능을 사용할 수 있는지, 사용하면 안되는 지 구분 X

 이 문제를 해결하고자, 사람들은 객체를 이용하거나 즉시 실행 함수를 전역 window에 할당해서 응용프로그램의 다른 모듈에서 사용할 수 있도록 하는 식으로 모듈을 흉내냈다.

 

 자바스크립트를 로딩하고 실행하는 동안 브라우저의 UI는 블록되기 때문에 웹 응용 프로그램이 커지고 코드가 늘어날수록 사용자의 브라우저는 점점 느려졌다. 이 문제를 해결하려고 페이지를 로드한 다음 필요한 파일만 동적으로 로드하는 방식으로 개발하기 시작했다. 이후 약 10년 후 Dojo, YUI, LABjs 등에서 첫 페이지가 로딩된 다음 자바스크립트가 게으르고 비동기적으로 로딩하는 모듈 로더를 제공했다. 이는 다음과 같은 세 가지를 의미한다.

  1. 모듈이 잘 캡슐화되어야 한다. 그렇지 않으면 의존성을 확보하는 과정에서 페이지가 망가질 수 있다.
  2. 모듈 간의 의존성이 명시적이어야 한다. 그렇지 않으면 한 모듈에 어떤 모듈이 필요하며 어떤 순서로 로딩해야 하는지 알 수 없기 때문이다.
  3. 모든 모듈은 앱 내에서 고유 식별자를 가져야 한다. 그렇지 않으면 어떤 모듈을 로딩해야 하는지 안정적으로 지정할 수 없다.

 비슷한 시기에 Node.js가 개발되었으며 모듈 시스템을 플랫폼 자체에 추가하였다. 위 세가지 조건을 만족해야하는데 Nodejs는 CommonJS 모듈 표준을 통해 이를 해결했다.

 

 몇 년 지난 후에 Beowserify가 출시 되면서 프론트엔드 엔지니어도 CommonJS를  사용할 수 있게 되었고, 사실상 CommonJS가 모듈 번들링, 임포트, 익스포트 문법의 표준으로 자리 잡았다. 하지만 이에도 몇 가지 문제점이 존재하였다.

  • require 호출은 반드시 동기 방식이어야 한다는 점
  • 모듈 해석 알고리즘이 웹에 적합하지 않은 점
  • 상황에 따라 정적 분석이 불가능했다는 점

이 문제는 ECMAScript 언어의 여섯 번째 개정판인 ES2015에 이르러 깔끔한 문법과 정적 분석이 가능한 새로윤 표준 임포트/익스포트가 소개되면서 해결되었다. 바로 오늘날 자바스크립트와 타입스크립트에서 사용하는 표준이다.

import eamilList from 'emailListModule'
improt emailComposer from 'eamilComposerModule'

export function renderBase(){
 // ...
}

 하지만 이 표준이 제정된 시점에서 표준을 지원하지 않는 자바스크립트 런타임도 있었기에 환경에 맞는 타입으로 컴파일해야 했다. 그러나 타입스크립트의 빌드 시스템 덕분에 소비자 환경에서 이용할 수 있는 환경을 혼합하는 등 다양한 환경에 맞게 모듈을 컴파일할 수 있게 되었다.

10.2 import, export

 타입스크립트 코드에서는 CommonJS, 전역, 네임스페이스로 구분한 모듈 보다는 ES2015의 import와 export를 사용하는 것이 바람직하다.

// a.tx
export function foo(){}
Export function bar(){}

// b.tx
import {foo, bar} from './a'
foo()
export let result = bar()

// c.ts : ES2015 모듈 표준은 default export를 지원한다.
export default function meow(loudness: number) {}

// d.ts
import meow from './c' // {}가 없다는 점
meow(11)

// e.ts : 와일드카드 임포틑 이용해 모듈의 모든 것을 임포트할 수 있다.
import * as a from './a'
a.foo()
a.bar()

// f.ts : 모듈에서 일부(또는 전체)를 다시 익스포트할 수 있따.
export * from '/.a'
export {result} from './b'
export meow from './c'

 타입스크립트를 사용하고 있으므로 값뿐만 아니라 타입과 인터페이스도 익스포트할 수 있다. 타입과 값은 별개의 네임스페이스에 존재하므로 두 가지를 하나의 이름으로 익스포트할 수 있다.

// g.ts
export let x = 3
export type x = {y : string}

// h.ts
import {x} from './g'

let a = x + 1 // x는 값 x를 가리킴
let b: x = {y : z} // x는 타입 x를 가리킴

 10.2.1 동적 임포트

 응용 프로그램이 커지면서 첫 렌더링 시간이 점점 길어진다. 프론트엔드에서는 코드를 분할하여 해결할 수 있다. 자바스크립트 파일을 여러 개 생성하여 나누어 저장하는 방법이다. 여러 조각을 병렬로 로딩할 수 있으므로 용량이 큰 네크워크 요청을 더 수월하게 처리할 수 있다.

 

 또한 코드가 꼭 필요할 때 로딩하는 게으른 로딩으로 조금 더 최적화 할 수 있다. LABjs와 관련 라이브러리는 필요할 때만 코드를 로딩하는 게으른 로딩의 개념을 소개했으며 이를 동적 임포트라는 개념으로 공식화했다.

let locale = await import('locale_us-en')

 개발자는 import를 모듈의 Promise를 반환하는 함수로 이용할 수 있다. import로 문자열로 평가되는 표현식이라면 무엇이든 전달할 수 있지만, 대신 타입 안정성을 잃게 된다. 이를 안전하게 사용하려면 다음 두 방법 중 하나를 이용해야 한다.

  1. 문자열을 변수에 할당하지 않고 import에 문자열 리터럴로 직접 제공한다.
  2. import에 표현식을 전달하고 모듈의 시그니처를 직접 명시했다.

 두 번째 방식을 사용할 때는 주로 모듈을 정적으로 임포트하지만 오직 타입 위치에만 사용한다.

import {locale} from './locales/locale-us'

async function main() {
	let userLocale = await getUserLocale()
    let path = ./ locales / locale-${userLocale}
    let localeUs: typeof locale = await import(path)
}

  위 예에선 locale을 값이 아닌 타입으로만 활용했으므로 타입스크립트는 정적 임포트를 컴파일해버리고 개발자는 타입 안정과 동적으로 계산된 임포트라는 두 마리 토끼를 잡을 수 있다.

10.2.2 CommonJS와 AMD 코드 사용하기

CommonJS나 AMD 표준을 사용하는 자바스크립트 모듈을 이용할 때는 ES2015 모듈을 사용할 때처럼 단순히 이름으로 임포트 할 수 있다. 그러나 디폴트 익스포트와 궁합이 맞지 않으므로 필요하면 와일드카드 임포트를 사용해야 한다.

import * as fs from 'fs'
fs.readFile('some/file.txt')

10.2.3 모듈 모드 vs. 스크립트 모드

 타입스크립트는 타입스크립트 파일을 모듈 모드 또는 스크립트 모드 중 하나로 파싱한다. import나 export를 포함하면 모듈 모드로, 그렇지 않으면 스크립트 모드로 동작한다. 지금까지 거의 모듈모드를 사용했고 앞으로도 대부분 그럴 것이다. 다른 파일의 코드를 가져올 때 import와 import()를 사용하고, 코드를 제공할 때 export를 사용한다. 서드 파티 UMD 모듈을 사용한다면 이들을 먼저 import 해야한다.

 스크립트 모드에서는 최상위 수준으로 선언한 모든 변수는 명시적으로 임포트하지 않아도 같은 프로젝트의 다른 파일들에서 사용할 수 있으며, 서드 파티 UMD 모듈의 전역 익스포트도 먼저 명시적으로 임포트할 필요 없이 바로 사용할 수 있다.

10.3 네임스페이스

 타입스크립트는 코드를 캡슐화할 수 있는 또 다른 수단인 namespace 키워드를 제공한다. 네임스페이스는 파일시스템에서 파일이 어떻게 구성되었는지 같은 자잘구레한 세부사항을 추상화한다. .mine 함수가 schemes/scamas/bitcon/apps 위에 위치한다는 사실을 알 필요없이 Schemes.Scamas.Bitcon.Apps.mine 같은 짧고 간편한 네임스페이스로 접근할 수 있게 해준다. 

 네임스페이스에는 반드시 이름이 있어야 하며 함수, 변수, 타입, 인터페이스, 다른 네임스페이스를 익스포트할 수 있다. 블록 안의 모든 코드는 명시적을 익스포트하지 않는 한 볼수 없고 네임스페이스가 중첩된 구조도 쉽게 만들 수 있다. 인터페이스처럼 네임 스페이스도 합칠 수 있므로 하나의 네임스페이스를 여러 파일로 쪼개 관리할 수 있다. 타입스크립트는 이름이 같은 네임스페이스를 알아서 재귀적으로 합쳐준다.

naemspace Network{
	export namespace HTTP {
    	export function get<T>(url: string): Promise<T>{
        	// ...
        }
    }
    export namespace TCP {
    	listenOn(port: number) : Connection {
        	// ...
        }
    }
    
    export namespace UDP {
    	// ...
    }
}

 네임스페이스 계층이 너무 길어졌다면 짧은 별칭을 지어질 수 있다.

// A.ts
namespace A {
	export namespace B {
    	export namespace C {
        	exprot let d = 3
        }
    }
}

// app.ts
import d = A.B.C.d

let e = d * 3

 

10.3.1 충돌

  같은 이름을 익스포트하면 충돌이 생긴다. 단, 함수 타입을 정제할 때 사용하느 오버로도딘 앰비언트 함수 선언에는 이름 충돌 금지 규칙이 적용되지 않는다.

// HTTP.ts
namespace Network {
	export function request<T>(url: string): T
}

// HTTP2.ts
namespace Network {
	export function request<T>(url: string, priority: number): T
}

// HTTPS.ts
namespace Network {
	export function request<T>(url: string, algo: 'SHA1'|'SHA256'): T
}

10.3.2 컴파일된 출력

 임포트, 익스포틀와 달리 네임스페이스는 tsconfig.json의 module 설정에 영향받지 않으며 항상 전역 변수로 컴파일된다.

// Flowers.ts
namespace Flowers {
	export function give(count: number) {
    	return count + 'flowers'
    }
}

// TSC로 컴파일하면 다음과 같은 자바스크립트 파일이 생긴다.
let Flowers
(function (Flowers) { // 1
	function give (count){
    	return count + 'flowers'
    }
    Flowers.give = give // 2
})(Flowers || (FLowers = {})) // 3
  1. Flowers는 클로저를 만들고 Flowers 모듈에서 명시적으로 익스포트하지 않은 변수가 노출되는 것을 방지하기 위해 즉시 실행 함수 안에 선언했다.
  2. 타입스크립트 Flowers 네임스페이스로 익스포트한 give 함수를 할당한다.
  3. Flowers 네임스페이스가 전역으로 이미 정의되어 있으면 타입스크립트는 Flowers를 확장한다. 그렇지 않으면 새로 네임스페이스를 생성(Flowers={})한 다음 확장한다.
  • 가능하면 네임스페이스보다는 모듈을 사용하자 :
     일반 모듈을 사용해 자바스크립트 표준을 따르고 의존성을 명시적으로 만들자. 명시적 의존성은 가독성, 모듈 분리, 정적 분석 면에서 유리하다. 그래서 코드를 제거하고 컴파일된 코드를 여러 파일로 나눠 성능을 높여야 하는 대규모 프론트엔드 프로젝트에서 아주 유용하다.

10.4 선언합치기

 타입스크립트는 다른 종류의 이름을 합치는 다양한 동작을 제공하며, 이를 통해 온갖 종류의 패턴을 표현할 수 있게 해준다.

무엇으로(to)
  클래스 Enum 함수 타입 별칭 인터페이스 네임스페이스 모듈
무엇을
(from)
X X X X O O X -
클래스 - X X X X O O -
Enum - - O X X X O -
함수 - - - X O O O -
타입 별칭 - - - - X X O -
인터페이스 - - - - - O O -
네임스페이스 - - - - - - O -
모듈 - - - - - - - O

 예를 들어 타입스크립트는 값과 타입 별칭을 같은 영역에 선언할 수 있도록 허용하며, 이름이 어떤 위치에 사용되었는지에 따라 값인지 타입인지 추론한다.

본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

8.1 자바스크립트의 이벤트 루프

거시적으로 보면 자바스크립트 VM은 다음처럼 동시성을 흉내낸다.

  1. 메인 자바스크립트 스레드는 네이티브 비동기 API를 호철한다.
  2. 네이티브 비동기 API를 호출한 이후에 다시 메인 스레드로 제어가 반환되며 아무 일도 없었던 것처럼 코드를 계속 실행한다.
  3. 비동기 작업이 완료되면 플랫폼은 태스크를 이벤트 큐에 추가한다. 각 스레드가 자신만의 큐를 가지고 있으며 이를 이용해 비동기 연산결과를 메인 스레드로 전달한다.
  4. 태스크에는 호출 자체와 관련한 메타 정보 이룹와 메인 스레드와 연결된 콜백 함수의 참조가 들어있다.
  5. 메인 스레드의 콜 스택이 비면 플랫폼은 이벤트 큐에 남아 있는 태스크가 있는 지 확인한다.
  6. 대기 중인 태스크가 있으면 플랫폼은 그 태스크를 실행한다. 이때 함수 호출이 일어나며 제어는 메인 스레드 함수로 반환된다.
  7. 함수 호출이 끝나고 콜 스택이 다시 비면 플랫폼은 다시 기다리는 태스크가 있는 지 이벤트 큐에서 확인한다.
  8. 콜 스택과 이벤트 큐가 모두 비고, 모든 비동기 네이티브 API 호출이 완료 될 때 까지 이 과정을 반복한다.

8.2 콜백 사용하기

비동기 자바스크립트 프로그램의 기본 단위는 콜백이다. 콜백은 다른 함수에 인수 형태로 전달된다. 동기 프로그램처럼 특정 함수가 고유한 동작을 완료하면 호출자가 건넨 콜백 함수를 호출한다.

fs.readFile(
	'/var/log/apache2/access_log',
    {encoding: 'utf8'},
    {error, data} => {
    	if (error){
        	console.log('error writing!', error)
        }
    }
)

fs.appendFile(
	'/var/log/apache2/access_log',
    'New access log entry',
    error => {
    	if (error){
        	console.log('error writing!', error)
        }
    }
)

 위 호출은 비동기로 일어나기 때문에 API 호출 순서로 파일시스템에서 실행할 동작 순서를 결정할 수 없다는 사실을 알지 못한다면 코드가 어떤 로그를 반환할 지 예측하지 못한다. 또한 콜백 방식은 연달아 수행되는 작업을 콛로 표현하기 어렵다는 문제도 있다. 여러 동작을 연달아 실행할 때 보통 한 동작이 성공했을 때만 다음 동작으로 이어가고, 에러가 발생하면 즉시 빠져나와야 할 때가 많다, 콜백을 이용하면 이런 제어를 수동을 처리해야 한다. 특히 동기 방식의 에러까지 관여 되기 시작하면 연이은 콜백을 올바로 처리하기가 상당히 어려워진다. 서로의 결과에 의존하는 콜백이 여러 개 등장하면 문제가 금방 복잡해지기 때문이다.

8.3 프로미스로 정상 회복하기

 비동기 작업을 추상화하여 서로 조합하거나 연결하는 등의 일을 할 수 있는 길을 열여주는 프로미스 개념을 설명한다.

function readFilePromise(path:string) : Promsise<string>{
	return new Promise((resolve, reject)=> {
    	readFile(path, (error, result) => {
        	if(error) {
            	reject(error)
            } else {
            	resolve(result)
            }
        }
    })
}

 위 resolve의 매개변수 타입은 우리가 어떤 API를 사용하는지에 따라 달라지며 reject의 매개변수 타입은 항상 Error 유형이 된다.

type Executor<T, E extends Error> = {
	resolve: (result : T) => void,
    reject: (error : E) => void
} => void
//...

 Promse만 보고도 Promise가 어떤 타입으로 해석될지를 알고자 하므로, Promise를 제네릭으로 만들고 그 생성자에서 자신의 타입 매개변수들을 Executor 타입에 전달할 것이다.

 이후 API 연쇄에 관해 생각해 본다면 Promise를 통해 연이어 실행하면서 결과를 전달하고 예외를 잡게끔하고 싶은 연산인 then과 catch를 추가한다.

class Promise<T, E extends Error> {
	constructor(f: Executor<T,E>) {}
    then<U, F extends Error>(g: (result: T)=> Promise<U, F>:Promise<U, F>)
    catch<U, F extends Error>(g: (error: T)=> Promise<U, F>:Promise<U, F>)

}

 then은 성공한 Promise 결과를 새 Promise로 매핑하여, catch는 거부시 에러를 새 Promise로 매핑한다.

let : a: () => Promise<string, TypeError> = // ...
let : b: (s : string) => Promise<number, never> = // ...
let : c: ()=> Promise<boolean, RangeError> = // ...

a()
	.then(b)
    .catch(e=>c()) // b는 에러가 아니므로 a가 에러일 때 호출됨
    .then(result => console.info('Done', result))
    .catch(e => console.error('Error', e))

 a가 성공하면 Promise를 b를 매핑하고, 그렇지 않으면 첫 번째 catch구문을 실행하면서 Promise를 c로 매핑한다. c가 성공하면 "Done"을 기록하고 거절되면 다시 마지막 catch를 실행한다. 마치 동기식 동작에 적용되는 try/catch를 비동기 동작에 적용하는 것과 같은 효과를 제공한다. 

 실제 예외를 던져야하는 상황을 처리하려면 then과 catch를 구현할 때 코드를 try/catch로 감싸고 catch구문에서 거절하는 식으로 처리하면된다.

  1. 모든 Promise는 거절될 수 있는 위험이 있으며, 정적으로 이를 확인할 수 없다.
  2. Promise가 거부되었다고 항상 Error인 것은 아니다. 타입스크립트는 어쩔 수 없이 자바스크립트의 동작을 상속받는데 자바스크립트는 throw로 모든 것을 던질 수 있기 때문이다. 따라서 거부된 결과가 Error의 서브타입이라고 간주할 수 없다.

이를 감안하여, 에러 타입을 지정하지 않아도 될게끔 Promise 타입을 조금 느슨하게 풀어준다.

class Promise<T> {
	constructor(f: Executor<T>) {}
    then<U>(g: (result: T)=> Promise<U>:Promise<U>) {
    	// ...
    }
    catch<U>(g: (error: unknown)=> Promise<U>:Promise<U>){
    	// ...
    }
}

8.4 async와 await

 프로미스는 비동기 코드를 다루는 강력한 추상 개념이다. 이 패턴이 유명해지면서 자바스크립트도 async와 await라는 형태로 이를 지원하기 시작하였다. 이 문법을 이용하면 마치 동기 작업을 다루듯이 비동기 작업을 처리할 수 있다.

async function getUser() {
	try{
    	let user = await getUserID(18)
        let location = await getLocation(user)
        console.ingo('got location', user)
    } catch (error) {
    	console.error(error)
    } finally {
    	console.info('done getting location')
    }
}

 프로미스가 필요한 곳에는 언제든 이들을 이용할 수 있으며 연결된 여러 동작을 쉽게 이해할 수 있게 해주고 then을 여러 번 사용할 필요가 없어진다.

8.5 비동기 스트림

 만약 미래의 서로 다른 시점에 이용할 수 있게 될 값이 여러 개라면 어떻게 처리해야 할까? 여러 개의 데이터로 이루어지며, 각각의 데이터를 미래의 어떤 시점에 받게 되는 상황을 설계해야한다. 이런 상황에선 이벤트 방출기나 리액티브 프로그래밍 라이브러리를 이용한다. 이 둘의 관계는 콜백과 프로미스의 관계와 비슷하다. 이벤트는 빠르고 가벼운 반면 리액티브 프로그래밍 라이브러리는 더 강력하며 이벤트 스트림을 조합하고 연결하는 기능을 제공한다.

이벤트 방출기

 방출기는 채널로 이벤트를 방출하고 채널에서 발생하는 이벤트를 리스닝하는 API를 제공한다.

interface Emiiter {
	// 이벤트 방출
    emit(channel: string, value: unknown): void
    // 이벤트가 방출되었을 때 어떤 작업을 수행
    on(channel: strong, f : (value: unknown)=> void): void
}

 대부분의 언어에서는 위와 같은 형태의 이벤트 방출기는 안전하지 않다. value의 타입이 특정 channel에 의존하는데 대부분의 언어에서는 이런 관계를 표현할 수 없기 때문이다. 그러나 타입스크립트는 타입 시스템을 이용해 안전하게 표현할 수 있다.

 emit과 on 메서드의 타입을 가능한 한 안전하게 정의해서 Node-Redis 라이브러리를 더 안전하게 사용할 수 있도록 만들어보자.

type RedisClient = {
	on<E extends Keyof Events>(
    	event: E,
        f: (arg: Events[E]) => void
    ): void
    emit<E extends keyof Events>(
    	event: E,
        arg: Events[E]
    ): void
}

 이벤트 이름과 인수를 하나의 형태로 따로 빼내고, 리스너와 방출기를 생성하는 데 이 형태에 매핑하는 패턴은 실무의 타입스크립트 코드에서 자주 볼수 있다. 간결하고 안전하기 때문이다. 철자를 틀리거나 인수 전달을 빼먹는 등의 실수를 막을 수 있고 코드 편집기가 이벤트와 콜백 매개변수 타입을 제시해주게 되므로 다른 개발자에게 코드가 하는 일을 설명하는 문서화 역할도 제공한다.

8.6 타입 안전 멀티스레딩

CPU를 많이 사용하는 작업에서는 진정한 병렬성, 즉 작업을 여러 개의 스레드로 분리해서 속도를 높이거나 메인 스레드의 부하를 줄여 반응성을 높여야 할 때가 있다.

8.6.1 브라우저에서 웹 워커 활용하기

 웹 워커는 브라우저에서의 멀티스레딩 작업을 폭넓게 지원하는 기능이다. Promise, setTimeout 같은 비동기 API는 동시성을 제공하는 반면 워커는 코드를 다른 CPU 스레드에서 병렬로 실행하도록 해준다.

 웹 워커는 브라우저에서 제공하는 API이므로 설계자들은 안정성을에 주안점을 두었다. 공유 메모리를 사용하는 동시성의 약점이 존재하기에 같은 영역의 메모리를 여러 스레드에서 동시에 읽고 쓰려 하면 비결정성이나 데드룩 등 온갖 병렬성 광련 문제에 직면하게 된다. 

 브라우저 코드는 특히 안전해야하고 브라우저가 크래시되거나 사용자 경험을 악화시키는 요인을 최소화해야 하므로 웹 워커가 메인 스레드나 다른 웹 워커와 통신하는 주된 수단은 메시지 전달이 되어야 한다.

// MainThread.ts
let worker = new Worker('WorkerScript.js')
worker.onmessage= e =>{
	console.log(e.data) // 'Ack: "some data"' 기록
}
wroker.postMessage('some data')

// WorkerScript.ts
onmessage = e => {
	console.log(e.data) // 'some data' 기록
    postMessage(Ack: "${e.data}")
}

  웹 워커에서는 전역적으로 이용할 수 있는 onmessage API로 들어오는 이벤트를 리스닝한다. 반대 반향으로 통신할 떄, 즉 워커에서 메인스레드로 실행이 바뀔 때는 전역으로 이용할 수 있는 postMessage를 이용해 메인 스레드로 메시지르 전달하고, .onmessage 메서드로 들어오는 메시지를 리스닝한다.

 이 API는 이벤트 방출기와 많이 비슷하다. 메시지를 편리하게 전달할 수 있다는 장점이 있지만, 타입 정보가 없다면 메시지로 전송될 수 있는 온갖 종류의 타입을 올바로 처리 했는지 알 수가 없다.

 메시지의 모든 가능한 타입의 유니온을 정의한 다음 switch문에서 Message 타입을 기준으로 분기하는 것으로 command 타입을 구현할 수 있다.

//WorkerScript.ts
type Command = // 1
	| {type: 'sendMessageToThread', data: [ThreadID, Message]} // 2
    | {type: 'createThread', data: [Participants] }
    | {type: 'addUserToThread', data: [ThreadID, UserID]}
    | {type: 'removeUSerFromThread', data: [ThreadID, UserID]}
    
onmessage = e => // 3
	processCommandFromMainThread(e.data)
    
function processCommandFromMainThread( // 4
	command: Command
) {
	switch(command.type){ // 5
    	case 'sendMessageToThread':
        	let [threadID,message] = command.data
            console.log(message)
            // ...
    }
}

 

  1. 메인 스레드가 워커 스레드로 보낼 수 있는 모든 명령의 유니온을 정의
  2. 긴 유니온 타입을 정의할 때는 파이프를 앞에 붙인다.
  3. 타입을 지정하지 않은 메시지를 받은 다음 타입을 지정한 processComan.. API에서 처리하도록 위임
  4. 메인 스레드에 들어오는 모든 메시지를 처리한다. 타입을 지정하지 않은 onmessageAPI를 타입을 지정해 감싸주는 래퍼 역할을 한다.
  5. Command 타입은 차별된 유니온이므로 switch문을 이용하여 메인 스레드가 발송할 수 있는 모든 메시지 타입을 처리한다.

 SafeEmitter를 이용하면 리스닝 계층을 안전하게 구현하는 데 필요한 많은 코드를 극적으로 줄일 수 있다. 워커 쪽에서는 모든 onmessage 호출을 방출기로 위임하여 사용자에게 편리하고 안전한 리스너 API를 제공할 수 있다.

class SafeEmiiter<
	Events extends Record<PropertyKey, unknown[]> // 1
>{
	private emitter = new EventEmitter // 2
    emit<K extends keyof Events>( // 3
    	channel: K,
        ...data : Events[K]
    ) {
    	return this.emitter.emit(channel, ...data)
    }
    on<K extends keyof Events>( // 4
    	channel: K,
        listener: (...data: Events[K]) => void
	) {
    	return this.emitter.on(channel, listener)
    }
}
  1. SafeEmitter는 제너릭 타입 Events를 선언한다. Events는 PropertyKey를 매개변수 목록에 매핑하는 Record다.
  2. SafeEmitter에서 emitter를 비공개 멤버로 선언했다. SafeEmitter를 상속 받지 않는 이유는 emit과 on의 시그니처가  EventEmitter에서 오버로드 한 대응 부분보다 더 제한적인데, 타입스크립트에서 함수의 매개변수는 반변이므로 이 함수들을 오버로드 할 수 없기 때문이다.
  3. emit의 첫 번째 인수는 channel이고 이어서 우리가 Events 타입에 정의한 매개변수 목록에 대응하는 인수들을 받는다.
  4. 비슷하게 on은 channel과 listtener를 받는다. listener가 받는 인수의 수는 가변적인데, 바로 우리가 Events 타입에 정의한 매개변수 목록에 대응하는 값들이다.

타입 안전 프로토콜

 두 스레드 사이에 메시지를 주고받는 기능에서 확장하여 특정 명령이 특정한 한 가지 이벤트만 받도록 제한하라면 어떻게 해야 할까?

type Protocol= { // 1
	[commad: string] : {
    	in: unknown[]
        out: unknwon
    }
}

function createProtocol<P extends Protocol>(script: string){ // 2
	return <K extends keyof P>(command: K) => // 3
    	(...args: P[K]['in']) => // 4
        	new Promise<P[K]['in']>((resolve, reject)=>{ // 5
            	let worker = new Worker(script)
                worker.onerror = reject
                worker.onmessage = event => resolve(event.data.data)
                worker.postMessage({ command, args})
            })
}
  1. MatrixProtocal에 한정되지 않는 범용 Protocol 타입을 지정한다.
  2. crateProtocol을 호출할 때는 워커 스크립트의 파일 경로와 구체적인 Protocol을 인수를 전달한다.
  3. createProtocol은 command를 인수로 받는 익명 함수를 반환한다. 이 command는 2에서 설정한 Protocol의 키다
  4. 3에서 전달한 command의 in 타입에 따라 이 익명함수를 호출한다.
  5. 구체적인 프로토콜에서 정의한 대로 command에서 지정한 out 타입의 Promise를 반환한다. 타입 매개변수가 Promise라고 명시했는데 하지 않았으면 기본값으로 {}가 사용된다.

프로토콜에서 사용할 수 있는 모든 명령은 한 곳(MatrixProtocol)에 정의되어 있으며 핵심 로직(createProtocol)은 구체적인 프로토콜 구현(runWithMatrixProtocol)과는 별개로 존재한다. 같은 기기 또는 네크워크로 연결된 다른 컴퓨터에 존재하는 두 프로세스 간에 통신을 해야하는 상황에서 타입 안전 프로토콜은 안전한 통산을 보장하는 훌륭한 도구다.

본 글은 Typescript Programming을 요약한 글입니다.
자세한 내요은 본 책을 읽으시길 바랍니다.

 

7장에서는 타입스크립트에서 에러를 표현하고 처리하는 가장 일반적인 패턴 네가지를 소개한다.

  • null 반환
  • 예외 던지기
  • 예외 반환
  • Option 타입

7.1 null 반환

function parse(birthday: string) : Data | null {
	let date = new DAte(birthday)
    if(!isValid(date)){
    	return null
    }
    return date
}

 타입 안전성을 유지하면서 에러를 처리하는 가장 간단한 방법은 null을 반환하는 것이다. 하지만 parse에서 발생하는 에러를 이 방식으로 처리하면 문제가 생긴 원인을 알수가 없다. 또한 조합이 어려워진다는 문제점도 존재한다. 모든 연산에서 null을 확인해야하므로 연산을 중첩하거나 연결할 때 코드가 지저분해진다.

7.2 예외 던지기

문제가 발생하면 null 반환 대신 예외를 던지자. 그러면 어떤 문제냐에 따라 대처가 가능할 수 있고, 디벙깅에 도움되는 메타데이터도 얻을 수 있다.

function parse(birthday: string) : Data {
	let date = new DAte(birthday)
    if(!isValid(date)){
    	throw new RangeError('Enter a date in the form YYYY/MM/DD')
    }
    return date
}

 나주에 다른 개발자가 parse나 ask에서 또다른 형태의 RangeError를 던질 수 있게 하려면 에러를 서브클래싱하여 더 구체적으로 표현하면 된다.

// 커스텀 에러 타입
class InvaliDateFormatError extends RangeError {}
class DateIsInTheFuterError extends RangeError {}

function parse(birthday: string) : Data {
	let date = new DAte(birthday)
    if(!isValid(date)){
    	throw new InvaliDateFormatError('Enter a date in the form YYYY/MM/DD')
    }
    if (date.getTime() > Date.now()){
    	throw new DateIsInTheFuterError('Are you a timelord?')
    }
    return date
}

 커스텀 에러를 이용하면 어떤 문제가 생겼는지 알려줄 수 있을 뿐 아니라 문제 생긴 이유도 설명할 수 있다. 여러 동작을 하나의 try/catch구문으로 감싸는 형태로, 연쇄적이고 중첩된 동작을 효율적으로 만들 수 있다.

7.3 예외 반환

타입스크립트는 자바가 아니기에 throw문을 지원하지 않는다. 하지만 유니온 타입을 이용해 비슷하게 흉내낼 수 있다.

function parse(birthday: string) : Data | InvaliDateFormatError | DateIsInTheFuterError{
	let date = new DAte(birthday)
    if(!isValid(date)){
    	return new InvaliDateFormatError('Enter a date in the form YYYY/MM/DD')
    }
    if (date.getTime() > Date.now()){
    	return new DateIsInTheFuterError('Are you a timelord?')
    }
    return date
}
  • parse의 시그니처에 발생할 수 있는 예외를 나열했다.
  • 메서드 사용자에게 어떤 에러가 발생할 수 있는지를 전달했다.
  • 메서드 사용자가 각각의 에러를 모두 처리하거나 다시 던지도록 강제했다.

한편 에러를 던지는 연산을 연쇄적으로 호출하거나 중첩하면 코드가 지저분해진다는 단점이 있다.

7.4 Option 타입

 특수 목전 데이터 타입을 사용해 예외를 표현하는 방법도 있다. 값과 에러의 유니온을 반환하는 방법에 비해 똑같은 데이터 타입을 사용하지 않는 다른 코드와 호환되지 않는다는 단점이 있지만 에러가 발생할 수 있는 계산에 여러 연산을 연쇄적으로 수행할 수 있게 된다.

 어떤 특정 값을 반환하는 대신 값을 포함하거나 포함하지 않을 수도 있는 컨테이너를 반환한다는 것이 Option 타입의 핵심이다. 컨테이너는 자체적으로 몇 가지 메서드를 제공하며, 개발자는 이를 이용해 설혹 안에 값이 없을지라도 여러 가지 연산을 연쇄적을 수행할 수 있다.

function ask() {
	let result = prompt('When is your brithday?')
    if (result === null){
    	return []
    }
    return [result]
}
// ...
ask()
	.map(parese)
    .map(date => date.toISOString()) // 에러:  속성'toISOString'는 'Date[]' 타입에 존재하지 않음
    
    // 배열의 배열을 배열로 평탄화하여 에러를 해결
function flatten<T>(array: T[][]): T[] {
	return Array.prorotype.caoncat.apply([],array)
}

flatten(ask()
	.map(parese)
    .map(date => date.toISOString()))

 현재 코드에서 타입이 많은 정보를 제공하지 않아 무슨 일이 벌어지고 있는지 한눈에 파악하기 어렵다. 우리가 하려는 작업을 컨테이너라는 특수한 데이터 타입에 담아 상황을 개선해보자. 이때 컨테이너는 대상 값을 이용해 연산을 수행하는 방법과 그 결과를 얻어내는 방법을 드러내는 역할을 한다.

 Option 타입은 다음과 같이 정의된다.

  • Option은 Some<T>와 None이 구현하게 될 인터페이스다. 두 클래스 모두 Option의 한 형태가 된다.
  • Some<T>는 T라는 값을 포함하는 Option이고 None은 값이 없는, 즉 실패한 상황의 Option을 가리킨다.
  • Option은 타입이기도 하고 함수이기도 하다. 타입 관점에서는 단순히 Some과 None의 슈퍼타입을, 함수 관점에서는 Option 타입의 새 값을 만드는 기능을 뜻한다.
interface Option<T>{
	flatMap<U>(f: (value: T) => Option<U>) : Option<U>
    getOrElse(value: T): T
}
class Some<T> implements Option<T> {
	constructor(private value : T){}
    flatMap<U>(f: (value: T) => Option<U>) : Option<U> { // 1
    	return f(this.value)
    }
    getOrElse() : T { // 2
    	return this.value
    }
}

class None implements Option<never>{
  	flatMap<U>() : Option<U> { // 3
    	return this
    }
    getOrElse<U>(value : U): U { // 4
    	return value
    }
}
  1. Some<T>에 flatMap을 호출하면 인수로 전달된 f를 호출해 새로운 타입의 새 Option을 만들어 반환한다.
  2. Some<T>에 getOrElse를 호출하면 Some<T>의 값을 반환한다.
  3. None은 계산 실패를 의미하므로 flatMap을 호출하면 항상 None을 반환한다.
  4. None에 getOrElse를 호출하면 항상 기본값으로 제공한 값을 그대로 반환한다.

 성공하거나 실패할 수 있는 연산을 연달아 수행할 때 Option을 유용하게 사용할 수 있다. 이는 타입 안정성을 제공할 뿐 아니라 타입 시스템을 통해 해당 연산이 실패할 수 있음을 사용자에게 알려줄 수 있다. 하지만 단점 역시 존재한다. 이 기능은 None으로 실패를 표현하기 때문에 무엇이 왜 실패했는지는 자세히 알려주지 못한다. 또한 Option을 사용하지 않는 다른 코드와 호환되지 않는다는 것도 단점이다.

7.5 요약

  • 어떤 작업이 실패했음을 단순히 알리거나 아니면 단순히 에러가 발생했을 떄 처리(null, 예외 처리) 
  • 가능한 모든 예외를 사용자가 명시적으로 처리하도록 강제하거나 (예외 반환)
  • 실패한 이유와 관련된 정보 제공,(예외 반환하거나 던지기)
  • 에러 처리 관련 코드를 더 적게 구현 (예외 던지기)
  • 에러를 만드는 방법이 필요하거나 (Option)
본 글은 Modern JavaScript Deep-dive을 요약한 글입니다.
자세한 내용은 본 책을 읽으시기 바랍니다.

 

 자바스크립트는 명령형, 함수형, 프로토타입 기반 객체지향 프로그래밍을 지원하는 멀티 패러다임 프로그래밍 언어다. 자바스크립트는 객체 기반의 프로그래밍 언어이며 자바스크립트를 이루고 있는 거의 "모든것"이 객체다. 원시 타입의 값을 제외한 나머지 값들 (함수, 배열, 정규 표현식 등)은 모두 객체다.

19.1 객체지향 프로그래밍

 객체지향 프로그래밍은 전통적인 명령형 프로그래밍의 절차지향적 관점에서 벗어나 여러 개의 독립적 단위, 즉 객체의 집합으로 프로그램을 표현하려는 프로그래밍 패러다임을 말한다. 객체지향 프로그래밍은 실세계의 실체를 인식하는 철학적 사고를 프로그래밍에 접목하려는 것에서 시작된다. 실체는 특징이나 성질을 나타내는 속성(attribute/ prototype)을 가지고 있고, 이를 통해 실체를 인식하거나 구별할 수 있다.

 예를 들어, 사람에게는 다양한 속성이 있으나 우리가 구현하려는 프로그램에서는 사람의 "이름"과 "주소"라는 속성에만 관심이 있다고 가정하자. 이처럼 다양한 속성 중에서 프로그램에 필요한 속성만 간추려 내어 표현하려는 것을 추상화라 한다.

// 이름과 주소 속성을 갖는 객체
const person = {
	name: 'Lee',
    address: 'Seoul'
}

console.log(person); // {name: 'Lee', address: 'Seoul'}

 이때 프로그래머는 이름과 주소 속성으로 표현된 객체(object)인 person을 다른 객체와 구별하여 인식할 수 있다. 이처럼 속성을 통해 여러 개의 값을 하나의 단위 구성한 복합적인 자료구조를 객체라 하며 객체지향 프로그래밍은 독립적인 객체의 집합으로 프로그램을 표현하려는 프로그래밍 패러다임이다.

 예를들어, 원이라는 개념을 객체로 만든다면 원에는 반지름이라는 속성이 있다. 이 반지름을 가지고 원의 지름, 둘레, 넓이를 구할 수 있다. 이때 반지름은 원의 상태를 나타내는 데이터이며 원의 지름, 둘레, 넓이를 구하는 것은 동작이다.

const circle = {
	radius : 5 , // 반지름
    
    // 원의 넓이: PIrr
    getArea(){
    	return Math.PI * this.radius **2;
    }
};

console.log(circle); // {radius: 5, getArea: f}

 객체지향 프로그래밍은 객체의 상태를 나타내는 데이터와 상태 데이터를 조작할 수 있는 동작을 하나의 논리적인 단위로 묶어 생각한다. 따라서 객체는 상태 데이터와 동작을 하나의 논리적인 단위로 묶은 복합적인 자료구조라고 할 수 있다. 이때 객체의 상태는 프로퍼티, 동작을 메서드라 부른다.

 각 객체는 고유의 기능을 갖는 독립적인 부품으로 볼 수 있지만 자신의 고유한 기능을 수행하면서 다른 객체와 관계성을 가질 수도 있고 다른 객체와 메시지를 주고받거나 데이터를 처리할 수도 있다. 또는 다른 객체의 상태 데이터나 동작을 상속받아 사용하기도 한다.

19.2 상속과 프로토타입

 상속은 객체지향 프로그래밍의 핵심 개념으로, 어떤 객체의 프로퍼티 또는 메서드를 다른 객체가 상속받아 그대로 사용할 수 있는 것을 말한다. 자바스크립트는 프로토타입을 기반으로 상속을 구현하여 불필요한 중복을 제거한다. 기존의 코드를 적극적으로 재사용하는 것이다.

const circle1 = new Circle(1);

const circle2 = new Circle(2);

console.log(circle1.getArea === circle2.getArea); // false

 위 예제에서 getArea 메서드는 모든 인스턴스가 동일한 내용의 메서드를 사용하므로 단 하나만 생성하여 모든 인스턴스가 공유해서 사용하는 것이 바람직하다. 그런데 Circle 생성자 함수는 인스턴스를 생성할 때마다 getArea 메서드를 중복 생성하고 모든 인스턴스 중복 소유한다. 동일한 생섬자 함수에 의해 모든 인스턴스가 동일한 메서드를 중복 소유하는 것은 메모리르 불필요하게 낭비하고 퍼포먼스에도 악영향을 준다.

 상속을 통해 불필요한 중복을 제거해 보자. 자바스크립트는 프로토타입을 기반으로 상속을 구현한다.

// 생성자 함수
function Circle(radius) {
	this.radius = radius;
}

Circle.prototype.getArea = function () {
	return Math.PI * this.radius ** 2;
}

const circle1 = new Circle(1);

const circle2 = new Circle(2);


// Circle 생성자 함수가 생성한 모든 인스턴스는 부모 객체의 역할을 하는 프로토타입 
// Circle.prototype으로부터 getArea 메서드를 상속받는다.
console.log(circle1.getArea === circle2.getArea); // true

19.3 프로토타입 객체

 프로토타입 객체는 객체 간 상속을 구현하기 위해 사용된다. 프로토타입은 어떤 객체의 상위 부모 객체의 역할을 하는 객체로서 다른 객체에 공유 프로퍼티(메서드 포함)를 제공한다. 모든 객체는 [[Prototype]] 이라는 내부 슬롯을 가지며, 이 내부 슬롯의 값은 프로토타입의 참조다. 객체가 생성될 떄 객체 생성 방식에 따라 프로토타입이 결정되고 [[Prototype]]에 저장된다.

 모든 객체는 하나의 프로토타입을 갖는다. 그리고 모든 프로토타입은 생성자 함수와 연결되어 있다. [[Prototype]] 내부 슬롯에는 직접 접근할 수 없지만 __proto__ 접근자 프로퍼티를 통해 자신의 [[Prototype]] 내부 슬롯이 가리키는 프로토타입에 간접적으로 접근 할 수 있다. 프로토타입은 자신의 constructor 프로퍼티를 통해 생성자 함수에 접근할 수 있고, 생성자 함수는 자신의 prototype 프로퍼티를 통해 프로토타입에 접근할 수 있다.

19.3.1 __proto__ 접근자 프로퍼티

 모든 객체는 __proto__ 접근자 프로퍼티를 통해 자신의 프로토타입, 즉 [[Prototype]] 내부 슬롯에 간접적으로 접근할 수 있다.

 

__proto__는 접근자 프로퍼티다.

 내부 슬롯은 프로퍼티가 아니다. 자바스크립트는 원칙적으로 내부 슬롯과 내부 메서드에 직접적으로 접근하거나 호출할 수 있는 방법을 제공하지 않는다. 단, 일부 내부 슬롯과 내부 메서드에 한하여 간접적으로 접근할 수 있는 수단을 제공하기는 한다.

 접근자 프로퍼티는 자체적으로 값([[Value]] 프로퍼티 어트리뷰트)을 갖지 않고 다른 데이터의 값을 읽거나 저장할 때 사용하는 접근자 함수 [[Get]], [[Set]] 프로퍼티 어트리뷰트로 구성된 프로퍼티다. __proto__ 접근자 프로퍼티를 통해 프로토타입에 접근하면 내부적으로 __proto__ 접근자 프로퍼티의 getter 함수인 [[Get]]이 호출된다. 또 이를 통해 새로운 프로토타입을 할당하면 __proto__ 접근자 프로퍼티의 setter 함수인 [[Set]]이 호출된다.

const obj = {};
const parent = {x : 1};

// getter 함수인 get __proto__ 가 호출되어 obj 객체의 프로토타입을 취득
obj.__proto__;

// setter 함수인 set __proto__ 가 호출되어 obj 객체의 프로토타입을 교체
obj.__proto__ = parent;

console.log(obj.x); // 1

 

__proto__는 접근자 프로퍼티다.

 __proto__ 접근자 프로퍼티는 객체가 직접 소유하는 프로퍼티가 아니라 Object.prototype의 프로퍼티다. 모든 객체는 상속을 통해 Object.prototype.__prototype__ 접근자 프로퍼티를 사용할 수 있다.

const person = {name : 'Lee'};

// person 객체는 __proto__ 프로퍼티를 소유하지 않는다.
console.log(person.hasOwnProperty('__proto__')); // false

// __proto__ 프로퍼티는 모든 객체의 프로토타입 객체인 Object.prototype의 접근자 프로퍼티다.
console.log(person.hasOwnPropertyDescriptor(Object.prototype,'__proto__')); 
// {get : f, set: f, enumerable: false, configurable: true}

// 모든 객체는 Object.prototype의 접근자 프로퍼티 __proto__를 상속받아 사용할 수 있다.
console.log({}.__proto__ === Object.prototype); // true

 

__proto__ 접근자 프로퍼티를 통해 프로토타입에 접근하는 이유

 [[Prototype]] 내부 슬롯의 값, 즉 프로토타입에 접근하기 위해 접근자 프로퍼티를 사용하는 이유는 상호 참조에 의해 프로토타입 체인이 생성되는 것을 방지하기 위해서다.

 프로토타입 체인은 단방향 링크드 리스트가 구현되어야 한다. 순환 참조하는 프로토타입 체인이 만들어지면 프로토타입 체인 종점이 존재하지 않기 때문에 프로토타입 체인에서 프로퍼티를 검색할 떄 무한 루프에 빠진다. 따라서 아무런 체크 없이 무조건적으로 프로토타입을 교체할 수 없도록 __proto__ 접근자 프로퍼티를 통해 프로토타입에 접근하고 교체하도록 구현되어 있다.

 

__proto__ 접근자 프로퍼티를 코드 내에서 직접 사용하는 것은 권장하지 않는다.

 코드 내에서 __proto__ 접근자 프로퍼티를 직접 사용하는 것은 권장하지 않는다. 모든 객체가 __proto__ 접근자 프로퍼티를 사용할 수 있는 것이 아니기 때문이다. 따라서 프로토타입의 참조를 취득하고 싶은 경우에는 Object.getPrototypeOf 메서드를 사용하고, 프로토타입을 교체하고 싶은 경우에는 Object.setPrototypeOf 메서드를 사용할 것을 권장한다.

const obj = {};
const parent = {x : 1};

// obj 객체의 프로토타입을 취득
Object.getPrototypeOf(obj); // obj.__proto__;

// obj 객체의 프로토타입을 교체
Object.setPrototypeOf(obj,parent)

console.log(obj.x); // 1

19.3.2 함수 객체의 prototype 프로퍼티

 함수 객체만이 소유하는 prototype 프로퍼티는 생성자 함수가 생성할 인스턴스의 프로토타입을 가리킨다.

// 함수 객체는 prototype 프로퍼티를 소유한다.
(function(){}).hasOwnProperty('prototype'); // => true

// 일반 객체는 prototype 프로퍼티를 소유하지 않는다.
({}).hasOwnProperty('prototype'); // => false

 따라서 생성자 함수로서 호출할 수 없는, 즉 non-constructor인 화살표 함수와 ES6 메서드 축약 표현으로 정의한 메서드는 prototype 프로퍼티를 소유하지 않으며 프로토타입도 생성하지 않는다.

 모든 객체가 가지고 있는 (엄밀히 말하면 Object.prototype으로 부터 상속받은) __proto__ 접근자 프로퍼티와 함수 객체 만이 가지고 prototype 프로퍼티는 결국 동일한 프로토타입을 가리킨다. 하지만 이들을 사용하는 주체가 다르다.

구분 소유 값  사용 주체 사용 목적
__proto__
접근자프로퍼티
모든 객체 프로토타입의 참조 모든 객체 객체가 자신의 프로토타입에 접근 또는 교체하기위해 사용
prototype
프로퍼티
constructor 프로토타입의 참조 생성자 함수 생성자 함수가 자신이 생성할 객체에 프로토타입을 할당하기 위해 사용
// 생성자 함수
function Person(name){
	this.name = name;
}

const me = new Person('Lee');

// 결국 Person.prototype과 me.__proto__는 결국 동일한 프로토타입을 가진다.
console.log(Person.prototype === me.__proto__); // true

19.3.3 프로토타입의 constructor 프로퍼티와 생성자 함수

 모든 프로토타입은 constructor 프로퍼티를 갖는다. 이 constructor 프로퍼티는 prototype 프로퍼티로 자신을 참고하고 있는 생성자 함수를 가리킨다. 이 연결은 함수 객체가 생성될때 이뤄진다.

// 생성자 함수
function Person(name){
	this.name = name;
}

const me = new Person('Lee');

console.log(me.constructor === Person); // true

19.4 리터럴 표기법에 의해 생성된 객체의 생성자 함수와 프로토타입

 생성자 함수에 의해 생성된 인스턴스는 프로토타입의 constructor 프로퍼티에 의해 생성자 함수와 연결된다. 이때 constructor 프로퍼티가 가리키는 생성자 함수는 인스턴스를 생성한 생성자 함수다.

// obj 객체를 생성한 생성자 함수는 Object다.
const obj = new Object();
console.log(obj.constructor === Object); // true

// add 함수 객체를 생성한 생성자 함수는 Function 이다.
const add = new Function ('a', 'b', 'return a + b');
console.log(add.constructor === Function); // true

// 생성자 함수
function Person(name) {
	this.name = name;
}

// me 객체를 생성한 생성자 함수는 Person이다.
const me = new Person('Lee');
console.log(me.constructor === Person); // true

 

하지만 리터럴 표기법에 의한 객체 생성 방식과 같이 인스턴스를 생성하지 않는 객체 생성 방식도 있다.

// 객체 리터럴
const obj = {};

// 함수 리터럴
const add = function (a, b){ return a + b;};

// 배열 리터럴
const arr = [1, 2, 3];

// 정규 표현식 리터럴
const regexp = /is/ig;

 리터럴 표기법에 의해 생성된 객체도 물론 프로토타입이 존재한다. 하지만 이 경우 프로토타입의 constructor 프로퍼티가 가리키는 생성자 함수가 반드시 객체를 생성한 생성자 함수라고 단정할 수는 없다.

 

객체 리터럴에 의해 생성된 객체는 사실 Object 생성자 함수로 생성되는 것이 아닐까?

// obj 객체는 Object 생성자 함수로 생성한 객체가 아니라 객체 리터럴로 생성했다.
const obj = {};

// 하지만 obj 객체의 생성자 함수는 Object 생성자 함수다.
console.log(obj.constructor === Object); true

 

EMCAScript Object 생성자 함수에 인수를 전달하지 않거나 undefined 또는 null을 인수로 전달하면서 호출하면 내부적으로는 추상 연상 OrdinaryObjectCreate를 호출하여 Object.prototype을 프로토타입으로 갖는 빈 객체를 생성한다.

// 2. Object 생성자 함수에 의한 객체 생성
// 인수가 전달되지 않았을 떄 추상 연산 OrdinaryObjectCreate를 호출하여 빈 객첼르 생성한다.
let obj = new Object();
console.log(obj); // {}

// 1. new target이 undefined 나 Object가 아닌 경우
// 인스턴스 => Foo.prototype => Object.prototype 순으로 프로토타입 체인이 생성된다.
class Foo extends Object {}
new Foo(); // Foo {}

// 3. 인수가 전달된 경우에는 인수를 객체로 변환한다.
// Number 객체 생성
obj = new Object(123);
console.log(obj); // Number{123}

// String 객체 생성
obj = new Object('123');
console.log(obj); // String {"123"}

 이처럼 object 생성자 함수 호출과 객체 리터럴의 평가는 추상연산 OrdinaryObjectCreate를 호출하여 빈 객체를 생성하는 점에서 동일하나 new.target의 확인이나 프로퍼티를 추가하는 처리 등 세부 내용은 다르다. 따라서 객체 리터럴에 의해 생성된 객체는 Object 생성자 함수가 생성한 객체가 아니다.

 리터럴 표기법에 의해 생성된 객체도 상속을 위해 프로토타입이 필요하다. 따라서 리터럴 표기법에 의해 생성된 객체도 가상적인 생성자 함수를 갖는다. 다시말해, 프로토타입과 생성자 함수는 단독으로 존재할 수 없고 언제나 쌍으로 존재한다.

 하지만 큰 틀에서 생각해보면 리터럴 표기법으로 생성한 개게도 생성자 함수로 생성한 객체와 본질적인 면에서는 큰 차이는 없다. 따라서 프로토타입의 constructor 프로퍼티를 통해 연결되어 있는 생성자 함수를 리터럴 표기법으로 생성한 객체를 생성한 함수로 생각해도 크게 무리는 없다.

리터럴 표기법 생성자 함수 프로토타입
객체 리터럴 Object Obejct.prototype
함수 리터럴 Function Function.prototype
배열 리터럴 Array Array.prototype
정규 표현식 리터럴 RegExp RegExp.prototype

19.5 프로토타입의 생성 시점

  결국 모든 객체는 생성자 함수와 연결되어 있다. 프로토 타입은 생성자 함수가 생성되는 시점에 더불어 생성된다. 생성자 함수는 사용자가 직접 정의한 사용자 정의 생성자 함수와 자바스크립트가 기본 제공하는 빌트인 생성자 함수로 구분할 수 있다.

19.5.1 사용자 정의 생성자 함수와 프로토타입 생성 시점

일반 함수(함수 선언문, 함수 표현식)으로 정의한 함수 객체는 new 연산자와 함께 생성자 함수로서 호출할 수 있다. 생성자 함수로서 호출할 수 있는 함수, 즉 constructor는 함수 정의가 평가되어 함수 객체를 생성하는 시점에 프로토타입도 더불어 생성된다. 

// 함수 정의(constructor)가 평가되어 함수 객체를 생성하는 시점에 프로토타입도 더불어 생성된다.
console.log(Person.prototype); // {constructor: f}

// 생성자 함수
function Person(name){
	this.name = name
}

 함수 선언문은 런타임 이전에 자바스크립트 엔진에 의해 먼저 실행된다. 따라서 함수 선언문으로 정의된 Person 생성자 함수는 어떤 코드보다 먼저 평가되어 함수 객체가 된다. 이때 프로토타입도 더불어 생성된다.

 

 생성자 함수로서 호출할 수 없는 함수 즉, non-constructo는 프로토타입이 생성되지 않는다.

// 화살표 함수는 non-constructor다.
const Person = name => {
	this.name = name;
}

// non-constructor는 프로토타입이 생성되지 않는다.
console.log(Person.prototype); // undefined

19.5.2 빌트인 생성자 함수와 프로토 타입 생성 시점

 빌트인 생성자 함수도 일반 함수와 마찬가지로 빌트인 생성자 함수가 생성되는 시점에 프로토타입이 생성된다. 모든 빌트인 생성자 함수는 전역 객체가 생성되는 시점에 생성된다. 

 이처럼 객체가 생성되기 이전에 생성자 함수와 프로토타입은 이미 객체화되어 존재한다. 이후 생성자 함수 또는 리터럴 펴기법으로 객체를 생성하면 프로토타입은 생성된 객체의 [[Prototype]] 내부 슬롯에 할당된다. 이로써 생성된 객체는 프로토타입은 상속받는다.

19.6 객체 생성 방식과 프로토타입의 결정

객체는 다양한 생성 방법이 있다.

  • 객체 리터럴
  • Object 생성자 함수
  • 생성자 함수
  • Object.create 메서드
  • 클래스(ES6)

 위 방식으로 생성된 모든 객체는 각 방식마다 세부적인 객체 생성 방식의 차이는 있으나 추상연산에 의해 생성된다는 공통점이 있다. 즉, 프로토타입은 추상연상 OrdinaryObjectCreate에 전달되는 인수에 의해 결정된다. 이 인수는 객체가 생성되는 시점에 객체 생성 방식에 의해 결정된다.

19.6.1 객체 리터럴에 의해 생성된 객체의 프로토타입

객체 리터럴에 의해 생성되는 객체의 프로토타입은 Object.prototype이다.

const obj = { x : 1};

 

이처럼 객체 리터럴에 의해 생성된 obj 객체는 Object.prototype을 프로토타입으로 갖게 되며, 이로써 Object.prototype을 상속받는다.

19.6.2 Object 생성자 함수에 의해 생성된 객체의 프로토타입

const obj = new Object();
obj.x =1;

위 코드가 실행되면 추상 연상 OrdinaryObjectCreat에 의해 다음과 같이 Object 생성자 함수와 Object.prototype과 생성된 객체 사이에 연결이 만들어진다. 객체 리터럴에 의해 생성된 객체와 동일한 구조를 갖는다.

객체 리터럴과 Object 생성자 함수에 의한 객체 생성 방식의 차이는 프로퍼티를 추가하는 방식에 있다. 객체 리터럴 방식은 객체 리터럴 내부에 프로퍼티를 추가하지만 Object 생성자 함수 방식은 일단 빈 객체를 생성한 이후 프로퍼티를 추가해야 한다.

19.6.3 생성자 함수에 의해 생성된 객체의 프로토타입

 생성자 함수에 의해 생성되는 객체의 프로토타입은 생성자 함수의 prototype 프로퍼티에 바인딩되어 있는 객체다.

function Person(name){
	this.name = name;
}

const me = new Person('Lee');

 표준 빌트인 객체인 Object 생성자 함수와 더불어 생성된 프로토타입 Object.prototype은 다양한 빌트인 메서드를 가지고 있지만 사용자 정의 함수 Person과 더불어 생성된 Person.prototype의 프로퍼티는 constructor뿐이다.

 프로토타입도 객체다. 따라서 일반 객체와 같이 프로토타입에도 프로퍼티를 추가/삭제할 수 있다. 그리고 이렇게 추가/삭제된 프로퍼티는 프로토타입 체인에 즉각 반영된다.

function Person(name){
	this.name = name;
}

// 프로토타입 메서드
Person.prototype.sayHello = function () {
	console.log(`Hi! My name is ${this.name}`);
}

const me = new Person('Lee');
const you = new Person('Kim');

me.sayHello; // Hi! My name is Lee
you.sayHello; // Hi! My name is Kim

 Person 생성자 함수를 통해 생성된 모든 객체는 프로토타입에 추가된 sayHello 메서드를 상속받아 자신의 메서드처럼 사용할 수 있다.

19.7 프로토타입 체인

function Person(name){
	this.name = name;
}

// 프로토타입 메서드
Person.prototype.sayHello = function () {
	console.log(`Hi! My name is ${this.name}`);
}

const me = new Person('Lee');

console.log(me.hasOwnProperty('name')); // true

 

 Person 생성자 함수에 의해 생성된 me 객체는 Object.prototype의 메서드인 hasOwnProperty를 호출할 수 있다. 이는 me 객체가 Object.prototype도 상속받았다는 것을 의미한다. me 객체의 프로토타입은 Person.prototype 이고 Person.prototype의 프로토타입은 Object.prototype이다.

 자바스크립트는 객체의 프로퍼티(메서드 포함)에 접근하려고 할 때 해당 객체에 접근하려는 프로퍼티가 없다면 [[prototype]] 내부 슬롯의 참조를 따라 자신의 부모 역할을 하는 프로토타입의 프로퍼티를 순차적으로 검색한다. 이를 프로토타입 체인이라 한다. 프로토타입 체인은 자바스크립트가 객체지향 프로그래밍의 상속을 구현하는 메커니즘이다. 

 프로토타입 체인의 최상위에 위치하는 객체는 언제나 Object.prototype이다. 따라서 모든 객체는 Object.prototype을 상속받는다. Object.prototype을 프로토타입 체인의 종점이라한다. Object.prototype의 프로토타입, 즉 [[Prototype]] 내부 슬롯의 값은 null 이다. 프로토타입 체인의 종점인 Object.prototype에도 프로퍼티를 검색할 수 없는 경우 undefiend를 반환한다.

 

 자바스크립트 엔진은 객체 간의 상속 관계로 이루어진 프로토타입의 계층적인 구조에서 객체의 프로퍼티를 검색한다. 따라서 프로토타입 체인은 상속과 프로퍼티 검색을 위한 메커니즘이라고 할 수 있다. 이에 반해 식별자는 스코프 체인에서 검색한다. 자바스크립트 엔진은 함수의 중첩 관계로 이루어진 스코프의 계층적 구조에서 식별자를 검색한다. 따라서  스코프 체인은 식별자 검색을 위한 메커니즘이라고 할 수 있다.

me.hasOwnProperty('name');
  1. 스코프 체인에서 me 식별자를 검색한다.
  2. me 식별자는 전역에서 선언 => 전역 스코프에서 검색된다.
  3. 이후, me 객체의 프로토타입 체인에서 hasOwnProperty 메서드를 검색한다.

이처럼 스코프 체인과 프로토타입 체인은 서로 연관없이 별도로 동작하는 것이 아니라 서로 협력하여 식별자와 프로퍼티를 검색하는 데 사용된다.

19.8 오버라이딩과 프로퍼티 섀도잉

  • 프로토타입이 소유한 프로퍼티를 프로토타입 프로퍼티
  • 인스턴스가 소유한 프로퍼티를 인스턴스 프로퍼티

 프로토타입 프로퍼티와 같은 이름의 프로퍼티를 추가하면 프로토타입 체인을 따라 프로토타입 프로퍼티를 검색하여 프로토타입 프로퍼티를 덮어쓰는 것이 아니라 인스터스 프로퍼티로 추가한다. 이처럼 상속 관계에 의해 프로퍼티가 가려지는 현상을 프로퍼티 섀도잉이라 한다.

  • 오버라이딩 : 상위 클래스가 가지고 있는 메서드를 하위 클래스가 재정의하여 사용하는 방식
  • 오버로딩 : 함수의 이름은 동일하지만 매개변수의 타입, 개수가 다른 메서드를 구현하고 매개변수에 의해서 구별하여 호출하는 방식

 프로퍼티를 삭제하는 경우도 마찬가지다. 프로토 타입 메서드가 삭제되는 것이 아니라 인스턴스 메서드가 삭제된다. 이와 같이 하위 객체를 통해 프로토 타입의 프로퍼티를 변경 또는 삭제하는 것은 불가능하다. 이를 가능케 할려면 프로토타입 체인으로 접근하는 것이 아니라 프로토 타입에 직접 접근해야한다.

19.9 프로토타입의 교체

프로토타입은 임의의 다른 객체로 변경할 수 있다. 부모 객체인 프로토타입을 동적으로 변경할 수 있다는 것을 의미한다. 이 특징을 활용하여 객체 간의 상속 관계를 동적으로 변경할 수 있다.

19.9.1 생성자 함수에 의한 프로토 타입의 교체

const Person = (function () {
	function Person(name) {
    	this.name
    }
    
    Person.prototype = { // 1. 생성자 함수의 prototype 프로퍼티를 통해 프로토타입을 교체
	sayHello() {
    	console.log(`Hi! My name is ${this.name}`);
    }
};

	return Person
}());

const me = New Person('Lee');

 프로토타입으로 교체한 객체 리터럴에는 constructor 프로퍼티가 없다. 이처럼 프로토타입을 교체하면 constructor 프로퍼티와 생성자 함수 간의 연결이 파괴된다. 교체한 객체 리터럴에 constructor 프로퍼티를 추가하여 프로토타입의 constructor 프로퍼티를 되살린다.

19.9.2 인스턴스에 의한 프로토타입의 교체

 프로토타입은 생성자 함수의 prototype 프로퍼티 뿐만 아니라 인스턴의 __proto__ 접근자 프로퍼티를 통해 접근할 수 있다. 따라서 인스턴스의 __proto__ 접근자 프로퍼티를 통해 프로토타입을 교체할 수 있다. 생성자 함수의 prototype 프로퍼티에 다른 임의의 객체를 바인딩하는 것은 미래에 생성할 인스턴스의 프로토타입을 교체하는 것이다. __proto__ 접근자 프로퍼티를 통해 프로토타입을 교체하는 것은 이미 생성된 객체의 프로토타입을 교체하는 것이다.

function Person(name) {
    	this.name 
};


const Person = { 
	sayHello() {
    	console.log(`Hi! My name is ${this.name}`);
    }
};

Object.setPrototypeOF(me, parent); // 1 me 객체의 프로토타입을 parent 객체로 교체한다.
// 위 코드는 아래의 코드와 동일하게 동작한다.
// me.__proto__ = parent;

 마찬가지로 프로토타입으로 교체한 객체에는 constructor 프로퍼티가 없으므로 constructor 프로퍼티와 생성자 함수간의 연결이 파괴된다.

생성자 함수로 교체하는 방식과 인스턴스에 의한 교체 방식은 두가지의 방법은 미묘한 차이가 있다. 그림으로 비교해보자.

프로토타입 교체를 통해 객체 간의 상속 관계를 동적으로 변경하는 것은 번거롭다. 따라서 직접 교체하지 않는 것이 좋다. 상속 관계를 인위적으로 설정하려면 직접 상속이 더 편리하고 안전하다. 또 클래스를 사용하면 간편하고 직관적으로 상속관계를 구현할 수 있다.

19.10 instanceof 연산자

instanceof 연산자는 이항 연산자로서 과변에 객체를 가리키는 식별자, 우변에 생성자 함수를 가리키는 식별자를 피연산자로 받는다. 우변의 생성자 함수의 prototype에 바인딩된 객체가 좌변의 객체의 프로토타입 체인 상에 존재하면 true로 평가되고, 그렇지 않은 경우에는false로 평가된다.

function Person(name) {
    	this.name 
};

const me = new Person('Lee');

console.log(me instanceof Person); // true

const parent = {};

// 프로토타입 교체
Object.setPrototypeOf(me, parent);

console.log(me instanceof Person); // false
console.log(me instanceof parent); // true

Person.prototype = parent;

console.log(me instanceof Person); // true

  이처럼 instanceof 연산자는 프로토타입의 constructor 프로퍼티가 가리키는 생성자 함수를 찾는 것이 아니라  생성자 함수의 protortpe에 바인딩된 객체가 프로토타입 체인 상에 존재하는지 확인한다.

const Person = (function () {
	function Person(name) {
    	this.name
    }
    
    Person.prototype = { // 생성자 함수의 prototype 프로퍼티를 통해 프로토타입을 교체
	sayHello() {
    	console.log(`Hi! My name is ${this.name}`);
    }
};

	return Person
}());

const me = New Person('Lee');

// constructor 프로퍼티와 생성자 함수 간의 연결이 파괴된것을 확인
console.log(me.constructor === Person); // false

// 그러나 instanceof는 영향을 받지 않음
conosole.log(me instanceof Person); // true

 따라서 생성자 함수에 의해 프로토타입이 교체되어 constructor 프로퍼티와 생성자 함수 간의 연결이 파괴되어도 생선자 함수의 prototype 프로퍼티와 프로토타입 간의 연결은 파괴되지 않으므로 instanceo는 아무런 영향을 받지 않는다.

19.11 직접 상속

19.11.1 Object.create에 의한 직접 상속

 Obejct.create 메서드는 명시적으로 프로토타입을 지정하여 새로운 객체를 생성한다. 다른 객체 생성 방식과 마찬가지로 추상연산을 호출한다. 첫번쨰 매개변수 에는 생성할 객체의 프로토타입으로 지정할 객체를 전달한다. 두 번째 매개변수에는 생성할 객체의 프로퍼티 키와 프로퍼티 디스크립터 객체로 이뤄진 객체를 전달한다. (두 번째 인수는 옵션이므로 생략 가능)

// 프로토타입이 null인 객체를 생성한다. 생성된 객체는 프로토타입 체인의 종점에 위치한다.
let obj = Object.create(null);

// obj = {} 와 동일하다
obj = Object.create(Object.prototype)

// obj => Object.prototype => null
// obj = {x : 1} 와 동일
object = Object.create(Object.prototype, {
	x: { value:1, writable:true, enumerable: true, configurable: true}
})

// 직접 상속받을 수도 있다.
const myProto = {x: 10};
obj = Object.create(myProto);

 이 메서드의 장점은 다음과 같다.

  • new 연산자가 없어도 객체를 생성할 수 있다.
  • 프로톹압을 지정하면서 객체를 생성할 수 있다.
  • 객체 리터럴에 의해 생성된 객체도 상속 받을 수 있다.

 Object.prototype의 빌트인 메서드를 객체가 직접 호출하는 것을 권장하지 않는다. 프로토타입 체인의 종점에 위치하는 객체를 생성할 수 있기 때문이다. 

19.11.2 객체 리터럴 내부에서 __proto__ 에 의한 직접 상속

Object.create 메서드에 의한 직접 상속은 두번쨰 인자로 프로퍼티를 정의하는 것이 번거롭다. 객체 리터럴 내부에서 __proto__ 접근자 프로퍼티를 사용하여 직접 상속을 구현할 수 있다.

cosnt myProto = { x : 10};

const obj = {
	y : 20,
    // 객체를 직접 상속받는다.
    __proto__ : myProto
}

19.12 정적 프로퍼티 / 메서드

정적 프로퍼티 / 메서드는 생성자 함수로 인스턴스를 생성하지 않아도 참조/ 호출할 수 있는 프로퍼트 / 메서드를 말한다.

정적 프로퍼티/메서드는 인스턴스의 프로토타입 체인에 속한 객체의 프로퍼티/메서드가 아니므로 인스턴스로 접근할 수 없다. 만약 인스턴스/ 프로토타입 메서드 내에서 this를 사용하지 않는다면 그 메서드는 정적 메서드로 변경할 수 있다. 인스턴스가 호출한 인스턴스/프로토타입 메서드 내에서 this는 인스턴스를 가리킨다. 메서드 내에서 인스턴스를 참조할 필요가 없다면 정적 메서드로 변경하여도 동작한다.

function Foo() {}

// 프로토타입 메서드
Foo.prototype.x = function() {
	console.log('x');
}

// 프로토타입 메서드를 호출하려면 인스턴스를 생성해야한다.
const foo = new Foo();
Foo.x(); // x

// 정적 메서드
Foo.x = function() {
	console.log('x');
}

// 인스턴스 생성없이 호출가능
Foo.x(); // x

19.13 프로퍼티 존재 확인

19.13.1 in 연산자

 in 연산자는 객체 내에 특정 프로퍼티가 존재하는지 여부를 확인한다. 확인 대상 객체의 프로퍼티 뿐만 아니라 확인 대상 객체가 상속받은 모든 프로토타입의 프로퍼티를 확인 하므로 주의가 필요하다.

console.log('toSting' in person); // true

19.13.2 Object.prototype.hasOwnProperty 메서드

 인수로 전달받은 프로퍼티 키가 객체 고유의 프로퍼티 키인 경우에만 true를 반환하고 상속받은 프로토타입의 프로퍼티 키인 경우 false를 반환한다.

console.log(person.hasOwnProperty('toString')); // false

19.14 프로퍼티 열거

19.14.1 for...in 문

객체의 모든 프로퍼티를 순회하며 열거하러면 for...in 문을 사용한다.

const perosn = {
	name:'Lee',
    address: 'Seoul'
}

for(const key in person) {
	conosole.log(key + `+` + person[key])
}
// name : 'Lee'
// address : 'Seoul'

 for ... in 문은 객체의 프로퍼티 개수만큼 순회하여 for .. in 문의 변수 선언문에서 선언한 변수에 프로퍼티키를 할당한다. 또한 상속받은 프로토타입의 프로퍼티까지 열거한다. 그러나 toString 메서드는 열거되지 않는다. 그 이유는 toString 메서드는 열거할 수 없도록 정의되어있는 프로퍼티이기 때문이다. 프로퍼티 어트리뷰트[[Enumerable]]의 값이 false이기 때문이다.

 따라서 정확히 표현한다면 for ... in 문은 객체의 프로토타입 체인 상에 존재하는 모든 프로토타입의 프로퍼티 중에서 프로퍼티 어트리뷰트 [[Enumerbale]]의 값이 true인 프로퍼티를 순회하며 열거한다.

 for ... in 문은 프로퍼티 키가 심벌인 프로퍼티는 열거하지 않는다. 또한 열거할 때 순서를 보장하지 않으므로 주의하기 바란다.(하지만 대부분의 모던 브라우저는 순서를 보장하고 문자열인 프로퍼티 키에 대해서는 정렬을 실시한다.)

 배열에는 for ... in 문을 사용하지 말고 일반적인 for 문이나 for ... of 문 또는 Array.prototype.forEach 메서드를 사용하기를 권장한다.

19.14.2 Object.keys/values/entries 메서드

 객체 자신의 고유 프로퍼티만을 열거하기 위해서는 for ... in 문을 사용하는 것보다 Object.keys/values/entries 메서드를 사용하는 것을 권장한다.

  • Object.keys 메서드는 객체 자신의 열거 가능한 프로퍼티 키를 배열로 반환한다.
  • ES8에서 도입된 Object.values 메서드는 객체 자신의 열거 가능한 프로퍼티 값을 배열로 반환한다.
  • ES8에서 도입된 Object.entries 메서드는 객체 자신의 열거 가능한 프로퍼티 키와 값의 쌍의 배열을 배열에 담아 반환한다.
const person = {
	name: 'Lee',
    address: 'Seoul',
    __proto__: {age: 20}
}

console.log(Object.keys(peron)); // ["name", "address"]

console.log(Object.values(peron)); // ["Lee", "Seoul"]

console.log(Object.entries(peron)); // ["name","Lee"], ["address", "Seoul"]

 

+ Recent posts