처음에는 자바스크립트에서 상속을 구현할 때, 단순히 클래스 기반 언어처럼 복사(copy)가 된다고 생각했습니다. 그런데 공부할수록 JS는 프로토타입을 통한 위임(delegation) 구조를 가지고 있다는 점이 인상깊었습니다. ES6 클래스가 나오면서 마치 JS가 클래스 기반 언어처럼 보이지만, 결국 내부는 프로토타입 체인 위에 돌아간다는 사실이 흥미로웠습니다.
프로토타입이란?
자바스크립트에서 프로토타입(Prototype)은 객체가 다른 객체의 속성과 메서드를 공유하거나 위임받을 수 있게 하는 메커니즘입니다.
- 클래스 기반 언어: 상속 시 속성과 메서드를 복사
- JS 프로토타입: 링크를 통해 참조, 필요할 때 위임
클래스 기반 언어에서의 상속은 보통 속성과 메서드를 복사(copy)하는 방식으로 이뤄지지만, JS의 프로토타입은 링크(link)를 통한 참조와 위임으로 동작한다는 점이 본질적인 차이입니다. 즉, 객체를 여러 개 만들 때 불필요한 복사를 줄이고, 메서드를 공유할 수 있습니다.
프로토타입 기반 상속과 비교해 클래스 기반 상속이 주는 장점은 무엇인가요?
1. 구조적 명확성
클래스는 '이 객체가 어떤 속성과 메서드를 가진다'를 선언 시점에 확정적으로 보여주기 때문에 코드를 읽는 사람과 도구가 객체의 형태와 사용법을 파악할 수 있습니다. 따라서 대규모 프로젝트에서 일관성과 가독성이 좋아집니다.
2. 컴파일러 / IDE 최적화
많은 클래스 기반 언어(C++, Java, C#)는 정적 타입 검사와 컴파일 최적화가 가능합니다. 메서드 디스패치나 인라인 최적화가 예측 가능해 성능상의 이점이 있습니다. 프로토타입 기반은 런타입에 동적으로 해석해야 해서 최적화에 불리한 경우가 있습니다.
3. 캡슐화와 접근 제어
클래스 언어는 public, private, protected와 같은 접근 제어자를 기본적으로 지원하기 때문에 데이터 은닉을 명시적이고 강력하게 가능하게 합니다.
4. 상속 구조의 예측 가능성
클래스 상속은 계층 구조가 비교적 단순하고 직관적입니다. 프로토타입 체인은 동적으로 이어질 수 있어서 유연한 대신, 추적하기 어려운 경우가 있습니다.
정리하자면 아래와 같습니다.
클래스 기반 상속 → 명확성, 타입 안정성, 성능 최적화, 캡슐화, 예측 가능성
프로토타입 기반 상속 → 유연성, 동적 확장성, 메모리 절약(공유)
특징
1. 객체 간 위임과 공유
프로토타입은 객체가 스스로 가지지 않은 속성도 연결된 객체를 통해 사용할 수 있게 해줍니다.
즉, 객체 간에 불필요한 복사를 하지 않고 필요한 기능을 위임(delegation)하여 사용할 수 있습니다.
const base = { greet() { return "hello"; } };
const obj = Object.create(base);
console.log(obj.greet()); // "hello"
위 코드에서 obj는 greet을 직접 가지고 있지 않지만, 프로토타입으로 연결된 base에 위임하여 사용할 수 있습니다.
2. [[Prototype]] 내부 슬롯
모든 객체에는 눈에 보이지 않는 [[Prototype]]이라는 내부 슬롯이 존재합니다.
이 슬롯은 객체가 연결된 상위 프로토타입을 가리키며, 이를 통해 탐색이 가능해집니다.
const a = { x: 1 };
const b = Object.create(a);
console.log(Object.getPrototypeOf(b) === a); // true
→ b는 [[Prototype]]을 통해 a와 연결되어 있어, b.x 접근 시 a의 값을 참조합니다.
[[Prototype]] 내부 슬롯이란?
JavaScript 엔진이 내부적으로 관리하는 숨겨진 속성입니다.
개발자가 직접 접근할 수는 없지만, 프로토타입 체인의 핵심 메커니즘을 담당합니다.
3. 프로토타입 체인과 탐색
프로퍼티를 탐색할 때 JS 엔진은 다음과 같은 단계를 밟습니다.
- 현재 객체에서 찾는다.
- 없다면 [[Prototype]]으로 연결된 객체에서 찾는다.
- 최종적으로 Object.prototype까지 탐색한다.
const top = { z: 3 };
const mid = Object.create(top);
const low = Object.create(mid);
console.log(low.z); // 3 (low → mid → top 순서로 탐색)
console.log(low.y); // undefined (끝까지 못 찾음)
4. 동적 변경 가능
프로토타입의 특성은 동적으로 변할 수 있습니다. 이미 만들어진 객체라도 연결된 프로토타입의 속성을 바꾸면 즉시 반영됩니다.
const proto = { v: 1 };
const obj = Object.create(proto);
proto.v = 99;
console.log(obj.v); // 99
이 덕분에 유연한 설계가 가능하지만, 동시에 예기치 못한 사이드 이펙트가 발생할 위험도 있습니다.
5. 메서드 오버라이딩
하위 객체에서 같은 이름의 메서드를 다시 정의하면, 상위 메서드는 가려지고 하위 메서드가 실행됩니다.
const animal = { sound() { return "?"; } };
const dog = Object.create(animal);
dog.sound = () => "woof";
console.log(dog.sound()); // "woof"
→ 탐색 과정에서 더 가까운 프로토타입(=자신)에서 먼저 찾기 때문입니다.
실제 사용 예시
1. Object.create()
Object.create(proto)는 JavaScript에서 새로운 객체를 생성하면서 동시에 프로토타입 체인을 설정하는 메서드입니다. 이를 통해 proto와 연결된 새로운 객체를 만들 수 있습니다.
1-1. 기본 문법
Object.create(proto[, propertiesObject])
- proto: 새로 만들어질 객체의 프로토타입이 될 객체
- propertiesObject (선택적): 새 객체에 추가할 속성들을 정의하는 객체
1-2. 동작 원리
const parent = { say() { return "hi"; } };
const child = Object.create(parent);
// child의 프로토타입이 parent로 설정됨
console.log(child.say()); // "hi"
Object.create(parent)를 호출했을 때
1. 새로운 빈 객체가 생성됩니다.
2. 이 새 객체의 [[Prototype]](내부 프로토타입 링크)이 parent를 가리키도록 설정합니다.
3. 따라서 child에서 찾을 수 없는 속성은 parent에서 찾게 됩니다.
1-3. null 프로토타입 객체를 생성한다면?
const pureObject = Object.create(null);
console.log(pureObject.toString); // undefined
// Object.prototype의 메서드들을 상속받지 않음
→ undefiend로 출력되어 Object.prototype의 메서드들을 상속받지 않을 수 있습니다.
1-4. 객체 리터럴 vs Object.create()
// 객체 리터럴 - Object.prototype을 상속
const obj1 = {};
console.log(obj1.toString); // [Function: toString]
// Object.create(null) - 아무것도 상속받지 않음
const obj2 = Object.create(null);
console.log(obj2.toString); // undefined
1-5. new 연산자 vs Object.create()
function Person(name) {
this.name = name;
}
Person.prototype.greet = function() {
return `Hi, I'm ${this.name}`;
};
// new 연산자
const person1 = new Person("Daeun");
// Object.create() equivalent
const person2 = Object.create(Person.prototype);
Person.call(person2, "Daisy");
console.log(person1.greet()); // "Hi, I'm Daeun"
console.log(person2.greet()); // "Hi, I'm Daisy"
2. 생성자 함수와 prototype
생성자 함수로 객체를 만들면, 자동으로 그 함수의 prototype과 연결됩니다.
function Person(name) {
this.name = name;
}
Person.prototype.getName = function () {
return this.name;
};
const p = new Person("Daeun");
console.log(p.getName()); // "Daeun"
→ new 키워드가 p.proto = Person.prototype 연결까지 내부적으로 처리해줍니다.
3. 상속 (Object.create)
JavaScript에서 완전한 상속을 구현하려면 두 가지를 모두 물려받아야 합니다.
- 인스턴스 속성 (각 객체가 개별적으로 가져야 하는 데이터)
- 공통 메서드 (모든 객체가 공유하는 기능)
따라서 생성자 빌려쓰기와 프로토타입 체인 연결을 함께 사용합니다.
function Animal(kind) {
this.kind = kind;
}
Animal.prototype.sound = function () { return "?"; };
function Dog(name) {
Animal.call(this, "dog"); // (1) 부모 속성 복사
this.name = name;
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.sound = function () { return "woof"; };
const d = new Dog("Mocha");
console.log(d.kind, d.sound()); // "dog" "woof"
4. 생성자 빌려쓰기
생성자 빌려쓰기는 개인적인 속성을 상속받는 방법입니다.
function Dog(name) {
Animal.call(this, "dog"); // 생성자 빌려쓰기
this.name = name;
}
- Animal은 kind라는 속성을 가집니다 (동물의 종류)
- Dog를 만들 때도 이 kind 속성이 필요합니다
- 하지만 각 Dog 인스턴스마다 고유한 값을 가져야 합니다
Animal.call(this, "dog")는 Animal 생성자 함수를 현재 객체(this)의 컨텍스트에서 실행합니다.
마치 Dog 안에서 this.kind = "dog"를 직접 실행한 것과 같은 효과를 보여줍니다.
4-1. call()을 사용하는 이유는?
// 잘못된 방법
Animal("dog"); // this가 전역 객체를 가리킴 (문제)
// 올바른 방법
Animal.call(this,"dog"); // this가 새로 생성되는 Dog 인스턴스를 가리킴
Animal.call(this, "dog")의 의미:
- "Animal 생성자야, 지금 만들어지는 이 Dog 인스턴스에서 실행해줘"라는 명령어로 인식됩니다.
- Animal 생성자가 this.kind = kind를 실행할 때, 이 this가 새로운 Dog 인스턴스를 가리키게 됩니다
- 결과적으로 Dog 인스턴스에 kind: "dog" 속성이 직접 추가됩니다
왜 Animal("dog")는 안 될까요?
- 일반 함수 호출에서 this는 전역 객체(window나 global)를 가리킵니다
- 그러면 this.kind = "dog"가 전역에 설정되어 버립니다
- call()을 사용해야 this를 우리가 원하는 객체로 지정할 수 있습니다
5. 프로토타입 체인 연결
프로토타입 체인 연결은 공통 메서드를 상속받는 방법입니다.
Dog.prototype = Object.create(Animal.prototype); // line 1
Dog.prototype.constructor = Dog; // line 2
- Animal.prototype에 sound() 메서드가 있습니다.
- 모든 Dog도 이 메서드를 사용할 수 있어야 합니다.
- 하지만 메모리를 절약하기 위해 복사하지 않고 공유하고 싶습니다.
5-1. Line 1: Dog.prototype = Object.create(Animal.prototype)
이 코드가 하는 일:
- Dog.prototype을 새로 만들되, Animal.prototype과 연결합니다.
- 마치 "Dog 가문이 Animal 가문의 후손이다"라고 선언하는 것과 같습니다.
- 이제 Dog 인스턴스에서 메서드를 찾을 때, Animal.prototype까지 올라가서 찾을 수 있습니다.
Before (연결 전)
Dog.prototype → Object.prototype → null
Animal.prototype → Object.prototype → null
After (연결 후)
Dog.prototype → Animal.prototype → Object.prototype → null
Object.create()를 사용하는 이유
// 잘못된 방법들
Dog.prototype = Animal.prototype; // 같은 객체를 참조 (문제)
Dog.prototype = new Animal(); // 불필요한 인스턴스 생성 (문제)
// 올바른 방법
Dog.prototype = Object.create(Animal.prototype); // 깔끔한 프로토타입 체인
왜 다른 방법들은 문제가 될까요?
- Dog.prototype = Animal.prototype:
- Dog와 Animal이 완전히 같은 prototype을 공유
- Dog에만 추가하고 싶은 메서드가 Animal에도 영향을 줌
- 마치 "Dog 가문과 Animal 가문이 같은 가문이다"라고 하는 것
- Dog.prototype = new Animal():
- Animal 인스턴스를 만들어서 prototype으로 사용
- 불필요한 인스턴스 생성과 초기화 과정 발생
- Animal 생성자에서 side effect가 있다면 문제 발생
5-2. Line 2: Dog.prototype.constructor = Dog
이 코드가 필요한 이유:
- Object.create()로 새 객체를 만들면, constructor 속성이 Animal을 가리키게 됩니다.
- 하지만 Dog.prototype의 constructor는 당연히 Dog를 가리켜야 합니다.
- 이는 "이 prototype을 사용하는 객체들의 생성자가 누구인지" 명확히 하는 것입니다.
// constructor 복원 전
console.log(Dog.prototype.constructor === Animal); // true (문제)
// constructor 복원 후
Dog.prototype.constructor = Dog;
console.log(Dog.prototype.constructor === Dog); // true (올바름)
→ Object.create(Animal.prototype)를 하면 Dog.prototype.constructor가 Animal을 가리키게 되므로, 이를 다시 Dog로 설정해줍니다.
전체적으로 이해해보자면 아래와 같습니다.
- 생성자 빌려쓰기: "Animal의 개인 소유품(속성)을 Dog에게도 주세요"
- 프로토타입 체인: "Animal 가문의 기술(메서드)을 Dog 가문도 쓸 수 있게 해주세요"
결과
- 각 Dog 인스턴스는 자신만의 kind, name 속성을 가집니다. (개인 소유)
- 하지만 sound() 같은 메서드는 프로토타입 체인을 통해 공유합니다. (가문 기술)
- Dog는 Animal의 메서드를 오버라이드할 수도 있습니다. (기술 개량)
이렇게 두 방법을 조합해야 JavaScript에서 완전하고 효율적인 상속을 구현할 수 있습니다.
ES6 class (문법적 설탕)
ES6 class는 내부적으로 동일한 프로토타입 구조를 사용하지만, 더 읽기 쉽게 표현한 문법입니다.
class Animal {
constructor(kind) { this.kind = kind; }
sound() { return "?"; }
}
class Dog extends Animal {
constructor(name) { super("dog"); this.name = name; }
sound() { return "woof"; }
}
const d = new Dog("Mocha");
console.log(d.kind, d.sound()); // "dog" "woof"
→ extends와 super()를 통해 상속을 간결하게 표현할 수 있습니다.
보조 개념 정리
- [[Prototype]]: 객체의 상위 프로토타입을 참조하는 내부 슬롯
- 프로토타입 체인: obj → obj.[[Prototype]] → ... → Object.prototype
- Object.prototype: 모든 객체의 최상위, 공통 메서드를 제공
- 생성자 빌려쓰기: Parent.call(this, ...)으로 부모 속성을 복사
- 문법적 설탕: class 문법처럼 내부 동작은 같지만 더 직관적으로 보이게 하는 구문
References
- [MDN: 프로토타입과 상속]
- https://ui.toast.com/posts/ko_20170217?utm_source=chatgpt.com
- https://tecoble.techcourse.co.kr/post/2021-06-14-prototype/?utm_source=chatgpt.com
'📚 cs > 자바스크립트' 카테고리의 다른 글
[JavaScript] 이벤트 루프 (Event Loop) (0) | 2025.09.04 |
---|---|
[JavaScript] 스코프 체인 (Scope Chain) (1) | 2025.08.31 |
[JavaScript] this 바인딩 (0) | 2025.08.30 |
[JavaScript] V8 엔진과 GC (Garbage Collection) (3) | 2025.08.28 |
[JavaScript] 실행 컨텍스트와 호이스팅 (1) | 2025.08.27 |