프론트엔드/Javascript

프로토타입 상속

syleemomo 2024. 3. 8. 12:29
728x90

개발을 하다 보면 기존에 있는 기능을 가져와 확장해야 하는 경우가 있다. 

사람에 관한 프로퍼티와 메서드를 가진 user라는 객체가 있는데, user와 상당히 유사하지만 약간의 차이가 있는 admin과 guest 객체를 만들어야 한다고 가정해 보자! 이때 "user의 메서드를 복사하거나 다시 구현하지 않고 user에 약간의 기능을 얹어 admin과 guest 객체를 만들 수 있지 않을까?"라는 생각이 들 수 있다. 

자바스크립트 언어의 고유 기능인 프로토타입 상속(prototypal inheritance) 을 이용하면 위와 같은 생각을 실현할 수 있다.

 

* [[Prototype]] 프로퍼티 

자바스크립트 객체는 명세서에서 명명한 [[Prototype]] 이라는 숨겨진 프로퍼티가 있다. 해당 프로퍼티의 값은 null 이거나 다른 객체에 대한 참조(주소값)가 되는데, 참조하는 객체를 "프로토타입(prototype)" 또는 "원형"이라고 한다. 

프로토타입(prototype)

객체에서 프로퍼티 값을 읽으려고 할때 해당 프로퍼티가 없으면 자바스크립트는 자동으로 참조하고 있는 프로토타입으로 올라가 동일한 이름의 프로퍼티를 찾으려고 한다. 이러한 동작방식을 "프로토타입 상속"이라고 하며, 코드를 구현할때 프로토타입 상속을 이용하여 작성된 것들이 많다. 

const animal = {
    eats: true
}
const rabbit = {
    jumps: true
}
console.log(rabbit)

현재 animal 과 rabbit 객체가 있다. rabbit 객체를 콘솔에 출력하면 아래와 같다. 현재 rabbit 객체에는 jumps 프로퍼티만 존재한다. 

rabbit 객체

 

[[Prototype]] 프로퍼티는 내부 프로퍼티이면서 숨김 프로퍼티이기 때문에 직접 변경하기는 힘들지만, 다양한 방법을 사용하여 값을 설정할 수 있다. 

const animal = {
    eats: true
}
const rabbit = {
    jumps: true
}

// rabbit 은 animal 을 상속한다.
rabbit.__proto__ = animal 
console.log(rabbit)

rabbit은 __proto__ 프로퍼티를 사용하여 상속하고 싶은 프로토타입(animal)을 설정할 수 있다. 아래와 같이 현재 rabbit 에는 jumps 프로퍼티만 있지만, animal을 상속하면서 eats 라는 프로퍼티도 사용이 가능하다. 

animal 객체 상속

__proto__는 [[Prototype]]용 getter, setter 이다. 즉, 해당 프로퍼티를 사용하여 다른 객체를 상속받을 수 있다. 또한, 상속받은 객체를 조회할 수도 있다. 하위 호완성 (올드 브라우저) 때문에 여전히 __proto__ 를 사용하고 있지만, 최근에 작성된 코드는 __proto__ 대신에 Object.getPrototypeOf 나 Object.setPrototypeOf 메서드를 사용하여 프로토타입을 설정하거나 조회할 수 있다. 

const animal = {
    eats: true
}
const rabbit = {
    jumps: true
}

// rabbit 은 animal 을 상속한다.
Object.setPrototypeOf(rabbit, animal)
console.log(rabbit)
console.log(Object.getPrototypeOf(rabbit))

또한, __proto__ 는 브라우저 환경에서만 지원하도록 자바스크립트 명세서에 규정하고 있지만, 실제로는 서버를 포함한 모든 호스트 환경에서 __proto__를 사용할 수 있다. 

const animal = {
    eats: true
}
const rabbit = {
    jumps: true
}

// rabbit 은 animal 을 상속한다.
rabbit.__proto__ = animal

// 프로퍼티 eats과 jumps를 rabbit에서도 사용할 수 있다.
console.log( rabbit.eats ) // true 
console.log( rabbit.jumps ) // true

rabbit 객체에서 eats 프로퍼티를 조회할때 해당 객체에는 eats 프로퍼티가 없기 때문에 자바스크립트는 자동으로 상속받은 프로토타입 객체([[Prototype]])인 animal에서 eats 프로퍼티를 찾는다. 다시말해, rabbit 에서 animal 의 프로퍼티와 메서드를 사용할 수 있게 되었다. eats 와 같이 프로토타입에서 상속받은 프로퍼티를 "상속 프로퍼티(inherited property)라고 한다.

rabbit 과 animal 의 상속관계

 

상속 프로퍼티를 사용하여 animal 에 정의된 메서드를 rabbit 에서 호출해보자!

const animal = {
    eats: true,
    walk() {
        console.log("동물이 걷습니다.")
    }
}
  
const rabbit = {
    jumps: true,
    __proto__: animal
}
  
// 메서드 walk는 rabbit의 프로토타입인 animal에서 상속받았다.
rabbit.walk() // 동물이 걷습니다.

아래 그림과 같이 프로토타입(animal)에서 walk 메서드도 함께 상속받았기 때문에 rabbit 에 walk 메서드가 없더라도 사용이 가능하다. 

walk 메서드 상속

 

* 프로토타입 체인 (prototype chain)

프로토타입 체인은 현재 객체에 조회하고자 하는 프로퍼티나 메서드가 없는 경우, 참조하고 있는 상위 객체로 올라가면서 해당 프로퍼티나 메서드를 찾는 과정이다. 

const animal = {
    eats: true,
    walk() {
        console.log("동물이 걷습니다.")
    }
}
  
const rabbit = {
    jumps: true,
    __proto__: animal
}
  
const longEar = {
    earLength: 10,
    __proto__: rabbit
}
  
// longEar 객체는 walk 메서드를 프로토타입 체인을 통해 animal 객체로부터 상속받았다.
longEar.walk() // 동물이 걷습니다.

// longEar 객체는 jumps 프로퍼티를 프로토타입 체인을 통해 rabbit 객체로부터 상속받았다.
console.log(longEar.jumps) // true

아래 그림은 프로토타입 체인을 그림으로 표현한 것이다.

프로토타입 체인

프로토타입 체이닝에는 두가지 제약사항이 있다. 

const animal = {
    eats: true,
    walk() {
        console.log("동물이 걷습니다.")
    },
    __proto__: longEar // 순환참조(circular reference)
}
  
const rabbit = {
    jumps: true,
    __proto__: animal
}
  
const longEar = {
    earLength: 10,
    __proto__: rabbit
}
  
// longEar 객체는 walk 메서드를 프로토타입 체인을 통해 animal 객체로부터 상속받았다.
longEar.walk() // 동물이 걷습니다.

// longEar 객체는 jumps 프로퍼티를 프로토타입 체인을 통해 rabbit 객체로부터 상속받았다.
console.log(longEar.jumps) // true

먼저 순환참조(circular reference)는 허용하지 않는다. __proto__를 이용하여 닫힌 형태로 다른 객체를 참조하면 에러가 발생한다. 해당 코드는 아래와 같이 에러가 발생된다. 

순환참조 에러

const animal = {
    eats: true,
    walk() {
        console.log("동물이 걷습니다.")
    },
}
  
const rabbit = {
    jumps: true,
    __proto__: animal
}
  
const longEar = {
    earLength: 10,
    __proto__: "rabbit" // 문자열 무시
}
  
// longEar 객체는 walk 메서드를 프로토타입 체인을 통해 animal 객체로부터 상속받았다.
longEar.walk() // 동물이 걷습니다.

// longEar 객체는 jumps 프로퍼티를 프로토타입 체인을 통해 rabbit 객체로부터 상속받았다.
console.log(longEar.jumps) // true

또한, __proto__의 값은 객체나 null 만 가능하다. 해당 코드와 같이 __proto__의 값으로 문자열을 설정하면 아래와 같이 에러가 발생한다. 즉, 상속이 되지 않는다. 

상속 실패

const animal = {
    eats: true,
    walk() {
        console.log("동물이 걷습니다.")
    },
}
  
const rabbit = {
    jumps: true,
    __proto__: animal
}
  
const longEar = {
    earLength: 10,
    __proto__: rabbit, // rabbit 상속
    __proto__: animal, // animal 상속 
}
  
// longEar 객체는 walk 메서드를 프로토타입 체인을 통해 animal 객체로부터 상속받았다.
longEar.walk() // 동물이 걷습니다.

// longEar 객체는 jumps 프로퍼티를 프로토타입 체인을 통해 rabbit 객체로부터 상속받았다.
console.log(longEar.jumps) // true

마지막으로 객체는 오직 하나의 객체로부터 상속받을수 있다. 만약 위와 같이 longEar 객체가 rabbit 과 animal 객체로부터 동시에 상속받으려고 하면 아래와 같은 에러가 발생한다. 

다수의 객체로부터 상속받으려고 하는 경우

 

* 프로토타입은 읽기전용이다

프로토타입 객체의 프로퍼티나 메서드는 읽을때만 사용하는 것이 좋다. 

const animal = {
    eats: true,
    walk() {
      /* 해당 메서드는 아래쪽 코드에 의해 변경된다. */
      console.log('동물이 걷습니다.')
    }
}

const rabbit = {
    __proto__: animal
}

rabbit.__proto__.walk = function() {
    console.log("토끼가 깡충깡충 뜁니다.")
}
rabbit.walk() // 변경된 animal 의 walk 호출

console.log(rabbit)
console.log(animal)

물론 위와 같이 __proto__ 프로퍼티를 사용하여 프로토타입인 animal 의 walk 메서드를 수정할 수도 있다. 하지만 이렇게 할 경우 animal 을 상속받는 다른 객체가 walk 메서드를 호출하면 이전과 동일하게 동작하지 않고, 변경된 메서드가 실행되므로 코드의 신뢰성을 보장받기 힘들다. 

프로토타입의 메서드를 수정한 경우

 

const animal = {
    eats: true,
    walk() {
      /* rabbit은 이제 이 메서드를 사용하지 않습니다. */
      console.log('동물이 걷습니다.')
    }
}

const rabbit = {
    __proto__: animal
}

rabbit.walk = function() {
console.log("토끼가 깡충깡충 뜁니다.")
}

rabbit.walk() // 토끼가 깡충깡충 뜁니다.

console.log(rabbit)
console.log(animal)

이러한 이유로 프로토타입의 메서드를 수정하거나, 프로퍼티를 추가하려면 해당 객체에 직접 해야 한다. 해당 코드는 rabbit 객체에서 프로토타입(animal)의 walk 메서드를 수정한다. 이때 animal 의 walk 이 아니라 rabbit 에 직접 walk 메서드를 재정의한다. rabbit.walk() 를 실행하면 프로토타입(animal)의 walk 메서드가 아니라 rabbit에 직접 추가한 walk 메서드가 실행된다.

해당 객체에 직접 메서드를 재정의한 경우
rabbit 에 walk 메서드 재정의

 

 

하지만 접근자 프로퍼티(accessor property)는 setter 함수를 사용하여 프로퍼티에 값을 할당하므로 admin 에 접근자 프로퍼티(fullName)가 추가되는게 아니라 프로토타입(user)의 setter 함수가 호출된다. 이때 fullName (setter) 에 정의된 this 는 fullName (setter)을 호출한 당사자인 admin 을 가리킨다. 그러므로 setter 가 실행되면 admin 에 name, surname 프로퍼티가 추가된다. 

const user = {
    name: "John",
    surname: "Smith",

    set fullName(value) {
        [this.name, this.surname] = value.split(" ")
    },

    get fullName() {
        return `${this.name} ${this.surname}`
    }
}
  
const admin = {
    __proto__: user,
    isAdmin: true
}
  
console.log(admin.fullName) // John Smith 

// user 의 setter 함수가 실행된다.
// admin 에 name, surname 프로퍼티가 추가된다.
admin.fullName = "Alice Cooper" 

console.log(admin.fullName) // Alice Cooper, setter에 의해 추가된 admin의 프로퍼티(name, surname)에서 값을 가져옴
console.log(user.fullName) // John Smith, 본래 user에 있었던 프로퍼티 값

console.log(admin)
console.log(user)

admin 의 fullName 접근자 프로퍼티를 조회하면 admin 의 프로토타입(user)의 fullName (getter)를 호출한다. 이때도 마찬가지로 fullName (getter)에 정의된 this 는 admin 을 가리킨다. 하지만 setter 를 호출하기 전이므로 이번에는 admin 에서 name, surname 프로퍼티를 찾지 못한다. 왜냐하면 현재 admin 에는 isAdmin 프로퍼티만 있기 때문이다. 그러므로 name, surname 을 찾기 위하여 프로토타입인 user 까지 프로토타입 체인을 타고 올라간다. 그래서 user 의 name, surname 이 출력된다.  

프로토타입(user)의 접근자 프로퍼티(fullName)를 사용한 경우

 

 

* this 의 의미

앞선 예제에서 이런 의문이 들수 있다. getter, setter 의 this 에는 어떤 값이 들어가지? this.name, this.surname 에 값을 할당하면 user 에 저장될까? 아니면 admin 에 저장될까? 

const user = {
    name: "John",
    surname: "Smith",

    set fullName(value) {
        [this.name, this.surname] = value.split(" ")
    },

    get fullName() {
        return `${this.name} ${this.surname}`
    }
}
  
const admin = {
    __proto__: user,
    isAdmin: true
}
  
console.log(admin.fullName) // John Smith 

// user 의 setter 함수가 실행된다.
// admin 에 name, surname 프로퍼티가 추가된다.
admin.fullName = "Alice Cooper" 

console.log(admin.fullName) // Alice Cooper, setter에 의해 추가된 admin의 프로퍼티(name, surname)에서 값을 가져옴
console.log(user.fullName) // John Smith, 본래 user에 있었던 프로퍼티 값

console.log(admin)
console.log(user)

답은 간단하다. 메서드를 admin 에서 호출하든 user(프로토타입)에서 호출하든 상관없이 해당 메서드를 호출한 점(.) 앞의 객체를 가리킨다. 예를 들어 walk() 메서드 안에 this 가 있다고 가정해보자! 이때 admin.walk() 의 this 는 admin 이다. user.walk() 의 this 는 user 를 가리키게 된다. 그러므로 admin.fullName 으로 setter 나 getter 를 호출하면 해당 함수안의 this 는 admin 이다.  

 

프로토타입 객체에 수많은 메서드를 만들어두고, 여러 객체에서 상속받아 사용하는 경우 이러한 특징을 알아두는 것이 좋다. 

// animal엔 다양한 메서드가 정의되어 있다.
const animal = {
    walk() {
        if (!this.isSleeping) {
        alert(`동물이 걸어갑니다.`)
        }
    },
    sleep() {
        this.isSleeping = true
    }
}
  
const rabbit = {
    name: "하얀 토끼",
    __proto__: animal
}
  
// rabbit에 새로운 프로퍼티 isSleeping을 추가하고 그 값을 true로 변경한다.
rabbit.sleep()

console.log(rabbit.isSleeping) // true
console.log(animal.isSleeping) // undefined (프로토타입에는 isSleeping이라는 프로퍼티가 없다.)

console.log(rabbit)
console.log(animal)

해당 코드는 animal 로부터 상속받은 sleep 메서드를 사용하지만, sleep 메서드 안의 this 는 프로토타입인 animal 이 아니라 sleep 메서드를 호출한 rabbit 이다. 그러므로 sleep 메서드로 this.isSleeping = true 코드를 실행하면 rabbit 에 isSleeping 프로퍼티가 새로 생성되고, true 값으로 저장된다. 

프로토타입에서 상속받은 메서드를 실행한 경우
프로토타입의 메소드를 호출한 경우

 

이와 마찬가지로 아래와 같이 rabbit 뿐만 아니라 bird, snake 도 animal 을 상속받는다고 해보자!

// animal엔 다양한 메서드가 정의되어 있다.
const animal = {
    walk() {
        if (!this.isSleeping) {
        alert(`동물이 걸어갑니다.`)
        }
    },
    sleep() {
        this.isSleeping = true
    }
}
  
const rabbit = {
    name: "하얀 토끼",
    __proto__: animal
}
const bird = {
    name: "작은 참새",
    __proto__: animal 
}
const snake = {
    name: "무서운 뱀",
    __proto__: animal 
}
  
// rabbit에 새로운 프로퍼티 isSleeping을 추가하고 그 값을 true로 변경한다.
rabbit.sleep()
bird.sleep()
snake.sleep()

console.log(rabbit)
console.log(bird)
console.log(snake)

이때 상속받은 프로토타입의 sleep 메서드를 bird, snake 객체가 사용하면 아래와 같이 bird, snake 에도 isSleeping 프로퍼티가 추가된다. 다시말해, 프로토타입의 메서드는 공유될 수 있지만, 객체의 상태(프로퍼티)는 공유되지 않는다.

bird, snake 객체가 프로토타입 메서드를 실행한 경우

 

* for ~ in 반복문에서의 객체 프로퍼티 

const animal = {
    eats: true
  }
  
  const rabbit = {
    jumps: true,
    __proto__: animal
  }
  
// Object.keys는 객체 자신의 키만 반환한다.
console.log(Object.keys(rabbit)) // jumps

// for..in은 객체 자신의 키와 상속 프로퍼티의 키 모두 조회한다.
for(let prop in rabbit) console.log(prop) // jumps, eats

for ~ in 반복문을 이용하여 객체의 프로퍼티를 조회할 수 있다. 이때 조회되는 프로퍼티는 프로토타입에서 상속받은 것도 포함된다. 

 

* 자신의 프로퍼티만 조회하는 경우 - hasOwnProperty, Object.keys, Object.values 

const animal = {
    eats: true
}

const rabbit = {
    jumps: true,
    __proto__: animal
}
  
for(let prop in rabbit) {
    const isOwn = rabbit.hasOwnProperty(prop)

    if (isOwn) {
        console.log(`객체 자신의 프로퍼티: ${prop}`) // 객체 자신의 프로퍼티: jumps
    } else {
        console.log(`상속 프로퍼티: ${prop}`) // 상속 프로퍼티: eats
    }
}

 객체의 hasOwnProperty(key) 메서드를 이용하면 해당 프로퍼티(key)가 자신의 것인지 상속받은 것인지 검사할 수 있다. 해당 프로퍼티가 자신의 것일때만 true 를 반환한다. 위의 코드에 대한 상속관계를 그림으로 나타내면 아래와 같다.

객체들의 상속관계

프로토타입 체인에 의하여 rabbit 은 animal 을 참조한다. animal 은 Object.prototype 을 참조한다. Object.prototype 은 null 을 참조한다. 참고로 animal 이 Object.prototype 을 상속받는 이유는 animal 을 객체 리터럴 방식으로 선언하였기 때문이다. 그림을 보면 for ~ in 반복문에서 사용한 hasOwnProperty 가 Object.prototype 에서 상속받은 것임을 확인할 수 있다. 

그런데 hasOwnProperty 를 상속받았으므로 상속 프로퍼티에 출력되어야 하는데 그렇지 않다. 이유는 hasOwnProperty 는 열거 가능한(enumerable) 프로퍼티가 아니기 때문이다. 

const animal = {
    eats: true
}

const rabbit = {
    jumps: true,
    __proto__: animal
}
  
console.log(Object.keys(rabbit))    // ['jumps']
console.log(Object.values(rabbit))  // [true]

객체의 키 또는 값만 추출해서 배열형태로 반환해주는 Object.keys 나 Object.values 메서드는 상속 프로퍼티는 제외하고 동작한다. 다시말해, 자기 자신의 프로퍼티만 조회한다. 

 

* 연습과제 1

객체 두 개를 이용해 쌍을 만들고 이를 수정하는 코드가 아래에 있다. 콘솔창에 어떤 값이 나올지 예측해보고 직접 확인해보자!

const animal = {
    jumps: null
}
const rabbit = {
    __proto__: animal,
    jumps: true
}
  
console.log( rabbit.jumps ) // ? 

delete rabbit.jumps

console.log( rabbit.jumps ) // ? 

delete animal.jumps

console.log( rabbit.jumps ) // ?

 

* 연습과제 2

__proto__를 사용해서, 프로퍼티 조회가 pockets → bed → table → head의 경로를 따르도록 하세요. pockets.pen은 table에 있는 3, bed.glasses는 head에 있는 1을 조회하면 된다.

const head = {
    glasses: 1
}

const table = {
    pen: 3
}

const bed = {
    sheet: 1,
    pillow: 2
}

const pockets = {
    money: 2000
}

 

* 연습과제 3

animal을 상속받는 rabbit이 있다. rabbit.eat()을 호출했을 때, animal과 rabbit 중 어떤 객체에 full 프로퍼티가 생길까? 

const animal = {
    eat() {
        this.full = true
    }
}

const rabbit = {
    __proto__: animal
}

rabbit.eat()

 

* 연습과제 4

hamster 객체를 상속받는 햄스터 speedy와 lazy가 있다고 가정해보자!

둘 중 한 마리에게만 먹이를 줘도, 다른 한 마리의 배 역시 꽉 차게 된다. 왜 그럴까? 어떻게 하면 이런 이상한 일이 일어나지 않게 할 수 있을까? 코드를 수정해서 음식을 먹은 햄스터만 배가 차도록 해보자!

const hamster = {
    stomach: [],

    eat(food) {
        this.stomach.push(food);
    }
}

const speedy = {
    __proto__: hamster
}

const lazy = {
    __proto__: hamster
}

// 햄스터 speedy가 음식을 먹습니다.
speedy.eat("apple")
console.log( speedy.stomach ) // apple

// 햄스터 lazy는 음식을 먹지 않았는데 배에 apple이 있다고 나오네요. 왜 그럴까요? lazy는 배가 비어있도록 고쳐주세요.
console.log( lazy.stomach ) // apple

 

728x90

'프론트엔드 > Javascript' 카테고리의 다른 글

비동기 - 프로미스 (Promise)  (0) 2024.03.14
비동기 - 콜백  (0) 2024.03.13
요소의 좌표 계산하기  (0) 2024.02.26
요소 사이즈와 스크롤  (0) 2024.02.25
반복문 (loop)  (0) 2024.02.17