JavaScript 클래스: ES6 class 문법, 상속, private 필드

이 글의 핵심

JavaScript 클래스는 문법만 보면 Java와 비슷하지만 내부는 프로토타입 기반이라 다르게 동작하는 부분이 있습니다. 자식 생성자에서 super()를 호출하지 않으면 this를 쓸 수 없는 이유, 메서드를 화살표 함수로 정의할 때의 차이를 짚고, 스택·타이머 클래스 연습 문제로 직접 설계해 보게 합니다.

들어가며

클래스란?

클래스(Class)는 객체를 찍어내기 위한 설계도(템플릿)에 해당합니다. ES6(ES2015)에서 class 문법이 도입되어, 예전 생성자 함수 패턴을 더 읽기 쉽게 쓸 수 있습니다. 클래스 이전 (ES5):

// 생성자 함수
// 함수 정의 및 구현
function Person(name, age) {
    this.name = name;
    this.age = age;
}
Person.prototype.greet = function() {
    console.log(`안녕하세요, ${this.name}입니다.`);
};
const person = new Person("홍길동", 25);
person.greet();  // 안녕하세요, 홍길동입니다.

클래스 사용 (ES6+):

class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    
    greet() {
        console.log(`안녕하세요, ${this.name}입니다.`);
    }
}
const person = new Person("홍길동", 25);
person.greet();  // 안녕하세요, 홍길동입니다.

두 코드는 거의 같은 객체 구조를 만듭니다. class 안에 선언한 greet도 결국 Person.prototype.greet에 저장되고, typeof Person은 여전히 "function"입니다. 그래서 흔히 “문법적 설탕”이라고 부르지만, 완전히 같지는 않습니다. class로 만든 생성자는 new 없이 호출하면 TypeError: Class constructor Person cannot be invoked without 'new'를 던지고(생성자 함수는 조용히 전역 객체에 속성을 만들어 버립니다), 클래스 본문은 자동으로 strict mode로 실행되며, 메서드는 for...in에 나타나지 않는 열거 불가 속성이 됩니다. 또 class 선언은 let처럼 호이스팅되지만 초기화되지 않아서, 선언보다 위에서 new Person()을 호출하면 ReferenceError: Cannot access 'Person' before initialization이 납니다. 함수 선언처럼 파일 아래쪽에 두고 위에서 쓰는 방식은 통하지 않습니다.


클래스 정의와 클래스 표현식

클래스 정의

class Rectangle {
    // 생성자: 객체 생성 시 자동 호출
    constructor(width, height) {
        this.width = width;
        this.height = height;
    }
    
    // 메서드
    getArea() {
        return this.width * this.height;
    }
    
    getPerimeter() {
        return 2 * (this.width + this.height);
    }
    
    // 메서드 내에서 다른 메서드 호출
    describe() {
        return `넓이: ${this.getArea()}, 둘레: ${this.getPerimeter()}`;
    }
}
// 객체 생성
const rect = new Rectangle(10, 5);
console.log(rect.getArea());       // 50
console.log(rect.getPerimeter());  // 30
console.log(rect.describe());      // 넓이: 50, 둘레: 30

constructor는 new Rectangle(10, 5)가 실행될 때 새로 만들어진 빈 객체를 this로 받아 속성을 채웁니다. getArea 같은 메서드는 인스턴스마다 복사되지 않고 Rectangle.prototype에 하나만 있으며, rect.getArea()를 호출하면 rect에서 찾지 못한 메서드를 프로토타입 체인을 따라 올라가 찾습니다. 인스턴스를 수천 개 만들어도 메서드는 하나라서 메모리 효율이 좋습니다. 메서드 안에서 다른 메서드를 부를 때 this.getArea()처럼 this를 꼭 붙여야 하는 것도 Java와 다른 점입니다. getArea()만 쓰면 같은 이름의 전역 함수를 찾다가 ReferenceError: getArea is not defined가 납니다.

클래스 표현식

// 익명 클래스
const Person = class {
    constructor(name) {
        this.name = name;
    }
    
    greet() {
        console.log(`Hello, ${this.name}!`);
    }
};
const person = new Person("홍길동");
person.greet();  // Hello, 홍길동!

// 기명 클래스: 이름은 클래스 내부에서만 보임
const Named = class PersonClass {
    constructor(name) {
        this.name = name;
    }
    clone() {
        return new PersonClass(this.name);  // 내부에서 자기 이름으로 참조
    }
};
// new PersonClass("x");  // ReferenceError: PersonClass is not defined

클래스도 함수처럼 값이므로 변수에 담거나 함수 인자로 넘길 수 있습니다. 기명 클래스 표현식의 이름(PersonClass)은 클래스 본문 안에서만 쓸 수 있는 지역 이름이고, 바깥에서는 변수 이름(Named)으로 접근합니다. 스택 트레이스와 디버거에는 내부 이름이 표시되므로 익명보다 디버깅이 조금 편합니다. (이 예제의 이전 버전은 두 번째 표현식도 const Person으로 선언해 SyntaxError: Identifier 'Person' has already been declared가 나는 코드였기에 이름을 나눴습니다.) 클래스 표현식은 const withLogging = (Base) => class extends Base { ... }처럼 기존 클래스에 기능을 덧붙이는 믹스인 패턴에서 주로 볼 수 있습니다.


Getter와 Setter

getter/setter 정의

class Circle {
    constructor(radius) {
        this._radius = radius;  // private 관례 (_prefix)
    }
    
    // getter: 속성처럼 접근
    get radius() {
        return this._radius;
    }
    
    // setter: 속성처럼 할당
    set radius(value) {
        if (value < 0) {
            throw new Error("반지름은 양수여야 합니다");
        }
        this._radius = value;
    }
    
    // 계산된 속성
    get area() {
        return Math.PI * this._radius ** 2;
    }
    
    get diameter() {
        return this._radius * 2;
    }
    
    set diameter(value) {
        this._radius = value / 2;
    }
}
// 사용
const circle = new Circle(5);
console.log(circle.radius);  // 5 (getter)
console.log(circle.area);    // 78.53981633974483
circle.radius = 10;  // setter
console.log(circle.area);  // 314.1592653589793
circle.diameter = 20;  // diameter setter
console.log(circle.radius);  // 10
// circle.radius = -5;  // Error: 반지름은 양수여야 합니다

getter와 setter를 쓰면 호출하는 쪽은 circle.radius = 10처럼 평범한 속성 대입으로 보이는데, 실제로는 검증 로직이 실행됩니다. area처럼 setter 없이 getter만 있는 속성은 읽기 전용이 되어, 대입해도 non-strict 모드에서는 조용히 무시되고 strict mode(클래스 본문, ES 모듈)에서는 TypeError: Cannot set property area of #<Circle> which has only a getter가 납니다. 이 차이 때문에 같은 코드가 환경에 따라 다르게 동작하는 것처럼 보이기도 합니다.

getter/setter를 처음 쓸 때 가장 흔한 실수는 setter 안에서 같은 이름의 속성에 대입하는 것입니다. set radius(value) { this.radius = value; }라고 쓰면 이 대입이 다시 setter를 호출하고, 끝없이 반복되다가 RangeError: Maximum call stack size exceeded로 멈춥니다. 예제가 실제 값을 _radius에 저장하는 이유가 이것입니다. 다만 _radius는 “건드리지 말라”는 관례일 뿐 외부에서 circle._radius = -5로 검증을 우회할 수 있으므로, 진짜로 숨기려면 뒤에서 볼 #radius private 필드를 씁니다. 또 생성자에서 this._radius = radius로 직접 대입하면 setter의 음수 검사를 건너뛴다는 점도 놓치기 쉽습니다. 생성자에서도 this.radius = radius로 setter를 거치게 하면 검증이 한 곳으로 모입니다.


static 메서드와 팩토리 패턴

static 키워드

class MathUtils {
    // 정적 속성
    static PI = 3.14159;
    
    // 정적 메서드: 인스턴스 없이 호출
    static add(a, b) {
        return a + b;
    }
    
    static max(...numbers) {
        return Math.max(...numbers);
    }
    
    // 팩토리 메서드
    static createCircle(radius) {
        return new Circle(radius);
    }
}
// 정적 메서드 호출
console.log(MathUtils.add(10, 20));  // 30
console.log(MathUtils.max(1, 5, 3));  // 5
console.log(MathUtils.PI);  // 3.14159
// 인스턴스에서는 호출 불가
// const util = new MathUtils();
// util.add(1, 2);  // TypeError: util.add is not a function

static 멤버는 인스턴스가 아니라 클래스 자체(생성자 함수)의 속성입니다. 그래서 인스턴스에서는 보이지 않고, 인스턴스 메서드 안에서 쓰려면 MathUtils.PI 또는 this.constructor.PI로 접근해야 합니다. 정적 메서드 안의 this는 클래스 자체를 가리키며, static 멤버는 상속됩니다. 자식 클래스에서 Child.add(1, 2)를 호출할 수 있고, 이때 this는 Child입니다. 정적 필드(static PI = ...)는 ES2022 문법이라 오래된 도구 체인에서는 Babel 설정이 필요할 수 있습니다.

사실 MathUtils처럼 상태 없이 정적 메서드만 모아 둔 클래스는 JavaScript에서는 굳이 클래스로 만들 필요가 없습니다. 모듈에서 export function add(a, b)로 내보내면 트리 셰이킹도 잘 되고 new MathUtils()처럼 의미 없는 인스턴스를 만드는 실수도 생기지 않습니다. 정적 메서드가 빛나는 곳은 다음 예제처럼 인스턴스를 만드는 방법에 이름을 붙이는 팩토리 메서드입니다.

팩토리 패턴 예제

class User {
    constructor(name, email, role) {
        this.name = name;
        this.email = email;
        this.role = role;
    }
    
    // 정적 팩토리 메서드
    static createAdmin(name, email) {
        return new User(name, email, "admin");
    }
    
    static createGuest(name) {
        return new User(name, `${name}@guest.com`, "guest");
    }
    
    hasPermission(permission) {
        const permissions = {
            admin: ["read", "write", "delete"],
            user: ["read", "write"],
            guest: ["read"]
        };
        return permissions[this.role].includes(permission);
    }
}
// 사용
const admin = User.createAdmin("관리자", "[email protected]");
const guest = User.createGuest("손님");
console.log(admin.hasPermission("delete"));  // true
console.log(guest.hasPermission("write"));   // false

new User(name, email, "admin")처럼 역할 문자열을 직접 넘기면 "amdin" 같은 오타가 그대로 들어가지만, User.createAdmin()을 쓰면 올바른 조합만 만들어집니다. 생성자는 이름이 하나뿐이라 “관리자 만들기”와 “손님 만들기”를 구분할 수 없는데, 정적 팩토리는 의도를 이름으로 드러냅니다. 인자 개수에 따라 동작을 바꾸는 생성자 오버로딩이 JavaScript에는 없다는 점도 팩토리 메서드를 자주 쓰는 이유입니다.

hasPermission에는 방어 코드가 하나 빠져 있습니다. role이 permissions에 없는 값(예: "editor")이면 permissions[this.role]이 undefined가 되어 TypeError: Cannot read properties of undefined (reading 'includes')가 납니다. 권한 검사는 실패했을 때 “허용”이 아니라 “거부”로 떨어져야 하므로 (permissions[this.role] ?? []).includes(permission)처럼 쓰는 것이 안전합니다. 또 permissions 객체를 메서드가 호출될 때마다 새로 만들고 있으니, 클래스 밖의 상수나 static 필드로 옮기면 불필요한 생성을 줄일 수 있습니다.


extends와 super로 상속하기

extends 키워드

// 부모 클래스
class Animal {
    constructor(name) {
        this.name = name;
    }
    
    speak() {
        console.log(`${this.name}이(가) 소리를 냅니다.`);
    }
    
    move() {
        console.log(`${this.name}이(가) 움직입니다.`);
    }
}
// 자식 클래스
class Dog extends Animal {
    constructor(name, breed) {
        super(name);  // 부모 생성자 호출 (필수!)
        this.breed = breed;
    }
    
    // 메서드 오버라이딩
    speak() {
        console.log(`${this.name}: 멍멍!`);
    }
    
    // 새 메서드
    fetch() {
        console.log(`${this.name}이(가) 공을 가져옵니다.`);
    }
}
class Cat extends Animal {
    speak() {
        console.log(`${this.name}: 야옹~`);
    }
}
// 사용
const dog = new Dog("바둑이", "진돗개");
dog.speak();  // 바둑이: 멍멍!
dog.move();   // 바둑이이(가) 움직입니다. (상속)
dog.fetch();  // 바둑이이(가) 공을 가져옵니다.
const cat = new Cat("나비");
cat.speak();  // 나비: 야옹~
// instanceof 체크
console.log(dog instanceof Dog);     // true
console.log(dog instanceof Animal);  // true
console.log(dog instanceof Cat);     // false

class Dog extends Animal은 Dog.prototype의 프로토타입을 Animal.prototype으로 연결합니다. dog.move()를 호출하면 dog 자신 → Dog.prototype → Animal.prototype 순서로 찾아 올라가서 Animal의 move를 실행하고, speak는 Dog.prototype에서 먼저 찾아지므로 오버라이드한 버전이 실행됩니다. Cat처럼 생성자를 생략하면 constructor(...args) { super(...args); }가 자동으로 만들어지므로 new Cat("나비")의 이름이 부모에게 그대로 전달됩니다. instanceof는 이 프로토타입 체인에 해당 클래스의 prototype이 있는지를 검사합니다. 그래서 iframe이나 다른 realm에서 만든 배열은 instanceof Array가 false가 될 수 있고, 배열 검사에는 Array.isArray()를 쓰는 것이 관례입니다.

JavaScript의 extends는 한 번에 하나의 부모만 가질 수 있습니다. 상속 계층이 세 단계, 네 단계로 깊어지면 부모의 작은 변경이 모든 자식에게 퍼지는 문제가 생기므로, “is-a” 관계가 분명할 때만 상속을 쓰고 기능을 조합하는 것이 목적이라면 다른 객체를 속성으로 갖는 구성(composition)을 먼저 고려하는 편이 좋습니다.

super 키워드

class Employee {
    constructor(name, salary) {
        this.name = name;
        this.salary = salary;
    }
    
    getInfo() {
        return `${this.name} - ${this.salary.toLocaleString()}원`;
    }
    
    work() {
        return `${this.name}이(가) 일합니다.`;
    }
}
class Manager extends Employee {
    constructor(name, salary, teamSize) {
        super(name, salary);  // 부모 생성자 호출
        this.teamSize = teamSize;
    }
    
    // 부모 메서드 확장
    getInfo() {
        const baseInfo = super.getInfo();  // 부모 메서드 호출
        return `${baseInfo} (팀원: ${this.teamSize}명)`;
    }
    
    manageTeam() {
        return `${this.name}이(가) ${this.teamSize}명을 관리합니다.`;
    }
}
const manager = new Manager("김팀장", 5000000, 5);
console.log(manager.getInfo());    // 김팀장 - 5,000,000원 (팀원: 5명)
console.log(manager.work());       // 김팀장이(가) 일합니다. (상속)
console.log(manager.manageTeam()); // 김팀장이(가) 5명을 관리합니다.

super는 두 가지 형태로 쓰입니다. 생성자 안의 super(name, salary)는 부모 생성자를 호출하는 것이고, 메서드 안의 super.getInfo()는 부모 프로토타입의 메서드를 호출하는 것입니다. 오버라이드한 메서드에서 부모 동작을 완전히 버리지 않고 확장하려면 후자를 씁니다. 자식 생성자에서 super()를 this보다 먼저 호출해야 하는 이유는, JavaScript에서 파생 클래스의 인스턴스 객체가 부모 생성자에 의해 만들어지기 때문입니다. super()가 실행되기 전에는 this가 아직 존재하지 않습니다. 이 구조 덕분에 Array, Error, Map 같은 내장 클래스도 제대로 상속할 수 있습니다.


# Private 필드 (ES2022+)

# 접두사

class BankAccount {
    // Private 필드
    #balance;
    
    constructor(owner, balance) {
        this.owner = owner;
        this.#balance = balance;
    }
    
    // Public 메서드
    deposit(amount) {
        if (amount > 0) {
            this.#balance += amount;
            return true;
        }
        return false;
    }
    
    withdraw(amount) {
        if (amount > 0 && amount <= this.#balance) {
            this.#balance -= amount;
            return true;
        }
        return false;
    }
    
    getBalance() {
        return this.#balance;
    }
    
    // Private 메서드
    #log(message) {
        console.log(`[${this.owner}] ${message}`);
    }
}
const account = new BankAccount("홍길동", 10000);
console.log(account.owner);  // 홍길동
// console.log(account.#balance);  // SyntaxError: Private field '#balance' must be declared in an enclosing class
account.deposit(5000);
console.log(account.getBalance());  // 15000

# private 필드는 _balance 같은 관례와 달리 언어 차원에서 클래스 바깥의 접근을 막습니다. 바깥 코드에서 account.#balance를 쓰면 실행 전에 파싱 단계에서 SyntaxError가 나고, Object.keys(), JSON.stringify(), for...in 어디에도 나타나지 않습니다. 반드시 클래스 본문 맨 위에 #balance;처럼 선언해야 하며, 선언 없이 생성자에서 this.#balance = ...만 쓰면 역시 문법 에러입니다. 예제의 #log처럼 #을 붙인 메서드도 클래스 내부에서만 this.#log(...)로 호출할 수 있습니다.

알아 두면 좋은 제약도 있습니다. private 필드는 선언한 클래스의 코드에서만 보이므로, 자식 클래스에서도 부모의 #balance에 접근할 수 없습니다(Java의 protected 같은 것이 없습니다). 그래서 상속 계층에서 공유해야 하는 상태라면 부모가 getter나 메서드를 제공해야 합니다. 또 Vue 3나 MobX처럼 Proxy로 객체를 감싸는 반응형 라이브러리에서 private 필드를 가진 클래스 인스턴스를 쓰면, 메서드가 프록시 객체를 this로 받은 상태에서 this.#balance를 읽으려다 TypeError: Cannot read private member #balance from an object whose class did not declare it이 나는 경우가 있습니다. 클래스 인스턴스를 상태 관리 라이브러리에 넣을 계획이라면 이 점을 먼저 확인해 보세요. 어떤 객체가 이 클래스의 인스턴스인지 확실하게 검사하려면 ES2022의 #balance in obj 문법을 쓸 수 있습니다.


게임 캐릭터와 쇼핑몰 클래스 설계 예제

예제 1: 게임 캐릭터

class Character {
    constructor(name, hp, attack) {
        this.name = name;
        this.hp = hp;
        this.maxHp = hp;
        this.attack = attack;
    }
    
    takeDamage(damage) {
        this.hp = Math.max(0, this.hp - damage);
        console.log(`${this.name} HP: ${this.hp}/${this.maxHp}`);
        return this.hp;
    }
    
    heal(amount) {
        this.hp = Math.min(this.maxHp, this.hp + amount);
        console.log(`${this.name} 회복! HP: ${this.hp}/${this.maxHp}`);
    }
    
    isAlive() {
        return this.hp > 0;
    }
    
    basicAttack(target) {
        console.log(`${this.name}의 공격!`);
        return target.takeDamage(this.attack);
    }
}
class Warrior extends Character {
    constructor(name, hp, attack, defense) {
        super(name, hp, attack);
        this.defense = defense;
    }
    
    takeDamage(damage) {
        const reduced = Math.max(0, damage - this.defense);
        console.log(`${this.name}이(가) 방어력 ${this.defense}로 데미지 감소!`);
        return super.takeDamage(reduced);
    }
    
    shieldBash(target) {
        console.log(`${this.name}의 방패 강타!`);
        return target.takeDamage(this.attack * 1.5);
    }
}
class Mage extends Character {
    constructor(name, hp, attack, mana) {
        super(name, hp, attack);
        this.mana = mana;
        this.maxMana = mana;
    }
    
    fireball(target) {
        if (this.mana < 20) {
            console.log("마나 부족!");
            return 0;
        }
        
        this.mana -= 20;
        console.log(`${this.name}의 파이어볼! (마나: ${this.mana}/${this.maxMana})`);
        return target.takeDamage(this.attack * 2);
    }
}
// 전투 시뮬레이션
const warrior = new Warrior("전사", 150, 20, 5);
const mage = new Mage("마법사", 100, 30, 50);
console.log("=== 전투 시작 ===");
mage.basicAttack(warrior);
warrior.shieldBash(mage);
mage.fireball(warrior);
console.log("\n=== 전투 결과 ===");
console.log(`${warrior.name}: ${warrior.isAlive() ? "생존" : "사망"}`);
console.log(`${mage.name}: ${mage.isAlive() ? "생존" : "사망"}`);

이 예제의 핵심은 mage.basicAttack(warrior)가 target.takeDamage()를 호출할 때, target이 Warrior라면 Warrior가 오버라이드한 takeDamage가 실행된다는 점입니다. basicAttack은 상대가 어떤 직업인지 전혀 모르지만, 방어력 계산은 각 클래스가 알아서 처리합니다. 이것이 다형성이며, if (target instanceof Warrior) 같은 분기를 공격 코드 곳곳에 넣지 않아도 되는 이유입니다. Warrior.takeDamage가 방어력만큼 줄인 뒤 super.takeDamage(reduced)로 HP 감소와 출력은 부모에게 맡기는 것도 중복을 피하는 방식입니다.

실제로 실행해 보면 shieldBash의 this.attack * 1.5가 30이라 정수로 떨어지지만, 공격력이 홀수이면 HP가 72.5 같은 소수가 됩니다. 게임 로직에서는 Math.floor로 정수를 유지하는 규칙을 한곳에 두는 편이 좋습니다. 그리고 이런 구조에서 직업이 늘어날 때마다 Character를 상속한 클래스가 계속 추가되는데, “방어력이 있는 마법사”처럼 특성이 겹치기 시작하면 단일 상속으로는 표현하기 어려워집니다. 그 시점이 스킬이나 방어 규칙을 별도 객체로 분리하는 구성 방식을 검토할 때입니다.

예제 2: 쇼핑몰

class Product {
    constructor(id, name, price, stock) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.stock = stock;
    }
    
    isAvailable(quantity = 1) {
        return this.stock >= quantity;
    }
    
    decreaseStock(quantity) {
        if (!this.isAvailable(quantity)) {
            throw new Error("재고 부족");
        }
        this.stock -= quantity;
    }
    
    toString() {
        return `${this.name} - ${this.price.toLocaleString()}원 (재고: ${this.stock})`;
    }
}
class Cart {
    constructor() {
        this.items = [];
    }
    
    addItem(product, quantity = 1) {
        if (!product.isAvailable(quantity)) {
            console.log(`${product.name} 재고 부족`);
            return false;
        }
        
        const existing = this.items.find(item => item.product.id === product.id);
        
        if (existing) {
            existing.quantity += quantity;
        } else {
            this.items.push({ product, quantity });
        }
        
        console.log(`${product.name} ${quantity}개 추가`);
        return true;
    }
    
    removeItem(productId) {
        this.items = this.items.filter(item => item.product.id !== productId);
    }
    
    getTotal() {
        return this.items.reduce((total, item) => {
            return total + item.product.price * item.quantity;
        }, 0);
    }
    
    checkout() {
        this.items.forEach(item => {
            item.product.decreaseStock(item.quantity);
        });
        
        const total = this.getTotal();
        this.items = [];
        return total;
    }
    
    printCart() {
        console.log("=== 장바구니 ===");
        this.items.forEach(item => {
            console.log(`${item.product.name} × ${item.quantity} = ${(item.product.price * item.quantity).toLocaleString()}원`);
        });
        console.log(`총액: ${this.getTotal().toLocaleString()}원`);
    }
}
// 사용
const laptop = new Product(1, "노트북", 1200000, 5);
const mouse = new Product(2, "마우스", 30000, 10);
const cart = new Cart();
cart.addItem(laptop, 1);
cart.addItem(mouse, 2);
cart.printCart();
const total = cart.checkout();
console.log(`결제 완료: ${total.toLocaleString()}원`);

Cart는 Product 객체를 복사하지 않고 참조로 보관합니다. 그래서 checkout에서 item.product.decreaseStock()을 호출하면 바깥의 laptop 객체 재고가 실제로 줄어듭니다. getTotal이 reduce로 합계를 계산하고, removeItem이 filter로 새 배열을 만드는 것도 배열 메서드와 클래스를 조합하는 전형적인 방식입니다.

이 예제를 실제 쇼핑몰 로직으로 옮기려면 두 가지를 보완해야 합니다. 첫째, addItem은 이번에 추가하는 수량만 재고와 비교하므로, 재고가 5개인 노트북을 3개씩 두 번 담으면 둘 다 통과해 장바구니에 6개가 들어갑니다. 기존 수량과 합친 값으로 isAvailable을 검사해야 합니다. 둘째, checkout은 항목을 차례로 처리하다가 중간에 재고 부족 예외가 나면 앞 항목의 재고는 이미 줄어든 상태로 멈춥니다. 모든 항목의 재고를 먼저 검사한 뒤 한꺼번에 차감하거나, 실패 시 되돌리는 로직이 필요합니다. 실제 서비스에서는 이 부분을 DB 트랜잭션이 담당하지만, 메모리 안의 객체를 다룰 때도 “부분 성공” 상태를 남기지 않는 설계는 똑같이 중요합니다. 금액을 1200000처럼 원 단위 정수로 다룬 것은 좋은 선택입니다. 0.1 + 0.2가 0.30000000000000004가 되는 부동소수점 오차를 피할 수 있기 때문입니다.


super() 누락, 화살표 함수 필드, new 없는 호출

실수 1: super() 호출 누락

// ❌ 잘못된 방법
class Child extends Parent {
    constructor(name, age) {
        // super() 누락!
        this.age = age;  // ReferenceError: Must call super constructor
                         // in derived class before accessing 'this'
    }
}
// ✅ 올바른 방법
class Child extends Parent {
    constructor(name, age) {
        super(name);  // 부모 생성자 호출 (필수!)
        this.age = age;
    }
}

실수 2: 화살표 함수 필드와 일반 메서드를 구분하지 않음

// 화살표 함수 필드: 인스턴스마다 함수가 따로 생성되고 this가 고정됨
class Person {
    constructor(name) {
        this.name = name;
    }
    
    greet = () => {
        console.log(`Hello, ${this.name}!`);
    }
}
// 일반 메서드: prototype에 하나만 있고 this는 호출 방식에 따라 정해짐
class Person {
    constructor(name) {
        this.name = name;
    }

    greet() {
        console.log(`Hello, ${this.name}!`);
    }
}

두 방식 중 어느 하나가 항상 틀린 것은 아니고, 동작이 다르다는 점을 모르고 섞어 쓰는 것이 실수입니다. 일반 메서드의 this는 호출하는 방식으로 정해집니다. person.greet()는 문제없지만, button.addEventListener("click", person.greet)나 setTimeout(person.greet, 100)처럼 메서드를 떼어서 넘기면 this가 undefined(클래스 본문은 strict mode)가 되어 TypeError: Cannot read properties of undefined (reading 'name')이 납니다. React 클래스 컴포넌트 시절 이 에러를 피하려고 생성자에서 this.greet = this.greet.bind(this)를 쓰거나 화살표 함수 필드를 쓰던 것이 바로 이 문제 때문입니다.

화살표 함수 필드는 정의될 때의 this(인스턴스)를 고정하므로 콜백으로 넘겨도 안전합니다. 대신 대가가 있습니다. 필드는 프로토타입이 아니라 인스턴스마다 새 함수로 만들어지므로 객체를 많이 만들면 메모리를 더 쓰고, 자식 클래스에서 super.greet()로 부를 수 없으며, 테스트에서 Person.prototype.greet를 스파이(mock)하는 방식도 통하지 않습니다. 콜백으로 넘길 일이 많은 이벤트 핸들러에는 화살표 함수 필드를, 일반적인 도메인 메서드에는 프로토타입 메서드를 쓰는 식으로 용도를 나누는 것이 현실적인 기준입니다.

실수 3: new 없이 호출

class Person {
    constructor(name) {
        this.name = name;
    }
}
// ❌ new 없이 호출
// const person = Person("홍길동");
// TypeError: Class constructor Person cannot be invoked without 'new'
// ✅ new 키워드 사용
const person = new Person("홍길동");

ES5 생성자 함수를 new 없이 호출하면 this가 전역 객체(또는 strict mode에서 undefined)가 되어 전역 변수가 오염되거나 엉뚱한 곳에서 에러가 났지만, 클래스는 호출 즉시 명확한 TypeError를 던집니다. 이 에러는 주로 클래스를 콜백으로 넘길 때(items.map(Person)) 나오며, items.map(name => new Person(name))처럼 감싸면 해결됩니다.


연습 문제: 스택과 타이머 클래스

문제 1: 스택 클래스

class Stack {
    constructor() {
        this.items = [];
    }
    
    push(item) {
        this.items.push(item);
    }
    
    pop() {
        if (this.isEmpty()) {
            throw new Error("Stack is empty");
        }
        return this.items.pop();
    }
    
    peek() {
        if (this.isEmpty()) {
            return null;
        }
        return this.items[this.items.length - 1];
    }
    
    isEmpty() {
        return this.items.length === 0;
    }
    
    get size() {
        return this.items.length;
    }
}
// 테스트
const stack = new Stack();
stack.push(1);
stack.push(2);
stack.push(3);
console.log(stack.peek());  // 3
console.log(stack.pop());   // 3
console.log(stack.size);    // 2

Stack은 배열을 감싸기만 한 것처럼 보이지만, 외부에 push/pop/peek만 노출해서 “중간 원소를 꺼내거나 끼워 넣는” 동작을 막는다는 데 의미가 있습니다. 다만 items가 public이라 stack.items.splice(0, 1)로 규칙을 우회할 수 있으므로, 앞에서 배운 #items로 바꿔 보는 것이 좋은 연습입니다. pop은 빈 스택에서 예외를 던지고 peek은 null을 돌려주는 식으로 두 메서드의 실패 처리 방식이 다른데, 하나의 클래스 안에서는 규칙을 통일하는 편이 사용하는 쪽이 덜 헷갈립니다. size를 getter로 만든 덕분에 stack.size처럼 속성으로 읽히고, stack.size = 10처럼 대입해도 실제 크기는 바뀌지 않습니다.

문제 2: 타이머 클래스

class Timer {
    constructor() {
        this.startTime = null;
        this.elapsed = 0;
        this.running = false;
    }
    
    start() {
        if (this.running) return;
        
        this.running = true;
        this.startTime = Date.now() - this.elapsed;
    }
    
    stop() {
        if (!this.running) return;
        
        this.running = false;
        this.elapsed = Date.now() - this.startTime;
    }
    
    reset() {
        this.startTime = null;
        this.elapsed = 0;
        this.running = false;
    }
    
    getTime() {
        if (this.running) {
            return Date.now() - this.startTime;
        }
        return this.elapsed;
    }
    
    toString() {
        const ms = this.getTime();
        const seconds = Math.floor(ms / 1000);
        const minutes = Math.floor(seconds / 60);
        const remainingSeconds = seconds % 60;
        
        return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
    }
}
// 테스트
const timer = new Timer();
timer.start();
setTimeout(() => {
    console.log(timer.toString());  // 0:02
    timer.stop();
}, 2000);

start에서 this.startTime = Date.now() - this.elapsed로 계산하는 부분이 이 클래스의 핵심입니다. 멈췄다가 다시 시작하면 이미 흐른 시간만큼 시작 시각을 과거로 당겨서, getTime()이 이어진 시간을 돌려주게 됩니다. 콜백을 화살표 함수로 넘겼기 때문에 timer를 그대로 참조할 수 있다는 점도 앞의 this 논의와 연결됩니다. 정밀한 측정이 필요하다면 Date.now() 대신 performance.now()를 쓰는 것이 좋습니다. Date.now()는 시스템 시계를 따르므로 사용자가 시각을 바꾸거나 NTP 동기화가 일어나면 경과 시간이 음수가 되는 일도 생깁니다. setTimeout의 2000ms도 최소 대기 시간일 뿐이라 출력이 0:02가 아닌 0:01이나 0:03에 가까운 값이 될 수 있습니다.


클래스 문법 요약

  1. 클래스 기본:
    • class 키워드로 정의
    • constructor: 생성자
    • 메서드: 함수 정의
  2. getter/setter:
    • get: 속성처럼 접근
    • set: 속성처럼 할당
    • 유효성 검사 가능
  3. static:
    • 정적 메서드/속성
    • 인스턴스 없이 호출
    • 유틸리티 함수, 팩토리 메서드
  4. 상속:
    • extends: 상속
    • super(): 부모 생성자 호출
    • 메서드 오버라이딩
  5. Private 필드 (ES2022+):
    • # 접두사
    • 클래스 외부 접근 불가

설계할 때 기억할 점

클래스를 쓸지 말지부터 판단하는 것이 좋습니다. 상태와 그 상태를 바꾸는 규칙이 함께 있고(은행 계좌, 장바구니, 타이머), 인스턴스를 여러 개 만든다면 클래스가 잘 맞습니다. 반면 상태 없는 함수 모음이라면 모듈 함수가 더 단순합니다. 클래스를 쓴다면 모든 필드를 생성자에서 초기화해 객체 모양을 일정하게 유지하고(V8 같은 엔진의 최적화에도 유리합니다), 외부에서 바꾸면 안 되는 상태는 # 필드로 숨기며, 상속은 “is-a” 관계가 분명할 때만 한두 단계로 제한하는 편이 유지보수하기 쉽습니다.

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 자식 클래스 constructor에서 super()를 호출하지 않으면 어떻게 되나요?

A. extends로 상속한 클래스에서 constructor를 정의했다면, this를 사용하기 전에 반드시 super()를 호출해야 합니다. 그렇지 않으면 ReferenceError: Must call super constructor in derived class before accessing 'this' 같은 오류가 납니다. 자식 클래스에서 constructor를 아예 생략하면 부모 생성자에 인자를 그대로 넘기는 기본 생성자가 자동으로 만들어집니다.