본문 바로가기

JavaScript

제네릭 타입은 무엇이며 왜 쓰는 걸까?

안녕하세요 :)

 

TypeScript를 사용하다 보면 가장 많이 언급되는 개념 중 하나가 바로 제네릭(Generic)입니다. 제네릭은 다양한 데이터 타입을 유연하게 처리할 수 있도록 도와주는 타입스크립트의 도구인데, 코드 재사용성과 타입 안정성을 동시에 제공한다는 장점이 있습니다. 하지만 처음 접할 때는 다소 복잡하고 어렵게 느껴질 수 있죠.

https://medium.com/nerd-for-tech/understanding-typescript-generics-f71495588d91

그래서 이 글에서는 제네릭의 기본 개념부터, 활용 예시, React와 같은 프레임워크에서의 확장법까지 단계적으로 알아보고자 합니다.

 

제네릭 타입이란?

제네릭(Generic) 타입은 TypeScript에서 다양한 타입의 데이터를 유연하게 처리하기 위한 타입 지정 방법 중 하나입니다. 코드 작성 시 데이터의 타입을 명시하지 않고, 대신 타입을 변수처럼 동적으로 설정할 수 있도록 설계된 기능이죠.

https://blog.openreplay.com/generics-in-typescript/

제네릭은 함수, 클래스, 인터페이스 등에서 "동적으로 결정되는 타입"을 사용하기 위해 도입된 개념입니다. 무슨 말이냐 하면,

  • JavaScript는 동적 타입 언어이고 실행 중에 데이터 타입으로 인한 오류가 발생할 가능성이 큽니다. 해당 문제때문에 컴파일 시점에서 타입 검사를 통해 오류를 방지하는 TypeScript가 큰 인기를 끌고 있죠.
  • 제네릭 타입은 이런 오류 방지를 목적으로 도입되었습니다. 다양한 데이터 타입을 더욱 유연하게 처리할 수 있으면서도 컴파일러(tsc)의 타입 검사를 활용할 수도 있게 합니다.

이렇게만 보면 무슨 말인지 잘 이해가 안 가시죠?

제네릭 타입은 말 그대로 유연하게 다양한 데이터 타입을 처리할 수 있게 해주는 일종/의 도구라고 생각하시면 편합니다. 우리가 보통 함수를 만들 때, 데이터를 처리하려면 어떤 데이터 타입을 받을지 미리 정해야 하잖아요?

 

그런데 제네릭 타입은 이렇게 묻습니다.

"왜 미리 타입을 못 박아야 하죠? 데이터를 받을 때 타입을 정하면 안 되나요?"

 

예를 들어서 볼까요?

숫자를 더하는 함수를 만든다고 해봅시다. 그렇다면 코드는 아래처럼 작성될 것입니다.

function addNumber(a:number, b:number):number {
    return a + b;
}

 

위 코드에서 문제는 "숫자"만 처리할 수 있다는 점입니다. 만약 문자열을 더하고 싶다면, 또 다른 함수를 작성해야 하죠.

function addString(a:string, b:string):string{
    return a + b;
}

왜 두 함수가 똑같은 기능을 하는데 데이터 타입만 다르다고 함수를 계속 만들어야 하나요? 매우 귀찮고 매우 비효율적이지 않나요?

 

이런 상황에서 제네릭이 등장합니다. 제네릭은 데이터를 처리할 때 타입을 나중에 지정할 수 있게 만들어줍니다. 아래처럼요.

function add<T>(a: T, b: T): T {
    return (a + b) as T;
}

여기서 T는 우리가 함수 안에서 사용할 타입을 나타냅니다. T는 말 그대로 "타입 버전의 변수"같은 거죠. 이 함수는 호출할 때 타입을 지정해주면 됩니다.

console.log(add<number>(10,20));
console.log(add<string>("Hi", "everyone"));

이처럼 숫자를 넣으면 숫자 연산을, 문자열을 넣으면 문자열 연산을 처리할 수 있습니다. 제네릭을 사용하면 같은 코드를 여러 타입에서 재사용할 수 있게 되는 것이죠.

 

이제 제네릭이 어떤 역할을 하는 건지 조금은 감이 오시나요? 위에서 보았듯이, 제네릭의 가장 큰 장점은 타입 유연성입니다.

  • 같은 코드로 다양한 데이터 타입을 처리할 수 있으니까 생산성이 높아지고,
  • 컴파일러가 타입 검사를 해 주기 때문에 잘못된 타입을 사용하려고 하면 코딩할 때 바로 오류를 알려줍니다.

조금 더 친근하게 생각해볼까요?

 

제네릭, 조금 더 친근하게 생각해보기

제네릭은 비어 있는 틀이나 템플릿이라고 생각하면 이해하기 쉽습니다. 예시를 들어보겠습니다.

  • 제네릭은 컵 자체고,
  • 데이터 타입은 컵 안에 담을 내용물입니다.

컵은 내용물에 따라 다르게 쓸 수 있습니다. 물, 주스, 초코우유, 커피 뭇엇이든 담을 수 있지만, 어떤 내용물을 담을지는 사용하는 순간에 정하면 됩니다.

 

코드로 보자면,

class Cup <T> {
   private content: T;
   
   constructor(content: T) {
       this.content = content;
   }
   
   getContent(): T {
       return this.content;
   }
}
  • Cup<number>를 만들어서 사용하면, 숫자만 담을 수 있는 컵,
  • Cup<String>를 만들어서 사용하면, 문자열만 담을 수 있는 컵이 되는 거죠.

사용 코드를 볼까요?

const numberCup = new Cup<number>(42);
console.log(numberCup.getContent()); // 42

const stringCup = new Cup<string>("Coffee");
console.log(stringCup.getContent()); // Coffee

 

이제 정말 제네릭 타입이 뭔지 감이 잡히시죠? 그럼 실무에서는 어떤식으로 제네릭을 사용할까요?

 

제네릭은 어떻게 사용될까?

제네릭은 실제 개발에서 유연성과 타입의 안정성을 모두 제공하기 때문에 다양한 상황에서 사용됩니다. 배열 작업부터 API 요청 처리까지, 제네릭을 사용하면 코드 재사용성과 안정성이 크게 향상됩니다. 문제 상황을 가정하고 제네릭으로 해결하는 방법을 예시로 들어보겠습니다.

 

1. API 호출과 응답 타입 관리

API를 통해 데이터를 받아오는데, 요청마다 반환 데이터의 구조가 다를 수 있습니다. 예를 들어,

1. /users API는 사용자 목록을 반환하고,
[{ "id": 1, "name": "Alice" }, { "id": 2, "name": "Bob" }]


2. /products API는 상품 목록을 반환합니다.

[{ "id": 101, "title": "Product A" }, { "id": 102, "title": "Product B" }]


이렇게 서로 다른 데이터 구조를 처리하려면, 이를 타입으로 명확히 정의하고 관리해야 하죠.

 

이런 상황일 때, 제네릭 타입을 사용하면 좋습니다. 왜 그럴까요?

  1. 하나의 API 호출로 다양한 데이터 타입을 처리할 수 있어서 코드 재사용성이 올라가기 때문입니다.
  2. 타입 안정성을 보장하여 잘못된 데이터 처리로 인한 오류를 방지할 수 있기 때문입니다.
  3. 호출 시점에 타입을 지정하므로, 컴파일 시점에 데이터 구조를 보장받을 수 있기 때문입니다.

코드를 보시죠.

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

interface Product {
    id: number;
    title: string;
}

// 제네릭 API 호출 함수
function fetchApi<T>(url: string): Promise<T> {
    return fetch(url).then((res) => res.json());
}

// 사용자 API 호출
fetchApi<User[]>("/users").then((users) => {
    users.forEach((user) => console.log(user.name));
});

// 상품 API 호출
fetchApi<Product[]>("/products").then((products) => {
    products.forEach((product) => console.log(product.title));
});
  • 제네릭 함수 <T> 정의
    • function fetchApi<T>(url: string): Promise<T> 에서 <T>는 함수에서 사용할 타입을 나중에 지정할 수 있도록 합니다.
    • 호출 시, T는 구체적인 타입으로 대체되죠.
  • 타입 안정성 및 유연성
    • 동일한 함수 fetchApi로 다양한 타입의 데이터를 처리할 수 있습니다.
    • 호출 시 타입(T)을 지정하면 컴파일러가 데이터 구조를 추적하여 잘못된 데이터 사용을 방지할 수 있습니다.
  • 코드를 쭉 설명해보자면,
    • fetchApi<User[]>("/users") : 호출 시 T는 User[]로 지정됩니다. 반환 값이 User[] 타입으로 지정되는 거죠.
    • fetchApi<Product[]>("/products") : 호출 시 T는 Product[]로 지정됩니다. 반환 값은 Product[] 타입이 되죠.

 

2. React 컴포넌트 설계

React에서 재사용 가능한 컴포넌트를 설계할 때, 다양한 데이터 타입을 처리해야 할 경우가 많습니다. 예를 들어, 숫자 배열이나 문자열 배열, 또는 객체 배열을 화면에 렌더링하는 리스트 컴포넌트를 작성해야 한다고 가정해봅시다.

문제 상황
-> 
데이터를 리스트로 렌더링하는 컴포넌트를 여러 타입별로 작성하면 중복 코드가 발생합니다.
-> 예를 들어, 숫자 리스트(List<number>), 문자열 리스트(List<string>), 객체 리스트(List<{ id: number; name: string }> ), 이렇게 각 타입을 처리하는 리스트 코드가 중복될 수 있습니다.

 

이런 상황일 때 왜 제네릭이 필요할까요?

  1. 같은 컴포넌트로 다양한 데이터 타입을 처리할 수 있기 때문입니다.
  2. 데이터를 렌더링할 때, 컴파일러가 잘못된 데이터 타입 사용을 방지할 수 있기 때문입니다.
  3. 제네릭을 사용하면 동일한 로직을 여러 타입에서 재사용할 수 있기 때문입니다.

코드를 보시죠.

import React, { ReactNode } from 'react';

// 제네릭 리스트 컴포넌트의 Props 타입 정의
interface ListProps<T> {
    items: T[]; // 렌더링할 데이터 배열, 타입은 제네릭 T
    renderItem: (item: T) => ReactNode; // 각 데이터를 렌더링하는 함수, ReactNode를 반환
}

function List<T>({ items, renderItem }: ListProps<T>): ReactNode {
    return (
        <ul>
            // 데이터 배열을 순회하며 각 항복을 렌더링
            {items.map((item, index) => (
                <li key = {index}> // React에서 리스트 항목에 고유한 key가 필요
                    {renderItem(item)} // renderitem 함수로 데이터를 렌더링
                </li> 
            ))}
        </ul>
    );
}
  • 제네릭 컴포넌트 <T> 정의
    • function List<T>({ items, renderItem }: ListProps<T>): ReactNode 에서 <T>는 컴포넌트에서 처리할 데이터의 타입을 나중에 지정할 수 있도록 합니다. 컴포넌트를 사용할 때, T는 구체적인 타입으로 대체되게 됩니다.
  • 타입 안정성 및 유연성
    • 동일한 컴포넌트 List로 다양한 타입의 데이터를 처리할 수 있게 됩니다.
    • 사용 시, 타입 T를 지정하면 컴파일러가 데이터 구조를 추적하여 잘못된 데이터 사용을 방지할 수 있습니다.
  • 코드를 쭉 설명해보자면
    1. items: T[]
      • items는 데이터 배열로, 리스트에서 렌더링할 데이터를 담고 있습니다.
      • 사용 시 타입 T가 지정이 되며, 컴파일러가 배열 내 데이터 타입을 추적합니다.
    2. renderItem: (items: T) => ReactNode
      • renderItem은 리스트의 각 요소를 렌더링하는 함수입니다.
      • 호출할 때 데이터를 받아, React에서 렌더링 가능한 값인 ReactNode를 반환합니다.
    3. items.map((item, index) => <li key={index}>{renderItem(item)}</li>)
      • 데이터 배열인 items를 순회하면서, 각 데이터를 renderItem으로 렌더링하고 <li>로 감쌉니다.
      • key는 React에서 리스트의 항목을 고유하게 식별하기 위해 사용됩니다.

 

하나의 API 호출 로직으로 다양한 데이터 타입 처리하기 (제네릭 타입을 곁들인)

(이번 섹션은 위 1번 예시에서 이어서 들어가는 섹션이라고 생각해주시면 편합니다.)

 

API 호출 시, 각 엔드포인트마다 반환되는 데이터 구조가 다를 수 있습니다. 예를 들어, 사용자 목록을 반환하는 /users API와 상품 목록을 반환하는 /products API는 서로 다른 데이터 구조를 가질 수 있다는 것이죠.

 

이처럼 데이터 구조가 다른 여러 API를 호출할 때, 매번 별도의 로직을 작성하는 대신, 하나의 API 호출 로직으로 다양한 데이터 타입을 처리할 수 있다면 훨씬 효과적일 것입니다. 

1. 제네릭 API 호출 함수

먼저, 다양한 타입의 데이터를 처리할 수 있는 API 호출 함수를 작성해보겠습니다.

async function fetchData<T>(url: string):Promise<T> {
    const reponse = await fetch(url);
    
    if(!response.ok) {
        throw new Error(`fetch 실패: ${response.status}`);
    }
    
    return response.json(); // 반환 타입은 호출 시점에 지정된 T
}

코드를 보면,

  • <T> 제네릭
    • <T>는 반환 데이터의 타입을 동적으로 지정하기 위한 타입 변수입니다.
    • 호출 시 구체적인 타입으로 대체되므로, 함수는 다양한 데이터 구조를 처리할 수 있습니다.
  • Promise<T>
    • 반환 타입을 Promise로 감싸 비동기 작업을 처리하며, 데이터의 타입은 호출 시 지정된 T에 따라 달라집니다.
    • 혹시 Promise에 대해 궁금하신가요? -> Promise는 비동기 작업의 결과(성공 또는 실패)를 나타내는 객체입니다.
      ->  반환 타입을 Promise로 감싸는 이유는 작업이 완료된 후에 결과를 다루기 위해서입니다.
      -> 이렇게 하면, 비동기 작업이 끝난 시점에 원하는 데이터를 안전하게 처리할 수 있습니다.

 

2. 제네릭으로 API 데이터 호출하기

위에서 작성한 fetchData함수로 다양한 데이터를 가져오는 방법을 살펴보겠습니다. 코드를 보면서 사용자 목록과 상품 목록을 가져오는 경우를 보도록 하겠습니다.

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

interface Product {
    id: number;
    title: string;
}

fetchData<User[]>('/api/users').then((users) => {
    console.log(users) // User 배열
    users.forEach((users) => console.log(user.name));
});

fetchData<Product[]>('/api/products').then((products) => {
    console.log(products);
    products.forEach((products) => console.log(products.title));
});

여기서 핵심 포인트는 '반환 타입을 지정'한다는 점입니다.

 

호출 시 fetchData<User[]>('/api/users') 처럼 제네릭 타입을 명시하여, 반호나 데이터가 User[]임을 컴파일러가 인식합니다. 그 후, 데이터 구조를 명확히 추적하고, 타입 안정성을 보장할 수 있게 됩니다. 보통 잘못된 데이터 구조를 처리하려고 할 경우, 컴파일 시점에서 오류를 알려줍니다.

  • 오류 코드가 TS2332인 에러가 나는데, 특정 타입의 데이터가 기대되는 타입에 할당되지 않을 때 발생하는 오류입니다.

 

3. 커스텀 훅을 활용해서 확장해보기

React와 같은 프레임워크를 사용할 때, API 호출 로직을 반복적으로 작성하지 않도록 커스텀 훅을 만들어 재사용성을 높일 수 있습니다.

 

코드로 보도록 하겠습니다.

import { useState, useEffect } from 'react';

function useFetch<T>(url: string) {
    const [data, setData] = useState<T | null>(null);
    const [error, setError] = useState<Error | null>(null);
    const [loading, setLoading] = useState<boolean>(true);
    
    useEffect(() => {
        setLoading(true);
        
        fetch(url)
            .then((response) => {
                if(!response.ok) {
                    throw new Error(`불러오기 실패!: ${response.status}`);
                }
                return response.json();
            })
            
            .then((data: T) => setData(data))
            .catch((err) => setError(err))
            .finally(() => setLoading(false));
    },[url]);
    
    return { data, error, loading };
}

 

이렇게 짜여진 커스텀 훅은 다음과 같이 사용됩니다.

// 사용자 데이터 가져오기
const { data: users, loading, error } = useFetch<User[]>('/api/users');

// 상품 데이터 가져오기
const { data: products } = useFetch<Product[]>('/api/products');


if (loading) {
  return <p>Loading...</p>;
}

if (error) {
  return <p>Error: {error.message}</p>;
}

코드 구조를 이렇게 가져가면 뭐가 좋을까요?

  • API 호출 로직을 컴포넌트와 분리하여 코드 가독성이 높아지고 유지보수가 아주 용이해집니다. 만약, 데이터 불러오는 부분에서 오류가 난다면, 이 부분만 다시 보면 되겠죠?
  • useFetch 커스텀 훅을 다양한 API 엔드포인트에 재사용할 수 있어 코드 중복을 줄일 수 있습니다.
  • 또한, 데이터 타입을 호출 시점에 지정함으로써, 데이터 구조를 명확히 관리할 수 있습니다.

 

4. 네트워크 상태 및 서버 에러 처리

실제로 네트워크 상태나 서버 에러 등을 처리해야 하는 경우도 많은데요, fetchData 로직을 에러 처리가 포함된 코드로 바꾸면 다음과 같습니다.

async function fetchData<T>(url: string): Promise<T> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`불러오기 실패: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error(`데이터 불러오기 도중 에러: ${url}:`, error);
    throw error; // 에러를 호출자에게 전달
  }
}

 

 

제네릭 간단 정리, 핵심 5가지

https://betterprogramming.pub/extending-typescript-generics-for-additional-type-safety-313f35aca5b3

"제네릭이란?"
  • 타입을 변수처럼 동적으로 설정하여 다양한 데이터 타입을 처리할 수 있는 TypeScript의 기능입니다.
  • 코드 재사용성과 타입의 안정성을 제공합니다.
"왜 사용할까요?"
  • 같은 로직을 여러 타입에서 재사용 가능합니다.
  • 컴파일 시점에 데이터 구조를 검증해 오류를 방지합니다.
  • 함수, 클래스, 인터페이스 등에서 다양한 데이터 타입을 지원합니다.
"사용법"
  • <T>로 타입을 동적으로 지정합니다.
  • 데이터 구조와 동작의 타입을 외부에서 지정해줍니다.
  • 반환 데이터 타입을 명확히 설정하여 안전한 데이터 처리가 가능합니다.
"실제로 이렇게 쓰일 수 있습니다"
  • API 데이터 처리: 다양한 엔드포인트의 반환 타입을 안전하게 관리할 수 있습니다.
  • React 컴포넌트 설계: 여러 타입 데이터를 처리하는 재사용 가능한 컴포넌트를 작성할 수 있습니다.
  • 상태 관리: 데이터 타입에 따라 동적인 상태 처리가 가능합니다.

 

끝내는 말

제네릭은 TypeScript에서 타입 안정성과 유연성을 동시에 제공하는 매우 중요한 기능이죠. 특히, 복잡한 데이터 구조를 다루거나, 다양한 상황에 맞는 재사용 가능한 코드를 작성해야 할 때 그 진가가 발휘됩니다.

 

이번 글에서는 제네릭의 개념부터 활용 사례까지 알아봤습니다. 제네릭을 이해하고 활용하게 된다면, 더욱 안정적이고 유지보수하기 쉬운 코드를 작성할 수 있을 것입니다 ㅎ.ㅎ

 

읽어주셔서 감사합니다 :)