2025. 7. 22. 17:15ㆍNext.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 };
차이점
- 확장 방식 (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; }` 타입이 됨.
- 다양한 타입 정의 능력: 타입 별칭은 인터페이스보다 더 다양한 타입을 정의할 수 있다.
- 원시 타입, 유니온 타입, 튜플 타입, 리터럴 타입 등: 인터페이스는 주로 객체(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]; // 오류
- 원시 타입, 유니온 타입, 튜플 타입, 리터럴 타입 등: 인터페이스는 주로 객체(object)의 형태를 정의하는 데 사용되지만, 타입 별칭은 객체 외의 다양한 타입 조합을 정의할 수 있다.
- 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 |