
자바스크립트를 배우다 보면, class 문법이나 객체 리터럴을 통해 객체를 만드는 방법은 금방 익힐 수 있습니다. 하지만 조금만 깊게 들어가면 이런 질문들이 생기게 되죠.
“내가 만든 객체에 없는 메서드를 어떻게 호출할 수 있지?”, “모든 배열이 push, map 같은 메서드를 쓸 수 있는 이유는 뭘까?”, “클래스를 쓰면 깔끔하긴 한데, 실제로 내부에서 어떤 구조로 동작하는 거지?”
이 모든 의문에 공통적으로 등장하는 개념이 바로 ‘프로토타입(prototype)’입니다. ES6의 class 문법은 사실 기존에 있던 프로토타입 기반 상속을 조금 더 보기 좋고 익숙한 문법으로 포장한 것일 뿐, 동작 원리 자체가 바뀐 것은 아닙니다.
클래스 본문은 항상 strict mode로 실행되고, 인스턴스의 [[Prototype]]은 ClassName.prototype에 연결됩니다. 또한 프로토타입에는 메서드와 비열거 constructor가 정의되고, 정적 메서드는 생성자 함수 객체(ClassName) 자체에 붙습니다. 결국 근본적인 원리는 프로토타입 기반 상속입니다. 이번 글에서는 프로토타입의 기본 개념, 프로토타입 체인의 구조, 그리고 실무에서 꼭 알아둬야 할 주의점까지 함께 살펴보겠습니다.
자바스크립트에서 모든 객체는 내부적으로 [[Prototype]]이라는 숨겨진 슬롯을 가지고 있습니다. 이 슬롯은 또 다른 객체를 가리키는데, 이를 통해 객체끼리 연결되는 상속 구조가 형성됩니다.
프로토타입은 객체가 다른 객체로부터 메서드와 프로퍼티를 상속받기 위해 사용하는 연결고리입니다. 쉽게 말해 객체의 “원형”이라고 할 수 있죠.
const obj = {};
console.log(Object.getPrototypeOf(obj) === Object.prototype); // true
위 예제를 보면 {}로 생성한 객체의 프로토타입이 Object.prototype이라는 사실을 알 수 있습니다. 덕분에 obj에서는 toString 같은 메서드가 없어도, 아래와 같이 Object.prototype에서 이를 찾아 실행할 수 있습니다.
const obj = {};
console.log(obj.toString()); // [object Object]
만약 프로토타입이 없다면, 객체마다 필요한 메서드를 전부 복사해서 가지고 있어야 합니다. 예를 들어, 1만 개의 배열을 생성하면, 각각의 배열이 map, filter, forEach 메서드를 개별적으로 보관해야겠죠. 이는 메모리 낭비가 심할 뿐만 아니라, 버그 수정 시 유지보수도 어렵게 만듭니다.
프로토타입을 활용하면 메서드를 한 번만 정의하고, 모든 인스턴스가 이를 공유합니다. 즉, 재사용성, 메모리 절약, 유지보수 향상이라는 세 마리 토끼를 한 번에 잡을 수 있습니다.
프로토타입은 객체의 생성 방법에 따라 다르게 연결됩니다. 객체 리터럴, 생성자 함수, 클래스 문법 등 각각의 방식이 내부적으로 어떻게 동작하는지 이해하면 혼란이 줄어듭니다.
가장 단순한 객체 리터럴 {}는 자동으로 Object.prototype과 연결됩니다.
const user = { name: 'Alice' };
console.log(Object.getPrototypeOf(user) === Object.prototype); // true
이렇게 생성된 객체는 Object.prototype이 가진 hasOwnProperty, toString 같은 메서드를 사용할 수 있습니다.
new 키워드와 함께 호출되는 생성자 함수는, 함수 객체의 prototype 속성을 인스턴스의 프로토타입으로 설정합니다.
function User(name) {
this.name = name;
}
User.prototype.sayHello = function() {
console.log(`Hello, ${this.name}`);
};
const u1 = new User(‘Hyobin’);
u1.sayHello(); // Hello, Hyobin
이 구조 덕분에, u1 변수는 직접 sayHello 메서드를 가지고 있지 않더라도, 프로토타입에서 찾아 실행할 수 있습니다.
ES6 class 문법은 보기에는 전통적인 객체지향 언어처럼 보이지만, 내부적으로는 생성자 함수와 프로토타입을 기반으로 동작합니다.
class User {
constructor(name) {
this.name = name;
}
sayHello() {
console.log(`Hello, ${this.name}`);
}
}
const u2 = new User(‘Bob’);
u2.sayHello(); // Hello, Bob
즉, class를 사용해도 결국은 User.prototype.sayHello 라는 구조가 만들어지고, 인스턴스들은 이를 공유하는 것입니다.
프로토타입 체인은 객체에서 특정 프로퍼티를 찾을 때 거치는 탐색 경로를 의미합니다. 자바스크립트는 객체지향 언어처럼 ‘클래스 상속’이 있는 대신, 모든 객체가 다른 객체를 참조할 수 있는 ‘프로토타입 기반 상속’을 사용합니다.
따라서 객체에 원하는 프로퍼티가 없을 경우, 자바스크립트 엔진은 자동으로 그 객체의 [[Prototype]]을 따라 상위 객체를 탐색하게 됩니다. 이 과정을 반복하면서 마치 ‘체인’처럼 연결된 경로를 따라가게 되는데, 이를 프로토타입 체인이라고 부릅니다.
프로토타입 체인은 객체 간에 연결된 상속 경로라고 할 수 있습니다. 예를 들어, 배열의 메서드를 호출할 때, 자바스크립트 엔진은 다음과 같은 순서로 탐색합니다.
const arr = [1, 2, 3];
arr.push(4);
// 탐색 순서
// 1) arr 자체에 push 메서드가 있는가?
// 2) 없다면 arr.[[Prototype]] → Array.prototype 탐색
// 3) Array.prototype에 push 존재 → 실행
즉, 배열 인스턴스는 자체적으로 push 메서드를 가지고 있지 않습니다. 그러나 Array.prototype이 연결되어 있어, 그곳에서 메서드를 찾아 실행할 수 있습니다. 이처럼 객체 하나하나가 필요한 기능을 모두 가지고 있는 것이 아니라, 필요한 기능을 공유하는 프로토타입을 참조함으로써 메모리를 절약하고 재사용성을 높일 수 있습니다.
프로토타입 체인의 최상단에는 항상 Object.prototype이 존재합니다. 모든 객체는 결국 Object.prototype을 조상으로 가지며, 그 위에는 더 이상 프로토타입이 존재하지 않습니다. 따라서 Object.prototype.[[Prototype]]은 null을 반환하게 됩니다.
console.log(Object.getPrototypeOf(Object.prototype)); // null
이 말은 즉, 체인을 따라 올라갔는데도 원하는 프로퍼티를 끝내 찾지 못하면, 최종적으로 undefined를 반환한다는 의미이기도 합니다. 따라서 “체인 끝 == 탐색 실패”라는 구조가 성립합니다.
프로토타입 체인의 동작을 가장 직관적으로 확인할 수 있는 방법은 사용자 정의 생성자 함수를 통해 객체를 만들고, 메서드를 호출해 보는 것입니다.
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function() {
console.log(`${this.name} makes a noise.`);
};
const dog = new Animal('Dog');
dog.speak();
// 탐색 과정:
// 1) dog 자체에 speak 없음
// 2) dog.[[Prototype]] → Animal.prototype.speak 발견 → 실행
이 과정을 통해 알 수 있듯이, 객체에 없는 속성이나 메서드를 호출하면 엔진은 즉시 ‘위로 올라가면서’ 탐색을 진행합니다. 만약 Animal.prototype에도 없다면, 다시 그 상위인 Object.prototype에서 찾게 되며, 거기에서도 발견하지 못하면 최종적으로 undefined를 반환합니다.
프로토타입 체인은 단순히 이론적으로만 중요한 개념이 아니라, 실무 코드에서도 의외로 자주 마주하게 됩니다. 특히 프론트엔드 프레임워크를 사용할 때는 자바스크립트의 동작 방식을 깊이 신경 쓰지 않아도 되지만, 라이브러리의 내부 동작을 분석하거나, 성능 최적화를 고민할 때는 프로토타입 체인을 이해하고 있는 것이 큰 도움이 됩니다.
또한 객체 확장이나 메서드 오버라이딩 과정에서 예상치 못한 버그를 막기 위해, 반드시 알아야 할 주제이기도 합니다.
객체에 직접 동일한 이름의 메서드를 정의하면, 프로토타입 체인보다 해당 메서드가 우선적으로 실행됩니다. 즉, 객체 자신의 속성이 항상 프로토타입보다 우선시됩니다.
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function() {
console.log("안녕하세요!");
};
const user = new Person("효빈");
// 객체에 직접 오버라이딩
user.sayHello = function() {
console.log(`안녕하세요, 저는 ${this.name}입니다.`);
};
user.sayHello(); // "안녕하세요, 저는 효빈입니다."
이 경우 원래 Person.prototype.sayHello는 무시되고, 객체 자신이 가진 메서드가 먼저 실행됩니다. 따라서 실무에서는 프로토타입에 정의된 메서드가 ‘덮어씌워질 수 있다’는 점을 꼭 염두에 두어야 합니다. 특히 외부 라이브러리를 확장하거나, 상속받을 때 주의하지 않으면, 기존 동작이 무의식적으로 바뀌어 예상치 못한 버그가 생길 수 있습니다.
가끔 모든 객체에서 공통으로 쓸 수 있도록 Object.prototype에 직접 메서드를 추가하는 경우가 있는데요. 이는 실무에서는 매우 위험한 방식입니다. 모든 객체가 해당 메서드를 상속받기 때문에 의도치 않은 충돌이 발생하거나, for…in 루프에서 불필요한 프로퍼티가 노출되어 버그로 이어질 수 있습니다.
따라서 Object.prototype 확장은 피하고, 대신 유틸리티 함수를 별도로 정의하는 것이 안전한 방법입니다.
Object.prototype.customLog = function() {
console.log("확장된 메서드");
};
const obj = { a: 1 };
obj.customLog(); // 실행 가능
for (let key in obj) {
console.log(key); // "a", "customLog" ← 불필요하게 출력
}
이러한 방식은 권장되지 않으며, 필요한 경우 별도의 유틸 함수나 헬퍼 모듈을 만드는 것이 훨씬 안전합니다.
프로토타입 체인은 기본적으로 위로 올라가며 탐색하는 구조입니다. 대부분의 체인 탐색은 매우 빠르게 동작하지만, 불필요하게 깊은 체인을 만들면 속도에 영향을 줄 수 있습니다. 예를 들어, 다단계 상속 구조에서 메서드를 찾기 위해 수십 단계까지 올라가야 한다면, 성능 저하가 생길 수 있습니다. 따라서 객체 설계를 단순화하고, 지나치게 복잡한 상속 구조는 피하는 것이 좋습니다.
프로토타입과 프로토타입 체인은 자바스크립트의 객체지향 설계를 이해하는 데 있어 필수적인 개념입니다. 객체 리터럴, 생성자 함수, 클래스 등 다양한 생성 방식이 결국 프로토타입 기반으로 돌아간다는 사실을 이해하면, 언어의 동작 원리를 명확히 파악할 수 있습니다.
이 개념을 제대로 알고 있으면, 단순히 코드를 작성하는 수준을 넘어 라이브러리나 프레임워크 내부 구조를 분석할 수 있고, 성능 최적화와 유지보수성 높은 아키텍처를 설계하는 데에도 도움이 됩니다. class 문법을 쓰더라도, 그 뒤에서 작동하는 프로토타입의 세계를 항상 염두에 두는 것이 자바스크립트 개발자의 중요한 소양이라고 할 수 있습니다.
이 개념을 제대로 이해하고 있으면 단순히 코드를 작성하는 수준을 넘어, 라이브러리나 프레임워크의 내부 구조까지 분석할 수 있습니다. 또한 성능 최적화는 물론, 유지보수성이 높은 아키텍처를 설계하는 데에도 큰 도움이 됩니다. 따라서 class 문법을 사용하더라도, 그 뒤에서 작동하는 프로토타입의 원리를 이해하는 것은 자바스크립트 개발자에게 실무적으로 꼭 필요한 역량이라 할 수 있습니다.
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.