JS의 프로토타입, 클래스, 상속

🔧 1. Prototype의 기초 개념

1-1. 모든 객체는 prototype을 가진다

// 기본 객체도 prototype을 가짐
const obj = { name: "김철수" };
console.log(obj.__proto__ === Object.prototype); // true

// 배열도 prototype을 가짐
const arr = [1, 2, 3];
console.log(arr.__proto__ === Array.prototype); // true

// 함수도 prototype을 가짐
function hello() {}
console.log(hello.__proto__ === Function.prototype); // true

/*
1. Prototype 객체는 하나만 존재

모든 인스턴스가 같은 prototype 객체를 참조
메소드들도 하나만 존재

2. 하지만 실행 시 this가 다름

3. 그래서 같은 메소드로 다른 결과

메소드는 공유하지만
각 인스턴스의 데이터(this.name, this.count 등)는 독립적
*/

1-2. Prototype Chain의 원리

const person = { name: "김철수" };

// person 객체에서 toString()을 찾는 과정:
// 1. person 객체 자체에서 찾기 → 없음
// 2. person.__proto__ (Object.prototype)에서 찾기 → 있음!
person.toString(); // "[object Object]"

// 이것이 prototype chain
// person → Object.prototype → null

1-3. 생성자 함수와 prototype

// 생성자 함수
function Person(name) {
  this.name = name;
}

// prototype에 메소드 추가
Person.prototype.greet = function() {
  return `Hello, I'm ${this.name}`;
};

Person.prototype.age = 0; // 기본값 설정

// 인스턴스 생성
const kim = new Person("김철수");
const lee = new Person("이영희");

// 메소드 사용 (prototype에서 상속받음)
console.log(kim.greet()); // "Hello, I'm 김철수"
console.log(lee.greet()); // "Hello, I'm 이영희"

// prototype은 모든 인스턴스가 공유
console.log(kim.__proto__ === Person.prototype); // true
console.log(lee.__proto__ === Person.prototype); // true
console.log(kim.__proto__ === lee.__proto__);    // true

1-4. new 키워드의 마법

function Person(name) {
  this.name = name;
  this.energy = 100;
}

// new Person("김철수")가 하는 일:
// 1. 빈 객체 생성: {}
// 2. 그 객체의 __proto__를 Person.prototype으로 설정
// 3. Person 함수를 그 객체 컨텍스트로 실행 (this = 새 객체)
// 4. 완성된 객체 반환

const kim = new Person("김철수");
// kim = { name: "김철수", energy: 100, __proto__: Person.prototype }

🎯 2. Class 문법 (ES6)

2-1. Class의 기본 구조

class Person {
  // constructor: 인스턴스 생성 시 호출
  constructor(name, age) {
    this.name = name;
    this.age = age;
    this.energy = 100;
  }
  
  // 메소드들 (자동으로 prototype에 추가됨)
  greet() {
    return `Hello, I'm ${this.name}`;
  }
  
  sleep() {
    this.energy = 100;
    return `${this.name} is sleeping`;
  }
  
  // 정적 메소드 (클래스 자체에 속함)
  static getSpecies() {
    return "Homo sapiens";
  }
}

// 사용법
const kim = new Person("김철수", 25);
console.log(kim.greet());        // "Hello, I'm 김철수"
console.log(Person.getSpecies()); // "Homo sapiens"

2-2. Class vs Function 생성자 비교

// Function 방식 (ES5)
function PersonFunc(name) {
  this.name = name;
}
PersonFunc.prototype.greet = function() {
  return `Hello, I'm ${this.name}`;
};

// Class 방식 (ES6) - 내부적으로는 같음
class PersonClass {
  constructor(name) {
    this.name = name;
  }
  
  greet() {
    return `Hello, I'm ${this.name}`;
  }
}

// 결과는 동일
const func = new PersonFunc("김철수");
const clss = new PersonClass("이영희");

console.log(func.__proto__ === PersonFunc.prototype); // true
console.log(clss.__proto__ === PersonClass.prototype); // true

2-3. Class의 특별한 기능들

class Person {
  constructor(name) {
    this.name = name;
    this._age = 0; // private 관례 (실제로는 public)
  }
  
  // Getter
  get age() {
    return this._age;
  }
  
  // Setter
  set age(value) {
    if (value < 0) {
      throw new Error("나이는 음수가 될 수 없습니다");
    }
    this._age = value;
  }
  
  // 정적 메소드
  static compare(person1, person2) {
    return person1.age - person2.age;
  }
}

const kim = new Person("김철수");
kim.age = 25;        // setter 호출
console.log(kim.age); // getter 호출, 25

// 정적 메소드 사용
const lee = new Person("이영희");
lee.age = 30;
console.log(Person.compare(kim, lee)); // -5

/*
정적 메소드:

클래스 자체에 속함 (인스턴스에 속하지 않음)
클래스명.메소드명() 으로 호출
this는 클래스 자체를 가리킴
인스턴스 데이터에 접근 불가
유틸리티, 팩토리, 검증 함수 등에 적합

인스턴스 메소드:

각 인스턴스에 속함
인스턴스.메소드명() 으로 호출
this는 해당 인스턴스를 가리킴
인스턴스 데이터에 접근 가능

쉽게 기억하기: "인스턴스 없이도 쓸 수 있는 기능 = 정적 메소드"
*/

🔗 3. Prototype 방식 상속

3-1. 단계별 상속 구현

// 1단계: 부모 생성자
function Animal(name, habitat) {
  this.name = name;
  this.habitat = habitat;
  this.energy = 100;
}

Animal.prototype.eat = function(food) {
  this.energy += 20;
  return `${this.name}가 ${food}를 먹었습니다`;
};

Animal.prototype.sleep = function() {
  this.energy = 100;
  return `${this.name}가 잠을 잡니다`;
};

// 2단계: 자식 생성자
function Dog(name, habitat, breed) {
  // 부모 생성자 호출 (중요!)
  Animal.call(this, name, habitat);
  this.breed = breed;
  this.loyalty = 100;
}

// 3단계: prototype chain 연결 (핵심!)
Dog.prototype = Object.create(Animal.prototype);

// 4단계: constructor 복구 (중요!)
Dog.prototype.constructor = Dog;

// 5단계: 자식만의 메소드 추가
Dog.prototype.bark = function() {
  this.energy -= 10;
  return `${this.name}가 멍멍!`;
};

Dog.prototype.wagTail = function() {
  this.loyalty += 5;
  return `${this.name}가 꼬리를 흔듭니다`;
};

// 6단계: 부모 메소드 오버라이드
Dog.prototype.eat = function(food) {
  // 부모 메소드 호출
  const result = Animal.prototype.eat.call(this, food);
  
  // 자식만의 추가 로직
  if (food === "뼈다귀") {
    this.energy += 10;
    return result + " (뼈다귀 보너스!)";
  }
  return result;
};

3-2. 복잡한 상속 구조

// 3세대 상속: Animal → Dog → GermanShepherd
function GermanShepherd(name, habitat) {
  Dog.call(this, name, habitat, "German Shepherd");
  this.trainedSkills = [];
}

GermanShepherd.prototype = Object.create(Dog.prototype);
GermanShepherd.prototype.constructor = GermanShepherd;

GermanShepherd.prototype.guard = function() {
  this.energy -= 15;
  return `${this.name}가 경비를 섭니다`;
};

GermanShepherd.prototype.learn = function(skill) {
  this.trainedSkills.push(skill);
  return `${this.name}가 ${skill}을 배웠습니다`;
};

// 사용법
const rex = new GermanShepherd("렉스", "집");
console.log(rex.eat("뼈다귀"));   // Animal + Dog 로직
console.log(rex.bark());         // Dog 로직
console.log(rex.guard());        // GermanShepherd 로직
console.log(rex instanceof GermanShepherd); // true
console.log(rex instanceof Dog);            // true
console.log(rex instanceof Animal);         // true

🎯 4. Class 방식 상속

4-1. extends와 super 사용법

// 부모 클래스
class Animal {
  constructor(name, habitat) {
    this.name = name;
    this.habitat = habitat;
    this.energy = 100;
  }
  
  eat(food) {
    this.energy += 20;
    return `${this.name}가 ${food}를 먹었습니다`;
  }
  
  sleep() {
    this.energy = 100;
    return `${this.name}가 잠을 잡니다`;
  }
  
  static getKingdom() {
    return "Animalia";
  }
}

// 자식 클래스
class Dog extends Animal {
  constructor(name, habitat, breed) {
    super(name, habitat);  // 부모 constructor 호출
    this.breed = breed;
    this.loyalty = 100;
  }
  
  bark() {
    this.energy -= 10;
    return `${this.name}가 멍멍!`;
  }
  
  wagTail() {
    this.loyalty += 5;
    return `${this.name}가 꼬리를 흔듭니다`;
  }
  
  // 부모 메소드 오버라이드
  eat(food) {
    const result = super.eat(food);  // 부모 메소드 호출
    
    if (food === "뼈다귀") {
      this.energy += 10;
      return result + " (뼈다귀 보너스!)";
    }
    return result;
  }
  
  // 정적 메소드도 상속 가능
  static getSpecies() {
    return "Canis lupus";
  }
}

4-2. 깊은 상속 구조

class GermanShepherd extends Dog {
  constructor(name, habitat) {
    super(name, habitat, "German Shepherd");
    this.trainedSkills = [];
  }
  
  guard() {
    this.energy -= 15;
    return `${this.name}가 경비를 섭니다`;
  }
  
  learn(skill) {
    this.trainedSkills.push(skill);
    return `${this.name}가 ${skill}을 배웠습니다`;
  }
  
  // 조부모 메소드까지 접근
  eat(food) {
    if (food === "전용사료") {
      // 할아버지(Animal) 메소드 직접 호출은 불가능
      // 하지만 부모(Dog) 메소드를 통해 간접 접근
      const result = super.eat(food);
      this.trainedSkills.forEach(skill => {
        this.energy += 5; // 훈련된 스킬당 보너스
      });
      return result + ` (${this.trainedSkills.length}개 스킬 보너스!)`;
    }
    return super.eat(food);
  }
}

🔍 5. 두 방식의 상세 비교

5-1. 문법적 차이점

// ========== Prototype 방식 ==========
function Animal(name) {
  this.name = name;
}

function Dog(name, breed) {
  Animal.call(this, name);      // 부모 생성자 호출
  this.breed = breed;
}

Dog.prototype = Object.create(Animal.prototype);  // 상속 설정
Dog.prototype.constructor = Dog;                   // constructor 복구

Dog.prototype.bark = function() {                  // 메소드 추가
  return `${this.name} barks`;
};

Dog.prototype.speak = function() {                 // 오버라이드
  return Animal.prototype.speak.call(this) + " woof!";
};

// ========== Class 방식 ==========
class Animal {
  constructor(name) {
    this.name = name;
  }
}

class Dog extends Animal {        // extends로 상속
  constructor(name, breed) {
    super(name);                  // super로 부모 호출
    this.breed = breed;
  }
  
  bark() {                        // 메소드 추가
    return `${this.name} barks`;
  }
  
  speak() {                       // 오버라이드
    return super.speak() + " woof!";
  }
}

5-2. 실행 시점과 동작

// 두 방식 모두 동일한 prototype chain 생성
const prototypeDog = new PrototypeDog("바둑이", "진돗개");
const classDog = new ClassDog("멍멍이", "골든리트리버");

// 내부 구조 동일
console.log(prototypeDog.__proto__ === PrototypeDog.prototype); // true
console.log(classDog.__proto__ === ClassDog.prototype);         // true

// instanceof 결과도 동일
console.log(prototypeDog instanceof PrototypeDog);  // true
console.log(prototypeDog instanceof Animal);        // true
console.log(classDog instanceof ClassDog);          // true
console.log(classDog instanceof Animal);            // true

5-3. 장단점 비교

Prototype 방식:

  • ✅ 더 명시적, 내부 동작 이해하기 좋음
  • ✅ 메모리 사용량 조금 더 효율적
  • ✅ 동적으로 prototype 수정 가능
  • ❌ 문법이 복잡하고 실수하기 쉬움
  • ❌ constructor 복구 등 보일러플레이트 많음

Class 방식:

  • ✅ 직관적이고 다른 언어와 유사
  • ✅ 실수할 여지가 적음
  • ✅ super 키워드로 부모 접근 쉬움
  • ✅ 정적 메소드, getter/setter 등 추가 기능
  • ❌ 내부 동작이 숨겨져 있음
  • ❌ hoisting이 되지 않음

🎯 6. 실무에서의 사용법과 권장사항

6-1. 현대적 사용 패턴

// 현대적 Class 사용법
class ApiClient {
  constructor(baseUrl, apiKey) {
    this.baseUrl = baseUrl;
    this.apiKey = apiKey;
  }
  
  async get(endpoint) {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      headers: { 'Authorization': `Bearer ${this.apiKey}` }
    });
    return response.json();
  }
}

class UserApiClient extends ApiClient {
  constructor(baseUrl, apiKey) {
    super(baseUrl, apiKey);
  }
  
  async getUser(id) {
    return this.get(`/users/${id}`);
  }
  
  async updateUser(id, data) {
    const response = await fetch(`${this.baseUrl}/users/${id}`, {
      method: 'PUT',
      headers: { 
        'Authorization': `Bearer ${this.apiKey}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(data)
    });
    return response.json();
  }
}

6-2. 권장사항과 베스트 프랙티스

✅ 추천:

  • 새로운 프로젝트에서는 Class 문법 사용
  • 상속보다는 Composition 패턴 고려
  • private 필드 사용 (최신 브라우저)
class BankAccount {
  #balance = 0;  // private 필드
  
  constructor(owner) {
    this.owner = owner;
  }
  
  deposit(amount) {
    this.#balance += amount;
    return this.#balance;
  }
  
  getBalance() {
    return this.#balance;
  }
}

❌ 주의사항:

  • 너무 깊은 상속 구조는 피하기
  • Prototype 직접 수정은 신중하게
  • Class와 Function 생성자 혼용 피하기

🔚 마무리

JavaScript의 상속은 Prototype 기반이지만, Class 문법으로 더 쉽게 사용할 수 있다.

핵심 이해:

  1. 모든 것은 Prototype Chain – Class도 내부적으로는 Prototype 사용
  2. 상속 = Prototype Chain 연결 – 부모의 속성과 메소드에 접근 가능
  3. 오버라이드 = 자식에서 재정의 – super로 부모 기능 활용 가능
  4. new 키워드가 모든 마법을 만듦 – 객체 생성과 prototype 연결

실무에서는 Class 문법을 사용하되, Prototype의 원리를 이해하고 있으면 JavaScript를 더 깊이 있게 활용할 수 있다.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *