Skip to main content

Command Palette

Search for a command to run...

TypeScript에서 interface vs abstract class

Published
•6 min read•View as Markdown

언제 쓰고, 언제 피해야 하며, 공개 API에서는 왜 더 조심해야 하는가

TypeScript로 설계를 하다 보면 거의 반드시 마주치는 질문이 있습니다.

“interface + implements 와 abstract class + extends 중 무엇을 사용해야 할까?”

두 방식 모두 “구현을 강제한다”는 공통점이 있지만,
실제로는 설계 철학, 결합도, 확장성, 그리고 장기적인 유지보수성 측면에서 매우 큰 차이를 가집니다.

이 글에서는 다음 내용을 하나의 흐름으로 정리합니다.

  • interface와 abstract class의 근본적인 차이

  • 실무에서의 선택 기준

  • abstract class를 피해야 하는 상황

  • 공개 API에서 abstract class가 특히 위험한 이유

  • interface 역시 변경하면 하위 호환성 문제가 생기는지 여부

  • interface + implements를 사용해도 런타임까지 에러를 못 잡는 경우가 있는 이유


1. interface와 abstract class의 핵심 차이

먼저 두 개념의 차이를 한눈에 정리해보겠습니다.

구분interface + implementsabstract class + extends
설계 목적구현 규약(계약) 정의공통 구현과 설계 골격 제공
구현 코드 포함불가능가능
필드(상태)불가능가능
다중 상속여러 개 가능단일 상속만 가능
런타임 존재 여부없음 (타입 전용)있음
생성자없음가능
접근 제어자public 개념만 존재public / protected / private

정리하면,
interface는 “무엇을 해야 하는가”를 정의하는 도구이고,
abstract class는 “어떻게 만들어야 하는가”까지 제시하는 도구입니다.


2. interface + implements의 역할과 특징

interface는 구현 규약(계약)을 정의하기 위한 도구입니다.
어떤 객체가 “이런 행동을 할 수 있다”는 사실만을 보장합니다.

interface Logger {
  log(message: string): void
}

class ConsoleLogger implements Logger {
  log(message: string) {
    console.log(message)
  }
}

이 경우 ConsoleLogger는 Logger라는 계약을 만족하기만 하면 되며,
구현 방식은 전적으로 클래스 쪽의 자유입니다.

interface의 주요 특징은 다음과 같습니다.

  • 구현 방식에 대한 제약이 거의 없습니다.

  • 런타임 코드가 생성되지 않아 비용이 없습니다.

  • 여러 interface를 동시에 구현할 수 있습니다.

  • 구조적 타이핑(duck typing)의 장점을 그대로 활용할 수 있습니다.

function process(logger: Logger) {
  logger.log("start")
}

이와 같이 사용하면, 구현체 교체가 쉽고 테스트 및 mocking에도 매우 유리합니다.


3. abstract class + extends의 역할과 특징

abstract class는 공통 구현과 설계 골격을 제공하는 도구입니다.
단순한 계약을 넘어, “이 구조 안에서 이렇게 확장하라”는 의도를 담습니다.

abstract class BaseLogger {
  protected prefix = "[LOG]"

  log(message: string) {
    console.log(this.format(message))
  }

  protected abstract format(message: string): string
}

class ConsoleLogger extends BaseLogger {
  protected format(message: string) {
    return `${this.prefix} ${message}`
  }
}

abstract class의 주요 특징은 다음과 같습니다.

  • 공통 로직을 상위 클래스에서 재사용할 수 있습니다.

  • protected 필드 및 메서드를 제공하여 확장 포인트를 만들 수 있습니다.

  • 템플릿 메서드 패턴에 적합합니다.

  • 단일 상속만 가능하므로 확장성이 제한됩니다.


4. 실무에서의 기본 선택 기준

interface를 기본 선택으로 권장하는 경우

다음과 같은 상황에서는 interface가 기본 선택이 됩니다.

  • “규약” 자체가 핵심인 경우

  • 구현체가 여러 개가 될 가능성이 높은 경우

  • 외부에 공개되는 API인 경우

  • 테스트 및 mocking이 중요한 코드인 경우

  • 상태나 수명주기를 강제할 필요가 없는 경우

실무에서는 특별한 이유가 없다면 interface를 먼저 선택하는 것이 안전합니다.


abstract class가 적합한 경우

반대로 다음과 같은 경우에는 abstract class가 적합합니다.

  • 공통 로직이 실제로 많고 의미 있는 경우

  • protected 헬퍼나 확장용 훅이 필요한 경우

  • 구현 방식이 어느 정도 고정된 내부 코드인 경우

  • 프레임워크나 템플릿 패턴을 구현하는 경우


5. abstract class를 피해야 하는 경우

1) 여러 역할을 조합해야 하는 경우

TypeScript는 단일 상속만 지원하므로,
여러 역할을 조합해야 하는 경우 abstract class는 빠르게 한계에 부딪힙니다.

class Service implements Loggable, Serializable, Disposable {}

이런 경우 interface 조합이 훨씬 유연합니다.


2) 공통 로직이 거의 없는 경우

공통 구현이 거의 없는데 abstract class를 사용하면,

  • 불필요한 상속 구조가 생기고

  • 결합도만 증가하며

  • 변경 비용이 커집니다.

이 경우에는 interface와 composition 방식이 더 적합합니다.


3) 테스트 및 Mocking이 중요한 경우

abstract class는 생성자, 내부 상태, protected 의존성 등을 포함할 수 있어
mock 객체를 만들기가 복잡해지는 경우가 많습니다.


4) 상속 관계가 실제로는 “has-a”인 경우

// 바람직하지 않은 예
class HttpClient extends RetryPolicy {}

// 바람직한 예
class HttpClient {
  constructor(private retry: RetryPolicy) {}
}

상속은 “is-a” 관계일 때만 사용하는 것이 바람직합니다.


5) 구조적 타이핑의 장점을 살리고 싶은 경우

interface는 형태만 맞으면 통과되지만,
abstract class는 상속 체인에 코드가 묶이게 됩니다.


6. 공개 API에서 abstract class가 더 위험한 이유

공개 API에서 abstract class가 위험한 이유는 다음 한 문장으로 요약할 수 있습니다.

abstract class는 계약뿐 아니라 구현과 확장 포인트까지 함께 공개하기 때문입니다.


1) 기본 구현이 사실상 계약이 됩니다

사용자는 abstract class의 기본 구현에 다음과 같이 의존하게 됩니다.

  • super.method() 호출 여부

  • 메서드 호출 순서

  • 내부 부작용(로그, 캐시, 이벤트 등)

이로 인해 내부 구현 변경이 런타임 의미 변경으로 이어질 수 있습니다.


2) 상태와 생성자, 수명주기까지 공개됩니다

abstract class는 생성자와 필드를 가질 수 있으므로,

  • 생성자 시그니처 변경

  • 초기화 순서 변경

  • 기본 필드 값 변경

등이 사용자 코드에 직접적인 영향을 줍니다.


3) protected는 사실상 공개 API입니다

protected 멤버를 변경하면 파생 클래스가 즉시 영향을 받습니다.
이는 interface에는 존재하지 않는 위험 요소입니다.


4) 상속 구조는 회피 비용이 큽니다

interface는 구현체 수정으로 대응할 수 있지만,
abstract class는 상속 구조 자체를 바꾸기 어렵습니다.


7. interface도 변경하면 하위 호환성 문제가 생기는가?

그렇습니다. interface 역시 변경하면 하위 호환성 문제가 발생합니다.

interface Logger {
  log(msg: string): void
}

// 이후 변경
interface Logger {
  log(msg: string): void
  flush(): void
}

기존 구현체는 모두 컴파일 에러가 발생합니다.

다만 차이점은 다음과 같습니다.

  • interface는 타입 단계에서 즉시 에러로 드러납니다.

  • abstract class는 타입 + 런타임 의미까지 함께 깨질 수 있습니다.


8. 하위 호환성을 고려한 설계 패턴

interface의 경우

  • 새 멤버는 optional로 추가합니다.

  • 기능 확장은 새 interface로 분리합니다.

interface Logger {
  log(msg: string): void
}

interface FlushableLogger extends Logger {
  flush(): void
}

abstract class의 경우

  • protected 사용을 최소화합니다.

  • 기본 구현 변경 시 의미를 명확히 문서화합니다.

  • 가능하면 composition 기반 helper를 제공합니다.


9. implements를 사용해도 런타임까지 에러를 못 잡는 경우가 있는가?

일반적인 strict 설정 환경에서는 대부분의 타입 오류가 컴파일 단계에서 바로 검출됩니다.

interface Logger {
  log(message: string): number
}

class BadLogger implements Logger {
  log(message: string): void {} // 컴파일 에러
}

다만 다음과 같은 경우에는 런타임까지 문제가 드러날 수 있습니다.

  • any 또는 과도한 타입 단언(as) 사용

  • 함수 파라미터 변성 규칙으로 인해 허용되는 케이스

  • 타입은 맞지만 내부 로직이 잘못된 경우

이는 interface의 한계라기보다는 타입 시스템의 범위 밖 문제입니다.


10. 가장 실무적인 패턴: interface + abstract class 병행

interface Logger {
  log(message: string): void
}

abstract class BaseLogger implements Logger {
  abstract log(message: string): void

  protected timestamp() {
    return new Date().toISOString()
  }
}
  • 외부 공개 표면은 interface로 유지합니다.

  • 내부 구현 공유는 abstract class로 처리합니다.


11. 결론

규약을 정의할 때는 interface를 사용합니다.
구현 골격이 필요할 때만 abstract class를 사용합니다.
공개 API는 interface, 내부 구현은 abstract class가 가장 안전한 조합입니다.

이 원칙만 지켜도 코드의 결합도, 테스트 용이성, 장기 유지보수성이 크게 향상됩니다.


🧾 작성 참고

이 글은 ChatGPT의 도움을 받아 내용을 정리하였습니다.

More from this blog

LangGraph의 astream_events: 에이전트 실행을 실시간으로 들여다보기

💡 한 줄 요약: astream_events()는 LangGraph 그래프 실행 중 발생하는 모든 이벤트(LLM 토큰, 툴 호출, 노드 시작/종료 등)를 실시간 스트림으로 받을 수 있는 비동기 API입니다. 들어가며 AI 에이전트가 응답을 생성하는 데 25초가 걸린다고 상상해보세요. 사용자는 평균 8초 후에 페이지를 이탈합니다. 하지만 스트리밍을 도입

Jun 20, 20266 min read

LLM은 어떻게 글을 읽고 쓰는가 — 입력부터 출력까지

대화형 AI에게 질문을 던지면, 마치 사람처럼 문장을 이해하고 답을 써 내려가는 것처럼 보인다.하지만 그 안에서 벌어지는 일은 생각보다 단순하고, 또 생각보다 기계적이다.이 글에서는 두 가지 질문에 답해 본다.LLM은 어떻게 글을 만들어내는가,그리고 우리가 입력한 프롬프트는 모델에 어떤 모습으로 들어가는가. 1부. LLM은 어떻게 글을 만들어내는가 핵심은

Jun 10, 20264 min read

useEffect 지옥이란 무엇이며, 어떻게 안전하게 사용하는가

React를 어느 정도 사용하다 보면 한 번쯤은“useEffect 지옥에 빠졌다”는 말을 듣거나 직접 느껴본 적이 있을 것이다. useEffect가 계속 늘어나고 의존성 배열은 점점 길어지고 왜 실행되는지 이해하기 어려워지며 eslint-disable-next-line 이 늘어나는 상태 하지만 많은 사람들이 오해한다.useEffect 지옥은 useEffect가 많아서 생기는 문제가 아니다. 이 글에서는 ‘useEffect 지옥’의 ...

Jan 12, 20263 min read

Zod란 무엇인가: TypeScript에서 런타임 검증과 타입 안정성을 동시에 해결하는 방법

TypeScript 프로젝트를 하다 보면 반드시 마주치는 문제가 있다.바로 “타입은 있는데, 데이터는 믿을 수 없다”는 것이다. TypeScript는 컴파일 타임에는 강력하지만,런타임에 들어오는 데이터에 대해서는 아무런 보장을 해주지 않는다. API 요청 데이터 사용자 입력(Form) 환경 변수 (process.env) 외부 JSON / 설정 파일 이 모든 것은 타입 시스템 바깥에서 들어온다. 이 문제를 해결하기 위해 등장한 라이브러...

Jan 10, 20263 min read

Flatpak을 이해하기 위한 배경 지식

배포판, 라이브러리 충돌, 그리고 OSTree까지 리눅스 데스크톱에서 Flatpak이 표준처럼 자리 잡은 데에는 분명한 이유가 있다.그 이유를 이해하려면 단순히 “Flatpak은 패키징 시스템이다”를 넘어서,리눅스 배포판 구조, 라이브러리 버전 충돌, OSTree라는 기술까지 함께 이해해야 한다. 이 글에서는 다음 질문에 답해본다. 리눅스에서 말하는 배포판이란 무엇인가? 라이브러리 버전 충돌은 왜 발생하는가? Flatpak은 이 문제를 어...

Jan 8, 20265 min read

Dev note

32 posts