- Published on
자바스크립트 프로토타입 - 암기가 아닌 이해하기 2편(完)
이번 포스팅은 자바스크립트는 왜 프로토타입을 선택했을까를 읽고,
관련 내용을 쉽게 이해하고, 정리하기 위한 글입니다.
1편
👉프로토타입 코드로 살펴보기
프로토타입의 동적인 유연함을 강조하는 이론을 알아봤으니, 이번에는 코드와 함께 프로토타입의 유연성을 살펴보기 위해 라면에서 인스턴트로 프로토타입을 변경해 보자.
function 라면(name) {
this.name = name;
}
const 신라면 = new 라면("신라면");
const 오징어_짬뽕 = new 라면("오징어 짬뽕");
const 짜파구리 = new 라면("짜파구리");
// 라면 -> 인스턴트 변경
function 인스턴트(name) {
this.name = name;
}
// 라면 생성자의 프로토타입을 인스턴트 프로토타입으로 변경
Object.setPrototypeOf(라면.prototype, 인스턴트.prototype);
인스턴트.prototype.추가된_자기소개 = function () {
console.log(`나는 맛있는 ${this.name}`);
};
신라면.추가된_자기소개(); // "나는 맛있는 신라면"
오징어_짬뽕.추가된_자기소개(); // "나는 맛있는 오징어 짬뽕"
짜파구리.추가된_자기소개(); // "나는 맛있는 짜파구리"
만약 클래스 기반에서 라면을 인스턴스로 변경했을 경우 인스턴스를 재정의 해야 하는 소요가 발생한다. 하지만, 자바스크립트 프로토타입에서는 메서드를 추가한 뒤에 신라면, 오징어 짬뽕, 짜파구리의 상위 프로토타입을 라면에서 인스턴트로 변경하는 동작만으로 인스턴스를 재정의 하지 않고 변경사항을 구현할 수 있다.
내부적으로는 new
키워드를 통해 생성된 신라면 함수 내부에는 자동으로 __proto__
프로퍼티가 생성된다. 그리고 생성자 함수의 프로퍼티인 prototype
을 복사하게 되고 링크가 연결된다.
그리고 라면에서 인스턴트로 변경됐을 때 모든 라면 인스턴스(신라면, 오징어 짬뽕, 짜파구리)의 __proto__
의 체인 상에서 인스턴트.prototype 프로퍼티
를 가리키게 변경된다.
이렇듯, 내부적으로 마치 연결된 체인이 상황에 따라 연결된 상태를 변경하는 모습을 보이게 된다.
상위 영역 프로토타입이 라면에서 인스턴트로 변경됐지만, 신라면(인스턴스)의 입장에서 이 변경은 라면과의 체인 연결을 끊고 인스턴트와 체인을 연결하기만 하면 된다. 이러한 변경은 자바스크립트의 객체가 프로토타입을 통해 상속 구조를 가지면서도 유연하게 변할 수 있음을 잘 보여준다.
자바스크립트에서 객체의 프로토타입 체인은 상황에 따라 동적으로 상속 체계를 조정할 수 있기 때문에, 개발자는 필요에 따라 객체의 특성을 추가하거나 변경할 수 있는 강력한 유연성을 갖게 된다.
prototype = ( 명사 ) 원형
각 객체들은 상위의 프로토타입과 연결되고, 또 상위의 프로토타입과 연결될 수 있다. 이렇게 체인이 상위로 연결되다 보면, 프로토타입은 그 의미에 걸맞게 여러 계층을 가지는 원형 구조가 된다.
이 원형 구조는 객체 간의 속성과 메서드를 상속할 수 있는 강력한 메커니즘으로 작용하며, 모든 객체가 서로 연결된 네트워크 형태를 이루게 된다.
이러한 계층적인 원형 구조와 그 속에 있는 각각의 원(객체) 간의 관계를 살펴보면, 자바스크립트에서 자주 언급되는 실행 컨텍스트, 렉시컬 스코프, 호이스팅, this 동작 원리를 이해하는데 큰 도움이 된다.
실행 컨텍스트, 렉시컬(정적) 스코프와 프로토타입
실행 컨텍스트는 함수가 호출될 때마다 생성되며, 해당 코드를 실행하는 데 필요한 모든 정보가 저장된다. 이때 객체가 어떤 속성이나 메서드를 찾을 때, 자바스크립트 엔진은 해당 객체의 스코프 영역 내에서 먼저 찾고, 만약 없다면 프로토타입 체인을 따라 상위 객체에서 찾게 된다.
그리고 실행 컨텍스트 내의 렉시컬(정적) 스코프는 실행 컨텍스트가 생성될 때, 내부에 선언된 변수와 함수를 실행 컨텍스트 최상단으로 호이스팅하게 되는데, 함수가 호출된 시점이 아닌 함수가 정의된 시점에서의 상위 스코프를 기준으로 변수 접근 범위를 결정하고 스코프를 고정한다.
반면, 프로토타입 체인은 실행 시점에서 동적으로 상위 객체를 찾아가며 동작하게 된다. 그렇다면 왜 렉시컬 스코프는 프로토타입처럼 동적으로 변경되지 않고 정적으로 고정될까?
만약 렉시컬 스코프가 동적으로 결정된다고 가정해보자.
let greeting = "Hello, World!";
function sayHello() {
console.log(greeting);
}
function setNewGreeting() {
let greeting = "Hello, JavaScript!"; // 새로운 greeting 정의
sayHello(); // sayHello()를 호출
}
sayHello(); // 예상 결과: "Hello, World!"
setNewGreeting(); // 예상 결과는 무엇일까?
위 코드를 보고 결과를 예측해보자. sayHello()
의 결과는 쉽게 예측할 수 있는 Hello, World!
가 출력될 것이다. 이제 setNewGreeting()
가 렉시컬(정적) 스코프일 때, 동적 스코프 일 때를 비교해보자.
렉시컬(정적) 스코프로 동작하면?
sayHello()
함수는 정의된 위치에서의 스코프를 참조해 sayHello()
함수가 호출될 때 전역으로 정의된 greeting
의 값 "Hello, JavaScript!"
을 출력하게 될 것이다.
동적 스코프로 동작하면?
상위의 프로토타입 체인이 변경됐다고 가정했을 때, 이제 sayHello()
는 호출된 위치에 따라 참조하는 스코프가 동적으로 변경되게 된다. 즉, setNewGreeting()
함수 내에서 sayHello()
를 호출하게 되면, 그 호출 위치에 따라 setNewGreeting()
함수 내부의 지역 변수 greeting
을 참조하게 될 수 있다. 이로 인해 결과는 "Hello, JavaScript!"
가 출력될 것이다.
무엇이 문제가 될까?
- 의도치 않은 참조 변화
개발자는 sayHello()
함수가 항상 전역의 greeting
을 참조할 것이라고 생각하고 작성했지만, 동적 스코프라면 호출 위치에 따라 다른 변수를 참조하게 된다.
즉 환경에 따라 동일한 함수 호출이 다른 결과를 얻게 된다. 이는 개발자 입장에서 함수의 동작을 예측하기 어렵고 코드의 일관성이 떨어지기 때문에 개발 및 유지 보수 과정에서 어려움이 발생할 것이다.
- 클로저는 더이상 사용이 불가능하다.
클로저는 함수가 정의될 때의 렉시컬 환경을 캡처하기 때문에 함수 외부에서 정의된 변수를 참조할 수 있게 된다. 하지만, 정의된 환경에 따라 동작하게 될 경우 클러저의 상태 유지를 통한 일관된 참조가 무력화 된다.
당장 우리가 많이 사용하는 React에서의 hook 관리에서도 지장이 생기고, 자바스크립트의 많은 기능을 사용하지 못하게 될 것이다.
자바스크립트의 프로토타입은 객체 간 상호 연결된 체인 구조를 통해 매우 동적인 특성을 지니게 된다. 반면, 렉시컬 스코프가 정적 스코프를 참조함으로써 함수 내부 동작에서의 일관성과 예측 가능성을 보장하고, 이를 통해 코드의 안정성을 부여하게 되는 상호 보완적인 역할을 수행하게 된다.
이제 우리는 이렇게 그동안 정적 스코프라고 불렀던 렉시컬 스코프가 왜 정적 스코프인지 이해할 수 있게 되었다.
this와 프로토타입
this는 렉시컬(정적) 스코프와 대비되는 특성을 가지고 있으며, 동적으로 호출되는 방식과 시점에 따라 그 값이 결정된다.
자바스크립트에서 함수나 스크립트가 실행될 때마다 실행 컨텍스트가 생성되고, 이 실행 컨텍스트가 활성화되면서 this 바인딩이 이루어지게 된다. 이때 자바스크립트 엔진은 함수가 어떤 방식으로 호출되었는지를 기준으로 this를 바인딩한다. (예를 들어, 일반 함수 호출, 메서드 호출, 생성자 호출 등)
프로토타입은 동적으로 결정되는 구조다. 즉, 스코프 체인을 따라가며 연결된 객체들을 탐색하는 과정에서 동적인 호출자가 등장할 수 있으며, 이는 언제든지 변할 수 있다. 이러한 특성 때문에, this 역시 고정된 렉시컬 스코프와 달리 동적으로 변화할 수 있는 메커니즘을 필요로 하게 된 것이다. 이는 this가 함수가 호출되는 맥락에 따라 값을 가질 수 있도록 설계된 이유이기도 하다.
결국, 자바스크립트의 프로토타입 체인과 this 바인딩은 모두 동적인 특성 덕분에, 객체나 함수 호출 시점에서의 유연한 동작을 가능하게 하여 복잡하고 다양한 상호작용을 실현할 수 있게되는 것이다.
만약 this가 정적으로 동작한다면?
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function() {
console.log(`${this.name}는 소리를 낸다.`);
};
const dog = new Animal('강아지');
const cat = new Animal('고양이');
dog.speak(); // "강아지는 소리를 낸다."
cat.speak(); // "고양이는 소리를 낸다."
우리는 당연하게 결과를 예상하지만 this가 정적으로 동작한다면,
dog.speak(); // "강아지는 소리를 낸다."
cat.speak(); // "고양이는 소리를 낸다."
speak
함수의 this가 정적으로 Animal.prototype
에 고정됐기 때문에 위 결과가 아닌 아래와 같은 결과가 출력됐을 것이다.
dog.speak(); // "undefined는 소리를 낸다."
cat.speak(); // "undefined는 소리를 낸다."
this가 정적으로 동작하게 되면, 함수가 어디서 호출되든지 간에 항상 같은 객체를 참조하게 되고 이는 프로토타입 기반 자바스크립트의 장점인 코드 유연성과 재사용성을 떨어뜨리게 된다.
프로토타입을 이해하는 답은 프로토타입(원형)이었다.
새와 관련된 원형 구조처럼 보이지만, 언제든지 상황과 맥락(context)에 따라서 펭귄이 연결될 수도 있고, 라면 혹은 강아지가 연결될 수도 있다. 글을 마치는 지금, 이제는 이러한 원형 구조와 프로토타입, 실행 컨텍스트, this, 렉시컬 스코프를 이해하고 "자바스크립트는 왜 프로토타입을 선택했는지"에 대한 답변을 자신있게 할 수 있을 것 같다.
결국 답은 프로토타입에 있었다..!
글을 마치며
이번 글은 "자바스크립트는 왜 프로토타입을 선택했는지?"에 대한 질문에서부터 시작되었는데, 사실 이전까지 프로토타입은 나에게 기술 면접 단골 출제 개념 정도의 인식이 강했고, 특별하게 의식했던 존재는 아니었다. 하지만 이번 글을 작성하며 아직 나의 학습이 많이 부족했다는 반성을 하게 되었다.
특히 이번에 클래스, 프로토타입 기반 객체지향의 원론을 비교한 부분이 정말 흥미롭고 유익하게 느껴졌는데, 참고 글을 읽고 나만의 방식으로 쉽게 정리해 기록하고 언젠가 내가 프로토타입과 관련된 기억을 망각하게 됐을 때를 대비해 글을 작성하게 되었다.
프로토타입뿐만 아니라 최근에 Promise, Closure와 React의 useState hook을 Vanilla JS로 직접 구현하며, 어렵고 어색하게만 느껴졌던 자바스크립트의 이론적인 개념들을 하나둘씩 이해하게 됐고, 더 가깝게 느껴지기 시작했다. 앞으로도 마냥 단순하게 암기하는 것보다 기억, 성장에 더 도움이 되는 효율적인 이해 -> 암기 -> 적용(활용) 단계를 걷기 위해 노력하자.
1편
👉잘못된 내용이 있다면 언제든 피드백 환영입니다~ 🙇🏻♂️
Reference
모던 자바스크립트 Deep Dive, 코어 자바스크립트