JavaScript 언어의 핵심에 대한 내용을 모아 JavaScript Garden을 만들어 었다. 이 글이 초보자가 JavaScript 익히면서 자주 겪는 실수, 미묘한 버그, 성능 이슈, 나쁜 습관들 줄일 수 있도록 도와줄 것이다.
JavaScript Garden은 단순히 JavaScript 언어 자체를 설명하려 만들지 않았다. 그래서 이 글에서 설명하는 주제들을 이해하려면 반드시 언어에 대한 기본 지식이 필요하다. 먼저 Mozilla Developer Network에 있는 문서로 JavaScript 언어를 공부하기 바란다.
숫자 리터럴은 객체처럼 사용되지 못할꺼라는 오해가 있는데 이것은 단지 JavaScript 파서의 문제일 뿐이다. JavaScript 파서는 숫자에 Dot Notation이 들어가면 오류라고 생각한다.
2.toString(); // SyntaxError가 난다.
하지만, 숫자를 객체처럼 사용할수 있는 꼼수가 몇 가지 있다.
2..toString(); // 두 번째 점은 잘 된다.
2 .toString(); // 왼쪽 공백이 있으면 잘 된다.
(2).toString(); // 2를 먼저 해석한다.
Object 타입
JavaScript 객체는 name/value 쌍으로 된 프로퍼티로 구성되기 때문에 Hashmap처럼 사용될 수도 있다.
객체 리터럴인 Object Notation으로 객체를 만들면 Object.prototype을 상속받고 프로퍼티를 하나도 가지지 않은 객체가 만들어진다.
var foo = {}; // 깨끗한 새 객체를 만든다.
// 값이 12인 'test' 프로퍼티가 있는 객체를 만든다.
var bar = {test: 12};
프로퍼티 접근
객체의 프로퍼티는 객체이름 다음에 점을 찍어(Dot Notation) 접근하거나 각괄호를 이용해(Square Bracket Notation) 접근할 수 있다.
var foo = {name: 'kitten'}
foo.name; // kitten
foo['name']; // kitten
var get = 'name';
foo[get]; // kitten
foo.1234; // SyntaxError
foo['1234']; // works
두 방식 모두 거의 동일하게 동작한다. 다만 차이가 있다면 각괄호 방식은 프로퍼티 이름을 동적으로 할당해서 값에 접근 할수 있지만 점을 이용한 방식은 구문 오류를 발생시킨다.
프로퍼티 삭제
객체의 프로퍼티를 삭제하려면 delete를 사용해야만 한다. 프로퍼티에 undefined나 null을 할당하는 것은 프로퍼티를 삭제하는 것이 아니라 프로퍼티에 할당된 value만 지우고 key는 그대로 두는 것이다.
var obj = {
bar: 1,
foo: 2,
baz: 3
};
obj.bar = undefined;
obj.foo = null;
delete obj.baz;
for(var i in obj) {
if (obj.hasOwnProperty(i)) {
console.log(i, '' + obj[i]);
}
}
위 코드의 출력 결과는 baz만 제거했기 때문에 bar undefined와 foo null은 출력되고 baz와 관련된 것은 출력되지 않는다.
Notation of Keys
var test = {
'case': 'I am a keyword, so I must be notated as a string',
delete: 'I am a keyword, so me too' // SyntaxError가 난다.
};
프로퍼티는 따옴표 없는 문자열(plain characters)과 따옴표로 감싼 문자열(strings)을 모두 Key 값으로 사용할 수 있다. 하지만 위와 같은 코드는 JavaScript 파서의 잘못된 설계 때문에 구버전(ECMAScript 5 이전 버전)에서는 SystaxError가 발생할 것이다.
위 코드에서 문제가 되는 delete 키워드를 따옴표로 감싸면 구버전의 JavaScript 엔진에서도 제대로 해석될 것이다.
Prototype
Javascript는 클래스 스타일의 상속 모델을 사용하지 않고 프로토타입 스타일의 상속 모델을 사용한다.
'이 점이 JavaScript의 약점이다.'라고 말하는 사람들도 있지만 실제로는 prototypal inheritance 모델이 훨씬 더 강력하다. 그 이유는 프로토타입 모델에서 클래스 모델을 흉내 내기는 매우 쉽지만, 반대로 클래스 모델에서 프로토타입 모델을 흉내 내기란 매우 어렵기 때문이다.
실제로 Prototypal Inheritance 모델을 채용한 언어 중에서 JavaScript만큼 널리 사용된 언어가 없었기 때문에 두 모델의 차이점이 다소 늦게 정리된 감이 있다.
먼저 가장 큰 차이점은 프로토타입 체인이라는 것을 이용해 상속을 구현한다는 점이다.
function Foo() {
this.value = 42;
}
Foo.prototype = {
method: function() {}
};
function Bar() {}
// Foo의 인스턴스를 만들어 Bar의 prototype에 할당한다.
Bar.prototype = new Foo();
Bar.prototype.foo = 'Hello World';
// Bar 함수를 생성자로 만들고
Bar.prototype.constructor = Bar;
var test = new Bar() // bar 인스턴스를 만든다.
// 결과적으로 만들어진 프로토타입 체인은 다음과 같다.
test [instance of Bar]
Bar.prototype [instance of Foo]
{ foo: 'Hello World', value: 42 }
Foo.prototype
{ method: ... }
Object.prototype
{ toString: ... /* etc. */ }
위 코드에서 test 객체는 Bar.prototype과 Foo.prototype을 둘 다 상속받았기 때문에 Foo에 정의한 method 함수에 접근할 수 있다. 그리고 프로토타입 체인에 있는 Foo 인스턴스의 value 프로퍼티도 사용할 수 있다. new Bar()를 해도 Foo 인스턴스는 새로 만들어지지 않고 Bar의 prototype에 있는 것을 재사용한다. 그래서 모든 Bar 인스턴스는 같은value 프로퍼티를 공유한다.
프로토타입 탐색
객체의 프로퍼티에 접근하려고 하면 JavaScript는 해당 이름의 프로퍼티를 찾을 때까지 프로토타입 체인을 거슬러 올라가면서 탐색하게 된다.
프로토타입 체인을 끝까지 탐색했음에도(보통은 Object.prototype임) 불구하고 원하는 프로퍼티를 찾지 못하면 undefined를 반환한다.
prototype 프로퍼티
prototype 프로퍼티는 프로토타입 체인을 만드는 데 사용하고 어떤 값이든 할당할 수 있지만, primitive 값을 할당되면 무시한다.
function Foo() {}
Foo.prototype = 1; // 무시됨
반면에 위 예제처럼 객체를 할당하면 프로토타입 체인이 동적으로 잘 만들어진다.
성능
프로토타입 체인을 탐색하는 시간이 오래걸릴수록 성능에 부정적인 영향을 줄수있다. 특히 성능이 중요한 코드에서 프로퍼티 탐색시간은 치명적인 문제가 될수있다. 가령, 없는 프로퍼티에 접근하려고 하면 항상 프로토타입 체인 전체를 탐색하게 된다.
뿐만아니라 객체를 순회(Iterate)할때도 프로토타입 체인에 있는 모든 프로퍼티를 탐색하게 된다.
네이티브 프로토타입의 확장
종종 Object.prototype을 이용해 내장 객체를 확장하는 경우가 있는데, 이것도 역시 잘못 설계된 것중에 하나다.
위와 같이 확장하는 것을 Monkey Patching라고 부르는데 캡슐화를 망친다. 물론 Prototype같은 유명한 프레임워크들도 이런 확장을 사용하지만, 기본 타입에 표준도 아닌 기능들을 너저분하게 추가하는 이유를 여전히 설명하지 못하고 있다.
기본 타입을 확장해야하는 유일한 이유는 Array.forEach같이 새로운 JavaScript 엔진에 추가된 기능을 대비해 미리 만들어 놓는 경우 말고는 없다.
결론
프로토타입을 이용해 복잡한 코드를 작성하기 전에 반드시 프로토타입 상속 (Prototypal Inheritance) 모델을 완벽하게 이해하고 있어야 한다. 뿐만아니라 프로토타입 체인과 관련된 성능 문제로 고생하지 않으려면 프로토타입 체인이 너무 길지 않도록 항상 주의하고 적당히 끊어줘야 한다. 마지막으로 새로운 JavaScript 기능에 대한 호환성 유지 목적이 아니라면 절대로 네이티브 프로토타입을 확장하지마라.
hasOwnProperty
어떤 객체의 프로퍼티가 자기 자신의 프로퍼티인지 아니면 프로토타입 체인에 있는 것인지 확인하려면 hasOwnProperty 메소드를 사용한다. 그리고 이 메소드는 Object.prototype으로 부터 상속받아 모든 객체가 가지고 있다.
hasOwnProperty메소드는 프로토타입 체인을 탐색하지 않고, 프로퍼티를 다룰수있는 유일한 방법이다.
hasOwnProperty 메소드는 어떤 프로퍼티가 자기 자신의 프로퍼티인지 아닌지 정확하게 알려주기 때문에 객체의 프로퍼티를 순회할때 꼭 필요하다. 그리고 프로토타입 체인 어딘가에 정의된 프로퍼티만을 제외하는 방법은 없다.
hasOwnProperty 메소드도 프로퍼티다
JavaScript는 hasOwnProperty라는 이름으로 프로퍼티를 덮어 쓸수도 있다. 그래서 객체 안에 같은 이름으로 정의된 hasOwnProperty가 있을 경우, 본래 hasOwnProperty의 값을 정확하게 얻고 싶다면 다른 객체의 hasOwnProperty 메소드를 빌려써야 한다.
var foo = {
hasOwnProperty: function() {
return false;
},
bar: 'Here be dragons'
};
foo.hasOwnProperty('bar'); // 항상 false를 반환한다.
// 다른 객체의 hasOwnProperty를 사용하여 foo 객체의 프로퍼티 유무를 확인한다.
({}).hasOwnProperty.call(foo, 'bar'); // true
// Object에 있는 hasOwnProperty를 사용해도 된다.
Object.prototype.hasOwnProperty.call(obj, 'bar'); // true
결론
어떤 객체에 원하는 프로퍼티가 있는지 확인하는 가장 확실한 방법은 hasOwnProperty를 사용하는 것이다. for in loop에서 네이티브 객체에서 확장된 프로퍼티를 제외하고 순회하려면 hasOwnProperty와 함께 사용하길 권한다.
for in Loop
객체의 프로퍼티를 탐색할때 in 연산자와 마찬가지로 for in 문도 프로토타입 체인까지 탐색한다.
// Object.prototype을 오염시킨다.
Object.prototype.bar = 1;
var foo = {moo: 2};
for(var i in foo) {
console.log(i); // bar와 moo 둘 다 출력한다.
}
for in문에 정의된 기본 동작을 바꿀순 없기 때문에 루프 안에서 불필요한 프로퍼티를 필터링 해야한다. 그래서 Object.prototype의 hasOwnProperty메소드를 이용해 본래 객체의 프로퍼티만 골라낸다.
hasOwnProperty로 필터링 하기
// 위의 예제에 이어서
for(var i in foo) {
if (foo.hasOwnProperty(i)) {
console.log(i);
}
}
위와 같이 사용해야 올바른 사용법이다. hasOwnProperty 때문에 오직moo만 출력된다. hasOwnProperty가 없으면 이 코드는 Object.prototype으로 네이티브 객체가 확장될 때 에러가 발생할 수 있다.
따라서 Proptotype 라이브러리처럼 네이티브 객체를 프로토타입으로 확장한 프레임워크를 사용할 경우 for in 문에 hasOwnProperty를 사용하지 않을 경우 문제가 발생할 수 있다.
결론
hasOwnProperty를 항상 사용하길 권한다. 실제 코드가 동작하는 환경에서는 절대로 네이티브 객체가 프로토타입으로 확장됐다 혹은 확장되지 않았다를 가정하면 안된다.
함수
함수 선언과 함수 표현식
JavaScript에서 함수는 First Class Object다. 즉, 함수 자체가 또 다른 함수의 인자될 수 있다는 말이다. 그래서 익명 함수를 비동기 함수의 콜백으로 넘기는 것도 이런 특징을 이용한 일반적인 사용법이다.
함수 선언
function foo() {}
위와 같이 선언한 함수는 프로그램이 실행하기 전에 먼저 호이스트(Hoist) (스코프가 생성)되기 때문에 정의된 스코프(Scope) 안에서는 어디서든 이 함수를 사용할 수 있다. 심지어 함수를 정의하기 전에 호출해도 된다.
foo(); // 이 코드가 실행되기 전에 foo가 만들어지므로 잘 동작한다.
function foo() {}
함수 표현식
var foo = function() {};
위 예제는 foo 변수에 익명 함수를 할당한다.
foo; // 'undefined'
foo(); // TypeError가 난다.
var foo = function() {};
'var'문을 이용해 선언하는 경우, 코드가 실행되기 전에 'foo' 라는 이름의 변수를 스코프의 맨 위로 올리게 된다.(호이스트 된다) 이때 foo 값은 undefiend로 정의된다.
하지만 변수에 값을 할당하는 일은 런타임 상황에서 이루어지게 되므로 실제 코드가 실행되는 순간의 foo변수는 기본 값인 undefined이 된다.
이름있는 함수 표현식
이름있는 함수를 할당할때도 특이한 경우가 있다.
var foo = function bar() {
bar(); // 이 경우는 동작 하지만,
}
bar(); // 이 경우는 참조에러를 발생시킨다.
foo 함수 스코프 밖에서는 foo 변수 외에는 다른 값이 없기 때문에 bar는 함수 밖에서 사용할 수 없지만 함수 안에서는 사용할 수 있다. 이와 같은 방법으로 자바스크립트에서 어떤 함수의 이름은 항상 그 함수의 지역 스코프 안에서 사용할수있다.
this의 동작 원리
다른 프로그래밍 언어에서 this가 가리키는 것과 JavaScript에서 this가 가리키는 것과는 좀 다르다. this가 가리킬 수 있는 객체는 정확히 5종류나 된다.
Global Scope에서
this;
Global Scope에서도 this가 사용될 수 있고 이때에는 Global 객체를 가리킨다.
함수를 호출할 때
foo();
이때에도 this는 Global 객체를 가리킨다.
메소드로 호출할 때
test.foo();
이 경우에는 this가 test를 가리킨다.
생성자를 호출할 때
new foo();
new 키워드로 생성자를 실행시키는 경우에 이 생성자 안에서 this는 새로 만들어진 객체를 가리킨다.
this가 가리키는 객체 정해주기.
function foo(a, b, c) {}
var bar = {};
foo.apply(bar, [1, 2, 3]); // a = 1, b = 2, c = 3으로 넘어간다.
foo.call(bar, 1, 2, 3); // 이것도...
Function.prototype의 call이나 apply 메소드를 호출하면 this가 무엇을 가리킬지 정해줄 수 있다. 호출할 때 첫 번째 인자로 this가 가리켜야 할 객체를 넘겨준다.
그래서 foo Function 안에서 this는 위에서 설명했던 객체 중 하나를 가리키는 것이 아니라 bar를 가리킨다.
대표적인 함정
this가 Global 객체를 가리키는 것도 잘못 설계된 부분 중 하나다. 괜찮아 보이지만 실제로는 전혀 사용하지 않는다.
Foo.method = function() {
function test() {
// 여기에서 this는 Global 객체를 가리킨다.
}
test();
};
test 에서 this가 Foo를 가리킬 것으로 생각할 테지만 틀렸다. 실제로는 그렇지 않다.
test에서 Foo에 접근하려면 method에 Local 변수를 하나 만들고 Foo를 가리키게 하여야 한다.
Foo.method = function() {
var self = this;
function test() {
// 여기에서 this 대신에 self를 사용하여 Foo에 접근한다
}
test();
};
self는 통상적인 변수 이름이지만, 바깥쪽의 this를 참조하기 위해 일반적으로 사용된다.
또한 클로저와 결합하여 this의 값을 주고 받는 용도로 사용할 수도 있다.
ECMAScript 5부터는 익명 함수와 결합된 bind 메소드를 사용하여 같은 결과를 얻을 수 있다.
Foo.method = function() {
var test = function() {
// this는 이제 Foo를 참조한다
}.bind(this);
test();
};
Method 할당하기
JavaScript의 또다른 함정은 바로 함수의 별칭을 만들수 없다는 점이다. 별칭을 만들기 위해 메소드를 변수에 넣으면 자바스크립트는 별칭을 만들지 않고 바로 할당해 버린다.
var test = someObject.methodTest;
test();
첫번째 코드로 인해 이제 test는 다른 함수와 똑같이 동작한다. 그래서 test 함수 내부의 this도 더이상 someObject를 가리키지 않는다. (역주: test가 methodTest의 별칭이라면 methodTest 함수 내부의 this도 someObject를 똑같이 가리켜야 하지만 test의 this는 더이상 someObject가 아니다.)
function Foo() {}
Foo.prototype.method = function() {};
function Bar() {}
Bar.prototype = Foo.prototype;
new Bar().method();
Bar 인스턴스에서 method를 호출하면 method에서 this는 바로 그 인스턴스를 가리킨다.
클로져(Closure)와 참조(Reference)
클로져는 JavaScript의 특장점 중 하나다. 클로저를 만들면 클로저 스코프 안에서 클로저를 만든 외부 스코프(Scope)에 항상 접근할 있다. JavaScript에서 스코프는 함수 스코프밖에 없기 때문에 기본적으로 모든 함수는 클로저가 될수있다.
private 변수 만들기
function Counter(start) {
var count = start;
return {
increment: function() {
count++;
},
get: function() {
return count;
}
}
}
var foo = Counter(4);
foo.increment();
foo.get(); // 5
여기서 Counter는 increment 클로저와 get 클로저 두 개를 반환한다. 이 두 클로저는 Counter 함수 스코프에 대한 참조를 유지하고 있기 때문에 이 함수 스코프에 있는 count 변수에 계속 접근할 수 있다.
Private 변수의 동작 원리
JavaScript에서는 스코프(Scope)를 어딘가에 할당해두거나 참조할수 없기 때문에 스코프 밖에서는 count 변수에 직접 접근할 수 없다. 접근할수 있는 유일한 방법은 스코프 안에 정의한 두 클로저를 이용하는 방법밖에 없다.
var foo = new Counter(4);
foo.hack = function() {
count = 1337;
};
위 코드에서 foo.hack 함수는 Counter 함수 안에서 정의되지 않았기 때문에 이 함수가 실행되더라도 Counter 함수 스코프 안에 있는 count 값은 변하지 않는다. 대신 foo.hack 함수의 count는 Global 스코프에 생성되거나 이미 만들어진 변수를 덮어쓴다.
반복문에서 클로저 사용하기
사람들이 반복문에서 클로저를 사용할 때 자주 실수를 하는 부분이 있는데 바로 인덱스 변수를 복사할때 발생한다.
for(var i = 0; i < 10; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
이 코드는 0부터 9까지의 수를 출력하지 않고 10만 열 번 출력한다.
타이머에 설정된 익명 함수는 변수 i에 대한 참조를 들고 있다가 console.log가 호출되는 시점에 i의 값을 사용한다. console.log가 호출되는 시점에서 for loop는 이미 끝난 상태기 때문에 i 값은 10이 된다.
for(var i = 0; i < 10; i++) {
(function(e) {
setTimeout(function() {
console.log(e);
}, 1000);
})(i);
}
이 익명 함수에 i를 인자로 넘기면 이 함수의 파라미터 e에 i의 값이 복사되어 넘어갈 것이다.
그리고 setTimeout는 익명 함수의 파라미터인 e에 대한 참조를 갖게 되고 e값은 복사되어 넘어왔으므로 loop의 상태에 따라 변하지 않는다.
또다른 방법으로 랩핑한 익명 함수에서 출력 함수를 반환하는 방법도 있다. 아래 코드는 위 코드와 동일하게 동작한다.
for(var i = 0; i < 10; i++) {
setTimeout((function(e) {
return function() {
console.log(e);
}
})(i), 1000)
}
즐겨 쓰이는 또 하나의 방법은 setTimeout 함수에 세번째 인자를 추가하는 방법이다.
추가된 인자는 콜백 함수에 전달된다.
for(var i = 0; i < 10; i++) {
setTimeout(function(e) {
console.log(e);
}, 1000, i);
}
레거시 JS 환경(Internet Explorer 9 이하)은 이 방법을 지원하지 않는다.
.bind를 사용하는 방법도 있다. .bind는 this 컨텍스트와 인자들을 함수에 결속(bind)시킨다.
아래 코드는 위 코드와 동일하게 동작한다.
for(var i = 0; i < 10; i++) {
setTimeout(console.log.bind(console, i), 1000);
}
arguments 객체
JavaScript의 모든 함수 스코프에는 arguments라는 특별한 변수가 있다. 이 변수는 함수에 넘겨진 모든 인자에 대한 정보가 담겨 있다.
arguments 객체는 Array가 아니다. 물론 length 프로퍼티도 있고 여러모로 Array와 비슷하게 생겼지만 Array.prototype을 상속받지는 않았다.
그래서 arguments에는 push, pop, slice 같은 표준 메소드가 없다. 일반 for문을 이용해 순회는 할수 있지만, Array의 메소드를 이용하려면 arguments를 Array로 변환해야 한다.
Array로 변환하기
다음 코드는 arguments에 있는 객체를 새로운 Array에 담아 반환한다.
Array.prototype.slice.call(arguments);
이 변환 과정은 느리기 때문에 성능이 중요한 부분에 사용하는 것은 별로 바람직하지 못 하다.
arguemnts 객체 넘기기
어떤 함수에서 다른 함수로 arguments 객체를 넘길 때에는 다음과 같은 방법을 권한다. (역주: foo 함수는 bar 함수 한번 랩핑한 함수다. )
function foo() {
bar.apply(null, arguments);
}
function bar(a, b, c) {
// 내곡동에 땅이라도 산다.
}
또 다른 트릭은 call과 apply를 함께 사용하여 메소드(this의 값과 인자들을 사용하는 함수)를
단지 인자들만 사용하는 일반 함수로 바꾸는 것이다.
function Person(first, last) {
this.first = first;
this.last = last;
}
Person.prototype.fullname = function(joiner, options) {
options = options || { order: "western" };
var first = options.order === "western" ? this.first : this.last;
var last = options.order === "western" ? this.last : this.first;
return first + (joiner || " ") + last;
};
// "fullname" 메소드의 비결합(unbound) 버전을 생성한다.
// 첫번째 인자로 'first'와 'last' 속성을 가지고 있는 어떤 객체도 사용 가능하다.
// "fullname"의 인자 개수나 순서가 변경되더라도 이 랩퍼를 변경할 필요는 없을 것이다.
Person.fullname = function() {
// 결과: Person.prototype.fullname.call(this, joiner, ..., argN);
return Function.call.apply(Person.prototype.fullname, arguments);
};
var grace = new Person("Grace", "Hopper");
// 'Grace Hopper'
grace.fullname();
// 'Turing, Alan'
Person.fullname({ first: "Alan", last: "Turing" }, ", ", { order: "eastern" });
일반 파라미터와 arguments 객체의 인덱스
일반 파라미터와 arguments 객체의 프로퍼티는 모두 getter와 setter를 가진다.
그래서 파라미터나 arguments 객체의 프로퍼티의 값을 바꾸면 둘 다 바뀐다.
function foo(a, b, c) {
arguments[0] = 2;
a; // 2
b = 4;
arguments[1]; // 4
var d = c;
d = 9;
c; // 3
}
foo(1, 2, 3);
성능에 대한 오해와 진실.
arguments 객체는 항상 만들어지지만 두가지 예외사항이 있다. arguments라는 이름으로 변수를 함수 안에 정의하거나 arguments 객체로 넘겨받는 인자중 하나라도 정식 인자로 받아서 사용하면 arguemnts 객체는 만들어지지 않는다. 하지만 뭐 이런 경우들은 어차피 arguments 객체를 안쓰겠다는 의미니까 상관 없다.
그리고 getter와 setter는 항상 생성되기 때문에 getter/setter를 사용하는 것은 성능에 별 영향을 끼치지 않는다. 예제처럼 단순한 코드가 아니라 arguments 객체를 다방면으로 활용하는 실제 코드에서도 마찬가지다.
그러나 예외도 있다. 최신 JavaScript 엔진에서 arguments.callee를 사용하면 성능이 확 떨어진다.
function foo() {
arguments.callee; // 이 함수를 가리킨다.
arguments.callee.caller; // 이 함수를 호출한 부모함수를 가리킨다.
}
function bigLoop() {
for(var i = 0; i < 100000; i++) {
foo(); // 원래 인라인 돼야 하는디...
}
}
위 코드에서 'foo' 함수는 자기 자신과 자신을 호출한 함수를 알아야 하기 때문에 더이상 인라인되지 않는다. 이렇게 쓰면 인라인이 주는 성능상 장점을 포기해야 하는데다가 이 함수가 호출되는 상황(calling context)에 의존하게 돼 버려서 캡슐화(Encapsulation)도 해친다.
(역주: 보통 코드가 컴파일 될때 코드를 인라인 시키면서 최적화 하는데, 위와 같이 arguments.callee나 caller를 사용하게 되면 런타임시에 해당 함수가 결정되므로 인라인 최적화를 할수가 없다.)
arguments.callee와 arguments.callee의 프로퍼티들은 절대 사용하지 말자!.
생성자
JavaScript의 생성자는 다른 언어들과 다르게 new 키워드로 호출되는 함수가 생성자가 된다.
생성자로 호출된 함수의 this 객체는 새로 생성된 객체를 가리키고, 새로 만든 객체의 prototype에는 생성자의 prototype이 할당된다.
그리고 생성자에 명시적인 return 구문이 없으면 this가 가리키는 객체를 반환한다.
function Person(name) {
this.name = name;
}
Person.prototype.logName = function() {
console.log(this.name);
};
var sean = new Person();
위 코드는 Person을 생성자로 호출하고 새로 생성된 객체의 prototype을 Person.prototype으로 설정한다.
아래 코드와 같이 생성자에 명시적인 return 문이 있는 경우에는 반환하는 값이 객체인 경우에만 그 값을 반환한다.
function Car() {
return 'ford';
}
new Car(); // 'ford'가 아닌 새로운 객체를 반환
function Person() {
this.someValue = 2;
return {
name: 'Charles'
};
}
new Person(); // someValue가 포함되지 않은 ({name:'Charles'}) 객체 반환
new 키워드가 없으면 그 함수는 객체를 반환하지 않는다.
function Pirate() {
this.hasEyePatch = true; // 전역 객체를 준비!
}
var somePirate = Pirate(); // somePirate = undefined
위 예제는 그때그때 다르게 동작한다. 그리고 this 객체의 동작 원리에 따라서 Foo 함수안의 this의 값은 Global 객체를 가리키게된다.
(역주: 결국 new 키워드를 빼고, 코드를 작성할 경우 원치 않은 this 참조 오류가 발생할 수 있다.)
팩토리
생성자가 객체를 반환하면 new 키워드를 생략할 수 있다.
function Robot() {
var color = 'gray';
return {
getColor: function() {
return color;
}
}
}
Robot.prototype = {
someFunction: function() {}
};
new Robot();
Robot();
new 키워드의 유무과 관계없이 Robot 생성자의 동작은 동일하다. 즉 클로저가 할당된 method 프로퍼티가 있는 새로운 객체를 만들어 반환한다.
new Robot()으로 호출되는 생성자는 반환되는 객체의 prototype 프로퍼티에 아무런 영향을 주지 않는다. 객체를 반환하지 않는 생성자로 만들어지는 경우에만 객체의 prototype이 생성자의 것으로 할당된다.
그러니까 이 예제에서 new 키워드의 유무는 아무런 차이가 없다.
(역주: 생성자에 객체를 만들어 명시적으로 반환하면 new 키워드에 관계없이 잘 동작하는 생성자를 만들수있다. 즉, new 키워드가 빠졌을때 발생하는 this 참조 오류를 방어해준다.)
팩토리로 객체 만들기
new 키워드를 빼먹었을 때 버그가 생긴다는 이유로 아예 new를 사용하지 말 것을 권하기도 한다.
객체를 만들고 반환해주는 팩토리를 사용하여 new 키워드 문제를 회피할 수 있다.
function CarFactory() {
var car = {};
car.owner = 'nobody';
var milesPerGallon = 2;
car.setOwner = function(newOwner) {
this.owner = newOwner;
}
car.getMPG = function() {
return milesPerGallon;
}
return car;
}
new 키워드가 없어도 잘 동작하고 private 변수를 사용하기도 쉽다. 그렇지만, 단점도 있다.
prototype으로 메소드를 공유하지 않으므로 메모리를 좀 더 사용한다.
팩토리를 상속하려면 모든 메소드를 복사하거나 객체의 prototype에 객체를 할당해 주어야 한다.
new 키워드를 누락시켜서 prototype chain을 끊어버리는 것은 아무래도 언어의 의도에 어긋난다.
결론
new 키워드가 생략되면 버그가 생길 수 있지만 그렇다고 prototype을 사용하지 않을 이유가 되지 않는다. 애플리케이션에 맞는 방법을 선택하는 것이 나을 거고 어떤 방법이든 *엄격하고 한결같이 지켜야 한다.
스코프와 네임스페이스
JavaScript는 '{}' Block이 배배 꼬여 있어도 문법적으로는 잘 처리하지만, Block Scope은 지원하지 않는다. 그래서 JavaScript에서는 항상 함수 스코프를 사용한다.
function test() { // Scope
for(var i = 0; i < 10; i++) { // Scope이 아님
// count
}
console.log(i); // 10
}
그리고 JavaScript에는 Namepspace 개념이 없기 때문에 모든 값이 하나의 전역 스코프에 정의된다.
변수를 참조 할 때마다 JavaScript는 해당 변수를 찾을 때까지 상위 방향으로 스코프를 탐색한다. 변수 탐색하다가 전역 스코프에서도 찾지 못하면 ReferenceError를 발생시킨다.
전역 변수 문제.
// script A
foo = '42';
// script B
var foo = '42'
이 두 스크립트는 전혀 다르다. Script A는 전역 스코프에 foo라는 변수를 정의하는 것이고 Script B는 현 스코프에 변수 foo를 정의하는 것이다.
다시 말하지만, 이 둘은 전혀 다르고 var가 없을 때 특별한 의미가 있다.
// Global Scope
var foo = 42;
function test() {
// local Scope
foo = 21;
}
test();
foo; // 21
test 함수 안에 있는 'foo' 변수에 var 구문을 빼버리면 Global Scope의 foo의 값을 바꿔버린다. '뭐 이게 뭐가 문제야'라고 생각될 수 있지만 수천 줄인 JavaScript 코드에서 var를 빼먹어서 생긴 버그를 해결하는 것은 정말 어렵다.
// Global Scope
var items = [/* some list */];
for(var i = 0; i < 10; i++) {
subLoop();
}
function subLoop() {
// Scope of subLoop
for(i = 0; i < 10; i++) { // var가 없다.
// 내가 for문도 해봐서 아는데...
}
}
subLoop 함수는 전역 변수 i의 값을 변경해버리기 때문에 외부에 있는 for문은 subLoop을 한번 호출하고 나면 종료된다. 두 번째 for문에 var를 사용하여 i를 정의하면 이 문제는 생기지 않는다. 즉, 의도적으로 외부 스코프의 변수를 사용하는 것이 아니라면 var를 꼭 넣어야 한다.
function test(i) {
// test 함수의 지역 공간
i = 5;
var foo = 3;
bar = 4;
}
test(10);
foo 변수와 i 변수는 test함수 스코프에 있는 지역 변수라서 전역 공간에 있는 foo, i 값은 바뀌지 않는다. 하지만 bar는 전역 변수이기 때문에 전역 공간에 있는 bar의 값이 변경된다.
호이스팅(Hoisting)
JavaScript는 선언문을 모두 호이스트(Hoist)한다. 호이스트란 var 구문이나 function 선언문을 해당 스코프의 맨 위로 옮기는 것을 말한다.
bar();
var bar = function() {};
var someValue = 42;
test();
function test(data) {
if (false) {
goo = 1;
} else {
var goo = 2;
}
for(var i = 0; i < 100; i++) {
var e = data[i];
}
}
코드를 본격적으로 실행하기 전에 JavaScript는 var 구문과 function 선언문을 해당 스코프의 맨위로 옮긴다.
// var 구문이 여기로 옮겨짐.
var bar, someValue; // default to 'undefined'
// function 선언문도 여기로 옮겨짐
function test(data) {
var goo, i, e; // Block Scope은 없으므로 local 변수들은 여기로 옮겨짐
if (false) {
goo = 1;
} else {
goo = 2;
}
for(i = 0; i < 100; i++) {
e = data[i];
}
}
bar(); // bar()가 아직 'undefined'이기 때문에 TypeError가 남
someValue = 42; // Hoisting은 할당문은 옮기지 않는다.
bar = function() {};
test();
블록 스코프(Block Scope)는 없으므로 for문과 if문 안에 있는 var 구문들까지도 모두 함수 스코프 앞쪽으로 옮겨진다. 그래서 if Block의 결과는 좀 이상해진다.
원래 코드에서 if Block은 전역 변수goo를 바꾸는 것처럼 보였지만 호이스팅(Hoisting) 후에는 지역 변수를 바꾼다.
호이스팅을 모르면 다음과 같은 코드는 ReferenceError를 낼 것으로 생각할 것이다.
// SomeImportantThing이 초기화됐는지 검사한다.
if (!SomeImportantThing) {
var SomeImportantThing = {};
}
var 구문은 전역 스코프의 맨위로 옮겨지기 때문에 이 코드는 잘 동작한다.
var SomeImportantThing;
// SomeImportantThing을 여기서 초기화하거나 말거나...
// SomeImportantThing는 선언돼 있다.
if (!SomeImportantThing) {
SomeImportantThing = {};
}
이름 찾는 순서
JavaScript의 모든 Scope은 현 객체를 가리키는 this를 가지고 있다. 전역 스코프에도 this가 있다.
함수 스코프에는 arguments라는 변수가 하나 더 있다. 이 변수는 함수에 인자로 넘겨진 값들이 담겨 있다.
예를 들어 함수 스코프에서 foo라는 변수에 접근할 때 JavaScript는 다음과 같은 순서로 찾는다.
해당 Scope에서 var foo 구문으로 선언된 것을 찾는다.
Function 파라미터에서 foo라는 것을 찾는다.
해당 Function 이름이 foo인지 찾는다.
상위 Scope으로 있는지 확인하고 있으면 #1부터 다시 한다.
네임스페이스
JavaScript에서는 전역 공간(Namepspace) 하나밖에 없어서 변수 이름이 중복되기 쉽다. 하지만 이름없는 랩퍼(Anonymous Wrappers)를 통해 쉽게 피해갈 수 있다.
(function() {
// 일종의 네임스페이스라고 할 수 있다.
window.foo = function() {
// 이 클로저는 전역 스코프에 노출된다.
};
})(); // 함수를 정의하자마자 실행한다.
위 결과가 훨씬 더 명확하고 문제가 쉽게 드러난다. 삼중 등호를 사용하면 코드를 좀 더 튼튼하게 만들수 있고, 비교하는 두 객체의 타입이 다르면 더 좋은 성능을 얻을 수도 있다.
객체 비교하기
이중 등호와(==)와 삼중 등호(===)는 둘 다 값을 비교하는 연산이지만 피연산자중에 Object 타입이 하나라도 있으면 다르게 동작한다.
{} === {}; // false
new String('foo') === 'foo'; // false
new Number(10) === 10; // false
var foo = {};
foo === foo; // true
두 연산자 모두 두 객체의 값이 같은지를 비교하지 않고, 두 객체가 같은 객체(identity)인지를 비교한다. C에서 포인터를 비교하거나 Python의 is처럼 같은 인스턴스인지 비교하는 것이다.
결론
삼중 등호 연산자를 사용할 것을 강력하게 권한다. 비교하기 위해서 타입 변환이 필요하면 언어의 복잡한 변환 규칙에 맡기지 말고 꼭 명시적으로 변환한 후에 비교해야 한다.
typeof 연산자
typeof 연산자도 instanceof 연산자와 함께 JavaScript에서 치명적으로 잘못 설계된 부분이다. 이건 정말이지 아무짝에도 쓸모가 없다.
instanceof 연산자는 그래도 여전히 쓸만한 데가 좀 있는데 typeof 연산자는 객체의 타입을 검사하는 것 외에는 쓸만한데가 없고, 이마저도 거의 쓸일이 없다.
JavaScript 타입 표
Value Class Type
-------------------------------------
"foo" String string
new String("foo") String object
1.2 Number number
new Number(1.2) Number object
true Boolean boolean
new Boolean(true) Boolean object
new Date() Date object
new Error() Error object
[1,2,3] Array object
new Array(1, 2, 3) Array object
new Function("") Function function
/abc/g RegExp object (function in Nitro/V8)
new RegExp("meow") RegExp object (function in Nitro/V8)
{} Object object
new Object() Object object
위 표에서 Type은 typeof가 반환하는 값이다. 위 표에서처럼 일치되는 값이 거의 없다.
위 표에서 Class는 객체 내부에 있는 [[Class]] 프로퍼티의 값을 말한다.
[[Class]] 프로퍼티의 값을 가져다 쓰려면 Object.prototype의 toString 메소드를 사용한다.
객체의 클래스
표준에 의하면 [[Class]] 값을 얻는 방법은 Object.prototype.toString 하나뿐이다.
var number = 1;
function test() {
var number = 2;
eval('number = 3');
return number;
}
test(); // 3
number; // 1
eval함수는 eval이라는 이름으로 직접 실행할 때에만 지역 스코프에서 실행된다. 그리고 eval이라는 이름에 걸맞게 악명또한 높다.
var number = 1;
function test() {
var number = 2;
var copyOfEval = eval;
copyOfEval('number = 3');
return number;
}
test(); // 2
number; // 3
어쨌든 eval은 사용하지 말아야 한다. eval을 사용하는 99.9%는 사실 eval 없이도 만들수있다.
가짜 eval
setTimeout과 setInterval은 첫 번째 인자로 스트링을 입력받을 수 있다. 이 경우에는 eval을 직접 호출하는 것이 아니라서 항상 Global Scope에서 실행된다.
보안 이슈
eval은 어떤 코드라도 무조건 실행하기 때문에 보안 문제도 있다. 따라서 신뢰하지 못하거나 모르는 코드가 포함되어 있을 경우 절대로 사용해서는 안된다.
결론
eval은 사용하지 않는 게 좋다. eval을 사용하는 모든 코드는 성능, 보안, 버그 문제를 일으킬 수 있다. 만약 eval이 필요해지면 설계를 변경하여 eval이 필요 없게 만들어야 한다.
undefined와 null
JavaScript는 nothing을 표현할때 null과 undefined 두 가지로 표현할 수 있고 그중 undefined가 더 유용하다.
undefined도 변수
undefined는 undefined라는 값을 가지는 데이터 형식이다.
undefined는 상수도 아니고 JavaScript의 키워드도 아니다. 그냥 undefined라는 이름의 Global 변수이고 이 변수에는 undefined라고 할당돼 있다. 그래서 이 Global 변수의 값을 쉽게 바꿀 수 있다.
undefined 값이 반환될 때:
global 변수 undefined에 접근할 때.
선언은 했지만 아직 초기화하지 않은 변수에 접근할 때.
return 구문이 없는 함수는 암묵적으로 undefined를 반환함.
return 구문으로 아무것도 반환하지 않을 때.
없는 프로퍼티를 찾을 때.
함수 인자가 생략될 때.
undefined가 할당된 모든 것.
void(expression) 형식으로 된 표현
undefined가 바뀔 때를 대비하기
global 변수 undefined는 undefined라는 객체를 가리키는 것뿐이기 때문에 새로운 값을 할당한다고 해도 undefined의 값 자체가 바뀌는 것이 아니다.
그래서 undefined와 비교하려면 먼저 undefined의 값을 찾아와야 한다.
undefined 변수가 바뀔 때를 대비해서 undefined라는 변수를 인자로 받는 anonymous wrapper로 감싸고 인자를 넘기지 않는 꼼수를 사용한다.
var undefined = 123;
(function(something, foo, undefined) {
// Local Scope에 undefined를 만들어서
// 원래 값을 가리키도록 했다.
})('Hello World', 42);
wrapper 안에 변수를 새로 정의하는 방법으로도 같은 효과를 볼 수 있다.
var undefined = 123;
(function(something, foo) {
var undefined;
...
})('Hello World', 42);
이 두 방법의 차이는 minified했을 때 4바이트만큼 차이 난다는 것과 한쪽은 wrapper 안에 var 구문이 없다는 것밖에 없다.
Null 객체의 용도
JavaScript 언어에서는 undefined를 다른 언어의 null 처럼 쓴다. 진짜 null은 그냥 데이터 타입 중 하나일 뿐이지 더도덜도 아니다.
JavaScript를 깊숙히 건드리는 것이 아니면 null 대신 undefined를 사용해도 된다(Foo.prototype = null같이 프로토타입 체인을 끊을 때는 null을 사용한다).
자동으로 삽입되는 쎄미콜론
JavaScript는 C와 문법이 비슷하지만, 꼭 코드에 쎄미콜론을 사용하도록 강제하지는 않는다. 그래서 생략할 수 있다.
사실 JavaScript는 쎄미콜론이 꼭 있어야 하고 없으면 이해하지 못한다. 그래서 JavaScript 파서는 쎄미콜론이 없으면 자동으로 쎄미콜론을 추가한다.
var foo = function() {
} // 쎄미콜론이 없으니 에러 난다.
test()
파서는 쎄미콜론을 삽입하고 다시 시도한다.
var foo = function() {
}; // 에러가 없어짐.
test()
쎄미콜론을 자동으로 삽입한 것이 대표적인 JavaScript 설계 오류다. 쎄미콜론 유무에 따라 전혀 다른 코드가 될 수 있다.
어떻게 다를까?
코드에 쎄미콜론이 없으면 파서가 어디에 넣을지 결정한다.
(function(window, undefined) {
function test(options) {
log('testing!')
(options.list || []).forEach(function(i) {
})
options.value.test(
'long string to pass here',
'and another long string to pass'
)
return
{
foo: function() {}
}
}
window.test = test
})(window)
(function(window) {
window.someLibrary = {}
})(window)
파서는 이 코드에 쎄미콜론을 다음과 같이 삽입한다.
(function(window, undefined) {
function test(options) {
// 쎄미콜론을 넣는 것이 아니라 줄을 합친다.
log('testing!')(options.list || []).forEach(function(i) {
}); // <- 여기
options.value.test(
'long string to pass here',
'and another long string to pass'
); // <- 여기
return; // <- 여기에 넣어서 그냥 반환시킨다.
{ // 파서는 단순 블럭이라고 생각하고
// 단순한 레이블과 함수
foo: function() {}
}; // <- 여기
}
window.test = test; // <- 여기
// 이 줄도 합쳐진다.
})(window)(function(window) {
window.someLibrary = {}; // <- 여기
})(window); //<- 여기에 파서는 쎄미콜론을 넣는다.
이렇게 한줄로 바뀌면 log 함수가 함수를 반환할 가능성이 거의 없으므로 undefined is not a function이라는 TypeError가 발생한다.
결론
쎄미콜론은 반드시 사용해야 한다. 그리고 {}도 생략하지 않고 꼭 사용하는 것이 좋다. 한 줄밖에 안 되는 if / else 블럭에서도 꼭 사용해야 한다. 이 두 가지 규칙을 잘 지키면 JavaScript 파서가 잘못 해석하는 일을 미리 방지하고 코드도 튼튼해진다.
delete 연산자
간단히 말해서 전역 변수와 전역 함수 그리고 DontDelete 속성을 가진 자바스크립트 객체는 삭제할 수 없다.
Global 코드와 Function 코드
전역이나 함수 스코프에 정의한 함수나 변수는 모두 Activation 객체나 전역 객체의 프로퍼티다. 이 프로퍼티는 모두 DontDelete 속성을 가진다. 전역이나 함수 코드에 정의한 변수와 함수는 항상 DontDelete 프로퍼티로 만들어지기 때문에 삭제될 수 없다:
// Global 변수:
var a = 1; // DontDelete가 설정된다.
delete a; // false
a; // 1
// Function:
function f() {} // DontDelete가 설정된다.
delete f; // false
typeof f; // "function"
// 다시 할당해도 삭제할 수 없다:
f = 1;
delete f; // false
f; // 1
명시적인(Explicit) 프로퍼티
다음 예제에서 만드는 프로퍼티는 delete할 수 있다. 이런 걸 명시적인(Explicit) 프로퍼티라고 부른다:
obj.x와 obj.y는 DontDelete 속성이 아니라서 delete할 수 있다. 하지만 다음과 같은 코드도 잘 동작하기 때문에 헷갈린다:
// IE를 빼고 잘 동작한다:
var GLOBAL_OBJECT = this;
GLOBAL_OBJECT.a = 1;
a === GLOBAL_OBJECT.a; // true - 진짜 Global 변수인지 확인하는 것
delete GLOBAL_OBJECT.a; // true
GLOBAL_OBJECT.a; // undefined
this가 전역 객체를 가리키는 것을 이용해서 명시적으로 프로퍼티 a를 선언하면 삭제할 수 있다. 이것은 꼼수다.