JavaScript 언어의 핵심에 대한 내용을 모아 JavaScript Garden을 만들어 었다. 이 글이 초보자가 JavaScript 익히면서 자주 겪는 실수, 미묘한 버그, 성능 이슈, 나쁜 습관들 줄일 수 있도록 도와줄 것이다.
JavaScript Garden은 단순히 JavaScript 언어 자체를 설명하려 만들지 않았다. 그래서 이 글에서 설명하는 주제들을 이해하려면 반드시 언어에 대한 기본 지식이 필요하다. 먼저 Mozilla Developer Network에 있는문서로 JavaScript 언어를 공부하기 바란다.
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만큼 널리 사용된 언어가 없었기 때문에 두 모델의 차이점이 다소 늦게 정리된 감이 있다.
// Bar 함수를 생성자로 만들고 Bar.prototype.constructor =Bar;
var test =newBar()// bar 인스턴스를 만든다.
// 결과적으로 만들어진 프로토타입 체인은 다음과 같다. test [instance of Bar] Bar.prototype [instance of Foo] { foo:'Hello World'} 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 값을 할당되면 무시한다.
functionFoo(){} Foo.prototype =1;// 무시됨
반면에 위 예제처럼 객체를 할당하면 프로토타입 체인이 동적으로 잘 만들어진다.
성능
프로토타입 체인을 탐색하는 시간이 오래걸릴수록 성능에 부정적인 영향을 줄수있다. 특히 성능이 중요한 코드에서 프로퍼티 탐색시간은 치명적인 문제가 될수있다. 가령, 없는 프로퍼티에 접근하려고 하면 항상 프로토타입 체인 전체를 탐색하게 된다.
뿐만아니라 객체를순회(Iterate)할때도 프로토타입 체인에 있는 모든 프로퍼티를 탐색하게 된다.
네이티브 프로토타입의 확장
종종Object.prototype을 이용해 내장 객체를 확장하는 경우가 있는데, 이것도 역시 잘못 설계된 것중에 하나다.
위와 같이 확장하는 것을Monkey Patching라고 부르는데 캡슐화를 망친다. 물론Prototype같은 유명한 프레임워크들도 이런 확장을 사용하지만, 기본 타입에 표준도 아닌 기능들을 너저분하게 추가하는 이유를 여전히 설명하지 못하고 있다.
기본 타입을 확장해야하는 유일한 이유는Array.forEach같이 새로운 JavaScript 엔진에 추가된 기능을 대비해 미리 만들어 놓는 경우 말고는 없다.
결론
프로토타입을 이용해 복잡한 코드를 작성하기 전에 반드시 프로토타입 상속 (Prototypal Inheritance) 모델을 완벽하게 이해하고 있어야 한다. 뿐만아니라 프로토타입 체인과 관련된 성능 문제로 고생하지 않으려면 프로토타입 체인이 너무 길지 않도록 항상 주의하고 적당히 끊어줘야 한다. 마지막으로 새로운 JavaScript 기능에 대한 호환성 유지 목적이 아니라면 절대로 네이티브 프로토타입을 확장하지마라.
hasOwnProperty
어떤 객체의 프로퍼티가 자기 자신의 프로퍼티인지 아니면 프로토타입 체인에 있는 것인지 확인하려면hasOwnProperty메소드를 사용한다. 그리고 이 메소드는Object.prototype으로 부터 상속받아 모든 객체가 가지고 있다.
hasOwnProperty메소드는 프로토타입 체인을 탐색하지 않고, 프로퍼티를 다룰수있는 유일한 방법이다.
// Object.prototype을 오염시킨다. Object.prototype.bar =1; var foo ={goo:undefined};
hasOwnProperty메소드는 어떤 프로퍼티가 자기 자신의 프로퍼티인지 아닌지 정확하게 알려주기 때문에 객체의 프로퍼티를 순회할때 꼭 필요하다. 그리고 프로토타입 체인 어딘가에 정의된 프로퍼티만을 제외하는 방법은 없다.
hasOwnProperty메소드도 프로퍼티다
JavaScript는hasOwnProperty라는 이름으로 프로퍼티를 덮어 쓸수도 있다. 그래서 객체 안에 같은 이름으로 정의된hasOwnProperty가 있을 경우, 본래hasOwnProperty의 값을 정확하게 얻고 싶다면 다른 객체의hasOwnProperty메소드를 빌려써야 한다.
var foo ={ hasOwnProperty:function(){ returnfalse; }, 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와 함께 사용하길 권한다.
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가 가리켜야 할 객체를 넘겨준다.
그래서fooFunction 안에서this는 위에서 설명했던 객체 중 하나를 가리키는 것이 아니라 bar를 가리킨다.
대표적인 함정
this가 Global 객체를 가리키는 것도 잘못 설계된 부분 중 하나다. 괜찮아 보이지만 실제로는 전혀 사용하지 않는다.
Foo.method =function(){ function test(){ // 여기에서 this는 Global 객체를 가리킨다. } test(); }
test에서this가Foo를 가리킬 것으로 생각할 테지만 틀렸다. 실제로는 그렇지 않다.
test에서Foo에 접근하려면 method에 Local 변수를 하나 만들고Foo를 가리키게 하여야 한다.
Foo.method =function(){ varself=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가 아니다.)
Bar인스턴스에서method를 호출하면method에서this는 바로 그 인스턴스를 가리킨다.
클로져(Closure)와 참조(Reference)
클로져는 JavaScript의 특장점 중 하나다. 클로저를 만들면 클로저 스코프 안에서 클로저를 만든 외부 스코프(Scope)에 항상 접근할 있다. JavaScript에서 스코프는함수 스코프밖에 없기 때문에 기본적으로 모든 함수는 클로저가 될수있다.
private 변수 만들기
functionCounter(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 =newCounter(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이 된다.
Person.prototype.fullname =function(joiner, options){ options = options ||{ order:"western"}; var first = options.order ==="western"?this.first :this.last; varlast= 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); returnFunction.call.apply(Person.prototype.fullname, 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이 할당된다.
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를 꼭 넣어야 한다.
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를 선언하면 삭제할 수 있다. 이것은 꼼수다.
댓글 영역