Chapter 5

클래스 (Class)

TypeScript의 객체지향 프로그래밍


OOP 캡슐화 (Encapsulation) 상속 (Inheritance) 추상화

클래스 기본 구조

프로퍼티, 생성자 (Constructor), 메서드로 구성됩니다

왜 클래스가 필요한가? 관련된 데이터(프로퍼티)와 동작(메서드)을 하나로 묶어서 관리하면, 코드를 더 체계적으로 정리할 수 있습니다. 붕어빵 틀(클래스)로 붕어빵(인스턴스 (Instance, 클래스로 만든 실제 객체))을 찍어내는 것과 같습니다.

class User {
  // 프로퍼티 선언 (타입 필수!)
  name: string;
  email: string;
  age: number;

  // 생성자: 클래스로 객체를 만들 때 자동으로 호출되는 함수
  constructor(name: string, email: string, age: number) {
    this.name = name;
    this.email = email;
    this.age = age;
  }

  // 메서드: 클래스 안에 정의된 함수
  greet(): string {
    return `안녕하세요, ${this.name}입니다!`;
  }
}

// new 키워드로 인스턴스(실제 객체) 생성
const user = new User("홍길동", "hong@example.com", 30);
console.log(user.greet()); // "안녕하세요, 홍길동입니다!"
        
TypeScript에서는 프로퍼티를 반드시 선언해야 합니다. JavaScript처럼 constructor에서 바로 할당하면 오류!

접근 제어자 (Access Modifier)

제어자 클래스 내부 자식 클래스 외부
public O O O
protected O O X
private O X X

class BankAccount {
  public owner: string;         // 어디서든 접근 가능 (기본값)
  protected balance: number;    // 클래스와 자식에서만
  private pin: string;          // 이 클래스 내부에서만

  constructor(owner: string, balance: number, pin: string) {
    this.owner = owner;
    this.balance = balance;
    this.pin = pin;
  }
}
        

접근 제어자 실전 예제


class BankAccount {
  public owner: string;         // 공개: 어디서든 접근 가능
  protected balance: number;    // 보호: 이 클래스와 자식 클래스에서만
  private pin: string;          // 비공개: 이 클래스 내부에서만

  constructor(owner: string, balance: number, pin: string) {
    this.owner = owner;
    this.balance = balance;
    this.pin = pin;
  }

  // private 메서드: 클래스 내부에서만 호출 가능
  private verifyPin(inputPin: string): boolean {
    return this.pin === inputPin;
  }

  // public 메서드: 외부에서 호출 가능
  public withdraw(amount: number, inputPin: string): boolean {
    if (!this.verifyPin(inputPin)) return false;  // 내부에서 private 호출
    if (this.balance < amount) return false;
    this.balance -= amount;
    return true;
  }
}

// 상속: 부모 클래스의 기능을 물려받음
class SavingsAccount extends BankAccount {
  getBalance(): number {
    return this.balance;    // OK: protected는 자식에서 접근 가능
    // return this.pin;     // Error: private은 자식에서도 접근 불가!
  }
}

const account = new BankAccount("홍길동", 10000, "1234");
account.owner;              // OK: public이므로 외부에서 접근 가능
// account.balance;         // Error: protected - 외부 접근 불가
// account.pin;             // Error: private - 외부 접근 불가
        

readonly 프로퍼티

생성 시에만 값을 할당하고, 이후 변경 불가 (읽기 전용)


class Config {
  readonly apiUrl: string;
  readonly maxRetries: number;
  readonly createdAt: Date;

  constructor(apiUrl: string, maxRetries: number) {
    // 생성자에서만 할당 가능
    this.apiUrl = apiUrl;
    this.maxRetries = maxRetries;
    this.createdAt = new Date();
  }

  // updateUrl(url: string) {
  //   this.apiUrl = url;  // Error! readonly 변경 불가
  // }
}

const config = new Config("https://api.example.com", 3);
console.log(config.apiUrl);     // OK: 읽기는 가능
// config.apiUrl = "new-url";   // Error: readonly!
        

readonly vs const

const는 변수에, readonly는 프로퍼티에 사용합니다.

매개변수 프로퍼티 (Parameter Properties)

생성자 매개변수에 접근 제한자를 붙이면 자동으로 프로퍼티 선언 + 할당!

기존 방식 (장황함)


class User {
  public name: string;
  private email: string;
  readonly id: number;

  constructor(
    name: string,
    email: string,
    id: number
  ) {
    this.name = name;
    this.email = email;
    this.id = id;
  }
}
            

매개변수 프로퍼티 (간결!)


class User {
  constructor(
    public name: string,
    private email: string,
    readonly id: number
  ) {
    // 자동으로 선언 + 할당!
    // 본문이 비어있어도 OK
  }
}
            
두 코드는 완전히 동일합니다. 매개변수 프로퍼티를 사용하면 보일러플레이트를 크게 줄일 수 있습니다.

게터/세터 (Getter/Setter)

프로퍼티 접근을 제어하고 유효성 검증 로직을 추가


class Temperature {
  private _celsius: number;

  constructor(celsius: number) {
    this._celsius = celsius;
  }

  // Getter: 프로퍼티처럼 읽기
  get fahrenheit(): number {
    return this._celsius * 9 / 5 + 32;
  }

  // Setter: 프로퍼티처럼 쓰기 + 유효성 검증
  set celsius(value: number) {
    if (value < -273.15) {
      throw new Error("절대영도 이하는 불가능합니다!");
    }
    this._celsius = value;
  }

  get celsius(): number {
    return this._celsius;
  }
}

const temp = new Temperature(100);
console.log(temp.fahrenheit);  // 212 (마치 프로퍼티처럼!)
temp.celsius = 0;              // setter 호출
// temp.celsius = -300;        // Error: 절대영도 이하!
        
Getter만 있고 Setter가 없으면 자동으로 readonly가 됩니다.

정적 멤버 (Static Member)

인스턴스가 아닌 클래스 자체에 속하는 멤버


class MathUtils {
  static readonly PI = 3.14159265;

  static circleArea(radius: number): number {
    return MathUtils.PI * radius ** 2;
  }

  static clamp(value: number, min: number, max: number): number {
    return Math.min(Math.max(value, min), max);
  }
}

// new 없이 클래스 이름으로 직접 호출!
console.log(MathUtils.PI);              // 3.14159265
console.log(MathUtils.circleArea(5));   // 78.539...
console.log(MathUtils.clamp(15, 0, 10)); // 10
        

// 실전: 싱글톤 패턴 (프로그램 전체에서 딱 하나만 존재하는 객체)
class Database {
  private static instance: Database;  // 유일한 인스턴스 저장

  // private 생성자: 외부에서 new로 생성 못하게 막음
  private constructor(private connectionString: string) {}

  // 유일한 인스턴스를 반환하는 정적 메서드
  static getInstance(): Database {
    if (!Database.instance) {
      Database.instance = new Database("mongodb://localhost:27017");
    }
    return Database.instance;
  }
}

const db1 = Database.getInstance();
const db2 = Database.getInstance();
console.log(db1 === db2); // true (항상 같은 인스턴스!)
        

추상 클래스 (Abstract Class)

직접 인스턴스화할 수 없고, 반드시 상속해서 구현 (Implements)해야 하는 클래스


abstract class Shape {
  constructor(public color: string) {}

  // 추상 메서드: 구현 없이 시그니처만 정의
  abstract getArea(): number;
  abstract getPerimeter(): number;

  // 일반 메서드: 공통 로직 제공
  describe(): string {
    return `${this.color} 도형 (면적: ${this.getArea().toFixed(2)})`;
  }
}

// const shape = new Shape("red"); // Error! 추상 클래스 인스턴스화 불가

class Circle extends Shape {
  constructor(color: string, public radius: number) {
    super(color);
  }

  // 반드시 구현해야 함!
  getArea(): number {
    return Math.PI * this.radius ** 2;
  }

  getPerimeter(): number {
    return 2 * Math.PI * this.radius;
  }
}

class Rectangle extends Shape {
  constructor(color: string, public width: number, public height: number) {
    super(color);
  }

  getArea(): number { return this.width * this.height; }
  getPerimeter(): number { return 2 * (this.width + this.height); }
}

const circle = new Circle("파랑", 5);
console.log(circle.describe()); // "파랑 도형 (면적: 78.54)"
        

추상 클래스 vs 인터페이스 (Interface)

특성 추상 클래스 인터페이스
구현 코드 포함 가능 (일반 메서드) 불가능
다중 상속/구현 단일 상속만 다중 구현 가능
생성자 가질 수 있음 없음
접근 제한자 사용 가능 public만
런타임 (Runtime, 실행 시점) 존재 JavaScript로 컴파일 (Compile, 코드 변환)됨 컴파일 시 제거됨
사용 시기 공통 구현 + 틀 제공 계약(규격) 정의

선택 기준

공통 로직이 있으면 추상 클래스, 순수하게 형태만 정의하면 인터페이스

인터페이스 구현 (implements)

클래스가 특정 인터페이스의 계약을 이행하도록 강제합니다. "이 클래스는 반드시 이 기능들을 가지고 있어야 한다"는 약속입니다.


interface Serializable {
  serialize(): string;
  deserialize(data: string): void;
}

interface Loggable {
  log(message: string): void;
}

// 다중 인터페이스 구현 가능!
class UserProfile implements Serializable, Loggable {
  constructor(
    public name: string,
    public email: string
  ) {}

  serialize(): string {
    return JSON.stringify({ name: this.name, email: this.email });
  }

  deserialize(data: string): void {
    const parsed = JSON.parse(data);
    this.name = parsed.name;
    this.email = parsed.email;
  }

  log(message: string): void {
    console.log(`[UserProfile] ${message}`);
  }
}

// 인터페이스 타입으로 사용 가능
function saveToFile(item: Serializable): void {
  const data = item.serialize();
  // 파일에 저장...
}
        
implements는 타입 체크만 합니다. 인터페이스의 메서드를 구현하지 않으면 컴파일 에러!

클래스 표현식 (Class Expressions)

함수 표현식처럼 클래스도 표현식으로 사용 가능


// 이름 있는 클래스 표현식
const Animal = class AnimalClass {
  constructor(public name: string) {}

  speak(): string {
    return `${this.name}이(가) 소리를 냅니다.`;
  }
};

const dog = new Animal("멍멍이");
// new AnimalClass("..."); // Error: 외부에서 클래스 이름 접근 불가
        

// 실전: 팩토리 패턴에서 유용
function createLogger(prefix: string) {
  return class {
    log(message: string) {
      console.log(`[${prefix}] ${message}`);
    }
  };
}

const AppLogger = createLogger("APP");
const DbLogger = createLogger("DB");

const logger = new AppLogger();
logger.log("서버 시작됨"); // [APP] 서버 시작됨
        

this 타입

this를 타입으로 사용하면 메서드 체이닝에 유용


class QueryBuilder {
  private conditions: string[] = [];
  private orderByField?: string;

  where(condition: string): this {
    this.conditions.push(condition);
    return this; // this 반환으로 체이닝
  }

  orderBy(field: string): this {
    this.orderByField = field;
    return this;
  }

  build(): string {
    let query = "SELECT * FROM table";
    if (this.conditions.length > 0) {
      query += " WHERE " + this.conditions.join(" AND ");
    }
    if (this.orderByField) {
      query += " ORDER BY " + this.orderByField;
    }
    return query;
  }
}

// 자식 클래스에서도 체이닝이 유지됨!
class AdvancedQueryBuilder extends QueryBuilder {
  private limitCount?: number;

  limit(count: number): this {
    this.limitCount = count;
    return this;
  }
}

const query = new AdvancedQueryBuilder()
  .where("age > 18")       // this = AdvancedQueryBuilder
  .where("active = true")  // this = AdvancedQueryBuilder
  .orderBy("name")         // this = AdvancedQueryBuilder
  .limit(10)               // this = AdvancedQueryBuilder
  .build();
        

믹스인 (Mixin) 패턴

다중 상속 (Multiple Inheritance)의 대안 - 여러 클래스의 기능을 조합


// 믹스인을 위한 생성자 타입
type Constructor<T = {}> = new (...args: any[]) => T;

// 타임스탬프 믹스인
function Timestamped<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    createdAt = new Date();
    updatedAt = new Date();

    touch() {
      this.updatedAt = new Date();
    }
  };
}

// 소프트 삭제 믹스인
function SoftDeletable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    isDeleted = false;
    deletedAt?: Date;

    softDelete() {
      this.isDeleted = true;
      this.deletedAt = new Date();
    }
  };
}

// 기본 클래스
class User {
  constructor(public name: string, public email: string) {}
}

// 믹스인 조합!
const EnhancedUser = SoftDeletable(Timestamped(User));

const user = new EnhancedUser("홍길동", "hong@mail.com");
user.touch();         // Timestamped 기능
user.softDelete();    // SoftDeletable 기능
console.log(user.createdAt, user.isDeleted); // 모든 기능 사용 가능
        

실전 예제: 이벤트 시스템


type EventHandler<T = any> = (data: T) => void;

abstract class EventEmitter {
  private handlers: Map<string, EventHandler[]> = new Map();

  on(event: string, handler: EventHandler): void {
    const existing = this.handlers.get(event) || [];
    this.handlers.set(event, [...existing, handler]);
  }

  protected emit(event: string, data?: any): void {
    const handlers = this.handlers.get(event) || [];
    handlers.forEach(handler => handler(data));
  }

  off(event: string, handler: EventHandler): void {
    const existing = this.handlers.get(event) || [];
    this.handlers.set(event, existing.filter(h => h !== handler));
  }
}

class ShoppingCart extends EventEmitter {
  private items: Array<{ name: string; price: number }> = [];

  addItem(name: string, price: number): void {
    this.items.push({ name, price });
    this.emit("itemAdded", { name, price });
    this.emit("totalChanged", this.getTotal());
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}

const cart = new ShoppingCart();
cart.on("itemAdded", (item) => console.log(`추가됨: ${item.name}`));
cart.on("totalChanged", (total) => console.log(`합계: ${total}원`));
cart.addItem("TypeScript 책", 35000);
        

Chapter 5 요약

핵심 정리

  • 클래스 기본: 프로퍼티 선언 필수, 생성자에서 초기화
  • 접근 제어자: public / protected / private로 캡슐화
  • 매개변수 프로퍼티: 생성자 매개변수에 제어자 = 자동 선언 + 할당
  • Getter/Setter: 프로퍼티 접근 제어 및 유효성 검증
  • static: 인스턴스 없이 클래스 레벨에서 접근
  • abstract: 구현을 강제하는 틀 제공 (인스턴스화 불가)
  • implements: 인터페이스 계약 이행
  • this 타입: 메서드 체이닝, 믹스인 등 고급 패턴

다음 챕터: Chapter 6 - 제네릭 →