본문으로 건너뛰기
Advertisement

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 등 클래스의 고급 기능을 다룬다. 클래스 수준 상태 관리와 체이닝 인터페이스 패턴을 실전 예제로 학습한다.

Advertisement