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.json의 strict 모드에서는 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 메서드에 외부 접근 불가
접근 제어자 비교
| 제어자 | 클래스 내부 | 서브클래스 | 외부 |
|---|---|---|---|
| public | O | O | O |
| protected | O | O | X |
| private | O | X | X |
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() | 인스턴스 초기화, 오버로딩 불가(단 하나) |
| public | public (기본값) | 어디서든 접근 가능 |
| private | private | 클래스 내부만, 컴파일 타임 검사 |
| protected | protected | 클래스 + 서브클래스 |
| readonly | readonly | 선언/생성자에서만 할당 |
| 매개변수 단축 | constructor(private x: T) | 프로퍼티 선언+할당 자동화 |
| 인터페이스 구현 | implements I | 다중 구현 가능 |
| JS private | #field | 런타임 진짜 비공개 |
| 클래스 표현식 | const Cls = class {} | 동적 클래스 생성, 믹스인 |
다음 장에서는 추상 클래스(abstract class)와 다중 인터페이스 구현을 통한 고급 OOP 패턴을 살펴본다. 추상 클래스가 일반 클래스, 인터페이스와 어떻게 다르며 언제 사용해야 하는지 실전 예제로 배운다.