⚛️ 07. React x TypeScript: 영철이의 렌더링 폭발 막기

2026년 3월 5일 수정됨

📋 개요

React.FC의 지양 이유부터 이벤트 파라미터, useRef 컴포넌트 타이핑의 모든 것

📌 이 문서를 읽기 전에

⏱️ 예상 읽기 시간: 15분(전체) / 핵심 파트만: 8분

🗺️ 이 문서의 흐름
[컴포넌트 Props 선언의 정석] → [빌런 제압하기(Event 객체 타입 쪼개기)] → [두 얼굴의 useRef 길들이기]

🎯 이 문서를 다 읽으면 할 수 있는 것

  • React.FC를 쓰지 않고 순수 함수형으로 컴포넌트를 타이핑해야 하는지 아키텍처 관점에서 설명할 수 있다.
  • onClick(e)onChange(e)e가 가지는 React.MouseEvent, React.ChangeEvent를 능숙하게 부여할 수 있다.
  • DOM을 조작하는 useRef와 변수를 저장하는 useRef의 타이핑 차이를 구분하여 null 런타임 에러를 방지한다.

🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'

  • 🐣 영철 ( 신입 ): "리드 님! 컴포넌트 쪼개기를 배웠으니 이제 완벽한 Button 컴포넌트를 만들 겁니다. 근데 onClick 이벤트 함수에 마우스를 올리니까 Parameter 'e' implicitly has an 'any' type 이라고 화를 내네요? 게다가 이 버튼 컴포넌트 사이(children)에 아이콘을 넣으려니까 그것도 에러가 나고요. 리액트랑 타입스크립트 조합, 생각보다 빡센데요?"
  • 🦁 영호 ( 리드 ): "영철 님, 일반 함수 타이핑은 잘하시면서 왜 리액트 컴포넌트만 오면 지레 겁을 먹나요? 컴포넌트도 결국 '객체(Props)를 받아서 JSX를 뱉는 함수'일 뿐입니다. 리액트가 제공하는 마법의 타입 몇 가지만 외우면, 이 조합은 세상에서 제일 든든한 방어막이 됩니다."

🤔 왜 알아야 하는가: 'any' 컴포넌트의 비극

초보 시절, 순수 TS 문법(1~6강)을 떼고 의기양양하게 React 프로젝트로 돌아오면 가장 먼저 멘붕에 빠지는 곳이 바로 Props와 Event의 타이핑입니다.

"아잇 귀찮아, 일단 화면부터 띄우자!" 라며 영철이가 과거에 짜던 [any] 떡칠 컴포넌트를 볼까요?

// 💣 영철이의 과거: 언제 터질지 모르는 공용 버튼
const Button = (props: any) => {
  return <button onClick={props.action}>{props.text}</button>;
};
 
// 사용처
<Button text={123} 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도 포함되어 있고 편한데요?"

네, 17버전 이전의 React.FCchildren 속성을 숨겨서 강제로 넣어주었습니다. 이것이 몰락의 원인입니다. children을 쓰지도 않는 컴포넌트(<OldButton>자식</OldButton>)에 자식을 몰래 넣을 수 있게 허용하여, 디버깅을 지옥으로 만들었죠.
게다가 제네릭 컴포넌트를 만들 때 코드가 몹시 보기 흉해집니다.

✅ 5년 차 모던 방식: 순수 함수형 선언

현재 React 공식 문서와 글로벌 스탠다드가 권장하는 방식은, 컴포넌트를 그저 **'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 하나면 해결됩니다. 컴포넌트는 특별한 마법이 아니라 그냥 함수라는 멘탈 모델을 유지하세요."


⚡ 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>) {
    // e.target.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>) {
  // 폼 리프레시 방지의 국룰
  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'로 강제 주입해야 TS가 알아차립니다!
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; // DOM 요소가 아니므로 current에 값을 마음대로 쓱쓱 집어넣어도 됨!
  
  timerId.current = window.setInterval(() => console.log('째깍'), 1000);
  
  return () => window.clearInterval(timerId.current);
}, []);

📝 마무리 퀴즈

Q1. 과거에 유행했던 React.FC의 사용을 현대 리액트 생태계에서 권장하지 않는 가장 큰 이유는 무엇인가요?

정답: children 속성을 암묵적으로 포함하고 있어, 자식(Children)을 받지 않아야 할 컴포넌트의 타입 안정성(엄격함)을 해치기 때문입니다.

💡 상세 해설:

  • React.FC를 쓰면 굳이 선언하지 않은 Props의 기본 속성들(children, defaultProps 등)이 우르르 몰려와 자동 완성이나 디버깅을 오염시킵니다.
  • 그냥 일반 함수에 interface를 직접 넣어주는 것이 제일 깔끔하며, 자식이 꼭 필요하다면 리액트 18부터 추가된 PropsWithChildren<MyProps> 래퍼를 씌우는 것이 정석입니다.

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)를 박는 짓은 야수의 심장이 아니라 그냥 시한폭탄을 터뜨리는 겁니다. 옵셔널 체이닝(?.)을 사랑하세요."
  • 📌 핵심 기억법: DOM 잡을 땐 무조건 useRef<T>(null), 쓸 땐 current?.

🐣 영철이의 퇴근 일기

💡 "컴포넌트도 결국 JS 함수다. 리액트의 '마법'이라고 쫄지 말고, 인자(Props)와 반환값(JSX)의 모양만 꽉 잡아주면 된다."

드디어 리액트에 타입스크립트 옷을 제대로 입히는 법을 배웠다. 맨날 e: any 박아두고 "아몰랑 돌아가면 장땡이지" 했던 과거의 내 코드들이 부끄러워져서 창문 밖으로 뛰어내리고 싶을 정도다.
ChangeEventMouseEvent 두 개만 외웠는데도 이렇게 코드 짜는 속도(자동 완성의 축복!)가 달라질 줄이야. 특히 VScode에서 onChange에 마우스 올려서 타입 훔쳐오는 꿀팁은 내일 당장 영숙 님한테 아는 척해야지 ㅋㅋㅋ.
이제 영수네 커뮤니티 모든 공통 컴포넌트는 내가 타입 떡칠해서 절대 안 터지게 만들 거다. 오늘 진짜 보람차다! 맥주 캔 따야지!!