[번역] React 개발자가 알아야 할 TypeScript 패턴
Soshy·

TypeScript는 React 코드를 훨씬 안전하게 만들어줄 수 있습니다. 다만 JavaScript 코드에 타입만 덧붙이는 식으로 써서는 그 장점을 제대로 누리기 어렵습니다. TypeScript를 하나의 설계 도구로 활용해야 합니다.
React와 TypeScript를 어느 정도 사용해봤다면 이런 경험이 한 번쯤 있을 겁니다.
컴포넌트를 하나 만들고,
interface를 몇 개 정의하고,
Props에 타입을 붙입니다.
그러다 어느 순간 이런 에러와 마주칩니다.
Type 'string | undefined' is not assignable to type 'string'
그래서 !를 붙입니다.
에러는 사라집니다.
그리고 6개월 뒤, 누군가 버튼을 클릭하는 순간 애플리케이션이 터집니다.
많은 React 개발자가 TypeScript를 오해하는 지점이 바로 여기입니다.
TypeScript의 가치는 단순히 이런 코드를 작성할 수 있다는 데 있지 않습니다.
const name: string = "Rupal";
이런 타입 지정은 어렵지 않습니다.
TypeScript의 진짜 강점은 더 나은 React 컴포넌트를 설계하고, UI 상태를 명확하게 모델링하고, 재사용 가능한 컴포넌트를 안전하게 만들며, 실제로는 존재해서는 안 되는 상태를 배포 전에 찾아내는 데 있습니다.
React 공식 문서에서도 모든 값에 직접 타입을 붙이기보다 TypeScript의 타입 추론을 적극적으로 활용하는 방식을 권장합니다.
그렇다면 React에서 실제로 유용하게 쓰이는 TypeScript 패턴을 하나씩 살펴보겠습니다.
1. 모든 값이 아니라 Props에 타입을 붙이자
React와 TypeScript를 함께 사용할 때 가장 기본적이면서도 중요한 패턴입니다.
type ButtonProps = {
label: string;
disabled?: boolean;
onClick: () => void;
};
function Button({
label,
disabled = false,
onClick,
}: ButtonProps) {
return (
<button disabled={disabled} onClick={onClick}>
{label}
</button>
);
}
코드 자체는 단순합니다.
하지만 여기서 중요한 점은 ButtonProps가 단순히 타입을 모아놓은 것이 아니라, 이 컴포넌트를 어떻게 사용해야 하는지 보여주는 계약(contract) 역할을 한다는 것입니다.
이 컴포넌트를 사용하는 사람은 타입만 봐도 바로 알 수 있습니다.
label은 반드시 전달해야 한다.disabled는 생략할 수 있다.onClick은 별도의 인자를 받지 않는다.
이런 정보가 TypeScript가 실제로 가치를 주는 부분입니다.
반대로 TypeScript를 사용한다는 이유만으로 모든 변수에 타입을 적을 필요는 없습니다.
const name: string = "Rupal";
const count: number = 10;
const isLoading: boolean = false;
TypeScript는 이미 이 값들의 타입을 알고 있습니다.
따라서 다음 정도로 충분합니다.
const name = "Rupal";
const count = 10;
const isLoading = false;
좋은 TypeScript 코드는 불확실성을 줄입니다.
불필요한 타입 표기는 오히려 코드를 복잡하게 만듭니다.
2. 타입 추론은 TypeScript에게 맡기자
TypeScript 코드가 필요 이상으로 장황해지는 대표적인 이유 중 하나는, 이미 TypeScript가 알아서 추론할 수 있는 타입까지 직접 적는 것입니다.
다음 코드를 보겠습니다.
const [isOpen, setIsOpen] = useState(false);
초기값이 false이기 때문에 TypeScript는 isOpen이 boolean이라는 사실을 이미 알고 있습니다.
즉, 굳이 이렇게 작성할 필요가 없습니다.
const [isOpen, setIsOpen] = useState<boolean>(false);
React의 useState를 비롯한 여러 Hook은 초기값을 기준으로 타입을 추론할 수 있습니다.
물론 타입을 직접 지정해야 하는 경우도 있습니다.
const [user, setUser] = useState<User | null>(null);
초기값이 null뿐이라면 TypeScript는 이후 이 상태에 User가 들어올 수 있다는 사실까지 알아낼 수 없습니다.
이럴 때는 타입을 명시하는 것이 필요합니다.
규칙은 단순합니다.
타입을 적었을 때 코드의 의미가 더 분명해진다면 적고, 단순히 적을 수 있다는 이유로 적지는 마세요.
3. Discriminated Union으로 상태를 명확하게 표현하자
React 개발자가 꼭 제대로 익혀두면 좋은 TypeScript 패턴을 하나만 꼽는다면 Discriminated Union을 추천할 수 있습니다.
API 요청 상태를 화면에 보여주는 컴포넌트가 있다고 해봅시다.
처음에는 다음처럼 작성할 수 있습니다.
type State = {
loading: boolean;
error?: string;
data?: User[];
};
얼핏 보면 큰 문제가 없어 보입니다.
하지만 이 타입은 다음과 같은 상태도 허용합니다.
{
loading: false,
error: "Something went wrong",
data: [/* users */]
}
요청이 성공한 걸까요, 실패한 걸까요?
error와 data가 동시에 존재한다면 UI에서는 어떤 상태를 보여줘야 할까요?
이럴 때는 가능한 상태 자체를 명확하게 나누는 편이 좋습니다.
type State =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; data: User[] };
이제 컴포넌트도 훨씬 단순하고 안전해집니다.
function UserList({ state }: { state: State }) {
switch (state.status) {
case "loading":
return <Spinner />;
case "error":
return <ErrorMessage message={state.message} />;
case "success":
return <Users users={state.data} />;
}
}
status가 "success"라면 TypeScript는 data가 반드시 존재한다는 것을 압니다.
반대로 "error"라면 message가 있다는 것을 압니다.
이처럼 특정 값을 기준으로 타입 범위를 좁혀가는 것을 Narrowing이라고 합니다. Discriminated Union은 이런 상태 모델링과 특히 잘 맞습니다.
예를 들어 다음과 같은 곳에서 유용합니다.
- API 요청 상태
- 인증 상태
- 결제 상태
- Form 상태
- Modal 종류
- 알림 상태
- 비동기 작업 상태
핵심은 모든 필드를 optional로 만들어서 “있을 수도 있는 값”을 표현하는 것이 아닙니다.
실제로 가능한 상태만 타입으로 표현하는 것입니다.
4. 여러 개의 Boolean 상태를 한꺼번에 두지 말자
React 코드에서 종종 이런 타입을 볼 수 있습니다.
type ModalProps = {
isOpen: boolean;
isLoading: boolean;
hasError: boolean;
isSuccess: boolean;
};
각 값만 보면 별문제가 없어 보입니다.
하지만 조합을 생각해보면 이야기가 달라집니다.
예를 들어 다음 상태도 타입상으로는 가능합니다.
isLoading: true
isSuccess: true
hasError: true
이때 UI에는 무엇을 보여줘야 할까요?
로딩 중이면서 성공했고, 동시에 에러가 난 상태가 정말 존재해야 할까요?
이럴 때는 상태를 하나의 값으로 묶는 편이 훨씬 낫습니다.
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success" }
| { status: "error"; message: string };
여러 개의 독립적인 Boolean 대신 하나의 status로 상태를 표현하는 것입니다.
이건 단순히 TypeScript를 잘 쓰는 요령이 아닙니다.
애플리케이션 상태를 더 잘 설계하는 방법이기도 합니다.
상태를 정의할 때는 이런 질문을 해보면 좋습니다.
“이 값의 조합이 실제로 발생할 수 있는가?”
실제로는 존재할 수 없는 조합이라면, 타입 단계에서부터 만들 수 없도록 하는 편이 좋습니다.
5. Generic으로 재사용성과 타입 안정성을 함께 챙기자
Generic은 재사용 가능한 컴포넌트를 만들 때 특히 유용합니다.
예를 들어 여러 곳에서 사용할 수 있는 Dropdown 컴포넌트를 만든다고 해봅시다.
단순하게 만들면 이렇게 작성할 수 있습니다.
type DropdownProps = {
options: any[];
onChange: (value: any) => void;
};
동작은 합니다.
하지만 any를 사용하는 순간 사실상 이 부분에서는 TypeScript의 타입 검사를 포기한 셈입니다.
Generic을 사용하면 타입 정보를 유지하면서도 여러 종류의 데이터에 대응할 수 있습니다.
type DropdownProps<T> = {
options: T[];
value: T;
onChange: (value: T) => void;
getLabel: (option: T) => string;
};
사용하는 쪽에서는 다음처럼 작성할 수 있습니다.
<Dropdown
options={users}
value={selectedUser}
onChange={setSelectedUser}
getLabel={(user) => user.name}
/>
이제 TypeScript는 options, value, onChange가 모두 같은 타입을 사용해야 한다는 관계를 알고 있습니다.
즉, 컴포넌트 자체는 재사용할 수 있으면서도 타입 정보는 잃지 않습니다.
Generic은 다음과 같은 컴포넌트나 로직에서 자주 활용됩니다.
- Data Table
- Select 컴포넌트
- Autocomplete
- Form 컴포넌트
- Pagination
- API Wrapper
- Custom Hook
6. keyof로 사용할 수 있는 Key를 제한하자
다음과 같은 User 타입이 있다고 해봅시다.
type User = {
id: number;
name: string;
email: string;
};
객체에서 특정 프로퍼티 값을 꺼내는 재사용 가능한 함수를 만들고 싶습니다.
다음처럼 작성하면 문제가 생깁니다.
function getValue(user: User, key: string) {
return user[key];
}
key가 단순한 string이기 때문에 "name"이나 "email"뿐 아니라 어떤 문자열도 전달할 수 있기 때문입니다.
keyof를 사용하면 실제 객체에 존재하는 Key만 허용할 수 있습니다.
function getValue<T, K extends keyof T>(
object: T,
key: K
) {
return object[key];
}
다음 코드는 정상적으로 동작합니다.
getValue(user, "name");
반면 다음 코드는
getValue(user, "password");
컴파일 단계에서 에러가 발생합니다.
User에 "password"라는 프로퍼티가 없기 때문입니다.
이런 방식은 공용 Utility 함수나 재사용 가능한 컴포넌트 API를 만들 때 특히 유용합니다.
K extends keyof T처럼 Generic Constraint를 사용하면 타입 사이의 관계까지 표현할 수 있습니다.
7. 검증은 하되 추론은 유지하고 싶다면 satisfies를 사용하자
satisfies는 객체가 특정 타입 조건을 만족하는지 확인하면서도, 실제 값에 대한 구체적인 타입 정보는 최대한 유지하고 싶을 때 유용합니다.
예를 들어 Route 설정이 있다고 해봅시다.
type RouteConfig = {
path: string;
requiresAuth: boolean;
}
const routes = {
dashboard: {
path: "/dashboard",
requiresAuth: true,
},
login: {
path: "/login",
requiresAuth: false,
},
} satisfies Record<string, RouteConfig>;
여기서 중요한 부분은 satisfies입니다.
routes 객체가 Record<string, RouteConfig> 조건을 만족하는지는 검사하지만, 객체가 가진 구체적인 타입 정보까지 넓은 타입으로 덮어버리지는 않습니다.
따라서 다음과 같은 설정 객체를 만들 때 특히 유용합니다.
- Route 설정
- Feature Flag
- Design Token
- Form 설정
- 권한 설정
- 각종 Configuration 객체
satisfies를 한 문장으로 표현하면 이렇습니다.
“이 객체가 정해진 규칙을 따르는지는 확인하되, 객체에 대해 이미 알고 있는 구체적인 정보는 그대로 유지해줘.”
8. API 응답은 애플리케이션 경계에서 다루자
React 애플리케이션에서 흔히 생기는 문제 중 하나는 타입이 불분명한 API 데이터가 그대로 애플리케이션 곳곳으로 퍼지는 것입니다.
예를 들어 다음과 같은 코드가 있습니다.
const response = await fetch("/api/users");
const data = await response.json();
이후 data가 any처럼 사용되기 시작하면 TypeScript가 제공하는 안전성을 상당 부분 잃게 됩니다.
우선 애플리케이션에서 기대하는 데이터 구조를 정의할 수 있습니다.
type User = {
id: string;
name: string;
email: string;
};
그리고 이후 코드는 이 구조를 기준으로 작성합니다.
하지만 여기서 중요한 점이 하나 있습니다.
TypeScript는 실제 네트워크 응답을 검증해주지 않습니다.
백엔드가 다음과 같이 내려준다고 해봅시다.
{
"id": 123
}
하지만 프론트엔드는 다음 타입을 기대하고 있습니다.
id: string;
코드에 User 타입을 선언해뒀다고 해서 TypeScript가 실제 서버 응답까지 확인해주는 것은 아닙니다.
따라서 API 데이터의 신뢰성이 중요한 애플리케이션이라면 TypeScript 타입과 별개로 런타임 검증을 함께 사용하는 것이 좋습니다.
중요한 원칙은 다음과 같습니다.
외부에서 들어오는 데이터는 애플리케이션 경계에서 검증하고, 그 이후부터는 신뢰할 수 있는 타입으로 다루세요.
9. 타입을 모를 때는 any보다 unknown을 사용하자
어떤 값의 타입을 실제로 알 수 없을 때 흔히 any를 사용합니다.
any
하지만 any는 TypeScript에게 사실상 이런 의미입니다.
“이 값은 더 이상 검사하지 않아도 돼.”
any를 사용하는 순간 해당 값에 대한 타입 검사는 거의 사라집니다.
정말 타입을 알 수 없는 값이라면 unknown을 사용하는 편이 더 안전합니다.
unknown
예를 들어 다음과 같습니다.
function parseData(value: unknown) {
if (typeof value === "string") {
return value.toUpperCase();
}
return null;
}
value를 바로 문자열처럼 사용할 수는 없습니다.
먼저 실제로 문자열인지 확인해야 합니다.
이런 특성은 다음과 같은 외부 데이터를 다룰 때 특히 유용합니다.
- API 응답
catch에서 받은 ErrorlocalStorage- 서드파티 라이브러리
- 외부 JSON
- 사용자가 입력한 데이터
차이를 단순하게 표현하면 다음과 같습니다.
unknown은
“아직 이 값의 타입을 모르겠다.”
라는 의미이고,
any는
“이 값의 타입은 신경 쓰지 않겠다.”
라는 의미에 가깝습니다.
둘은 전혀 다릅니다.
10. Utility Type으로 중복되는 타입을 줄이자
TypeScript는 기존 타입을 바탕으로 새로운 타입을 만들 수 있는 여러 Utility Type을 제공합니다.
다음과 같은 User 타입이 있다고 해봅시다.
type User = {
id: string;
name: string;
email: string;
role: string;
};
사용자 정보를 수정하는 화면에서는 모든 값이 필수가 아닐 수 있습니다.
type UpdateUser = Partial<User>;
외부에 공개하는 사용자 정보에서는 email을 제외하고 싶을 수도 있습니다.
type PublicUser = Omit<User, "email">;
몇 가지 프로퍼티만 필요하다면 Pick을 사용할 수 있습니다.
type UserPreview = Pick<User, "id" | "name">;
값을 변경할 수 없도록 만들고 싶다면 Readonly를 사용할 수 있습니다.
type ReadonlyUser = Readonly<User>;
이런 Utility Type은 규모가 큰 애플리케이션일수록 더 유용해집니다.
User, UserPreview, UserUpdate, UserReadonly를 각각 따로 정의하는 대신 하나의 원본 타입에서 파생시킬 수 있기 때문입니다.
덕분에 원본 타입이 바뀌었을 때 파생된 타입들도 자연스럽게 함께 바뀝니다.
결국 타입 사이의 관계를 더 일관성 있게 유지할 수 있습니다.
11. 이벤트에는 필요한 타입만 정확하게 지정하자
React 이벤트 핸들러에서도 습관적으로 any를 사용하는 경우가 많습니다.
다음처럼 작성하는 대신
function handleChange(event: any) {
...
}
실제 이벤트에 맞는 타입을 지정할 수 있습니다.
function handleChange(
event: React.ChangeEvent<HTMLInputElement>
) {
console.log(event.target.value);
}
Button 클릭 이벤트라면 다음과 같이 작성할 수 있습니다.
function handleClick(
event: React.MouseEvent<HTMLButtonElement>
) {
...
}
다만 여기서도 무조건 타입을 붙일 필요는 없습니다.
const handleClick = () => {
...
};
이벤트 객체를 사용하지 않는다면 이 코드로 충분합니다.
중요한 것은 모든 곳에 타입을 적는 것이 아닙니다.
코드가 전제로 삼고 있는 중요한 조건을 타입으로 드러내는 것입니다.
12. Custom Hook도 하나의 API처럼 설계하자
Custom Hook은 결국 다른 코드에서 가져다 쓰는 하나의 API입니다.
따라서 반환값도 사용하는 사람이 쉽게 이해할 수 있도록 설계하는 편이 좋습니다.
예를 들어 다음처럼 배열을 반환할 수 있습니다.
return [data, loading, error];
하지만 값이 많아질수록 각 위치가 무엇을 의미하는지 알기 어려워집니다.
대신 객체로 반환할 수 있습니다.
return {
data,
isLoading,
error,
refetch,
};
그리고 반환 타입을 명확하게 정의합니다.
type UseUsersResult = {
data: User[];
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
};
이렇게 하면 Hook을 사용하는 쪽에서 어떤 값을 받을 수 있는지 훨씬 쉽게 파악할 수 있습니다.
특히 시간이 지나면서 Hook의 기능과 반환값이 늘어날수록 차이는 더 커집니다.
좋은 Custom Hook은 사용하는 사람이 이런 생각을 하게 만들어야 합니다.
“이 Hook에서 무엇을 받을 수 있는지 바로 알겠다.”
13. Exhaustive Check로 빠뜨린 상태를 잡아내자
마지막으로 살펴볼 패턴은 Exhaustive Check입니다.
다음과 같은 상태 타입이 있다고 해봅시다.
type Status =
| "idle"
| "loading"
| "success"
| "error";
그리고 각각의 상태를 처리하고 있습니다.
switch (status) {
case "idle":
return <Idle />;
case "loading":
return <Spinner />;
case "success":
return <Success />;
case "error":
return <Error />;
}
그런데 나중에 새로운 상태가 하나 추가됐다고 해봅시다.
type Status =
| "idle"
| "loading"
| "success"
| "error"
| "cancelled";
타입에는 "cancelled"가 추가됐지만 기존 switch문에는 아직 이를 처리하는 코드가 없습니다.
이런 상황에서 TypeScript가 빠뜨린 케이스를 알려주도록 만들 수 있습니다.
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}
그리고 switch문의 마지막에 다음 코드를 추가합니다.
default:
return assertNever(status);
모든 케이스가 처리됐다면 default에 도달했을 때 status의 타입은 never가 됩니다.
반대로 새로운 상태가 추가됐는데 처리하지 않았다면 status가 더 이상 never가 아니기 때문에 컴파일 에러가 발생합니다.
새로운 상태를 추가하면서 UI 처리를 빠뜨리는 실수를 컴파일 단계에서 잡아낼 수 있는 것입니다.
이것이 TypeScript로 상태를 제대로 모델링했을 때 얻을 수 있는 큰 장점 중 하나입니다.
컴파일러가 UI 구현을 위한 체크리스트 역할을 하게 됩니다.
더 중요한 것은 문법이 아니라 설계다
TypeScript라고 하면 흔히 변수나 함수에 타입을 붙이는 모습을 먼저 떠올립니다.
물론 그것도 TypeScript의 일부입니다.
하지만 TypeScript의 가장 큰 가치는 그보다 더 넓은 곳에 있습니다.
중요한 질문은 이것입니다.
우리 애플리케이션이 당연하다고 가정하고 있는 조건은 무엇이고, 그 조건을 TypeScript가 지키도록 만들 수 있을까?
React 애플리케이션을 만든다면 다음과 같은 질문을 해볼 수 있습니다.
- 정말 반드시 필요한 Props는 무엇인가?
- UI에서 실제로 가능한 상태는 무엇인가?
- 반대로 절대 존재해서는 안 되는 상태는 무엇인가?
- 외부에서 받은 데이터는 어디까지 신뢰할 수 있는가?
- 어떤 컴포넌트를 재사용할 수 있어야 하는가?
- 어떤 값 사이의 타입 관계가 유지돼야 하는가?
- 런타임 검증은 어디에서 해야 하는가?
- 새로운 상태를 추가했을 때 빠뜨린 처리를 컴파일러가 알려줄 수 있는가?
이런 질문을 하기 시작하면 TypeScript는 더 이상 JavaScript 코드 위에 타입 문법을 덧붙이는 도구가 아닙니다.
애플리케이션을 설계하는 하나의 도구가 됩니다.
마무리
더 나은 React 개발자가 되기 위해 TypeScript의 모든 고급 기능을 알고 있어야 하는 것은 아닙니다.
우선 다음 패턴부터 익혀도 충분합니다.
Props → 타입 추론 → Discriminated Union → Generic → keyof → satisfies → Utility Type → unknown → Custom Hook 타입 설계 → Exhaustive Check
그리고 무엇보다 중요한 원칙이 하나 있습니다.
이미 작성한 코드를 설명하기 위해 TypeScript를 쓰지 마세요. 어떤 코드가 만들어질 수 있고, 어떤 코드는 만들어질 수 없는지를 설계하기 위해 TypeScript를 사용하세요.
좋은 TypeScript 코드는 타입이 가장 많이 들어간 코드가 아닙니다.
잘못된 코드를 작성하기 어렵게, 가능하다면 아예 작성할 수 없게 만든 코드입니다.
React에서 TypeScript가 주는 가장 큰 힘도 바로 여기에 있습니다.