추상 클래스
직접 인스턴스를 만들 수 없는 추상 클래스에 공통 상태와 구현, 자식이 반드시 채워야 할 추상 메서드를 함께 두는 방법과, 공통 흐름 안에서 추상 메서드를 부르는 설계, 인터페이스와 고르는 기준을 예제로 다룹니다.
앞 글의 Animal은 그 자체로도 인스턴스를 만들 수 있는 클래스였습니다. 그런데 "도형"처럼 공통의 상태와 동작은 있지만, 넓이를 어떻게 계산할지는 원인지 직사각형인지 정해져야 알 수 있는 부모 클래스도 있습니다. 이런 부모는 직접 인스턴스를 만들 이유가 없고, 자식 클래스가 반드시 채워야 할 부분을 남겨 두는 편이 자연스럽습니다.
추상 클래스(abstract class)는 직접 인스턴스를 만들 수 없고, 구현 없이 이름과 타입만 적은 추상 메서드(abstract method)를 가질 수 있는 클래스입니다. 이 글은 추상 클래스의 문법과 규칙을 도형 예제로 보고, 공통 흐름 안에서 추상 메서드를 부르는 설계를 거쳐, 인터페이스와 어떻게 고르는지 정리합니다. 예제는 ts-first 프로젝트에 abstract.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.
추상 클래스와 추상 메서드
추상 클래스는 class 앞에 abstract를 붙여 선언합니다. 그 안에는 일반 클래스처럼 속성과 생성자, 구현이 있는 메서드를 둘 수 있고, 메서드 앞에 abstract를 붙이면 본문 없이 이름과 타입만 적은 추상 메서드가 됩니다.
abstract class Shape {
protected name: string;
constructor(name: string) {
this.name = name;
}
displayInfo(): void {
console.log(`이 도형은 ${this.name}입니다.`);
}
abstract calculateArea(): number;
abstract getPerimeter(): number;
}
class Circle extends Shape {
constructor(name: string, private radius: number) {
super(name);
}
calculateArea(): number {
return Math.PI * this.radius * this.radius;
}
getPerimeter(): number {
return 2 * Math.PI * this.radius;
}
}
class Rectangle extends Shape {
constructor(name: string, private width: number, private height: number) {
super(name);
}
calculateArea(): number {
return this.width * this.height;
}
getPerimeter(): number {
return 2 * (this.width + this.height);
}
}
const circle = new Circle("원", 5);
circle.displayInfo();
console.log(`원의 넓이: ${circle.calculateArea().toFixed(2)}, 둘레: ${circle.getPerimeter().toFixed(2)}`);
const rectangle = new Rectangle("직사각형", 10, 5);
rectangle.displayInfo();
console.log(`직사각형의 넓이: ${rectangle.calculateArea()}, 둘레: ${rectangle.getPerimeter()}`);이 도형은 원입니다.
원의 넓이: 78.54, 둘레: 31.42
이 도형은 직사각형입니다.
직사각형의 넓이: 50, 둘레: 30반지름 5인 원의 넓이는 π×5×5로 약 78.5398이고, 둘레는 2×π×5로 약 31.4159이므로 toFixed(2)가 각각 78.54와 31.42를 만듭니다. 직사각형은 10×5로 넓이가 50, 2×(10+5)로 둘레가 30입니다. Shape는 이름과 displayInfo를 함께 쓰도록 제공하고, 두 계산 메서드는 각 도형이 자기 방식으로 구현합니다.
TYPE / RUNTIME / CONTRACT
Shape의 공통 구현을 두 도형이 상속합니다
Shape는 name과 displayInfo를 제공합니다. Circle·Rectangle은 calculateArea와 getPerimeter를 구현합니다. 이 예제에서 displayInfo가 계산 메서드를 호출하지는 않습니다.
COMMON
이름과 표시 동작
Shape의 name은 protected입니다. displayInfo를 공개해 원 또는 직사각형이라는 이름을 출력합니다. name을 하위 클래스에서 사용할 수 있지만 외부에서 직접 읽을 수는 없습니다.
REQUIRED METHODS
두 계산 계약을 구현
구체 Circle과 Rectangle은 모두 calculateArea·getPerimeter를 구현해야 합니다. 부모의 공통 표시 구현은 두 클래스가 그대로 사용합니다.
CIRCLE
반지름 5의 결과
원의 면적은 πr²로 78.54, 둘레는 2πr로 31.42입니다. 본문은 toFixed(2)로 출력하며 반지름 검증 코드는 없습니다.
RECTANGLE
너비 10·높이 5의 결과
면적은 50, 둘레는 30입니다. Shape[] 배열에서는 공통 메서드를 호출하고 실제 객체의 계산 구현이 선택됩니다.
위쪽 화살표는 하위 클래스가 Shape를 상속하는 방향입니다. 이 코드는 공통 구현과 추상 슬롯을 보여 주며, 계산 호출 순서를 고정한 template method 예제는 아닙니다.
위 그림의 화살표는 Circle과 Rectangle이 Shape를 상속하는 방향이며, 계산이 실행되는 순서가 아닙니다. 이 예제의 displayInfo는 계산 메서드를 부르지 않으므로, 이름을 출력하는 일과 넓이를 계산하는 일은 호출하는 쪽에서 따로 부릅니다.
추상 클래스의 규칙
추상 클래스는 직접 인스턴스를 만들 수 없습니다. new Shape("도형")은 Cannot create an instance of an abstract class.라는 TS2511로 진단되며, Shape는 Circle처럼 모든 추상 메서드를 구현한 자식 클래스를 통해서만 씁니다. 그래도 Shape 타입의 변수와 배열은 만들 수 있어서, 앞 글에서 본 다형성을 그대로 쓸 수 있습니다.
const shapes: Shape[] = [circle, rectangle];
for (const shape of shapes) {
shape.displayInfo();
console.log(`넓이: ${shape.calculateArea().toFixed(2)}`);
}이 도형은 원입니다.
넓이: 78.54
이 도형은 직사각형입니다.
넓이: 50.00추상 메서드를 하나라도 구현하지 않은 자식 클래스는 그 자체로 진단됩니다. 예를 들어 calculateArea만 구현한 class Square extends Shape는 Non-abstract class 'Square' does not implement inherited abstract member 'getPerimeter' from class 'Shape'.라는 TS2515가 나옵니다. 자식 클래스도 abstract로 선언하면 구현을 다음 자식에게 미룰 수 있고, 속성에도 abstract color: string;처럼 abstract를 붙여 자식이 반드시 채우게 할 수 있습니다.
이 규칙들은 모두 타입 검사에서 지켜집니다. 컴파일한 자바스크립트에서 Shape는 abstract 표시가 지워진 평범한 클래스로 남고, 추상 메서드의 선언은 사라집니다. 그래서 추상 클래스는 구현 누락을 실행 전에 찾아 줄 뿐, 반지름이 음수가 아닌지 같은 값의 조건이나 계산이 정확한지는 따로 확인해야 합니다.
더 깊이: 클래스 자체를 인수로 받을 때의 생성자 타입
인스턴스가 아니라 클래스 자체를 함수에 넘기고, 함수 안에서 new로 인스턴스를 만들고 싶을 때가 있습니다. 공식 Handbook은 인수 없이 만드는 추상 클래스 Base와 그것을 상속한 구체 클래스 Derived로 이 경우를 설명합니다. 매개변수의 타입을 typeof Base로 적으면 그 함수에 Base 자체를 넘길 수 있으므로, 컴파일러는 함수 안의 new ctor()를 추상 클래스의 인스턴스를 만들 수 없다는 TS2511로 진단합니다. Handbook은 이럴 때 new () => Base처럼 생성 시그니처(construct signature)를 적으라고 안내합니다. 그러면 Derived는 넘길 수 있지만 Base를 넘기는 호출은 추상 생성자 타입을 추상이 아닌 생성자 타입에 넣을 수 없다는 진단을 받습니다.
반대로 인스턴스를 직접 만들지 않고 클래스를 상속할 재료로만 받는 함수라면, 추상 클래스도 받을 수 있어야 합니다. TypeScript 4.2부터는 abstract new () => HasArea처럼 생성 시그니처 앞에 abstract를 붙여, 추상 클래스와 구체 클래스를 모두 받을 수 있다는 뜻을 나타냅니다. 릴리스 노트는 이 기능을 추상 클래스에도 쓸 수 있는 믹스인(mixin) 함수, 즉 받은 클래스를 상속한 새 클래스를 돌려주는 함수에 쓰는 예를 듭니다. 이때 타입 매개변수의 제약이 추상 생성자 타입이면, 그 값을 상속해 만든 클래스도 abstract로 선언해야 합니다. 어떤 추상 멤버를 가진 클래스가 넘어올지 알 수 없어서, 새 클래스가 추상 멤버를 모두 구현했는지 확인할 방법이 없기 때문입니다.
공통 흐름에서 추상 메서드 부르기
추상 클래스의 일반 메서드는 추상 메서드를 부를 수 있습니다. 부모가 작업의 순서를 정해 두고, 순서 가운데 달라지는 한 단계만 자식이 채우게 하는 설계이며, 이것을 템플릿 메서드(template method) 패턴이라고 부릅니다.
abstract class ReportTemplate {
constructor(protected title: string) {}
print(): void {
console.log(`== ${this.title} ==`);
console.log(this.body());
}
protected abstract body(): string;
}
class SalesReport extends ReportTemplate {
protected body(): string {
return "매출 120만 원";
}
}
new SalesReport("주간 매출").print();== 주간 매출 ==
매출 120만 원print는 제목을 먼저 출력하고 본문을 출력하는 순서를 정하고, 본문을 만드는 body는 자식이 구현합니다. body는 protected abstract이므로 바깥 코드는 부를 수 없고, 바깥에는 순서가 정해진 print만 공개됩니다. 새 보고서를 만들 때는 body 하나만 구현하면 되고, 출력의 형식은 부모 한곳에서 관리됩니다.
추상 클래스와 인터페이스
TYPE / RUNTIME / CONTRACT
공통 구현을 공유할지, 구조 계약만 정할지 고릅니다
Shape는 name 상태와 displayInfo 구현을 공유하고 계산 메서드는 하위 클래스에 맡깁니다. interface는 구현 코드가 없는 정적 구조 계약입니다.
ABSTRACT CLASS
상태와 기본 구현을 공유
본문 Shape는 protected name을 생성자로 저장하고 displayInfo를 제공합니다. Circle·Rectangle은 계산 메서드를 구현하는 상속 계층입니다.
INTERFACE
필요한 구조를 선언
interface는 객체·클래스 인스턴스가 만족할 형태를 검사합니다. JavaScript 출력에 interface 객체가 생성되지 않습니다. 선택 멤버를 반드시 구현하는 규칙은 아닙니다.
ABSTRACT RULE
TypeScript의 인스턴스화 제한
추상 Shape의 직접 new는 오류입니다. 구체 하위 클래스는 추상 멤버를 구현해야 하며 추상 하위 클래스는 구현을 다시 미룰 수 있습니다. abstract 키워드 자체는 런타임 입력 검증이 아닙니다.
COMPOSITION / ROLES
상속과 독립 역할을 함께 검토
단일 클래스 상속과 여러 interface 역할은 함께 사용할 수 있습니다. 독립 교체할 동작의 위임은 설계 확장이며 이 절에는 그 실행 코드가 없습니다.
추상 클래스도 abstract 메서드 없이 공통 상태와 구현을 제공할 수 있습니다. 공유할 코드와 필요한 구조를 먼저 비교합니다.
추상 클래스와 인터페이스는 둘 다 "이런 멤버를 가져야 한다"는 규칙을 정한다는 점에서 비슷합니다. 하지만 위 그림처럼 추상 클래스는 상태와 생성자, 공통 구현을 가진 실행 중의 클래스이고, 인터페이스는 컴파일한 뒤 지워지는 모양의 선언이라는 점이 다릅니다.
| 비교 | 추상 클래스 | 인터페이스 |
|---|---|---|
| 구현 | 일반 메서드의 구현과 속성의 초기값을 가질 수 있다 | 멤버의 이름과 타입만 적는다 |
| 상태와 생성자 | 가질 수 있다 | 가질 수 없다 |
| 쓰는 방법 | extends로 하나만 상속한다 | implements로 여러 개를 구현하거나, 객체가 모양만 맞추어도 된다 |
| 접근 제어자 | public, protected, private를 쓸 수 있다 | 모든 멤버가 공개된다 |
| 실행 중에 남는 것 | 클래스로 남는다 | 아무것도 남지 않는다 |
여러 클래스가 공통의 상태와 구현을 함께 쓰면서 일부만 다르게 구현해야 한다면 추상 클래스가 알맞습니다. 예를 들어 모든 직원의 기본 급여 계산은 같고 보너스 계산만 직책마다 다르다면, Employee 추상 클래스에 기본 급여 계산을 두고 calculateBonus를 추상 메서드로 남깁니다. 반대로 "탈것은 start와 stop을 가져야 한다"처럼 필요한 모양만 정하면 될 때는, 서로 다른 계층의 클래스도 함께 구현할 수 있는 인터페이스가 더 단순합니다.
두 방법은 함께 쓸 수도 있습니다. 바깥 코드가 기대하는 모양은 인터페이스로 정하고, 그 인터페이스를 구현하는 여러 클래스가 함께 쓸 코드를 추상 클래스에 모아 두면, 바깥 코드는 인터페이스에만 의존하고 공통 구현은 한곳에서 관리할 수 있습니다.
정리
- 추상 클래스는
abstract class로 선언하며 직접 인스턴스를 만들 수 없고,new로 만들면 TS2511이 나옵니다. - 추상 메서드는 본문 없이 이름과 타입만 적으며, 추상 클래스가 아닌 자식 클래스는 모든 추상 메서드를 구현해야 하고 빠뜨리면 TS2515가 나옵니다.
- 추상 클래스 타입의 변수와 배열로 여러 자식 클래스의 인스턴스를 함께 다룰 수 있습니다.
- 추상 클래스의 일반 메서드가 작업의 순서를 정하고 추상 메서드를 부르면, 자식은 달라지는 단계만 구현하면 됩니다.
- 추상 클래스는 상태와 구현을 가진 실행 중의 클래스로 남고, 인터페이스는 지워지는 모양의 선언이므로, 공통 구현이 필요한지로 둘을 고릅니다.
연습 문제
1. 새 도형의 계산
이 글의 정의를 그대로 두고 new Circle("작은 원", 2)와 new Rectangle("새 직사각형", 3, 4)를 만들었습니다. 각 도형의 넓이와 둘레를 toFixed(2)로 표시하면 무엇이 되나요?
해설 보기
반지름 2인 원의 넓이는 π×2×2, 곧 4π이고 둘레도 2×π×2로 4π이므로, 둘 다 약 12.566이어서 12.57이 됩니다. 직사각형은 넓이가 3×4로 12, 둘레가 2×(3+4)로 14이므로 12.00과 14.00입니다.
2. 빠뜨린 추상 메서드
Shape를 상속하면서 calculateArea만 구현하고 getPerimeter를 빠뜨린 클래스를 만들면 어떻게 되나요? 그 클래스에 abstract를 붙이면 무엇이 달라지나요?
해설 보기
추상 클래스가 아닌 자식 클래스는 모든 추상 메서드를 구현해야 하므로, 빠뜨린 getPerimeter 때문에 TS2515가 나옵니다. 그 클래스에 abstract를 붙이면 그 자체도 추상 클래스가 되어 진단이 사라지지만, 대신 그 클래스로는 인스턴스를 만들 수 없고 getPerimeter의 구현은 그 클래스를 다시 상속하는 자식이 맡게 됩니다.
3. 상속이 꼭 필요한가
어떤 함수가 도형에서 넓이만 계산해 쓰고, 이름이나 displayInfo는 쓰지 않습니다. 이 함수가 받을 객체는 반드시 Shape를 상속해야 하나요?
해설 보기
그럴 필요가 없습니다. 함수가 쓰는 것은 넓이를 계산하는 메서드 하나이므로, interface AreaProvider { calculateArea(): number; }처럼 그 모양만 정한 인터페이스를 매개변수의 타입으로 쓰면 충분합니다. 그러면 Circle 같은 Shape의 자식은 물론, Shape를 상속하지 않았지만 calculateArea를 가진 객체도 넘길 수 있습니다.
다음 글 인터페이스를 활용한 설계에서는 마지막 문제처럼 필요한 모양만 인터페이스로 정해 두고, 구현을 바꾸어 끼울 수 있게 만드는 설계를 다룹니다.