제네릭 클래스와 인터페이스는 언제 쓰는가
함수 제네릭과 클래스 제네릭은 생명주기가 다르다
함수의 타입 매개변수는 호출할 때마다 새로 결정됩니다. wrapInArray(3)과 wrapInArray("a")는 같은 함수를 서로 다른 T로 두 번 쓴 것입니다. 클래스는 다릅니다 — 인스턴스를 만들 때 T가 한 번 고정되면, 그 인스턴스가 살아있는 동안 계속 그 타입으로 남습니다.
class Box<T> {
private value: T;
constructor(value: T) {
this.value = value;
}
get(): T {
return this.value;
}
set(value: T) {
this.value = value;
}
}
const numberBox = new Box(42);
numberBox.set(43); // OK
numberBox.set("43"); // 에러: string은 number에 할당할 수 없다
const stringBox = new Box("hello");numberBox는 생성 시점에 T가 number로 고정됐기 때문에, 그 이후로는 set에 문자열을 넣을 수 없습니다. stringBox는 별개의 인스턴스라 자신만의 T = string을 갖습니다. 함수 제네릭이 "호출마다 리셋되는 임시 타입 변수"라면, 클래스 제네릭은 "인스턴스에 각인되는 타입"이라는 차이를 기억해두면 헷갈리지 않습니다.
제네릭 인터페이스로 계약을 표현하기
인터페이스에 타입 매개변수를 쓰면 "이 모양의 데이터 구조인데, 내용물의 타입은 나중에 정한다"는 계약을 만들 수 있습니다.
interface Repository<T, ID = number> {
findById(id: ID): T | undefined;
save(item: T): void;
delete(id: ID): void;
}
interface User {
id: number;
name: string;
}
class InMemoryUserRepository implements Repository<User> {
private users = new Map<number, User>();
findById(id: number) {
return this.users.get(id);
}
save(item: User) {
this.users.set(item.id, item);
}
delete(id: number) {
this.users.delete(id);
}
}Repository<T, ID = number>는 저장소가 갖춰야 할 세 가지 메서드의 모양을 정의하면서, 실제로 무엇을 저장하는지는 구현체에 미룹니다. ID에 기본값 number를 준 이유는, 대부분의 경우 식별자가 숫자이기 때문입니다. UUID처럼 문자열 식별자를 쓰는 저장소라면 Repository<Post, string>처럼 두 번째 인수를 직접 지정하면 됩니다. 이런 구조는 여러 도메인 객체(User, Post, Comment...)에 대해 반복되는 CRUD 인터페이스를 매번 새로 정의하지 않고 재사용하게 해줍니다.
언제 클래스 제네릭이 과한가
모든 클래스가 제네릭이어야 하는 건 아닙니다. 아래는 실무에서 실제로 본 과설계 사례를 단순화한 것입니다.
// 이 서비스는 항상 User만 다루는데 제네릭으로 만들어져 있다
class UserService<T extends { id: number }> {
private items: T[] = [];
add(item: T) {
this.items.push(item);
}
}
const service = new UserService<User>();UserService는 이름부터 User 전용인데 제네릭으로 감싸놨습니다. 다른 타입이 들어올 일이 없다면 <T extends { id: number }>는 코드를 읽는 사람에게 "이 클래스는 여러 타입에 쓰일 수 있다"는 잘못된 신호만 줍니다. 실제로 쓰이는 곳도 항상 UserService<User> 하나뿐이라면, 그냥 이렇게 쓰는 편이 낫습니다.
class UserService {
private items: User[] = [];
add(item: User) {
this.items.push(item);
}
}제네릭 클래스를 만들지 결정하는 기준은 함수와 동일합니다 — "이 클래스가 실제로 둘 이상의 타입에 대해 쓰일 예정인가, 아니면 지금 당장 하나의 타입만 다루는데 미리 일반화해두는 것인가." 후자라면 나중에 정말 필요해질 때 제네릭으로 바꿔도 늦지 않습니다. TypeScript의 리팩터링은 이런 전환을 어렵지 않게 만들어 줍니다.