TypeScript: 제네릭(Generic) 기본

ε( ε ˙³˙)з ○º·2025년 6월 21일
post-thumbnail

Intro

함수나 컴포넌트를 만들다 보면, 타입이 고정되지 않고 입력값에 따라 더 유연하게 타입을 다뤄야 하는 상황이 있어요. 이럴 때 제네릭을 사용하면 타입의 안정성까지 챙기면서 깔끔하게 해결할 수 있습니다.

TypeScript 제네릭의 기본 개념과 실무에서 어떻게 활용할 수 있는지 정리해보았습니다.


◻️ 제네릭(Generic)이란?

TypeScript에서 제네릭(Generic)은 타입을 변수처럼 사용할 수 있는 기능으로 컴포넌트(함수, 클래스, 인터페이스 등)를 선언할 때 타입을 고정하지 않고 실제 사용할 때 타입을 외부에서 주입받아 다양한 타입을 처리할 수 있도록 만든다.

function identity<T>(value: T): T {
  return value;
}

T는 타입 매개변수 (Type Parameter)로 이 함수는 호출하는 시점에 T가 어떤 타입인지 결정된다.

예시: form 데이터 타입이 매번 다를 때


◻️ 제네릭은 왜 필요할까?

타입을 지정하기가 어려울 때 보통 any를 사용하게되는데 any는 타입 검사를 우회해버리기 때문에 타입스크립트를 사용하는 이점이 사라진다. 제네릭은 타입을 동적으로 받을 수 있으면서도 타입 검사를 유지할 수 있다.

위 코드와 같이 any를 사용하게되면 타입스크립트는 타입 검사를 하지 않아 실제로 number를 넘겼더라도 타입스크립트는 이를 체크하지 않아서 toUpperCase() 같은 잘못된 메서드를 호출해도 타입 에러가 발생하지 않는다.

하지만 런타임에서는 TypeError: 100.toUpperCase is not a function 에러가 발생할 수 있다.

제네릭을 사용했을 때의 차이점

제네릭을 사용하면 타입스크립트가 타입 추론을 정확하게 수행하고, 잘못된 메서드 호출을 컴파일 단계에서 바로 잡을 수 있다.

  • T는 호출 시점에 number로 추론이 되어 result[0]의 타입이 명확하게 number로 추론되기 때문에 toUpperCase() 호출 시 타입스크립트가 바로 에러를 잡아준다.

◻️ 제네릭 사용 패턴 예제

함수에서 제네릭 활용

// 결제 정보와 배송 정보를 병합하는 함수
function mergePaymentAndShipping<T, U>(payment: T, shipping: U): T & U {
  return { ...payment, ...shipping };
}

const paymentInfo = { paymentId: 'PAY123', amount: 120000, method: 'CreditCard' };
const shippingInfo = { trackingNumber: 'TRK456', courier: '택배사' };

const orderDetail = mergePaymentAndShipping(paymentInfo, shippingInfo);

여기서 T 첫 번째 인자(payment)의 타입, U두 번째 인자(shipping)의 타입을 의미한다. 제네릭은 필요하면 여러 개의 타입 매개변수를 동시에 받을 수 있다.

이 함수는 결제 데이터와 배송 데이터처럼 서로 다른 구조의 타입을 병합할 때 유용하다.

결제 정보와 배송 정보를 모두 포함한 하나의 새로운 객체를 반환하며,
타입스크립트는 반환 객체가 결제 타입 + 배송 타입의 속성을 모두 가진다는 것을 정확히 추론한다.

T & U의 의미
반환 타입 T & U는 교차 타입(Intersection Type) 이다.

교차 타입이란?
두 타입을 모두 만족하는 새로운 타입을 생성하는 것이다.
쉽게 말하면 결제 타입 + 배송 타입이 합쳐진 확장된 타입을 의미한다.

결제 정보와 배송 정보를 모두 포함한 하나의 새로운 객체를 반환하며 타입스크립트는 반환 객체가 결제 타입 + 배송 타입의 속성을 모두 가진다는 것을 정확히 추론한다.

인터페이스에서 제네릭 (API 응답 데이터)

  • 제네릭을 사용하면 API 응답의 공통 구조를 하나의 타입으로 재사용할 수 있고 각 API에서 data에 들어오는 실제 타입만 유연하게 정의할 수 있다.

🤔 제네릭 API 응답 패턴을 주로 사용하는 이유

1. 대부분 API 응답 포맷이 동일하다.

{
  "status": 200,
  "data": { ... },
  "message": "Success"
}

실무에서 대부분의 API는 위처럼 공통 응답 포맷을 사용하고, 실제로 바뀌는 부분은 data 타입뿐이다. 이럴 때 제네릭을 사용하면 공통 응답 구조는 한 번만 선언하고, data 타입만 유연하게 바꿔서 재사용할 수 있다.

2. 중복 선언을 줄일 수 있다
API마다 응답 타입을 일일이 선언하면 타입 파일이 불필요하게 늘어나고, 추후 응답 구조가 바뀌었을 때 중복 수정이 많이 발생해 유지보수 부담이 커진다.

제네릭을 사용하면 공통 스키마를 단일화하고, 각 API별로 바뀌는 data만 타입 주입으로 관리하면 된다.

const orderResponse: ApiResponse<OrderDetail> = { status: 200, data: { ... } };
const shippingResponse: ApiResponse<ShippingData> = { status: 200, data: { ... } };

3. API 통신 유틸과 잘 맞는다
실무에서는 보통 API를 여러 군데에서 반복해서 호출하지 않기 위해
공통 fetch 함수를 따로 만들어 재사용하는 경우가 많다.

// 공통 fetch 함수 예시
async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
  const res = await fetch(url);
  return res.json();
}

// 결제 응답 데이터 타입
type PaymentData = {
  paymentId: string;
  paymentStatus: 'SUCCESS' | 'FAILURE';
};

// 결제 API를 호출
const res = await fetchData<PaymentData>('/api/payment');

fetchData<T>는 제네릭 함수로 fetchData<PaymentData>는 "이번 호출에서는 결제 데이터 타입이 올 거야!" 라고 타입스크립트에게 명확하게 알려주는 것이다.

이렇게 제네릭 타입을 주입하면, 타입스크립트가 res.data를 정확히 PaymentData 타입으로 추론해서 자동완성 지원, 타입 안전성, 잘못된 속성 접근 방지까지 모두 보장할 수 있다.

만약 API마다 status, message 등 응답 구조 자체가 완전히 다르거나, 레거시 API처럼 공통 스키마가 아예 없는 경우에는
이런 제네릭 공통 패턴 대신, 각 API에 맞는 개별 타입 선언이 더 안전할 수도 있다. 😢


제네릭 사용할 때 참고할 점

데이터 타입을 명확하게 정의할 것

공통 응답 스키마는 제네릭으로 추상화해도, 각 API별로 data 타입은 명확히 선언해줘야 한다. 만약 data 타입을 any로 넘기거나 대충 작성하면 타입스크립트의 장점을 제대로 못 쓴다.

// ❌
const res = await fetchData<any>('/api/payment'); // 타입 추론 X

// ✅
const res = await fetchData<PaymentData>('/api/payment'); // 타입 추론 O

2. 가능한 타입을 명시적으로 주입할 것

타입스크립트가 때로는 API 응답을 추론할 수도 있지만, API 문서가 불완전할 때 또는 백엔드 API 응답 스펙이 자주 바뀌는 경우 타입을 명시적으로 주입하는 것이 훨씬 더 안전하다.

// 안전하지 않은 예시 (타입 명시 안 함)
const res = await fetchData('/api/payment');

// 안전한 예시 (타입 직접 주입)
const res = await fetchData<PaymentData>('/api/payment');

잘못된 데이터 접근을 컴파일 타임에서 빠르게 잡아낼 수 있다.

또는 fetchData 함수는 항상 Promise<ApiResponse<T>>처럼
타입을 반환까지 강제하는 패턴으로 설계하는 것도 안정성이 높아진다.

async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
  const res = await fetch(url);
  return res.json();
}

3. 제네릭 네이밍을 의미 있게 작성

T, U 같은 추상적인 네이밍을 쓰기보다는, ResponseData, PaymentType처럼 실제 데이터의 의미를 담은 이름이 가독성에 좋다.

async function fetchData<ResponseData>(url: string): Promise<ApiResponse<ResponseData>> { ... }

💭 마무리하며

상황에 맞게 제네릭을 적절히 사용하면 공통 패턴을 잘 활용할 수 있어 API 타입 선언이 훨씬 깔끔해지고 협업에서도 안정성 있는 코드로 이어질 수 있다. 💪🏻


📚 Reference


이 글은 공식 문서를 기반으로 내용을 정리한 포스팅입니다.
혹시 내용 중 틀린 부분이나 보완할 부분이 있다면 댓글로 남겨주시면 감사하겠습니다. 🙏🏻

profile
차곡차곡 쌓아두기 💭

0개의 댓글