JavaScript 프로토타입 체인, 범위 체인 및 성능: 알아야 할 사항

게시 됨: 2022-03-11

JavaScript: 눈에 보이는 것 이상

JavaScript는 처음에는 배우기 매우 쉬운 언어처럼 보일 수 있습니다. 아마도 유연한 구문 때문일 것입니다. 또는 Java와 같이 잘 알려진 다른 언어와의 유사성 때문일 수도 있습니다. 또는 Java, Ruby 또는 .NET과 같은 언어에 비해 데이터 유형이 너무 적기 때문일 수 있습니다.

그러나 실제로 JavaScript는 대부분의 개발자가 처음에 인식하는 것보다 훨씬 덜 단순하고 미묘한 차이가 있습니다. 경험이 더 많은 개발자라도 JavaScript의 가장 두드러진 기능 중 일부는 계속해서 오해를 받고 혼란을 야기합니다. 그러한 기능 중 하나는 데이터(속성 및 변수) 조회가 수행되는 방식과 인식해야 할 JavaScript 성능 파급 효과입니다.

JavaScript에서 데이터 조회는 프로토타입 상속범위 체인 이라는 두 가지에 의해 관리됩니다. 개발자로서 이 두 가지 메커니즘을 명확하게 이해하는 것이 필수적입니다. 그렇게 하면 코드의 구조와 성능을 향상시킬 수 있기 때문입니다.

프로토타입 체인을 통한 속성 조회

JavaScript와 같은 프로토타입 기반 언어에서 속성에 액세스할 때 개체의 프로토타입 트리 내에서 다른 레이어를 포함하는 동적 조회가 발생합니다.

JavaScript에서 모든 함수는 객체입니다. new 연산자로 함수를 호출하면 새 객체가 생성됩니다. 예를 들어:

 function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; } var p1 = new Person('John', 'Doe'); var p2 = new Person('Robert', 'Doe');

위의 예에서 p1p2 는 각각 Person 함수를 생성자로 사용하여 생성된 두 개의 다른 객체입니다. 다음 코드 스니펫에서 볼 수 있듯이 Person 의 독립적인 인스턴스입니다.

 console.log(p1 instanceof Person); // prints 'true' console.log(p2 instanceof Person); // prints 'true' console.log(p1 === p2); // prints 'false'

JavaScript 함수는 객체이기 때문에 속성을 가질 수 있습니다. 각 함수가 가지고 있는 특히 중요한 속성을 prototype 이라고 합니다.

그 자체가 객체인 prototype 타입은 부모의 프로토타입에서 상속하고 부모의 프로토타입에서 상속하는 식입니다. 이것은 종종 프로토타입 체인 이라고 합니다. 항상 프로토타입 체인의 끝(즉, 프로토타입 상속 트리의 맨 위에 있음)에 있는 Object.prototype 에는 toString() , hasProperty() , isPrototypeOf() 등과 같은 메서드가 포함되어 있습니다.

JavaScript 프로토타입과 범위 체인 간의 관계가 중요합니다.

각 함수의 프로토타입을 확장하여 고유한 사용자 정의 메서드와 속성을 정의할 수 있습니다.

new 연산자를 사용하여 함수를 호출하여 개체를 인스턴스화하면 해당 함수의 프로토타입에 있는 모든 속성이 상속됩니다. 그러나 이러한 인스턴스는 prototype 개체에 직접 액세스할 수 없고 해당 속성에만 액세스할 수 있다는 점에 유의하십시오. 예를 들어:

 // Extending the Person prototype from our earlier example to // also include a 'getFullName' method: Person.prototype.getFullName = function() { return this.firstName + ' ' + this.lastName; } // Referencing the p1 object from our earlier example console.log(p1.getFullName()); // prints 'John Doe' // but p1 can't directly access the 'prototype' object... console.log(p1.prototype); // prints 'undefined' console.log(p1.prototype.getFullName()); // generates an error

여기에 중요하고 다소 미묘한 점이 있습니다. getFullName 메서드가 정의되기 전에 p1 이 생성된 경우에도 프로토타입이 Person 프로토타입이기 때문에 여전히 액세스할 수 있습니다.

(브라우저는 __proto__ 속성에 모든 객체의 프로토타입에 대한 참조도 저장한다는 점에 주목할 가치가 있지만 __proto__ 속성을 통해 프로토타입에 직접 액세스하는 것은 표준 ECMAScript 언어 사양의 일부가 아니기 때문에 정말 나쁜 습관 입니다. 하지마! )

Person 객체의 p1 인스턴스 자체는 prototype 객체에 직접 액세스할 수 없기 때문에 p1 에서 getFullName 을 덮어쓰려면 다음과 같이 하면 됩니다.

 // We reference p1.getFullName, *NOT* p1.prototype.getFullName, // since p1.prototype does not exist: p1.getFullName = function(){ return 'I am anonymous'; }

이제 p1 에는 고유한 getFullName 속성이 있습니다. 그러나 p2 인스턴스(이전 예에서 생성됨)에는 자체 속성이 없습니다 . 따라서 p1.getFullName() 을 호출하면 p1 인스턴스 자체의 getFullName 메서드에 액세스하는 반면, p2.getFullName() 을 호출하면 프로토타입 체인을 따라 Person 프로토타입 객체로 이동하여 getFullName 을 확인합니다.

 console.log(p1.getFullName()); // prints 'I am anonymous' console.log(p2.getFullName()); // prints 'Robert Doe' 

이 JavaScript 프로토타입 예제에서 P1 및 P2가 Person 프로토타입과 어떻게 관련되는지 확인하십시오.

알아야 할 또 다른 중요한 사실은 객체의 프로토타입을 동적으로 변경할 수도 있다는 것입니다. 예를 들어:

 function Parent() { this.someVar = 'someValue'; }; // extend Parent's prototype to define a 'sayHello' method Parent.prototype.sayHello = function(){ console.log('Hello'); }; function Child(){ // this makes sure that the parent's constructor is called and that // any state is initialized correctly. Parent.call(this); }; // extend Child's prototype to define an 'otherVar' property... Child.prototype.otherVar = 'otherValue'; // ... but then set the Child's prototype to the Parent prototype // (whose prototype doesn't have any 'otherVar' property defined, // so the Child prototype no longer has 'otherVar' defined!) Child.prototype = Object.create(Parent.prototype); var child = new Child(); child.sayHello(); // prints 'Hello' console.log(child.someVar); // prints 'someValue' console.log(child.otherVar); // prints 'undefined'

프로토타입 상속을 사용할 때 부모 클래스에서 상속하거나 대체 프로토타입을 지정한 프로토타입에서 속성을 정의해야 합니다.

이 다이어그램은 프로토타입 체인에서 JavaScript 프로토타입 간의 관계에 대한 예를 보여줍니다.

요약하자면 JavaScript 프로토타입 체인을 통한 속성 조회는 다음과 같이 작동합니다.

  • 개체에 지정된 이름의 속성이 있으면 해당 값이 반환됩니다. ( hasOwnProperty 메서드를 사용하여 개체에 특정 명명된 속성이 있는지 확인할 수 있습니다.)
  • 객체에 명명된 속성이 없으면 객체의 프로토타입이 확인됩니다.
  • 프로토타입도 객체이기 때문에 속성도 포함하지 않으면 부모의 프로토타입이 확인됩니다.
  • 이 프로세스는 속성을 찾을 때까지 프로토타입 체인을 계속 진행합니다.
  • Object.prototype 에 도달하고 속성도 갖지 않으면 속성이 undefined 것으로 간주됩니다.

프로토타입 상속 및 속성 조회가 작동하는 방식을 이해하는 것은 일반적으로 개발자에게 중요하지만 (때로는 상당한) JavaScript 성능 영향 때문에 필수적입니다. V8(Google의 오픈 소스, 고성능 JavaScript 엔진)에 대한 문서에서 언급했듯이 대부분의 JavaScript 엔진은 사전과 같은 데이터 구조를 사용하여 객체 속성을 저장합니다. 따라서 각 속성 액세스에는 속성을 해결하기 위해 해당 데이터 구조에서 동적 조회가 필요합니다. 이 접근 방식을 사용하면 일반적으로 Java 및 Smalltalk와 같은 프로그래밍 언어의 인스턴스 변수에 액세스하는 것보다 JavaScript의 속성에 액세스하는 속도가 훨씬 느립니다.

범위 체인을 통한 변수 조회

JavaScript의 또 다른 조회 메커니즘은 범위를 기반으로 합니다.

이것이 어떻게 작동하는지 이해하려면 실행 컨텍스트의 개념을 도입할 필요가 있습니다.

JavaScript에는 두 가지 유형의 실행 컨텍스트가 있습니다.

  • JavaScript 프로세스가 시작될 때 생성되는 전역 컨텍스트
  • 함수가 호출될 때 생성되는 로컬 컨텍스트

실행 컨텍스트는 스택으로 구성됩니다. 스택의 맨 아래에는 항상 각 JavaScript 프로그램에 대해 고유한 전역 컨텍스트가 있습니다. 함수가 발생할 때마다 새로운 실행 컨텍스트가 생성되어 스택의 맨 위에 푸시됩니다. 함수 실행이 완료되면 해당 컨텍스트가 스택에서 꺼집니다.

다음 코드를 고려하십시오.

 // global context var message = 'Hello World'; var sayHello = function(n){ // local context 1 created and pushed onto context stack var i = 0; var innerSayHello = function() { // local context 2 created and pushed onto context stack console.log((i + 1) + ': ' + message); // local context 2 popped off of context stack } for (i = 0; i < n; i++) { innerSayHello(); } // local context 1 popped off of context stack }; sayHello(3); // Prints: // 1: Hello World // 2: Hello World // 3: Hello World

각 실행 컨텍스트 내에는 변수를 해결하는 데 사용되는 범위 체인 이라는 특수 개체가 있습니다. 범위 체인은 기본적으로 가장 직접적인 컨텍스트에서 전역 컨텍스트에 이르기까지 현재 액세스 가능한 범위의 스택입니다. (좀 더 정확하게 말하면, 스택 맨 위에 있는 객체는 실행 중인 함수에 대한 로컬 변수에 대한 참조, 명명된 함수 인수 및 두 개의 "특수" 객체인 thisarguments 를 포함하는 활성화 객체 라고 합니다. ) 예를 들어:

범위 체인이 객체와 관련된 방식은 이 JavaScript 예제에 설명되어 있습니다.

위의 다이어그램에서 this 어떻게 기본적으로 window 개체를 가리키는지 그리고 전역 컨텍스트가 consolelocation 와 같은 다른 개체의 예를 포함하는 방법에 주목하십시오.

범위 체인을 통해 변수를 해결하려고 할 때 일치하는 변수가 있는지 직접 컨텍스트에서 먼저 확인합니다. 일치하는 항목이 없으면 범위 체인의 다음 컨텍스트 개체를 확인하는 식으로 일치 항목이 발견될 때까지 계속됩니다. 일치하는 항목이 없으면 ReferenceError 가 발생합니다.

try-catch 블록 또는 with 블록이 발생하면 범위 체인에 새 범위가 추가된다는 점도 중요합니다. 이 두 경우 모두 새 객체가 생성되어 범위 체인의 맨 위에 배치됩니다.

 function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; }; function persist(person) { with (person) { // The 'person' object was pushed onto the scope chain when we // entered this "with" block, so we can simply reference // 'firstName' and 'lastName', rather than person.firstName and // person.lastName if (!firstName) { throw new Error('FirstName is mandatory'); } if (!lastName) { throw new Error('LastName is mandatory'); } } try { person.save(); } catch(error) { // A new scope containing the 'error' object is accessible here console.log('Impossible to store ' + person + ', Reason: ' + error); } } var p1 = new Person('John', 'Doe'); persist(p1);

범위 기반 변수 조회가 발생하는 방식을 완전히 이해하려면 JavaScript에는 현재 블록 수준 범위가 없다는 점을 기억하는 것이 중요합니다. 예를 들어:

 for (var i = 0; i < 10; i++) { /* ... */ } // 'i' is still in scope! console.log(i); // prints '10'

대부분의 다른 언어에서 위의 코드는 변수 i 의 "수명"(즉, 범위)이 for 블록으로 제한되기 때문에 오류를 발생시킵니다. 그러나 JavaScript에서는 그렇지 않습니다. 오히려 i 는 범위 체인의 맨 위에 있는 활성화 개체에 추가되고 해당 개체가 범위에서 제거될 때까지 거기에 유지됩니다. 이는 해당 실행 컨텍스트가 스택에서 제거될 때 발생합니다. 이 동작을 가변 호이스팅이라고 합니다.

그러나 블록 수준 범위에 대한 지원이 새로운 let 키워드를 통해 JavaScript에 적용되고 있다는 점은 주목할 가치가 있습니다. let 키워드는 이미 JavaScript 1.7에서 사용할 수 있으며 ECMAScript 6부터 공식적으로 지원되는 JavaScript 키워드가 될 예정입니다.

JavaScript 성능 영향

속성 및 변수 조회가 각각 프로토타입 체인과 범위 체인을 사용하여 JavaScript에서 작동하는 방식은 언어의 주요 기능 중 하나이지만 이해하기 가장 까다롭고 가장 미묘합니다.

프로토타입 체인을 기반으로 하든 범위 체인을 기반으로 하든 이 예제에서 설명한 조회 작업은 속성이나 변수에 액세스할 때마다 반복됩니다. 이 조회가 루프 또는 기타 집중적인 작업 내에서 발생하면 특히 여러 작업이 동시에 발생하는 것을 방지하는 단일 스레드 언어의 특성에 비추어 볼 때 상당한 JavaScript 성능 파급 효과가 있을 수 있습니다.

다음 예를 고려하십시오.

 var start = new Date().getTime(); function Parent() { this.delta = 10; }; function ChildA(){}; ChildA.prototype = new Parent(); function ChildB(){} ChildB.prototype = new ChildA(); function ChildC(){} ChildC.prototype = new ChildB(); function ChildD(){}; ChildD.prototype = new ChildC(); function ChildE(){}; ChildE.prototype = new ChildD(); function nestedFn() { var child = new ChildE(); var counter = 0; for(var i = 0; i < 1000; i++) { for(var j = 0; j < 1000; j++) { for(var k = 0; k < 1000; k++) { counter += child.delta; } } } console.log('Final result: ' + counter); } nestedFn(); var end = new Date().getTime(); var diff = end - start; console.log('Total time: ' + diff + ' milliseconds');

이 예에는 긴 상속 트리와 세 개의 중첩 루프가 있습니다. 가장 깊은 루프 내에서 카운터 변수는 delta 값으로 증가합니다. 그러나 delta 는 상속 트리의 거의 맨 위에 있습니다! 이는 child.delta 에 액세스할 때마다 전체 트리를 아래에서 위로 탐색해야 함을 의미합니다. 이것은 성능에 정말 부정적인 영향을 미칠 수 있습니다.

이것을 이해하면 다음과 같이 로컬 delta 변수를 사용하여 child.delta 에 값을 캐시함으로써 위의 nestedFn 함수의 성능을 쉽게 향상시킬 수 있습니다.

 function nestedFn() { var child = new ChildE(); var counter = 0; var delta = child.delta; // cache child.delta value in current scope for(var i = 0; i < 1000; i++) { for(var j = 0; j < 1000; j++) { for(var k = 0; k < 1000; k++) { counter += delta; // no inheritance tree traversal needed! } } } console.log('Final result: ' + counter); } nestedFn(); var end = new Date().getTime(); var diff = end - start; console.log('Total time: ' + diff + ' milliseconds');

물론 이 특정 기술은 for 루프가 실행되는 동안 child.delta 값이 변경되지 않는 것으로 알려진 시나리오에서만 실행 가능합니다. 그렇지 않으면 로컬 복사본을 현재 값으로 업데이트해야 합니다.

네, nestedFn 메소드의 두 버전을 모두 실행하고 두 버전 사이에 눈에 띄는 성능 차이가 있는지 확인하겠습니다.

node.js REPL에서 첫 번째 예제를 실행하여 시작하겠습니다.

 diego@alkadia:~$ node test.js Final result: 10000000000 Total time: 8270 milliseconds

따라서 실행하는 데 약 8초가 걸립니다. 오랜만이다.

이제 최적화된 버전을 실행할 때 어떤 일이 발생하는지 봅시다.

 diego@alkadia:~$ node test2.js Final result: 10000000000 Total time: 1143 milliseconds

이번에는 1초밖에 걸리지 않았다. 훨씬 더 빨리!

값비싼 조회를 피하기 위해 지역 변수를 사용하는 것은 속성 조회(프로토타입 체인을 통해)와 변수 조회(스코프 체인을 통해) 모두에 적용될 수 있는 기술입니다.

더욱이, 이러한 유형의 값 "캐싱"(즉, 로컬 범위의 변수)은 가장 일반적인 JavaScript 라이브러리를 사용할 때도 유용할 수 있습니다. 예를 들어 jQuery를 보자. jQuery는 기본적으로 DOM에서 하나 이상의 일치하는 요소를 검색하기 위한 메커니즘인 "선택자"의 개념을 지원합니다. jQuery에서 선택기를 쉽게 지정할 수 있기 때문에 각 선택기 조회가 얼마나 비용이 많이 드는지(성능 관점에서) 잊어버릴 수 있습니다. 따라서 선택기 조회 결과를 로컬 변수에 저장하면 성능에 매우 유리할 수 있습니다. 예를 들어:

 // this does the DOM search for $('.container') "n" times for (var i = 0; i < n; i++) { $('.container').append(“Line “+i+”<br />”); } // this accomplishes the same thing... // but only does the DOM search for $('.container') once, // although it does still modify the DOM "n" times var $container = $('.container'); for (var i = 0; i < n; i++) { $container.append("Line "+i+"<br />"); } // or even better yet... // this version only does the DOM search for $('.container') once // AND only modifies the DOM once var $html = ''; for (var i = 0; i < n; i++) { $html += 'Line ' + i + '<br />'; } $('.container').append($html);

특히 많은 수의 요소가 있는 웹 페이지에서 위 코드 샘플의 두 번째 접근 방식은 잠재적으로 첫 번째보다 훨씬 더 나은 성능을 초래할 수 있습니다.

마무리

JavaScript의 데이터 조회는 대부분의 다른 언어와 상당히 다르며 미묘한 차이가 있습니다. 따라서 언어를 진정으로 마스터하기 위해서는 이러한 개념을 완전하고 적절하게 이해하는 것이 필수적입니다. 데이터 조회 및 기타 일반적인 JavaScript 실수는 가능한 한 피해야 합니다. 이러한 이해는 개선된 JavaScript 성능을 달성하는 보다 깨끗하고 강력한 코드를 생성할 가능성이 높습니다.

관련: JS 개발자로서 이것이 나를 밤에 깨우게 하는 것입니다 / ES6 클래스 혼란에 대한 이해