• 프로토타입 (ProtoType)
  • 2025. 2. 11. 19:37
  • 자바스크립트는 프로토타입 기반 객체 지향 프로그래밍 언어입니다. (cf. 함수형 프로그래밍 언어이기도 하죠)

    그렇다면 프로토타입은 무엇일까요? 프로토타입은 즉 '전신' 이라는 의미이기도 합니다. 즉, 후속 제품들의 부모 역할이라는 뜻이죠.

    자바스크립트에서의 프로토타입은 '객체' 에 대한 프로토타입을 의미합니다. (물론 프로토타입 자체도 객체이긴 합니다)

     

    다른 프로그래밍 언어들은 클래스 기반 객체 지향 프로그래밍 언어인데, 자바스크립트프로토타입 기반 객체 지향 프로그래밍 언어입니다. 이로 인해 훨씬 더 효율적이고 강력합니다. 프로토타입 기반 상속을 통해 불필요한 중복을 제거하고, 코드를 재사용할 수 있게 됩니다. 이로 인해 모든 인스턴스가 같은 메서드를 생성하지 않아도 되므로 메모리의 공간을 확보할 수 있고, 인스턴스 생성 시의 퍼포먼스의 향상에도 도움을 줍니다.

     

    모든 객체들은 본인의 프로토타입을 상속 받습니다.

     

     

    프로토타입을 이해하기 위해서는 생성자 함수, 프로토타입 객체, 인스턴스 객체(리터럴 혹은 생성자로 생성한 객체), 프로토타입 체인(+ 스코프 체인), 내부 슬롯, 프로토타입의 생성 시점을 알고 있어야 합니다. 


    생성자 함수

    생성자 함수는 function 키워드로 생성한 함수 객체를 의미합니다. 앞에 new 키워드를 붙이면 생성자 함수로서 작동하며 인스턴스 객체를 생성하고, new 키워드 없이 호출하면 일반 함수와 같이 작동합니다. 보통 파스칼 케이스를 사용합니다.

    function Circle(radius) {
      this.radius = radius;
    
      this.getArea = function() {
        // Math.PI는 원주율을 나타내는 상수입니다.
        return Math.PI * this.radius ** 2;
      };
    }

     

    하지만, ES6 에 추가된 화살표 함수나 메서드 축약 표현으로 정의된 함수는 non-constructor 이므로 new 키워드를 사용할 수 없습니다.

    즉, 생성자 함수가 아닙니다.

     

    생성자 함수는 prototype 프로퍼티를 소유합니다. prototype은 생성자 함수가 생성할 인스턴스 객체의 프로토타입을 가리킵니다.

    이를 조금 더 확장해서 생각해보면, 생성자 함수도 결국 (2가지 경우를 제외한) 함수나 마찬가지이니 prototype 프로퍼티는 함수 객체만이 소유하는 프로퍼티라고 할 수 있습니다.

     

    또한 생성자 함수는 [[Construct]] 라는 내부 메서드를 가집니다. (이 값이 프로토타입의 constructor 프로퍼티에 할당 됩니다.)

     

    정적 프로퍼티 / 메서드 (static prop / static method)

    생성자 함수에 직접 묶여 있는 프로퍼티, 메서드를 의미합니다.

    함수도 결국 객체이기 때문에, 본인만의 프로퍼티나 메서드를 지닐 수 있습니다.

    // 생성자 함수
    function Person(name) {
      this.name = name;
    }
    
    // 프로토타입 메서드
    Person.prototype.sayHello = function () {
      console.log(`Hi! My name is ${this.name}`);
    };
    
    // 정적 프로퍼티
    Person.staticProp = "staticProp";
    
    // 정적 메서드
    Person.staticMethod = function () {
      console.log("staticMethod");
    };
    
    const me = new Person("Lee");
    
    // 생성자 함수에 추가한 정적 프로퍼티/메서드는 생성자 함수로 참조 및 호출한다.
    Person.staticMethod(); // staticMethod
    
    // 정적 프로퍼티/메서드는 생성자 함수가 생성한 인스턴스로 참조/호출할 수 없다.
    // 인스턴스로 참조/호출할 수 있는 프로퍼티/메서드는 프로토타입 체인 상에 존재해야 한다.
    me.staticMethod(); // TypeError: me.staticMethod is not a function

     

    위의 코드에서는 staticProp, staticMethod 라는 이름으로 정적 프로퍼티와 메서드를 바인딩 해두었습니다.

    (물론, 이름은 당연히 바뀌어도 됩니다!)

     

    호출시에는 생성자 함수를 통해 직접 호출해야하며, 생성자 함수로 만든 인스턴스를 통해 호출할 수 없습니다.


    프로토타입 (프로토타입 객체)

    괄호 안에 '프로토타입 객체' 라고 표현한 이유는 '프로토타입도 결국 객체이기 때문' 입니다. 

    위의 그림에서 생성자함수.prototype 이라고 되어있는 부분이 프로토타입 입니다.

     

    프로토타입은 내부적으로 constructor 프로퍼티를 소유하며, 생성자 함수를 가리킵니다. 

    참고로, 모든 객체는 __proto__ 접근자 프로토타입을 통해 상위 프로토타입을 참조할 수 있습니다. (부모를 상속받을 수 있습니다.)

     

    프로토타입 프로퍼티 / 메서드

    프로토타입에 묶여있는 프로퍼티/메서드를 의미합니다. 

    보통 인스턴스나 프로토타입 메서드 내에서 this를 사용하지 않는다면 정적 메서드로 바꿔도 동일하게 동작합니다.

    반대로 인스턴스를 참조할 일이 있다면 정적 메서드가 아닌 프로토타입 프로퍼티나 메서드로 정의해야만 합니다. 

     

     

    MDN과 같은 문서에서는 이를 명확히 분리하여 설명하고 있으니, 알아두는 게 좋습니다.

    정적메서드는 생성자함수.메서드 의 형식을 가지고, 프로토타입메서드는 생성자함수.prototype.메서드 의 형식을 가집니다.


    인스턴스 객체

    사실 그냥 객체라고 표현하곤 하는데, 저는 좀 더 명확하게 표현하고자 인스턴스 객체라고 표현하겠습니다.

    인스턴스 객체는 생성자 함수를 실행(new)함으로써 만들어지는 객체를 의미합니다. 

     

    하지만, 꼭 생성자 함수가 아니라 리터럴 표현식을 통해 객체가 만들어질 수도 있습니다. 

    생성 방식에 약간의 차이는 있지만 결국 본질은 같습니다. (그래서 인스턴스라는 말을 빼고 그냥 객체라고 표현하는 것 같습니다.)

     

    여튼 객체에는 프로퍼티와 메서드가 존재합니다. 

    본인만의 프로퍼티와 메서드가 존재하지 않는다면, 프로토타입 체인을 따라서 프로퍼티와 메서드를 검색합니다.

    참고로 객체의 프로토타입은 __proto__ 접근자 프로퍼티를 통해 접근이 가능합니다. 

     

    그리고 인스턴스 객체에는 [[Prototype]] 내부 슬롯이 존재합니다. 

    cf) 내부 슬롯은 간단하게 외부에서 접근할 수 없는 변수와 같다고 생각하시면 됩니다. 

     

    이 [[Prototype]] 내부 슬롯의 값에는 프로토타입(프로토타입에 대한 참조)이 할당됩니다. 

     

    __proto__ 접근자 프로퍼티

    모든 객체가 사용할 수 있는 접근자 프로퍼티입니다. 이를 통해 상위 프로토타입을 참조할 수 있습니다.

    '모든 객체'가 사용가능하다는 건 결국 무슨 말일까요? 눈치 채신 분도 계시겠지만, 결국 __proto__ 접근자 프로퍼티는 

    Object.prototype 의 메서드입니다. 즉, Object.prototype.__proto__ 를 상속받아와서 사용하는 겁니다.

     

    모든 객체는 특별히 Object.create(null)을 하지 않는 이상, Object.prototype을 상속 받으니까요!

     

     

    왜 __proto__ 접근자 프로퍼티를 제공할까? 

    그 이유는, 비정상적인 상호 참조(순환 참조)시 에러를 발생시키기 위해서입니다. 

    const parent = {};
    const child = {};
    
    // child의 프로토타입을 parent로 설정
    child.__proto__ = parent;
    
    // parent의 프로토타입을 child로 설정 (오류 발생)
    parent.__proto__ = child; 
    // TypeError: cyclic __proto__ value

     

    서로가 서로의 프로토타입이 된다는 건 정말 이상한데, 만약 __proto__ 접근자를 통한 [[Prototype]] 간접 접근이 아니라, 직접 접근을 했다면 해당 경우를 방지하지 못했을 겁니다. 

     

    또한 내부적으로 __proto__ 접근자 프로퍼티는 getter/setter 라고 부르는 접근자 함수를 통해 프로토타입의 값을 취득해오거나 할당할 수 있게 해줍니다. 바로 위의 코드에서는 __proto__가 [[Set]] 에 할당되어 있는 함수를 호출하여 할당할 수 있도록 돕습니다. 

     

    하지만 __proto__ 또한 완전한 것은 아닙니다.

    프로토타입이 존재하지 않는 경우가 있을 수 있기 때문입니다.

    이런 경우에는, Object.prototype의 메서드도 사용이 불가능합니다. 

    //obj는 프로토타입 체인의 종점이 된다. Object.prototype 조차도 상속받지 않는다.
    const obj = Object.create(null);
    
    console.log(obj.__proto__); //undefined
    
    //따라서, 아래의 방식을 추천한다.
    console.log(Object.getPrototypeOf(obj)); //null

     

    따라서, 프로토타입에 대한 접근 시에는 정적 메서드인 

    Object.getPrototypeOfObject.setPrototypeOf 사용을 강력히 권장합니다.

     


    프로토타입의 생성 시기

    프로토타입은 생성자 함수가 생길 때 더불어 생성됩니다. 즉, 프로토타입과 생성자 함수는 쌍(Pair)으로 존재합니다.

     

    더욱 자세히 설명하면, 함수 정의(constructor)가 평가되어 함수 객체가 생성될 때, 프로토타입도 같이 생성이 됩니다.

    이때 생성자 함수의 prototype의 프로퍼티에는 프로토타입이, 프로토타입의 constructor 프로퍼티에는 생성자 함수가 할당됩니다.

    서로의 프로퍼티에 값이 동시에 할당되면서 연결됩니다. 

     

    이후, 인스턴스 객체를 생성하면 해당 객체의 [[Prototype]] 내부 슬롯에 생성자 함수의 prototype 프로퍼티의 값이 할당되고,

    __proto__를 통해 간접적으로 [[Prototype]] 내부 슬롯에 접근하여 프로토타입에 대한 참조를 할 수 있게 되는 겁니다.

     

     

     


    객체 생성 방식에 따른 프로토타입의 결정

    객체 생성 방식은 정말 다양하나, 하나의 공통점은 OrdinaryObjectCreate 라는 추상 연산을 통해 만들어진다는 겁니다. 

    이 추상 연산은 필수적으로 자신이 생성할 객체의 프로토타입을 인자로 받습니다. 또한 추가적으로 이 객체에 추가할 프로퍼티 목록을 옵션으로 전달할 수 있습니다. 

     

    추상 연산 OrdinaryObjectCreate 은 다음의 과정을 거칩니다.

     

    1. 빈 객체를 생성한다.

    2. 프로퍼티 목록이 인수로 전달되었을 경우, 해당 프로퍼티들을 객체에 추가한다.

    3. 인수로 전달받은 프로토타입을 자신이 생성한 객체의 [[Prototype]] 내부 슬롯에 할당한다.

    4. 생성한 객체를 반환한다. 

     

    참고로 프로토타입 인수를 전달하지 않았을 경우(null, undefined), Object.prototype을 넣습니다.

     

     

     

    그래서 결론만 말하면, 아래와 같습니다.

     

    1. 리터럴 표기법으로 객체를 생성하면 프로토타입은 아래와 같습니다.

     

     

     

    2. 생성자 함수로 객체를 생성하면 생성자함수.prototype이 프로토타입이 됩니다.

     

    예를 들어, Object 빌트인 생성자를 통해 객체를 만들면 해당 객체의 프로토타입은 Object.prototype이 됩니다.

    하지만 만약 사용자 정의 Person 생성자를 통해 객체를 만들면 해당 객체의 프로토타입은 Person.prototype이 됩니다. 

     


    프로토타입 체인 

    프로토타입 체인은, 자바스크립트가 객체 지향 프로그래밍의 상속을 구현하기 위한 핵심 메커니즘입니다.

    모든 객체는 기본적으로 Object.prototype을 상속 받습니다. 그리고 Object.prototype의 [[Prototype]] 은 null 입니다.

    Object.create(null)을 하지 않는 한, Object.prototype가 프로토타입의 종점에 존재하기 때문입니다.

     

     

     

    특정 객체에 프로퍼티나 메서드가 존재하지 않는다면 이 프로토타입 체인을 따라 부모 역할을 하는 프로토타입을 하나 하나 탐색합니다.

    참고로 위와 같이 객체와 객체의 프로토타입이 동일한 메서드를 갖고 있다면 부모의 메서드는 가려지는 프로퍼티 섀도잉이 얼어납니다.

    (오버라이딩이란, 상위 클래스가 가지고 있는 메서드를 하위 클래스가 재정의하여 사용하는 방식을 의미합니다)

     

    const Person = (function () {
      // 생성자 함수
      function Person(name) {
        this.name = name;
      }
    
      // 프로토타입 메서드
      Person.prototype.sayHello = function () {
        console.log(`Hi! My name is ${this.name}`);
      };
    
      // 생성자 함수를 반환
      return Person;
    })();
    
    const me = new Person("Lee");
    
    // 인스턴스 메서드
    me.sayHello = function () {
      console.log(`Hey! My name is ${this.name}`);
    };
    
    // 인스턴스 메서드가 호출된다. 프로토타입 메서드는 인스턴스 메서드에 의해 가려진다.
    me.sayHello(); // Hey! My name is Lee

     

    여기서 인스턴스 메서드를 제거하고 싶다면 다음과 같이 코드를 작성하면 됩니다.

    //인스턴스 메서드를 삭제한다.
    delete me.sayHello

     

    다만 위의 코드를 아무리 반복해도 프로토타입 체인을 따라서 프로토타입의 메서드를 삭제하지는 않습니다.

    만약 프로토타입의 메서드를 삭제하고 싶다면 아래와 같은 코드가 필요합니다.

    delete Person.prototype.sayHello ;

     

     


    프로토타입의 교체

    프로토타입도 원하면 교체할 수 있습니다.

    Person.prototype = {}

     

    이 방식으로 변경한다면, 기본적으로 가지는 constructor 프로퍼티가 빠져버리게 되니 주의해야합니다.

    참고로 프로토타입의 교체는 권장되지 않습니다. (객체간의 상속 관계를 동적으로 변경하는 건 꽤나 번거롭기 때문입니다.)

     

    따라서 상속 관계를 인위적으로 설정해야한다면 '직접 상속'을 이용하거나 클래스를 이용하는 방식을 추천합니다.

    직접 상속이란 Object.create을 사용하는 방식입니다. (인수로 넣은 값은 프로토타입으로 설정합니다)

     

     


    instanceOf 연산자

    객체 instanceOf 생성자 함수

     

    단순하게 해당 생성자로 만들어진 객체인지를 판단하는 것이 아니라, 

    생성자 함수의 prototype 에 바인딩 된 객체가 좌변 객체의 프로토타입 체인 상에 존재하면 true를, 아니면 false를 반환합니다. 


    프로퍼티 확인 연산자 2가지

    in 연산자

    객체에 해당 프로퍼티가 존재하는지 확인합니다. 프로토타입 체인 상에 존재하는 모든 프로퍼티를 확인해봅니다.

    //key : 프로퍼티의 키를 나타내는 문자열
    //object: 객체로 평가되는 표현식
    
    key in object
    
    //예시
    const person = {}
    
    console.log('name' in person);

     

     Object.prototype.hasOwnProperty 메서드

    console.log(person.hasOwnProperty( 'name' ));

     

    해당 객체만의 '고유한' key로 존재하는지 확인합니다. 즉, 프로토타입 체인을 따라 검색하지 않습니다. 

     


    for ... in 문 

    해당 객체의 모든 프로퍼티를 가져옵니다. (프로토타입 체인 상에 있는 프로퍼티까지)

    따라서 배열과 같은 경우에는 for ... of 문이나 forEach 문을 사용하는 것이 더욱 정확하게 동작할 수 있습니다.

    (배열도 객체이므로)

     

    참고로 모든 프로퍼티를 단순하게 찍어내는게 아니라, 프로퍼티 어트리뷰트 [[Enumerable]] 값이 true인 프로퍼티만 찍어냅니다.

     

     

     Object .keys/values/entries 메서드

    위의 for... in 은 추가 처리를 해줘야한다는 단점이 있습니다. (아니면 모든 프로퍼티를 가져오므로)

    따라서 key만을 배열로 반환하거나, value만을 배열로 반환하거나, key-value 쌍의 배열을 배열로 반환하는 메서드를 사용하는 걸 추천합니다.

     

    const person = {
      name: "Lee",
      address: "Seoul",
      __proto__: { age: 20 },
    };
    
    console.log(Object.keys(person)); // ["name", "address"]
    
    // ES8에서 도입된 Object.values 메서드는 객체 자신의 열거 가능한 프로퍼티 값을 배열로 반환한다.
    console.log(Object.values(person)); // ["Lee", "Seoul"]
    
    // ES8에서 도입된 Object.entries 메서드는 객체 자신의 열거 가능한 프로퍼티 키와 값의 쌍을 배열에 담아 반환한다.
    console.log(Object.entries(person)); // [["name", "Lee"], ["address", "Seoul"]]
    
    Object.entries(person).forEach(([key, value]) => console.log(key, value));
    // name Lee
    // address Seoul

     

     


     

    이제 프로토타입과 그와 연관된 개념들(생성자 함수, 정적 메서드, Enurmable, 내부 슬롯 등등), 추가로 연산자들까지 살펴보았습니다.

    꽤 긴 글이 되었는데, 이 덕분에 프로토타입에 대해서 확실히 이해할 수 있는 시간이 되었네요.

     

     

    참고 서적 ) 모던 자바스크립트 Deep Dive (저자-이웅모)

    COMMENT