⚛️ 07. React x TypeScript: 영철이의 컴포넌트 계약 다듬기
📋 개요
React.FC의 지양 이유부터 이벤트 파라미터, useRef 컴포넌트 타이핑의 모든 것
📌 이 문서를 읽기 전에
⏱️ 예상 읽기 시간: 15분(전체) / 핵심 파트만: 8분
🗺️ 이 문서의 흐름
[컴포넌트 Props 선언의 정석] → [이벤트 객체 타입 쪼개기] → [두 얼굴의 useRef 다루기]
🎯 이 문서를 다 읽으면 할 수 있는 것
- 왜
React.FC를 쓰지 않고 순수 함수형으로 컴포넌트를 타이핑해야 하는지 아키텍처 관점에서 설명할 수 있다. -
onClick(e)와onChange(e)의e가 가지는React.MouseEvent,React.ChangeEvent를 능숙하게 부여할 수 있다. - DOM을 조작하는
useRef와 변수를 저장하는useRef의 타이핑 차이를 구분하여null런타임 에러를 방지한다.
🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'
- 🐣 영철 ( React 코드에 타입 기준을 적용하는 중 ): "리드 님! 컴포넌트 쪼개기를 배웠으니 이제 공통
Button컴포넌트를 만들 겁니다. 근데onClick이벤트 함수에 마우스를 올리니까Parameter 'e' implicitly has an 'any' type이라고 나오네요. 게다가 이 버튼 컴포넌트 사이(children)에 아이콘을 넣을지 말지도 타입으로 정해야 할 것 같아요." - 🦁 영호 ( 리드 ): "영철 님, 컴포넌트도 결국 '객체(Props)를 받아서 JSX를 반환하는 함수'예요. 리액트가 제공하는 이벤트와 ref 타입을 몇 가지 기준으로 나누면, UI 컴포넌트의 사용 계약을 꽤 선명하게 만들 수 있습니다."
🤔 왜 알아야 하는가: 'any' 컴포넌트의 비극
순수 TS 문법(1~6강)을 익히고 React 프로젝트로 돌아오면 가장 먼저 부딪히는 곳이 바로 Props와 Event의 타이핑입니다.
영철이가 예전에 "일단 화면부터 띄우자"며 Props를 전부 any로 둔 공용 버튼을 볼까요?
// 영철이의 과거: 잘못된 props도 컴파일 단계에서 걸러지지 않는 공용 버튼
const Button = (props: any) => {
return <button onClick={props.action}>{props.text}</button>;
};
// 사용처
<Button text={123} action="클릭해!" /> // 에러 안 남. 클릭하면 action이 함수가 아니라서 런타임 예외 발생이런 컴포넌트가 10개, 100개가 모이면, 컴파일은 통과하지만 브라우저에서는 is not a function 같은 예외가 사용자 행동 뒤에야 드러납니다.
결국 5년 차 프론트엔드 개발자가 되기 위해서는, 우리가 만든 UI 컴포넌트의 뼈대를 TS로 완벽하게 래핑(Wrapping)하는 실전 기술을 장착해야 합니다.
🏗️ 1. 컴포넌트와 Props: React.FC를 신중히 쓰는 이유
몇 년 전만 해도 구글에 'React TypeScript'를 검색하면 십중팔구 튜토리얼에서 React.FC를 가르쳤습니다. (지금도 레거시 코드에 널려있습니다.)
❌ 레거시 방식: React.FC를 습관처럼 붙이기
interface ButtonProps {
title: string;
}
// 명시적으로 "이 함수는 리액트 컴포넌트다!" 라고 선언
const OldButton: React.FC<ButtonProps> = ({ title }) => {
return <button>{title}</button>;
};
// 🐣 영철: "와, 안에 암묵적으로 children도 포함되어 있고 편한데요?"과거 @types/react에서는 React.FC가 children을 암묵적으로 포함해, 자식을 받지 않아야 하는 컴포넌트도 <OldButton>자식</OldButton> 형태를 허용하는 문제가 있었습니다. React 18 타입 이후 이 문제는 완화됐지만, 여전히 children을 받을지 말지를 Props에서 명시하는 습관이 더 읽기 쉽습니다.
게다가 제네릭 컴포넌트를 만들 때 React.FC가 오히려 타입 흐름을 복잡하게 만들 수 있습니다.
✅ 5년 차 모던 방식: 순수 함수형 선언
현재 많은 React + TypeScript 팀이 선호하는 방식은, 컴포넌트를 **'Props라는 객체를 인자로 받는 함수'**로 대하는 것입니다.
import { PropsWithChildren } from 'react';
// 1. Props의 형태를 굳건히 잡는다. (보통 interface 사용)
interface ButtonProps {
title: string;
variant?: 'primary' | 'secondary';
onClick: () => void;
}
// 2. 함수의 인자 타입으로 조용히 씌워준다.
// 만약 children이 필요하다면, 내장 유틸리티인 PropsWithChildren으로 덮어준다!
export default function ModernButton({ title, variant = 'primary', onClick, children }: PropsWithChildren<ButtonProps>) {
return (
<button className={variant} onClick={onClick}>
{title}
{children}
</button>
);
}🦁 영호의 한마디: "
children이 필요한 컴포넌트만PropsWithChildren이나children?: React.ReactNode로 드러내면 됩니다. 컴포넌트는 특별한 예외가 아니라, Props 계약을 받는 함수라는 멘탈 모델을 유지하세요."
⚡ 2. 이벤트 헌터: 그 많은 e들은 무슨 타입인가?
가장 영철이를 화나게 한 주범입니다. <input onChange={(e) => ...}> 할 때 이 e를 도대체 뭐라고 타이핑해야 할까요?
여기서 외워야 할 대표적인 3대 리액트 이벤트 타입이 있습니다. (순수 HTML 이벤트가 아님에 주의하세요! 리액트가 감싼 合成 이벤트, SyntheticEvent의 하위 타입들입니다.)
1) 마우스 클릭: React.MouseEvent
import React, { MouseEvent } from 'react';
function handleProfileClick(e: MouseEvent<HTMLButtonElement>) {
// 꺾쇠(<>) 안에는 이벤트가 발생한 '주체(DOM)'를 넣어줍니다.
// 이 덕분에 e.currentTarget 이 <button> 임을 TS가 완벽히 추론합니다!
e.preventDefault();
console.log("프로필 클릭됨!");
}
return <button onClick={handleProfileClick}>프로필 보기</button>2) 입력창 타이핑: React.ChangeEvent
import React, { ChangeEvent, useState } from 'react';
function EmailInput() {
const [email, setEmail] = useState("");
function handleChange(e: ChangeEvent<HTMLInputElement>) {
// HTMLInputElement의 value가 정확히 추론되어 자동 완성이 동작한다.
setEmail(e.target.value);
}
return <input type="email" value={email} onChange={handleChange} />
}3) 폼 제출: React.FormEvent
import React, { FormEvent } from 'react';
function handleSubmit(e: FormEvent<HTMLFormElement>) {
// 기본 제출 동작을 막아 SPA 라우팅과 상태를 유지한다.
e.preventDefault();
console.log("로그인 요청 전송!");
}
return <form onSubmit={handleSubmit}>...</form>💡 TS 꿀팁: 그래도 정 헷갈린다면, 인라인 화살표 함수에서 마우스를 쓱 올려보세요.
<input onChange={(e) => {}}>에서e에 마우스를 호버하면 VScode가 정답(React.ChangeEvent<HTMLInputElement>)을 대놓고 알려줍니다. 그대로 복사해서 빼내면 됩니다.
🎯 3. 지킬 앤 하이드: 두 얼굴의 useRef
React의 useRef는 렌더링에 영향을 주지 않는 변수들을 관리하지만, 사용하는 '목적'에 따라 컴파일러를 다르게 대해야 합니다. 여기서 초보자들이 가장 많이 null 에러를 뿜습니다.
얼굴 1: 진짜 진짜 DOM을 파고들 때 (읽기 전용 참조)
영숙 님이 무조건 화면 로딩 시 이메일 칸에 커서가 깜빡이게(focus) 해달라고 요청했습니다.
// ❌ 영철이의 실수
const inputRef = useRef<HTMLInputElement>(); // TS: "너 안에 undefined 들어있어."
// ✅ 영호의 5년 차 정석
// DOM은 마운트 전에는 비어 있으므로 초기값을 null로 둔다.
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// 마운트 전에는 null이므로 optional chaining(?.)으로 길들이기
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;얼굴 2: 컴포넌트 안의 렌더링 안 되는 "비밀 변수"로 쓸 때
타이머(Timer) ID나 상태 변화를 기억하는 로컬 변수 목적으로 씁니다.
// 일반 변수용으로 쓸 때는 타입을 주고, 기본값 세팅을 합니다.
const timerId = useRef<number>(0);
const isMounted = useRef<boolean>(false);
useEffect(() => {
isMounted.current = true; // 렌더링을 유발하지 않는 플래그를 ref에 보관한다.
timerId.current = window.setInterval(() => console.log('째깍'), 1000);
return () => window.clearInterval(timerId.current);
}, []);📝 마무리 퀴즈
Q1. React.FC를 습관적으로 붙이기보다 일반 함수 Props 타이핑을 선호하는 이유는 무엇인가요?
✅ 정답: 컴포넌트가 받는 Props 계약, 특히 children 허용 여부를 더 명시적으로 드러낼 수 있기 때문입니다.
💡 상세 해설:
- 과거
React.FC타입은children을 암묵적으로 포함해 불필요한 children 사용을 허용하는 문제가 있었습니다. React 18 타입 이후 완화됐지만, 팀 코드에서는 Props에children을 직접 드러내는 편이 의도를 읽기 쉽습니다. - 그냥 일반 함수에
interface를 직접 넣어주는 방식이 간단하며, 자식이 꼭 필요하다면PropsWithChildren<MyProps>또는children?: React.ReactNode를 명시합니다.
Q2. <form onSubmit={...}> 의 이벤트 콜백 함수에서 e.preventDefault()를 호출하려고 합니다. 파라미터 e의 정확한 타입은 무엇이어야 할까요?
✅ 정답: React.FormEvent<HTMLFormElement> 입니다.
💡 상세 해설:
FormEvent는 가장 넓은 범위의 폼 제출 이벤트를 담당합니다.- 이 밖에 클릭은
MouseEvent, 포커스 이동은FocusEvent, 키보드 타이핑은KeyboardEvent가 짝꿍이라는 것을 외워두거나, 호버(Hover)해서 커닝하는 습관을 들이면 좋습니다.
Q3. [영철이의 테스트 타임: 긴급 디버깅] 영철이가 모달 창 외부 영역을 클릭하면 닫히게 만드는 커스텀 훅을 짰습니다. const modalRef = useRef<HTMLDivElement>();라고 써놓고 if (modalRef.current.contains(e.target)) 로직을 태웠더니, 앱이 켜지자마자 "Cannot read properties of undefined (reading 'contains')"가 발생했습니다. 코드를 본 영호 리드는 어떤 처방전을 내려줄까요?
✅ 정답: "영철 님, DOM 요소를 잡는 useRef는 반드시 괄호 안에 초기값 null을 넣어줘야 하고, 사용할 때는 옵셔널 체이닝(?.)이나 Truthy 검사가 필수입니다!" (const modalRef = useRef<HTMLDivElement>(null);)
💡 상세 해설:
- 원리 설명:
<HTMLDivElement>라고 타입만 써주고 초기값을 비워두면 TS는 기본적으로undefined가 섞인 상태로 인식하며, DOM 트리에 마운트되기 전 최초 렌더링 시점에는 꼼짝없이 런타임 에러가 터집니다. 명시적으로(null)을 주입하고, 사용할 때if (modalRef.current && ...)처럼 안전장치를 걸어야 합니다. modalRef.current!.contains(...)처럼 non-null assertion으로 억지 통과시키면 마운트 전 시점의 위험을 숨길 뿐입니다.- 핵심은 DOM 참조는 처음엔 비어 있다는 사실을 타입에 반영하고, 사용하는 순간에도
current가 채워졌는지 확인하는 것입니다.
🐣 영철이의 퇴근 일기
오늘은 React 컴포넌트도 결국 Props를 받아 JSX를 반환하는 함수라는 기준으로 다시 봤다. React.FC를 습관처럼 붙이는 대신, 이 컴포넌트가 children을 받아야 하는지부터 정하는 게 먼저였다.
이벤트 타입도 외우기 게임이 아니었다. 인라인 핸들러에 마우스를 올려 실제 타입을 확인하고, 분리할 때 React.ChangeEvent<HTMLInputElement>처럼 DOM 주체까지 같이 적으면 자동 완성이 훨씬 정확해진다.
💡 "React 타입은 컴포넌트의 사용 계약이다. Props, event target, ref의 비어 있는 시점을 타입에 남기면 런타임 방어 코드도 자연스럽게 따라온다."
내일 공통 Button PR에서는 children을 받는 버튼과 받지 않는 버튼을 구분해서 설계해보려고 한다. 모달 훅의 useRef도 null 초기값과 current 검사를 넣어, 영숙 님 QA에서 같은 클릭 버그가 다시 나오지 않게 해야겠다.