PromleeBlog
sitemap
aboutMe

posting thumbnail
TypeScript와 React (제네릭, 커스텀 훅, 폼 타입) - React, 알고 쓰자 14일차
TypeScript with React (Generics, Custom Hooks, Form Types) - React Explained Day 14

📅

🚀

들어가기 전에 🔗

마지막 주제는 우리가 작성하는 React 코드를 더욱 견고하고 예측 가능하게 만들어주는 강력한 도구,
TypeScript
를 React 프로젝트에서 어떻게 더 잘 활용할 수 있는지에 대한 유용한 팁들을 나누려고 합니다.
TypeScript는 JavaScript에 정적 타입을 더함으로써, 코드를 작성하는 단계(컴파일 타임)에서 미리 오류를 발견하고, 코드 자동 완성과 리팩토링을 용이하게 하여 개발 생산성을 크게 향상시켜 줍니다.
React와 TypeScript를 함께 사용하는 것은 이제 매우 일반적인 선택이 되었죠.
하지만 단순히 타입을 지정하는 것을 넘어, 제네릭(Generics)을 활용하여 재사용 가능한 컴포넌트를 만들거나, 커스텀 훅의 타입을 효과적으로 추론하게 하거나, 폼 처리 시 타입 안전성을 높이는 등 TypeScript의 장점을 극대화하는 방법들이 있습니다.
오늘은 바로 이런 고급 활용 팁들을 중심으로, 여러분의 TypeScript + React 개발 경험을 한 단계 끌어올리는 데 도움을 드리고자 합니다.

🚀

제네릭 기반 컴포넌트 타이핑 🔗

컴포넌트를 만들다 보면, 다양한 타입의 데이터를 처리해야 하는 경우가 있습니다.
예를 들어, 숫자 배열을 받아서 목록을 보여주는 컴포넌트와 문자열 배열을 받아서 목록을 보여주는 컴포넌트를 따로 만들어야 할까요?
이럴 때 *제네릭(Generics)*을 사용하면, 타입을 마치 변수처럼 사용하여 다양한 타입을 지원하는 재사용 가능한 컴포넌트를 만들 수 있습니다.

제네릭이란 무엇일까요? 🔗

제네릭은 타입을 정의할 때 결정하는 것이 아니라, 함수나 컴포넌트를
사용하는 시점
에 타입을 결정할 수 있도록 하는 기능입니다.
마치 함수에 매개변수를 전달하듯, 타입 변수(보통 T, U, K 등으로 표시)를 사용하여 타입을 전달받을 수 있습니다.
// 제네릭 함수 예시: 어떤 타입의 값이든 받아서 그대로 반환
function identity<T>(arg: T): T {
  return arg;
}
 
let output1 = identity<string>("hello"); // T는 string 타입으로 결정됨
let output2 = identity<number>(123);     // T는 number 타입으로 결정됨
let output3 = identity("auto");         // 타입 인자 생략 시 추론됨 (string)

React 컴포넌트에 제네릭 적용하기 🔗

이제 이 제네릭 개념을 React 함수형 컴포넌트에 적용해 봅시다.
다양한 타입의 아이템 배열을 받아 목록으로 렌더링하는 List 컴포넌트를 만들어 보겠습니다.
import React from 'react';
 
// 제네릭 Props 타입 정의
// T라는 타입 변수를 받음 (사용 시 결정될 아이템의 타입)
interface ListProps<T> {
  items: T[]; // items 배열은 T 타입의 요소들로 구성
  renderItem: (item: T, index: number) => React.ReactNode; // 각 아이템을 렌더링하는 함수
  // keyExtractor?: (item: T) => string | number; // key 추출 함수 (선택적)
}
 
// 제네릭 함수형 컴포넌트 정의
// <T,> 처럼 쉼표를 찍는 이유는 JSX 태그와 구분하기 위함 (또는 <T extends {}> 사용)
function List<T>({ items, renderItem }: ListProps<T>): React.ReactElement {
  return (
    <ul>
      {items.map((item, index) => (
        // key prop은 실제 사용 시 renderItem 내부나 여기서 적절히 제공해야 함
        // 예: <li key={keyExtractor ? keyExtractor(item) : index}>
        <React.Fragment key={index}> {/* key는 실제 상황에 맞게 수정 필요 */}
          {renderItem(item, index)}
        </React.Fragment>
      ))}
    </ul>
  );
}
 
// --- 컴포넌트 사용 예시 ---
 
interface User { id: number; name: string; }
const users: User[] = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' } ];
 
interface Product { sku: string; title: string; price: number; }
const products: Product[] = [
  { sku: 'P001', title: '노트북', price: 1500000 },
  { sku: 'P002', title: '키보드', price: 80000 },
];
 
function App() {
  return (
    <div>
      <h2>사용자 목록</h2>
      {/* T를 User 타입으로 지정하여 사용 */}
      <List<User>
        items={users}
        renderItem={(user) => <li key={user.id}>{user.name}</li>}
      />
 
      <h2>상품 목록</h2>
      {/* T를 Product 타입으로 지정하여 사용 */}
      <List<Product>
        items={products}
        renderItem={(product) => (
          <li key={product.sku}>
            {product.title} - {product.price.toLocaleString()}
          </li>
        )}
      />
    </div>
  );
}
 
export default App; // List 컴포넌트 export 필요 시 추가
List 컴포넌트는 제네릭 타입 T를 사용하여 어떤 타입의 배열(items: T[])이든 처리할 수 있습니다.
renderItem prop 역시 T 타입을 인자로 받으므로, 각 타입에 맞는 렌더링 로직을 외부에서 주입할 수 있습니다.
이를 통해 User 목록과 Product 목록을 별도의 컴포넌트 없이 하나의 List 컴포넌트로 처리할 수 있게 되어 코드 재사용성이 크게 향상됩니다.
과거에는 React.FC<ListProps<T>> 와 같이 React.FC를 사용하는 경우도 많았지만, 최근에는 React.FC 사용 시 몇 가지 단점(children prop 암시적 포함 등) 때문에 함수 시그니처에 직접 타입을 명시하는 방식(위 예시처럼)이 더 선호되는 경향이 있습니다.
제네릭 컴포넌트 개념도
제네릭 컴포넌트 개념도

🚀

Custom Hook 타입 추론 패턴 🔗

9일차에 배웠던 커스텀 훅은 상태 관련 로직을 재사용 가능하게 만드는 강력한 방법입니다.
TypeScript와 함께 사용하면 커스텀 훅의 입력(매개변수)과 출력(반환 값)에 타입을 명확히 지정하여 안정성을 높일 수 있습니다.
더 나아가, TypeScript의
타입 추론(Type Inference)
기능을 잘 활용하면 더욱 편리하고 유연한 커스텀 훅을 만들 수 있습니다.

기본 타입 정의 🔗

먼저 커스텀 훅의 매개변수와 반환 값 타입을 명시적으로 정의하는 기본 방법입니다.
import { useState } from 'react';
 
// 커스텀 훅: 입력 필드 값 관리
function useInput(initialValue: string): [string, (e: React.ChangeEvent<HTMLInputElement>) => void, () => void] {
  const [value, setValue] = useState(initialValue);
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  };
  const reset = () => {
    setValue(initialValue);
  };
  // 반환 타입을 튜플(Tuple) 형태로 명시
  return [value, handleChange, reset];
}
 
// 컴포넌트에서 사용
function MyForm() {
  const [name, handleNameChange, resetName] = useInput('');
  // name은 string, handleNameChange는 함수, resetName은 함수 타입으로 자동 추론됨
  // ...
}
반환 타입을 명시적으로 적어주면 훅의 사용법이 명확해지는 장점이 있습니다.

반환 값 타입 추론 활용 🔗

TypeScript는 함수 본문에서 반환되는 값을 분석하여 자동으로 반환 타입을 추론하는 능력이 뛰어납니다.
반환 타입이 복잡하지 않거나 명확하다면, 굳이 명시적으로 타입을 적지 않고 타입 추론에 맡기는 것이 코드를 더 간결하게 만들 수 있습니다.
(단, 라이브러리로 만들거나 복잡한 경우에는 명시하는 것이 좋을 수 있습니다.)
// 반환 타입 명시 생략 (TypeScript가 추론하도록 함)
function useToggle(initialValue: boolean = false) { // 기본값 설정 가능
  const [value, setValue] = useState(initialValue);
  const toggle = () => setValue(prev => !prev);
  // TypeScript는 반환 값을 보고 [boolean, () => void] 타입으로 추론함
  return [value, toggle] as const; // 'as const' 추가: 튜플 타입을 더 정확하게 추론하고 readonly로 만듦
}
 
function ToggleComponent() {
  const [isOn, toggleIsOn] = useToggle(false);
  // isOn은 boolean, toggleIsOn은 () => void 타입으로 잘 추론됨
  // ...
}
as const를 사용하면 반환되는 배열을 읽기 전용 튜플(readonly tuple) 타입으로 더 정확하게 추론하여, 배열 구조 분해 할당 시 각 요소의 타입을 고정시키는 데 도움이 됩니다.

제네릭 활용: 더 유연한 커스텀 훅 🔗

커스텀 훅에서도 제네릭을 활용하면 다양한 타입을 처리하는 유연한 훅을 만들 수 있습니다.
예를 들어, API 데이터를 가져오는 useFetch 훅을 만든다고 가정해 봅시다. 응답 데이터의 타입은 API마다 다를 것입니다.
import React, { useState, useEffect } from 'react';
import axios from 'axios';
 
// 제네릭 커스텀 훅 정의
// T는 예상되는 응답 데이터의 타입을 나타냄
function useFetch<T>(url: string | null) {
  const [data, setData] = useState<T | null>(null); // 데이터 타입 T
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
 
  useEffect(() => {
    if (!url) { // url이 없으면 요청하지 않음
      setLoading(false);
      return;
    }
 
    const controller = new AbortController();
    setLoading(true);
    setData(null); // 이전 데이터 초기화
    setError(null);
 
    axios.get<T>(url, { signal: controller.signal }) // 응답 데이터 타입을 T로 기대
      .then(response => {
        setData(response.data);
      })
      .catch(err => {
        if (!axios.isCancel(err)) {
          setError(err);
        }
      })
      .finally(() => {
        setLoading(false);
      });
 
    return () => {
      controller.abort();
    };
  }, [url]); // url이 변경될 때마다 재요청
 
  // 반환 값의 타입은 TypeScript가 { data: T | null, loading: boolean, error: Error | null } 로 추론
  return { data, loading, error };
}
 
// --- 컴포넌트에서 사용 예시 ---
interface User { id: number; name: string; }
function UserComponent({ userId }: { userId: number }) {
  // useFetch 사용 시 제네릭 타입(User) 지정
  const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
 
  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>오류: {error.message}</div>;
  // 여기서 user 타입은 User | null 로 정확하게 추론됨
  return <h1>{user?.name}</h1>;
}
 
interface Product { sku: string; title: string; }
function ProductComponent({ productId }: { productId: string }) {
  // Product 타입 지정
  const { data: product, loading, error } = useFetch<Product>(`/api/products/${productId}`);
  // ... product 타입 사용 ...
}
이렇게 제네릭을 사용하면 useFetch 훅 하나로 다양한 타입의 API 응답을 처리하고, 사용하는 쪽에서는 각 데이터 타입에 맞는 타입 안전성을 누릴 수 있습니다.
커스텀 훅 타입 추론 과정
커스텀 훅 타입 추론 과정

🚀

Form 타입 안전성 강화 🔗

React에서 폼(Form)을 다룰 때 입력 값의 유효성을 검사하고, 폼 데이터의 타입을 안전하게 관리하는 것은 매우 중요하면서도 번거로운 작업입니다.
특히 사용자가 입력하는 값은 기본적으로 문자열인 경우가 많고, 이를 숫자나 다른 타입으로 변환하거나 특정 형식(이메일, URL 등)을 검증해야 할 때 타입 관련 오류가 발생하기 쉽습니다.
이럴 때 스키마 기반 유효성 검사 라이브러리인
Zod
Yup
을 사용하면 타입 안전성과 유효성 검사를 동시에 효과적으로 처리할 수 있습니다.

Zod/Yup 소개 🔗

React Hook Form과 함께 사용하기 - Zod 🔗

폼 관리 라이브러리인
React Hook Form
과 함께 사용하면 더욱 강력한 시너지를 낼 수 있습니다.
@hookform/resolvers 패키지를 통해 Zod나 Yup 스키마를 React Hook Form의 유효성 검사 규칙으로 쉽게 통합할 수 있습니다.
➡️

1. 필요한 라이브러리 설치 🔗

npm install react-hook-form zod @hookform/resolvers
 # 또는
yarn add react-hook-form zod @hookform/resolvers
➡️

2. 스키마 정의 및 타입 추론 🔗

import { z } from 'zod'; // zod 임포트
 
// 폼 데이터 스키마 정의 (유효성 규칙 포함)
const SignUpSchema = z.object({
  email: z.string().min(1, "이메일은 필수 항목입니다.").email("유효한 이메일 주소를 입력해주세요."),
  password: z.string().min(8, "비밀번호는 최소 8자 이상이어야 합니다."),
  confirmPassword: z.string().min(1, "비밀번호 확인은 필수 항목입니다."),
  age: z.number().min(18, "만 18세 이상만 가입 가능합니다.").optional(), // 선택적 숫자 필드
}).refine(data => data.password === data.confirmPassword, { // 두 필드 비교 검증
  message: "비밀번호가 일치하지 않습니다.",
  path: ["confirmPassword"], // 오류 메시지를 표시할 필드 지정
});
 
// 스키마로부터 TypeScript 타입 자동 추론!
type SignUpFormValues = z.infer<typeof SignUpSchema>;
Zod 스키마를 정의하면, z.infer를 통해 별도의 interfacetype 정의 없이도 해당 스키마 구조를 따르는 TypeScript 타입을 얻을 수 있습니다.
➡️

3. React Hook Form에 적용 🔗

import React from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; // zod 리졸버 임포트
import { z } from 'zod'; // zod 스키마 정의는 다른 파일에 있어도 됨
 
// 스키마 및 타입 정의 (다른 파일에서 가져왔다고 가정)
const SignUpSchema = z.object({ /* ... 스키마 정의 ... */ });
type SignUpFormValues = z.infer<typeof SignUpSchema>;
 
function SignUpForm() {
  const {
    register,
    handleSubmit,
    formState: { errors } // errors 객체를 통해 유효성 검사 오류 확인 가능
  } = useForm<SignUpFormValues>({ // useForm에 추론된 타입 전달
    resolver: zodResolver(SignUpSchema), // zod 스키마를 resolver로 사용
    // defaultValues: { email: '', password: '', confirmPassword: '', age: undefined } // 기본값 설정
  });
 
  // 폼 제출 시 실행될 핸들러 (타입 안전성 보장!)
  // data 매개변수는 SignUpFormValues 타입으로 자동 추론됨
  const onSubmit: SubmitHandler<SignUpFormValues> = (data) => {
    console.log(data);
    // data.email, data.password 등 타입이 보장된 상태로 사용 가능
    // data.age는 number | undefined 타입
  };
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">이메일</label>
        <input id="email" type="email" {...register('email')} />
        {errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
      </div>
 
      <div>
        <label htmlFor="password">비밀번호</label>
        <input id="password" type="password" {...register('password')} />
        {errors.password && <p style={{ color: 'red' }}>{errors.password.message}</p>}
      </div>
 
      <div>
        <label htmlFor="confirmPassword">비밀번호 확인</label>
        <input id="confirmPassword" type="password" {...register('confirmPassword')} />
        {errors.confirmPassword && <p style={{ color: 'red' }}>{errors.confirmPassword.message}</p>}
      </div>
 
      <div>
        <label htmlFor="age">나이 (선택)</label>
        {/* register의 두 번째 인자로 valueAsNumber: true 전달 시 숫자로 변환 시도 */}
        <input id="age" type="number" {...register('age', { valueAsNumber: true })} />
        {errors.age && <p style={{ color: 'red' }}>{errors.age.message}</p>}
      </div>
 
      <button type="submit">가입하기</button>
    </form>
  );
}
이렇게 Zod(또는 Yup)와 React Hook Form을 함께 사용하면,
폼 제출 흐름
폼 제출 흐름

🚀

결론 🔗

오늘은 TypeScript와 React를 함께 사용할 때 개발 경험과 코드 품질을 향상시키는 몇 가지 유용한 팁과 패턴을 살펴보았습니다.
TypeScript는 단순히 오류를 방지하는 것을 넘어, 코드의 의도를 명확히 하고, 리팩토링을 용이하게 하며, 더 나은 설계를 유도하는 강력한 도구입니다.
React와 함께 TypeScript의 다양한 기능들을 적극적으로 활용하여 더 견고하고 유지보수하기 좋은 애플리케이션을 만들어나가시길 바랍니다.
👨‍💻
이것으로 "React, 알고 쓰자" 시리즈의 마지막 주제까지 모두 다루었습니다. 그동안 함께해주셔서 감사합니다! 이 시리즈가 여러분의 React 개발 여정에 든든한 길잡이가 되었기를 바랍니다.

참고 🔗