TypeScript 두 번째

2025. 7. 22. 17:15Next.js + TypeScript

 

제네릭(Generics)

제네릭은 타입스크립트에서 재사용 가능한 컴포넌트(함수, 클래스, 인터페이스 등)를 만들 때 사용되는 기능. 한 마디로, 코드를 작성할 때 타입을 미리 정하지 않고, 그 코드를 사용할 때(호출할 때) 타입을 결정할 수 있도록 해주는 문법.

예를 들어 특정 타입의 데이터를 배열로 받아서 첫 번째 요소를 반환하는 함수를 만든다고 할 때

// 1. 특정 타입에만 동작하는 함수 (string만)
function getFirstString(arr: string[]): string {
    return arr[0];
}

// 2. 다른 타입을 처리하려면 또 다른 함수를 만들어야 함 (number도)
function getFirstNumber(arr: number[]): number {
    return arr[0];
}

이 방식은 string이나 number뿐만 아니라 boolean, 객체 등 다양한 타입에 대해 함수를 계속해서 만들어야 하는 비효율성이 있다. 이때 제네릭을 사용하면 하나의 함수로 모든 타입을 처리 가능.

 

// 제네릭 T (Type의 약자)를 사용하여 어떤 타입이든 받을 수 있게 함
function getFirstElement<T>(arr: T[]): T {
    return arr[0];
}

let numArr = [1, 2, 3];
let strArr = ["a", "b", "c"];

let firstNum = getFirstElement(numArr); // T는 number로 추론됨, firstNum은 number 타입
let firstStr = getFirstElement(strArr); // T는 string으로 추론됨, firstStr은 string 타입

// 타입을 명시적으로 지정할 수도 있습니다.
let firstBoolean = getFirstElement<boolean>([true, false]); // firstBoolean은 boolean 타입

위 예시에서 <T>는 "이 함수가 어떤 특정 타입 T를 사용할 것이다"라고 선언하는 것. 함수를 호출할 때 T의 실제 타입이 결정되며, 타입스크립트는 이 T를 사용하여 함수 내부의 타입 검사를 수행.

 

즉,

제네릭을 사용하는 주된 이유:

  • 코드 재사용성: 다양한 타입에 대해 동일한 로직을 적용할 수 있어 중복 코드를 줄임.
  • 타입 안정성: any를 사용하는 것과 달리, 제네릭은 사용 시점에 타입을 명확히 지정하거나 추론하여 타입 안정성을 유지.
  • 유연성: 코드를 작성할 때는 유연하게 타입을 비워두고, 실제 사용 시점에서 구체적인 타입을 지정할 수 있음.

 

 

1. 인터페이스(Interface)에 제네릭 사용

인터페이스에 제네릭을 사용하면, 인터페이스가 정의하는 구조가 특정 타입에 국한되지 않고 다양한 타입을 포용할 수 있게 됨

// 제네릭 인터페이스: Box
// <T>는 이 인터페이스를 사용할 때 결정될 타입.
interface Box<T> {
    value: T; // value 속성은 T 타입의 값을 가짐.
    label: string;
    // T 타입의 값을 받아들이는 메서드도 정의할 수 있음.
    unwrap(): T;
}

// string 타입을 위한 Box 인스턴스
const stringBox: Box<string> = {
    value: "Hello Generics",
    label: "문자열 상자",
    unwrap() {
        return this.value;
    }
};

console.log(stringBox.unwrap()); // "Hello Generics"
// stringBox.value = 123; // 오류: 'number' 타입은 'string' 타입에 할당될 수 없음

// number 타입을 위한 Box 인스턴스
const numberBox: Box<number> = {
    value: 123,
    label: "숫자 상자",
    unwrap() {
        return this.value;
    }
};

console.log(numberBox.unwrap()); // 123

// 커스텀 객체 타입을 위한 Box 인스턴스
interface User {
    id: number;
    name: string;
}

const userBox: Box<User> = {
    value: { id: 1, name: "Alice" },
    label: "사용자 상자",
    unwrap() {
        return this.value;
    }
};

console.log(userBox.unwrap().name); // "Alice"

위 예시에서 Box<T> 인터페이스는 어떤 타입 T라도 value 속성의 타입으로 받을 수 있게 정의되어 있음. 이를 통해 string, number, User 등 다양한 타입의 값을 담는 Box를 생성.

 

 

2. 타입 별칭(Type Alias)에 제네릭 사용

타입 별칭 또한 인터페이스와 유사하게 제네릭을 사용하여 유연한 타입 정의를 할 수 있음. 타입 별칭은 인터페이스보다 더 다양한 타입 조합(유니온, 튜플 등)에도 제네릭을 적용할 수 있다는 장점이 있음.

// 제네릭 타입 별칭: Pair
// <K, V>는 각각 키(Key)와 값(Value)의 타입.
type Pair<K, V> = {
    key: K;
    value: V;
};

// string 키와 number 값을 가지는 Pair
const idValuePair: Pair<string, number> = {
    key: "userId",
    value: 12345
};

console.log(idValuePair.key, idValuePair.value); // "userId" 12345

// number 키와 boolean 값을 가지는 Pair
const statusPair: Pair<number, boolean> = {
    key: 200,
    value: true
};

console.log(statusPair.key, statusPair.value); // 200 true

// 제네릭 타입 별칭: ArrayOf<T> (배열 타입에 별칭을 부여)
type ArrayOf<T> = T[];

const numbers: ArrayOf<number> = [10, 20, 30];
// numbers = ["hello"]; // 오류: 'string' 타입은 'number' 타입에 할당될 수 없음

// 제네릭 타입 별칭: Result<T, E> (성공 또는 실패 결과를 나타낼 때 유용)
type Result<T, E> = { success: true; data: T } | { success: false; error: E };

function fetchDataResult(): Result<string, Error> {
    const success = Math.random() > 0.5;
    if (success) {
        return { success: true, data: "데이터를 성공적으로 가져왔습니다!" };
    } else {
        return { success: false, error: new Error("네트워크 오류 발생!") };
    }
}

const apiResponse = fetchDataResult();
if (apiResponse.success) {
    console.log(apiResponse.data); // 'data'는 string 타입
} else {
    console.log(apiResponse.error.message); // 'error'는 Error 타입
}

Pair<K, V> 타입 별칭은 다양한 키와 값 타입을 갖는 객체를 정의할 때 유용, Result<T, E>는 비동기 작업의 성공/실패 결과를 명확한 타입으로 표현할 수 있게 해준다.

 

 

 

3. 변수(Variable)에 제네릭 사용 (함수, 클래스, 인터페이스 등의 인스턴스)

변수 자체에 제네릭 타입을 직접 선언하는 경우는 드물지만, 제네릭이 적용된 함수, 클래스, 인터페이스 등의 타입을 변수에 할당할 때 제네릭 타입 매개변수를 지정하여 사용.

// 1. 제네릭 함수를 변수에 할당하는 경우
function identity<T>(arg: T): T {
    return arg;
}

// 변수 myIdentity는 제네릭 함수 identity의 타입 시그니처를 가짐.
// 타입스크립트가 자동으로 T를 number로 추론.
let myIdentity = identity(100); // myIdentity: number

// 명시적으로 T를 string으로 지정할 수도 있음.
let myStringIdentity: <T>(arg: T) => T = identity;
let resultString = myStringIdentity<string>("TypeScript"); // resultString: string

// 2. 제네릭 클래스 인스턴스를 변수에 할당하는 경우
class LinkedListNode<T> {
    constructor(public value: T, public next: LinkedListNode<T> | null = null) {}
}

// ListNode 변수는 string 타입의 값을 가지는 LinkedListNode 인스턴스.
const headNode: LinkedListNode<string> = new LinkedListNode("first");
headNode.next = new LinkedListNode("second");

console.log(headNode.value); // "first"
console.log(headNode.next?.value); // "second"

// 3. 제네릭 인터페이스의 인스턴스를 변수에 할당하는 경우 (위 인터페이스 예시에서 다시 가져옴)
interface Box<T> {
    value: T;
    label: string;
}

// string 타입 값을 담는 Box 인스턴스를 변수에 할당
const myProductBox: Box<string> = {
    value: "Laptop",
    label: "전자제품"
};

console.log(myProductBox.value); // "Laptop"

// number 타입 값을 담는 Box 인스턴스를 변수에 할당
const myQuantityBox: Box<number> = {
    value: 500,
    label: "재고수량"
};

console.log(myQuantityBox.value); // 500

위 예시들처럼, 변수 자체에 <T>와 같은 제네릭 선언을 하는 것이 아니라, 제네릭이 적용된 다른 타입(함수 타입, 클래스 타입, 인터페이스 타입)을 변수에 할당할 때 제네릭 타입 인자(string, number 등)를 지정하여 사용.

 

 

 

인터페이스(Interface)와 타입 별칭(Type Alias)의 차이점과 공통점

타입스크립트에서 객체의 모양(shape)을 정의하거나 복잡한 타입을 재사용할 때 인터페이스(interface)와 타입 별칭(type)이라는 두 가지 방법이 있음. 이 둘은 많은 부분에서 유사하게 사용될 수 있지만, 중요한 차이점과 그에 따른 사용 권장 사항이 있다.

공통점

  • 객체의 타입 정의: 가장 기본적인 공통점은 둘 다 객체의 구조(속성과 메서드)를 정의하는 데 사용된다.
    interface PersonInterface {
        name: string;
        age: number;
    }
    
    type PersonType = {
        name: string;
        age: number;
    };
    
    const user1: PersonInterface = { name: "Alice", age: 30 };
    const user2: PersonType = { name: "Bob", age: 25 };
  • 함수의 타입 정의: 함수 시그니처를 정의하는 데 사용.
    interface GreetFunctionInterface {
        (message: string): void;
    }
    
    type GreetFunctionType = (message: string) => void;
    
    const greet: GreetFunctionInterface = (msg) => console.log(msg);
    const sayHello: GreetFunctionType = (msg) => console.log(`Hello, ${msg}`);
    
  • 확장(extends) 또는 인터섹션(&)을 통한 결합: 두 타입 모두 기존 타입을 확장하거나 결합하여 새로운 타입을 만들 수 있다.

    참고: 인터페이스도 &를 사용하여 인터섹션 타입을 만들 수 있고, 타입 별칭도 extends와 비슷한 방식으로 인터섹션(&)을 통해 확장 효과를 낼 수 있다.
    // 인터페이스 확장
    interface Animal {
        name: string;
    }
    interface Dog extends Animal {
        breed: string;
    }
    const myDog: Dog = { name: "Buddy", breed: "Golden" };
    
    // 타입 별칭 인터섹션
    type Draggable = { drag(): void; };
    type Resizable = { resize(): void; };
    type UIElement = Draggable & Resizable; // 두 타입의 모든 속성을 가짐
    const button: UIElement = {
        drag: () => console.log("dragging"),
        resize: () => console.log("resizing")
    };
 
  • 제네릭 사용: 둘 다 제네릭과 함께 사용가능.
    interface BoxInterface<T> {
        value: T;
    }
    type BoxType<T> = {
        value: T;
    };
    
    const stringBox: BoxInterface<string> = { value: "hello" };
    const numberBox: BoxType<number> = { value: 123 };
    

 

차이점

  1. 확장 방식 (Declaring Merging / Declaration Augmentation): 인터페이스는 동일한 이름으로 여러 번 선언하면 자동으로 병합(Merge)됨. 타입 별칭은 동일한 이름을 두 번 정의하면 오류가 발생.
    type MyType = { a: string; };
    // type MyType = { b: number; }; // 오류: 중복 식별자 'MyType'
    // Interface Merging (인터페이스만 가능!)
    interface Car {
        brand: string;
    }
    
    interface Car { // 동일한 이름의 Car 인터페이스를 다시 선언
        model: string;
    }
    
    const myCar: Car = { brand: "Hyundai", model: "Sonata" }; // brand와 model 모두 가짐
    // `myCar`는 `{ brand: string; model: string; }` 타입이 됨.

     

  2. 다양한 타입 정의 능력: 타입 별칭은 인터페이스보다 더 다양한 타입을 정의할 수 있다.
    • 원시 타입, 유니온 타입, 튜플 타입, 리터럴 타입 등: 인터페이스는 주로 객체(object)의 형태를 정의하는 데 사용되지만, 타입 별칭은 객체 외의 다양한 타입 조합을 정의할 수 있다.
      // Type Alias만 가능한 경우
      type ID = string | number; // 유니온 타입
      type Point = [number, number]; // 튜플 타입
      type Direction = "up" | "down" | "left" | "right"; // 리터럴 유니온 타입
      type MyString = string; // 원시 타입의 별칭
      
      // interface MyID = string | number; // 오류: 인터페이스는 유니온 타입을 정의할 수 없음
      // interface MyPoint = [number, number]; // 오류
      
  3. implements 키워드 사용: 클래스가 특정 타입을 따르도록 강제할 때, 인터페이스는 implements 키워드를 통해 클래스가 해당 인터페이스를 구현하도록 강제할 수 있음. 타입 별칭은 이 기능을 직접적으로 제공하지 않음.
    interface Shape {
        getArea(): number;
    }
    
    class Circle implements Shape { // 클래스가 인터페이스를 구현
        constructor(public radius: number) {}
        getArea() {
            return Math.PI * this.radius * this.radius;
        }
    }
    
    // class Square implements Point { /* 오류: 'Point'는 인터페이스가 아님 */ }
    

 

언제 무엇을 사용해야 하는가? 

  • 객체의 형태를 정의하고, 나중에 확장 또는 병합될 가능성이 있다면 interface
    • 특히 라이브러리를 작성하거나, 서드파티 모듈의 타입을 확장해야 할 때 매우 유용
    • 객체 지향적인 개념(implements, extends)과 더 잘 어울림.
    • 명시적인 객체 구조를 선언하는 데 더 적합.
  • 객체 외의 다른 타입(원시 타입, 유니온 타입, 튜플, 리터럴 타입 등)을 정의하거나, 복잡한 타입 조합에 이름을 부여하고 싶다면 type
    • 함수 시그니처를 정의할 때 type이 더 간결할 수 있다.
    • 인터섹션 타입을 통해 여러 타입을 결합할 때 유용

일반적인 규칙:

많은 경우에 둘 중 아무거나 사용해도 무방하며, 개인 또는 팀의 코딩 스타일에 따라 선택할 수 있다. 하지만 위에서 설명한 차이점들을 고려하여 적절한 도구를 선택하는 것이 좋다. 최근에는 유연성 때문에 type 키워드의 사용이 늘어나는 추세

'Next.js + TypeScript' 카테고리의 다른 글

TypeScript 첫 번째  (3) 2025.07.22
Next.js 두 번째  (0) 2025.07.22
Next.js 시작  (0) 2025.07.18