menu

자바스크립트 프로토타입 체인과 상속에 대해

profile image
15 min ·July 10, 2024

들어가며

예전에 웹 공통 모듈 개발 업무를 담당한 적이 있습니다. 해당 모듈은 번들 사이즈를 줄이는 것이 중요한 미션 중 하나였고 UI 요소가 그렇게 많지 않았기 때문에 모든 코드가 순수 바닐라 자바스크립트로 작성되어 있었습니다. 이 때 정말 많이 사용했던 문법이 ES6에 등장한 클래스(class) 키워드였습니다.

클래스 키워드를 사용하면 간단하게 클래스를 선언할 수 있을 뿐만 아니라, 추상화된 부모 클래스를 선언하고 이를 상속해 조금 더 구체화된 자식 클래스를 만드는 등 객체지향 프로그래밍 스타일로 자바스크립트를 다룰 수 있었습니다. 덕분에 코드의 재사용성과 가독성, 유지보수성을 크게 높일 수 있었죠.

그렇다면 클래스 키워드가 등장하기 전엔 자바스크립트에서 어떻게 이런 동작을 구현했을까요? 이 글에서는 클래스 키워드 등장 이전의 이야기, 생성자 함수와 프로토타입에 대한 이야기를 해보려고 합니다.

생성자 함수

ES6 클래스 키워드가 등장하기 전까지는 생성자 함수와 프로토타입을 이용해 클래스와 상속을 구현했습니다. 먼저 생성자 함수를 살펴보겠습니다.

new 키워드

생성자 함수는 자바스크립트에서 객체를 생성할 때 사용하는 특별한 함수로, 문법 자체는 일반 함수와 특별히 다르지 않습니다. 함수 이름의 첫 글자를 대문자로 표시하는 것은 일반적인 네이밍 컨벤션일 뿐입니다. 그렇다면 어떤 점이 생성자 함수를 특별하게 만드는 것일까요? 해답은 함수를 호출할 때 붙이는 new에 있습니다.

function Animal(name) {
  // let this = {};

  this.name = name;
  this.isPlant = false;

  // return this;
}

const a = new Animal("lion");
console.log(a); // Animal {name: 'lion', isPlant: false}

Animal 함수 안에서 제가 주석처리한 코드는 이 함수를 new와 함께 호출했을 때 일어나는 암묵적인 동작으로, 임의의 this 객체를 만들고 이를 리턴합니다. 이렇게 new 키워드를 사용하면 동일한 속성을 가진 객체(인스턴스)를 여러개 만들어낼 수 있습니다. 그렇다면 new 없이 호출됐을 땐 어떻게 될까요?

const b = Animal("tiger");
console.log(b); // undefined
console.log(window.name); // 'tiger'

new 없이 호출되었기 때문에 위 함수는 일반 함수처럼 동작합니다. 암묵적으로 this 객체가 만들어지지 않았기 때문에 함수 실행 컨텍스트에서 thiswindow를 가리키게 되며('use strict' 모드에서는 undefined), name / isPlant는 전역 변수로 저장되어버립니다. 또한 함수 안에서 리턴하는 값도 없기 때문에 b 변수에는 undefined가 할당됩니다.

프로토타입 (Prototype)

기본 개념

new 키워드를 사용하면 동일한 속성을 가진 객체(인스턴스)를 계속해서 만들어낼 수 있지만 이것만으로는 부모-자식 상속 관계를 구현할 수 없습니다. 우리가 흔히 객체지향 프로그래밍에서 보아왔던 상속 관계를 구현하기 위해선 프로토타입(prototype)이 필요합니다.

prototype은 생성자 함수 혹은 클래스로 생성된 객체 인스턴스들이 어떤 속성과 메소드를 공유할지 정의할 수 있는 객체입니다. 아래 코드 예시를 보겠습니다.

function Animal() {
  this.kind = "animal";
}

Animal.prototype.run = function () {
  console.log("run");
};

const bird = new Animal();
const lion = new Animal();

bird.run(); // "run"
lion.run(); // "run"

birdlionAnimal.prototype 객체에 정의된 run() 메소드를 공유합니다. 공유라 함은 다시 말해 인스턴스마다 똑같은 메소드가 정의되는게 아니라 prototype에 존재하는 단일 메소드를 동일하게 바라본다는 뜻입니다.

bird.run(); // "run";

Animal.prototype.run = function () {
  console.log("overwrite");
};

bird.run(); // "overwrite"

프로토타입을 런타임에 수정하는 것은 성능적으로 굉장히 비싼 작업에 속하므로 위와 같은 코드 패턴을 추천하진 않습니다. 중요한 것은 prototype을 활용해 각 객체 인스턴스의 메모리를 절감하는 효과를 누릴 수 있다는 것입니다.

객체 인스턴스 속성에 접근할 때, 자바스크립트는 인스턴스 자체 -> 인스턴스가 공유받은 프로토타입 순으로 해당 속성이 있는지 검사합니다. 위의 예시에서는 bird 객체에 run이 없기 때문에 bird를 생성할 때 쓰였던 생성자 함수의 프로토타입(Animal.prototype)에서 run을 찾아 실행했습니다. 인스턴스 자체에 속성을 정의하면 이 속성값이 우선시됩니다.

bird.run(); // "run" : Animal.prototype에서 공유받은 메소드

bird.run = function () {
  console.log("new");
};

bird.run(); // "new" : 객체 인스턴스 자체에 정의된 메소드

인스턴스가 생성될 때 어떤 prototype을 기반으로 만들어졌는지, 어떤 prototype을 사용할지는 아래 두 메소드로 다룰 수 있습니다.

예전엔 위 메소드 대신 __proto__ 속성에 직접 접근하기도 했으나 이는 공식 ECMAScript 표준이 아니므로 되도록이면 쓰지 않는 것이 좋습니다. Object.setPrototypeOf를 활용하여 bird의 동작을 바꿔보겠습니다.

bird.run(); // "run" : Animal.prototype에서 공유받은 메소드

console.log(Object.getPrototypeOf(bird)); // {run: ƒ} === Animal.prototype

const newPrototype = {
  run: () => console.log("New Run"),
};

Object.setPrototypeOf(bird, newPrototype);

bird.run(); // "New Run" : 프로토타입 객체로 지정된 newPrototype에서 공유받은 메소드

기존의 birdAnimal 생성자 함수를 이용해 만들어졌기에 속성을 bird 객체 -> 프로토타입(Animal.prototype) 순으로 찾았지만 이제는 bird 객체 -> 새 프로토타입(newPrototype)순으로 찾아 호출하는 것을 확인할 수 있습니다.

프로토타입 체인을 통한 상속

만약 프로토타입 객체에도 해당 속성이 없다면 어떻게 될까요. 그럴 경우 그 프로토타입 객체의 상위 프로토타입 객체로 탐색을 이어나갑니다. 이 연결 관계를 프로토타입 체인이라 부르며, 이를 통해 부모로부터 속성과 메소드를 상속받는 프로토타입 상속을 구현할 수 있습니다.

function Animal() {
  this.kind = "animal";
}

Animal.prototype.run = function () {
  console.log("run");
};

function Rabbit(name) {
  this.name = name;
}

// Rabbit이 Animal을 상속
// 속성을 찾을 때 객체 -> Rabbit.prototype -> Animal.prototype 순으로 탐색
Object.setPrototypeOf(Rabbit.prototype, Animal.prototype);

const r = new Rabbit("sol");

r.run(); // "run" : 부모 Animal에서 상속받은 메소드
r.run === Animal.prototype.run; // true

Object.getPrototypeOf(Rabbit.prototype) === Animal.prototype; // true

이 코드에서는 Rabbitrun을 정의하지 않고도 프로토타입 체인을 이용해 해당 메소드를 부모인 Animal로부터 상속받아 사용하는 것을 확인할 수 있습니다. 이렇게 프로토타입 체인을 이용해 부모 자식 관계를 연속적으로 설정해나갈 수 있으며, 자바스크립트는 속성 접근시 이 순서에 기반해 속성을 탐색합니다.

"모든 길은 Object로 통한다"

자바스크립트 네이티브 데이터형의 프로토타입(Array.prototype, Function.prototype, Number.prototype, ...)은 전부 Object.prototype을 프로토타입 객체로 바라보고 있으며, Object.prototypenull을 바라보고 있습니다.

Object.getPrototypeOf(Number.prototype) === Object.prototype; // true
Object.getPrototypeOf(String.prototype) === Object.prototype; // true
Object.getPrototypeOf(Function.prototype) === Object.prototype; // true
Object.getPrototypeOf(Array.prototype) === Object.prototype; // true

Object.getPrototypeOf(Object.prototype) === null; // true

nullundefined를 제외한 모든 데이터형이 결국 Object를 상속받고 있기 때문에 프로토타입 상속을 통해 hasOwnProperty와 같은 Object.prototype 메소드에 접근할 수 있게 됩니다. 물론, 앞서 말했던 탐색 순서를 이용해 자기 자신만의 메소드를 정의하고 사용할 수도 있습니다. 예시로 Number를 보겠습니다.

console.log(Number.prototype);
/*
constructor: ƒ,
toExponential: f,
toFixed: : f,
toLocaleString: f, 
toPrecision: f,
toString: f, 
valueOf: f,  
*/

console.log(Object.prototype);
/*
constructor: ƒ,
hasOwnProperty: ƒ,
isPrototypeOf: ƒ,
propertyIsEnumerable: ƒ,
toLocaleString: ƒ,
toString: ƒ,
valueOf: ƒ,
...
*/

const count = 1;

// case 1. 자신만의 고유 메소드를 정의한 경우
count.toString === Number.prototype.toString;

// case 2. Object.prototype으로부터 상속받은 경우
count.hasOwnProperty === Object.prototype.hasOwnProperty;

이제 자바스크립트 네이티브에서 프로토타입 체인 관계가 어떻게 되어있는지, 그동안 자연스럽게 가져다 쓰던 속성과 메소드들이 어디서 온 것인지 알게 되었습니다. 여기서 한가지 더, count는 원시값인데 어떻게 속성 접근이 가능한지 궁금하실 수 있는데, 그건 원시값에 접근시 자바스크립트 내부적으로 원시값(1)을 임시 Wrapper 객체로(Number(1)) 감싸기 때문입니다.

마무리하며: 그래서 Class는?

이 글에서는 ES6 클래스 키워드가 등장하기 이전 자바스크립트에서 객체지향 프로그래밍을 어떻게 구현했는지, 생성자 함수와 프로토타입을 살펴보며 알아보았습니다. 프로토타입 상속은 자바스크립트의 강력한 기능으로, 메모리 절약과 코드 재사용성을 높이는 중요한 개념입니다.

혹시 위에 적었던 코드 예시 중 이상한 점을 찾으신 분이 계신가요?

function Animal() {
  this.kind = "animal";
}

Animal.prototype.run = function () {
  console.log("run");
};

function Rabbit(name) {
  this.name = name;
}

// Rabbit이 Animal을 상속받음
Object.setPrototypeOf(Rabbit.prototype, Animal.prototype);

const rabbit = new Rabbit("sol");
rabbit.run(); // "run"

rabbit.kind; // undefined ???????????

일반적인 상속관계를 상상했다면 당연히 rabbit 객체가 부모 속성 kind를 가지고 있을것 같지만, 실제로는 undefined가 찍히게 됩니다. 이는 kind가 prototype에 정의되지 않았기 때문이며, 사실은 Rabbit 함수 안에서 Animal.call(this)를 통해 부모 생성자 함수를 직접 호출하고 같은 this 객체를 바라보도록 해야 정상적으로 rabbit.kind가 찍히게 됩니다.

ES6 클래스에서는 constructor의 super()가 이 역할을 수행하며, extends를 이용해 상속 관계 또한 더 직관적이고 간결한 코드로 구현할 수 있게 되었습니다. 현업 서비스 코드에서 직접 코드에 prototype을 타이핑할 일은 별로 없을 수 있지만, 자바스크립트의 내부 동작을 이해하기 위해선 프로토타입 개념을 이해하는 것이 중요합니다. 거기에 이 글이 조금이나마 도움이 되었길 바라며 이만 마치도록 하겠습니다. 읽어주셔서 감사합니다.