icon
6장 : 제네릭 심화

제네릭 클래스와 인터페이스


3장 4절에서 제네릭 함수를 통해 다양한 타입에서 동작하는 재사용 가능한 함수를 만드는 방법을 배웠습니다. 제네릭은 함수뿐만 아니라 클래스(Classes)인터페이스(Interfaces) 에도 적용될 수 있으며, 이를 통해 더욱 강력하고 유연하며 타입 안전한 데이터 구조와 컴포넌트를 설계할 수 있습니다.

이번 절에서는 제네릭 클래스와 인터페이스의 선언 및 활용 방법을 자세히 알아보겠습니다.


제네릭 클래스

제네릭 클래스(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

제네릭 제약 조건과 함께 사용하기 (복습)

제네릭 클래스와 인터페이스에서도 제네릭 함수와 마찬가지로 제약 조건(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 제약 조건 덕분에 IdentifiableRepositoryid 속성을 가진 객체만 저장하고 검색할 수 있도록 타입 안전성이 보장됩니다.


제네릭의 활용 시나리오

제네릭 클래스와 인터페이스는 현대 웹 애플리케이션 개발에서 광범위하게 사용됩니다.

  • 데이터 구조: 스택, 큐, 연결 리스트, 트리 등 다양한 타입의 요소를 담을 수 있는 재사용 가능한 데이터 구조를 구현할 때.
  • 컴포넌트 라이브러리: UI 컴포넌트(예: Dropdown, Table)가 다양한 타입의 데이터를 표시하거나 다룰 수 있도록 할 때.
  • API 클라이언트: 서버로부터 받아오는 데이터의 형태가 다양할 때, 응답 데이터를 타입 안전하게 처리할 수 있는 클라이언트 모듈을 만들 때.
  • 상태 관리: Redux, Vuex 등의 상태 관리 라이브러리에서 상태의 타입을 제네릭으로 정의하여 유연하게 사용할 때.
  • 공통 유틸리티: 로깅, 캐싱, 데이터 변환 등 특정 타입에 얽매이지 않고 범용적으로 사용될 수 있는 유틸리티를 만들 때.

제네릭 클래스와 인터페이스는 타입스크립트의 강력한 타입 시스템을 활용하여 코드의 재사용성, 유연성, 그리고 무엇보다 타입 안전성을 크게 향상시킵니다. 데이터 구조, 컴포넌트, 유틸리티 등 다양한 상황에서 제네릭을 적절히 사용하여 견고하고 확장 가능한 애플리케이션을 구축하는 데 익숙해지는 것이 중요합니다.