5.4 클래스 심화
상속, 추상 클래스, 프라이빗 필드, 믹스인 패턴 등 클래스의 고급 기능을 학습합니다.
extends와 super(): 상속 구현
extends로 부모 클래스를 상속하고, super()로 부모 생성자를 호출합니다.
class Shape {
constructor(color = "black") {
if (new.target === Shape) {
throw new Error("Shape는 직접 인스턴스화할 수 없습니다");
}
this.color = color;
}
area() {
throw new Error("area()는 서브클래스에서 구현해야 합니다");
}
perimeter() {
throw new Error("perimeter()는 서브클래스에서 구현해야 합니다");
}
toString() {
return `${this.constructor.name}[color=${this.color}, area=${this.area().toFixed(2)}]`;
}
}
class Circle extends Shape {
#radius;
constructor(radius, color) {
super(color); // 반드시 super() 먼저 호출
this.#radius = radius;
}
get radius() { return this.#radius; }
area() { return Math.PI * this.#radius ** 2; }
perimeter() { return 2 * Math.PI * this.#radius; }
}
class Rectangle extends Shape {
constructor(width, height, color) {
super(color);
this.width = width;
this.height = height;
}
area() { return this.width * this.height; }
perimeter() { return 2 * (this.width + this.height); }
}
class Square extends Rectangle {
constructor(side, color) {
super(side, side, color); // Rectangle의 생성자 호출
}
// Rectangle의 area()와 perimeter()를 그대로 상속
}
const circle = new Circle(5, "red");
const rect = new Rectangle(4, 6, "blue");
const sq = new Square(3, "green");
console.log(`${circle}`); // "Circle[color=red, area=78.54]"
console.log(`${rect}`); // "Rectangle[color=blue, area=24.00]"
console.log(`${sq}`); // "Square[color=green, area=9.00]"
console.log(sq instanceof Square); // true
console.log(sq instanceof Rectangle); // true
console.log(sq instanceof Shape); // true
super.method() vs super()
class Logger {
log(message) {
return `[LOG] ${message}`;
}
error(message) {
return `[ERROR] ${message}`;
}
}
class TimestampLogger extends Logger {
log(message) {
const ts = new Date().toTimeString().slice(0, 8);
// super.method(): 부모의 메서드 호출
return `${ts} ${super.log(message)}`;
}
error(message) {
return `${super.error(message)} (${new Date().toISOString()})`;
}
}
class FilterLogger extends TimestampLogger {
#minLevel;
constructor(minLevel = "log") {
super(); // super(): 부모 생성자 호출
this.#minLevel = minLevel;
}
log(message) {
if (this.#minLevel === "error") return; // 필터링
return super.log(message); // 부모 메서드 위임
}
}
const logger = new TimestampLogger();
console.log(logger.log("서버 시작"));
// "12:30:00 [LOG] 서버 시작"
const filtered = new FilterLogger("error");
console.log(filtered.log("무시됨")); // undefined (필터됨)
console.log(filtered.error("심각한 오류")); // 오류 메시지
추상 클래스 패턴
JavaScript는 추상 클래스를 언어 레벨에서 지원하지 않지만, new.target으로 구현할 수 있습니다.
class AbstractRepository {
constructor() {
if (new.target === AbstractRepository) {
throw new TypeError("AbstractRepository는 직접 인스턴스화할 수 없습니다");
}
// 추상 메서드 정의 강제
const abstractMethods = ["findById", "findAll", "save", "delete"];
for (const method of abstractMethods) {
if (typeof this[method] !== "function") {
throw new TypeError(
`${new.target.name}은 '${method}' 메서드를 구현해야 합니다`
);
}
}
}
// 공통 유틸리티 메서드 (서브클래스가 상속)
async findOrFail(id) {
const entity = await this.findById(id);
if (!entity) {
throw new Error(`ID ${id}인 엔티티를 찾을 수 없습니다`);
}
return entity;
}
async exists(id) {
const entity = await this.findById(id);
return entity !== null;
}
}
// 구체 구현 클래스
class InMemoryUserRepository extends AbstractRepository {
#store = new Map();
#nextId = 1;
async findById(id) {
return this.#store.get(id) ?? null;
}
async findAll() {
return [...this.#store.values()];
}
async save(user) {
if (!user.id) {
user.id = this.#nextId++;
}
this.#store.set(user.id, { ...user });
return user;
}
async delete(id) {
return this.#store.delete(id);
}
}
const repo = new InMemoryUserRepository();
(async () => {
await repo.save({ name: "Alice", email: "alice@example.com" });
await repo.save({ name: "Bob", email: "bob@example.com" });
const users = await repo.findAll();
console.log(users.length); // 2
const user = await repo.findById(1);
console.log(user.name); // "Alice"
try {
await repo.findOrFail(99);
} catch (e) {
console.log(e.message); // "ID 99인 엔티티를 찾을 수 없습니다"
}
})();
Private 필드/메서드 (#)
# 접두사로 선언된 필드와 메서드는 클래스 외부에서 접근이 완전히 불가능합니다.
class LinkedList {
// Private 클래스 필드
#head = null;
#tail = null;
#size = 0;
// Private 메서드
#createNode(value) {
return { value, next: null, prev: null };
}
#validateIndex(index) {
if (!Number.isInteger(index) || index < 0 || index >= this.#size) {
throw new RangeError(`유효하지 않은 인덱스: ${index}`);
}
}
// Public API
push(value) {
const node = this.#createNode(value); // private 메서드 호출
if (this.#head === null) {
this.#head = this.#tail = node;
} else {
node.prev = this.#tail;
this.#tail.next = node;
this.#tail = node;
}
this.#size++;
return this;
}
pop() {
if (this.#size === 0) return undefined;
const value = this.#tail.value;
this.#tail = this.#tail.prev;
if (this.#tail) {
this.#tail.next = null;
} else {
this.#head = null;
}
this.#size--;
return value;
}
get(index) {
this.#validateIndex(index);
let current = this.#head;
for (let i = 0; i < index; i++) {
current = current.next;
}
return current.value;
}
get size() { return this.#size; }
*[Symbol.iterator]() {
let current = this.#head;
while (current) {
yield current.value;
current = current.next;
}
}
toArray() { return [...this]; }
}
const list = new LinkedList();
list.push(1).push(2).push(3).push(4);
console.log(list.size); // 4
console.log(list.get(2)); // 3
console.log(list.pop()); // 4
console.log(list.toArray()); // [1, 2, 3]
// 외부에서 접근 불가
// list.#head // SyntaxError
// list.#size // SyntaxError
// list.#createNode(5) // SyntaxError
// Private 필드 존재 확인 (in 연산자 사용)
const hasPrivate = (obj) => #head in obj;
console.log(hasPrivate(list)); // true
Protected 패턴: _prefix 관례
JavaScript에는 protected 키워드가 없습니다. _ 접두사 관례로 "서브클래스에서만 사용"을 표현합니다.
class EventEmitter {
// _prefix: 관례상 protected (외부에서 직접 사용하지 않을 것을 표현)
_listeners = new Map();
on(event, listener) {
if (!this._listeners.has(event)) {
this._listeners.set(event, new Set());
}
this._listeners.get(event).add(listener);
return this;
}
off(event, listener) {
this._listeners.get(event)?.delete(listener);
return this;
}
_emit(event, ...args) {
this._listeners.get(event)?.forEach(fn => fn(...args));
}
}
class Store extends EventEmitter {
#state;
constructor(initialState) {
super();
this.#state = initialState;
}
getState() { return { ...this.#state }; }
setState(updates) {
const prevState = this.#state;
this.#state = { ...this.#state, ...updates };
// _emit은 protected: 서브클래스에서 사용
this._emit("change", this.#state, prevState);
return this;
}
}
const store = new Store({ count: 0, name: "앱" });
store.on("change", (newState, prevState) => {
console.log(`상태 변경: count ${prevState.count} → ${newState.count}`);
});
store.setState({ count: 1 }); // "상태 변경: count 0 → 1"
store.setState({ count: 5 }); // "상태 변경: count 1 → 5"
믹스인 (Mixin) 패턴
JavaScript의 단일 상속 한계를 극복하는 다중 상속 대안입니다.
// 믹스인: 인수 클래스를 받아 확장된 클래스를 반환하는 함수
const Serializable = (Base) => class extends Base {
serialize() {
return JSON.stringify(this);
}
static deserialize(json) {
return Object.assign(new this(), JSON.parse(json));
}
};
const Validatable = (Base) => class extends Base {
#validators = {};
addValidator(field, fn) {
this.#validators[field] = fn;
return this;
}
validate() {
const errors = {};
for (const [field, fn] of Object.entries(this.#validators)) {
const error = fn(this[field]);
if (error) errors[field] = error;
}
return { valid: Object.keys(errors).length === 0, errors };
}
};
const Timestamped = (Base) => class extends Base {
createdAt = new Date().toISOString();
updatedAt = new Date().toISOString();
touch() {
this.updatedAt = new Date().toISOString();
return this;
}
};
// 여러 믹스인 조합
class UserModel extends Serializable(Validatable(Timestamped(class {}))) {
constructor(name, email, age) {
super();
this.name = name;
this.email = email;
this.age = age;
this
.addValidator("name", v => !v?.trim() ? "이름 필수" : null)
.addValidator("email", v => !v?.includes("@") ? "유효한 이메일 필요" : null)
.addValidator("age", v => (v < 0 || v > 150) ? "유효한 나이 필요" : null);
}
}
const user = new UserModel("Alice", "alice@example.com", 30);
console.log(user.validate()); // { valid: true, errors: {} }
const invalid = new UserModel("", "notanemail", -1);
const { valid, errors } = invalid.validate();
console.log(valid); // false
console.log(errors); // { name: "이름 필수", email: "유효한 이메일 필요", age: "유효한 나이 필요" }
const json = user.serialize();
console.log(typeof json); // "string"
console.log(user.createdAt); // ISO 날짜 문자열
toString, Symbol.toPrimitive 오버라이드
class Vector2D {
constructor(x, y) {
this.x = x;
this.y = y;
}
// 문자열 표현
toString() {
return `Vector2D(${this.x}, ${this.y})`;
}
// 타입에 따른 변환
[Symbol.toPrimitive](hint) {
switch (hint) {
case "number":
return Math.sqrt(this.x ** 2 + this.y ** 2); // 크기(magnitude)
case "string":
return this.toString();
default:
return this.x + this.y; // 기본값
}
}
// 벡터 연산 메서드
add(other) {
return new Vector2D(this.x + other.x, this.y + other.y);
}
scale(factor) {
return new Vector2D(this.x * factor, this.y * factor);
}
get magnitude() {
return Math.sqrt(this.x ** 2 + this.y ** 2);
}
normalize() {
const mag = this.magnitude;
return new Vector2D(this.x / mag, this.y / mag);
}
}
const v1 = new Vector2D(3, 4);
const v2 = new Vector2D(1, 2);
console.log(`${v1}`); // "Vector2D(3, 4)"
console.log(+v1); // 5 (크기, Symbol.toPrimitive "number")
console.log(v1 > v2); // true (5 > ~2.24)
console.log(v1.add(v2).toString()); // "Vector2D(4, 6)"
// JSON 직렬화 커스터마이징
class DateRange {
constructor(start, end) {
this.start = new Date(start);
this.end = new Date(end);
}
toJSON() {
return {
start: this.start.toISOString(),
end: this.end.toISOString(),
days: Math.ceil((this.end - this.start) / (1000 * 60 * 60 * 24)),
};
}
toString() {
return `${this.start.toLocaleDateString()} ~ ${this.end.toLocaleDateString()}`;
}
}
const range = new DateRange("2024-01-01", "2024-01-31");
console.log(JSON.stringify({ trip: range }));
// {"trip":{"start":"2024-01-01T00:00:00.000Z","end":"2024-01-31T00:00:00.000Z","days":30}}
고수 팁
팁 1: 클래스를 반환하는 팩토리 함수
function createModel(tableName, schema) {
return class Model {
static table = tableName;
static #schema = schema;
static validate(data) {
const errors = {};
for (const [field, rules] of Object.entries(Model.#schema)) {
if (rules.required && !data[field]) {
errors[field] = `${field}은 필수입니다`;
}
if (rules.type && typeof data[field] !== rules.type) {
errors[field] = `${field}은 ${rules.type} 타입이어야 합니다`;
}
}
return errors;
}
constructor(data) {
const errors = Model.validate(data);
if (Object.keys(errors).length > 0) {
throw new Error(Object.values(errors).join(", "));
}
Object.assign(this, data);
}
};
}
const UserModel = createModel("users", {
name: { required: true, type: "string" },
age: { required: true, type: "number" },
});
const user = new UserModel({ name: "Alice", age: 30 });
console.log(user.name); // "Alice"
console.log(UserModel.table); // "users"
팁 2: 상속 체인에서 super 올바른 사용
class A {
method() { return "A"; }
static staticMethod() { return "A.static"; }
}
class B extends A {
method() { return `B → ${super.method()}`; }
static staticMethod() { return `B.static → ${super.staticMethod()}`; }
}
class C extends B {
method() { return `C → ${super.method()}`; }
}
const c = new C();
console.log(c.method()); // "C → B → A"
console.log(C.staticMethod()); // 에러! C.staticMethod 없음
console.log(B.staticMethod()); // "B.static → A.static"
팁 3: instanceof와 Symbol.hasInstance
class EvenNumber {
static [Symbol.hasInstance](instance) {
return typeof instance === "number" && instance % 2 === 0;
}
}
console.log(2 instanceof EvenNumber); // true
console.log(3 instanceof EvenNumber); // false
console.log(100 instanceof EvenNumber); // true
// 타입 검사 유틸리티
class TypeChecker {
static [Symbol.hasInstance](instance) {
return this.check(instance);
}
}
class NonEmptyString extends TypeChecker {
static check(value) {
return typeof value === "string" && value.length > 0;
}
}
console.log("hello" instanceof NonEmptyString); // true
console.log("" instanceof NonEmptyString); // false
console.log(42 instanceof NonEmptyString); // false