4.Node.js/JavaScript&TypeScript

[TypeScript] 6편. 제네릭 (Generic) 이해하기: 함수, 인터페이스, 클래스, constraints 활용

쿼드큐브 2025. 12. 22. 09:05
반응형
반응형

 

6편. 제네릭 (Generic) 이해하기: 함수, 인터페이스, 클래스, constraints 활용

 

📚 목차
1. any 대신 제네릭(Generic)이 필요한 이유
2. 제네릭(Generic) 함수 개념과 동작 방식
3. 제네릭 인터페이스(Interface)와 클래스(Class) 활용
4. 제약 조건(Constraints)으로 타입 제한하기

 

제네릭 이해하기 삽화 이미지
제네릭 이해하기 삽화 이미지

 

 

📂 [GitHub 예시 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /TypeScript

 

1. any 대신 제네릭(Generic)이 필요한 이유

제네릭은 '일반적인', '범용적인'이라는 뜻처럼, 코드를 작성할 때 사용할 타입을 미리 정하지 않고, 그 코드를 사용하는 시점에 원하는 타입으로 지정할 수 있게 해주는 기능입니다.

이를 통해 타입 안전성을 유지하면서도 코드를 매우 유연하고 재사용 가능하게 만들 수 있습니다.

 

개발을 하다 보면 여러 타입에 대해 동일한 처리 과정을 수행해야 하는 상황이 자주 등장합니다.
예를 들어, 전달받은 값을 그대로 반환하는 “단순한 함수”를 만든다고 가정해 보겠습니다.

이때 any를 사용하면 일견 편리해 보이지만, TypeScript가 제공하는 타입 안전성(type safety)을 잃게 됩니다.

 

🔷 any 타입을 사용할 때 발생하는 문제

function identityAny(arg: any): any {
  return arg;
}

let numResult = identityAny(123);
let strResult = identityAny("hello");

// numResult는 분명 숫자였는데, TS는 여전히 그 타입을 any로만 알고 있습니다.
// 이로 인해 아래와 같은 오류를 컴파일러가 잡아주지 못합니다.
// 🚨 런타임 오류 가능성: 문자열에 toFixed 메서드가 없지만, any라서 TS가 오류를 찾지 못함
// strResult.toFixed(2);

위 코드는 실행은 되지만, 문제는 다음과 같습니다.
▸ TypeScript는 numResult가 어떤 타입인지 실제로 알지 못합니다.
▸ strResult 역시 특정 타입으로 확정되지 않고 그대로 any입니다.
따라서 아래와 같은 코드가 작성되어도 컴파일 단계에서 오류가 발생하지 않습니다.

// 🚨 형식상 오류 없음 — 하지만 런타임 오류 가능성 존재
strResult.toFixed(2);  // 문자열에는 toFixed가 존재하지 않습니다

즉, any를 사용하면 TypeScript가 제공하고자 하는 정적 안정성의 장점이 사라집니다.

 

🔷 제네릭(Generic)의 해결책

제네릭은 함수나 클래스 내부에서 사용할 타입을 호출 시점에 결정하도록 만드는 문법입니다.
공식 문서에서는 제네릭을 다음과 같이 설명합니다:

“A generic type can be reused across multiple code locations while preserving the relationships between input and output types.”
(제네릭 타입은 입력과 출력 타입 간의 관계를 보존하면서 다양한 위치에서 재사용할 수 있습니다.)

 

✔️ 제네릭을 적용한 예시

function identityGeneric<T>(arg: T): T {
  return arg;
}

위 코드에서 <T>는 타입 변수(Type Parameter)이며, 함수가 호출될 때 구체적인 타입으로 치환됩니다.

let numResult = identityGeneric(123);
// numResult의 타입은 number로 추론됩니다

let strResult = identityGeneric("hello");
// strResult의 타입은 string으로 추론됩니다

 

이제 TypeScript는 strResult가 문자열(string)이라는 사실을 알고 있기 때문에, 다음과 같은 잘못된 코드를 작성하면 컴파일 시점에 오류를 알려줍니다.

strResult.toFixed(1);
// ❌ 컴파일 오류
// Property 'toFixed' does not exist on type 'string'.

제네릭을 사용하면 함수나 클래스에서 타입 정보를 유지한 채로 다양한 타입에 유연하게 대응할 수 있으며, any를 사용할 때처럼 타입 안정성이 깨지는 문제도 방지할 수 있습니다.

 

✔️ 전체 예제

// 제네릭 함수 선언
function identityGeneric<T>(value: T): T {
  return value;
}

// number 타입 호출
const a = identityGeneric(42);
console.log(a.toFixed(2)); // 정상 동작

// string 타입 호출
const b = identityGeneric("hello");
console.log(b.toUpperCase()); // 정상 동작

// 다음과 같은 코드는 오류를 사전에 잡아줍니다
// console.log(b.toFixed(2)); 
// ❌ Error: Property 'toFixed' does not exist on type 'string'

 

2. 제네릭(Generic) 함수 개념과 동작 방식

TypeScript에서 제네릭(Generics)은 타입을 변수처럼 다루는 기능으로, 코드의 재사용성과 타입 안전성을 동시에 높여주는 핵심 기능입니다.

 

🔷 타입 매개변수란? (Type Parameter)

제네릭 함수는 타입 매개변수(Type Parameter)를 사용하여 정의합니다.
일반적으로 T, U, K, V 등 대문자 한 글자 이름을 사용합니다.


이는 "Temporary(임시)"의 의미가 아니라 "Type의 약자"로 관용적으로 사용되는 표기입니다.
다음과 같은 형태를 기본 구조로 가집니다:

function 함수명<T>(value: T) {}

여기서 <T>는 함수 호출 시점에서 확정되는 타입을 대표하는 변수입니다.

 

✔️ 제네릭 함수 정의 실습

/**
 * T 타입의 값을 받아, T 타입 요소로 이루어진 배열을 반환합니다.
 * @param element 배열로 만들 요소
 * @returns T 타입의 요소를 가진 배열
 */
function toArray<T>(element: T): T[] {
  return [element];
}

// 1. string 타입 사용
let stringArray = toArray("Apple");
// stringArray의 타입은 string[]으로 정확히 추론됩니다.

// 2. number 타입 사용
let numberArray = toArray(42);
// numberArray의 타입은 number[]로 정확히 추론됩니다.

console.log(stringArray); // 출력: [ 'Apple' ]
console.log(numberArray); // 출력: [ 42 ]

 

🔷 여러 개의 타입 매개변수 사용하기

필요하다면, 두 개 이상의 타입 매개변수를 사용할 수 있습니다.
다음 예제는 서로 다른 타입 값을 받아 튜플 형태로 반환하는 함수입니다.

// 두 개의 다른 타입 U와 V를 사용하여 튜플을 반환하는 함수
function createPair<U, V>(first: U, second: V): [U, V] {
  return [first, second];
}

// 호출 시점에 U는 string, V는 number로 결정됩니다.
let myPair = createPair("score", 95); // myPair의 타입은 [string, number]

// myPair[0].toUpperCase(); // string 메서드 사용 가능
// myPair[1].toFixed(1);    // number 메서드 사용 가능

제네릭을 사용했기 때문에 첫 번째 요소는 언제나 string, 두 번째 요소는 언제나 number라는 점이 보장됩니다.

 

🔷 제네릭 함수가 필요한 이유

1. 제네릭 X (타입 정보가 사라짐)

▸ result는 any[]이므로 오타 등의 위험 존재

function wrap(value: any) {
  return [value];
}

const result = wrap("Hello");
result[0].toUpperCase(); // 가능하지만 타입 안전하지 않음

 

2. 제네릭 사용 시

▸ 타입 안전성 강화
▸ 자동 타입 추론
▸ 호출 시점별 맞춤 타입 적용

function wrapSafe<T>(value: T): T[] {
  return [value];
}

const resultSafe = wrapSafe("Hello");
resultSafe[0].toUpperCase(); // 정확하고 안전함
반응형

 

3. 제네릭 인터페이스(Interface)와 클래스(Class) 활용

제네릭(Generic)은 함수뿐만 아니라 인터페이스, 클래스, 그리고 다양한 데이터 구조에 적용할 수 있습니다.
이를 통해 “어떤 타입을 사용할지 미리 확정하지 않은 상태에서” 구조를 설계하고, 사용 시점에 타입을 지정함으로써 타입 안전성과 재사용성을 동시에 확보할 수 있습니다.

 

TypeScript에서 우리가 자주 사용하는 대표적인 제네릭 구조는 다음과 같습니다:
▸ Array<number>
▸ Promise<string>
즉, “값을 담고 있는 구조”에 타입을 부여하는 방식입니다.

 

🔷 제네릭 인터페이스 (Generic Interface)

인터페이스 정의 시 타입 매개변수(Type Parameter)를 선언하여, 이 인터페이스를 사용할 때 어떤 타입의 데이터를 다룰 것인지 명확하게 지정할 수 있습니다.

 

✔️ 예제: Container 인터페이스

// T 타입의 값을 담는 컨테이너 인터페이스
interface Container<T> {
  value: T;
  isLocked: boolean;
}

// 1. Container를 string 타입으로 사용
const stringBox: Container<string> = {
  value: "TypeScript is awesome",
  isLocked: false,
};

console.log(stringBox.value.toUpperCase()); // 정상 작동

위 예제처럼 Container<string>이라고 선언하면 value는 반드시 string 타입이 되고, string의 메서드들이 안전하게 사용됩니다.

 

✔️ 예제: 커스텀 타입 적용

interface User {
  id: number;
  name: string;
}

// 2. User 타입 데이터를 담는 Container
const userBox: Container<User> = {
  value: {
    id: 1,
    name: "Alice",
  },
  isLocked: true,
};

console.log(userBox.value.name);

이 경우 value는 반드시 User 타입이어야 하며, name, id 같은 속성을 타입 안정성 있게 사용할 수 있습니다.

 

🔷 제네릭 클래스 (Generic Class)

제네릭 클래스는 클래스가 저장하는 데이터 타입을 외부에서 지정할 수 있게 해줍니다.
특히 큐(Queue), 스택(Stack)처럼 다양한 데이터 타입을 담는 자료구조에 매우 적합합니다.

 

✔️ 예제: 단순한 큐 구현 (SimpleQueue)

class SimpleQueue<T> {
  private items: T[] = [];

  enqueue(item: T) {
    this.items.push(item);
  }

  dequeue(): T | undefined {
    return this.items.shift();
  }

  size(): number {
    return this.items.length;
  }
}

 

✔️ 사용 예시 및 에러 안전성 확인

// 숫자 타입만 허용하는 큐 생성
const numberQueue = new SimpleQueue<number>();
numberQueue.enqueue(10);
numberQueue.enqueue(20);

// numberQueue.enqueue("hello"); // 🚨 오류 발생!
/*
Argument of type 'string' is not assignable to parameter of type 'number'.
*/

let removed = numberQueue.dequeue();
console.log(removed); // number 또는 undefined

console.log(numberQueue.size()); // 남은 요소 개수 출력

▸ numberQueue는 number 타입 요소만 처리하며
▸ 컴파일 단계에서 타입이 맞지 않는 값이 들어오면 오류가 발생합니다.

 

4. 제약 조건(Constraints)으로 타입 제한하기

TypeScript에서 제네릭(Generic)을 사용할 때, 때로는 모든 타입을 허용하기보다, 특정 속성이나 형태를 가진 타입만 허용하고 싶을 때가 있습니다.
이럴 때 사용하는 것이 바로 제약 조건 (Constraints)입니다.

제약 조건을 설정하면 타입 안정성을 확보하면서도, 보다 유연하고 강력한 함수나 클래스 설계가 가능합니다.

 

🔷 제약 조건이 왜 필요할까요?
아래는 입력된 값의 길이(length)를 반환하는 함수를 만들고자 하는 예시입니다:

function getLength<T>(arg: T): number {
  // return arg.length; // ❌ 오류 발생!
}

▸ 이 코드는 오류가 발생합니다.
▸ 그 이유는 T가 어떤 타입인지 TypeScript가 알 수 없기 때문입니다.
▸ 예를 들어, T가 string이나 Array일 수도 있지만, number처럼 length 속성이 없는 타입일 수도 있기 때문에 arg.length에 바로 접근하는 것은 타입 안전하지 않습니다.

 

🔷 해결 방법: extends를 사용한 제약 조건 설정

이럴 때는 extends 키워드를 사용하여 제네릭 타입 T가 반드시 length 속성을 가지고 있어야 한다는 조건을 설정할 수 있습니다.

1. 인터페이스로 제약 조건 정의

interface HasLength {
  length: number;
}

 

2. 제네릭 타입에 제약 조건 적용

function getLength<T extends HasLength>(arg: T): number {
  return arg.length;
}

이렇게 작성하면 T는 반드시 length: number 속성을 가진 타입이어야만 이 함수를 사용할 수 있게 됩니다.

 

✔️ 실습 예시: 다양한 타입으로 테스트해 보기

// ✅ 문자열: length 속성이 있으므로 사용 가능
console.log(getLength("Hello TypeScript")); // 출력: 17

// ✅ 배열: length 속성이 있으므로 사용 가능
console.log(getLength([1, 2, 3, 4])); // 출력: 4

// ✅ 사용자 정의 객체
const book = { title: "TypeScript", length: 300 };
console.log(getLength(book)); // 출력: 300

// ❌ 숫자: length 속성이 없으므로 오류 발생
// console.log(getLength(100)); 
// Error: Argument of type 'number' is not assignable to parameter of type 'HasLength'.

 


※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형