목차
- 문제 정의 — 구조적 타입에서의 상속
- 기존 접근 — type 교집합으로 우회하던 패턴
- 제안 — interface extends의 정밀한 사용법
- 선언 병합 — 라이브러리 타입 확장의 핵심 도구
- 검증 — 컴파일러가 잡는 경계
- 한계점 — 모든 경우에 맞지는 않는다
error TS2430: Interface 'AdminUser' incorrectly extends interface 'User'.
Types of property 'role' are incompatible.
Type 'string' is not assignable to type '"admin" | "viewer"'.
그런데, 타입스크립트 인터페이스 extends를 다루다 보면 한 번쯤 마주치는 에러다. 프론트에서 백엔드로 넘어온 지 2년차, NestJS DTO를 손보다가 이 메시지를 다시 봤다. 원인은 단순한데 의미는 깊다. 구조적 타입 시스템에서 상속이 단순한 "is-a" 관계가 아니라 "구조가 더 좁아져야 하는" 관계임을 알려주는 신호다.
이 글은 interface extends의 동작을 문제 정의 → 기존 접근 → 제안 → 검증 → 한계 순으로 분해한다. type intersection과의 차이, implements와 함께 쓰는 패턴, 선언 병합까지 다룬다. 기준은 TypeScript 5.4(2026년 6월 기준 LTS 후보)다.
문제 정의 — 구조적 타입에서의 상속
물론, 객체지향 언어에 익숙한 개발자가 TypeScript를 처음 만나면 interface를 자바의 그것처럼 쓰려는 경향이 있다. 백엔드 전환 초기에 나도 그랬다. 그런데 TS의 interface는 명목적(nominal) 타입이 아니라 구조적(structural) 타입이다. 이름이 같든 다르든 모양만 맞으면 호환된다.
물론, extends가 하는 일은 두 가지다. 첫째, 부모 인터페이스의 멤버를 자식에 복사한다. 둘째, 자식 타입이 부모 타입의 하위 타입(subtype)임을 컴파일러에 약속한다. 두 번째가 핵심이다. 위 TS2430은 자식의 role: string이 부모의 role: "admin" | "viewer"보다 더 넓기 때문에 발생한다. 좁은 타입을 더 넓힐 수는 없다는 단순한 규칙이다.
(공식 문서: TypeScript Handbook — Object Types: Extending Types)
기존 접근 — type 교집합으로 우회하던 패턴
그러나, interface를 안 쓰고 type intersection(&)만으로 모든 합성을 처리하던 시기가 있었다. 프론트 쪽에서는 여전히 흔하다. Redux 액션 타입이나 styled-components props에서 자주 본다.
type User = { id: string; role: "admin" | "viewer" };
type WithCreatedAt = { createdAt: Date };
type AuditedUser = User & WithCreatedAt;
실제로, 깔끔해 보이지만 한계가 명확하다. 우선 &는 호환성 체크를 강하게 하지 않는다. 위에서 본 TS2430 같은 경고가 나오지 않고, 충돌하는 프로퍼티가 있으면 never로 조용히 만들어버린다. 디버깅이 까다로워진다.
그러나, 두 번째로 type은 선언 병합이 안 된다. 같은 이름으로 두 번 선언하면 즉시 컴파일 에러가 난다. 외부 라이브러리의 타입을 확장해야 할 때 결정적인 차이를 만든다.
그러나, interface extends와 type intersection을 한눈에 비교하면 이렇다.
| 항목 | interface extends | type intersection (&) |
|---|---|---|
| 호환성 체크 | TS2430 등으로 사전 경고 | 충돌 시 침묵 후 never |
| 선언 병합 | 가능 | 불가능 |
| 다중 부모 | extends A, B, C 가능 |
A & B & C |
| 타입 체커 부하 | 상대적으로 가볍다 | 깊어지면 느려진다 |
| 표현력 | union·mapped 불가 | 거의 모든 합성 가능 |
(출처: TypeScript 팀 Performance Wiki — Preferring Interfaces Over Intersections)
제안 — interface extends의 정밀한 사용법
단일 상속과 좁히기
가장 단순한 패턴이다. 부모의 프로퍼티를 더 좁은 리터럴로 다시 선언하면 컴파일러가 하위 타입 여부를 검사한다.
interface User {
id: string;
role: "admin" | "viewer" | "guest";
}
interface AdminUser extends User {
role: "admin"; // 부모보다 좁다 → OK
permissions: string[];
}
여기서 role을 string으로 다시 선언하면 앞서 본 TS2430이 나온다. type intersection으로는 얻을 수 없는 안전망이다.
다중 상속과 충돌 규칙
즉, interface는 여러 부모를 동시에 extends 할 수 있다. JavaScript 클래스의 단일 상속과 다른 지점이다.
interface Timestamped { createdAt: Date; updatedAt: Date }
interface SoftDeletable { deletedAt: Date | null }
interface AuditedUser extends User, Timestamped, SoftDeletable {}
그래서, 같은 이름의 멤버가 충돌할 경우 규칙은 단순하다. 타입이 정확히 호환되면 병합되고, 호환되지 않으면 에러다. 메서드의 경우 시그니처가 오버로드처럼 누적되는 특수한 동작이 있는데, 의도하지 않은 오버로드를 만들기 쉽다. 같은 이름의 메서드를 여러 부모에서 다중 상속하는 패턴은 실무에선 피하는 편이 낫다.
클래스와의 결합 — 양방향 관계
게다가, interface는 class도 extends 할 수 있고, class가 interface를 implements 할 수도 있다. 이 양방향성이 의외로 강력하다.
class BaseRepository {
protected db: unknown;
find(id: string) { /* ... */ }
}
// 클래스를 interface로 확장 — 구현은 빼고 시그니처만
interface ReadOnlyRepository extends BaseRepository {
count(): Promise<number>;
}
class UserRepository implements ReadOnlyRepository {
protected db: unknown; // BaseRepository의 protected까지 약속해야 한다
find(id: string) { return null; }
count() { return Promise.resolve(0); }
}
여기서 한 가지 주의점. interface가 class를 extends할 때 private/protected 멤버까지 함께 가져온다. 위 UserRepository도 같은 class 계층 안에 있지 않으면 사실상 implements가 불가능하다. 직관과 어긋나는 동작이라 처음엔 한참 헤맸다. 백엔드 전환하면서 가장 헷갈렸던 부분 중 하나다.
실제로, :::tip interface가 class를 extends하는 패턴은 "구현체의 일부 시그니처만 노출하는 facade"를 만들 때 쓸 만하다. private 멤버까지 따라온다는 점만 기억하면 된다. 외부 모듈에서 그대로 implements 하기는 어렵다. :::
선언 병합 — 라이브러리 타입 확장의 핵심 도구
물론, 같은 이름의 interface를 여러 번 선언하면 컴파일러가 자동으로 합친다. Express의 Request에 사용자 정보를 붙이는 패턴이 대표적이다.
// express.d.ts
import "express";
declare module "express-serve-static-core" {
interface Request {
user?: { id: string; role: "admin" | "viewer" };
}
}
이처럼, 이 한 블록으로 모든 req.user 접근이 타입 안전해진다. type intersection으론 불가능하다. 라이브러리 확장이 잦은 백엔드 코드일수록 interface 비중을 키우게 되는 이유다.
(공식 문서: Declaration Merging)
검증 — 컴파일러가 잡는 경계
반면, interface extends가 안전망이라 했지만 한계는 명확하다. 실험해보니 다음과 같이 갈렸다.
특히, 컴파일러가 잡아주는 것은 프로퍼티 타입 호환성, 메서드 반환 타입의 covariance, optional을 required로 좁히려는 시도, readonly 위반 정도다. 정적 시점에 거의 다 걸린다.
이처럼, 못 잡는 것도 분명하다. 런타임에 외부에서 들어온 객체가 실제로 그 구조인지(이건 zod/io-ts의 영역이다), 메서드 파라미터의 contravariance 위반(strictFunctionTypes가 꺼져 있으면 통과), 그리고 선언 병합으로 의도치 않게 추가된 멤버. 후자는 의외로 함정이 깊다. 같은 이름의 interface가 다른 파일에서 조용히 병합되면 에러 메시지가 추적하기 어려운 위치로 튀어 나간다.
게다가, 빌드 시간 측면에서도 차이가 있다. 동일 프로젝트(파일 약 1,200개)에서 깊은 type intersection을 interface extends로 풀어낸 뒤 tsc --extendedDiagnostics로 본 type instantiation 수치가 체감상 20% 정도 줄었다. 정확한 비율은 의존 그래프에 따라 다를 텐데, 깊은 intersection을 평탄화하는 비용이 의외로 크다는 건 분명해 보인다.
한계점 — 모든 경우에 맞지는 않는다
interface extends가 어울리지 않는 자리도 있다. union 타입을 상속의 출발점으로 삼고 싶을 때(예: type A = B | C), mapped·conditional 타입을 합성할 때, 외부 라이브러리 API 계약이 type alias로만 노출돼 있을 때. 이 경우엔 type이 자연스럽다.
선언 병합도 강력한 만큼 위험하다. 라이브러리 확장 외 용도로 일반 비즈니스 코드에 끌어들이는 건 권장하지 않는다. 같은 이름이 멀리 떨어진 두 파일에서 합쳐지면, 누가 무엇을 추가했는지 추적하는 비용이 빠르게 누적된다.
또한, 당장 실행할 수 있는 액션을 세 가지 적어둔다. 첫째, 도메인 모델은 interface로 시작하고 합성이 필요한 지점에서만 type을 섞는다. 둘째, Express·Fastify 같은 프레임워크 확장은 무조건 선언 병합 패턴으로 통일한다. 셋째, tsc --extendedDiagnostics로 현재 타입 체커 비용을 한 번 측정해두면 이후 리팩토링 효과를 숫자로 검증할 수 있다.
예를 들어, 다만 5.5 이후 inferred type predicates가 다중 상속과 결합했을 때 어떻게 동작하는지는 더 지켜봐야 한다.
관련 글
- TypeScript 제네릭 사용법 — 다들 쓰는데 정작 잘못 쓰는 10가지 패턴 – typescript 제네릭 사용법은 단순히 T를 박는 게 아니다. 제약과 추론을 제대로 안 쓰면 any와 다를 게 없다. 실전에서 자주 부…
- 쿠버네티스 HPA 설정 Node.js 앱 비교 — CPU vs 메모리 vs KEDA – Node.js 앱을 HPA로 스케일링할 때 후보는 셋이다. CPU 기반, 메모리 기반, KEDA. 어느 게 맞는지 세 가지 축으로 비교하고…
- TypeScript ESM CommonJS 오류 해결 — require/import 충돌 정리 – TypeScript ESM CommonJS 오류 해결의 핵심은 단 두 줄이다. package.json type 한 줄, tsconfig m…