Menu

자바스크립트 프로토타입과 프로토타입 체인 완벽 정리

자바스크립트 프로토타입이 실제로 무엇인지, 프로토타입 체인이 어떻게 속성을 찾아 올라가는지, 그리고 class 문법이 이 구조 위에서 어떻게 동작하는지 정리합니다.

모든 객체는 프로토타입을 가진다

자바스크립트는 프로토타입 기반 언어다. 말은 거창해 보이지만 개념은 의외로 단순하다. 모든 객체에는 또 다른 객체를 가리키는 숨겨진 연결 고리가 있는데, 이걸 바로 프로토타입(prototype) 이라고 부른다. 객체에 없는 프로퍼티를 찾으면, 자바스크립트는 이 연결 고리를 타고 올라가 그 객체에게 다시 물어본다.

index.js
Output
Click Run to see the output here.

rabbit 객체 자체에는 eats 프로퍼티가 없습니다. 자바스크립트는 먼저 rabbit을 확인하고, 없으면 프로토타입 링크를 따라 animal로 올라가서 eats: true를 찾아 반환하죠. flies의 경우에는 체인을 끝까지 따라가도 찾지 못하니 undefined가 반환됩니다.

이렇게 "찾고, 없으면 위로 올라가는" 동작이 프로토타입의 핵심 메커니즘입니다. 상속, 메서드, class 문법까지 전부 이 원리 위에 세워져 있어요.

프로토타입 체인 (prototype chain)

체인은 한 단계에서 멈추지 않습니다. 프로토타입도 자기 자신의 프로토타입을 가질 수 있고, 이렇게 계속 올라가다가 결국 null에 도달하면 끝납니다.

index.js
Output
Click Run to see the output here.

실행해 보면 rabbit, Object.prototype, 그리고 마지막에 null이 차례로 찍힙니다. 정의한 적도 없는 rabbit.toString()이 동작하는 이유가 바로 여기에 있어요. toString은 거의 모든 프로토타입 체인의 꼭대기에 있는 Object.prototype에 들어 있기 때문이죠.

프로퍼티를 읽을 때는 이 프로토타입 체인을 아래에서 위로 거슬러 올라갑니다. 반면 값을 할당할 때는 체인을 타고 올라가지 않고 항상 객체 자신에게 기록돼요. 이 비대칭은 꽤 중요한 포인트인데, 실제로 많은 분들이 여기서 헷갈려합니다.

생성자 함수와 .prototype

class 문법이 등장하기 전에는, 비슷한 객체를 여러 개 찍어내는 표준적인 방법이 바로 new 키워드와 함께 호출하는 생성자 함수(constructor function) 였습니다.

index.js
Output
Click Run to see the output here.

new User("Ada")를 호출하면 두 가지 일이 일어납니다.

  1. 새로운 객체가 만들어지고, 그 객체의 프로토타입이 User.prototype으로 설정됩니다.
  2. this가 그 새 객체에 바인딩된 상태로 User 함수가 실행됩니다.

여기서 중요한 건 greet 메서드가 인스턴스마다 복사되지 않는다는 점입니다. User.prototype에 딱 한 번 올라가 있고, adaboris는 프로토타입 체인을 따라 올라가서 이 메서드를 찾아 씁니다. 마지막 줄이 true를 찍는 이유도 바로 이것 — 둘이 참조하는 함수가 말 그대로 같은 함수이기 때문이죠.

prototype vs __proto__ 차이

이 두 이름 때문에 누구나 한 번쯤은 헷갈립니다. 서로 관련은 있지만 같은 건 아니에요.

  • User.prototype생성자 함수 자체에 달려 있는 프로퍼티입니다. new User(...)로 만든 인스턴스의 프로토타입이 바로 이 객체가 됩니다.
  • ada.__proto__ (또는 Object.getPrototypeOf(ada))는 인스턴스 쪽에 붙어 있는 링크로, 자신의 프로토타입을 가리킵니다.
index.js
Output
Click Run to see the output here.

새 코드를 작성할 때는 obj.__proto__보다 Object.getPrototypeOf(obj)를 쓰는 편이 좋습니다. __proto__는 하위 호환성을 위해 남겨둔 레거시 접근자일 뿐이고, 함수 형태가 공식 API예요.

class 문법은 결국 프로토타입의 겉옷

최신 자바스크립트에서는 class 키워드로 깔끔하게 코드를 작성할 수 있지만, 내부를 열어보면 결국 프로토타입 위에서 돌아갑니다. 두 방식을 나란히 두고 비교해 보죠:

index.js
Output
Click Run to see the output here.

greet 메서드는 손으로 직접 할당했을 때와 똑같이 User.prototype에 올라갑니다. class 키워드가 해주는 일은 결국 조금 더 깔끔한 문법, 조금 더 엄격한 규칙(new 없이는 호출 불가), 그리고 extends를 더 매끄럽게 쓰도록 해주는 것 정도예요. 런타임 동작 모델 자체는 완전히 동일합니다.

이 점을 알고 있으면 에러 메시지를 읽거나 this를 디버깅할 때 도움이 됩니다. "User.prototype.greet"라는 에러가 떠도 그게 내부에서 쓰는 이상한 이름이 아니라, 실제로 메서드가 살고 있는 바로 그 위치라는 걸 알 수 있거든요.

프로토타입 상속은 체인이 길어질 뿐

extends는 프로토타입을 또 다른 프로토타입에 연결합니다. 부모의 prototype이 자식 prototype의 프로토타입이 되는 구조죠:

index.js
Output
Click Run to see the output here.

rex.eat을 찾을 때는 rexDog.prototypeAnimal.prototype 순서로 체인을 거슬러 올라가면서 eat을 찾아내고, this는 여전히 rex에 바인딩된 채로 호출됩니다. extends가 해주는 일은 딱 이게 전부예요. 프로토타입 체인을 대신 연결해 주는 것뿐이죠.

프로토타입을 지정해서 객체 직접 만들기

생성자 함수가 꼭 필요한 건 아닙니다. Object.create(proto)를 쓰면 원하는 프로토타입을 가진 새 객체를 바로 만들 수 있어요.

index.js
Output
Click Run to see the output here.

class도, new도, 생성자 함수도 없습니다. 그냥 두 객체가 공유 프로토타입을 통해 하나의 메서드를 함께 쓰고 있을 뿐이죠. 이게 바로 프로토타입 상속의 가장 밑바닥 형태이고, 나머지 모든 것들은 이 위에 얹혀 있습니다.

hasOwnProperty: 자기 속성 vs 상속받은 속성

속성 조회는 프로토타입 체인을 따라 거슬러 올라가기 때문에, "foo" in obj는 상속받은 속성에 대해서도 true를 돌려줍니다. 객체가 직접 소유한 속성인지 구분해야 할 때는 Object.hasOwn(혹은 예전 방식인 hasOwnProperty)을 쓰면 됩니다:

index.js
Output
Click Run to see the output here.

name은 인스턴스에, greet은 프로토타입에 있습니다. in 연산자는 둘 다 찾아내지만, Object.hasOwn은 자기 자신의 속성만 찾습니다. for...in으로 순회하거나 객체를 직렬화할 때 이 차이가 중요해지는데, 보통은 자기 자신의 속성만 다루고 싶을 때가 많기 때문입니다.

내장 프로토타입을 함부로 건드리지 마세요

Array.prototype은 프로그램 안의 모든 배열이 공유하기 때문에, 마음만 먹으면 거기에 메서드를 추가할 수도 있습니다:

// 제발 이러지 마세요.
Array.prototype.last = function () {
    return this[this.length - 1];
};

[1, 2, 3].last(); // 3

문제는 동작을 안 한다는 게 아닙니다. 동작은 잘 됩니다. 진짜 문제는 그 네임스페이스를 여러분만 쓰는 게 아니라는 점이죠. 내가 쓰는 모든 라이브러리, 모든 의존성, 그리고 앞으로 나올 모든 자바스크립트 버전이 그 공간을 함께 공유합니다. 언젠가 Array.prototype.last가 미묘하게 다른 동작으로 표준에 들어오는 날, 여러분 코드(또는 남의 코드)는 원인을 찾기 힘든 방식으로 터지게 됩니다. Array.prototype.flatten / Array.prototype.flat 사건이 바로 이 교훈을 상징하는 대표적인 사례죠.

헬퍼는 그냥 독립된 함수로 두세요:

index.js
Output
Click Run to see the output here.

충돌할 수 있는 공용 표면이 하나 줄어드는 셈이죠.

멘탈 모델 정리

군더더기를 다 걷어내고 나면, 자바스크립트 프로토타입은 결국 세 가지 규칙으로 압축됩니다.

  • 모든 객체는 프로토타입 링크를 가진다 (때로는 null일 수도 있다).
  • 프로퍼티를 읽을 때는 체인을 타고 올라가지만, 쓸 때는 그렇지 않다.
  • class, new, extends는 결국 Object.create를 직접 호출하지 않고도 이 체인을 엮어주는 도구일 뿐이다.

이 세 가지만 머릿속에 넣어두면 this의 동작, instanceof, 메서드 탐색, 그리고 프로토타입 상속까지 자연스럽게 풀립니다.

다음 주제: 이벤트 루프

프로토타입을 끝으로 객체 모델 이야기는 마무리됩니다. 다음 장에서는 완전히 다른 주제를 다룹니다. 바로 자바스크립트가 시간의 흐름 속에서 코드를 실제로 어떻게 실행하는지에 관한 이야기죠. 타이머, 프로미스, 그리고 async/await이 지금처럼 동작하는 이유가 바로 이벤트 루프에 있고, 이게 모든 비동기의 뿌리입니다.

자주 묻는 질문

자바스크립트에서 프로토타입이 뭔가요?

자바스크립트의 모든 객체는 내부적으로 다른 객체를 가리키는 링크를 하나 가지고 있는데, 이게 바로 프로토타입입니다. 객체 자체에 없는 속성에 접근하면 자바스크립트는 이 링크를 따라 위로 올라가면서 속성을 찾는데, 이 경로를 프로토타입 체인이라고 부릅니다. 메서드를 한 번만 정의해 놓고 여러 인스턴스가 공유할 수 있는 이유도 바로 이 구조 덕분이죠.

__proto__와 prototype은 뭐가 다른가요?

prototype은 생성자 함수(그리고 클래스)에 달려 있는 속성입니다. new로 인스턴스를 만들 때 이 객체가 그 인스턴스의 프로토타입이 됩니다. 반면 __proto__(또는 Object.getPrototypeOf(obj))는 인스턴스에 붙어 있는 실제 링크로, 자신의 프로토타입을 가리킵니다. 정리하면 instance.__proto__ === Constructor.prototype인 거죠.

자바스크립트의 class는 결국 프로토타입의 문법 설탕인가요?

거의 그렇습니다. class Foo { bar() {} }라고 쓰면 bar는 결국 Foo.prototype에 올라갑니다. function Foo(){}Foo.prototype.bar = function(){}로 쓴 것과 사실상 동일하죠. 물론 클래스에는 private 필드, 더 엄격한 실행 규칙, extendssuper를 위한 깔끔한 문법 같은 게 추가됐지만, 엔진 속에서 돌아가는 메커니즘은 여전히 프로토타입입니다.

Array.prototype 같은 내장 프로토타입에 메서드를 추가해도 될까요?

웬만하면 하지 마세요. Array.prototype이나 Object.prototype을 건드리면 프로젝트 안의 모든 배열과 객체에 영향을 주고, 여기엔 외부 라이브러리가 만든 것들도 포함됩니다. 나중에 언어 표준에 같은 이름의 메서드가 추가되면 충돌이 나고, for...in 루프도 망가질 수 있습니다. 커스텀 헬퍼는 별도 함수나 모듈로 분리해서 쓰는 게 정석입니다.

Coddy로 코딩 배우기

시작하기