기존 리액트 프로젝트에 타입스크립트 점진적으로 도입하기

현대의 웹 개발에서 자바스크립트는 여전히 핵심적인 언어이지만, 프로젝트의 규모가 커지고 복잡성이 증가함에 따라 동적 타입 언어의 한계가 명확히 드러나고 있습니다. 예기치 않은 `undefined is not a function` 오류, API 응답 데이터 구조 변경으로 인한 런타임 에러, 그리고 동료 개발자가 만든 함수의 인자를 파악하기 위해 코드를 전부 읽어야 하는 비효율 등은 우리 모두가 겪어본 문제입니다. 바로 이 지점에서 타입스크립트(TypeScript)는 단순한 대안을 넘어, 대규모 애플리케이션의 안정성과 유지보수성을 위한 필수적인 해법으로 자리 잡았습니다.

특히 컴포넌트 기반 아키텍처의 대표주자인 리액트(React)와 크로스플랫폼의 강자 리액트 네이티브(React Native) 생태계에서 타입스크립트의 도입은 더 이상 선택이 아닌 표준으로 여겨지고 있습니다. 하지만 이미 수만 라인의 자바스크립트 코드로 운영되고 있는 거대한 프로젝트를 마주한 개발자에게 '타입스크립트로 전환하자'는 말은 '모든 것을 처음부터 다시 만들자'는 말처럼 막막하게 들릴 수 있습니다. 비즈니스는 멈출 수 없고, 전체 마이그레이션은 엄청난 비용과 위험을 동반하기 때문입니다.

이 글은 바로 그런 고민을 가진 개발자들을 위한 실용적인 가이드입니다. 우리는 '전면 개편'이라는 이상 대신 '점진적 도입(Incremental Adoption)'이라는 현실적인 전략에 초점을 맞출 것입니다. 새로운 프로젝트를 타입스크립트로 시작하는 가장 현대적인 방법부터 시작하여, 이 글의 핵심인 기존 자바스크립트 리액트 프로젝트에 타입스크립트를 안전하고 효율적으로 통합해나가는 구체적인 단계와 고급 전략까지, 풀스택 개발자의 시각에서 깊이 있게 파헤쳐 보겠습니다. 이 여정이 끝날 때쯤, 여러분은 타입스크립트라는 강력한 무기를 기존 프로젝트에 장착할 자신감을 얻게 될 것입니다.

이 가이드에서 다룰 내용:
  • 왜 자바스크립트에서 타입스크립트로 전환해야 하는가? (비용과 이점 분석)
  • 최신 도구(CRA, React Native CLI)를 이용한 타입스크립트 프로젝트 완벽 설정
  • 핵심 설정 파일 tsconfig.json의 주요 옵션 심층 분석
  • 안전하고 효율적인 점진적 마이그레이션 전략 (단계별 가이드)
  • 타입스크립트를 활용한 리액트 고급 패턴 (제네릭 훅, Context API 타이핑 등)

자바스크립트 vs 타입스크립트: 무엇을 위한 전환인가?

타입스크립트 도입을 단순히 '최신 기술 스택 따라가기' 정도로 생각해서는 안 됩니다. 이는 개발 문화와 프로세스 전반에 긍정적인 영향을 미치는 전략적 결정입니다. 도입에 앞서 우리가 무엇을 얻게 되는지 명확히 이해하는 것은 성공적인 전환의 첫걸음입니다. 순수 자바스크립트(ES6+)와 타입스크립트 개발 환경을 여러 관점에서 비교해 보겠습니다.

평가 항목 순수 자바스크립트 (JavaScript) 타입스크립트 (TypeScript) 주요 이점
에러 발견 시점 런타임 (사용자가 앱을 사용하는 도중) 컴파일 타임 (개발자가 코드를 작성하는 도중) 치명적인 버그 사전 차단: 런타임 에러는 비즈니스 손실로 직결될 수 있습니다. 타입스크립트는 빌드 단계에서부터 수많은 잠재적 오류를 잡아내어 프로덕션 환경의 안정성을 극대화합니다.
개발자 경험 (DX) 동적 타이핑으로 초기 진입은 쉬우나, 코드 자동 완성과 리팩토링 기능이 제한적입니다. 함수의 인자나 반환값을 알려면 내부 구현을 봐야 합니다. 강력한 코드 자동 완성, 타입 기반의 리팩토링, 실시간 타입 에러 피드백을 제공합니다. VS Code와 같은 에디터의 지원을 100% 활용할 수 있습니다. 생산성 향상: 개발자는 더 이상 타입을 추측하거나 코드를 뒤지는 데 시간을 낭비하지 않아도 됩니다. 타입 자체가 명확한 가이드가 되어 개발 속도를 높여줍니다.
코드 가독성 및 문서화 JSDoc 등을 사용해 별도의 문서화 노력이 필요합니다. 주석이 실제 코드와 동기화되지 않는 경우가 많습니다. 타입 정의 자체가 가장 정확하고 최신 상태를 유지하는 문서 역할을 합니다. interfacetype 선언만 봐도 데이터 구조를 즉시 파악할 수 있습니다. 자체 문서화(Self-documenting): 코드를 읽는 것이 곧 문서를 읽는 경험이 됩니다. 이는 신규 팀원 온보딩 및 코드 인수인계 비용을 획기적으로 줄여줍니다.
팀 협업 API 데이터 구조 변경이나 공용 컴포넌트의 props 변경 시, 이를 사용하는 모든 부분을 사람이 직접 찾아 수정해야 하므로 실수가 발생하기 쉽습니다. 타입 정의만 변경하면, 해당 타입을 사용하는 모든 코드에서 컴파일 에러가 발생합니다. 수정해야 할 부분이 명확하게 드러나 협업 효율과 안정성이 크게 증가합니다. 안전한 계약 기반 개발: 타입은 컴포넌트와 모듈 간의 'API 계약' 역할을 합니다. 이 계약을 위반하는 코드는 즉시 감지되므로 대규모 팀에서도 일관성을 유지하기 용이합니다.
리팩토링 및 유지보수 함수 이름 변경, 매개변수 순서 변경과 같은 리팩토링은 매우 위험합니다. 관련 코드를 전부 찾아서 수정했는지 확신하기 어렵고, 테스트 코드에 크게 의존해야 합니다. 타입 시스템의 지원으로 변수명, 함수 시그니처 변경 등 대규모 리팩토링을 자신감 있게 수행할 수 있습니다. IDE가 모든 참조를 추적하여 안전하게 변경해줍니다. 장기적인 유지보수 비용 감소: 프로젝트가 복잡해질수록 타입스크립트의 가치는 기하급수적으로 증가합니다. 코드 베이스를 건강하게 유지하고 변화에 유연하게 대응할 수 있습니다.

물론 타입스크립트 도입에는 초기 학습 비용과 타입을 정의하는 추가적인 노력이 필요합니다. 하지만 이는 단기적인 '비용'이라기보다는 장기적인 안정성과 생산성을 위한 '투자'에 가깝습니다. 특히 복잡한 비즈니스 로직을 다루거나 여러 개발자가 협업하는 프로젝트라면, 이 투자는 반드시 높은 수익률로 돌아올 것입니다.

1. 새 출발: 최신 리액트 & 리액트 네이티브 프로젝트 설정

기존 프로젝트에 타입스크립트를 도입하기에 앞서, 가장 이상적인 타입스크립트 프로젝트가 어떻게 구성되는지 이해하는 것은 중요합니다. 이는 우리의 목표점을 명확히 해주고, 마이그레이션 과정에서 참고할 좋은 기준이 됩니다. 다행히 현재는 Create React App(CRA)React Native CLI 모두 공식적으로 타입스크립트 템플릿을 지원하여, 복잡한 설정 없이 몇 분 만에 완벽한 개발 환경을 구축할 수 있습니다.

1.1. 리액트(React) 웹 프로젝트 생성

과거에는 `react-scripts-ts`와 같은 커뮤니티 기반의 해결책을 사용했지만, 이제는 그럴 필요가 없습니다. CRA가 모든 것을 처리해줍니다. 터미널을 열고 다음 명령어를 실행하세요.


# npx는 별도 설치 없이 항상 최신 버전의 create-react-app을 사용하도록 보장합니다.
npx create-react-app my-react-ts-app --template typescript

이 한 줄의 명령어로 타입스크립트 컴파일러, 웹팩 및 바벨 설정, Jest를 이용한 테스트 환경, 그리고 리액트 관련 타입 정의(@types/react)까지 모두 자동으로 구성됩니다. 프로젝트 생성이 완료되면, 생성된 디렉토리로 이동하여 개발 서버를 실행해봅시다.


cd my-react-ts-app
npm start

브라우저에 `http://localhost:3000`이 열리면서 타입스크립트로 작성된 리액트 앱이 여러분을 맞이할 것입니다. 이제 프로젝트의 핵심 파일들을 살펴보며 그 구조를 깊이 이해해 보겠습니다.

1.2. 리액트 네이티브(React Native) 프로젝트 생성

리액트 네이티브 역시 공식 CLI를 통해 타입스크립트 프로젝트 생성을 완벽하게 지원합니다. React Native 개발 환경(Node, Watchman, Xcode/Android Studio 등)이 구축되어 있다는 가정 하에, 다음 명령어를 실행합니다.


npx react-native init MyRNTsApp --template react-native-template-typescript

웹 프로젝트와 마찬가지로, 이 명령어는 타입스크립트 개발에 필요한 모든 설정을 포함한 리액트 네이티브 프로젝트를 생성합니다. 네이티브 종속성 설치가 완료되면 각 플랫폼에 맞게 앱을 실행할 수 있습니다.


cd MyRNTsApp

# iOS 시뮬레이터에서 실행
npx react-native run-ios

# 또는 Android 에뮬레이터에서 실행 (에뮬레이터가 실행 중이어야 함)
npx react-native run-android

1.3. 핵심 설정 파일: `tsconfig.json` 심층 분석

타입스크립트 프로젝트의 두뇌와 같은 역할을 하는 파일이 바로 `tsconfig.json`입니다. CRA나 React Native CLI가 생성해주는 기본 설정은 대부분의 경우에 훌륭하지만, 그 안에 담긴 옵션들의 의미를 이해하면 프로젝트의 요구사항에 맞게 설정을 최적화할 수 있습니다.

옵션 설명 권장 값 (CRA 기본) 풀스택 개발자의 관점
"target" 컴파일된 자바스크립트 코드의 ECMAScript 버전을 지정합니다. "es5" ES5는 가장 넓은 브라우저 호환성을 보장합니다. 모던 브라우저만 타겟팅한다면 "es6""es2015" 이상으로 설정하여 더 간결하고 성능 좋은 코드를 생성할 수 있습니다.
"jsx" .tsx 파일의 JSX 코드를 어떻게 처리할지 결정합니다. "react-jsx" 최신 리액트(17+)의 새로운 JSX Transform을 사용하는 설정입니다. 더 이상 파일 상단에 import React from 'react'를 명시하지 않아도 됩니다. 구버전 리액트라면 "react"를 사용해야 합니다.
"moduleResolution" 모듈 해석 전략을 정의합니다. (e.g., `import`가 어떤 파일을 찾아가는지) "node" Node.js의 모듈 해석 방식을 따르는 것으로, 사실상의 표준입니다. 거의 변경할 일이 없습니다.
"strict" 모든 엄격한 타입 검사 옵션을 활성화합니다. (noImplicitAny, strictNullChecks 등 포함) true 반드시 `true`로 설정하는 것을 권장합니다. 타입스크립트의 진정한 가치는 이 옵션을 켰을 때 발휘됩니다. `null`과 `undefined`를 명시적으로 처리하게 강제하여 수많은 버그를 예방합니다.
"esModuleInterop" CommonJS 모듈과 ES 모듈 간의 상호 운용성을 개선합니다. true import React from "react"와 같은 구문을 가능하게 해주는 중요한 옵션입니다. `false`일 경우 import * as React from "react"처럼 사용해야 합니다. true로 두는 것이 현대적인 개발 방식에 부합합니다.
"allowJs" .js 파일의 임포트를 허용합니다. true 점진적 마이그레이션의 핵심 옵션입니다. 이 옵션이 켜져 있어야 .tsx 파일에서 기존의 .js 파일을 임포트하여 함께 사용할 수 있습니다.
"baseUrl" 모듈 경로 해석의 기준 디렉토리를 설정합니다. (기본 없음) "src"로 설정하면 import MyComponent from '../../components/MyComponent'와 같은 지저분한 상대 경로 대신 import MyComponent from 'components/MyComponent'와 같은 절대 경로를 사용할 수 있어 가독성과 유지보수성이 크게 향상됩니다. paths 옵션과 함께 사용하면 더 강력합니다.
"noEmit" 타입 검사만 수행하고 실제 자바스크립트 파일을 생성하지 않습니다. true 리액트 프로젝트에서는 실제 코드 변환(transpiling)을 Babel이 담당하므로, 타입스크립트 컴파일러(TSC)는 타입 검사 역할만 수행하는 것이 효율적입니다. 이 옵션은 그 역할을 명확히 합니다.

1.4. 타입스크립트로 리액트 컴포넌트 작성하기: 기본과 심화

타입스크립트의 꽃은 바로 컴포넌트의 PropsState를 명확하게 정의하는 것입니다. 이는 컴포넌트의 API를 정의하고, 잘못된 사용을 원천적으로 차단하는 역할을 합니다.

Props 타입 정의: `interface` vs `type`

Props 타입을 정의할 때는 `interface`와 `type` 두 가지 키워드를 사용할 수 있습니다. 기능적으로 거의 유사하지만, 약간의 차이와 커뮤니티의 선호도가 존재합니다.

  • `interface`: 객체의 모양을 정의하는 데 특화되어 있으며, `extends`를 통해 확장이 가능합니다. 선언 병합(declaration merging)이 가능하여 같은 이름의 인터페이스를 여러 번 선언하면 합쳐집니다. 일반적으로 애플리케이션의 API(컴포넌트 Props 등)를 정의할 때 선호됩니다.
  • `type`: 더 범용적입니다. 객체뿐만 아니라 유니온(`string | number`), 튜플 등 모든 타입에 별칭을 붙일 수 있습니다. 확장은 `&` (Intersection) 연산자를 사용합니다.

간단한 `UserProfile` 컴포넌트를 `interface`를 사용하여 만들어 보겠습니다.


// src/components/UserProfile.tsx
import React from 'react';

// UserProfile 컴포넌트가 받을 props의 타입을 정의합니다.
interface UserProfileProps {
  name: string;
  email: string;
  age: number;
  isMember?: boolean; // '?'는 optional prop을 의미합니다. (있어도 되고 없어도 됨)
  theme: 'light' | 'dark'; // 특정 문자열 값만 허용하는 유니온 타입
  onUpdate: (newName: string) => void; // 함수 prop 타입 정의
}

// React.FC 사용에 대한 논의
// 장점: children prop이 기본적으로 포함되고, 컴포넌트 타입임이 명확함
// 단점: children이 필요 없는 컴포넌트에도 타입이 존재하고, defaultProps와 잘 동작하지 않는 이슈가 있었음
// 현대 리액트에서는 아래와 같이 직접 타이핑하는 것을 선호하는 경향도 있음
// const UserProfile = ({ name, email, ... }: UserProfileProps) => { ... }
const UserProfile: React.FC<UserProfileProps> = ({ name, email, age, isMember, theme, onUpdate }) => {
  
  const handleNameChange = () => {
    onUpdate(`${name} updated`);
  };

  return (
    <div style={{ 
      border: '1px solid #ccc', 
      padding: '16px', 
      borderRadius: '8px',
      backgroundColor: theme === 'light' ? '#fff' : '#333',
      color: theme === 'light' ? '#000' : '#fff'
    }}>
      <h2>{name}</h2>
      <p>Email: {email}</p>
      <p>Age: {age}</p>
      {isMember && <p>Status: Premium Member</p>}
      <button onClick={handleNameChange}>Update Name</button>
    </div>
  );
};

export default UserProfile;

이렇게 타입을 정의하면, 이 컴포넌트를 사용하는 곳에서 VS Code는 `UserProfile`에 필요한 props 목록을 자동으로 보여주고, `theme` prop에 'light'나 'dark'가 아닌 다른 문자열을 입력하면 즉시 오류를 표시합니다. `onUpdate` 함수에 잘못된 타입의 인자를 전달하려는 시도 또한 사전에 차단됩니다.

State 타입 정의: `useState`와 `useReducer`

상태(State)를 다룰 때도 타입스크립트는 강력한 안정성을 제공합니다. `useState` 훅은 초기값을 기반으로 타입을 추론하지만, 복잡한 데이터 구조나 초기값이 `null`인 경우에는 명시적으로 타입을 지정해주는 것이 좋습니다.


import React, { useState, useReducer } from 'react';

// API로부터 받아올 사용자 정보의 타입
type User = {
    id: number;
    name: string;
    username: string;
    email: string;
}

// useReducer를 위한 상태, 액션 타입 정의
type State = {
    data: User | null;
    loading: boolean;
    error: Error | null;
}

type Action = 
    | { type: 'FETCH_START' }
    | { type: 'FETCH_SUCCESS'; payload: User }
    | { type: 'FETCH_ERROR'; payload: Error };

function reducer(state: State, action: Action): State {
    switch (action.type) {
        case 'FETCH_START':
            return { ...state, loading: true, error: null };
        case 'FETCH_SUCCESS':
            return { ...state, loading: false, data: action.payload };
        case 'FETCH_ERROR':
            return { ...state, loading: false, error: action.payload };
        default:
            throw new Error('Unhandled action type');
    }
}

const UserLoader: React.FC = () => {
    // 1. useState 예제: User 타입 또는 null 값을 가질 수 있음을 명시
    // const [user, setUser] = useState<User | null>(null);

    // 2. useReducer 예제: 복잡한 상태 로직을 타입 안전하게 관리
    const [state, dispatch] = useReducer(reducer, {
        data: null,
        loading: false,
        error: null,
    });

    const fetchUser = () => {
        dispatch({ type: 'FETCH_START' });
        setTimeout(() => {
            // 실제로는 여기서 fetch API 등을 사용
            const mockUser: User = { id: 1, name: 'Leanne Graham', username: 'Bret', email: 'Sincere@april.biz' };
            dispatch({ type: 'FETCH_SUCCESS', payload: mockUser });
            // 만약 에러가 발생했다면:
            // dispatch({ type: 'FETCH_ERROR', payload: new Error('Failed to fetch user') });
        }, 1000);
    }

    const { data, loading, error } = state;

    if (loading) {
        return <div>Loading...</div>;
    }
    
    if (error) {
        return <div>Error: {error.message}</div>
    }

    return (
        <div>
            {data ? (
                <p>Welcome, {data.name} (@{data.username})</p>
            ) : (
                <button onClick={fetchUser}>Load User</button>
            )}
        </div>
    )
}

위 `useReducer` 예제는 타입스크립트의 진가를 보여줍니다. 액션 객체의 `type` 값에 따라 `payload`의 타입이 동적으로 결정되고, `reducer` 함수는 모든 가능한 액션 타입을 처리하도록 강제됩니다(default 케이스). 이로 인해 상태 관리 로직에서 발생할 수 있는 수많은 실수를 컴파일 시점에 잡아낼 수 있습니다.

2. 핵심 가이드: 기존 자바스크립트 프로젝트에 타입스크립트 점진적으로 도입하기

이제 이 글의 핵심 주제로 넘어가 보겠습니다. 이미 수많은 자바스크립트 파일로 구성된 레거시 프로젝트를 마주했을 때, 우리는 어떻게 타입스크립트의 안정성을 이식할 수 있을까요? 핵심은 '한 번에'가 아닌 '점진적으로' 접근하는 것입니다. 목표는 자바스크립트와 타입스크립트가 평화롭게 공존하는 하이브리드 상태를 만들고, 점차 타입스크립트의 영역을 넓혀나가는 것입니다.

단계 1: 개발 환경 구축 및 설정

가장 먼저 할 일은 프로젝트에 타입스크립트 컴파일러와 필요한 타입 정의 라이브러리들을 설치하는 것입니다.


# 프로젝트 루트 디렉토리에서 실행합니다.
# 웹 리액트 프로젝트의 경우
npm install --save-dev typescript @types/node @types/react @types/react-dom @types/jest

# 리액트 네이티브 프로젝트의 경우 @types/react-native 추가
# npm install --save-dev @types/react-native

`@types/` 접두사가 붙은 패키지들은 DefinitelyTyped라는 거대한 커뮤니티 기반 타입 정의 저장소에서 제공됩니다. 이를 통해 자바스크립트로 작성된 라이브러리(리액트 등)를 타입스크립트 프로젝트에서 타입 정보와 함께 사용할 수 있습니다.

다음으로, 프로젝트 루트에 `tsconfig.json` 파일을 생성해야 합니다. 처음부터 모든 옵션을 작성하기보다는, 타입스크립트가 제공하는 명령어로 기본 파일을 생성하고 수정하는 것이 효율적입니다.


npx tsc --init

이제 생성된 `tsconfig.json` 파일을 열고, 점진적 마이그레이션을 위한 핵심 옵션들을 수정해야 합니다.


// tsconfig.json (마이그레이션을 위한 핵심 설정)
{
  "compilerOptions": {
    /* 기본 옵션들... */
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,

    /* 마이그레이션 핵심 옵션 */
    "jsx": "react-jsx", // 프로젝트의 리액트 버전에 맞게 설정
    "allowJs": true, // ★★★ 매우 중요: .js 파일을 .ts 파일에서 import 할 수 있게 허용
    "checkJs": false, // 처음에는 false로 시작. true로 하면 .js 파일에서도 타입 에러를 체크하기 시작함.
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,

    /* 점진적으로 강화할 옵션 */
    "strict": false, // ★★★ 처음에는 false로 시작하여 점진적으로 true로 전환하는 전략이 유효
    "noImplicitAny": false, // strict가 false일 때의 세부 옵션. 점진적으로 true로 변경
    
    "baseUrl": "./src", // 절대 경로 임포트를 위한 설정
    "paths": {
      "@components/*": ["components/*"],
      "@hooks/*": ["hooks/*"],
      "@utils/*": ["utils/*"]
    }
  },
  "include": [
    "src" // 타입스크립트 컴파일러가 검사할 파일들의 범위를 지정
  ]
}
중요 전략: `strict` 옵션 다루기
처음부터 "strict": true를 켜는 것은 이상적이지만, 기존 코드베이스에서는 수천 개의 에러를 발생시켜 마이그레이션을 불가능하게 만들 수 있습니다. 초기에는 false로 설정하여 타입스크립트와 자바스크립트가 공존하는 환경을 먼저 만드세요. 그리고 새로운 타입스크립트 파일은 처음부터 엄격한 규칙 하에 작성하고, 기존 파일을 하나씩 변환하면서 점차적으로 타입 오류를 수정해나간 뒤, 최종적으로 `strict` 옵션을 `true`로 전환하는 것이 현실적인 전략입니다.

단계 2: "리프 컴포넌트"부터 변환 시작하기

어떤 파일부터 변환해야 할까요? 정답은 "의존성이 가장 적은 파일"부터입니다. 이를 '리프 컴포넌트(Leaf Component)' 또는 '리프 모듈' 전략이라고 부릅니다. 앱의 기능 트리에서 가장 끝단에 위치한 컴포넌트들, 예를 들어 재사용 가능한 `Button`, `Input`, `Icon`, `Spinner` 컴포넌트나 순수 유틸리티 함수(`formatDate.js` 등)가 좋은 시작점입니다.

  1. 대상 파일 선정: `src/components/atoms/Button.js` 와 같은 파일을 선택합니다.
  2. 파일 확장자 변경: `Button.js`를 `Button.tsx`로 변경합니다. (JSX를 포함하므로 `.tsx`)
  3. 타입 오류 해결: 확장자를 바꾸는 순간, VS Code는 타입스크립트 컴파일러를 통해 파일을 분석하기 시작하고, 여러 오류를 표시할 것입니다. 이제 이 오류들을 하나씩 해결해 나갑니다.

실전 예제: `Button.js`를 `Button.tsx`로 변환하기

다음과 같은 자바스크립트 버튼 컴포넌트가 있다고 가정해봅시다.


// src/components/Button.js (변환 전)
import React from 'react';

const Button = ({ onClick, children, primary, disabled, style }) => {
  const buttonStyle = {
    backgroundColor: primary ? 'blue' : 'gray',
    color: 'white',
    padding: '10px 15px',
    border: 'none',
    borderRadius: '5px',
    cursor: disabled ? 'not-allowed' : 'pointer',
    opacity: disabled ? 0.6 : 1,
    ...style
  };

  return (
    <button onClick={onClick} style={buttonStyle} disabled={disabled}>
      {children}
    </button>
  );
};

export default Button;

이 파일을 `Button.tsx`로 바꾸면 `onClick`, `children` 등의 매개변수에서 `'(parameter) implicitly has an 'any' type.'`과 같은 오류가 발생합니다. 이제 타입을 정의하여 이 문제를 해결해 보겠습니다.


// src/components/Button.tsx (변환 후)
import React, { ReactNode, CSSProperties } from 'react';

// 1. Props 타입 정의
interface ButtonProps {
  // 이벤트 핸들러 타입: React가 제공하는 MouseEvent 타입을 사용
  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void; 
  // children 타입: 문자열, 숫자, JSX 요소 등 다양한 타입을 허용
  children: ReactNode; 
  primary?: boolean;
  disabled?: boolean;
  // 스타일 객체 타입: React가 제공하는 CSSProperties를 사용
  style?: CSSProperties; 
}

// 2. 컴포넌트 매개변수에 타입 적용
const Button = ({ onClick, children, primary = false, disabled = false, style }: ButtonProps) => {
  const buttonStyle: CSSProperties = { // 3. 내부 변수에도 타입 적용
    backgroundColor: primary ? 'blue' : 'gray',
    color: 'white',
    padding: '10px 15px',
    border: 'none',
    borderRadius: '5px',
    cursor: disabled ? 'not-allowed' : 'pointer',
    opacity: disabled ? 0.6 : 1,
    ...style
  };

  return (
    <button onClick={onClick} style={buttonStyle} disabled={disabled}>
      {children}
    </button>
  );
};

export default Button;

이 과정을 통해 `Button` 컴포넌트는 이제 타입 안전성을 확보했습니다. 다른 개발자가 `onClick` prop에 함수가 아닌 값을 전달하거나, `primary` prop에 boolean이 아닌 값을 넣으려고 하면 즉시 에러가 발생하여 실수를 방지할 수 있습니다. 이 작은 성공을 시작으로, 점차 더 복잡하고 의존성이 많은 컴포넌트(예: 이 `Button`을 사용하는 `Card` 컴포넌트)로 변환을 확장해 나갑니다.

단계 3: 서드파티 라이브러리 및 전역 타입 다루기

마이그레이션 과정에서 반드시 마주치는 또 다른 문제는 외부 라이브러리입니다. 대부분의 유명 라이브러리(`axios`, `lodash`, `styled-components` 등)는 `@types` 패키지를 제공합니다.


npm install --save-dev @types/lodash @types/styled-components

하지만 타입 정의가 제공되지 않는 라이브러리나, 프로젝트 전역에서 사용되는 `window` 객체 확장, 이미지 파일(`.png`, `.svg`) 임포트 같은 경우에는 어떻게 해야 할까요? 이럴 때 사용하는 것이 바로 선언 파일(`.d.ts`)입니다.

`src` 폴더 아래에 `types`와 같은 디렉토리를 만들고, 그 안에 선언 파일을 작성합니다. 예를 들어, `.svg` 파일을 리액트 컴포넌트처럼 사용하기 위한 타입 선언은 다음과 같습니다.


// src/types/custom.d.ts

// SVG 파일을 React 컴포넌트로 import하기 위한 타입 선언
declare module '*.svg' {
  import React = require('react');
  export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
  const src: string;
  export default src;
}

// 타입 정의가 없는 라이브러리를 위한 최소한의 선언
declare module 'some-untyped-library'; 
// 위와 같이 선언하면, 최소한 import 에러는 발생하지 않음. 
// 더 나아가 함수의 시그니처 등을 직접 정의해줄 수도 있음.

// window 객체 확장
interface Window {
  myGlobalFunction: () => void;
}

이렇게 작성된 선언 파일은 타입스크립트 컴파일러가 자동으로 인식하여 프로젝트 전역에 타입을 제공해줍니다. 이를 통해 타입 시스템의 사각지대를 최소화하고, 일관성 있는 개발 환경을 유지할 수 있습니다.

단계 4: CI/CD 파이프라인에 타입 체크 통합하기

점진적 마이그레이션이 진행되면서, 팀 전체가 타입 안전성을 유지하도록 강제하는 장치가 필요합니다. CI(Continuous Integration) 파이프라인에 타입 체크 스크립트를 추가하는 것이 가장 효과적인 방법입니다.

`package.json`에 다음과 같은 스크립트를 추가합니다.


// package.json
"scripts": {
  "start": "react-scripts start",
  "build": "react-scripts build",
  "test": "react-scripts test",
  "eject": "react-scripts eject",
  "type-check": "tsc --noEmit" // ★★★ 타입 체크 스크립트 추가
}

tsc --noEmit 명령어는 자바스크립트 파일 변환 없이 순수하게 타입 검사만 수행합니다. 이제 GitHub Actions나 Jenkins 같은 CI 도구에서, 코드를 푸시하거나 Pull Request를 생성할 때마다 `npm run type-check` 명령어를 실행하도록 설정합니다. 이렇게 하면 타입 에러가 있는 코드가 메인 브랜치에 병합되는 것을 원천적으로 차단하여 코드베이스의 건강성을 지킬 수 있습니다.

3. 생산성을 높이는 리액트 타입스크립트 고급 패턴

타입스크립트에 익숙해졌다면, 이제는 더 나아가 코드의 재사용성과 유연성을 극대화하는 고급 패턴들을 활용할 차례입니다. 이러한 패턴들은 특히 공통 로직을 추상화하는 커스텀 훅이나 재사용 가능한 컴포넌트를 만들 때 빛을 발합니다.

3.1. 제네릭(Generics)을 활용한 커스텀 훅 만들기

API 요청을 처리하는 커스텀 훅 `useFetch`를 만든다고 상상해봅시다. 어떤 API는 사용자 정보를 반환하고, 다른 API는 게시글 목록을 반환할 것입니다. 이때 제네릭을 사용하면, 어떤 종류의 데이터든 타입 안전하게 처리할 수 있는 유연한 훅을 만들 수 있습니다.


// src/hooks/useFetch.ts
import { useState, useEffect } from 'react';

interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

// <T>는 이 훅이 사용될 때 결정될 타입을 의미하는 제네릭 플레이스홀더
function useFetch<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({
    data: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    // 이전 요청을 정리하기 위한 AbortController
    const controller = new AbortController();
    const signal = controller.signal;

    const fetchData = async () => {
      setState({ data: null, loading: true, error: null });
      try {
        const response = await fetch(url, { signal });
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const jsonData = (await response.json()) as T; // 받아온 데이터를 제네릭 타입 T로 단언
        setState({ data: jsonData, loading: false, error: null });
      } catch (error) {
        if (error.name !== 'AbortError') {
          setState({ data: null, loading: false, error: error as Error });
        }
      }
    };

    fetchData();

    return () => {
      // 컴포넌트가 언마운트되면 요청을 취소
      controller.abort();
    };
  }, [url]); // url이 변경될 때마다 재요청

  return state;
}

export default useFetch;

// --- 컴포넌트에서 사용하는 예시 ---
// import useFetch from '@hooks/useFetch';
//
// interface Post {
//   userId: number;
//   id: number;
//   title: string;
//   body: string;
// }
//
// const PostList: React.FC = () => {
//   // useFetch를 사용할 때 Post[] 타입을 명시적으로 전달
//   const { data: posts, loading, error } = useFetch<Post[]>('https://jsonplaceholder.typicode.com/posts');
//
//   if (loading) return <p>Loading posts...</p>;
//   if (error) return <p>Error: {error.message}</p>;
//
//   return (
//     <ul>
//       {/* 이제 'posts'는 Post[] 타입으로 완벽하게 추론됨 */}
//       {posts?.map(post => <li key={post.id}>{post.title}</li>)}
//     </ul>
//   );
// };

제네릭 덕분에 `useFetch` 훅은 어떤 데이터 구조에도 대응할 수 있게 되었고, 이 훅을 사용하는 컴포넌트에서는 `posts` 변수가 `Post[]` 타입임을 완벽하게 추론하여 `post.title`과 같은 속성에 안전하게 접근할 수 있습니다. 이것이 바로 타입스크립트가 제공하는 재사용성과 안정성의 조화입니다.

3.2. 유틸리티 타입을 활용한 Props 유연하게 다루기

때로는 기존에 정의된 타입을 재활용하여 약간 변형된 버전을 만들고 싶을 때가 있습니다. 예를 들어, 모든 필드가 필수인 `CreationForm`이 있고, 일부 필드만 수정하는 `UpdateForm`이 있을 수 있습니다. 이때 타입스크립트의 유틸리티 타입은 빛을 발합니다.

  • `Partial<T>`: T의 모든 프로퍼티를 선택적(optional)으로 만듭니다.
  • `Pick<T, K>`: T에서 K 프로퍼티만 골라서 새로운 타입을 만듭니다.
  • `Omit<T, K>`: T에서 K 프로퍼티를 제외하고 새로운 타입을 만듭니다.
  • `Required<T>`: T의 모든 프로퍼티를 필수로 만듭니다.

import React from 'react';

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

// 1. Omit을 사용하여 id가 없는 생성용 타입 만들기
type UserCreationProps = Omit<User, 'id'>;

const UserCreationForm = (props: UserCreationProps) => {
  // props.id 접근 시 에러 발생
  return <div>...</div>;
};

// 2. Partial을 사용하여 모든 필드가 선택적인 업데이트용 타입 만들기
type UserUpdateProps = Partial<User>;

const UserUpdateForm = (props: UserUpdateProps) => {
  // props.name, props.email 등이 모두 optional (undefined일 수 있음)
  return <div>...</div>;
};

// 3. Pick을 사용하여 일부 정보만 표시하는 컴포넌트 타입 만들기
type UserAvatarProps = Pick<User, 'name' | 'email'>;

const UserAvatar = ({ name, email }: UserAvatarProps) => {
  // name과 email만 props로 받음
  return <div>{name} ({email})</div>;
};

이러한 유틸리티 타입은 불필요한 타입 중복을 줄여주고, 코드의 의도를 명확하게 표현하여 유지보수성을 크게 향상시킵니다.

결론: 단순한 문법을 넘어 개발 문화의 혁신으로

지금까지 우리는 리액트와 리액트 네이티브 생태계에서 타입스크립트를 활용하는 여정을 함께했습니다. 최신 CLI 도구를 통해 이상적인 프로젝트를 설정하는 방법부터, 거대한 자바스크립트 코드베이스에 타입스크립트를 안전하게 이식하는 현실적인 전략, 그리고 생산성을 한 단계 끌어올리는 고급 패턴까지 살펴보았습니다.

리액트 프로젝트에 타입스크립트를 도입하는 것은 단순히 `.js`를 `.tsx`로 바꾸는 행위가 아닙니다. 이는 다음과 같은 근본적인 변화를 가져오는 전략적 투자입니다.

  • 버그와의 전쟁에서 우위를 점하게 합니다: 컴파일 타임에 수많은 잠재적 오류를 잡아내어 런타임의 불안정성을 획기적으로 줄여줍니다.
  • 개발자의 자신감을 높여줍니다: 강력한 자동 완성와 타입 추론은 개발자가 코드의 세부 사항을 암기하는 부담에서 벗어나, 비즈니스 로직 구현에 더 집중할 수 있게 해줍니다. 대규모 리팩토링이 더 이상 두려운 작업이 아니게 됩니다.
  • 소통 비용을 줄여줍니다: 잘 정의된 타입은 그 자체로 가장 명확한 문서입니다. 팀원 간의 소통 오류를 줄이고, 코드의 의도를 명확하게 전달하여 협업의 효율성을 극대화합니다.

처음에는 타입을 정의하고 컴파일 에러를 해결하는 과정이 번거롭게 느껴질 수 있습니다. 하지만 그 작은 노력이 쌓여 만들어낼 애플리케이션의 견고함과 장기적인 유지보수의 용이성은 그 어떤 초기 비용보다 훨씬 값진 보상으로 돌아올 것입니다. 이제 여러분의 기존 리액트 프로젝트에 타입스크립트라는 안전장치를 점진적으로 도입하여, 더욱 신뢰할 수 있고 성장 가능한 소프트웨어를 만들어나갈 시간입니다.

Post a Comment