JavaScript - 프로토타입과 상속

2025. 9. 29. 19:07·Frontend/Javascript

5년간 React를 쓰다 보니 "class 컴포넌트는 이제 안 쓰니까 프로토타입은 몰라도 되는 거 아닌가?" 했던 시절이 있었다. 그런데 팀 코드 리뷰에서 선배가 "이 함수 왜 이렇게 짰어? 프로토타입 이해하고 짠 거야?" 라고 물어봤을 때 대답 못 했던 기억이 난다.

최근에 라이브러리 코드를 뜯어보다가 프로토타입 체인이 빈번하게 나오는 걸 보고, 이제는 JavaScript의 근본을 제대로 이해해야겠다고 생각했다. 오늘은 그 과정에서 정리한 내용을 공유해보자.

 

프로토타입, JavaScript의 숨겨진 핵심

 

JavaScript는 프로토타입 기반 객체지향 언어다. Java나 C++처럼 클래스가 먼저 있고 인스턴스를 만드는 게 아니라, 객체가 다른 객체를 직접 상속받는 구조다.

모든 JavaScript 객체는 숨겨진 [[Prototype]] 속성을 가진다. 이걸 통해 다른 객체의 속성과 메서드를 마치 자기 것처럼 사용할 수 있다.

let animal = {
  eats: true,
  walk() {
    console.log("동물이 걷고 있습니다");
  }
};

let rabbit = {
  jumps: true
};

// rabbit이 animal을 상속받도록 설정
rabbit.__proto__ = animal;

console.log(rabbit.eats); // true (animal에서 상속받음)
console.log(rabbit.jumps); // true (자기 자신의 속성)
rabbit.walk(); // "동물이 걷고 있습니다" (animal의 메서드 호출)

rabbit 객체는 eats 속성을 가지지 않지만, 프로토타입 체인을 통해 animal에서 찾아서 사용한다.

 

프로토타입 체인, 속성을 찾아 떠나는 여행

JavaScript 엔진이 객체의 속성을 찾는 과정을 프로토타입 체인이라고 한다.

  1. 현재 객체에서 찾기: 해당 속성이 있으면 사용
  2. 프로토타입에서 찾기: 없으면 [[Prototype]]이 가리키는 객체에서 찾기
  3. 상위 프로토타입에서 찾기: 여전히 없으면 더 상위로 올라가기
  4. Object.prototype까지: 최종적으로 Object.prototype에서 찾고, 여기도 없으면 undefined
let animal = {
  eats: true
};

let mammal = {
  warmBlooded: true,
  __proto__: animal
};

let rabbit = {
  jumps: true,
  __proto__: mammal
};

console.log(rabbit.jumps); // true (자기 자신)
console.log(rabbit.warmBlooded); // true (mammal에서 상속)
console.log(rabbit.eats); // true (animal에서 상속)
console.log(rabbit.toString); // function (Object.prototype에서 상속)

실무에서 "이 메서드가 어디서 왔지?" 할 때 프로토타입 체인을 따라가면 답을 찾을 수 있다.

 

Object.create(), 명시적 프로토타입 설정

__proto__를 직접 사용하는 건 권장되지 않는다. 대신 **Object.create()**를 사용하는 게 표준이다.

let animal = {
  eats: true,
  walk() {
    console.log(`${this.name}이 걷고 있습니다`);
  }
};

// animal을 프로토타입으로 하는 새 객체 생성
let rabbit = Object.create(animal);
rabbit.name = "토끼";
rabbit.jumps = true;

rabbit.walk(); // "토끼이 걷고 있습니다"
console.log(rabbit.eats); // true

Object.create()의 장점

  • 명시적: 프로토타입 관계가 명확하다.
  • 안전함: __proto__보다 표준적이고 안정적이다.
  • null 프로토타입: Object.create(null)로 깨끗한 객체 생성 가능

 

생성자 함수와 prototype 속성

ES6 클래스가 나오기 전에는 생성자 함수로 객체지향을 구현했다. 지금도 라이브러리 코드에서 자주 볼 수 있다.

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

// 모든 Animal 인스턴스가 공유할 메서드
Animal.prototype.eat = function() {
  console.log(`${this.name}이 먹고 있습니다`);
};

Animal.prototype.walk = function() {
  console.log(`${this.name}이 걷고 있습니다`);
};

let rabbit = new Animal("토끼");
let cat = new Animal("고양이");

rabbit.eat(); // "토끼이 먹고 있습니다"
cat.walk(); // "고양이이 걷고 있습니다"

// 같은 메서드를 공유하는지 확인
console.log(rabbit.eat === cat.eat); // true

핵심 포인트:

  • new 키워드로 생성된 객체는 자동으로 생성자함수.prototype을 상속받는다.
  • 모든 인스턴스가 메서드를 공유해서 메모리 효율적이다.
  • this는 메서드를 호출한 인스턴스를 가리킨다.

 

ES6 클래스, 프로토타입의 syntactic sugar

class Animal {
  constructor(name) {
    this.name = name;
  }

  eat() {
    console.log(`${this.name}이 먹고 있습니다`);
  }

  walk() {
    console.log(`${this.name}이 걷고 있습니다`);
  }
}

class Rabbit extends Animal {
  constructor(name, jumpHeight) {
    super(name); // 부모 생성자 호출
    this.jumpHeight = jumpHeight;
  }

  jump() {
    console.log(`${this.name}이 ${this.jumpHeight}cm 높이로 점프합니다`);
  }

  // 부모 메서드 오버라이드
  walk() {
    console.log(`${this.name}이 깡충깡충 걷고 있습니다`);
  }
}

let rabbit = new Rabbit("토끼", 50);
rabbit.eat(); // "토끼이 먹고 있습니다" (Animal에서 상속)
rabbit.jump(); // "토끼이 50cm 높이로 점프합니다"
rabbit.walk(); // "토끼이 깡충깡충 걷고 있습니다" (오버라이드된 메서드)

클래스 문법이 훨씬 직관적이지만, 내부적으로는 여전히 프로토타입을 사용한다:

 
 

 

console.log(typeof Animal); // "function"
console.log(rabbit.__proto__ === Rabbit.prototype); // true
console.log(Rabbit.prototype.__proto__ === Animal.prototype); // true

 

this 바인딩, 프로토타입 메서드에서의 함정

프로토타입 메서드에서 this는 메서드를 호출한 객체를 가리킨다. 이게 때로는 예상과 다르게 동작할 수 있다.

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

Animal.prototype.introduce = function() {
  console.log(`안녕하세요, 저는 ${this.name}입니다`);
};

let rabbit = new Animal("토끼");
let cat = new Animal("고양이");

rabbit.introduce(); // "안녕하세요, 저는 토끼입니다"

// 메서드를 변수에 저장하면?
let introduce = rabbit.introduce;
introduce(); // "안녕하세요, 저는 undefined입니다" (this가 전역 객체가 됨)

// 해결 방법 1: bind 사용
let boundIntroduce = rabbit.introduce.bind(rabbit);
boundIntroduce(); // "안녕하세요, 저는 토끼입니다"

// 해결 방법 2: 화살표 함수 (단, 프로토타입에서는 주의)

실무에서 이벤트 핸들러나 콜백 함수로 메서드를 전달할 때 이런 문제가 자주 발생한다.

 

프로토타입 상속의 장단점

장점들

메모리 효율성: 모든 인스턴스가 메서드를 개별적으로 가지지 않고 프로토타입에서 공유한다.

function Animal(name) {
  this.name = name;
  
  // 안좋은 예 - 인스턴스마다 함수 생성
  // this.eat = function() {
  //   console.log(`${this.name}이 먹고 있습니다`);
  // };
}

// 좋은 예 - 프로토타입에서 공유
Animal.prototype.eat = function() {
  console.log(`${this.name}이 먹고 있습니다`);
};

let animals = [];
for (let i = 0; i < 1000; i++) {
  animals.push(new Animal(`동물${i}`));
}
// 1000개 인스턴스가 하나의 eat 메서드를 공유

 

동적 확장: 런타임에 프로토타입에 메서드를 추가하면 모든 인스턴스에서 즉시 사용 가능하다.

Animal.prototype.sleep = function() {
  console.log(`${this.name}이 잠들었습니다`);
};

// 이미 생성된 인스턴스에서도 새 메서드 사용 가능
rabbit.sleep(); // "토끼이 잠들었습니다"

 

단점들

성능 오버헤드: 프로토타입 체인이 깊어질수록 속성 탐색 시간이 길어진다.

// 깊은 프로토타입 체인
let level1 = { prop1: 1 };
let level2 = Object.create(level1);
level2.prop2 = 2;
let level3 = Object.create(level2);
level3.prop3 = 3;
let level4 = Object.create(level3);
level4.prop4 = 4;

// level4에서 prop1을 찾으려면 3단계를 거쳐야 함
console.log(level4.prop1); // 1 (하지만 탐색 시간이 오래 걸림)

 

디버깅 복잡성: 어떤 속성이 어느 프로토타입에서 왔는지 추적하기 어렵다.

// 개발자 도구에서 hasOwnProperty로 확인 가능
console.log(rabbit.hasOwnProperty('jumps')); // true
console.log(rabbit.hasOwnProperty('eats')); // false

 

실무에서 프로토타입 활용하기

라이브러리 확장

// Array 프로토타입 확장 (주의해서 사용)
Array.prototype.unique = function() {
  return [...new Set(this)];
};

let numbers = [1, 2, 2, 3, 3, 3];
console.log(numbers.unique()); // [1, 2, 3]

주의사항: 내장 객체의 프로토타입을 확장하는 건 신중하게 해야 한다. 다른 라이브러리와 충돌할 수 있다.

 

Mixin 패턴 구현

let CanFly = {
  fly() {
    console.log(`${this.name}이 날고 있습니다`);
  }
};

let CanSwim = {
  swim() {
    console.log(`${this.name}이 수영하고 있습니다`);
  }
};

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

// 여러 기능을 Duck에 추가
Object.assign(Duck.prototype, CanFly, CanSwim);

let duck = new Duck("오리");
duck.fly(); // "오리이 날고 있습니다"
duck.swim(); // "오리이 수영하고 있습니다"

 

TypeScript에서 프로토타입 다루기

TypeScript와 프로토타입을 함께 사용할 때는 타입 정의가 중요하다.

interface Animal {
  name: string;
  eat(): void;
}

interface AnimalConstructor {
  new (name: string): Animal;
  prototype: Animal;
}

const Animal: AnimalConstructor = function(this: Animal, name: string) {
  this.name = name;
} as any;

Animal.prototype.eat = function(this: Animal) {
  console.log(`${this.name}이 먹고 있습니다`);
};

let rabbit = new Animal("토끼");
rabbit.eat(); // 타입 안전하게 호출

 

마무리

5년차가 되어서야 제대로 이해한 프로토타입은 JavaScript의 DNA라고 할 수 있다. ES6 클래스가 나왔어도 내부적으로는 여전히 프로토타입을 사용하고, 많은 라이브러리들이 프로토타입 기반으로 설계되어 있다.

특히 메모리 효율성과 동적 확장성은 다른 언어에서는 쉽게 볼 수 없는 JavaScript만의 강점이다. React나 Vue 같은 프레임워크를 쓰면서도 이런 기본기를 이해하고 있으면, 더 깊이 있는 개발자가 될 수 있다고 생각한다.

프로토타입을 이해하면 JavaScript가 왜 이렇게 설계되었는지, 어떤 철학을 가지고 있는지 알 수 있다. 그리고 그 이해를 바탕으로 더 효율적이고 구조적인 코드를 작성할 수 있게 된다.

'Frontend > Javascript' 카테고리의 다른 글

JavaScript - JavaScript의 복사, 비교, 그리고 Immer  (0) 2025.11.05
Javascript - 이벤트 루프  (0) 2025.09.26
Javascript - Promise 비동기 처리  (0) 2025.09.25
'Frontend/Javascript' 카테고리의 다른 글
  • JavaScript - JavaScript의 복사, 비교, 그리고 Immer
  • Javascript - 이벤트 루프
  • Javascript - Promise 비동기 처리
qkrdkwl9090
qkrdkwl9090
6년차 프론트엔드 개발자 Tony(박동현)입니다.
  • qkrdkwl9090
    Tony - Blog
    qkrdkwl9090
  • 전체
    오늘
    어제
    • 분류 전체보기 (59)
      • Frontend (24)
        • React (2)
        • Three.js (1)
        • Javascript (4)
        • R&D (7)
        • 번역글 (9)
      • IT (18)
      • 일상 (6)
        • 장소 (1)
        • 맛집 (3)
      • 경제 (11)
  • 링크

    • Github
    • Linkedin
  • 인기 글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
qkrdkwl9090
JavaScript - 프로토타입과 상속
상단으로

티스토리툴바