본문으로 건너뛰기

4.2 추상 클래스와 인터페이스 구현

추상 클래스(abstract class)는 직접 인스턴스를 만들 수 없는 클래스다. "이 클래스를 상속하는 모든 자식 클래스는 반드시 이 메서드를 구현해야 한다"는 계약을 강제하는 수단이다. 인터페이스와 비슷해 보이지만 중요한 차이가 있다. 추상 클래스는 실제 구현 코드를 포함할 수 있고, 생성자와 상태(프로퍼티)를 가질 수 있다.

이 장에서는 추상 클래스와 인터페이스의 차이를 명확히 이해하고, 다중 인터페이스 구현, 템플릿 메서드 패턴, 그리고 도형 계층과 보고서 생성기 실전 예제를 통해 설계 능력을 키운다.


추상 클래스 기본 문법​

abstract 키워드를 클래스 선언 앞에 붙이면 추상 클래스가 된다. 추상 메서드는 구현 없이 시그니처만 선언하며, 서브클래스에서 반드시 구현해야 한다.

abstract class Shape {
// 일반 메서드: 구현 포함 (서브클래스가 공유)
describe(): string {
return `이 도형의 넓이는 ${this.area()}입니다.`;
}

// 추상 메서드: 구현 없음, 서브클래스에서 필수 구현
abstract area(): number;
abstract perimeter(): number;
abstract name(): string;
}

// new Shape(); // 오류: 추상 클래스는 인스턴스화 불가

class Circle extends Shape {
constructor(private radius: number) {
super();
}

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

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

name(): string {
return "원";
}
}

const circle = new Circle(5);
console.log(circle.name()); // 원
console.log(circle.area().toFixed(2)); // 78.54
console.log(circle.describe()); // 이 도형의 넓이는 78.53981633974483입니다.

추상 클래스에서 추상 메서드를 구현하지 않으면 컴파일 오류가 발생한다.

class BadShape extends Shape {
area(): number { return 0; }
// 오류: perimeter()와 name() 미구현
// Non-abstract class 'BadShape' does not implement inherited abstract member 'perimeter'
}

abstract vs interface — 언제 무엇을?​

이 둘의 선택 기준은 명확하다.

추상 클래스를 선택하는 경우​

  • 공통 구현 로직(메서드 본체)을 서브클래스와 공유해야 할 때
  • 생성자 로직이 필요할 때
  • 상태(프로퍼티)를 가지고 서브클래스가 이를 상속해야 할 때
  • "is-a" 관계가 명확할 때 (Circle is a Shape)
  • 상속 계층이 필요할 때

인터페이스를 선택하는 경우​

  • 구현 없이 계약(contract)만 정의할 때
  • 여러 상속 계층에 걸친 능력(capability)을 표현할 때
  • 다중 구현이 필요할 때 (한 클래스가 여러 인터페이스 구현)
  • "can-do" 관계를 표현할 때 (Printable, Serializable)
  • 외부 라이브러리와의 통합 지점을 정의할 때
// 인터페이스: 능력/계약 표현
interface Printable {
print(): void;
}

interface Exportable {
exportToPDF(): Buffer;
exportToCSV(): string;
}

// 추상 클래스: 공통 기반 구현 제공
abstract class BaseReport {
protected title: string;
protected createdAt: Date;

constructor(title: string) {
this.title = title;
this.createdAt = new Date();
}

// 공통 구현
protected formatHeader(): string {
return `=== ${this.title} (${this.createdAt.toLocaleDateString()}) ===`;
}

// 서브클래스 강제 구현
abstract generateContent(): string;
}

// 추상 클래스 + 인터페이스 동시 사용
class SalesReport extends BaseReport implements Printable, Exportable {
constructor(private data: { product: string; sales: number }[]) {
super("월간 판매 보고서");
}

generateContent(): string {
return this.data
.map((d) => `${d.product}: ${d.sales}개`)
.join("\n");
}

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

exportToPDF(): Buffer {
const content = this.generateContent();
return Buffer.from(content); // 실제로는 PDF 라이브러리 사용
}

exportToCSV(): string {
return this.data.map((d) => `${d.product},${d.sales}`).join("\n");
}
}

다중 인터페이스 구현과 인터페이스 분리 원칙(ISP)​

TypeScript 클래스는 여러 인터페이스를 동시에 구현할 수 있다. 인터페이스 분리 원칙(Interface Segregation Principle)은 클라이언트가 사용하지 않는 메서드에 의존하지 않도록 인터페이스를 작게 나누라는 지침이다.

// 나쁜 예: 하나의 거대한 인터페이스 (ISP 위반)
interface BadWorker {
work(): void;
eat(): void;
sleep(): void;
drive(): void; // 로봇은 먹지도 자지도 않는다
}

// 좋은 예: 분리된 작은 인터페이스
interface Workable {
work(): void;
}

interface Eatable {
eat(): void;
}

interface Sleepable {
sleep(): void;
}

interface Drivable {
drive(): void;
}

// 사람: 모든 능력 구현
class Human implements Workable, Eatable, Sleepable, Drivable {
work(): void { console.log("열심히 일한다."); }
eat(): void { console.log("맛있게 먹는다."); }
sleep(): void { console.log("푹 잔다."); }
drive(): void { console.log("차를 운전한다."); }
}

// 로봇: 필요한 능력만 구현
class WorkRobot implements Workable, Drivable {
work(): void { console.log("로봇이 일한다."); }
drive(): void { console.log("로봇이 이동한다."); }
}

// 함수는 필요한 능력만 요구
function sendToWork(worker: Workable): void {
worker.work();
}

const human = new Human();
const robot = new WorkRobot();
sendToWork(human); // OK
sendToWork(robot); // OK

템플릿 메서드 패턴​

추상 클래스의 가장 강력한 활용 패턴이다. 알고리즘의 골격을 추상 클래스에 정의하고, 세부 단계를 서브클래스가 구현하도록 한다. 전체 흐름은 변하지 않지만 각 단계의 구체적인 동작은 서브클래스마다 다르다.

abstract class DataProcessor {
// 템플릿 메서드: 알고리즘 골격 정의 (final처럼 재정의 권장 안 함)
process(rawData: string): string {
const validated = this.validate(rawData);
const parsed = this.parse(validated);
const transformed = this.transform(parsed);
return this.format(transformed);
}

// 공통 구현 (서브클래스가 필요시 오버라이드 가능)
protected validate(data: string): string {
if (!data || data.trim() === "") {
throw new Error("빈 데이터는 처리할 수 없습니다.");
}
return data.trim();
}

// 추상 단계: 반드시 구현
protected abstract parse(data: string): Record<string, unknown>[];
protected abstract transform(data: Record<string, unknown>[]): Record<string, unknown>[];

// 기본 구현 제공 (오버라이드 가능)
protected format(data: Record<string, unknown>[]): string {
return JSON.stringify(data, null, 2);
}
}

class CSVProcessor extends DataProcessor {
protected parse(data: string): Record<string, unknown>[] {
const lines = data.split("\n");
const headers = lines[0].split(",");
return lines.slice(1).map((line) => {
const values = line.split(",");
return headers.reduce((obj, header, i) => {
obj[header.trim()] = values[i]?.trim() ?? "";
return obj;
}, {} as Record<string, unknown>);
});
}

protected transform(data: Record<string, unknown>[]): Record<string, unknown>[] {
// 빈 행 제거
return data.filter((row) =>
Object.values(row).some((v) => v !== "")
);
}
}

class JSONProcessor extends DataProcessor {
protected parse(data: string): Record<string, unknown>[] {
return JSON.parse(data);
}

protected transform(data: Record<string, unknown>[]): Record<string, unknown>[] {
// 모든 문자열 값 소문자로 변환
return data.map((row) =>
Object.fromEntries(
Object.entries(row).map(([k, v]) => [
k,
typeof v === "string" ? v.toLowerCase() : v,
])
)
);
}
}

const csvData = `name,age,city
Alice,30,Seoul
Bob,25,Busan
`;

const jsonData = `[{"name":"Charlie","age":35,"city":"Incheon"}]`;

const csvProcessor = new CSVProcessor();
const jsonProcessor = new JSONProcessor();

console.log(csvProcessor.process(csvData));
console.log(jsonProcessor.process(jsonData));

실전 예제: 도형 계층과 보고서 생성기​

도형 계층​

abstract class Shape {
abstract area(): number;
abstract perimeter(): number;
abstract name(): string;

// 공통 구현: 도형 정보 출력
describe(): void {
console.log(
`[${this.name()}] 넓이: ${this.area().toFixed(2)}, 둘레: ${this.perimeter().toFixed(2)}`
);
}

// 공통 유틸리티
isLargerThan(other: Shape): boolean {
return this.area() > other.area();
}
}

class Circle extends Shape {
constructor(private radius: number) {
super();
if (radius <= 0) throw new Error("반지름은 양수여야 합니다.");
}

area(): number { return Math.PI * this.radius **2; }
perimeter(): number { return 2 * Math.PI * this.radius; }
name(): string { return "원"; }
get diameter(): number { return this.radius * 2; }
}

class Rectangle extends Shape {
constructor(private width: number, private height: number) {
super();
if (width <= 0 || height <= 0) throw new Error("너비와 높이는 양수여야 합니다.");
}

area(): number { return this.width * this.height; }
perimeter(): number { return 2 * (this.width + this.height); }
name(): string { return "직사각형"; }

isSquare(): boolean { return this.width === this.height; }
}

class Triangle extends Shape {
constructor(
private a: number,
private b: number,
private c: number
) {
super();
if (a + b <= c || a + c <= b || b + c <= a) {
throw new Error("삼각형 조건을 만족하지 않습니다.");
}
}

// 헤론의 공식
area(): number {
const s = (this.a + this.b + this.c) / 2;
return Math.sqrt(s * (s - this.a) * (s - this.b) * (s - this.c));
}

perimeter(): number { return this.a + this.b + this.c; }
name(): string { return "삼각형"; }
}

// 다형성 활용
const shapes: Shape[] = [
new Circle(7),
new Rectangle(4, 6),
new Triangle(3, 4, 5),
];

shapes.forEach((s) => s.describe());

// 가장 넓은 도형 찾기
const largest = shapes.reduce((max, s) => (s.isLargerThan(max) ? s : max));
console.log(`\n가장 넓은 도형: ${largest.name()} (${largest.area().toFixed(2)})`);

// 총 넓이
const totalArea = shapes.reduce((sum, s) => sum + s.area(), 0);
console.log(`총 넓이: ${totalArea.toFixed(2)}`);

보고서 생성기​

interface ReportData {
title: string;
sections: { heading: string; content: string }[];
}

abstract class ReportGenerator {
abstract generate(data: ReportData): string;

protected buildHeader(title: string): string {
return this.wrap(title, "header");
}

protected buildSection(heading: string, content: string): string {
return this.wrap(heading, "section") + "\n" + content;
}

protected abstract wrap(text: string, type: "header" | "section"): string;

// 템플릿 메서드
render(data: ReportData): string {
const header = this.buildHeader(data.title);
const body = data.sections
.map((s) => this.buildSection(s.heading, s.content))
.join(this.getSeparator());
return [header, body].join("\n\n");
}

protected getSeparator(): string {
return "\n\n";
}
}

class MarkdownReportGenerator extends ReportGenerator {
generate(data: ReportData): string {
return this.render(data);
}

protected wrap(text: string, type: "header" | "section"): string {
return type === "header" ? `# ${text}` : `## ${text}`;
}
}

class HTMLReportGenerator extends ReportGenerator {
generate(data: ReportData): string {
return `<!DOCTYPE html>\n<html>\n<body>\n${this.render(data)}\n</body>\n</html>`;
}

protected wrap(text: string, type: "header" | "section"): string {
return type === "header" ? `<h1>${text}</h1>` : `<h2>${text}</h2>`;
}

protected getSeparator(): string {
return "\n<hr/>\n";
}
}

const reportData: ReportData = {
title: "2025년 4분기 성과 보고서",
sections: [
{ heading: "매출 현황", content: "전년 대비 15% 성장" },
{ heading: "주요 성과", content: "신규 고객 230명 확보" },
],
};

const mdGen = new MarkdownReportGenerator();
const htmlGen = new HTMLReportGenerator();

console.log(mdGen.generate(reportData));
console.log(htmlGen.generate(reportData));

고수 팁​

추상 생성자 타입 abstract new()​

추상 클래스 자체를 타입으로 사용할 때는 일반 new() 대신 추상 생성자 타입을 사용한다.

abstract class Plugin {
abstract name(): string;
abstract execute(): void;
}

class LogPlugin extends Plugin {
name(): string { return "LogPlugin"; }
execute(): void { console.log("Logging..."); }
}

// 추상 클래스를 인자로 받는 함수
// abstract new()는 TypeScript에서 직접 지원하지 않으므로
// 다음처럼 구체 클래스 생성자 타입을 사용한다
type ConcreteOf<T extends Plugin> = new () => T;

function loadPlugin<T extends Plugin>(PluginClass: ConcreteOf<T>): T {
const plugin = new PluginClass();
console.log(`플러그인 로드: ${plugin.name()}`);
return plugin;
}

const plugin = loadPlugin(LogPlugin);
plugin.execute(); // Logging...

추상 정적 메서드의 한계​

TypeScript는 추상 정적 메서드를 지원하지 않는다. 이는 의도된 설계다.

abstract class Registry {
// abstract static create(): Registry; // 오류: 지원 안 함

// 대안 1: 인터페이스로 정적 계약 표현
static create?(): Registry;

// 대안 2: 팩토리 인터페이스 별도 정의
}

interface RegistryFactory {
new(): Registry;
create(): Registry;
}

// 정적 메서드가 있는 클래스를 검사할 때
function useFactory(Factory: RegistryFactory): Registry {
return Factory.create();
}

인터페이스 상속으로 계층 구성​

인터페이스도 extends로 상속 계층을 만들 수 있다.

interface Animal {
name: string;
breathe(): void;
}

interface Pet extends Animal {
owner: string;
play(): void;
}

interface ServiceAnimal extends Animal {
license: string;
performDuty(): void;
}

// 두 인터페이스를 모두 확장
interface ServicePet extends Pet, ServiceAnimal {
isRetired: boolean;
}

class GuideDog implements ServicePet {
constructor(
public name: string,
public owner: string,
public license: string,
public isRetired: boolean = false
) {}

breathe(): void { console.log("숨 쉬기"); }
play(): void { console.log(`${this.name}가 논다.`); }
performDuty(): void { console.log(`${this.name}가 안내한다.`); }
}

정리​

구분추상 클래스인터페이스
인스턴스화불가불가 (컴파일 후 사라짐)
구현 코드 포함가능 (일반 메서드)불가 (default 메서드 없음)
생성자가능불가
상태(프로퍼티)가능가능 (선언만)
다중 상속불가 (단일 extends)가능 (다중 implements)
접근 제어자가능모두 public
사용 목적공통 구현 공유, is-a계약 정의, can-do
런타임 존재존재 (JS 클래스)사라짐 (타입 전용)

다음 장에서는 정적 멤버, 싱글톤 패턴, 팩토리 패턴, getter/setter 등 클래스의 고급 기능을 다룬다. 클래스 수준 상태 관리와 체이닝 인터페이스 패턴을 실전 예제로 학습한다.