생성자 함수 (Constructor Function)

혹시 이런 코드를 작성하다가 멈춘 적 있나요?

function User(name, email) {
  this.name = name;
  this.email = email;
}

const user1 = User('Alice', 'alice@example.com');
console.log(user1); // undefined - 왜?!

const user2 = new User('Bob', 'bob@example.com');
console.log(user2); // User { name: 'Bob', email: 'bob@example.com' } - 이건 또 왜?

저도 처음 JavaScript를 배울 때 이 코드가 너무 혼란스러웠습니다. “new를 빼먹으면 왜 undefined가 나올까?”, “this는 대체 무엇을 가리키는 걸까?” 이것이 바로 생성자 함수(Constructor Function)의 핵심 개념입니다.

상상해보세요. 케이크 공장이 있습니다. 공장에는 케이크를 만드는 틀(몰드)이 있고, 그 틀에 반죽을 부어 넣으면 똑같은 모양의 케이크가 계속 만들어집니다. 생성자 함수는 바로 이 “틀”과 같습니다. 틀 자체는 케이크가 아니지만, 틀을 사용하면 수백 개의 동일한 구조를 가진 케이크를 만들 수 있습니다.

JavaScript의 생성자 함수도 정확히 이렇게 동작합니다. 생성자 함수는 객체를 만드는 템플릿(설계도)이고, new 키워드는 그 템플릿을 사용해 실제 객체를 찍어내는 기계입니다.

왜 생성자 함수를 이해해야 할까요?

1. 클래스 문법의 기초

ES6부터 class 문법을 사용하지만, 내부적으로는 생성자 함수로 동작합니다.

// 겉모습: 클래스
class User {
  constructor(name) {
    this.name = name;
  }
}

// 실제 동작: 생성자 함수
typeof User; // "function"

클래스를 이해하려면 생성자 함수를 먼저 이해해야 합니다. 왜냐하면 클래스는 생성자 함수의 문법적 설탕(syntactic sugar)이기 때문입니다.

2. 레거시 코드 이해

많은 라이브러리와 레거시 코드베이스는 여전히 생성자 함수를 사용합니다.

// jQuery
const $element = new jQuery('#myElement');

// Date
const now = new Date();

// Promise
const promise = new Promise((resolve, reject) => {
  // ...
});

생성자 함수를 이해하지 못하면 이런 코드들이 어떻게 동작하는지 알 수 없습니다.

3. 디버깅과 문제 해결

실무에서 이런 버그를 만났을 때:

function Counter() {
  this.count = 0;
  this.increment = function() {
    this.count++;
  };
}

const counter = Counter(); // new 빼먹음!
counter.increment(); // TypeError: Cannot read property 'increment' of undefined

생성자 함수의 동작 원리를 이해해야 해결할 수 있습니다.

4. 메모리 효율적인 객체 생성

// ❌ 매번 메서드를 새로 생성 (메모리 낭비)
function BadUser(name) {
  this.name = name;
  this.greet = function() {
    return `Hello, ${this.name}`;
  };
}

// ✅ 프로토타입에 메서드 공유 (메모리 효율)
function GoodUser(name) {
  this.name = name;
}
GoodUser.prototype.greet = function() {
  return `Hello, ${this.name}`;
};

const user1 = new GoodUser('Alice');
const user2 = new GoodUser('Bob');

console.log(user1.greet === user2.greet); // true (같은 함수 참조!)

생성자 함수를 제대로 이해하면 메모리 효율적인 코드를 작성할 수 있습니다.

기본 개념: 생성자 함수란 무엇인가?

생성자 함수의 정의

생성자 함수는 새로운 객체를 생성하고 초기화하는 함수입니다. 일반 함수와 동일하지만, new 키워드와 함께 호출될 때 특별한 동작을 합니다.

// 생성자 함수 (관례적으로 대문자로 시작)
function Person(name, age) {
  // this = 새로 만들어질 객체를 가리킴
  this.name = name;
  this.age = age;
}

// new 키워드로 인스턴스 생성
const person1 = new Person('Alice', 25);
const person2 = new Person('Bob', 30);

console.log(person1.name); // "Alice"
console.log(person2.age);  // 30

console.log(person1 instanceof Person); // true

생성자 함수의 핵심 특징:

  1. 함수 이름이 관례적으로 대문자로 시작 (PascalCase)
  2. new 키워드와 함께 호출
  3. this를 통해 새 객체의 속성 설정
  4. 명시적으로 return하지 않아도 자동으로 새 객체 반환

new 키워드가 하는 4가지 일

MDN 공식 문서에 따르면, new Person('Alice', 25)를 실행하면 내부적으로:

// 1. 빈 객체 생성
const newInstance = {};

// 2. 프로토타입 연결
Object.setPrototypeOf(newInstance, Person.prototype);
// 또는: newInstance.__proto__ = Person.prototype;

// 3. 생성자 함수 실행 (this를 새 객체에 바인딩)
Person.call(newInstance, 'Alice', 25);

// 4. 반환값 처리
// - 생성자가 객체를 반환하면 그 객체 반환
// - 아니면 newInstance 반환
return newInstance;

이 과정을 시각화하면:

┌─────────────────────────┐
│   new Person('Alice')   │
└───────────┬─────────────┘
            │
            ↓
┌─────────────────────────┐
│  1. 빈 객체 생성         │
│  {}                      │
└───────────┬─────────────┘
            │
            ↓
┌─────────────────────────┐
│  2. 프로토타입 연결      │
│  {}.__proto__ =          │
│    Person.prototype      │
└───────────┬─────────────┘
            │
            ↓
┌─────────────────────────┐
│  3. 생성자 실행          │
│  this.name = 'Alice'     │
│  this.age = 25           │
└───────────┬─────────────┘
            │
            ↓
┌─────────────────────────┐
│  4. 객체 반환            │
│  { name: 'Alice',        │
│    age: 25 }             │
└─────────────────────────┘

일반 함수 vs 생성자 함수

// 일반 함수
function greet(name) {
  return `Hello, ${name}`;
}

const message = greet('Alice');
console.log(message); // "Hello, Alice"

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

const user = new User('Alice');
console.log(user); // User { name: 'Alice' }

// ❌ 생성자를 일반 함수처럼 호출
const result = User('Bob');
console.log(result); // undefined
console.log(window.name); // 'Bob' - 전역 오염!

차이점:

  • 일반 함수: 값을 계산하거나 작업을 수행하고 결과를 반환
  • 생성자 함수: 새로운 객체를 생성하고 초기화

실전 예제: 생성자 함수 작성하기

1. 기본 생성자 함수

function Car(brand, color, year) {
  // 속성 초기화
  this.brand = brand;
  this.color = color;
  this.year = year;
  this.mileage = 0; // 기본값
}

// 프로토타입에 메서드 추가 (모든 인스턴스가 공유)
Car.prototype.drive = function(distance) {
  this.mileage += distance;
  return `${this.color} ${this.brand}${distance}km 주행했습니다.`;
};

Car.prototype.getInfo = function() {
  return `${this.year}년식 ${this.color} ${this.brand} (주행거리: ${this.mileage}km)`;
};

// 인스턴스 생성
const tesla = new Car('Tesla', 'red', 2023);
const bmw = new Car('BMW', 'blue', 2022);

console.log(tesla.drive(100)); // "red Tesla가 100km 주행했습니다."
console.log(bmw.drive(50));    // "blue BMW가 50km 주행했습니다."

console.log(tesla.getInfo()); // "2023년식 red Tesla (주행거리: 100km)"
console.log(bmw.getInfo());   // "2022년식 blue BMW (주행거리: 50km)"

// 메서드는 공유되지만 데이터는 독립적
console.log(tesla.drive === bmw.drive); // true (같은 함수 참조)
console.log(tesla.mileage !== bmw.mileage); // true (다른 값)

2. 기본값이 있는 생성자 함수

function Product(name, price, category) {
  this.name = name;
  this.price = price || 0; // 기본값: 0
  this.category = category || 'uncategorized'; // 기본값: 'uncategorized'
  this.createdAt = new Date(); // 자동으로 현재 시간
}

// 더 현대적인 방법: ES6 기본 매개변수
function ModernProduct(name, price = 0, category = 'uncategorized') {
  this.name = name;
  this.price = price;
  this.category = category;
  this.createdAt = new Date();
}

const product1 = new Product('Laptop', 1200, 'Electronics');
const product2 = new Product('Notebook'); // price와 category는 기본값

console.log(product1); // Product { name: 'Laptop', price: 1200, category: 'Electronics', ... }
console.log(product2); // Product { name: 'Notebook', price: 0, category: 'uncategorized', ... }

3. 검증 로직이 있는 생성자 함수

function BankAccount(owner, initialBalance) {
  // 검증: owner는 필수
  if (!owner) {
    throw new Error('소유자 이름은 필수입니다.');
  }

  // 검증: 초기 잔액은 0 이상
  if (initialBalance < 0) {
    throw new Error('초기 잔액은 0 이상이어야 합니다.');
  }

  this.owner = owner;
  this.balance = initialBalance || 0;
  this.transactions = [];
}

BankAccount.prototype.deposit = function(amount) {
  if (amount <= 0) {
    throw new Error('입금액은 0보다 커야 합니다.');
  }

  this.balance += amount;
  this.transactions.push({
    type: 'deposit',
    amount: amount,
    date: new Date()
  });

  return this.balance;
};

BankAccount.prototype.withdraw = function(amount) {
  if (amount <= 0) {
    throw new Error('출금액은 0보다 커야 합니다.');
  }

  if (this.balance < amount) {
    throw new Error('잔액이 부족합니다.');
  }

  this.balance -= amount;
  this.transactions.push({
    type: 'withdraw',
    amount: amount,
    date: new Date()
  });

  return this.balance;
};

// 사용
const account = new BankAccount('Alice', 1000);
account.deposit(500);  // 1500
account.withdraw(300); // 1200

console.log(account.balance); // 1200
console.log(account.transactions.length); // 2

// ❌ 검증 실패
try {
  const invalidAccount = new BankAccount('', -100);
} catch (e) {
  console.error(e.message); // "소유자 이름은 필수입니다."
}

4. new 없는 호출 방지하기

function SafeUser(name, email) {
  // new 없이 호출되었는지 체크
  if (!(this instanceof SafeUser)) {
    return new SafeUser(name, email);
  }

  this.name = name;
  this.email = email;
}

// 두 방법 모두 동작
const user1 = new SafeUser('Alice', 'alice@example.com');
const user2 = SafeUser('Bob', 'bob@example.com'); // new 없이 호출해도 동작!

console.log(user1 instanceof SafeUser); // true
console.log(user2 instanceof SafeUser); // true

// 더 현대적인 방법: new.target 사용 (ES6+)
function ModernUser(name, email) {
  // new로 호출되지 않았으면 에러
  if (!new.target) {
    throw new Error('User는 new 키워드와 함께 호출해야 합니다.');
  }

  this.name = name;
  this.email = email;
}

const user3 = new ModernUser('Charlie', 'charlie@example.com'); // ✅ 동작
try {
  const user4 = ModernUser('David', 'david@example.com'); // ❌ 에러
} catch (e) {
  console.error(e.message); // "User는 new 키워드와 함께 호출해야 합니다."
}

5. 계산된 속성이 있는 생성자 함수

function Rectangle(width, height) {
  this.width = width;
  this.height = height;
}

// 프로토타입에 getter 추가
Object.defineProperty(Rectangle.prototype, 'area', {
  get: function() {
    return this.width * this.height;
  }
});

Object.defineProperty(Rectangle.prototype, 'perimeter', {
  get: function() {
    return 2 * (this.width + this.height);
  }
});

Rectangle.prototype.resize = function(factor) {
  this.width *= factor;
  this.height *= factor;
};

const rect = new Rectangle(10, 20);

console.log(rect.area);      // 200 (계산됨)
console.log(rect.perimeter); // 60 (계산됨)

rect.resize(2);

console.log(rect.area);      // 800 (자동으로 재계산)
console.log(rect.perimeter); // 120 (자동으로 재계산)

6. 팩토리 메서드가 있는 생성자 함수

function User(name, email, role) {
  this.name = name;
  this.email = email;
  this.role = role;
  this.createdAt = new Date();
}

// 정적 팩토리 메서드 (생성자 함수 자체에 추가)
User.createAdmin = function(name, email) {
  return new User(name, email, 'admin');
};

User.createGuest = function(name) {
  return new User(name, `${name}@guest.com`, 'guest');
};

User.createFromData = function(data) {
  return new User(data.name, data.email, data.role);
};

// 사용
const admin = User.createAdmin('Alice', 'alice@example.com');
const guest = User.createGuest('Anonymous');
const user = User.createFromData({
  name: 'Bob',
  email: 'bob@example.com',
  role: 'member'
});

console.log(admin.role); // "admin"
console.log(guest.role); // "guest"
console.log(guest.email); // "Anonymous@guest.com"

좋은 예 vs 나쁜 예

❌ 나쁜 예 1: 메서드를 생성자 내부에서 정의

function BadAnimal(name, species) {
  this.name = name;
  this.species = species;

  // ❌ 인스턴스마다 새로운 함수 생성 (메모리 낭비)
  this.speak = function() {
    console.log(`${this.name}가 소리를 냅니다.`);
  };

  this.eat = function(food) {
    console.log(`${this.name}${food}를 먹습니다.`);
  };
}

const dog = new BadAnimal('멍멍이', '');
const cat = new BadAnimal('야옹이', '고양이');

console.log(dog.speak === cat.speak); // false (다른 함수!)
// 1000개의 인스턴스를 만들면 1000개의 speak 함수가 생성됨

✅ 좋은 예 1: 메서드는 프로토타입에

function GoodAnimal(name, species) {
  // ✅ 인스턴스별 데이터만 생성자에
  this.name = name;
  this.species = species;
}

// ✅ 공유 메서드는 프로토타입에
GoodAnimal.prototype.speak = function() {
  console.log(`${this.name}가 소리를 냅니다.`);
};

GoodAnimal.prototype.eat = function(food) {
  console.log(`${this.name}${food}를 먹습니다.`);
};

const dog = new GoodAnimal('멍멍이', '');
const cat = new GoodAnimal('야옹이', '고양이');

console.log(dog.speak === cat.speak); // true (같은 함수 참조!)
// 1000개의 인스턴스를 만들어도 speak 함수는 하나만 존재

❌ 나쁜 예 2: new 빼먹기

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

// ❌ new 없이 호출
const user = User('Alice');

console.log(user); // undefined
console.log(window.name); // 'Alice' - 전역 오염!

// this가 window(전역 객체)를 가리킴

✅ 좋은 예 2: new 강제하기

function User(name) {
  // ✅ new 없이 호출되면 자동으로 new 추가
  if (!(this instanceof User)) {
    return new User(name);
  }

  this.name = name;
}

// 두 방법 모두 안전
const user1 = new User('Alice'); // ✅
const user2 = User('Bob');       // ✅ 자동으로 new 추가됨

console.log(user1 instanceof User); // true
console.log(user2 instanceof User); // true

❌ 나쁜 예 3: 참조 타입을 프로토타입에

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

// ❌ 배열을 프로토타입에 정의 (모든 인스턴스가 공유!)
BadStudent.prototype.grades = [];

const alice = new BadStudent('Alice');
const bob = new BadStudent('Bob');

alice.grades.push(90);
bob.grades.push(85);

console.log(alice.grades); // [90, 85] - 오염됨!
console.log(bob.grades);   // [90, 85] - 오염됨!
console.log(alice.grades === bob.grades); // true (같은 배열 참조)

✅ 좋은 예 3: 참조 타입은 생성자에서

function GoodStudent(name) {
  this.name = name;
  // ✅ 인스턴스마다 새 배열 생성
  this.grades = [];
}

const alice = new GoodStudent('Alice');
const bob = new GoodStudent('Bob');

alice.grades.push(90);
bob.grades.push(85);

console.log(alice.grades); // [90]
console.log(bob.grades);   // [85]
console.log(alice.grades === bob.grades); // false (다른 배열)

❌ 나쁜 예 4: 명시적으로 잘못된 값 반환

function BadConstructor(value) {
  this.value = value;

  // ❌ 객체를 명시적으로 반환하면 this가 무시됨
  return { value: 'overridden' };
}

const instance = new BadConstructor(42);
console.log(instance.value); // 'overridden' (this.value가 아님!)
console.log(instance instanceof BadConstructor); // false (프로토타입 연결도 끊김)

✅ 좋은 예 4: 반환값을 명시하지 않기

function GoodConstructor(value) {
  this.value = value;
  // ✅ 명시적으로 return하지 않음 (자동으로 this 반환)
}

const instance = new GoodConstructor(42);
console.log(instance.value); // 42
console.log(instance instanceof GoodConstructor); // true

// 예외: 원시값 반환은 무시됨 (정상 동작)
function AlsoGoodConstructor(value) {
  this.value = value;
  return 100; // 무시됨
}

const instance2 = new AlsoGoodConstructor(42);
console.log(instance2.value); // 42 (정상)

활용

1. 상속 구현하기

// 부모 생성자
function Animal(name, species) {
  this.name = name;
  this.species = species;
}

Animal.prototype.speak = function() {
  return `${this.name}가 소리를 냅니다.`;
};

Animal.prototype.getInfo = function() {
  return `이름: ${this.name}, 종: ${this.species}`;
};

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

// 프로토타입 체인 설정 (메서드 상속)
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog; // constructor 복원

// Dog만의 메서드 추가
Dog.prototype.bark = function() {
  return `${this.name}: 멍멍!`;
};

// Dog의 speak 메서드 오버라이드
Dog.prototype.speak = function() {
  return `${this.name}가 멍멍 짖습니다.`;
};

const dog = new Dog('멍멍이', 'Golden Retriever');

console.log(dog.getInfo()); // "이름: 멍멍이, 종: 개"
console.log(dog.speak());   // "멍멍이가 멍멍 짖습니다."
console.log(dog.bark());    // "멍멍이: 멍멍!"

// 프로토타입 체인 확인
console.log(dog instanceof Dog);    // true
console.log(dog instanceof Animal); // true
console.log(dog instanceof Object); // true

프로토타입 체인 시각화:

┌─────────────────────────┐
│   dog 인스턴스          │
│  name: '멍멍이'          │
│  species: '개'          │
│  breed: 'Golden...'     │
└──────────┬──────────────┘
           │ [[Prototype]]
           ↓
┌─────────────────────────┐
│   Dog.prototype         │
│  bark: function         │
│  speak: function        │
│  constructor: Dog       │
└──────────┬──────────────┘
           │ [[Prototype]]
           ↓
┌─────────────────────────┐
│  Animal.prototype       │
│  speak: function        │
│  getInfo: function      │
│  constructor: Animal    │
└──────────┬──────────────┘
           │ [[Prototype]]
           ↓
┌─────────────────────────┐
│  Object.prototype       │
│  toString: function     │
│  hasOwnProperty: fn     │
└──────────┬──────────────┘
           │
           ↓
         null

2. private 변수 구현하기 (클로저 활용)

function Counter(initialValue) {
  // private 변수 (클로저로 보호됨)
  let count = initialValue || 0;
  const history = [];

  // public 메서드 (클로저를 통해 private 변수 접근)
  this.increment = function() {
    count++;
    history.push({ action: 'increment', value: count, time: new Date() });
    return count;
  };

  this.decrement = function() {
    count--;
    history.push({ action: 'decrement', value: count, time: new Date() });
    return count;
  };

  this.getValue = function() {
    return count;
  };

  this.getHistory = function() {
    return [...history]; // 복사본 반환
  };

  this.reset = function() {
    const oldCount = count;
    count = initialValue || 0;
    history.push({ action: 'reset', from: oldCount, to: count, time: new Date() });
    return count;
  };
}

const counter = new Counter(10);

console.log(counter.increment()); // 11
console.log(counter.increment()); // 12
console.log(counter.decrement()); // 11
console.log(counter.getValue());  // 11

// ❌ private 변수에 직접 접근 불가
console.log(counter.count);    // undefined
console.log(counter.history);  // undefined

console.log(counter.getHistory());
// [
//   { action: 'increment', value: 11, time: ... },
//   { action: 'increment', value: 12, time: ... },
//   { action: 'decrement', value: 11, time: ... }
// ]

// 주의: 이 방식은 메서드가 프로토타입에 있지 않고 인스턴스마다 생성됨
const counter2 = new Counter(0);
console.log(counter.increment === counter2.increment); // false

3. 믹스인(Mixin) 패턴

// 믹스인: 여러 생성자에 기능 추가
const timestampMixin = {
  getCreatedTime() {
    return this.createdAt;
  },

  getAge() {
    return Date.now() - this.createdAt;
  }
};

const serializableMixin = {
  toJSON() {
    const obj = {};
    for (let key in this) {
      if (this.hasOwnProperty(key)) {
        obj[key] = this[key];
      }
    }
    return JSON.stringify(obj);
  },

  fromJSON(json) {
    const obj = JSON.parse(json);
    for (let key in obj) {
      this[key] = obj[key];
    }
    return this;
  }
};

// 믹스인 적용 헬퍼 함수
function mixin(target, ...sources) {
  Object.assign(target.prototype, ...sources);
}

// 생성자 정의
function User(name, email) {
  this.name = name;
  this.email = email;
  this.createdAt = Date.now();
}

function Product(name, price) {
  this.name = name;
  this.price = price;
  this.createdAt = Date.now();
}

// 믹스인 적용
mixin(User, timestampMixin, serializableMixin);
mixin(Product, timestampMixin, serializableMixin);

// 사용
const user = new User('Alice', 'alice@example.com');
const product = new Product('Laptop', 1200);

setTimeout(() => {
  console.log(user.getAge()); // 1000 (1초 후)
  console.log(product.getAge()); // 1000

  const userJson = user.toJSON();
  console.log(userJson); // {"name":"Alice","email":"alice@example.com","createdAt":...}

  const newUser = new User('', '');
  newUser.fromJSON(userJson);
  console.log(newUser.name); // "Alice"
}, 1000);

4. 싱글톤 패턴

function Database(host, port) {
  // 이미 인스턴스가 존재하면 반환
  if (Database.instance) {
    return Database.instance;
  }

  this.host = host;
  this.port = port;
  this.connected = false;
  this.queries = [];

  // 인스턴스 저장
  Database.instance = this;
}

Database.prototype.connect = function() {
  if (this.connected) {
    console.log('이미 연결되어 있습니다.');
    return;
  }

  console.log(`${this.host}:${this.port}에 연결 중...`);
  this.connected = true;
};

Database.prototype.query = function(sql) {
  if (!this.connected) {
    throw new Error('데이터베이스에 연결되지 않았습니다.');
  }

  this.queries.push({ sql, time: new Date() });
  console.log(`쿼리 실행: ${sql}`);
};

// 첫 번째 인스턴스 생성
const db1 = new Database('localhost', 5432);
db1.connect();
db1.query('SELECT * FROM users');

// 두 번째 시도 - 같은 인스턴스 반환
const db2 = new Database('example.com', 3306);

console.log(db1 === db2); // true (같은 인스턴스!)
console.log(db2.host);    // 'localhost' (첫 번째 설정 유지)
console.log(db2.queries.length); // 1 (첫 번째 쿼리 기록됨)

5. 팩토리 패턴과 결합

function Vehicle(type, brand, model) {
  this.type = type;
  this.brand = brand;
  this.model = model;
}

Vehicle.prototype.getInfo = function() {
  return `${this.type}: ${this.brand} ${this.model}`;
};

// 팩토리 함수
function VehicleFactory() {}

VehicleFactory.createCar = function(brand, model) {
  return new Vehicle('자동차', brand, model);
};

VehicleFactory.createMotorcycle = function(brand, model) {
  return new Vehicle('오토바이', brand, model);
};

VehicleFactory.createTruck = function(brand, model) {
  return new Vehicle('트럭', brand, model);
};

VehicleFactory.create = function(type, brand, model) {
  const types = {
    car: '자동차',
    motorcycle: '오토바이',
    truck: '트럭'
  };

  const vehicleType = types[type.toLowerCase()];
  if (!vehicleType) {
    throw new Error('알 수 없는 차량 타입입니다.');
  }

  return new Vehicle(vehicleType, brand, model);
};

// 사용
const car = VehicleFactory.createCar('Tesla', 'Model 3');
const motorcycle = VehicleFactory.createMotorcycle('Harley-Davidson', 'Street 750');
const truck = VehicleFactory.create('truck', 'Volvo', 'FH16');

console.log(car.getInfo());        // "자동차: Tesla Model 3"
console.log(motorcycle.getInfo()); // "오토바이: Harley-Davidson Street 750"
console.log(truck.getInfo());      // "트럭: Volvo FH16"

함정과 주의사항

1. this 바인딩 문제

function Timer(name) {
  this.name = name;
  this.seconds = 0;
}

Timer.prototype.start = function() {
  // ❌ 문제: setInterval 내부에서 this가 바뀜
  setInterval(function() {
    this.seconds++; // this가 window를 가리킴!
    console.log(`${this.name}: ${this.seconds}초`);
  }, 1000);
};

const timer1 = new Timer('타이머1');
// timer1.start(); // NaN초 출력

// ✅ 해결 1: 화살표 함수 사용
Timer.prototype.startArrow = function() {
  setInterval(() => {
    this.seconds++; // this가 Timer 인스턴스를 가리킴
    console.log(`${this.name}: ${this.seconds}초`);
  }, 1000);
};

// ✅ 해결 2: bind 사용
Timer.prototype.startBind = function() {
  setInterval(function() {
    this.seconds++;
    console.log(`${this.name}: ${this.seconds}초`);
  }.bind(this), 1000);
};

// ✅ 해결 3: that/self 변수 사용
Timer.prototype.startThat = function() {
  const that = this;
  setInterval(function() {
    that.seconds++;
    console.log(`${that.name}: ${that.seconds}초`);
  }, 1000);
};

2. 생성자 반환값 오버라이드

function User(name) {
  this.name = name;

  // ❌ 객체를 반환하면 this가 무시됨
  return { name: 'overridden' };
}

const user = new User('Alice');
console.log(user.name); // 'overridden' (this.name이 아님!)
console.log(user instanceof User); // false (프로토타입 연결도 끊김)

// MDN 문서: "생성자가 객체(non-primitive)를 반환하면
// 그 객체가 최종 결과가 됩니다."

// ✅ 원시값 반환은 무시됨 (정상 동작)
function SafeUser(name) {
  this.name = name;
  return 42; // 무시됨
}

const safeUser = new SafeUser('Bob');
console.log(safeUser.name); // 'Bob'
console.log(safeUser instanceof SafeUser); // true

3. prototype 덮어쓰기

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

Animal.prototype.speak = function() {
  return `${this.name}가 소리를 냅니다.`;
};

const dog = new Animal('멍멍이');
console.log(dog.speak()); // "멍멍이가 소리를 냅니다."

// ❌ 프로토타입을 통째로 교체하면 이전 인스턴스는 영향받지 않음
Animal.prototype = {
  eat: function(food) {
    return `${this.name}${food}를 먹습니다.`;
  }
};

const cat = new Animal('야옹이');
console.log(cat.eat); // function (새 프로토타입)
console.log(dog.eat); // undefined (이전 프로토타입 참조)

// ✅ constructor 속성도 복원해야 함
Animal.prototype = {
  constructor: Animal, // 명시적으로 추가
  eat: function(food) {
    return `${this.name}${food}를 먹습니다.`;
  }
};

4. 화살표 함수는 생성자로 사용 불가

// ❌ 화살표 함수는 prototype이 없음
const ArrowConstructor = (name) => {
  this.name = name;
};

console.log(ArrowConstructor.prototype); // undefined

try {
  const instance = new ArrowConstructor('Alice');
} catch (e) {
  console.error(e.message); // "ArrowConstructor is not a constructor"
}

// ✅ 일반 함수 사용
function RegularConstructor(name) {
  this.name = name;
}

console.log(RegularConstructor.prototype); // { constructor: RegularConstructor }
const instance = new RegularConstructor('Alice'); // 동작

5. 프로토타입 체인의 성능

function DeepChain() {}

// ❌ 너무 깊은 프로토타입 체인
let current = DeepChain.prototype;
for (let i = 0; i < 100; i++) {
  const next = Object.create(current);
  next[`method${i}`] = function() {
    return i;
  };
  current = next;
}

// 프로토타입 체인이 100단계!
const instance = new DeepChain();
Object.setPrototypeOf(instance, current);

// 메서드 조회가 느려짐 (100단계를 탐색해야 함)
console.time('deep');
instance.method99();
console.timeEnd('deep');

// ✅ 프로토타입 체인은 가능한 짧게 유지
function ShallowChain() {
  // 모든 메서드를 한 곳에
}

ShallowChain.prototype.method1 = function() {};
ShallowChain.prototype.method2 = function() {};
ShallowChain.prototype.method99 = function() {};

const shallowInstance = new ShallowChain();

console.time('shallow');
shallowInstance.method99();
console.timeEnd('shallow');

실전 활용

1. 이벤트 에미터 구현

function EventEmitter() {
  // 이벤트 리스너를 저장할 객체
  this.events = {};
}

EventEmitter.prototype.on = function(eventName, listener) {
  if (!this.events[eventName]) {
    this.events[eventName] = [];
  }

  this.events[eventName].push(listener);
  return this; // 체이닝을 위해 this 반환
};

EventEmitter.prototype.once = function(eventName, listener) {
  const wrapper = (...args) => {
    listener.apply(this, args);
    this.off(eventName, wrapper);
  };

  return this.on(eventName, wrapper);
};

EventEmitter.prototype.off = function(eventName, listener) {
  if (!this.events[eventName]) return this;

  this.events[eventName] = this.events[eventName].filter(
    fn => fn !== listener
  );

  return this;
};

EventEmitter.prototype.emit = function(eventName, ...args) {
  if (!this.events[eventName]) return this;

  this.events[eventName].forEach(listener => {
    listener.apply(this, args);
  });

  return this;
};

// 사용
const emitter = new EventEmitter();

emitter.on('data', (data) => {
  console.log('데이터 수신:', data);
});

emitter.once('error', (error) => {
  console.error('에러 발생:', error);
});

emitter.emit('data', { id: 1, name: 'Alice' }); // "데이터 수신: ..."
emitter.emit('data', { id: 2, name: 'Bob' });   // "데이터 수신: ..."
emitter.emit('error', new Error('테스트 에러')); // "에러 발생: ..."
emitter.emit('error', new Error('무시됨'));      // once이므로 실행 안됨

2. 연결 리스트(Linked List) 구현

function Node(value) {
  this.value = value;
  this.next = null;
}

function LinkedList() {
  this.head = null;
  this.tail = null;
  this.length = 0;
}

LinkedList.prototype.append = function(value) {
  const node = new Node(value);

  if (!this.head) {
    this.head = node;
    this.tail = node;
  } else {
    this.tail.next = node;
    this.tail = node;
  }

  this.length++;
  return this;
};

LinkedList.prototype.prepend = function(value) {
  const node = new Node(value);

  if (!this.head) {
    this.head = node;
    this.tail = node;
  } else {
    node.next = this.head;
    this.head = node;
  }

  this.length++;
  return this;
};

LinkedList.prototype.find = function(value) {
  let current = this.head;

  while (current) {
    if (current.value === value) {
      return current;
    }
    current = current.next;
  }

  return null;
};

LinkedList.prototype.toArray = function() {
  const array = [];
  let current = this.head;

  while (current) {
    array.push(current.value);
    current = current.next;
  }

  return array;
};

// 사용
const list = new LinkedList();
list.append(1).append(2).append(3);
list.prepend(0);

console.log(list.toArray()); // [0, 1, 2, 3]
console.log(list.length);    // 4
console.log(list.find(2));   // Node { value: 2, next: Node { ... } }

3. Observable 패턴

function Observable(subscriber) {
  this.subscriber = subscriber;
}

Observable.prototype.subscribe = function(observer) {
  return this.subscriber(observer);
};

Observable.create = function(subscriber) {
  return new Observable(subscriber);
};

// 사용
const observable = Observable.create((observer) => {
  let count = 0;

  const interval = setInterval(() => {
    observer.next(count++);

    if (count > 5) {
      observer.complete();
      clearInterval(interval);
    }
  }, 1000);

  // 구독 취소 함수 반환
  return () => {
    clearInterval(interval);
    console.log('구독 취소됨');
  };
});

const subscription = observable.subscribe({
  next: (value) => console.log('값:', value),
  error: (err) => console.error('에러:', err),
  complete: () => console.log('완료!')
});

// 3초 후 구독 취소
setTimeout(() => {
  subscription();
}, 3000);

4. 상태 기계(State Machine)

function StateMachine(initialState, transitions) {
  this.currentState = initialState;
  this.transitions = transitions;
  this.history = [initialState];
}

StateMachine.prototype.transition = function(event) {
  const stateTransitions = this.transitions[this.currentState];

  if (!stateTransitions || !stateTransitions[event]) {
    throw new Error(
      `"${this.currentState}" 상태에서 "${event}" 이벤트는 허용되지 않습니다.`
    );
  }

  const nextState = stateTransitions[event];
  this.currentState = nextState;
  this.history.push(nextState);

  return this;
};

StateMachine.prototype.getState = function() {
  return this.currentState;
};

StateMachine.prototype.can = function(event) {
  const stateTransitions = this.transitions[this.currentState];
  return !!(stateTransitions && stateTransitions[event]);
};

StateMachine.prototype.getHistory = function() {
  return [...this.history];
};

// 사용: 문 상태 기계
const doorMachine = new StateMachine('closed', {
  closed: {
    open: 'opened'
  },
  opened: {
    close: 'closed',
    lock: 'locked'
  },
  locked: {
    unlock: 'closed'
  }
});

console.log(doorMachine.getState()); // "closed"

doorMachine.transition('open');
console.log(doorMachine.getState()); // "opened"

doorMachine.transition('lock');
console.log(doorMachine.getState()); // "locked"

console.log(doorMachine.can('unlock')); // true
console.log(doorMachine.can('open'));   // false

try {
  doorMachine.transition('open'); // 에러!
} catch (e) {
  console.error(e.message); // "locked" 상태에서 "open" 이벤트는 허용되지 않습니다.
}

console.log(doorMachine.getHistory()); // ["closed", "opened", "locked"]

클래스 문법과의 비교

ES6부터는 class 문법을 사용할 수 있지만, 내부적으로는 생성자 함수로 동작합니다.

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

Person.prototype.greet = function() {
  return `안녕하세요, ${this.name}입니다.`;
};

Person.createGuest = function() {
  return new Person('Guest', 0);
};

// 클래스 방식 (정확히 같은 동작)
class PersonClass {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  greet() {
    return `안녕하세요, ${this.name}입니다.`;
  }

  static createGuest() {
    return new PersonClass('Guest', 0);
  }
}

// 동일한 결과
const person1 = new Person('Alice', 25);
const person2 = new PersonClass('Alice', 25);

console.log(typeof Person);      // "function"
console.log(typeof PersonClass); // "function"

console.log(person1.greet()); // "안녕하세요, Alice입니다."
console.log(person2.greet()); // "안녕하세요, Alice입니다."

차이점:

특징 생성자 함수 클래스
호이스팅 함수 호이스팅 호이스팅 안됨
new 없이 호출 가능 (주의 필요) TypeError 발생
메서드 열거 가능 불가능 (non-enumerable)
strict mode 선택 자동 적용
가독성 낮음 높음
상속 복잡함 extends로 간단
// 호이스팅 차이
const instance1 = new HoistedConstructor(); // ✅ 동작
function HoistedConstructor() {}

try {
  const instance2 = new NotHoistedClass(); // ❌ ReferenceError
  class NotHoistedClass {}
} catch (e) {
  console.error(e.message);
}

// new 없이 호출
function FlexibleConstructor() {
  this.value = 42;
}
const result1 = FlexibleConstructor(); // ✅ 동작 (전역 오염 주의)

class StrictClass {
  constructor() {
    this.value = 42;
  }
}
try {
  const result2 = StrictClass(); // ❌ TypeError
} catch (e) {
  console.error(e.message); // "Class constructor StrictClass cannot be invoked without 'new'"
}

참고 자료

MDN 공식 문서

  • [constructor - JavaScript MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/constructor)
  • [new operator - JavaScript MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/new)
  • [Function() constructor - JavaScript MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function)
  • [Object.prototype.constructor - JavaScript MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/constructor)
  • [Inheritance and the prototype chain - JavaScript MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_prototype_chain)
  • [this - JavaScript MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this)
  • [Classes - JavaScript MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes)

심화 학습

댓글