문서 읽는 데 65분 · A4

A-4: React 컴포넌트 타이핑 — props·이벤트·children·ref

목차 45
전체 6강 중 4강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

안녕하세요, 홍순구입니다. 지난 시간에 좋아요가 눌리고 댓글이 달리는 피드를 만들었죠. 그러면서 제가 마지막에 이런 말을 남겼습니다. 오늘 이벤트 객체의 타입을 한 번도 안 적었다고요.

정말 그랬는지 지난 시간 코드를 다시 봅시다.

tsx
// apps/web-spa/src/components/CommentForm.tsx — 지난 시간에 만든 입력창
<input
  className="comment-input"
  value={content}
  onChange={(event) => setContent(event.target.value)}
  placeholder="댓글 달기..."
  aria-label="댓글 입력"
/>

event 에 타입이 없습니다. 그런데도 event.target.value 를 칠 때 에디터가 자동완성을 띄워줬어요. TypeScript 가 이미 알고 있었다는 뜻입니다.

이제 이 핸들러를 밖으로 빼 봅시다. 함수 안이 길어지면 누구나 하는 일이죠.

tsx
function handleChange(event) {
  setContent(event.target.value);
}

빨간 줄이 그어집니다. 방금까지 알아서 잘 되던 게 안 돼요.

오늘은 이 빨간 줄에서 출발합니다. 컴포넌트에는 값이 드나드는 입구가 여럿 있어요. props 로 값이 들어오고, 이벤트로 사용자의 동작이 들어오고, children 으로 다른 화면 조각이 들어오고, ref 로는 실제 DOM 요소가 나갑니다. 오늘 이 네 입구에 전부 타입을 답니다.

텍스트
 오늘의 여정

 Step 1~3   이벤트 객체에 타입 달기 · 함수를 props 로 받을 때의 타입
    │
 Step 4~5   props 타입을 짓는 세 가지 길 · 컴포넌트를 함수로 선언하는 이유
    │
 Step 6~7   children 을 명시로 받기 · ref 를 props 로 받기
    
 네 입구가 모두 타입으로 지켜지는 인스타 컴포넌트

💡 오늘 수업의 핵심 — "컴포넌트의 입구를 타입으로 적는다"

지난 세 번의 TypeScript 시간에 데이터의 모양을 타입으로 적었습니다. 오늘은 그 타입을 컴포넌트에 연결해요. 무엇이 들어오고 무엇이 나가는지를 함수 시그니처 하나만 봐도 알 수 있게 만드는 것이 오늘의 전부입니다.

🎯 학습 목표

  • 이벤트 핸들러를 밖으로 뺐을 때 필요한 이벤트 타입을 스스로 찾아 적을 수 있다
  • props 타입을 새로 선언할지, 파생시킬지, 확장할지 판단할 수 있다
  • childrenref 를 props 로 명시해서 받을 수 있다

Step 1: "핸들러를 밖으로 빼는 순간"

오프닝에서 본 빨간 줄부터 확인하고 갑시다. 에디터에 마우스를 올리면 이렇게 나옵니다.

텍스트
error TS7006: Parameter 'event' implicitly has an 'any' type.

매개변수 event 가 슬그머니 any 가 됐다는 뜻입니다. 지난 A-1 시간에 strict 를 켜 두었죠. any 를 조용히 넘어가지 않고 잡아주는 것이 그 설정의 일입니다.

인라인일 때는 왜 됐을까

같은 코드인데 JSX 안에 있을 때는 아무 말이 없었습니다. 그 이유는 화살표 함수가 놓인 위치에 있어요.

tsx
onChange={(event) => setContent(event.target.value)}

<input>onChange 는 아무 함수나 받는 게 아닙니다. React 타입 정의에 "이 속성에는 이런 모양의 함수가 온다" 고 이미 적혀 있어요. 그래서 그 속성에 직접 써 넣은 화살표 함수는 자기 매개변수의 타입을 그 정보에서 물려받습니다. 주변 문맥을 보고 타입을 채워주는 것이라 문맥 기반 타이핑이라고 불러요.

밖으로 빼는 순간 이 연결이 끊어집니다. 함수 혼자 떨어져 있으면 자기가 나중에 어디에 쓰일지 알 방법이 없거든요.

텍스트
 인라인으로 쓸 때                       밖으로 뺐을 때

 <input onChange={(e) => ...} />        function handleChange(e) { ... }
                                                         
   onChange 가 원하는 함수 모양을            물려받을 문맥이 없다
   그 자리에서 물려받는다                      직접 적어야 한다

그래서 직접 적는다

tsx
// apps/web-spa/src/components/CommentForm.tsx
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
  setContent(event.target.value);
}

이름을 두 조각으로 나눠 읽으면 외울 게 없습니다. ChangeEvent 는 어떤 종류의 이벤트인지를 말하고, 꺾쇠 안의 HTMLInputElement 는 그 이벤트가 어느 요소에서 났는지를 말해요. 값이 바뀌는 사건이 입력창에서 일어났다, 이렇게 읽으면 됩니다.

이걸 적어주면 event.target.value 가 다시 string 으로 잡힙니다. 자동완성도 돌아와요.

지금까지의 CommentForm 은 이렇게 생겼습니다.

tsx
// apps/web-spa/src/components/CommentForm.tsx
import { useState } from 'react';

interface CommentFormProps {
  onSubmit: (content: string) => void;
}

export function CommentForm({ onSubmit }: CommentFormProps) {
  const [content, setContent] = useState('');
  const isEmpty = content.trim() === '';

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setContent(event.target.value);
  }

  return (
    <form
      className="comment-form"
      onSubmit={(event) => {
        event.preventDefault();
        onSubmit(content.trim());
        setContent('');
      }}
    >
      <input
        className="comment-input"
        value={content}
        onChange={handleChange}
        placeholder="댓글 달기..."
        aria-label="댓글 입력"
      />
      <button className="comment-submit" type="submit" disabled={isEmpty}>
        게시
      </button>
    </form>
  );
}

제출 핸들러는 아직 JSX 안에 남아 있습니다. 다음 Step 에서 저것도 꺼냅니다.

💡 한 줄 정리

JSX 속성에 직접 쓴 화살표 함수는 이벤트 타입을 문맥에서 물려받지만, 이름 붙여 밖으로 뺀 함수는 물려받을 문맥이 없어 직접 적어야 합니다.

🙋 학생 질문 — "튜터님, React 를 임포트한 적이 없는데 React.ChangeEvent 를 어떻게 쓰나요?"

좋은 관찰입니다. 파일 맨 위를 보면 import { useState } from 'react' 뿐이고 React 라는 이름을 가져온 적이 없죠.

타입을 적는 위치에서는 임포트 없이도 React. 로 시작하는 이름을 쓸 수 있습니다. React 타입 패키지가 이 이름을 전역으로 열어두기 때문이에요. 다만 이건 타입에만 해당합니다. 실제 값으로 쓰려고 하면 그때는 임포트하라는 에러가 납니다.

지난 A-3 시간에 배운 방식으로 가져와도 똑같이 동작합니다.

tsx
import type { ChangeEvent } from 'react';

function handleChange(event: ChangeEvent<HTMLInputElement>) {
  setContent(event.target.value);
}

둘 다 맞는 코드예요. 팀에서 한쪽으로 정해두면 됩니다. 우리 강의는 React. 를 붙이는 쪽으로 통일할게요. 이벤트 타입은 종류가 많은데, 앞에 React. 가 붙어 있으면 임포트 목록을 늘리지 않고도 어디서 온 이름인지 바로 보이거든요.


Step 2: "클릭과 제출 — MouseEvent 와 FormEvent"

이제 제출 핸들러도 밖으로 꺼냅시다. 방식은 똑같아요. 어떤 이벤트가 어느 요소에서 나는지만 적으면 됩니다.

tsx
// apps/web-spa/src/components/CommentForm.tsx
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
  event.preventDefault();
  if (isEmpty) {
    return;
  }
  onSubmit(content.trim());
  setContent('');
}

제출 사건이 폼에서 일어났으니 FormEventHTMLFormElement 입니다.

event.preventDefault() 는 지난 시간에도 있었던 줄이에요. 브라우저는 폼이 제출되면 페이지를 통째로 새로 불러오려고 합니다. 그 기본 동작을 막는 것이고, 이걸 빼먹으면 댓글을 달 때마다 화면이 깜빡이며 처음으로 돌아갑니다.

if (isEmpty) return 도 새로 넣었습니다. 버튼은 비활성이라 못 누르지만, 입력창에서 엔터를 치면 제출은 그대로 일어나거든요. 빈 댓글이 달리는 걸 막아줍니다.

tsx
// apps/web-spa/src/components/CommentForm.tsx
import { useState } from 'react';

interface CommentFormProps {
  onSubmit: (content: string) => void;
}

export function CommentForm({ onSubmit }: CommentFormProps) {
  const [content, setContent] = useState('');
  const isEmpty = content.trim() === '';

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setContent(event.target.value);
  }

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (isEmpty) {
      return;
    }
    onSubmit(content.trim());
    setContent('');
  }

  return (
    <form className="comment-form" onSubmit={handleSubmit}>
      <input
        className="comment-input"
        value={content}
        onChange={handleChange}
        placeholder="댓글 달기..."
        aria-label="댓글 입력"
      />
      <button className="comment-submit" type="submit" disabled={isEmpty}>
        게시
      </button>
    </form>
  );
}

JSX 가 훨씬 읽기 좋아졌습니다. 폼이 무엇을 그리는지와 제출될 때 무슨 일이 일어나는지가 나뉘었어요.

마우스 이벤트로 더블클릭 좋아요 만들기

인스타그램에는 사진을 두 번 두드리면 좋아요가 눌리는 동작이 있죠. 이제 만들 수 있습니다.

tsx
// apps/web-spa/src/components/PostCard.tsx
function handleImageDoubleClick(event: React.MouseEvent<HTMLImageElement>) {
  // 더블클릭이 이미지를 선택 상태로 만드는 브라우저 기본 동작을 막는다
  event.preventDefault();
  onToggleLike(id);
}
tsx
<img
  className="post-image"
  src={imageUrl}
  alt={`${username} 의 게시물`}
  onDoubleClick={handleImageDoubleClick}
/>

클릭 계열 사건이 이미지에서 났으니 MouseEventHTMLImageElement 입니다. 지난 시간에 만들어 둔 onToggleLike 를 그대로 부르기 때문에, 버튼으로 누르든 사진을 두드리든 같은 상태를 건드립니다.

이름 짓는 규칙은 하나뿐

이벤트 타입은 종류가 많지만 형태는 전부 같습니다.

사용자가 한 일 이벤트 타입 자주 쓰는 요소
입력창에 글자를 친다 React.ChangeEvent HTMLInputElement·HTMLTextAreaElement
버튼이나 사진을 누른다 React.MouseEvent HTMLButtonElement·HTMLImageElement
폼을 제출한다 React.FormEvent HTMLFormElement
키를 누른다 React.KeyboardEvent HTMLInputElement
입력창에 커서가 들어가고 나간다 React.FocusEvent HTMLInputElement

외울 필요는 없습니다. 요소 이름을 모르겠으면 JSX 안에 인라인으로 한 번 써 보세요. 에디터가 추론해서 보여주는 타입을 그대로 복사해 밖으로 옮기면 됩니다.

요소를 틀리게 적으면 어디서 걸릴까

HTMLInputElement 자리에 엉뚱한 요소를 적었다고 해봅시다. 재미있는 것은 에러가 뜨는 위치예요.

텍스트
error TS2322: Type '(event: ChangeEvent<HTMLFormElement, Element>) => void' is not
assignable to type 'ChangeEventHandler<HTMLInputElement, HTMLInputElement>'.

핸들러를 정의한 곳이 아니라 그 핸들러를 onChange 에 붙이는 곳에서 걸립니다. 함수 자체는 그것대로 말이 되는 함수니까요. 문제는 그 함수를 입력창에 붙일 수 없다는 것이고, TypeScript 는 붙이려는 순간에 그 사실을 말해줍니다.

💡 한 줄 정리

이벤트 타입은 React.{무슨 사건}Event<{어느 요소}> 한 가지 형태입니다. 요소를 잘못 적으면 핸들러를 정의한 곳이 아니라 JSX 에 붙이는 곳에서 걸립니다.

🙋 학생 질문 — "튜터님, event.target 과 event.currentTarget 은 뭐가 다른가요?"

currentTarget 은 핸들러를 붙여 둔 요소이고, target 은 실제로 사건이 시작된 요소입니다. 대개는 같지만 다를 때가 있어요. 버튼 안에 아이콘이 들어 있으면 아이콘을 눌렀을 때 target 은 아이콘이고 currentTarget 은 버튼입니다.

타입에서도 이 차이가 드러납니다. ChangeEventtarget 을 우리가 적어준 요소로 좁혀줘요. 그래서 event.target.value 가 바로 됩니다. 값이 바뀌는 사건은 값을 가진 요소에서만 나기 때문에 React 타입이 이렇게 정해뒀어요.

반면 MouseEvent 는 안 좁혀줍니다. 클릭은 어느 요소에서든 날 수 있으니까요. 확인해 보면 이렇게 말합니다.

텍스트
error TS2339: Property 'value' does not exist on type 'EventTarget'.

그래서 클릭 핸들러 안에서 요소의 정보를 읽고 싶으면 event.currentTarget 을 쓰세요. 이쪽은 우리가 적어준 요소 타입으로 좁혀져 있습니다. 습관을 하나만 들이자면 currentTarget 쪽이 안전합니다.


Step 3: "함수를 props 로 받을 때"

지금까지는 컴포넌트가 자기 안에서 쓰는 핸들러에 타입을 달았습니다. 그런데 함수는 props 를 타고 컴포넌트 사이를 오가기도 하죠. 지난 시간에 이미 그렇게 썼어요.

tsx
// apps/web-spa/src/components/CommentForm.tsx
interface CommentFormProps {
  onSubmit: (content: string) => void;
}

(content: string) => void 는 함수의 모양을 적은 타입입니다. 문자열 하나를 받고 아무것도 안 돌려준다는 뜻이에요. 화살표 왼쪽이 받는 것, 오른쪽이 돌려주는 것입니다.

돌려줄 게 없을 때 쓰는 void 가 눈에 띌 겁니다. 부모가 이 함수로 무엇을 하든 자식은 관심이 없다는 선언이에요. 자식은 그저 "댓글이 달렸다" 고 알리기만 하고, 그 뒤에 무슨 일이 일어날지는 부모가 정합니다.

지난 시간의 의문 하나

LikeButton 을 다시 봅시다.

tsx
// apps/web-spa/src/components/LikeButton.tsx
interface LikeButtonProps {
  liked: boolean;
  likeCount: number;
  onToggle: () => void;
}

onToggle 은 아무것도 안 받는 함수입니다. 그런데 이걸 버튼에 그대로 넘겼어요.

tsx
<button onClick={onToggle}>

onClick 은 함수를 부를 때 이벤트 객체를 하나 넘겨줍니다. 그런데 아무것도 안 받는 함수를 넣었는데도 통과했어요. 왜일까요.

넘겨주는 쪽이 인자를 하나 준다고 해서 받는 쪽이 반드시 받아야 하는 건 아니기 때문입니다. 안 쓸 거면 안 받아도 돼요. 반대로 받는 쪽이 요구하는 것보다 적게 주는 것은 안 됩니다.

텍스트
 onClick 이 부를 때:  handler(event)

 () => void          받기로 한 게 없다  event 를 그냥 흘려보낸다  통과
 (e: MouseEvent) => void   받기로 한 걸 받는다  통과
 (a, b) => void      두 번째 인자를 아무도 안 준다  거절

JavaScript 에서 인자를 덜 받는 함수를 부르는 건 원래 자연스러운 일이고, 타입도 그 사실을 그대로 따릅니다. 덕분에 이벤트 객체가 필요 없는 핸들러는 매개변수를 아예 안 적어도 됩니다. 실제로 클릭 핸들러 대부분이 이벤트 객체를 안 씁니다.

준비된 이름을 쓰는 방법

이벤트 핸들러의 모양은 워낙 자주 쓰여서 React 가 이름을 미리 만들어 뒀습니다.

tsx
// apps/web-spa/src/components/CommentInput.tsx — Step 7 에서 만들 입력창 컴포넌트
interface CommentInputProps {
  value: string;
  onChange: React.ChangeEventHandler<HTMLInputElement>;
}

React.ChangeEventHandler<HTMLInputElement>(event: React.ChangeEvent<HTMLInputElement>) => void 와 똑같은 뜻입니다. 짧아지죠. 이 컴포넌트는 Step 7 에서 실제로 만듭니다.

소괄호 하나가 만드는 사고

지난 시간에 함수를 넘기는 것과 부르는 것을 구분했습니다. props 로 넘길 때도 똑같은 실수가 나옵니다.

tsx
<LikeButton onToggle={handleToggle()} />
텍스트
error TS2322: Type 'void' is not assignable to type '() => void'.

handleToggle() 은 함수를 실행한 결과이고 그 결과는 void 입니다. props 는 나중에 부를 함수를 원했는데 이미 실행해 버린 뒤의 빈손을 받은 거예요. 함수 타입을 적어두면 이런 실수가 화면을 열어보기 전에 잡힙니다.

💡 한 줄 정리

props 로 받는 함수는 (받는 것) => 돌려주는 것 으로 적습니다. 받기로 한 것보다 적게 받는 함수는 그 자리에 들어갈 수 있어서, 이벤트가 필요 없는 핸들러는 매개변수를 안 적어도 됩니다.

🙋 학생 질문 — "튜터님, 핸들러 별칭과 직접 적기 중에 뭘 써야 하나요?"

둘은 완전히 같은 타입이라 어느 쪽을 써도 동작은 같습니다. 판단 기준은 그 props 가 무엇을 약속하느냐예요.

React.ChangeEventHandler<HTMLInputElement> 라고 적으면 "나는 입력창의 onChange 에 그대로 꽂을 함수를 받는다" 는 뜻이 됩니다. 그 컴포넌트가 입력창을 감싸고 있다는 사실이 타입에 드러나요.

반대로 (content: string) => void 처럼 적으면 이벤트 객체가 아니라 이미 다듬어진 값을 넘기겠다는 뜻입니다. CommentForm 이 이쪽이죠. 부모는 이벤트가 무엇인지 몰라도 되고 댓글 내용만 받으면 됩니다.

정리하면 이렇습니다. DOM 이벤트를 그대로 전달하는 얇은 컴포넌트라면 별칭이 잘 맞고, 자기가 한 번 가공해서 의미 있는 값을 올려보내는 컴포넌트라면 직접 적는 쪽이 낫습니다. 뒤쪽이 대체로 더 좋은 설계예요. 부모가 자식의 DOM 구조를 몰라도 되니까요.


Step 4: "props 타입을 짓는 세 가지 길"

지금 우리 코드베이스에는 props 타입이 세 가지 방식으로 만들어져 있습니다. 지금까지 그때그때 편한 대로 썼는데, 오늘 기준을 세우고 갑시다.

첫째, 새로 선언한다

tsx
// apps/web-spa/src/components/Avatar.tsx
interface AvatarProps {
  username: string;
  profileImageUrl: string;
}

Avatar 가 필요한 것만 손으로 적었습니다. 짧고, 이 컴포넌트가 무엇을 요구하는지 한눈에 보여요.

둘째, 원본에서 파생시킨다

tsx
// apps/web-spa/src/types/derived.ts
export type PostCardProps = Omit<Post, 'hashtagNames' | 'createdAt'>;

A-3 에서 만든 타입입니다. Post 에서 카드가 안 쓰는 두 개를 빼고 나머지를 그대로 씁니다. 백엔드가 Post 에 필드를 하나 추가하면 이쪽도 자동으로 따라와요.

셋째, 있는 것을 확장한다

tsx
// apps/web-spa/src/components/PostCard.tsx
interface PostCardViewProps extends PostCardProps {
  onToggleLike: (id: number) => void;
}

지난 시간에 만든 것입니다. 서버가 준 데이터는 PostCardProps 그대로 두고, 화면에서만 필요한 콜백을 얹었어요.

무엇을 언제 쓰나

방식 어울리는 곳 대가
새로 선언 서버 데이터와 상관없는 작은 표시용 컴포넌트 원본이 바뀌어도 안 따라온다
파생 서버 응답을 거의 그대로 그리는 컴포넌트 원본을 모르면 무엇이 오는지 안 보인다
확장 데이터에 화면 전용 값을 더할 때 상속 사슬이 길어지면 추적이 힘들다

기준을 한 문장으로 줄이면 이렇습니다. 그 props 가 서버 데이터의 일부인가, 화면이 만들어낸 값인가. 서버에서 온 것은 원본에서 파생시키고, 화면이 만들어낸 것은 손으로 적습니다. PostCardViewProps 가 정확히 이 둘을 나눠 담고 있어요.

interfacetype 중 무엇을 쓸지도 같은 결론입니다. A-1 에서 정한 대로 공개되는 객체 모양은 interface, 유틸리티 타입으로 만든 것은 type 입니다. extends 로 얹을 일이 있으면 interface 가 편해요.

💡 한 줄 정리

서버 데이터에서 온 props 는 원본 타입에서 파생시키고, 화면이 만들어낸 props 는 새로 적습니다. 둘이 섞이면 extends 로 나눠 담습니다.

🙋 학생 질문 — "튜터님, 그럼 AvatarProps 도 Post 에서 파생시키는 게 낫지 않나요?"

지난 시간 마지막에 이 이야기를 던져뒀었죠. 답부터 말하면 지금은 안 하는 쪽이 낫습니다.

Avatar 가 받는 usernameprofileImageUrl 은 확실히 Post 에도 있는 이름입니다. Pick<Post, 'username' | 'profileImageUrl'> 로 뽑아낼 수 있어요. 그런데 그렇게 적는 순간 Avatar 는 게시물 전용 컴포넌트가 됩니다.

아바타는 게시물에만 뜨는 게 아니에요. 프로필 화면에도 뜨고, 댓글 목록에도 뜨고, 팔로워 목록에도 뜹니다. 그때 넘어오는 데이터는 Post 가 아니라 UserComment 죠. Post 에서 파생시켜 두면 User 를 그리려는 순간 타입이 안 맞습니다.

그래서 판단 기준이 "이름이 같은가" 가 아니라 "운명을 같이하는가" 입니다. Post 에 필드가 하나 늘었을 때 Avatar 도 따라 바뀌어야 하나요? 아니죠. 서로 상관없이 바뀔 것들을 타입으로 묶어두면 나중에 한쪽을 고칠 때 다른 쪽이 딸려옵니다.

PostCardProps 는 반대입니다. 카드는 게시물을 그리려고 존재하니까 Post 와 운명을 같이해요. 그래서 파생이 맞습니다.


Step 5: "컴포넌트를 함수로 선언하는 이유"

여기까지 우리가 만든 컴포넌트는 전부 같은 모양이었습니다.

텍스트
function 이름({ 구조분해 }: Props) {
  return (JSX);
}

너무 당연해 보이지만, 사실 이건 선택의 결과입니다. 예전에는 다른 방식이 표준에 가까웠고, 지금도 오래된 코드나 조금 지난 블로그 글에서는 그쪽을 자주 만나게 될 거예요.

예전 방식

React.FC 라는 타입이 있습니다. Function Component 의 줄임말이고, 컴포넌트를 변수에 담으면서 그 변수에 이 타입을 붙이는 방식이었어요. 이름을 알아둬야 하는 이유는 하나입니다. 남의 코드에서 봤을 때 "아, 옛날 방식이구나" 하고 알아보기 위해서요. 새로 쓸 일은 없습니다.

밀려난 이유는 세 가지입니다.

첫째, children 이 자동으로 딸려왔습니다. 자식을 받을 생각이 없는 컴포넌트에도 자식을 넘길 수 있었어요. props 에 안 적은 것이 들어온다는 뜻이라 타입이 거짓말을 하는 셈이었습니다. 이 문제는 지금은 고쳐졌지만, 고치는 과정에서 버전마다 동작이 달라져 혼란이 컸습니다.

둘째, 제네릭 컴포넌트를 만들기가 까다로웠습니다. 어떤 타입의 목록이든 받아 그리는 컴포넌트 같은 것을 말해요. 변수에 타입을 붙이는 형태라 타입 매개변수를 얹을 자리가 마땅치 않았습니다. 이건 다음 시간에 직접 만들어 봅니다.

셋째, 함수 선언의 장점을 버려야 했습니다. function 으로 선언하면 파일 안 어디에 두든 위에서 쓸 수 있고, 스택 트레이스에 이름이 제대로 찍히고, 에러 메시지도 짧습니다.

그래서 지금은 함수로 선언하고 매개변수에 타입을 붙이는 방식이 표준입니다. 우리가 처음부터 써온 그대로예요.

반환 타입은 적지 않습니다

함수를 배울 때 반환 타입을 적으라고 배웠으니 이렇게 쓰고 싶어질 수 있습니다.

tsx
export function Avatar({ username }: AvatarProps): JSX.Element {

그런데 이건 아예 컴파일이 안 됩니다.

텍스트
error TS2503: Cannot find namespace 'JSX'.

예전에는 JSX 라는 이름이 전역에 열려 있었지만 React 19 의 타입 정의에서는 React 안쪽으로 들어갔습니다. 그래서 굳이 적으려면 React.JSX.Element 라고 써야 해요.

하지만 적지 않는 쪽을 권합니다. 컴포넌트가 무엇을 돌려주는지는 return 문만 봐도 뻔하고, TypeScript 가 알아서 정확하게 추론합니다. A-3 에서 이야기했듯 애너테이션은 추론이 못 하거나 틀리게 하는 곳에만 붙이는 것이 좋아요. 여기는 둘 다 아닙니다.

💡 한 줄 정리

컴포넌트는 function 으로 선언하고 매개변수에 props 타입을 붙입니다. 반환 타입은 추론에 맡깁니다.

🙋 학생 질문 — "튜터님, 그럼 화살표 함수로 컴포넌트를 만들면 틀린 건가요?"

틀리지 않습니다. 아래 두 개는 똑같이 잘 동작해요.

tsx
function Avatar({ username }: AvatarProps) {
  return <span>{username}</span>;
}

const Avatar = ({ username }: AvatarProps) => {
  return <span>{username}</span>;
};

제가 앞에서 밀어낸 것은 화살표 함수 자체가 아니라 변수에 컴포넌트 전용 타입을 붙이는 방식입니다. 화살표 함수를 쓰면서 매개변수에 타입을 다는 건 아무 문제가 없어요.

실제로 팀에 따라 화살표 함수로 통일하는 곳도 많습니다. 파일 안에서 컴포넌트와 일반 함수를 한눈에 구분하기 좋다는 이유로요.

우리 강의는 function 선언으로 통일합니다. 컴포넌트가 여러 개인 파일에서 순서 신경 안 쓰고 위아래로 배치할 수 있어서요. 여러분이 나중에 팀에 들어가면 그 팀 규칙을 따르면 됩니다. 중요한 건 한 프로젝트 안에서 섞어 쓰지 않는 것입니다.


Step 6: "children 을 명시로 받기"

지금까지 컴포넌트에 값을 넘길 때는 항상 속성을 썼습니다. 그런데 HTML 을 떠올려 보면 태그 사이에 내용을 넣는 방식도 있죠.

HTML
<section>
  <p>여기가 내용입니다</p>
</section>

React 컴포넌트도 똑같이 쓸 수 있습니다. 피드 위에 제목을 붙이는 껍데기를 하나 만들어 봅시다.

tsx
// apps/web-spa/src/components/Section.tsx

interface SectionProps {
  title: string;
  children: React.ReactNode;
}

export function Section({ title, children }: SectionProps) {
  return (
    <section className="section" aria-label={title}>
      <h2 className="section-title">{title}</h2>
      {children}
    </section>
  );
}

children 은 React 가 정해둔 특별한 이름입니다. 태그 사이에 넣은 것이 이 이름으로 들어와요. 나머지는 우리가 아는 props 와 똑같습니다. props 목록에 적고, 구조분해로 꺼내고, JSX 안에서 중괄호로 그립니다.

App 에서 이렇게 씁니다.

tsx
// apps/web-spa/src/App.tsx
<Section title="피드">
  <Feed posts={posts} onToggleLike={handleToggleLike} />
</Section>

<Section></Section> 사이에 넣은 <Feed />children 으로 넘어가서 제목 아래에 그려집니다.

ReactNode 는 넓은 타입입니다

React.ReactNode 는 화면에 그릴 수 있는 거의 모든 것을 받습니다.

텍스트
 React.ReactNode 가 받는 것

 <Feed />              컴포넌트
 <p>글자</p>            HTML 요소
 "그냥 문자열"          문자열
 42                    숫자
 [<A />, <B />]        위 것들의 배열
 null · undefined      아무것도 안 그린다

 못 받는 것: { username: 'jaehoon' } 같은 순수 객체

넓은 이유는 분명합니다. 태그 사이에 뭐가 올지는 쓰는 사람 마음이니까요. 숫자나 문자열을 그대로 넣어도 되고, 조건에 따라 null 을 넣어 아무것도 안 그릴 수도 있습니다.

순수 객체만 막혀 있는데, 이건 React 가 화면에 어떻게 그려야 할지 모르기 때문입니다. 실수로 객체를 넣으면 이렇게 알려줘요.

텍스트
error TS2353: Object literal may only specify known properties, and 'username'
does not exist in type 'ReactElement<...> | Iterable<ReactNode> | ReactPortal | ...'

안 적으면 못 받습니다

여기가 중요합니다. props 에 children 을 안 적어두면 자식을 넘길 수 없어요.

tsx
interface BadgeProps {
  label: string;
}

export function Badge({ label }: BadgeProps) {
  return <span className="badge">{label}</span>;
}

이 컴포넌트에 자식을 넣으면 이렇게 막힙니다.

텍스트
error TS2322: Type '{ children: string; label: string; }' is not assignable to type
'IntrinsicAttributes & BadgeProps'.
  Property 'children' does not exist on type 'IntrinsicAttributes & BadgeProps'.

불편해 보이지만 이게 맞습니다. Badge 는 자식을 그릴 방법이 없거든요. 넘겨봐야 화면에 안 나옵니다. 조용히 사라지는 대신 미리 막아주는 편이 훨씬 낫죠. 앞 Step 에서 이야기한 예전 방식의 문제가 정확히 이것이었습니다.

자식을 받고 싶으면 받겠다고 적으면 됩니다. 그것뿐이에요.

💡 한 줄 정리

태그 사이의 내용은 children 이라는 이름으로 들어옵니다. children: React.ReactNode 를 props 에 적어둔 컴포넌트만 자식을 받을 수 있습니다.

🙋 학생 질문 — "튜터님, 그냥 props 로 넘기면 되는데 왜 굳이 children 을 쓰나요?"

<Section title="피드" content={<Feed />} /> 처럼 써도 화면은 똑같이 나옵니다. 맞는 말이에요.

차이는 읽는 사람에게 있습니다. children 으로 쓰면 감싸는 관계가 코드 모양에 그대로 드러나요. 여는 태그와 닫는 태그 사이에 들어 있으니 무엇이 무엇을 감싸는지 눈으로 보입니다. HTML 을 읽던 감각을 그대로 쓸 수 있고요.

그리고 자식이 여러 개이거나 개수가 정해지지 않았을 때 훨씬 편합니다. props 로 넘기려면 배열로 묶어야 하는데, 태그 사이에는 그냥 나열하면 되거든요.

다만 자식을 받을 곳이 여러 군데면 이야기가 달라집니다. 예를 들어 카드에 머리말과 본문과 꼬리말을 각각 받아야 하면 children 하나로는 부족해요. 그때는 header·footer 같은 이름으로 나눠 받습니다. 이렇게 컴포넌트를 조립해서 화면을 만드는 방법은 다음 시간(B-3)에 본격적으로 다룹니다.


Step 7: "ref 를 props 로 받기"

마지막 입구입니다. 지금까지는 값이 들어오기만 했는데, 이번에는 컴포넌트 안의 실제 DOM 요소를 밖으로 꺼내 봅니다.

이유가 있어요. 지금 댓글을 달고 나면 입력창에서 커서가 사라집니다. 연달아 댓글을 쓰려면 매번 입력창을 다시 눌러야 해요. 인스타그램은 안 그렇죠.

커서를 옮기는 일은 값을 바꿔서 할 수 없습니다. 실제 <input> 요소를 붙잡고 focus() 를 불러야 해요. 그 붙잡는 손잡이가 ref 입니다.

입력창을 별도 컴포넌트로 떼어냅니다

tsx
// apps/web-spa/src/components/CommentInput.tsx

interface CommentInputProps {
  value: string;
  onChange: React.ChangeEventHandler<HTMLInputElement>;
  ref: React.Ref<HTMLInputElement>;
}

export function CommentInput({ value, onChange, ref }: CommentInputProps) {
  return (
    <input
      className="comment-input"
      ref={ref}
      value={value}
      onChange={onChange}
      placeholder="댓글 달기..."
      aria-label="댓글 입력"
    />
  );
}

ref 를 다른 props 와 똑같이 받아서 <input> 에 그대로 넘겼습니다. 특별할 게 없어 보이죠. 이게 React 19 의 변화입니다.

타입은 React.Ref<HTMLInputElement> 예요. 앞에서 배운 규칙이 그대로 통합니다. 무엇을 가리키는 손잡이인지를 꺾쇠 안에 적습니다.

예전에는 특별 취급이었습니다

ref 는 오랫동안 일반 props 가 아니었습니다. 컴포넌트에 ref 를 넘기면 React 가 중간에서 가로챘고, 자식에게 전달하려면 forwardRef 라는 함수로 컴포넌트를 한 겹 감싸야 했어요. 그러면 컴포넌트 모양이 달라지고 타입도 복잡해졌습니다.

React 19 부터는 감쌀 필요가 없습니다. ref 가 그냥 props 가 됐어요. forwardRef 는 아직 동작하지만 쓸 이유가 없고, React 팀도 앞으로 걷어낼 예정이라고 밝혔습니다. 오래된 코드에서 만나면 "이건 옛날에 필요했던 껍데기구나" 하고 알아보면 됩니다.

부모가 손잡이를 만들어 넘깁니다

tsx
// apps/web-spa/src/components/CommentForm.tsx
import { useRef, useState } from 'react';
import { CommentInput } from './CommentInput';

interface CommentFormProps {
  onSubmit: (content: string) => void;
}

export function CommentForm({ onSubmit }: CommentFormProps) {
  const [content, setContent] = useState('');
  const inputRef = useRef<HTMLInputElement>(null);
  const isEmpty = content.trim() === '';

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setContent(event.target.value);
  }

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (isEmpty) {
      return;
    }
    onSubmit(content.trim());
    setContent('');
    inputRef.current?.focus();
  }

  return (
    <form className="comment-form" onSubmit={handleSubmit}>
      <CommentInput ref={inputRef} value={content} onChange={handleChange} />
      <button className="comment-submit" type="submit" disabled={isEmpty}>
        게시
      </button>
    </form>
  );
}

useRef 로 빈 손잡이를 만들어 CommentInput 에 넘깁니다. React 가 실제 <input> 을 그리고 나면 그 요소를 inputRef.current 에 넣어줘요.

inputRef.current?.focus() 의 물음표를 보세요. 아직 화면에 안 그려졌을 수도 있어서 current 는 비어 있을 수 있습니다. A-2 에서 배운 좁히기가 여기서 쓰입니다. 있을 때만 focus() 를 부르는 거예요.

이제 댓글을 달면 커서가 입력창에 남습니다. 연달아 쓸 수 있어요.

텍스트
 상태로 하는 일                    ref 로 하는 일

 값이 바뀌면 화면을 다시 그린다      화면을 다시 그리지 않는다
 무엇을 보여줄지 정한다             이미 그려진 요소를 직접 만진다

 예) 입력값·좋아요·댓글 목록        예) 커서 옮기기·스크롤 위치·재생 정지

경계는 이렇게 잡으면 됩니다. 화면에 보이는 내용이 달라지면 상태, 화면은 그대로인데 브라우저에 뭔가 시켜야 하면 ref 입니다. ref 를 상태 대신 쓰면 값이 바뀌어도 화면이 안 따라오니 조심하세요.

💡 한 줄 정리

React 19 에서 ref 는 일반 props 입니다. ref: React.Ref<HTMLInputElement> 로 받아 안쪽 요소에 넘기면, 부모가 그 요소를 직접 만질 수 있습니다.

🙋 학생 질문 — "튜터님, ref 를 아무 컴포넌트에나 넘길 수 있나요?"

아니요. 받겠다고 적어둔 컴포넌트에만 넘길 수 있습니다. children 과 똑같아요. props 에 ref 가 없으면 넘길 수 없습니다.

그리고 어떤 요소를 가리키는지도 맞아야 합니다. 입력창용 손잡이를 다른 요소에 달면 이렇게 막혀요.

텍스트
error TS2322: Type 'Ref<HTMLInputElement>' is not assignable to type
'Ref<HTMLTextAreaElement> | undefined'.

당연한 안전장치입니다. HTMLInputElement 라고 적어둔 손잡이를 잡고 입력창에만 있는 기능을 부를 텐데, 실제로는 다른 요소가 들어 있으면 실행 중에 터지겠죠. 그걸 미리 막아줍니다.

하나 더 기억해 두세요. ref 는 꼭 필요할 때만 씁니다. 화면에 보이는 것을 바꾸려고 ref 로 DOM 을 직접 고치기 시작하면 지난 과목의 순수 JS 로 돌아가는 것과 같습니다. 상태로 안 되는 일에만 쓰세요.


마무리

오늘 컴포넌트의 입구를 하나씩 열어봤습니다. props 로 값이 들어오고, 이벤트로 사용자의 동작이 들어오고, children 으로 다른 화면 조각이 들어오고, ref 로 요소가 나갑니다. 네 개 다 타입으로 적었어요.

지난 세 번의 TypeScript 시간이 데이터의 모양을 그리는 일이었다면 오늘은 그 타입이 화면과 만나는 지점을 다뤘습니다. Post 라는 타입 하나가 PostCardProps 로 파생되고, 그게 다시 컴포넌트의 입구가 되어 화면까지 이어졌어요.

오늘 배운 핵심 세 가지

💡 하나 — 문맥이 없으면 타입을 직접 적습니다. JSX 속성에 바로 쓴 화살표 함수는 이벤트 타입을 물려받지만, 이름 붙여 밖으로 뺀 함수는 그 연결이 끊깁니다. 그래서 React.{무슨 사건}Event<{어느 요소}> 를 적어줍니다.

💡 둘 — props 타입은 출처를 따라 짓습니다. 서버 데이터에서 온 것은 원본에서 파생시키고, 화면이 만들어낸 것은 새로 적습니다. 둘이 섞이면 extends 로 나눠 담아요. 기준은 이름이 같은지가 아니라 함께 바뀔 운명인지입니다.

💡 셋 — 안 적은 것은 못 받습니다. childrenref 도 props 에 적어둔 컴포넌트만 받을 수 있습니다. 불편해 보여도 이게 맞아요. 받을 방법이 없는 것이 조용히 넘어와 사라지는 것보다 낫습니다.

다음 시간 예고

오늘 Section 을 만들면서 자식을 받는 껍데기를 처음 써봤습니다. 그런데 자식을 받을 곳이 하나뿐이라 제목과 본문 정도만 나눌 수 있었어요. 실제 화면에는 머리말과 꼬리말이 따로 있는 카드, 눌러야 열리는 패널처럼 자식을 여러 갈래로 받아야 하는 껍데기가 많습니다.

다음 시간에는 컴포넌트를 조립해서 화면을 만드는 방법을 배웁니다. 큰 컴포넌트를 언제 쪼개야 하는지, 쪼갠 것들을 어떻게 다시 합칠지가 주제예요.

그리고 오늘 CommentForm 안에 useStateuseRef 와 핸들러 두 개가 함께 들어갔습니다. 지난 시간 LikeButton 에서도 비슷한 묶음을 봤죠. 이렇게 반복되는 묶음을 통째로 뽑아 이름을 붙이는 방법도 함께 다룹니다.


과제

[구현] 댓글 입력창에 글자 수 세기

CommentInput 아래에 지금 몇 글자를 썼는지 보여주고, 너무 길면 못 쓰게 막아 주세요.

요구사항은 다음과 같습니다.

  • apps/web-spa/src/components/CommentCounter.tsxCommentCounter 컴포넌트를 만들어 주세요. 지금 글자 수와 최대 글자 수를 props 로 받습니다.
  • 최대 글자 수는 100 자로 하고, CommentForm 이 넘겨줍니다. CommentCounter 안에 숫자를 적어두지 마세요.
  • 남은 글자가 10 자 이하로 떨어지면 눈에 띄게 보여 주세요.
  • 100 자를 넘기면 게시 버튼이 안 눌리게 해주세요. 지금 빈 값을 막는 것과 같은 방식으로 하면 됩니다.
  • CommentForm 의 핸들러는 전부 이름 붙인 함수로 두고, 이벤트 타입을 명시해 주세요.
  • Section 을 써서 피드 제목 아래에 카드가 들어가는 구조는 그대로 유지해 주세요.

다 만들고 나면 npm run typecheck -w web-spanpm run lint -w web-spa 를 돌려 둘 다 통과하는지 확인해 주세요.

useMemouseCallback 은 쓰지 마세요. 아직 안 배웠고, 오늘 배운 것만으로 충분히 됩니다.

[탐구] 타입을 일부러 어겨보기

아래 여섯 가지를 하나씩 시도해 보고, 에디터가 뭐라고 하는지와 그게 무슨 뜻인지 한 줄씩 적어 주세요.

  • handleChangeReact.ChangeEvent<HTMLInputElement> 에서 꺾쇠 안을 HTMLFormElement 로 바꿔보기
  • 더블클릭 핸들러 안에 console.log(event.currentTarget.src) 를 넣어보고, 그 다음 event.target.src 로 바꿔보기
  • Avatar 의 반환 타입에 : JSX.Element 를 적어보기
  • Badge 처럼 children 을 안 받는 컴포넌트에 자식 넘겨보기
  • CommentInput 에 넘기는 inputRef<textarea> 에 달아보기
  • <LikeButton onToggle={handleToggle} />onToggle={handleToggle()} 로 바꿔보기

특히 첫 번째와 두 번째를 비교해 보세요. 하나는 핸들러를 붙이는 곳에서 걸리고 다른 하나는 핸들러 안에서 걸립니다. 왜 걸리는 위치가 다른지 생각해 보세요.


생각해볼 주제

1. 타입을 파생시킬 것인가 새로 적을 것인가

오늘 PostCardPropsPost 에서 파생시켰고 AvatarProps 는 새로 적었습니다. 기준으로 "함께 바뀔 운명인가" 를 제시했죠.

그런데 실무에서는 이 판단이 처음부터 명확한 경우가 드뭅니다. 지금은 게시물에만 쓰이지만 나중에 다른 화면에도 쓰일지 모르는 컴포넌트라면 어느 쪽으로 시작하시겠어요. 나중에 방향을 바꿔야 할 때 어느 쪽에서 어느 쪽으로 옮기는 게 더 쉬울지도 함께 생각해 보세요.

2. 이벤트 타입을 매번 적는 비용

오늘 핸들러를 밖으로 뺄 때마다 타입을 적었습니다. 인라인으로 두면 안 적어도 되는데 말이죠.

그렇다면 짧은 핸들러는 계속 인라인으로 두는 게 나을까요. 아니면 길이와 상관없이 밖으로 빼는 규칙을 정하는 게 나을까요. 타입을 적는 수고 말고도 두 방식은 읽기 쉬움이나 컴포넌트가 다시 그려질 때의 동작에서 차이가 납니다. 어떤 기준으로 정하시겠어요.

3. ref 가 일반 props 가 된 것

React 19 는 ref 를 특별 취급하던 규칙을 없애고 보통의 props 로 만들었습니다. 감싸는 껍데기가 사라져서 코드는 확실히 짧아졌어요.

그런데 특별 취급에는 특별 취급 나름의 쓸모가 있었을 수도 있습니다. 문법적으로 눈에 띄면 "여기서 DOM 을 직접 만지는구나" 하고 알아채기 쉬웠을 테니까요. 쉬워진 것과 눈에 덜 띄게 된 것 사이에서 무엇을 얻고 무엇을 잃었을까요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 댓글 입력창에 글자 수 세기

채점 포인트

항목 확인 내용 배점
props 설계 지금 글자 수와 최대 글자 수를 둘 다 props 로 받았는가 20
한도의 위치 100 이라는 숫자가 CommentCounter 안에 없고 CommentForm 에 있는가 15
이벤트 타이핑 핸들러가 전부 이름 붙은 함수이고 이벤트 타입이 명시됐는가 20
경고 표시 남은 글자가 10 자 이하일 때 구별되게 보이는가 10
제출 차단 한도를 넘기면 버튼도 잠기고 엔터로도 안 올라가는가 20
검사 통과 typechecklint 가 모두 통과하는가 15

한도를 넘긴 상태에서 엔터를 쳐봤다면 이 과제의 숨은 부분까지 잡으신 겁니다.

풀이 예시

먼저 세어 보여주는 컴포넌트입니다.

tsx
// apps/web-spa/src/components/CommentCounter.tsx

interface CommentCounterProps {
  count: number;
  max: number;
}

export function CommentCounter({ count, max }: CommentCounterProps) {
  const remaining = max - count;
  const isNearLimit = remaining <= 10;

  return (
    <p
      className={isNearLimit ? 'comment-counter near-limit' : 'comment-counter'}
      aria-label="글자 수"
    >
      {count} / {max}
    </p>
  );
}

props 두 개를 모두 받은 것이 핵심입니다. max 를 안 받고 컴포넌트 안에 const MAX = 100 이라고 적으면 당장은 동작하지만, 나중에 소개글 입력창에 이 컴포넌트를 다시 쓰려는 순간 막힙니다. 소개글은 한도가 다르거든요.

remaining 을 상태로 두지 않은 것도 봐주세요. 글자 수에서 계산해 나오는 값이라 따로 기억할 이유가 없습니다. 지난 시간에 배운 기준 그대로예요. 다른 값에서 계산되는 것은 상태로 두지 않습니다.

다음은 폼입니다.

tsx
// apps/web-spa/src/components/CommentForm.tsx
import { useRef, useState } from 'react';
import { CommentInput } from './CommentInput';
import { CommentCounter } from './CommentCounter';

const MAX_COMMENT_LENGTH = 100;

interface CommentFormProps {
  onSubmit: (content: string) => void;
}

export function CommentForm({ onSubmit }: CommentFormProps) {
  const [content, setContent] = useState('');
  const inputRef = useRef<HTMLInputElement>(null);

  const isEmpty = content.trim() === '';
  const isTooLong = content.length > MAX_COMMENT_LENGTH;
  const cannotSubmit = isEmpty || isTooLong;

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setContent(event.target.value);
  }

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (cannotSubmit) {
      return;
    }
    onSubmit(content.trim());
    setContent('');
    inputRef.current?.focus();
  }

  return (
    <form className="comment-form" onSubmit={handleSubmit}>
      <CommentInput ref={inputRef} value={content} onChange={handleChange} />
      <CommentCounter count={content.length} max={MAX_COMMENT_LENGTH} />
      <button className="comment-submit" type="submit" disabled={cannotSubmit}>
        게시
      </button>
    </form>
  );
}

MAX_COMMENT_LENGTH 를 컴포넌트 바깥에 둔 이유가 있습니다. 렌더링과 상관없이 항상 같은 값이라 안에 둘 이유가 없어요. 밖에 두면 다시 그릴 때마다 새로 만들지 않습니다.

막는 조건을 cannotSubmit 하나로 합쳐둔 것도 봐주세요. 버튼의 disabledhandleSubmit 의 방어가 같은 조건을 보게 됩니다. 두 곳에 따로 적으면 한쪽만 고치는 사고가 나거든요.

스타일은 이 정도면 됩니다. 직접 globals.css 에 더해 보세요.

CSS
.comment-counter {
  margin: 0;
  padding: 0 12px;
  font-size: 12px;
  color: #8e8e8e;
  text-align: right;
}

.comment-counter.near-limit {
  color: #ed4956;
  font-weight: 600;
}

자주 나오는 실수

한도를 컴포넌트 안에 적는 것. 요구사항에서 굳이 막아둔 부분입니다. CommentCounter 는 세어서 보여주는 일만 하고, 얼마까지 허용할지는 쓰는 쪽이 정합니다. 이렇게 나눠두면 소개글이든 댓글이든 같은 컴포넌트를 씁니다.

남은 글자를 상태로 두는 것.

tsx
const [remaining, setRemaining] = useState(100);

function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
  setContent(event.target.value);
  setRemaining(100 - event.target.value.length);
}

동작은 합니다. 하지만 상태가 두 개가 되면서 둘이 어긋날 길이 열립니다. 다른 곳에서 setContent 만 부르면 글자 수가 안 맞게 되죠. 계산되는 값은 계산으로 두세요.

버튼만 잠그고 끝내는 것. 버튼의 disabled 는 마우스로 누르는 것만 막습니다. 입력창에 커서를 두고 엔터를 치면 제출은 그대로 일어나요. handleSubmit 안에도 같은 방어를 넣어야 합니다.

maxLength 속성으로 처리하는 것. <input maxLength={100} /> 을 쓰면 아예 101 번째 글자가 안 쳐집니다. 간단해 보이지만 사용자는 왜 안 써지는지 모릅니다. 넘긴 상태를 보여주고 왜 못 올리는지 알려주는 편이 친절해요.

💡 튜터의 한마디

이 과제의 진짜 주제는 글자 세기가 아니라 무엇을 props 로 열어둘 것인가 입니다.

컴포넌트를 만들 때마다 이 질문을 하게 될 거예요. 안에 고정할까, 밖에서 받을까. 전부 받게 하면 쓸 때마다 넘겨야 해서 번거롭고, 전부 고정하면 한 곳에서만 쓸 수 있습니다.

기준은 "이 값이 쓰는 곳마다 다를까" 입니다. 한도는 다르고, 세는 방식은 같죠. 그래서 한도는 props 가 되고 세는 방식은 안에 남습니다.

🎯 [과제 2 예시답안] 타입을 일부러 어겨보기

채점 포인트

항목 확인 내용 배점
여섯 가지 시도 각각 실제로 해보고 메시지를 옮겨 적었는가 30
메시지 해석 무엇이 무엇과 안 맞는지 자기 말로 설명했는가 30
위치 비교 첫 번째와 두 번째가 왜 다른 곳에서 걸리는지 답했는가 25
정리 여섯 개를 관통하는 규칙을 한 줄로 뽑았는가 15

풀이 예시

1. 꺾쇠 안을 HTMLFormElement 로 바꾸기

텍스트
error TS2322: Type '(event: ChangeEvent<HTMLFormElement, Element>) => void' is not
assignable to type 'ChangeEventHandler<HTMLInputElement, HTMLInputElement>'.

핸들러를 정의한 곳이 아니라 onChange={handleChange} 라고 붙이는 곳에서 걸립니다. 폼에서 나는 변경 사건을 받는 함수를 만든 것 자체는 말이 되니까요. 문제는 그 함수를 입력창에 붙일 수 없다는 것입니다.

2. event.currentTarget.srcevent.target.src 로 바꾸기

텍스트
error TS2339: Property 'src' does not exist on type 'EventTarget'.

이번에는 핸들러 안에서 걸립니다. currentTargetHTMLImageElement 로 좁혀져 있어서 src 를 읽을 수 있지만, target 은 좁혀지지 않은 EventTarget 이라 src 가 없어요.

3. 반환 타입에 : JSX.Element 적기

텍스트
error TS2503: Cannot find namespace 'JSX'.

타입이 안 맞는다는 말이 아니라 그런 이름을 못 찾겠다는 말입니다. React 19 의 타입 정의에서 이 이름은 React 안쪽으로 들어갔어요. 적으려면 React.JSX.Element 라고 써야 하지만, 안 적는 쪽이 낫습니다.

4. children 을 안 받는 컴포넌트에 자식 넘기기

텍스트
error TS2322: Type '{ children: string; label: string; }' is not assignable to type
'IntrinsicAttributes & BadgeProps'.
  Property 'children' does not exist on type 'IntrinsicAttributes & BadgeProps'.

태그 사이에 넣은 내용이 children 이라는 props 로 바뀐다는 사실이 메시지에 그대로 보입니다. BadgeProps 에는 그런 props 가 없다고 하고요.

5. inputRef<textarea> 에 달기

텍스트
error TS2322: Type 'Ref<HTMLInputElement>' is not assignable to type
'Ref<HTMLTextAreaElement> | undefined'.

useRef<HTMLInputElement> 로 만든 손잡이는 입력창만 잡을 수 있습니다. 여기에 텍스트 영역을 넣으면 나중에 입력창에만 있는 기능을 부를 때 터지겠죠.

6. onToggle={handleToggle}onToggle={handleToggle()} 로 바꾸기

텍스트
error TS2322: Type 'void' is not assignable to type '() => void'.

소괄호를 붙이는 순간 함수가 아니라 함수를 실행한 결과가 넘어갑니다. handleToggle 은 아무것도 안 돌려주니 그 결과는 void 예요. props 는 나중에 부를 함수를 원했는데 빈손을 받았습니다.

위치가 다른 이유

1 번과 2 번을 비교해 보라고 한 이유입니다.

텍스트
 1번 — 함수 자체는 문제없다          2번 — 함수 안에서 이미 틀렸다

 function handleChange(             function handleDoubleClick(
   e: ChangeEvent<HTMLFormElement>    e: MouseEvent<HTMLImageElement>
 ) { ... }          여긴 통과       ) {
                                       e.target.src    여기서 걸림
 <input onChange={handleChange} />   }
                   여기서 걸림

1 번은 함수 혼자 놓고 보면 아무 문제가 없습니다. 폼의 변경 사건을 받는 함수일 뿐이에요. 입력창에 붙이려는 순간에야 안 맞는다는 게 드러납니다.

2 번은 함수 안에서 이미 틀렸습니다. 마우스 이벤트의 targetsrc 가 없다는 건 붙이기 전에도 알 수 있으니까요.

여섯 개를 관통하는 규칙

타입은 값이 만들어지는 곳이 아니라 값이 쓰이는 곳 에서 검사됩니다. 함수를 만들 때는 통과하고 그 함수를 넘길 때 걸리는 것, 손잡이를 만들 때는 통과하고 엉뚱한 요소에 달 때 걸리는 것이 전부 같은 이야기예요.

자주 나오는 실수

메시지를 복사만 하고 넘어가는 것. 이 과제의 절반은 읽는 연습입니다. TypeScript 메시지는 대개 "무엇이 무엇에 할당될 수 없다" 형태예요. 앞의 것이 내가 준 것, 뒤의 것이 요구되는 것입니다. 이 순서만 알아도 대부분 읽힙니다.

빨간 줄이 그어진 곳만 보는 것. 1 번처럼 원인과 증상이 다른 곳에 있을 때가 있어요. 붙이는 곳에 빨간 줄이 그어졌다고 그 줄을 고치려 들면 안 됩니다. 고칠 곳은 핸들러의 타입 선언입니다.

🤔 [생각해볼 주제 1] 타입을 파생시킬 것인가 새로 적을 것인가

문제 상황 요약

PostCardPropsPost 에서 파생시켰고 AvatarProps 는 새로 적었습니다. 기준으로 "함께 바뀔 운명인가" 를 제시했죠.

그런데 새 컴포넌트를 만드는 시점에는 이걸 알기 어렵습니다. 지금은 게시물 화면에만 있지만 나중에 다른 곳에도 쓰일지 모르는 컴포넌트라면 어느 쪽으로 시작해야 할까요.

튜터의 가이드 및 해설

결론부터 말하면 새로 적는 쪽으로 시작합니다. 확신이 없을 때는요.

이유는 나중에 방향을 바꿀 때의 비용이 한쪽으로 크게 기울어 있기 때문입니다.

새로 적어둔 것을 파생으로 바꾸는 일은 쉽습니다. 필드 이름이 이미 같다면 선언 한 줄을 Pick<Post, ...> 로 갈아끼우면 끝이에요. 컴포넌트 본문도, 그 컴포넌트를 쓰는 곳도 안 바뀝니다.

반대는 훨씬 아픕니다. Post 에서 파생시켜 둔 컴포넌트를 User 에도 쓰려고 하면, 그 컴포넌트를 쓰는 모든 곳이 이미 Post 를 넘기고 있어요. 타입만 바꾸는 게 아니라 호출하는 쪽까지 훑어야 합니다.

그런데 예외가 하나 있습니다. 서버 응답을 거의 그대로 그리는 컴포넌트라면 처음부터 파생이 맞아요. 백엔드 필드가 늘거나 이름이 바뀔 때 자동으로 따라오는 것이 이런 컴포넌트에서는 진짜 이득이거든요. PostCard 가 그렇습니다. 게시물을 그리려고 존재하는데 게시물 타입과 따로 놀 이유가 없습니다.

정리하면 판단 순서는 이렇습니다.

텍스트
 이 컴포넌트는 특정 서버 응답을 그리려고 존재하는가?

   예   파생시킨다 (PostCard · CommentItem · ProfileHeader)
   아니오  새로 적는다 (Avatar · Button · Badge · Section)
   모르겠다  새로 적는다 (나중에 파생으로 바꾸기 쉽다)

한 가지 덧붙이면, 필드 이름이 같다는 사실 자체는 근거가 못 됩니다. username 이라는 이름은 Post 에도 User 에도 Comment 에도 있어요. 이름이 겹치는 것과 함께 바뀌는 것은 다릅니다.

🎯 면접관을 홀리는 핵심 멘트

"타입 파생은 중복을 줄이는 도구가 아니라 의존 관계를 선언하는 도구라고 봅니다. PostCardPropsPost 에서 파생시킨 건 코드를 줄이려는 게 아니라 이 컴포넌트가 게시물 응답과 운명을 같이한다고 못 박은 겁니다. 그래서 이름이 겹친다는 이유로 파생시키지는 않습니다. 확신이 없으면 새로 적어두는데, 나중에 파생으로 바꾸는 건 선언 한 줄이지만 반대로 되돌리려면 호출하는 곳까지 전부 훑어야 하거든요. 되돌리기 싼 쪽에서 시작하는 게 원칙입니다."

🤔 [생각해볼 주제 2] 이벤트 타입을 매번 적는 비용

문제 상황 요약

핸들러를 밖으로 뺄 때마다 이벤트 타입을 적었습니다. 인라인으로 두면 안 적어도 되는데 말이죠.

짧은 핸들러는 계속 인라인으로 둘까요, 아니면 길이와 상관없이 밖으로 빼는 규칙을 세울까요.

튜터의 가이드 및 해설

먼저 이 선택이 성능 문제라고 생각하기 쉬운데, 지금은 그렇지 않다는 걸 짚고 갑시다.

인라인 화살표 함수는 컴포넌트를 다시 그릴 때마다 새로 만들어집니다. 예전에는 이것이 실제로 문제가 됐어요. 자식에게 넘기는 함수가 매번 달라지면 자식도 매번 다시 그려졌거든요. 지금은 빌드할 때 컴파일러가 이런 것을 알아서 처리합니다. 성능을 이유로 인라인을 피할 필요는 없어요. 정말 느린 곳이 생기면 그때 재보고 손대면 되고, 그 방법은 C-4 에서 다룹니다.

그래서 판단 기준은 읽기 쉬움입니다.

인라인이 나은 경우는 그 함수가 무엇을 하는지 한 눈에 들어올 때입니다.

tsx
<button onClick={() => onToggleLike(post.id)}>

여기서는 클릭하면 무슨 일이 나는지 버튼을 보는 순간 알 수 있습니다. 이걸 굳이 밖으로 빼면 이름을 확인하러 위로 올라갔다 내려와야 해요. 오히려 손해입니다.

밖으로 빼는 게 나은 경우는 세 가지입니다.

첫째, 두 줄을 넘어갈 때. JSX 는 화면 구조를 보여주는 곳인데 그 사이에 로직이 길게 끼면 구조가 안 보입니다. 오늘 handleSubmit 이 그랬죠. preventDefault, 빈 값 검사, 부모 호출, 입력창 비우기, 커서 되돌리기까지 다섯 가지를 하는데 이게 <form> 태그 안에 들어 있으면 폼이 무엇을 그리는지 안 보입니다.

둘째, 이름이 설명이 될 때. handleImageDoubleClick 이라는 이름 하나가 주석 한 줄 역할을 합니다.

셋째, 이벤트 객체를 실제로 쓸 때. 이 경우는 타입을 적는 것이 오히려 이득이에요. event.target.value 를 쓰는 순간 그 함수가 입력창 전용이라는 사실이 타입에 드러납니다.

그래서 제가 권하는 규칙은 이렇습니다.

상황 어떻게
한 줄이고 이벤트 객체를 안 쓴다 인라인
두 줄 이상이다 밖으로 빼고 타입 명시
이벤트 객체를 쓴다 밖으로 빼고 타입 명시

타입을 적는 수고 자체는 생각보다 작습니다. 요소 이름이 기억 안 나면 인라인으로 한 번 써서 에디터가 보여주는 타입을 복사해 오면 되니까요. 그 수고보다는 팀 안에서 기준이 흔들리는 쪽이 더 비쌉니다.

🎯 면접관을 홀리는 핵심 멘트

"인라인이냐 분리냐를 성능으로 판단하지 않습니다. React Compiler 가 들어온 뒤로 매 렌더마다 함수가 새로 만들어지는 것은 대부분 문제가 아니고, 정말 느리면 그때 측정해서 손대면 되니까요. 제 기준은 JSX 가 구조를 보여주는 데 집중하느냐 입니다. 한 줄로 끝나고 이벤트 객체도 안 쓰면 인라인이 더 읽히고, 두 줄이 넘어가거나 이벤트 객체를 만지기 시작하면 밖으로 뺍니다. 밖으로 빼면 타입을 적어야 하는데, 그게 비용이 아니라 그 핸들러가 어떤 요소에 붙는 물건인지 문서가 돼서 오히려 남는 장사라고 봅니다."

🤔 [생각해볼 주제 3] ref 가 일반 props 가 된 것

문제 상황 요약

React 19 는 ref 를 특별 취급하던 규칙을 없애고 보통의 props 로 만들었습니다. 감싸는 껍데기가 사라져 코드가 짧아졌어요.

그런데 특별 취급에도 나름의 쓸모가 있었을 수 있습니다. 눈에 띄면 "여기서 DOM 을 직접 만지는구나" 하고 알아채기 쉬웠을 테니까요.

튜터의 가이드 및 해설

얻은 것부터 정리해 봅시다.

배우는 순서가 자연스러워졌습니다. 예전에는 입력창에 커서를 옮기는 정도의 일을 하려 해도 감싸는 함수 하나를 먼저 배워야 했어요. props 를 이제 막 이해한 학생에게 "ref 는 props 가 아니라서 다른 방식으로 받아야 한다" 는 예외를 설명해야 했습니다. 지금은 예외가 사라졌습니다.

타입이 단순해졌습니다. 오늘 우리가 적은 것은 ref: React.Ref<HTMLInputElement> 한 줄이었죠. 다른 props 와 형태가 같습니다.

그리고 명시적이 됐습니다. 이게 사실 제일 큽니다. 예전에는 컴포넌트가 ref 를 받을지 말지가 감싸는 함수를 썼느냐로 정해졌어요. 지금은 props 목록에 ref 를 적었느냐로 정해집니다. 받을 생각이 없으면 안 적으면 되고, 안 적힌 컴포넌트에 ref 를 넘기면 타입이 막습니다. children 과 똑같은 규칙이 됐어요.

잃은 것도 분명합니다.

가장 큰 것은 눈에 덜 띄게 됐다는 점입니다. 감싸는 함수가 있으면 그 컴포넌트가 DOM 을 밖으로 내준다는 사실이 코드 모양에서 튀었어요. 지금은 props 목록 한 줄이라 훑어보다 지나치기 쉽습니다.

이게 왜 문제가 되냐면, ref 는 남용하기 쉬운 도구이기 때문입니다. 상태로 풀어야 할 것을 ref 로 DOM 을 직접 고쳐서 해결하면 당장은 돌아가요. 그런데 그 순간 React 가 화면을 관리한다는 전제가 깨집니다. 화면과 상태가 어긋나기 시작하고, 지난 과목의 순수 JS 로 돌아가는 셈이 됩니다.

그래서 실무에서는 이렇게 메웁니다.

ref 를 받는 컴포넌트를 만들 때 왜 필요한지 짧게 남깁니다. 그리고 코드 리뷰에서 ref props 가 새로 생기면 "이거 상태로는 안 되나요" 를 한 번 묻습니다. 문법이 안 해주는 신호를 팀의 습관으로 대신하는 거예요.

정리하면 이 변화는 입문 비용을 줄이는 대신 규율의 책임을 팀으로 옮긴 것 입니다. 프레임워크가 문법으로 막아주던 것을 사람이 봐야 하게 됐어요. React 전반이 이런 방향으로 가고 있습니다. 예외를 줄이고 일관된 규칙으로 통일하는 대신, 무엇을 조심할지는 쓰는 사람이 알아야 하는 쪽으로요.

🎯 면접관을 홀리는 핵심 멘트

"ref-as-prop 은 단순히 forwardRef 를 없앤 편의 기능이 아니라 일관성을 위해 예외를 걷어낸 결정이라고 봅니다. ref 가 props 가 되면서 children 과 같은 규칙을 따르게 됐어요. 받겠다고 적은 컴포넌트만 받고, 안 적었으면 타입이 막습니다. 대신 잃은 것도 있는데, 감싸는 함수가 사라지면서 이 컴포넌트가 DOM 을 밖으로 내준다는 신호가 코드에서 덜 튀게 됐습니다. ref 는 남용하면 React 가 화면을 관리한다는 전제를 깨뜨리는 도구라서, 저희 팀은 리뷰에서 ref props 가 새로 생기면 상태로 안 되는 이유를 한 번 확인하는 규칙을 둡니다. 프레임워크가 문법으로 막아주던 걸 팀 습관으로 옮겨온 셈이죠."

전체 목록 리액트