제네릭 클래스와 인터페이스
클래스와 인터페이스에 타입 매개변수를 적용해 저장소·응답·컴포넌트 같은 재사용 가능한 타입 안전 구조를 설계합니다.
3장 4절에서 제네릭 함수를 통해 다양한 타입에서 동작하는 재사용 가능한 함수를 만드는 방법을 배웠습니다.
제네릭은 함수뿐만 아니라 클래스(Classes)와 인터페이스(Interfaces)에도 적용될 수 있으며, 이를 통해 더욱 강력하고 유연하며 타입 안전한 데이터 구조와 컴포넌트를 설계할 수 있습니다.
이번 절에서는 제네릭 클래스와 인터페이스의 선언 및 활용 방법을 자세히 알아보겠습니다.
선언에 타입 자리를 만들고 생성 시 구체 타입을 바인딩하면, 저장·입력·반환 계약에 함께 전파된다.
- 타입 자리를 선언
class Box<T> T는 아직 결정되지 않음 구조는 고정하고 값 타입만 비워 둔다.
- 인스턴스에서 바인딩
new Box<string>("hi") T → string 타입 인자나 생성자 값에서 T를 정한다.
- 모든 위치에 전파
value: string setValue(v: string) getValue(): string 저장·입력·반환 타입이 함께 바뀐다.
- 인스턴스 상태를 묶기
Class<T> 필드와 메서드가 같은 T를 공유한다.
- 외부 계약을 묶기
Interface<K, V> 구현체가 달라도 K–V 관계는 유지한다.
- 필요한 능력만 제한
extends HasId 내부에서 쓸 최소 속성만 요구한다.
제네릭 클래스
제네릭 클래스(Generic Classes)는 클래스 자체에 타입 매개변수를 적용하여, 클래스 내부의 속성이나 메서드가 해당 타입 매개변수에 따라 타입을 결정하도록 합니다.
이는 특정 데이터 타입을 담는 컬렉션 클래스나, 타입에 따라 동작이 달라질 수 있는 유틸리티 클래스를 만들 때 매우 유용합니다.
클래스 이름 뒤에 <T>와 같은 타입 매개변수를 선언하여 제네릭 클래스를 정의합니다.
// Box 클래스는 어떤 타입이든 담을 수 있는 제네릭 클래스입니다.
class Box<T> {
private _value: T; // _value 속성의 타입은 T에 의해 결정됩니다.
constructor(value: T) {
this._value = value;
}
// T 타입의 값을 반환하는 getter 메서드
getValue(): T {
return this._value;
}
// T 타입의 값을 설정하는 setter 메서드
setValue(newValue: T): void {
this._value = newValue;
}
// 다른 타입의 값을 받아서 T 타입과 비교하는 메서드
isSameAs(otherValue: T): boolean {
return this._value === otherValue;
}
}
// 인스턴스 생성 시 타입 인자를 명시하여 T의 타입을 결정합니다.
const stringBox = new Box<string>("Hello TypeScript!");
console.log(stringBox.getValue()); // "Hello TypeScript!"
stringBox.setValue("New String");
console.log(stringBox.isSameAs("New String")); // true
const numberBox = new Box<number>(123);
console.log(numberBox.getValue()); // 123
numberBox.setValue(456);
console.log(numberBox.isSameAs(456)); // true
// numberBox.setValue("abc"); // Error: '"abc"' 형식은 'number' 형식에 할당할 수 없습니다.
const booleanBox = new Box<boolean>(true);
console.log(booleanBox.getValue()); // true
// 타입 추론: 생성자 매개변수를 통해 제네릭 타입을 자동으로 추론할 수도 있습니다.
const inferredBox = new Box(789); // inferredBox의 타입은 Box<number>로 추론됩니다.
console.log(inferredBox.getValue()); // 789제네릭 클래스를 사용하면 Box<string>, Box<number> 등과 같이 특정 타입에 특화된 여러 클래스를 별도로 정의할 필요 없이 하나의 제네릭 Box 클래스로 모든 경우를 처리할 수 있습니다.
이는 코드의 재사용성을 극대화합니다.
제네릭 인터페이스
제네릭 인터페이스(Generic Interfaces)는 인터페이스 자체에 타입 매개변수를 적용하여, 인터페이스가 정의하는 속성이나 메서드의 타입을 동적으로 결정하도록 합니다.
이는 다양한 타입의 데이터를 다루는 데이터 구조나 콜백 함수의 시그니처를 유연하게 정의할 때 매우 유용합니다.
인터페이스 이름 뒤에 <T>와 같은 타입 매개변수를 선언하여 제네릭 인터페이스를 정의합니다.
// KeyValuePair 인터페이스는 어떤 타입이든 key와 value로 가질 수 있습니다.
interface KeyValuePair<K, V> {
key: K;
value: V;
}
// StringValuePair는 K가 string, V가 string인 KeyValuePair를 나타냅니다.
type StringValuePair = KeyValuePair<string, string>;
const pair1: StringValuePair = { key: "name", value: "Alice" };
// NumberAndBooleanPair는 K가 number, V가 boolean인 KeyValuePair를 나타냅니다.
type NumberAndBooleanPair = KeyValuePair<number, boolean>;
const pair2: NumberAndBooleanPair = { key: 1, value: true };
// 인터페이스를 클래스에서 구현할 때 제네릭 타입 인자를 지정할 수 있습니다.
class DataStore<T> implements KeyValuePair<string, T> {
key: string;
value: T;
constructor(key: string, value: T) {
this.key = key;
this.value = value;
}
print(): void {
console.log(`Key: ${this.key}, Value: ${this.value}`);
}
}
const userStore = new DataStore<string>("user_id", "u12345");
userStore.print(); // Key: user_id, Value: u12345
const productStore = new DataStore<number>("product_code", 98765);
productStore.print(); // Key: product_code, Value: 98765제네릭 인터페이스는 특히 콜백 함수나 함수 시그니처를 정의할 때 그 진가를 발휘합니다.
// ProcessCallback 인터페이스는 T 타입의 데이터를 받고 V 타입의 결과를 반환하는 함수를 정의합니다.
interface ProcessCallback<T, V> {
(data: T): V;
}
// StringProcessor는 string을 받아 string을 반환하는 함수를 의미합니다.
const stringProcessor: ProcessCallback<string, string> = (input) => input.toUpperCase();
console.log(stringProcessor("hello")); // "HELLO"
// NumberProcessor는 number 배열을 받아 number를 반환하는 함수를 의미합니다.
const numberProcessor: ProcessCallback<number[], number> = (numbers) => numbers.reduce((sum, num) => sum + num, 0);
console.log(numberProcessor([1, 2, 3, 4, 5])); // 15제네릭 인터페이스는 실제 애플리케이션에서 저장소(Repository)처럼 여러 도메인 타입이 같은 동작 계약을 공유해야 할 때 특히 유용합니다.
Repository<T>라는 하나의 계약을 만들고 구현 클래스에서 T를 유지하면, User, Product처럼 서로 다른 타입을 넣어도 findById, findAll 같은 메서드의 반환 타입이 각 타입에 맞게 안전하게 결정됩니다.
제네릭 인터페이스는 findById, findAll, save 같은 공통 동작의 모양을 고정하고, T는 User나 Product 같은 실제 반환 타입을 끝까지 보존한다.
- 구현체MemoryRepository<T>
구현체 저장 방식은 하나로 공유하되, 메서드의 입출력은 T에 맞춰 좁혀진다. 서비스와 테스트 대역은 구현체가 아니라 Repository<T> 계약에 의존한다.
- 도메인 타입
User, Product처럼 실제 데이터 모양을 정의한다.
- 계약에 주입
Repository<User>처럼 T 자리에 모델을 넣는다.
- 메서드 추론
findAll은 User[], save는 User로 계산된다.
- 재사용 경계
캐시, 서비스, 테스트가 같은 API 모양을 공유한다.
interface Repository<T> {
findById(id: string): T
findAll(): T[]
save(entity: T): T
}
제네릭 제약 조건과 함께 사용하기 (복습)
length만 쓰는 함수라면 배열 전체가 아니라 length 속성만 요구하면 된다.
| 질문 | 판정 |
|---|---|
| 내부에서 length를 쓰는가? | T extends { length: number } |
| key 접근을 하는가? | K extends keyof T |
| 생성자를 호출하는가? | new (...args) => T 형태로 제한 |
| 반환 타입을 보존해야 하는가? | 입력 T와 반환 T의 관계 유지 |
| 신호 | 해석 |
|---|---|
| 최소 조건 | 함수 본문이 실제로 사용하는 능력 |
| 과잉 조건 | 호출 가능한 타입을 불필요하게 줄임 |
| 판단 | 구현에서 읽는 멤버를 기준으로 bound를 정한다 |
제네릭 클래스와 인터페이스에서도 제네릭 함수와 마찬가지로 제약 조건(Constraints)을 사용할 수 있습니다.
이는 특정 조건을 만족하는 타입만 제네릭 타입 인자로 받을 수 있도록 제한할 때 사용됩니다.
interface HasId {
id: string | number;
}
// IdentifiableRepository 클래스는 HasId 인터페이스를 만족하는 T 타입만 다룰 수 있습니다.
class IdentifiableRepository<T extends HasId> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: string | number): T | undefined {
return this.items.find(item => item.id === id);
}
}
interface User extends HasId {
name: string;
email: string;
}
interface Product extends HasId {
name: string;
price: number;
}
const userRepository = new IdentifiableRepository<User>();
userRepository.add({ id: 1, name: "Alice", email: "alice@example.com" });
console.log(userRepository.findById(1)); // { id: 1, name: 'Alice', email: 'alice@example.com' }
const productRepository = new IdentifiableRepository<Product>();
productRepository.add({ id: "P001", name: "Laptop", price: 1200 });
console.log(productRepository.findById("P001")); // { id: 'P001', name: 'Laptop', price: 1200 }
// const invalidRepository = new IdentifiableRepository<string>();
// Error: 'string' 형식은 'HasId' 형식에 할당할 수 없습니다.T extends HasId 제약 조건 덕분에 IdentifiableRepository는 id 속성을 가진 객체만 저장하고 검색할 수 있도록 타입 안전성이 보장됩니다.
제네릭의 활용 시나리오
제네릭 클래스와 인터페이스는 현대 웹 애플리케이션 개발에서 광범위하게 사용됩니다.
- 데이터 구조: 스택, 큐, 연결 리스트, 트리 등 다양한 타입의 요소를 담을 수 있는 재사용 가능한 데이터 구조를 구현할 때.
- 컴포넌트 라이브러리: UI 컴포넌트(예: Dropdown, Table)가 다양한 타입의 데이터를 표시하거나 다룰 수 있도록 할 때.
- API 클라이언트: 서버로부터 받아오는 데이터의 형태가 다양할 때, 응답 데이터를 타입 안전하게 처리할 수 있는 클라이언트 모듈을 만들 때.
- 상태 관리: Redux, Vuex 등의 상태 관리 라이브러리에서 상태의 타입을 제네릭으로 정의하여 유연하게 사용할 때.
- 공통 유틸리티: 로깅, 캐싱, 데이터 변환 등 특정 타입에 얽매이지 않고 범용적으로 사용될 수 있는 유틸리티를 만들 때.
제네릭 클래스와 인터페이스는 데이터 구조, 컴포넌트, 유틸리티처럼 여러 타입을 받아야 하는 구조에서 타입 매개변수를 명시하는 방법입니다.
저장되는 값, 외부 계약, 콜백 입출력 중 무엇을 일반화할지 먼저 구분해야 합니다.
제네릭 클래스와 인터페이스를 설계할 때는 타입 매개변수가 저장소의 상태를 대표하는지, 외부 계약을 대표하는지, 콜백의 입출력을 대표하는지부터 나누면 구조가 선명해집니다.
아래 표는 선언 위치별로 제네릭을 배치하는 기준입니다.
제네릭은 클래스, 인터페이스, 함수 중 어디에 붙이느냐에 따라 재사용 범위와 추론 방식이 달라집니다.
- 상태를 오래 보관할 때
Box 처럼 저장된 값과 메서드가 같은 타입 변수에 묶입니다.
- 도메인 저장소 생성
Repository 처럼 한 타입의 목록과 조회 결과를 유지합니다.
- 계약을 재사용할 때
KeyValuePair 처럼 구현과 분리된 데이터 계약을 둡니다.
- 입출력 관계 고정
콜백 시그니처에 제네릭을 두면 입력 타입에 맞는 반환 타입을 유지할 수 있습니다.
제네릭 설계가 커질수록 타입 매개변수의 이름, 제약 조건, 공개 메서드의 반환 타입이 같은 도메인 언어를 가리키는지 함께 점검해야 합니다.
제네릭 클래스와 인터페이스는 저장할 값, 외부 계약, 반환 타입을 하나의 타입 매개변수 흐름으로 묶습니다.
- 설계 단서설계 단서 <T> extends
Repository<T>
- 2상태 대표
컬렉션이나 저장소가 담는 값 전체를 T가 대표하면 add, get, find 반환 타입이 일관됩니다.
- 3계약 제한
extends HasId처럼 필요한 속성을 제약하면 구현 내부에서 안전하게 접근할 수 있습니다.
- 4외부 확장
인터페이스에 제네릭을 두면 같은 구조를 User, Product, DTO 같은 도메인별 타입으로 재사용합니다.
- 5이름
T가 무엇을 대표하는지 이름이나 문맥에서 보이는지 확인합니다.
- 6제약
구현이 요구하는 속성은 extends로 드러냅니다.
- 7메서드
입력과 반환이 같은 타입 흐름에 묶이는지 봅니다.
- 8재사용
새 도메인 타입을 넣어도 계약이 유지되는지 점검합니다.
다음 다이어그램은 제네릭 클래스와 인터페이스를 컨테이너 설계 기준으로 정리한 표입니다.
제네릭 클래스와 인터페이스를 코드에 적용하기 전, 컴파일 오류가 막아 줄 지점과 사람이 약속해야 할 지점을 나눕니다.
아래 다이어그램은 제네릭 클래스와 인터페이스를 재사용 가능한 데이터 구조와 API 계약으로 연결합니다.
제네릭 클래스·인터페이스 재사용 사례는 데이터의 구체 타입은 호출자가 정하고, 구조와 동작은 한 번만 정의합니다.
- 제네릭 클래스상태 타입을 외부에서 결정
제네릭 클래스 클래스 내부 배열이나 캐시가 어떤 타입을 담을지 인스턴스 생성 시 정합니다. class Stack<T>
- 제네릭 인터페이스응답 구조 재사용
제네릭 인터페이스 data의 모양만 바뀌고 meta나 error 구조는 같은 API 타입을 만들 수 있습니다. interface Response<T>
- 제약 결합필요한 속성 보장
제약 결합 저장소나 테이블처럼 id가 필요한 타입은 최소 속성을 제약으로 둡니다. T extends { id: string }
- 활용 영역컴포넌트와 클라이언트
활용 영역 테이블, 드롭다운, API 클라이언트처럼 자료형이 다양한 모듈에 잘 맞습니다. Table<Row>