[TypeScript] 클래스: 타입 안정성과 객체 지향의 만남

클래스 기본 구조와 필드 선언

가장 기본적인 클래스는 아무런 멤버도 없는 빈 클래스입니다.

class EmptyClass {}

그러나 실용적인 클래스는 데이터를 저장하기 위해 필드를 선언합니다. 필드는 기본적으로 public이며, 명시적 타입 지정과 초기값 할당이 가능합니다.

class Coordinate {
  x: number;
  y: number;

  // 필드 초기값으로 타입 추론
  z = 0;
}

const point = new Coordinate();
point.x = 10;
point.y = 20;
console.log(`좌표: (${point.x}, ${point.y}, ${point.z})`);
// 출력: 좌표: (10, 20, 0)

엄격한 속성 초기화와 확실한 할당

TypeScript의 --strictPropertyInitialization 옵션은 클래스 필드가 생성자 내에서 반드시 초기화되어야 함을 요구합니다. 만약 생성자에서 초기화하지 않으면 오류가 발생합니다.

class Uninitialized {
  message: string; // 오류: 생성자에서 초기화되지 않음
}

// 해결 방법 1: 생성자에서 초기화
class Initialized {
  message: string;
  constructor(msg: string) {
    this.message = msg;
  }
}

// 해결 방법 2: 확실한 할당 단언자 (!) 사용, 나중에 초기화 보장
class DeferredInit {
  message!: string;
}

const deferred = new DeferredInit();
deferred.message = "나중에 초기화";
console.log(deferred.message);

읽기 전용 필드 (readonly)

읽기 전용 필드는 생성자에서만 수정할 수 있으며, 클래스 외부에서는 재할당할 수 없습니다.

class ImmutablePoint {
  readonly x: number;
  readonly y: number = 0; // 초기값 지정 가능

  constructor(x: number) {
    this.x = x;
  }
}

const p = new ImmutablePoint(5);
console.log(`ImmutablePoint: (${p.x}, ${p.y})`);
// p.x = 10; // 오류: 읽기 전용 필드는 재할당 불가

생성자: 초기화와 상속

생성자는 인스턴스 초기화를 담당하며, 기본값과 상속 시 `super` 호출을 지원합니다.

class Shape {
  color: string;

  constructor(color = "black") {
    this.color = color;
  }
}

class Square extends Shape {
  size: number;

  constructor(size: number, color?: string) {
    super(color);
    this.size = size;
  }
}

const sq = new Square(4, "red");
console.log(`사각형: ${sq.color}, 크기: ${sq.size}`); // 출력: 사각형: red, 크기: 4

메서드 정의와 오버라이딩

클래스 메서드는 클래스의 동작을 정의합니다. 메서드는 기본적으로 public이며, 파생 클래스에서 오버라이딩하여 재정의할 수 있습니다.

class Vehicle {
  move(): string {
    return "이동 중";
  }
}

class Car extends Vehicle {
  move(): string {
    return "자동차가 달립니다";
  }
}

const car = new Car();
console.log(car.move()); // 출력: 자동차가 달립니다

접근자 (Getters/Setters)

접근자는 클래스 필드에 접근할 때 추가 로직을 수행할 수 있도록 도와줍니다. 게터(getter)는 값을 읽을 때, 세터(setter)는 값을 쓸 때 호출됩니다.

class Rectangle {
  private _width: number = 0;
  private _height: number = 0;

  get area(): number {
    return this._width * this._height;
  }

  set dimensions({ width, height }: { width: number; height: number }) {
    this._width = width;
    this._height = height;
  }
}

const rect = new Rectangle();
rect.dimensions = { width: 5, height: 10 };
console.log("면적:", rect.area); // 출력: 면적: 50

인덱스 시그니처

클래스에서 동적으로 키를 다뤄야 할 경우, 인덱스 시그니처를 사용할 수 있습니다.

class Dictionary {
  [key: string]: string | number;

  addEntry(key: string, value: string | number): void {
    this[key] = value;
  }
}

const dict = new Dictionary();
dict.addEntry("age", 30);
dict.addEntry("name", "Alice");
console.log(dict["name"]); // 출력: Alice

접근 제어자: public, protected, private

멤버의 접근성을 제어하여 클래스 내부와 외부의 경계를 설정할 수 있습니다.

public

기본 접근자로, 클래스 외부에서도 자유롭게 접근할 수 있습니다.

class PublicExample {
  message: string = "Public";
}
const pe = new PublicExample();
console.log(pe.message); // 접근 가능

protected

해당 클래스와 파생 클래스 내에서만 접근할 수 있습니다.

class ProtectedExample {
  protected secret: string = "Not for public";
}

class DerivedExample extends ProtectedExample {
  reveal(): void {
    console.log(this.secret);
  }
}

const de = new DerivedExample();
de.reveal(); // 출력: Not for public
// de.secret; // 오류: 외부에서는 접근 불가

private

해당 클래스 내에서만 접근할 수 있으며, 파생 클래스에서도 접근할 수 없습니다.

class PrivateExample {
  private hidden: number = 42;

  getHidden(): number {
    return this.hidden;
  }
}

const pe2 = new PrivateExample();
console.log(pe2.getHidden()); // 출력: 42
// console.log(pe2.hidden); // 오류: private 멤버 접근 불가

this와 컨텍스트 보장

클래스 내 메서드는 this를 통해 해당 클래스 인스턴스에 접근할 수 있습니다. TypeScript는 함수의 첫 번째 매개변수로 this 타입을 지정하거나 화살표 함수를 사용하여, 메서드가 올바른 컨텍스트에서 호출되도록 강제할 수 있습니다.

class Counter {
  count = 0;

  increment(this: Counter): void {
    this.count++;
  }
}

const counter = new Counter();
counter.increment(); // 정상 호출

const inc = counter.increment;
// inc(); // 오류: 'this' 컨텍스트가 맞지 않음

화살표 함수

class SafeCounter {
  count = 0;

  // 화살표 함수를 사용하여 각 인스턴스마다 독립된 함수를 생성
  increment = (): void => {
    this.count++;
  };
}

const sc = new SafeCounter();
const incSafe = sc.increment;
incSafe(); // 안전하게 호출됨
console.log(sc.count); // 출력: 1

this 매개변수

class Clock {
  time = 0;

  advance(this: Clock): void {
    this.time++;
  }
}

const clock = new Clock();
const advance = clock.advance;
// advance(); // 오류: this 컨텍스트 누락
clock.advance();
console.log(clock.time); // 출력: 1

매개변수 속성

생성자 매개변수에 접근 제어자를 붙여 필드를 자동 생성합니다.

class UserProfile {
  // 생성자 파라미터 앞에 붙은 접근 제어자가 곧 클래스 필드가 됩니다.
  constructor(public readonly username: string, private age: number) {}

  display(): void {
    console.log(`User: ${this.username}, Age: ${this.age}`);
  }
}

const profile = new UserProfile("minji", 29);
profile.display();  // 출력: User: minji, Age: 29
// profile.age; // 오류: age는 private 멤버

클래스 표현식

클래스 표현식은 클래스 선언과 매우 유사하지만, 이름이 없이 표현할 수 있다는 점이 특징입니다. 표현식에 의해 생성된 클래스는 상수에 할당할 수 있으며, 그 상수를 통해 인스턴스를 생성할 수 있습니다.

const DataHolder = class<T> {
  data: T;
  constructor(value: T) {
    this.data = value;
  }
  getData(): T {
    return this.data;
  }
};

const holder = new DataHolder<number>(42);
console.log(holder.getData()); // 출력: 42

생성자 시그니처와 InstanceType

생성자 시그니처는 `new`로 호출 가능한 타입을 정의하며, `InstanceType`으로 인스턴스 타입을 추출합니다.

class Circle {
  radius: number;
  timestamp: number;

  constructor(radius: number) {
    this.radius = radius;
    this.timestamp = Date.now();
  }
}

type CircleInstance = InstanceType<typeof Circle>;

function resize(circle: CircleInstance, factor: number): void {
  circle.radius *= factor;
}

const circ = new Circle(5);
resize(circ, 2);
console.log(`반지름: ${circ.radius}`); // 출력: 반지름: 10

추상 클래스

추상 클래스는 직접 인스턴스화할 수 없으며, 하위 클래스에서 추상 멤버를 구현해야 합니다.

abstract class Device {
  abstract powerOn(): string;

  status(): string {
    return "장치 준비됨";
  }
}

class Lamp extends Device {
  powerOn(): string {
    return "램프가 켜졌습니다";
  }
}

const lamp = new Lamp();
console.log(lamp.status()); // 출력: 장치 준비됨
console.log(lamp.powerOn()); // 출력: 램프가 켜졌습니다

추상 생성자 시그니처

추상 클래스를 상속받은 구체적인 클래스만 생성할 수 있도록 제한합니다.

function activate(ctor: new () => Device): string {
  const device = new ctor();
  return device.powerOn();
}

console.log(activate(Lamp)); // 출력: 램프가 켜졌습니다
// activate(Device); // 오류: 추상 클래스는 생성 불가

클래스 간 관계: 구조적 타이핑

클래스의 명시적 상속 관계 없이도 구조적으로 동일하면 호환됩니다.

동일 구조 호환

class PointA {
  x: number = 0;
  y: number = 0;
}

class PointB {
  x: number = 0;
  y: number = 0;
}

const pt: PointA = new PointB();
console.log(`Point: (${pt.x}, ${pt.y})`); // 출력: Point: (0, 0)

하위 구조 호환

class BaseEntity {
  id: string;
  name: string;
}

class DetailedEntity {
  id: string;
  name: string;
  details: string;
}

const entity: BaseEntity = new DetailedEntity();
entity.id = "e1";
entity.name = "항목";
console.log(`${entity.id}: ${entity.name}`); // 출력: e1: 항목

빈 클래스 호환

class VoidClass {}

function handle(obj: VoidClass): void {
  console.log("처리됨:", obj);
}

handle(new VoidClass()); // 출력: 처리됨: {}
handle({ key: "value" }); // 출력: 처리됨: { key: "value" }