본문으로 건너뛰기
Advertisement

4.1 클래스 기초

TypeScript에서 클래스(class)는 JavaScript ES6의 클래스 문법 위에 타입 시스템을 얹은 구조다. 같은 종류의 데이터와 그 데이터를 다루는 동작을 하나의 단위로 묶는 것이 클래스의 핵심 역할이다. Java나 C#을 경험한 개발자라면 익숙하겠지만, TypeScript의 클래스는 컴파일하면 일반 JavaScript 함수와 프로토타입으로 변환된다는 점을 기억해야 한다.

이 장에서는 클래스의 기본 선언부터 접근 제어자, readonly, 생성자 단축 문법, 인터페이스 구현까지 실무에서 반드시 알아야 할 패턴을 체계적으로 다룬다.


클래스 선언과 생성자

가장 기본적인 클래스 선언 방법이다. class 키워드, 클래스 이름(PascalCase), 중괄호 안에 프로퍼티와 메서드를 정의한다.

class Person {
name: string;
age: number;

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

greet(): string {
return `안녕하세요, 저는 ${this.name}이고 ${this.age}살입니다.`;
}
}

const alice = new Person("Alice", 30);
console.log(alice.greet()); // 안녕하세요, 저는 Alice이고 30살입니다.
console.log(alice.name); // Alice

TypeScript는 선언된 프로퍼티와 할당을 모두 검사한다. 생성자에서 this.name에 할당하지 않으면 "Property 'name' has no initializer" 오류가 발생한다.

strictPropertyInitialization

tsconfig.jsonstrict 모드에서는 strictPropertyInitialization이 활성화된다. 이 옵션이 켜져 있으면 생성자에서 반드시 초기화하거나, ! 단언 연산자 또는 기본값을 제공해야 한다.

class Example {
a: string; // 오류: 생성자에서 초기화 필요
b: string = ""; // OK: 기본값 제공
c!: string; // OK: 나중에 확실히 초기화됨을 단언
d: string | undefined; // OK: undefined 허용
}

접근 제어자 — public, private, protected

TypeScript는 세 가지 접근 제어자를 제공한다. 이는 컴파일 타임에만 강제되는 규칙이다(런타임에는 일반 JS 프로퍼티).

public — 기본값

아무 키워드도 붙이지 않으면 public이다. 클래스 안팎 어디서든 접근 가능하다.

class Dog {
public name: string; // public 명시 (생략해도 동일)

constructor(name: string) {
this.name = name;
}

public bark(): void {
console.log(`${this.name}: 멍멍!`);
}
}

const dog = new Dog("바둑이");
dog.name = "흰둥이"; // OK
dog.bark(); // OK

private — 클래스 내부 전용

private으로 선언된 멤버는 해당 클래스 내부에서만 접근할 수 있다. 서브클래스에서도 접근 불가능하다.

class Counter {
private count: number = 0;

increment(): void {
this.count++; // OK: 클래스 내부
}

getCount(): number {
return this.count; // OK: 클래스 내부
}
}

const c = new Counter();
c.increment();
console.log(c.getCount()); // 1
// c.count; // 오류: private 멤버에 외부 접근 불가

protected — 상속 계층 내 공유

protected는 클래스 내부와 서브클래스에서만 접근 가능하다. 외부에서는 접근할 수 없다.

class Animal {
protected species: string;

constructor(species: string) {
this.species = species;
}

protected describe(): string {
return `나는 ${this.species}입니다.`;
}
}

class Cat extends Animal {
private name: string;

constructor(name: string) {
super("고양이");
this.name = name;
}

introduce(): string {
// protected 멤버 접근 가능
return `${this.describe()} 이름은 ${this.name}입니다.`;
}
}

const cat = new Cat("나비");
console.log(cat.introduce()); // 나는 고양이입니다. 이름은 나비입니다.
// cat.species; // 오류: protected 멤버에 외부 접근 불가
// cat.describe(); // 오류: protected 메서드에 외부 접근 불가

접근 제어자 비교

제어자클래스 내부서브클래스외부
publicOOO
protectedOOX
privateOXX

readonly 프로퍼티

readonly 키워드가 붙은 프로퍼티는 선언 시점 또는 생성자 안에서만 값을 할당할 수 있다. 그 이후 어디서든 재할당하면 컴파일 오류가 발생한다.

class Config {
readonly appName: string;
readonly version: string = "1.0.0"; // 선언 시점 할당

constructor(appName: string) {
this.appName = appName; // 생성자에서 할당 OK
}

updateVersion(): void {
// this.version = "2.0.0"; // 오류: readonly 프로퍼티 변경 불가
// this.appName = "other"; // 오류: readonly 프로퍼티 변경 불가
}
}

const cfg = new Config("MyApp");
console.log(cfg.appName); // MyApp
// cfg.appName = "Other"; // 오류

readonly는 접근 제어자와 함께 사용할 수 있다.

class Point {
constructor(
public readonly x: number,
public readonly y: number
) {}

distanceTo(other: Point): number {
const dx = this.x - other.x;
const dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
}
}

const p1 = new Point(0, 0);
const p2 = new Point(3, 4);
console.log(p1.distanceTo(p2)); // 5
// p1.x = 10; // 오류

생성자 매개변수 단축 (Parameter Properties)

TypeScript만의 문법으로, 생성자 매개변수에 접근 제어자나 readonly를 붙이면 자동으로 같은 이름의 프로퍼티가 선언되고 할당된다. 보일러플레이트를 크게 줄여준다.

// 기존 방식 (보일러플레이트 많음)
class UserOld {
public id: number;
private name: string;
protected email: string;
readonly createdAt: Date;

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

// 단축 문법 (동일 결과)
class User {
constructor(
public id: number,
private name: string,
protected email: string,
readonly createdAt: Date
) {}

getName(): string {
return this.name;
}
}

const user = new User(1, "Alice", "alice@example.com", new Date());
console.log(user.id); // 1 (public)
console.log(user.getName()); // Alice
// user.name; // 오류: private

접근 제어자 또는 readonly가 없는 매개변수는 단축 적용이 안 된다. 반드시 하나 이상 붙어야 프로퍼티로 선언된다.


클래스와 인터페이스 — implements

클래스는 implements 키워드로 인터페이스를 구현할 수 있다. 인터페이스에 선언된 모든 멤버를 반드시 구현해야 한다.

interface Printable {
print(): void;
}

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

class Document implements Printable, Serializable {
private content: string;

constructor(content: string) {
this.content = content;
}

print(): void {
console.log(this.content);
}

serialize(): string {
return JSON.stringify({ content: this.content });
}

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

const doc = new Document("Hello TypeScript");
doc.print(); // Hello TypeScript
const serialized = doc.serialize();
console.log(serialized); // {"content":"Hello TypeScript"}

// 인터페이스 타입으로 사용
const printable: Printable = new Document("World");
printable.print();

implements는 타입 검사만 수행한다. 실제 구현 로직은 클래스 안에 작성해야 한다.


클래스 자체를 타입으로

TypeScript에서 클래스는 두 가지 타입 역할을 한다.

  • 인스턴스 타입: new ClassName()으로 만들어진 객체의 타입
  • 클래스 타입(생성자 타입): 클래스 자체, 즉 new를 호출할 수 있는 타입
class Robot {
constructor(public name: string) {}
greet(): string {
return `저는 로봇 ${this.name}입니다.`;
}
}

// 인스턴스 타입: Robot
const r: Robot = new Robot("R2-D2");

// 클래스 타입 (생성자 타입)
type RobotConstructor = typeof Robot;
const RobotClass: RobotConstructor = Robot;
const r2 = new RobotClass("C-3PO");
console.log(r2.greet());

// 생성자 시그니처를 직접 표현
type Newable<T> = new (...args: any[]) => T;
function createInstance<T>(cls: Newable<T>, ...args: any[]): T {
return new cls(...args);
}

const r3 = createInstance(Robot, "BB-8");
console.log(r3.greet()); // 저는 로봇 BB-8입니다.

실전 예제: BankAccount 클래스

은행 계좌를 모델링하여 캡슐화, 접근 제어자, readonly, 인터페이스를 모두 활용한 예제다.

interface AccountInfo {
readonly accountNumber: string;
owner: string;
getBalance(): number;
}

interface Transactionable {
deposit(amount: number): void;
withdraw(amount: number): boolean;
getHistory(): TransactionRecord[];
}

interface TransactionRecord {
type: "deposit" | "withdraw";
amount: number;
balance: number;
timestamp: Date;
}

class BankAccount implements AccountInfo, Transactionable {
readonly accountNumber: string;
owner: string;
private balance: number;
private history: TransactionRecord[] = [];

constructor(
accountNumber: string,
owner: string,
initialBalance: number = 0
) {
this.accountNumber = accountNumber;
this.owner = owner;
this.balance = initialBalance;
}

getBalance(): number {
return this.balance;
}

deposit(amount: number): void {
if (amount <= 0) {
throw new Error("입금액은 0보다 커야 합니다.");
}
this.balance += amount;
this.recordTransaction("deposit", amount);
console.log(`${amount}원 입금 완료. 잔액: ${this.balance}`);
}

withdraw(amount: number): boolean {
if (amount <= 0) {
throw new Error("출금액은 0보다 커야 합니다.");
}
if (amount > this.balance) {
console.log("잔액 부족");
return false;
}
this.balance -= amount;
this.recordTransaction("withdraw", amount);
console.log(`${amount}원 출금 완료. 잔액: ${this.balance}`);
return true;
}

getHistory(): TransactionRecord[] {
// 외부에서 내부 배열을 직접 수정하지 못하도록 복사본 반환
return [...this.history];
}

printStatement(): void {
console.log(`\n=== 거래내역 (${this.owner}) ===`);
console.log(`계좌번호: ${this.accountNumber}`);
this.history.forEach((record, index) => {
const typeLabel = record.type === "deposit" ? "입금" : "출금";
console.log(
`${index + 1}. [${typeLabel}] ${record.amount}원 | 잔액: ${record.balance}`
);
});
console.log(`현재 잔액: ${this.balance}원\n`);
}

private recordTransaction(
type: "deposit" | "withdraw",
amount: number
): void {
this.history.push({
type,
amount,
balance: this.balance,
timestamp: new Date(),
});
}
}

// 사용
const account = new BankAccount("1234-5678", "홍길동", 10000);
account.deposit(5000); // 5000원 입금 완료. 잔액: 15000원
account.withdraw(3000); // 3000원 출금 완료. 잔액: 12000원
account.withdraw(20000); // 잔액 부족
account.printStatement();

// accountNumber는 readonly
// account.accountNumber = "0000-0000"; // 오류
// account.balance; // 오류: private

고수 팁

JS private(#) vs TypeScript private 차이

TypeScript의 private은 컴파일 타임 전용 검사다. 컴파일된 JS에서는 일반 프로퍼티로 접근 가능하다. 반면 JavaScript의 # private field는 런타임에도 진짜 비공개다.

class Comparison {
private tsPrivate: string = "TS private"; // 런타임에는 공개
#jsPrivate: string = "JS private"; // 런타임에도 비공개

getTs(): string { return this.tsPrivate; }
getJs(): string { return this.#jsPrivate; }
}

const obj = new Comparison();
// (obj as any).tsPrivate; // 런타임에는 접근 가능 (TS 검사만 통과)
// (obj as any).#jsPrivate; // SyntaxError — 런타임에 완전 차단

실제 보안이 필요한 데이터(비밀번호 해시 등)는 #을 사용하고, 단순 설계 의도 표현에는 private을 사용한다.

클래스 표현식

클래스는 표현식으로도 사용할 수 있다. 익명 클래스나 동적 클래스 생성에 유용하다.

// 익명 클래스 표현식
const Greeter = class {
greet(name: string): string {
return `Hello, ${name}!`;
}
};

// 이름 있는 클래스 표현식 (이름은 내부에서만 사용 가능)
const Counter = class CounterClass {
private count = 0;
increment(): CounterClass {
this.count++;
return this;
}
value(): number { return this.count; }
};

const c = new Counter();
console.log(c.increment().increment().value()); // 2

// 믹스인 패턴에서 유용
type Constructor<T = {}> = new (...args: any[]) => T;

function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
createdAt = new Date();
};
}

class Article {
constructor(public title: string) {}
}

const TimestampedArticle = Timestamped(Article);
const article = new TimestampedArticle("TypeScript 클래스");
console.log(article.title); // TypeScript 클래스
console.log(article.createdAt); // 생성 시각

구조적 타이핑과 클래스

TypeScript는 구조적 타입 시스템을 사용한다. 클래스 이름이 달라도 구조가 같으면 호환 가능하다.

class Cat {
name: string;
constructor(name: string) { this.name = name; }
meow(): string { return "야옹"; }
}

class Robot {
name: string;
constructor(name: string) { this.name = name; }
meow(): string { return "삐빅"; }
}

function makeSound(animal: Cat): void {
console.log(animal.meow());
}

const r = new Robot("R2");
makeSound(r); // OK — 구조가 동일하므로 호환 가능

이 특성 때문에 TypeScript에서는 클래스 타입 검사가 "이름"이 아닌 "구조"를 기반으로 한다.


정리

개념키워드/문법핵심 특징
클래스 선언class Name {}PascalCase, 생성자 필수 아님
생성자constructor()인스턴스 초기화, 오버로딩 불가(단 하나)
publicpublic (기본값)어디서든 접근 가능
privateprivate클래스 내부만, 컴파일 타임 검사
protectedprotected클래스 + 서브클래스
readonlyreadonly선언/생성자에서만 할당
매개변수 단축constructor(private x: T)프로퍼티 선언+할당 자동화
인터페이스 구현implements I다중 구현 가능
JS private#field런타임 진짜 비공개
클래스 표현식const Cls = class {}동적 클래스 생성, 믹스인

다음 장에서는 추상 클래스(abstract class)와 다중 인터페이스 구현을 통한 고급 OOP 패턴을 살펴본다. 추상 클래스가 일반 클래스, 인터페이스와 어떻게 다르며 언제 사용해야 하는지 실전 예제로 배운다.

Advertisement