| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- Microtask Queue
- 25년 2월
- 암묵적 타입 변환
- 좋은 PR
- react
- tanstack_virtual
- AJIT
- Compound Component
- CS
- jotai
- 클라이언트 상태 관리 라이브러리
- Render Queue
- github actions
- TypeScript
- Sparkplug
- Headless 컴포넌트
- prettier-plugin-tailwindcss
- 회고
- 명시적 타입 변환
- linux 배포판
- interface
- msw
- 타입 단언
- docker
- helm-chart
- JavaScript
- 라이브러리_분석
- vue_virtual_scroll_list
- 프로세스
- mocking
- Today
- Total
구리
[TypeScript] 추상 클래스를 언제 사용해야 할까? 본문
인터페이스와 추상 클래스, 언제 어떻게 써야 할까?
react + typescript 관련 책을 보다 아래 예시 코드를 보게 되었다. 여기서 AbstractMenuItem 클래스는 일반 클래스로도 구현할 수 있는데 왜 굳이 추상 클래스로 표현되었는지 의문이었다. 그래서 인터페이스, 추상 클래스를 언제 사용하는지 찾아보며 정리한 글이다.
export interface IMenuItem {
id: string;
name: string;
type: string;
price: number;
ingredients: string[];
calculateDiscount(): number;
}
export abstract class AbstractMenuItem implements IMenuItem {
private readonly _id: string;
private readonly _name: string;
private readonly _type: string;
private readonly _price: number;
private readonly _ingredients: string[];
private _discountStrategy: IDiscountStrategy;
constructor(item: RemoteMenuItem) {
this._id = item.id;
this._name = item.name;
this._price = item.price;
this._type = item.category;
this._ingredients = item.ingredients;
this._discountStrategy = new NoDiscountStrategy();
}
static from(item: IMenuItem): RemoteMenuItem {
return {
id: item.id,
name: item.name,
category: item.type,
price: item.price,
ingredients: item.ingredients,
}
}
get id() {
return this._id;
}
get name() {
return this._name;
}
get price() {
return this._price;
}
get type() {
return this._type.toLowerCase();
}
get ingredients() {
return this._ingredients.slice(0, 2);
}
set discountStrategy(strategy: IDiscountStrategy) {
this._discountStrategy = strategy;
}
calculateDiscount() {
return this._discountStrategy.calculate(this.price);
}
}
export class PizzaMenuItem extends AbstractMenuItem {
private readonly toppings: number;
constructor(item: RemoteMenuItem, toppings: number) {
super(item);
this.toppings = toppings;
}
}
인터페이스: 형태만을 정의하는 계약
인터페이스는 말 그대로 타입의 모양만 정의한다.
런타임에는 존재하지 않고, 오직 컴파일 시점에 타입 체크와 자동완성 같은 도움을 준다.
예를 들어 Repository라는 인터페이스를 정의해두면, UserRepo, OrderRepo 같은 구현체들이 동일한 계약을 따르게 만들 수 있다.
하지만 인터페이스 자체에는 구현이 없으므로, 각 클래스는 메서드를 전부 직접 구현해야 한다.
interface Repository<T> {
findById(id: string): T;
save(entity: T): void;
}
class UserRepo implements Repository<User> {
findById(id: string) { /* ... */ }
save(entity: User) { /* ... */ }
}
이런 특성 덕분에 인터페이스는 “능력(capability)” 을 정의할 때 유용하다.
예를 들어 ISpeaker라는 인터페이스를 만들고, 사람, 로봇, AI 모두 speak()를 구현하도록 강제할 수 있다.
구현 방식은 다르지만, 모두 “말할 수 있다”는 공통 능력을 갖게 되는 것이다.
추상 클래스: 규약과 기본 구현을 함께 제공
반면 추상 클래스는 조금 다르다. 규약뿐만 아니라 일부 기본 구현도 제공할 수 있다.
그리고 런타임에도 클래스 형태로 남기 때문에 instanceof 체크 같은 것도 가능하다.
추상 클래스는 직접 인스턴스화할 수 없고, 반드시 상속받아야만 쓸 수 있다.
이 특징 덕분에 “이 클래스는 단독으로 쓰이지 않고, 반드시 파생 클래스에서만 의미가 있다”는 의도를 코드로 표현할 수 있다.
예를 들어 BaseRepository라는 추상 클래스를 두고, findById는 자식 클래스에서 반드시 구현하도록 강제하면서save는 공통 로깅 로직을 포함한 기본 구현을 제공할 수 있다
abstract class BaseRepository<T> {
abstract findById(id: string): T;
save(entity: T) {
console.log("공통 로깅");
// 저장 로직
}
}
class UserRepo extends BaseRepository<User> {
findById(id: string) { /* ... */ }
}
이렇게 하면 “규약”과 “중복 제거”라는 두 마리 토끼를 잡을 수 있다.
런타임 차이에서 오는 활용
인터페이스는 컴파일 후에는 사라지지만, 추상 클래스는 런타임에도 남는다.
그래서 타입 체크나 리플렉션, 메타프로그래밍에 활용할 수 있다.
abstract class Animal {
abstract makeSound(): void;
}
class Dog extends Animal {
makeSound() { console.log("멍멍"); }
}
const d = new Dog();
console.log(d instanceof Animal); // true
이 점 때문에 프레임워크나 라이브러리에서 추상 클래스를 종종 제공한다.
개발자가 상속만 하면 필요한 메타 정보나 동작이 자동으로 연결되도록 만들 수 있기 때문이다.
템플릿 메서드 패턴과 추상 클래스
추상 클래스가 특히 빛나는 경우는 템플릿 메서드 패턴이다.
상위 클래스에서 전체 흐름(Workflow)을 정의하고, 구체적인 단계는 자식 클래스가 채워넣도록 하는 구조다.
예를 들어 파일 업로더를 구현할 때, upload라는 큰 흐름은 동일하지만validate, prepare, send 같은 세부 단계는 파일 타입마다 다르게 만들 수 있다.
abstract class FileUploader {
async upload(file: File) {
this.validate(file);
const prepared = this.prepare(file);
return this.send(prepared);
}
protected abstract validate(file: File): void;
protected abstract prepare(file: File): any;
protected abstract send(file: any): Promise<string>;
}
실무에서는 어떻게 쓸까?
실무에서 자주 쓰는 패턴으로 구분하면 이렇다.
1) UI 레벨 → 인터페이스
Props, State, Context 같은 데이터 구조는 런타임에 남을 필요가 없기에 인터페이스로 정의할 수 있다.
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
}
export function Button({ label, onClick, disabled }: ButtonProps) {
return <button onClick={onClick} disabled={disabled}>{label}</button>;
}
2) 서비스/도메인 레이어 → 추상 클래스
API 클라이언트, 저장소, 캐시처럼 공통 구현 + 강제 규약이 필요한 곳은 추상 클래스로 표현할 수 있다.
export abstract class BaseApiClient {
constructor(protected readonly baseUrl: string) {}
protected async request<T>(path: string): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`);
return res.json() as Promise<T>;
}
abstract getById<T>(id: string): Promise<T>;
}
export class UserClient extends BaseApiClient {
getById<User>(id: string) {
return this.request<User>(`/users/${id}`);
}
}
그래서 고민의 해답은?
AbstractMenuItem 클래스는 일반 클래스로 둬도 문제가 없을 수 있다. 하지만 추상 클래스로 선언한 이유는 명확하다.
메뉴 아이템이라는 개념은 추상적이고, 실제로는 PizzaMenuItem, PastaMenuItem 같은 구체 클래스만 존재해야 한다.
즉 new AbstractMenuItem()은 도메인적으로 말이 되지 않는다.
추상 클래스로 둠으로써 “이건 직접 생성하지 말고 반드시 상속해서만 써라”라는 의도를 코드 차원에서 표현한 것이다.
또한 앞으로 모든 메뉴 아이템에 공통으로 강제해야 할 메서드가 생길 수도 있다.
그럴 때 추상 클래스로 두면 자연스럽게 확장이 가능하다.
다시 정리하자민 이렇다.
• 인터페이스: “형태만 정의”한다. 런타임에는 없고, 다중 구현이 가능하다.
• 추상 클래스: “형태 + 일부 구현”을 함께 제공한다. 런타임에도 남고, 상속을 통한 일관된 구조를 강제할 수 있다.
실무에서는 인터페이스와 추상 클래스를 적재적소에 섞어 쓰는 게 중요하다.
지금 당장은 일반 클래스로도 충분해 보일 수 있지만, 추상 클래스로 선언하면 “단독으로 쓰지 마”라는 의도를 명확히 할 수 있고, 미래 확장성에도 대비할 수 있다.
'TypeScript' 카테고리의 다른 글
| [TypeScript] any, unknown은 언제 사용해야 할까? (0) | 2023.12.25 |
|---|---|
| [TypeScript] Type, Interface 중 뭘 써야할까? (0) | 2023.12.13 |
| [TypeScript + React] 반응형 데이터를 OOP적으로 상태관리하는 방법에 대한 고민 (0) | 2023.03.19 |