자바스크립트의 프로토타입 제대로 이해하기
프로토타입을 제대로 이해하는 것이야말로 자바스크립트의 고수로 가는 지름길입니다. 하지만 프로토타입은 자바스크립트에서 이해하기 어렵기로 악명이 높은 개념이기도 하죠.
자바스크립트는 프로토타입(prototype)이라는 다소 독특한 프로그래밍 패러다임을 가진 언어입니다. ES6에 클래스가 도입되면서 예전처럼 프로토타입을 직접 다뤄야 할 일은 줄었지만 여전히 프로토타입은 자바스크립트의 근간이 되는 개념입니다. 프로토타입을 제대로 이해하지 않으면 레거시 코드를 읽거나 문제가 발생했을 때 디버깅이 어려워질 수 있어요.
이번 글에서는 자바스크립트 개발자로서 제대로 알아두면 두고두고 도움이 되는 프로토타입에 대해서 알아보겠습니다.
프로토타입이란?
굳이 한국어로 번역을 하자면 “원형”이라고 번역할 수 있는 프로토타입(prototype)은 자바스크립트 언어의 근간이 되는 핵심적인 개념입니다.
자바스크립트에서 모든 객체는 자신의 원형을 __proto__라는 비밀 속성을 통해 참조하고 있는데요.
바로 __proto__라는 비밀 속성을 통해 참조되고 있는 객체를 프로토타입이라고 합니다.
브라우저 콘솔에서 빈 객체를 생성한 다음에 한 번 __proto__ 속성을 확인해보세요.
const obj = {};
obj.__proto__;
다음과 같이 여러 가지 속성으로 이루어진 객체가 확인이 될 거예요. 이 객체가 바로 빈 객체의 원형, 즉 프로토타입입니다.
{
constructor: function Object() { [native code] }
hasOwnProperty: function hasOwnProperty() { [native code] }
isPrototypeOf: function isPrototypeOf() { [native code] }
propertyIsEnumerable: function propertyIsEnumerable() { [native code] }
toLocaleString: function toLocaleString() { [native code] }
toString: function toString() { [native code] }
valueOf: function valueOf() { [native code] }
/* 그 밖에 다른 속성들 ... */
}
자바스크립트에서 모든 객체는 자신의 프로토타입의 속성을 마치 자신의 속성인 것처럼 접근할 수 있어요.
예를 들어, 빈 객체는 프로토타입의 속성인 toString() 함수를 마치 자신의 속성인 양 호출할 수 있습니다.
obj.toString(); // '[object Object]'
프로토타입 확인
어떤 객체의 프로토타입은 객체의 __proto__ 속성을 통해서 참조되지만 __proto__ 속성에 바로 접근하는 것은 웹 표준이 아니라서 권장되지 않습니다.
const proto = obj.__proto__; // ❌
대신 Object.getPrototypeOf() 함수에 객체를 인자로 넘기면 해당 객체의 프로토타입이 무엇인지 알아낼 수 있습니다.
이 함수에 대해 더 자세히 알고 싶다면 자바스크립트 객체의 프로토타입을 다루는 방법을 참고하세요.
const proto = Object.getPrototypeOf(obj); // ✅
프로토타입 설정
어떤 객체의 프로토타입을 변경하기 위해서 해당 객체의 __proto__ 속성을 다른 객체를 할당하는 것은 역시 웹 표준이 아니라서 권장되지 않습니다.
obj.__proto__ = { a: 1 }; // ❌
obj.a; // 1
대신 Object 클래스의 setPrototypeOf() 함수를 사용하여 객체의 프로토타입을 다른 객체로 설정할 수 있습니다.
Object.setPrototypeOf(obj, { a: 1 }); // ✅
obj.a; // 1
프로토타입 체이닝
객체의 프로토타입은 마치 체인(chain)처럼 여러 단계에 걸쳐서 연결이 될 수 있습니다. 왜냐하면 어떤 객체의 프로토타입도 평범한 객체이기 때문에 자신의 원형인 프로토타입을 가질 수 있기 때문입니다.
자바스크립트에서 어떤 객체의 속성에 접근할 때는 해당 객체에 접근하려는 속성이 없다면 바로 undefined를 반환하는 것이 아니라 해당 객체의 프로토타입 체인에 해당 속성이 없는지 연쇄 탐색을 합니다.
예를 들어, A 객체의 프로토타입이 B 객체이고, B 객체의 프로토타입이 C 객체라면, A 객체는 B 객체의 속성 뿐만 아니라 C 객체의 속성에도 접근할 수 있게 됩니다.
const objA = {
propOfA: "AAA",
};
const objB = {
propOfB: "BBB",
};
const objC = {
propOfC: "CCC",
};
Object.setPrototypeOf(objA, objB);
Object.setPrototypeOf(objB, objC);
objA.propOfA; // 'AAA'
objA.propOfB; // 'BBB'
objA.propOfC; // 'CCC'
위 예제로 생각해보면 우리가 objA.propOfC에 접근하면, 우선 objA 객체에 propOfC 속성이 있는지 확인합니다.
propOfC 속성이 objA 객체에 없으므로, objA 객체의 프로토타입인 objB 객체에 propOfC 속성이 있는지 확인합니다.
propOfC 속성이 objB 객체에도 없으므로, objB 객체의 프로토타입인 objC 객체에 propOfC 속성이 있는지 확인합니다.
propOfC 속성이 objC 객체에 있으므로 해당 속성의 값을 얻을 수 있습니다.
프로토타입 체이닝은 자바스크립트의 내장 자료형에서도 확인할 수 있습니다.
예를 들어, 객체의 프로토타입은 프로토타입을 갖지 않습니다.
즉, 프로토타입의 __proto__ 속성이 null입니다.
const obj = { a: 1 };
obj.__proto__.__proto__; // null
반면에 배열의 프로토타입은 프로토타입을 갖습니다.
const arr = [1, 2];
arr.__proto__.__proto__; // {constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, hasOwnProperty: ƒ, __lookupGetter__: ƒ, …}
사실 빈 배열의 프로토타입의 프로토타입은 빈 객체의 프로토타입과 동일합니다.
arr.__proto__.__proto__ == obj.__proto__;
이것이 바로 객체의 프로토타입의 속성인 toString() 함수를 배열을 상대로도 호출할 수 있는 이유입니다.
arr.toString(); // '1,2'
toString() 함수는 배열 자체의 속성도 아니고 배열의 프로토타입의 속성도 아니지만 배열의 프로토타입의 프로토타입의 속성이기 때문에 접근이 가능한 것입니다.
프로토타입 쉐도잉
만약에 프로토타입 체인에서 동일한 이름의 속성이 있다면 어떨까요? 그럴 때는 객체와 가까운 프로토타입의 속성이 더 높은 우선순위를 가지게 됩니다.
예를 들어서, 배열의 직속 프로토타입에 toString 속성으로 다른 함수를 할당해볼까요?
arr.__proto__.toString = function () {
return "프로토타입: 나는 배열이다";
};
arr.toString(); // '프로토타입: 나는 배열이다'
동일한 배열을 상대로 toString() 함수를 호출했는데 다른 결과를 얻게 됩니다.
이번에는 배열 자체의 toString 속성으로 다른 함수를 할당해보겠습니다.
arr.toString = function () {
return "객체: 나는 배열이다";
};
arr.toString(); // '객체: 나는 배열이다'
동일한 배열을 상대로 toString() 함수를 호출했는데 역시 또 다른 결과를 얻게 됩니다.
객체 자신의 속성 여부 판단
객체의 속성에 접근할 때 반드시 객체 자체의 속성이 아닐 수도 있다는 것을 배웠는데요. 그러면 어떤 속성이 객체 자체의 속성인지 프로토타입 체인으로부터 온 것인지를 어떻게 알 수 있을까요?
바로 객체의 프로토타입의 속성인 hasOwnProperty() 함수를 활용하면 됩니다.
이 주제에 대해서는 자바스크립트 객체에 특정 속성이 있는지 확인하는 방법에서 더 자세히 다루고 있어요.
예를 들어, 속성 a를 갖는 obj 객체를 상대로 hasOwnProperty("a")를 호출하면 참이 반환되지만, .hasOwnProperty("toString")를 호출하면 거짓이 반환됩니다.
toString 속성은 obj 객체의 프로토타입의 속성이지 obj 객체 자신의 속성은 아니기 때문입니다.
const obj = { a: 1 };
obj.a; // 1
obj.hasOwnProperty("a"); // true
obj.toString(); // '[object Object]'
obj.hasOwnProperty("toString"); // false
기본 자료형의 내장 속성
자바스크립트에서 모든 문자열에는 substring() 함수가 있으며, 모든 배열에는 sort() 함수가 있습니다.
"ABCDE".substring(2, 4); // "CD"
[2, 3, 1].sort(); // [1, 2, 3]
문자열과 배열에 이런 함수가 기본으로 달려 있는 이유도 프로토타입 덕분입니다.
문자열 리터럴에 메서드를 호출하면 엔진이 해당 문자열을 임시로 String 객체로 감싸줍니다.
이 String 객체는 String.prototype을 프로토타입으로 가지고 있고, String.prototype에는 substring(), toUpperCase(), trim() 같은 문자열 관련 함수가 정의되어 있어요.
const str = "hello";
Object.getPrototypeOf(str); // String.prototype
str.toUpperCase(); // 'HELLO'
배열도 마찬가지입니다. 배열 리터럴은 Array.prototype을 프로토타입으로 가지고, Array.prototype에 sort(), map(), filter() 같은 배열 메서드가 정의되어 있습니다.
const arr = [1, 2, 3];
Object.getPrototypeOf(arr) === Array.prototype; // true
여기서 프로토타입 체이닝이 빛을 발합니다.
String.prototype이나 Array.prototype도 결국 평범한 객체이기 때문에 자신의 프로토타입이 있는데요.
바로 Object.prototype입니다.
Object.getPrototypeOf(String.prototype) === Object.prototype; // true
Object.getPrototypeOf(Array.prototype) === Object.prototype; // true
그래서 문자열이나 배열을 상대로 toString()이나 hasOwnProperty() 같은 함수를 호출할 수 있는 겁니다.
이 함수들은 프로토타입 체인의 끝자락에 있는 Object.prototype에 정의되어 있으니까요.
정리하면 프로토타입 체인은 이런 구조가 됩니다.
문자열 리터럴 → String.prototype → Object.prototype → null
배열 리터럴 → Array.prototype → Object.prototype → null
객체 리터럴 → Object.prototype → null
Object.prototype의 프로토타입은 null이고, 여기가 프로토타입 체인의 끝입니다.
속성을 찾지 못한 채 null에 도달하면 그때 비로소 undefined가 반환됩니다.
마치며
지금까지 자바스크립트의 프로토타입에 대해서 알아보았습니다.
프로토타입은 자바스크립트에서 객체 간 속성을 공유하는 핵심 메커니즘입니다.
__proto__를 통한 연결, 프로토타입 체이닝, 쉐도잉, 그리고 내장 자료형의 프로토타입 구조까지 이해하고 나면 자바스크립트의 동작 원리가 훨씬 명확하게 보일 거예요.
ES6의 class 문법도 내부적으로는 프로토타입 기반으로 동작합니다.
클래스의 메서드가 인스턴스마다 복사되는 게 아니라 prototype 객체에 정의되어 공유되는 것도 이 글에서 배운 원리와 같은 맥락이에요.
프로토타입 체인과 instanceof 연산자에서 클래스 상속이 프로토타입 체인으로 어떻게 구현되는지 더 자세히 확인할 수 있습니다.
더 자세한 내용은 Inheritance and the prototype chain - MDN Web Docs를 참고하세요.
This work is licensed under
CC BY 4.0