문서 읽는 데 130분 · B3

B-3: 컴포넌트 설계 — children·합성·커스텀 훅

목차 65
전체 13강 중 9강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

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

안녕하세요, 홍순구입니다. 지난 시간에 컴포넌트의 네 입구에 타입을 달았죠. props 로 값이 들어오고, 이벤트로 동작이 들어오고, children 으로 화면 조각이 들어오고, ref 로 요소가 나갔습니다.

그중 children 을 배우면서 껍데기 컴포넌트를 하나 만들었어요.

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} 딱 하나입니다. 제목은 title 로 따로 받지만 그건 문자열만 통과시키는 입구예요. 그래서 이 껍데기로 만들 수 있는 건 "제목 하나와 그 아래 전부" 정도입니다.

그런데 인스타그램 카드를 떠올려 보세요. 위에는 프로필 줄이 있고, 가운데는 사진과 캡션이 있고, 아래에는 댓글 입력창이 붙어 있습니다. 껍데기 하나가 자식을 세 갈래로 받아야 해요.

남겨둔 것이 하나 더 있습니다. 지난 시간 CommentForm 안에 useStateuseRef 와 핸들러 두 개가 함께 들어갔죠. LikeButton 을 만들 때도 비슷한 묶음을 봤고요. 화면을 그리는 줄보다 그 앞에 쌓이는 준비 줄이 더 길어졌습니다.

오늘은 이 둘을 정리합니다. 큰 컴포넌트를 쪼개고, 쪼갠 것들을 다시 조립하고, 반복되는 로직 묶음에 이름을 붙여 밖으로 꺼냅니다.

텍스트
 오늘의 여정

 Step 1~2   언제 쪼갤까 · children 으로 안쪽을 받는 Button
    │
 Step 3~4   자식을 여러 갈래로 받는 Card · 합성으로 카드 조립
    │
 Step 5~6   훅을 부르는 규칙 · 좋아요 로직에 이름 붙이기
    │
 Step 7~8   접기 펼치기 훅 · 댓글 입력 묶음을 훅으로
    
 조각으로 조립되고 로직에 이름이 붙은 인스타 피드

💡 오늘 수업의 핵심 — "쪼개는 기준을 세우고, 조립으로 다시 붙인다"

지난 시간까지는 컴포넌트 하나하나를 잘 만드는 이야기였습니다. 오늘은 여러 개를 어떻게 나누고 어떻게 합칠지를 다뤄요. 나누는 판단과 합치는 방법이 오늘의 전부입니다.

🎯 학습 목표

  • 컴포넌트를 언제 쪼개고 언제 쪼개지 않을지 기준을 세워 판단할 수 있다
  • children 과 이름 붙인 슬롯으로 안쪽을 받는 껍데기 컴포넌트를 만들 수 있다
  • 반복되는 상태와 핸들러 묶음을 커스텀 훅으로 뽑아 이름을 붙일 수 있다

Step 1: "언제 쪼갤까"

지난 시간을 마친 PostCard 를 열어봅시다. 그동안 기능을 하나씩 얹기만 했더니 이렇게 됐습니다. PostCardViewPropsDraftComment 는 앞서 만들어 둔 타입이라 파일 위쪽에 그대로 있고, 아래는 컴포넌트 부분만 떼어 온 것입니다.

tsx
// apps/web-spa/src/components/PostCard.tsx — 지난 시간까지의 모습
export function PostCard({
  id,
  username,
  profileImageUrl,
  imageUrl,
  content,
  liked,
  likeCount,
  commentCount,
  onToggleLike,
}: PostCardViewProps) {
  // 이 댓글은 이 카드만 쓰니까 카드 안에 둔다
  const [comments, setComments] = useState<DraftComment[]>([]);

  function addComment(text: string) {
    setComments([...comments, { id: comments.length + 1, content: text }]);
  }

  function handleImageDoubleClick(event: React.MouseEvent<HTMLImageElement>) {
    // 더블클릭이 이미지를 선택 상태로 만드는 브라우저 기본 동작을 막는다
    event.preventDefault();
    onToggleLike(id);
  }

  return (
    <article className="post-card">
      <Avatar username={username} profileImageUrl={profileImageUrl} />
      <img
        className="post-image"
        src={imageUrl}
        alt={`${username} 의 게시물`}
        onDoubleClick={handleImageDoubleClick}
      />
      <LikeButton
        liked={liked}
        likeCount={likeCount}
        onToggle={() => onToggleLike(id)}
      />
      <p className="post-content">
        <strong>{username}</strong> {content}
      </p>
      <p className="post-comments">
        댓글 {commentCount + comments.length}개 모두 보기
      </p>
      <ul className="comment-list">
        {comments.map((comment) => (
          <li key={comment.id}>
            <strong>me</strong> {comment.content}
          </li>
        ))}
      </ul>
      <CommentForm onSubmit={addComment} />
    </article>
  );
}

한 파일이 하는 일을 세어 봅시다.

텍스트
 PostCard 한 파일이 그리는 것

 Avatar             프로필 사진과 이름
 img                사진 · 더블클릭하면 좋아요
 LikeButton         좋아요 버튼과 개수
 p.post-content     캡션
 p.post-comments    댓글 수
 ul.comment-list    지금 화면에서 단 댓글
 CommentForm        댓글 입력창

일곱 개입니다. 지금도 못 읽을 정도는 아니에요. 그런데 여기에 저장 버튼, 공유 버튼, 게시 시각, 해시태그가 더 붙으면 어떻게 될까요. 캡션 한 줄을 고치려고 스크롤을 한참 내려야 하는 파일이 됩니다.

쪼개는 기준

여기서 조심할 게 있습니다. "길면 쪼갠다" 는 기준이 아니에요. 줄 수를 기준으로 삼으면 아무 의미 없는 조각이 우수수 생기고, 그때부터는 파일 사이를 왔다 갔다 하느라 오히려 읽기 어려워집니다.

기준은 세 가지 중 하나라도 해당하는지로 잡습니다.

쪼갤 이유 우리 카드에서
자기 상태나 이벤트를 가진다 그 조각만의 동작이 있다 사진의 더블클릭 좋아요
둘 이상을 하나로 묶는다 묶음 자체가 이름을 가질 만하다 프로필 + 더보기 버튼
다른 곳에서도 쓴다 재사용 대상이다 버튼·카드 껍데기

셋 다 아니면 안 쪼갭니다. 이 문장이 오늘 Step 1 의 절반이에요.

안 쪼개도 되는 것부터

카드 맨 위의 프로필 줄을 떼어내 봅시다. 이렇게 만들고 싶어질 겁니다.

tsx
// apps/web-spa/src/components/PostHeader.tsx — 이렇게 만들 뻔했습니다
interface PostHeaderProps {
  username: string;
  profileImageUrl: string;
}

export function PostHeader({ username, profileImageUrl }: PostHeaderProps) {
  return <Avatar username={username} profileImageUrl={profileImageUrl} />;
}

파일이 하나 늘었는데 늘어난 값어치가 없습니다. 받은 props 를 Avatar 에 그대로 넘기기만 해요. 화면도 동작도 Avatar 와 완전히 같고, 바뀐 건 이름뿐입니다. 이런 껍데기가 쌓이면 Avatar 하나를 고치러 가는 길에 파일을 두 번 열게 됩니다.

이유가 생기면 그때 쪼갠다

인스타그램 카드 오른쪽 위에는 점 세 개짜리 더보기 버튼이 있죠. 그걸 넣어 봅시다.

tsx
// apps/web-spa/src/components/PostHeader.tsx
export function PostHeader({ username, profileImageUrl }: PostHeaderProps) {
  return (
    <div className="post-header">
      <Avatar username={username} profileImageUrl={profileImageUrl} />
      <button className="post-more" aria-label="게시물 메뉴">
        ⋯
      </button>
    </div>
  );
}

이제 쪼갤 이유가 생겼습니다. 프로필과 더보기 버튼을 한 줄에 나란히 세운다는 규칙이 이 파일 안에 담겼어요. 표의 두 번째 기준에 해당합니다.

<button> 태그를 직접 쓴 건 마음에 걸리실 텐데, 지금은 공통 버튼이 없어서 그렇습니다. Step 2 에서 바꿉니다.

사진 구역과 본문 구역

사진은 더블클릭 핸들러를 가지고 있으니 첫 번째 기준에 걸립니다.

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

interface PostImageProps {
  imageUrl: string;
  username: string;
  onLike: () => void;
}

export function PostImage({ imageUrl, username, onLike }: PostImageProps) {
  function handleDoubleClick(event: React.MouseEvent<HTMLImageElement>) {
    // 더블클릭이 이미지를 선택 상태로 만드는 브라우저 기본 동작을 막는다
    event.preventDefault();
    onLike();
  }

  return (
    <img
      className="post-image"
      src={imageUrl}
      alt={`${username} 의 게시물`}
      onDoubleClick={handleDoubleClick}
    />
  );
}

핸들러가 자기 파일 안으로 따라 들어왔습니다. 카드는 이제 더블클릭이 있다는 사실조차 몰라도 돼요. onLike 만 넘겨주면 됩니다.

사진 아래는 좋아요와 캡션과 댓글 수가 이어집니다. 셋은 늘 함께 붙어 다니니 두 번째 기준에 해당해요.

tsx
// apps/web-spa/src/components/PostBody.tsx
interface PostBodyProps {
  username: string;
  content: string;
  liked: boolean;
  likeCount: number;
  commentCount: number;
  onToggle: () => void;
}

// 사진 아래 본문 구역 — 좋아요·캡션·댓글 수가 함께 산다.
export function PostBody({
  username,
  content,
  liked,
  likeCount,
  commentCount,
  onToggle,
}: PostBodyProps) {
  return (
    <>
      <LikeButton liked={liked} likeCount={likeCount} onToggle={onToggle} />
      <p className="post-content">
        <strong>{username}</strong> {content}
      </p>
      <p className="post-comments">댓글 {commentCount}개 모두 보기</p>
    </>
  );
}

<> 프래그먼트로 감싼 것을 눈여겨보세요. 세 요소를 묶어 돌려주면서 쓸데없는 <div> 를 하나도 안 만들었습니다. 감싸는 상자가 필요해서 묶은 게 아니라 셋이 한 덩어리라서 묶은 것이니까요.

안 쪼개기로 한 것 하나 더

댓글 목록도 봅시다. map 이 돌고 있으니 반사적으로 CommentItem 을 만들고 싶어져요.

tsx
{comments.map((comment) => (
  <li key={comment.id}>
    <strong>me</strong> {comment.content}
  </li>
))}

기준 세 개를 대 봅시다. 한 줄은 자기 상태가 없습니다. 이벤트도 없어요. 묶고 있는 것도 이름 하나와 내용 하나뿐이고, 다른 화면에서 이 모습 그대로 쓸 일도 없습니다. 셋 다 아니니 안 만듭니다.

나중에 댓글 한 줄마다 좋아요나 답글 버튼이 생기면 그때가 첫 번째 기준에 걸리는 순간이에요. 그때 꺼내면 됩니다. 미리 만들어 두는 것이 준비성 있어 보이지만, 쓰이지 않는 추상은 읽는 사람에게 "이건 왜 따로 있지" 라는 질문만 남깁니다.

화면은 그대로입니다

세 조각을 떼어낸 카드는 이렇게 됐습니다.

tsx
// apps/web-spa/src/components/PostCard.tsx
export function PostCard({
  id,
  username,
  profileImageUrl,
  imageUrl,
  content,
  liked,
  likeCount,
  commentCount,
  onToggleLike,
}: PostCardViewProps) {
  const [comments, setComments] = useState<DraftComment[]>([]);

  function addComment(text: string) {
    setComments([...comments, { id: comments.length + 1, content: text }]);
  }

  return (
    <article className="post-card">
      <PostHeader username={username} profileImageUrl={profileImageUrl} />
      <PostImage
        imageUrl={imageUrl}
        username={username}
        onLike={() => onToggleLike(id)}
      />
      <PostBody
        username={username}
        content={content}
        liked={liked}
        likeCount={likeCount}
        commentCount={commentCount + comments.length}
        onToggle={() => onToggleLike(id)}
      />
      <ul className="comment-list">
        {comments.map((comment) => (
          <li key={comment.id}>
            <strong>me</strong> {comment.content}
          </li>
        ))}
      </ul>
      <CommentForm onSubmit={addComment} />
    </article>
  );
}
텍스트
 PostCard
 ├── PostHeader        Avatar + ⋯ 더보기
 ├── PostImage         사진 + 더블클릭 좋아요
 ├── PostBody          좋아요 + 캡션 + 댓글 수
 ├── ul.comment-list   (아직 카드가 직접 그린다)
 └── CommentForm

브라우저를 새로고침해 보세요. 오른쪽 위에 더보기 버튼이 하나 생긴 것 말고는 화면이 그대로고, 좋아요도 댓글도 똑같이 동작합니다. 당연합니다. 쪼개면서 우리가 한 일은 같은 JSX 를 다른 파일로 옮긴 것뿐이고, 새로 더한 건 그 버튼 하나거든요.

달라진 건 읽는 경험입니다. 카드를 열면 "머리 · 사진 · 본문 · 댓글 · 입력창 이 순서로 온다" 는 문장이 먼저 보이고, 각 구역의 세부는 그 구역 파일이 압니다.

💡 한 줄 정리

쪼개는 이유는 길이가 아니라 자기 동작이 있는지, 묶을 것이 둘 이상인지, 다른 곳에서도 쓰는지입니다. 셋 다 아니면 안 쪼갭니다.

🙋 학생 질문 — "튜터님, 컴포넌트 하나가 몇 줄을 넘으면 쪼개라는 규칙은 없나요?"

찾아보면 "150줄 넘으면 쪼개라" 같은 말이 돌아다닙니다. 그런데 그 숫자를 규칙으로 삼으면 이상한 일이 벌어져요.

150줄짜리 폼이 하나 있다고 합시다. 입력 항목이 스무 개라서 긴 것뿐이고, 나눌 만한 덩어리는 없어요. 억지로 셋으로 자르면 props 를 셋으로 나눠 넘기고 상태는 여전히 위에 있는, 아무도 이해 못 할 세 파일이 생깁니다. 반대로 30줄인데 안에 타이머와 구독과 폼 상태가 뒤엉킨 컴포넌트도 있어요. 이건 짧아도 쪼개야 합니다.

줄 수는 신호일 뿐이에요. 길어지면 "여기 여러 일이 섞였나" 를 한번 들여다보라는 알림 정도입니다. 실제 판단은 오늘 세운 세 기준으로 하세요.

한 가지 더 도움이 되는 감각이 있습니다. 쪼갠 조각에 이름을 붙여 보고, 그 이름이 PostSectionWrapperContainer 처럼 억지스러워지면 쪼갤 덩어리가 아니라는 뜻입니다. 좋은 이름이 바로 떠오르는 조각은 대개 쪼갤 만한 조각이에요.


Step 2: "children — 안쪽을 받는 컴포넌트"

Step 1 에서 PostHeader 를 만들면서 <button> 태그를 직접 썼습니다. 그런데 지금 우리 코드에서 버튼이 나오는 곳을 세어 보면 세 군데예요.

tsx
// apps/web-spa/src/components/LikeButton.tsx — 지금까지의 모습
<button
  className={liked ? 'like-button liked' : 'like-button'}
  onClick={onToggle}
>
  {liked ? '♥ 좋아요 취소' : '♡ 좋아요'}
</button>
tsx
// apps/web-spa/src/components/CommentForm.tsx — 지금까지의 모습
<button className="comment-submit" type="submit" disabled={isEmpty}>
  게시
</button>

세 곳이 각자 <button> 을 쓰고 각자 규칙을 적습니다. 지금은 별일 없어 보이지만, 나중에 "버튼은 비활성일 때 흐리게" 같은 규칙이 생기면 세 곳을 전부 찾아다녀야 해요. 하나라도 빠뜨리면 그 버튼만 다르게 보입니다.

공통 버튼을 하나 만듭시다.

안쪽을 받는 껍데기

버튼마다 다른 건 안에 들어가는 글자입니다. 좋아요 버튼엔 하트가, 제출 버튼엔 "게시" 가, 더보기 버튼엔 점 세 개가 들어가요. 그러니까 안쪽 내용을 밖에서 받아야 합니다. 지난 시간에 배운 children 이 정확히 그 일을 합니다.

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

interface ButtonProps {
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  // 안 적으면 'button' — form 안에서 눌러도 제출되지 않는다
  type?: 'button' | 'submit';
  // 생김새는 쓰는 쪽이 정한다
  className?: string;
  // 글자 대신 기호만 보이는 버튼은 읽어줄 이름을 따로 준다
  'aria-label'?: string;
}

export function Button({
  children,
  onClick,
  disabled,
  type = 'button',
  className,
  'aria-label': ariaLabel,
}: ButtonProps) {
  return (
    <button
      className={className}
      type={type}
      disabled={disabled}
      onClick={onClick}
      aria-label={ariaLabel}
    >
      {children}
    </button>
  );
}

children: React.ReactNode 는 지난 시간 Section 에 적었던 것과 똑같습니다. 태그 사이에 넣은 것이 이 이름으로 들어와 <button> 안쪽에 그려져요.

className 을 받는 것도 눈여겨보세요. 생김새까지 이 파일이 정해 버리면 좋아요 버튼과 제출 버튼을 다르게 꾸밀 수 없습니다. 껍데기는 동작 규칙만 쥐고 겉모습은 쓰는 쪽에 맡깁니다.

type 기본값이 'button' 인 이유

type = 'button' 이 오늘의 작은 발견입니다. HTML 을 배울 때 지나쳤을 법한 함정을 막아줘요.

텍스트
 HTML 의 <button> 은 type 을 안 적으면 submit 이다
   └─ form 안에 두면 누를 때마다 폼이 제출된다

 우리 Button 은 type 을 안 적으면 button 이다
   └─ form 안에서 눌러도 아무 일이 안 일어난다
   └─ 진짜 제출할 버튼에만 type="submit" 을 적는다

댓글 폼 안에 나중에 이모지 버튼이나 사진 첨부 버튼을 넣는다고 생각해 보세요. type 을 안 적으면 이모지를 고르려고 누른 순간 댓글이 제출됩니다. 원인을 찾기 아주 어려운 종류의 버그예요. 껍데기가 기본값을 뒤집어 두면 이 실수를 아예 못 하게 됩니다.

aria-label 은 왜 따로 적었나

지난 시간에 이런 이야기를 했죠. 안 적은 것은 못 받습니다. <button> 태그는 aria-label 을 원래 받지만, 우리 Button 은 우리가 적어둔 props 만 받아요.

그래서 필요합니다. 더보기 버튼은 화면에 점 세 개()만 보여요. 눈으로 보는 사람은 메뉴 버튼이라고 짐작하지만, 화면 낭독기를 쓰는 사람에게는 읽어줄 글자가 없습니다. 그때 aria-label 이 이름을 대신 줍니다.

props 이름에 하이픈이 들어가서 구조분해가 조금 낯설 거예요.

tsx
'aria-label': ariaLabel,

JavaScript 변수 이름에는 하이픈을 못 씁니다. 그래서 원래이름: 새이름 형태로 이름을 바꿔 꺼냈어요. 꺼낸 뒤에는 ariaLabel 이라는 평범한 변수입니다.

children 을 안 적으면 어떻게 될까

한번 어겨봅시다. 자식을 받을 생각 없이 만든 버튼에 자식을 넘겨보는 거예요.

tsx
interface IconButtonProps {
  onClick?: () => void;
}

function IconButton({ onClick }: IconButtonProps) {
  return <button onClick={onClick} />;
}
tsx
<IconButton>게시</IconButton>
텍스트
error TS2559: Type '{ children: string; }' has no properties in common with type
'IntrinsicAttributes & IconButtonProps'.

막힙니다. 예상한 대로예요. 그런데 여기서 재미있는 일이 벌어집니다. 같은 실수인데 onClick 을 함께 넘기면 메시지가 완전히 달라져요.

tsx
<IconButton onClick={() => {}}>게시</IconButton>
텍스트
error TS2322: Type '{ children: string; onClick: () => void; }' is not assignable to type
'IntrinsicAttributes & IconButtonProps'.
  Property 'children' does not exist on type 'IntrinsicAttributes & IconButtonProps'.

번호도 다르고 문장도 다릅니다. 왜 갈릴까요.

첫 번째는 넘긴 것이 children 하나뿐이었습니다. 받는 쪽이 아는 이름은 onClick 뿐이고요. 겹치는 이름이 하나도 없습니다. 이럴 때 TypeScript 는 "이건 그냥 다른 물건을 넘긴 것 같은데요" 라고 판단하고 TS2559 라는 별도 메시지를 냅니다. 어느 props 가 틀렸다고 짚어봐야 도움이 안 되니까요.

두 번째는 onClick 이 겹칩니다. 그러니 "얼추 맞는 물건인데 뭔가 하나 틀렸다" 로 보고 일반 검사로 들어가요. 그 결과 어긋난 이름을 정확히 짚어줍니다. children 이 없다고요.

메시지가 두 갈래인 게 헷갈릴 수 있는데, 읽는 요령은 하나입니다. 둘 다 "네가 넘긴 것" 과 "내가 받기로 한 것" 을 나란히 보여주고 있어요. 앞쪽 중괄호가 넘긴 것, 뒤쪽 타입 이름이 받기로 한 것입니다. 둘을 비교하면 원인이 보입니다.

세 곳을 갈아 끼웁니다

이제 <button>Button 으로 바꿉니다.

tsx
// apps/web-spa/src/components/LikeButton.tsx
import { Button } from './Button';

interface LikeButtonProps {
  liked: boolean;
  likeCount: number;
  onToggle: () => void;
}

export function LikeButton({ liked, likeCount, onToggle }: LikeButtonProps) {
  return (
    <div className="like-area">
      <Button
        className={liked ? 'like-button liked' : 'like-button'}
        onClick={onToggle}
      >
        {liked ? '♥ 좋아요 취소' : '♡ 좋아요'}
      </Button>
      {likeCount > 0 && <p className="post-likes">좋아요 {likeCount}개</p>}
    </div>
  );
}
tsx
// apps/web-spa/src/components/PostHeader.tsx
import { Avatar } from './Avatar';
import { Button } from './Button';

interface PostHeaderProps {
  username: string;
  profileImageUrl: string;
}

// 머리 구역에는 프로필과 더보기 버튼이 나란히 선다.
// 묶을 것이 둘 이상이라 파일을 따로 둘 이유가 생긴다.
export function PostHeader({ username, profileImageUrl }: PostHeaderProps) {
  return (
    <div className="post-header">
      <Avatar username={username} profileImageUrl={profileImageUrl} />
      <Button className="post-more" aria-label="게시물 메뉴">
        ⋯
      </Button>
    </div>
  );
}
tsx
// apps/web-spa/src/components/CommentForm.tsx
<Button className="comment-submit" type="submit" disabled={isEmpty}>
  게시
</Button>

세 곳 모두 대문자 Button 이 됐습니다. 이제 버튼 전체의 규칙을 바꾸고 싶으면 파일 하나만 열면 돼요.

💡 한 줄 정리

children: React.ReactNode 로 안쪽을 밖에서 받으면, 겉모습이 다른 버튼들을 껍데기 하나로 모을 수 있습니다. 껍데기는 동작 규칙을 쥐고 생김새는 쓰는 쪽에 맡깁니다.

🙋 학생 질문 — "튜터님, 그냥 button 태그를 쓰면 안 되나요? 한 겹 더 감싸는 게 손해 같은데요."

정당한 의심입니다. 사실 잘못 만들면 진짜 손해예요.

먼저 얻은 것부터 봅시다. type 기본값을 뒤집어서 폼 안 오작동을 원천 차단했고, 버튼 규칙이 한 파일에 모였고, 나중에 로딩 표시나 눌림 효과 같은 걸 붙일 때 한 곳만 고치면 됩니다.

이제 잃은 것을 봅시다. <button> 태그는 원래 수십 가지 속성을 받아요. 그런데 우리 Button 은 여섯 개만 받습니다. 적지 않은 것은 못 받으니까요. 예를 들어 폼 초기화 버튼을 만들려고 하면 이렇게 막힙니다.

텍스트
error TS2322: Type '"reset"' is not assignable to type '"button" | "submit" | undefined'.

reset 은 우리가 유니온에 안 넣었거든요. 이럴 때 판단은 둘 중 하나입니다. 정말 필요하면 ButtonProps 에 추가하고, 우리 서비스에 초기화 버튼이 필요 없다면 그대로 둡니다. 뒤쪽도 훌륭한 선택이에요. 껍데기가 좁다는 건 팀이 쓸 수 있는 방법이 정해져 있다는 뜻이니까요.

정리하면 이렇습니다. 껍데기는 원래 태그보다 좁아집니다. 그 좁음이 규칙으로 쓸모 있으면 남는 장사고, 그냥 불편하기만 하면 손해예요. 버튼처럼 규칙이 분명한 요소는 대개 남는 장사입니다.


Step 3: "자식을 여러 갈래로 — 이름 붙인 슬롯"

오프닝에서 꺼낸 숙제를 풀 차례입니다. Section 은 자식 입구가 하나뿐이라 카드를 못 만든다고 했죠.

그럼 Sectiontitle 에 JSX 를 넣으면 되지 않을까요. 제목 대신 프로필 줄을 넣어보는 겁니다.

tsx
<Section title={<strong>피드</strong>}>노을 사진</Section>
텍스트
error TS2322: Type 'Element' is not assignable to type 'string'.

막힙니다. title: string 이라고 적어놨으니 당연해요. 문자열만 받겠다고 선언한 입구에 화면 조각을 넣을 수는 없습니다.

여기서 답이 나옵니다. 자식을 받을 입구는 string 이 아니라 React.ReactNode 여야 합니다. 그리고 그런 입구를 여러 개 두면 됩니다. children 이라는 이름은 하나뿐이지만, 나머지는 우리가 이름을 지어 붙일 수 있어요.

입구가 셋인 껍데기

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

interface CardProps {
  // 안 넘겨도 되는 자리 — 넘기지 않으면 그 자리는 그려지지 않는다
  header?: React.ReactNode;
  footer?: React.ReactNode;
  children: React.ReactNode;
  // 생김새는 쓰는 쪽이 정한다
  className?: string;
}

export function Card({ header, footer, children, className }: CardProps) {
  return (
    <article className={className}>
      {header}
      {children}
      {footer}
    </article>
  );
}

스무 줄이 안 됩니다. 하는 일도 단순해요. 받은 셋을 정해진 순서로 늘어놓습니다.

이렇게 이름을 붙여 자식을 받는 입구를 슬롯이라고 부릅니다. 카드라는 틀에 머리말 칸, 본문 칸, 꼬리말 칸을 뚫어두고 거기 무엇을 끼울지는 쓰는 쪽이 정하는 거예요.

텍스트
 Section — 입구가 하나

   title    ─  제목 (문자열만 받는다)
   children ─  그 아래 전부

 Card — 입구가 셋

   header   ─  머리말 (안 넘기면 그 칸은 안 그려진다)
   children ─  가운데 (반드시 넘겨야 한다)
   footer   ─  꼬리말 (안 넘기면 그 칸은 안 그려진다)

써 보면 이렇습니다.

tsx
<Card header={<h3>jaehoon</h3>} footer={<Button>팔로우</Button>}>
  <p>게시물 42 · 팔로워 1240</p>
</Card>

머리말에는 이름을, 꼬리말에는 팔로우 버튼을, 가운데에는 요약을 넣었습니다. 게시물 카드가 아니라 프로필 카드를 만들었는데도 같은 Card 를 썼어요.

안 넘기면 안 그려집니다

headerfooter 에 물음표가 붙어 있죠. 안 넘겨도 된다는 뜻입니다. 그러면 화면에서 그 칸은 어떻게 될까요.

tsx
<Card header={null} footer={isOwner && <Button>삭제</Button>}>
  노을 사진
</Card>

{null} 은 아무것도 안 그립니다. 빈 <div> 조차 안 남아요. isOwnerfalsefalse 가 들어가는데 그것도 마찬가지로 아무것도 안 그립니다. 지난 시간에 React.ReactNodenullundefined 를 받는다고 했던 게 여기서 쓰입니다.

덕분에 조건에 따라 칸을 켜고 끄는 게 아주 자연스러워집니다. 내 글일 때만 삭제 버튼이 붙는 카드가 한 줄로 표현돼요.

슬롯이 React.ReactNode 라서 JSX 말고도 들어갑니다.

tsx
<Card className="profile-card" header="jaehoon" footer={42}>
  노을 사진
</Card>

문자열도 숫자도 그대로 화면에 찍힙니다. 넓은 타입이라 가능한 일이에요.

슬롯이 안 받는 것들

넓다고 아무거나 받는 건 아닙니다. 대표적으로 함수가 안 됩니다.

tsx
<Card>{() => '노을'}</Card>
텍스트
error TS2322: Type '() => string' is not assignable to type 'ReactNode'.

이름 붙인 슬롯도 똑같이 막혀요.

tsx
<Card header={() => <p>jaehoon</p>}>노을 사진</Card>
텍스트
error TS2322: Type '() => React.JSX.Element' is not assignable to type 'ReactNode'.

두 번째 실수가 특히 흔합니다. "머리말을 그리는 방법" 을 넘기려다 소괄호를 빼먹는 거예요. () => <p>jaehoon</p> 는 화면 조각이 아니라 화면 조각을 만드는 함수입니다. React 는 함수를 받아도 화면에 어떻게 그릴지 모릅니다. 부르지도 않고요. 그래서 그냥 막습니다.

순수 객체도 안 됩니다. 지난 시간에 본 그 메시지 그대로예요.

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

이름을 잘못 부르면

슬롯 이름은 우리가 지은 것이라, 없는 이름을 부르면 바로 걸립니다.

tsx
<Card side={<p>옆</p>}>노을 사진</Card>
텍스트
error TS2322: Type '{ children: string; side: Element; }' is not assignable to type
'IntrinsicAttributes & CardProps'.
  Property 'side' does not exist on type 'IntrinsicAttributes & CardProps'.

Step 2 에서 본 두 번째 메시지와 같은 형태죠. 넘긴 것과 받기로 한 것을 나란히 보여주고 어긋난 이름을 짚어줍니다.

반대로 필수인 children 을 빠뜨리면 이렇게 나옵니다.

tsx
<Card header={<p>jaehoon</p>} />
텍스트
error TS2741: Property 'children' is missing in type '{ header: Element; }'
  but required in type 'CardProps'.

headerfooter 에만 물음표를 붙이고 children 에는 안 붙인 결과입니다. 가운데가 비어 있는 카드는 카드가 아니니까요. 물음표 하나로 "있어도 되고 없어도 되는 칸" 과 "반드시 채워야 하는 칸" 이 갈립니다.

💡 한 줄 정리

자식을 받을 입구를 여러 개 두고 싶으면 React.ReactNode 타입의 props 에 이름을 붙입니다. 물음표를 붙인 슬롯은 안 넘기면 그 칸이 아예 안 그려집니다.

🙋 학생 질문 — "튜터님, 슬롯에 넣은 컴포넌트의 상태는 누구 것이 되나요?"

아주 중요한 질문입니다. 이걸 오해하면 합성이 무서워져요.

결론부터 말하면 넣은 쪽 것입니다. Card 는 배달만 합니다.

tsx
export function SlotKeepsOwnerState() {
  const [likeCount, setLikeCount] = useState(0);

  return (
    <Card
      header={<p>좋아요 {likeCount}개</p>}
      footer={<Button onClick={() => setLikeCount(likeCount + 1)}>좋아요</Button>}
    >
      노을 사진
    </Card>
  );
}

likeCount 는 이 컴포넌트의 상태입니다. Card 안으로 들어간 게 아니에요. 버튼을 누르면 이 컴포넌트가 다시 그려지고, 그 결과로 만들어진 새 화면 조각이 Card 에 다시 배달됩니다.

이렇게 생각하면 편합니다. header={<p>좋아요 {likeCount}개</p>} 를 쓰는 순간 이미 <p> 조각이 만들어진 상태예요. Card 는 완성된 조각을 받아서 정해진 순서로 늘어놓을 뿐입니다. 그 조각이 어디서 왔고 어떤 상태를 읽었는지는 알지도 못하고 알 필요도 없어요.

그래서 Card 같은 껍데기는 어떤 화면에 써도 안전합니다. 배달만 하니까 배달물의 사정에 끼어들 방법이 없거든요.


Step 4: "합성으로 조립하기"

Card 를 만들었으니 이제 진짜 카드를 조립합시다. Step 1 에서 세 조각으로 나눠뒀으니 재료는 다 있어요.

목록도 컴포넌트로

조립하기 전에 하나만 정리하고 갑니다. Step 1 이 끝났을 때 카드 안에는 아직 이 덩어리가 날것으로 남아 있었어요.

tsx
// apps/web-spa/src/components/PostCard.tsx — 카드 안에 남아 있던 덩어리
<ul className="comment-list">
  {comments.map((comment) => (
    <li key={comment.id}>
      <strong>me</strong> {comment.content}
    </li>
  ))}
</ul>

주변은 전부 대문자 컴포넌트 이름인데 여기만 <ul>map 이 섞여 있습니다. 카드를 읽다가 여기서만 눈높이가 뚝 떨어져요. 조립하는 곳은 "무엇이 어떤 순서로 오는지" 만 말하게 두는 편이 낫습니다.

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

export interface DraftComment {
  id: number;
  content: string;
}

interface CommentListProps {
  comments: DraftComment[];
}

// 댓글 한 줄은 자기 상태도 이벤트도 없어서 따로 파일을 만들지 않는다.
// 나중에 한 줄마다 좋아요나 답글이 생기면 그때 CommentItem 으로 꺼낸다.
export function CommentList({ comments }: CommentListProps) {
  return (
    <ul className="comment-list">
      {comments.map((comment) => (
        <li key={comment.id}>
          <strong>me</strong> {comment.content}
        </li>
      ))}
    </ul>
  );
}

주석 두 줄을 봐 주세요. Step 1 에서 내린 판단이 파일에 그대로 남아 있습니다. 이런 주석은 꽤 값어치가 큽니다. 나중에 이 파일을 여는 사람이 "왜 한 줄을 따로 안 뺐지" 를 물을 때, 잊어버려서가 아니라 판단해서 그랬다는 걸 알려주거든요.

DraftCommentexport 로 바꾼 것도 눈에 띄실 겁니다. 댓글 한 건의 모양은 이제 목록을 그리는 쪽이 정의하고, 카드는 그걸 가져다 씁니다.

Card 의 슬롯에 끼웁니다

이제 조립합니다. 머리말 칸에는 PostHeader 를, 꼬리말 칸에는 CommentForm 을, 가운데에는 사진과 본문과 댓글 목록을 넣어요.

텍스트
 Step 1 을 끝냈을 때 — article 이 다섯을 세로로 늘어놓는다

   <article className="post-card">
     <PostHeader />
     <PostImage />
     <PostBody />
     <ul className="comment-list"> 안에서 map 이 돈다
     <CommentForm />
   </article>

 Step 4 를 끝냈을 때 — Card 가 세 칸으로 나눠 받는다

   <Card header={<PostHeader />} footer={<CommentForm />}>
     <PostImage />
     <PostBody />
     <CommentList />
   </Card>
tsx
// apps/web-spa/src/components/PostCard.tsx
import { useState } from 'react';
import type { PostCardProps } from '../types/derived';
import type { DraftComment } from './CommentList';
import { Card } from './Card';
import { PostHeader } from './PostHeader';
import { PostImage } from './PostImage';
import { PostBody } from './PostBody';
import { CommentList } from './CommentList';
import { CommentForm } from './CommentForm';

interface PostCardViewProps extends PostCardProps {
  onToggleLike: (id: number) => void;
}

export function PostCard({
  id,
  username,
  profileImageUrl,
  imageUrl,
  content,
  liked,
  likeCount,
  commentCount,
  onToggleLike,
}: PostCardViewProps) {
  // 이 댓글은 이 카드만 쓰니까 카드 안에 둔다
  const [comments, setComments] = useState<DraftComment[]>([]);

  function addComment(text: string) {
    setComments([...comments, { id: comments.length + 1, content: text }]);
  }

  return (
    <Card
      className="post-card"
      header={<PostHeader username={username} profileImageUrl={profileImageUrl} />}
      footer={<CommentForm onSubmit={addComment} />}
    >
      <PostImage
        imageUrl={imageUrl}
        username={username}
        onLike={() => onToggleLike(id)}
      />
      <PostBody
        username={username}
        content={content}
        liked={liked}
        likeCount={likeCount}
        commentCount={commentCount + comments.length}
        onToggle={() => onToggleLike(id)}
      />
      <CommentList comments={comments} />
    </Card>
  );
}

PostCard 안에 HTML 태그가 하나도 없습니다. 전부 대문자로 시작하는 컴포넌트 이름뿐이에요. 이 파일이 하는 일은 딱 두 가지입니다. 댓글 상태를 들고 있는 것, 그리고 조각들을 어느 칸에 끼울지 정하는 것.

import type { DraftComment } from './CommentList' 도 봐 주세요. A-3 에서 배운 타입 전용 임포트입니다. 타입만 가져오는 줄이니 실행되는 코드에는 안 남아요.

화면은 이번에도 그대로입니다. Card<article> 을 그리고 그 안에 셋을 순서대로 늘어놓으니 결과가 같을 수밖에 없어요.

합성이 상속을 대신합니다

다른 언어를 해보신 분은 화면 조각을 재사용하는 방법으로 상속을 떠올리셨을 겁니다. 기본 카드 클래스를 만들고, 게시물 카드가 그걸 물려받아 일부를 덮어쓰는 방식이요.

React 에는 그 길이 없습니다. 정확히는 문법적으로 못 하는 건 아니지만 아무도 안 쓰고 공식 문서도 권하지 않아요. 대신 오늘 한 방식을 씁니다. 물려받는 대신 안에 넣습니다. 이걸 합성(composition)이라고 부릅니다.

차이를 느껴보려면 방금 만든 Card 를 다시 보세요. Card 는 자기가 게시물에 쓰일지 프로필에 쓰일지 모릅니다. Step 3 에서 같은 껍데기로 프로필 카드를 만들었을 때 Card 파일은 한 글자도 안 고쳤어요.

상속이었다면 어땠을까요. 화면이 하나 늘 때마다 부모 클래스에 "이 경우엔 이렇게" 라는 분기가 쌓이거나, 물려받는 사슬이 길어집니다. 새 카드를 만들려면 부모가 무엇을 어떻게 그리는지 전부 알아야 하고요.

합성은 그 반대입니다. 껍데기는 칸의 이름과 순서만 정하고, 무엇을 채울지는 끝까지 모릅니다. 아는 게 적을수록 여기저기 쓰기 좋아져요.

오늘까지의 컴포넌트 트리

텍스트
 App
 └── Section  title="피드"
     └── Feed
         └── Card             PostCard 가 조립한다
             ├── header       PostHeader ─ Avatar + Button
             ├── children     PostImage
             │                PostBody ─ LikeButton
             │                CommentList
             └── footer       CommentForm ─ CommentInput + Button

Button 이 두 군데에 나오는 게 보이시죠. 왼쪽 위 더보기 버튼과 오른쪽 아래 게시 버튼입니다. 겉모습도 하는 일도 전혀 다른데 같은 껍데기에서 나왔어요. Step 2 에서 만든 공통 버튼이 실제로 값을 하고 있다는 증거입니다.

💡 한 줄 정리

컴포넌트를 재사용하는 방법은 물려받는 것이 아니라 슬롯에 넣는 것입니다. 껍데기는 칸의 이름과 순서만 정하고, 무엇이 들어올지는 끝까지 모릅니다.

🙋 학생 질문 — "튜터님, Card 는 article 태그 하나만 그리는데 이걸 왜 만든 건가요? Step 1 에서 이런 껍데기는 만들지 말라고 하셨잖아요."

날카롭습니다. Step 1 에서 Avatar 를 그냥 감싸기만 한 PostHeader 를 만들지 말자고 했는데, Card 도 비슷해 보이죠.

차이는 더해주는 것이 있느냐 입니다.

Avatar 를 감싸기만 한 껍데기는 아무것도 안 더했어요. Avatar 가 이미 정해둔 것을 이름만 바꿔 되풀이했습니다. 그래서 지우면 아무것도 안 잃습니다.

Card 는 없던 구조를 만듭니다. 머리말·본문·꼬리말이라는 세 칸의 이름과 순서 를 정해요. 이건 <article> 태그가 안 해주는 일입니다. 그리고 그 이름 덕분에 PostCard 를 읽는 사람이 "아, 프로필 줄은 머리말이고 댓글 폼은 꼬리말이구나" 를 바로 압니다.

한 가지 더요. 지금은 얇은 게 맞습니다. 다만 이 껍데기가 두꺼워질 자연스러운 이유들이 곧 생겨요. 카드에 그림자와 테두리와 여백 규칙이 붙고, 화면 폭에 따라 배치가 바뀌고, 그런 것들이 전부 여기로 모입니다. 그때 Card 가 없으면 카드처럼 생긴 모든 곳에 같은 규칙을 흩뿌려야 합니다.

기준을 다시 정리하면 이렇습니다. 껍데기가 아무것도 안 더하면 만들지 않고, 구조든 규칙이든 하나라도 더하면 만듭니다. Card 는 구조를 더했어요.


Step 5: "반복을 알아채기 — 커스텀 훅"

여기까지가 화면 조각을 다루는 이야기였습니다. 이제 로직 쪽으로 넘어갑니다.

오프닝에서 남겨둔 이야기 기억나시죠. CommentForm 안에 준비 줄이 잔뜩 쌓였다고 했습니다. 그런데 그게 CommentForm 만의 일이 아니에요. 지금까지 만든 것들을 훑어보면 같은 모습이 계속 나옵니다.

텍스트
 같은 모습이 세 번 나온다

 App           useState(feedPosts)         handleToggleLike
 CommentForm   useState('') + useRef       handleChange · handleSubmit
 LikeButton    useState(initialLiked)      handleClick        (B-2 에서 처음 만들었을 때)

 상태 하나와 그 상태를 바꾸는 함수가 늘 붙어 다닌다

세 곳 다 "상태를 만들고, 그 상태를 어떻게 바꿀지 정하는 함수를 옆에 둔다" 는 같은 일을 합니다. 그런데 그 묶음이 컴포넌트 몸통에 그대로 풀어헤쳐져 있어요. 컴포넌트를 열면 화면 이야기가 시작되기 전에 이 준비 줄부터 지나가야 합니다.

Step 1 에서 화면 조각에 대해 세운 기준을 그대로 로직에 적용해 봅시다. 묶음이 반복되고 그 묶음에 이름을 붙일 수 있으면 밖으로 뺍니다.

커스텀 훅은 새로운 기능이 아닙니다

여기서 오해가 자주 생깁니다. "커스텀 훅" 이라는 이름 때문에 뭔가 특별한 문법을 배워야 할 것 같거든요.

아닙니다. 커스텀 훅은 그냥 함수예요. function 으로 선언하고, 인자를 받고, 값을 돌려주는 평범한 함수입니다. 다른 함수와 다른 점은 딱 두 가지뿐이에요.

첫째, 안에서 useState 같은 React 훅을 부릅니다. 둘째, 이름이 use 로 시작합니다.

그것뿐입니다. 새 API 도 없고 등록할 곳도 없어요. 우리가 이미 쓸 줄 아는 것을 함수 하나로 옮겨 담는 것이 전부입니다.

훅을 부를 때 지켜야 하는 세 가지

다만 훅에는 규칙이 있습니다. B-2 에서 잠깐 스쳤던 이야기를 이제 제대로 정리합시다.

하나 — 매 렌더에 같은 순서로 부릅니다. 조건문 안, 반복문 안, 이른 return 뒤에서 부르면 안 됩니다.

둘 — 컴포넌트나 다른 커스텀 훅 안에서만 부릅니다. 평범한 함수 안에서는 못 부릅니다.

셋 — 커스텀 훅 이름은 use 로 시작합니다. 이건 취향이 아니라 도구가 실제로 검사하는 규칙이에요.

셋 다 어겨보면서 확인합시다. 다행히 우리 프로젝트에는 린트가 켜져 있어서 화면을 열기 전에 잡아줍니다.

tsx
export function ConditionalToggle({ expandable }: { expandable: boolean }) {
  if (expandable) {
    const [open, setOpen] = useState(false);
    return <button onClick={() => setOpen(!open)}>{open ? '접기' : '더보기'}</button>;
  }

  return null;
}
텍스트
error  React Hook "useState" is called conditionally. React Hooks must be called
       in the exact same order in every component render

마지막 문장이 핵심입니다. "매 렌더에서 정확히 같은 순서로 불려야 한다" 고 말하고 있어요. 왜 순서인지는 곧 봅니다.

반복문도 같은 이유로 막힙니다.

tsx
export function LoopToggle({ captions }: { captions: string[] }) {
  const shown: string[] = [];

  for (const caption of captions) {
    const [open] = useState(false);
    shown.push(open ? caption : caption.slice(0, 10));
  }

  return <p>{shown.join(' / ')}</p>;
}
텍스트
error  React Hook "useState" may be executed more than once. Possibly because it
       is called in a loop. React Hooks must be called in the exact same order in
       every component render

배열 길이가 바뀌면 훅 개수가 바뀌니까요. 같은 문제입니다.

이름 규칙을 어기면 메시지가 아주 친절합니다.

tsx
export function makeToggle(initialOn: boolean) {
  const [on, setOn] = useState(initialOn);

  return { on, toggle: () => setOn(!on) };
}
텍스트
error  React Hook "useState" is called in function "makeToggle" that is neither a
       React function component nor a custom React Hook function. React component
       names must start with an uppercase letter. React Hook names must start with
       the word "use"

이 메시지가 우리가 지금까지 지켜온 이름 규칙의 근거를 그대로 말해줍니다. 도구는 이름만 보고 판단해요. 대문자로 시작하면 컴포넌트, use 로 시작하면 훅, 둘 다 아니면 그냥 함수. makeToggleuseToggle 로 바꾸기만 하면 이 에러는 사라집니다. 코드는 한 글자도 안 고쳤는데요.

조건문이 안 보여도 조건부일 수 있습니다

이게 실무에서 가장 자주 걸리는 형태입니다.

tsx
export function EarlyReturnCard({ content }: { content: string | null }) {
  if (content === null) {
    return null;
  }

  const [open, setOpen] = useState(false);

  return <button onClick={() => setOpen(!open)}>{open ? content : '더보기'}</button>;
}
텍스트
error  React Hook "useState" is called conditionally. React Hooks must be called
       in the exact same order in every component render

useState 는 분명히 if 블록 바깥에 있는데도 조건부라고 합니다. 맞는 말이에요. 위쪽에서 먼저 return 해버리면 이 줄까지 못 오니까요. 눈에 보이는 들여쓰기가 아니라 실제로 실행되느냐가 기준입니다.

그래서 원칙은 단순합니다. 훅은 컴포넌트 몸통의 맨 위에 전부 모아 두세요. "값이 없으면 일찍 나가기" 같은 판단은 훅을 다 부른 뒤에 합니다.

왜 하필 순서일까

React 는 훅을 이름으로 기억하지 않습니다. 몇 번째로 불렸는가 로 기억해요.

텍스트
 정상 — 매 렌더 같은 순서

   1) useState   content
   2) useRef     inputRef
   3) useState   open

 조건부 — 어떤 렌더에서 2번이 사라지면

   1) useState   content
   2) useState   open        3번이 받아야 할 값을 2번이 받아간다

두 번째 그림에서 openinputRef 의 서랍을 열고 있습니다. 값이 통째로 뒤엉키는 거예요.

이렇게 만든 이유가 있습니다. 이름으로 기억하려면 우리가 훅마다 이름표를 붙여줘야 하는데, 그러면 useState 를 부를 때마다 문자열 키를 하나씩 적어야 합니다. 순서로 기억하면 그 수고가 사라져요. 대신 순서를 지켜야 한다는 약속이 생긴 겁니다.

⚠️ 어기면 항상 터질까요 — 아닙니다

여기가 오늘 가장 조심해야 할 부분입니다.

조건부로 훅을 불러도 조건이 안 바뀌면 아무 일도 안 일어납니다. 방금 ConditionalToggle 에서 expandable 이 계속 true 이거나 계속 false 이면 화면은 멀쩡히 그려져요. 한 번도 안 터집니다.

그럼 조건이 바뀌면 바로 터질까요. 그것도 아닙니다. 여기서 한 번 더 갈립니다.

ConditionalToggle 은 훅이 조건부 useState 하나뿐입니다. expandable 이 뒤집히면 훅 개수가 0개와 1개 사이를 오가요. 이때는 에러가 안 납니다. 직전 렌더가 남긴 훅이 하나도 없으면 React 는 그 렌더를 처음 그리는 것으로 봅니다. 비교할 대상이 없으니 그냥 새로 시작해요.

대신 다른 일이 벌어집니다. 펼쳐뒀던 상태가 조용히 사라집니다.

텍스트
 expandable  true ──────── false ──────── true
             펼침(접기)     안 그려짐      더보기
                                          
                              펼쳐둔 상태가 사라졌다

에러도 경고도 없습니다. 사용자는 방금 펼친 게 왜 다시 접혔는지 알 수 없고, 우리도 콘솔에 아무것도 안 뜨니 찾을 단서가 없어요.

터지는 건 조건부 훅 앞에 살아남는 훅이 하나라도 있을 때 입니다. 좋아요 상태를 먼저 들고 있는 카드를 생각해 봅시다.

tsx
function PostCardBroken({ withCaption }: { withCaption: boolean }) {
  const [liked, setLiked] = useState(false);

  if (withCaption) {
    const [open, setOpen] = useState(false);
    return (
      <div>
        <button onClick={() => setOpen(!open)}>{open ? '접기' : '더보기'}</button>
        <button onClick={() => setLiked(!liked)}>{liked ? '♥' : '♡'}</button>
      </div>
    );
  }

  return <button onClick={() => setLiked(!liked)}>{liked ? '♥' : '♡'}</button>;
}

이제 훅 개수가 1개와 2개 사이를 오갑니다. 앞의 useState 가 살아남아 있으니 React 가 직전 렌더와 비교할 수 있어요. 그리고 방향에 따라 메시지가 갈립니다.

훅이 하나 늘어나는 쪽으로 바뀌면 이렇게 나옵니다.

텍스트
Rendered more hooks than during the previous render.

줄어드는 쪽으로 바뀌면 이렇게 나옵니다.

텍스트
Rendered fewer hooks than expected. This may be caused by an accidental early
return statement.

둘 다 화면이 통째로 사라집니다. 카드 한 장이 아니라 페이지 전체가요.

그래서 더 무섭습니다. 같은 규칙 위반인데 어떤 코드에서는 화면이 무너지고, 어떤 코드에서는 아무 소리 없이 상태만 사라집니다. 어느 쪽인지는 그 컴포넌트에 다른 훅이 있느냐 없느냐로 갈리는데, 이건 나중에 훅 하나를 추가하는 것만으로도 바뀌어요. 오늘 조용하던 코드가 다음 주에 터질 수 있다는 뜻입니다.

린트가 이걸 미리 잡아주는 게 얼마나 고마운 일인지 여기서 알 수 있어요. 실행해봐야 알 수 있는 문제를 코드를 쓰는 동안 잡아주니까요. 린트 경고를 무시하지 마세요. 지금 안 터진다는 건 아직 안 터졌다는 뜻일 뿐입니다.

💡 한 줄 정리

커스텀 훅은 use 로 시작하는 평범한 함수입니다. 훅은 매 렌더 같은 순서로 불려야 하는데, 이 규칙을 어겨도 조용히 넘어가는 경우가 있어서 오히려 위험합니다.

🙋 학생 질문 — "튜터님, 커스텀 훅 안에서도 이 규칙을 지켜야 하나요?"

지켜야 합니다. 훅 안이라고 봐주지 않아요.

tsx
export function useCommentDraft(autoFocus: boolean) {
  const [content, setContent] = useState('');

  if (autoFocus) {
    const inputRef = useRef<HTMLInputElement>(null);
    return { content, setContent, inputRef };
  }

  return { content, setContent, inputRef: null };
}
텍스트
error  React Hook "useRef" is called conditionally. React Hooks must be called in
       the exact same order in every component render. Did you accidentally call a
       React Hook after an early return?

앞에서 본 것과 같은 메시지에 힌트 한 줄이 더 붙었습니다. if 블록 안에서 훅을 부르고 그 블록이 바로 return 하는 모습이라 도구가 이른 return 을 의심한 거예요. 이 힌트는 항상 붙는 게 아니라 이런 모습일 때만 붙습니다.

당연한 결과입니다. 커스텀 훅은 별도의 무언가가 아니라 컴포넌트 몸통에 그대로 펼쳐지는 코드니까요. useCommentDraft 를 부른 컴포넌트 입장에서 보면, 그 안의 useStateuseRef 는 자기가 직접 부른 것과 똑같습니다. 순서 기록도 한 줄로 이어져요.

그래서 훅을 만들 때도 규칙은 그대로입니다. 함수 맨 위에 훅을 다 부르고, 조건 분기는 그 뒤에 두세요.


Step 6: "첫 커스텀 훅 — useLikeToggle"

규칙을 알았으니 실제로 하나 만들어 봅시다. 우리 App 부터요.

tsx
// apps/web-spa/src/App.tsx — 지금까지의 모습
export function App() {
  const [posts, setPosts] = useState(feedPosts);
  const likedCount = posts.filter((post) => post.liked).length;

  function handleToggleLike(id: number) {
    setPosts(toggleLike(posts, id));
  }

  return (
    <main className="feed">
      <header className="feed-header">
        <h1 className="feed-title">인스타그램</h1>
        <span className="feed-liked-count">좋아요 누른 게시물 {likedCount}개</span>
      </header>
      <Section title="피드">
        <Feed posts={posts} onToggleLike={handleToggleLike} />
      </Section>
    </main>
  );
}

App 이 무슨 일을 하는지 봅시다. 화면 구조를 정하는 일이 본업이죠. 그런데 그 앞에 좋아요를 어떻게 관리하는지가 다섯 줄 먼저 나옵니다. 게시물 목록을 상태로 들고, 좋아요 개수를 세고, 토글하는 함수를 만들어요.

이 다섯 줄에 이름을 붙일 수 있을까요. 있습니다. 좋아요 토글 이에요.

훅 파일로 옮깁니다

TypeScript
// apps/web-spa/src/hooks/useLikeToggle.ts
import { useState } from 'react';
import type { Post } from '../types/instagram';
import { toggleLike } from '../lib/likes';

// 피드가 들고 있던 좋아요 상태와 갱신 함수를 통째로 옮겨 이름을 붙인 것이다.
// 옮긴 것은 코드일 뿐, 상태는 여전히 이 훅을 부른 컴포넌트의 것이다.
export function useLikeToggle(initialPosts: Post[]) {
  const [posts, setPosts] = useState(initialPosts);
  const likedCount = posts.filter((post) => post.liked).length;

  function toggle(id: number) {
    setPosts(toggleLike(posts, id));
  }

  return { posts, likedCount, toggle };
}

App 에서 잘라낸 다섯 줄이 그대로 들어왔습니다. 달라진 건 세 가지뿐이에요.

첫째, 파일이 hooks/ 폴더에 있고 확장자가 .ts 입니다. JSX 가 한 줄도 없거든요. 화면을 안 그리고 로직만 다루는 파일이라 .tsx 일 이유가 없습니다.

둘째, 초기 게시물 목록을 인자로 받습니다. feedPosts 를 훅 안에 적어 넣지 않고 밖에서 받게 했어요. 그래야 나중에 다른 목록으로도 쓸 수 있습니다.

셋째, 필요한 셋을 객체로 묶어 돌려줍니다. posts 는 화면에 그릴 목록, likedCount 는 세어둔 개수, toggle 은 바꾸는 함수예요.

App 이 짧아집니다

tsx
// apps/web-spa/src/App.tsx
import { Feed } from './components/Feed';
import { Section } from './components/Section';
import { feedPosts } from './data/feed';
import { useLikeToggle } from './hooks/useLikeToggle';

export function App() {
  const { posts, likedCount, toggle } = useLikeToggle(feedPosts);

  return (
    <main className="feed">
      <header className="feed-header">
        <h1 className="feed-title">인스타그램</h1>
        <span className="feed-liked-count">좋아요 누른 게시물 {likedCount}개</span>
      </header>
      <Section title="피드">
        <Feed posts={posts} onToggleLike={toggle} />
      </Section>
    </main>
  );
}

다섯 줄이 한 줄이 됐습니다. 그리고 그 한 줄이 무엇을 하는지 이름으로 말해줘요. App 을 처음 여는 사람은 "좋아요 토글을 쓰는구나" 하고 넘어간 다음 바로 화면 구조를 읽습니다. 좋아요를 어떻게 관리하는지 궁금하면 그때 훅 파일을 열면 돼요.

⚠️ 상태 끌어올리기가 무너진 게 아닙니다

여기서 반드시 짚고 갈 게 있습니다. useStateApp 파일에서 사라졌으니 상태가 어디론가 옮겨간 것처럼 보이거든요.

아닙니다. 상태는 여전히 App 의 것입니다.

텍스트
 상태가 사는 곳은 바뀌지 않는다

 옮기기 전
   App 안에서 useState 가 불린다    상태는 App 의 것

 옮긴 뒤
   App 이 useLikeToggle() 을 부르고, 그 안에서 useState 가 불린다
   훅은 App 이 렌더될 때 App 의 몸통에서 실행된다    상태는 여전히 App 의 것

커스텀 훅은 별도의 상자가 아니에요. 함수를 부르면 그 안의 코드가 부른 곳에서 실행되죠. 훅도 똑같습니다. useLikeToggle() 이라고 적는 순간 그 안의 useStateApp 의 훅 목록에 등록됩니다.

그러니까 B-2 에서 배운 상태 끌어올리기는 그대로 유효합니다. 여러 카드가 함께 봐야 하는 목록이라 App 까지 올렸던 거였죠. 그 판단은 안 바뀌었어요. 우리가 한 일은 그 코드에 이름을 붙여 다른 파일로 옮긴 것뿐입니다.

한 문장으로 줄이면 이렇습니다. 훅은 로직을 공유하지 상태를 공유하지 않습니다. 다음 Step 에서 이 문장이 왜 중요한지 눈으로 확인합니다.

💡 한 줄 정리

커스텀 훅은 컴포넌트에서 잘라낸 상태와 함수를 함수 하나로 옮겨 담은 것입니다. 상태는 여전히 훅을 부른 컴포넌트의 것이라 상태 끌어올리기 판단은 그대로 유효합니다.

🙋 학생 질문 — "튜터님, 그럼 useState 를 한 번만 쓰는 컴포넌트도 전부 훅으로 빼야 하나요?"

아니요. Step 1 에서 컴포넌트를 쪼갤 때 세운 기준을 그대로 쓰시면 됩니다.

훅으로 뺄 이유는 대개 셋 중 하나예요. 반복되거나, 이름을 붙일 만한 덩어리이거나, 컴포넌트 몸통에서 준비 줄이 화면 줄을 밀어낼 때 입니다.

useLikeToggle 은 세 번째에 해당했습니다. App 의 본업은 화면 구조인데 좋아요 관리가 그 앞을 다섯 줄이나 막고 있었죠. 지금은 아직 한 곳에서만 쓰지만, 나중에 프로필 화면에서도 좋아요를 다루게 되면 첫 번째 이유까지 붙습니다.

반대로 PostCard 의 댓글 상태는 어떨까요.

tsx
const [comments, setComments] = useState<DraftComment[]>([]);

한 줄입니다. 이름을 붙여봐야 useComments 정도인데, 그 이름이 comments 라는 변수명보다 더 알려주는 게 없어요. 파일만 하나 늘고 읽으려면 두 곳을 열어야 합니다. 그대로 두는 게 맞습니다.

Step 1 에서 이야기한 그 감각이 여기서도 통합니다. 뽑아낸 것에 좋은 이름이 바로 안 떠오르면 아직 뽑을 때가 아니에요.


Step 7: "useToggle — 하나의 훅, 여러 개의 상태"

이번엔 더 작고 더 많이 쓰이는 훅을 만들어 봅시다.

먼저 문제부터요. 우리 피드의 캡션을 보세요.

텍스트
 jaehoon   오늘 한강 노을이 미쳤다
 minji     제주도 3박 4일 기록

지금은 짧아서 괜찮은데, 인스타그램 캡션은 원래 훨씬 깁니다. 해시태그까지 붙으면 화면 절반을 캡션이 차지해요. 그래서 실제 인스타그램은 캡션을 잘라 보여주고 "더 보기" 를 누르면 펼칩니다.

만들어 봅시다. 필요한 건 켜고 꺼지는 값 하나와 그걸 뒤집는 함수 하나예요.

켜고 끄는 것만 하는 훅

TypeScript
// apps/web-spa/src/hooks/useToggle.ts
import { useState } from 'react';

// 켜고 끄는 값 하나와 뒤집는 함수 하나.
// useState 처럼 배열로 돌려주면 쓰는 쪽이 이름을 마음대로 붙일 수 있다.
// 돌려주는 모양을 적어두지 않으면 두 값이 하나로 뭉뚱그려져 toggle() 을 부를 수 없다.
export function useToggle(initialOn = false): [boolean, () => void] {
  const [on, setOn] = useState(initialOn);

  function toggle() {
    setOn(!on);
  }

  return [on, toggle];
}

여덟 줄짜리 훅입니다. 짧지만 오늘 배울 게 두 개나 들어 있어요.

돌려주는 모양을 왜 적었을까

: [boolean, () => void] 를 지워보면 바로 알 수 있습니다. 훅 자체는 멀쩡히 컴파일되는데, 쓰는 쪽에서 toggle() 을 부르려는 순간 막혀요.

이유는 배열 추론에 있습니다. return [on, toggle] 만 보면 TypeScript 는 이걸 "boolean 이거나 함수인 것들의 배열" 로 읽습니다. (boolean | (() => void))[] 이 되는 거예요. 그러면 꺼낸 두 값이 전부 이 뭉뚱그려진 타입을 갖습니다. toggle 이 함수라는 확신이 없으니 부를 수가 없죠.

돌려주는 모양을 직접 적어주면 첫 번째는 반드시 boolean, 두 번째는 반드시 함수 로 확정됩니다. 이렇게 순서마다 타입이 정해진 배열을 튜플이라고 부릅니다.

A-3 에서 배운 원칙을 떠올려 보세요. 애너테이션은 추론이 못 하거나 틀리게 하는 곳에만 붙인다고 했죠. 여기가 정확히 그런 곳입니다.

배열로 돌려줄까, 객체로 돌려줄까

useLikeToggle 은 객체를 돌려줬는데 useToggle 은 배열을 돌려줍니다. 왜 다를까요.

배열로 돌려주면 쓰는 쪽이 이름을 마음대로 붙일 수 있습니다. useState 가 그렇게 생겼죠.

tsx
const [captionOpen, toggleCaption] = useToggle();
const [commentsOpen, toggleComments] = useToggle();

순서만 맞으면 되니까 한 컴포넌트에서 두 번 써도 이름이 안 겹칩니다. 객체였다면 const { on: captionOpen, toggle: toggleCaption } 처럼 매번 이름을 바꿔 꺼내야 했을 거예요.

대신 대가가 있습니다. 몇 번째가 무엇인지 외워야 합니다. 순서를 바꿔 받아도 TypeScript 가 못 잡는 경우가 생기고, 값이 셋만 넘어가도 헷갈리기 시작해요.

돌려주는 방식 어울리는 곳 대가
배열(튜플) 값이 둘 이하 · 한 컴포넌트에서 여러 번 쓴다 순서를 외워야 한다
객체 값이 셋 이상 · 일부만 꺼내 쓸 때가 있다 이름이 고정된다

useToggle 은 값이 둘이고 한 화면에서 여러 번 쓰일 훅이라 배열이 맞습니다. useLikeToggle 은 셋이고 useCommentInput 은 다섯이라 객체가 맞고요. 기준은 그것뿐이에요.

캡션에 붙입니다

tsx
// apps/web-spa/src/components/PostBody.tsx
import { LikeButton } from './LikeButton';
import { Button } from './Button';
import { useToggle } from '../hooks/useToggle';

// 이 글자 수를 넘는 캡션은 접어서 보여준다
const CAPTION_LIMIT = 10;

interface PostBodyProps {
  username: string;
  content: string;
  liked: boolean;
  likeCount: number;
  commentCount: number;
  onToggle: () => void;
}

// 사진 아래 본문 구역 — 좋아요·캡션·댓글 수가 함께 산다.
export function PostBody({
  username,
  content,
  liked,
  likeCount,
  commentCount,
  onToggle,
}: PostBodyProps) {
  const [captionOpen, toggleCaption] = useToggle();
  const isLong = content.length > CAPTION_LIMIT;
  const shownContent =
    isLong && !captionOpen ? `${content.slice(0, CAPTION_LIMIT).trimEnd()}...` : content;

  return (
    <>
      <LikeButton liked={liked} likeCount={likeCount} onToggle={onToggle} />
      <p className="post-content">
        <strong>{username}</strong> {shownContent}
        {isLong && (
          <Button className="caption-toggle" onClick={toggleCaption}>
            {captionOpen ? '접기' : '더 보기'}
          </Button>
        )}
      </p>
      <p className="post-comments">댓글 {commentCount}개 모두 보기</p>
    </>
  );
}

CAPTION_LIMIT 을 파일 맨 위에 상수로 뺐습니다. 화면 안쪽에 숫자 10 이 그대로 적혀 있으면 나중에 이게 무슨 숫자였는지 알 수 없거든요.

shownContent 를 계산하는 줄을 보세요. 캡션이 길고 접혀 있을 때만 잘라내고, 아니면 원본 그대로입니다. trimEnd() 는 잘린 끝에 공백이 남았을 때 지워줘요. "오늘 한강 노을이 ..." 처럼 어색하게 벌어지는 걸 막습니다.

"더 보기" 버튼을 isLong && 로 감싼 것도 눈여겨보세요. 캡션이 짧으면 버튼 자체가 안 그려집니다. B-2 에서 배운 조건부 렌더링이고, Step 3 에서 슬롯에 false 를 넘겼을 때와 같은 원리예요.

⚠️ 같은 훅을 두 번 써도 상태는 안 섞입니다

여기가 이 Step 의 핵심입니다.

지금 피드에는 카드가 두 장 있고, 두 장 다 PostBody 를 그립니다. 두 PostBody 가 같은 useToggle 을 부르죠. 그러면 한 카드의 캡션을 펼쳤을 때 다른 카드도 같이 펼쳐질까요?

직접 눌러 보세요. 안 그렇습니다.

텍스트
 같은 useToggle 을 두 곳에서 불러도 상태는 섞이지 않는다

 PostCard #1  PostBody  useToggle()    captionOpen = true
 PostCard #2  PostBody  useToggle()    captionOpen = false

 훅이 공유하는 것은 코드이고, 상태는 부른 쪽마다 따로 생긴다

Step 6 에서 말한 문장이 여기서 증명됩니다. 훅은 로직을 공유하지 상태를 공유하지 않습니다.

당연한 결과예요. useToggle() 을 부르면 그 안의 useState 가 부른 컴포넌트의 훅 목록에 등록된다고 했죠. 카드가 두 장이면 컴포넌트 인스턴스도 두 개고, 훅 목록도 두 개입니다. 서로 남남이에요.

이 성질 때문에 커스텀 훅이 안심하고 쓸 수 있는 도구가 됩니다. 남이 만든 훅을 가져다 써도 내 상태가 다른 화면과 얽힐 걱정이 없거든요.

거꾸로 여러 컴포넌트가 정말 같은 값을 봐야 한다면 훅으로는 안 됩니다. 그때는 B-2 에서 배운 대로 공통 부모까지 상태를 올려야 해요. App 의 좋아요 목록이 그래서 App 에 있는 겁니다.

💡 한 줄 정리

값이 둘이면 배열로, 셋 이상이면 객체로 돌려줍니다. 같은 훅을 여러 곳에서 불러도 상태는 부른 쪽마다 따로 생기므로, 값을 진짜로 공유하려면 상태를 부모로 올려야 합니다.

🙋 학생 질문 — "튜터님, 그럼 훅으로 전역 상태를 만들 수는 없나요?"

오늘 배운 방식으로는 안 됩니다. useToggle 을 열 곳에서 부르면 상태가 열 개 생기니까요.

방법이 아예 없는 건 아니에요. React 에는 값을 트리 아래로 흘려보내는 별도의 장치가 있고, 그것 말고도 전역 상태를 다루는 도구가 여럿 있습니다. 이건 C 카테고리에서 제대로 다룹니다. 지금 이름만 알아두시면 돼요.

다만 한 가지는 지금 짚고 갑니다. 전역 상태는 마지막 수단입니다. 순서는 이래요.

먼저 그 값을 쓰는 컴포넌트 안에 둘 수 있는지 봅니다. 안 되면 공통 부모로 올립니다. 부모가 너무 멀어서 props 를 다섯 단계씩 내려야 할 때, 그때 비로소 다른 도구를 꺼냅니다.

지금 우리 피드는 두 단계면 닿습니다. App 에서 Feed 로, Feed 에서 PostCard 로요. 이 정도는 오히려 props 로 내리는 편이 읽기 좋습니다. 어디서 와서 어디로 가는지가 코드에 다 보이거든요.


Step 8: "묶음을 통째로 — useCommentInput"

오늘의 마지막입니다. 오프닝에서 남겨둔 이야기를 이제 끝냅니다.

CommentForm 을 다시 봅시다.

tsx
// apps/web-spa/src/components/CommentForm.tsx — 지금까지의 모습
import { useRef, useState } from 'react';
import { Button } from './Button';
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>
  );
}

이 컴포넌트가 그리는 화면은 입력창 하나와 버튼 하나입니다. 여덟 줄이면 끝나요. 그런데 그 여덟 줄에 닿기까지 열일곱 줄을 지나야 합니다.

그 열일곱 줄이 하는 일을 세어 봅시다. 입력값을 상태로 들고, 입력창 손잡이를 만들고, 빈 값인지 판정하고, 글자가 바뀔 때와 제출할 때를 처리해요. 다섯 가지인데 전부 댓글 입력창 하나를 돌리는 데 필요한 것들 입니다.

이름을 붙일 수 있죠. useCommentInput 입니다.

다섯을 한 함수로

TypeScript
// apps/web-spa/src/hooks/useCommentInput.ts
import { useRef, useState } from 'react';

// 댓글 입력창 하나를 돌리는 데 필요한 것을 통째로 묶었다.
// 입력값·입력창 손잡이·빈 값 판정·두 핸들러가 늘 함께 다니던 묶음이다.
export function useCommentInput(onSubmit: (content: string) => void) {
  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 { content, inputRef, isEmpty, handleChange, handleSubmit };
}

잘라내서 붙여넣은 것이 전부입니다. 코드가 한 줄도 안 바뀌었어요. 달라진 건 두 가지뿐입니다.

하나 — onSubmit 을 인자로 받습니다. 원래는 props 로 들어오던 값인데 이제 훅이 인자로 받아요. 훅에도 값을 넘길 수 있다는 걸 여기서 처음 봅니다. useLikeToggle 에 초기 목록을 넘긴 것과 같은 방식이에요.

이게 중요한 이유가 있습니다. 훅이 "댓글이 제출됐다" 는 사실만 알고, 그 뒤에 무슨 일이 일어날지는 모르게 하려는 거예요. A-4 에서 (content: string) => void 를 배울 때 했던 이야기가 여기서도 통합니다.

둘 — 필요한 다섯을 객체로 돌려줍니다. 값이 셋을 넘으니 앞 Step 의 기준대로 객체입니다.

CommentForm 이 반으로 줄어듭니다

tsx
// apps/web-spa/src/components/CommentForm.tsx
import { Button } from './Button';
import { CommentInput } from './CommentInput';
import { useCommentInput } from '../hooks/useCommentInput';

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

export function CommentForm({ onSubmit }: CommentFormProps) {
  const { content, inputRef, isEmpty, handleChange, handleSubmit } =
    useCommentInput(onSubmit);

  return (
    <form className="comment-form" onSubmit={handleSubmit}>
      <CommentInput ref={inputRef} value={content} onChange={handleChange} />
      <Button className="comment-submit" type="submit" disabled={isEmpty}>
        게시
      </Button>
    </form>
  );
}
텍스트
 CommentForm 의 몸통

 옮기기 전   준비 17 줄  +  화면 8 줄
 옮긴 뒤     준비  2 줄  +  화면 8 줄

준비 줄이 열일곱에서 둘로 줄었습니다. 이제 이 파일을 열면 곧바로 화면 이야기가 시작돼요. 폼이 무엇으로 이루어져 있는지가 파일의 주인공이 됐습니다.

useRefuseState 임포트도 사라졌죠. 이 파일은 이제 상태를 직접 다루지 않으니까요.

동작은 하나도 안 바뀌었습니다

브라우저에서 확인해 봅시다. 오늘 하루 종일 반복한 그 문장이 마지막에도 그대로예요.

빈 댓글을 쓰려고 하면 버튼이 안 눌립니다. 입력창에서 그냥 엔터를 쳐도 아무 일이 안 일어나요. 댓글을 달면 입력창이 비워지고 커서가 그대로 남아서 연달아 쓸 수 있습니다.

A-4 에서 하나씩 붙였던 동작들이 전부 살아 있습니다. 코드를 다른 파일로 옮겼을 뿐이니까요.

오늘 만든 훅 셋

텍스트
 useLikeToggle    피드 하나에 하나       객체 { posts, likedCount, toggle }
 useToggle        카드마다 하나씩        배열 [on, toggle]
 useCommentInput  댓글 폼마다 하나씩     객체 { content, inputRef, isEmpty, 핸들러 둘 }

셋 다 하는 일은 같습니다. 컴포넌트 몸통에 흩어져 있던 상태와 함수를 한 덩어리로 묶고 이름을 붙였어요.

그리고 셋 다 화면에 보이는 값 을 다룹니다. 좋아요가 눌렸는지, 캡션이 펼쳐졌는지, 입력창에 무엇이 쓰였는지요. 이게 오늘 다룬 훅들의 공통점이고, 동시에 오늘의 한계이기도 합니다. 이 이야기는 마무리에서 이어갈게요.

💡 한 줄 정리

한 컴포넌트 안에서 늘 함께 다니는 상태와 핸들러 묶음은 훅 하나로 통째로 옮길 수 있습니다. 훅도 인자를 받을 수 있어서, 결과를 어떻게 쓸지는 부르는 쪽이 정합니다.

🙋 학생 질문 — "튜터님, 이 훅은 한 곳에서만 쓰는데 그래도 뺄 값어치가 있나요?"

좋은 질문이고, 실제로 이럴 때 판단이 갈립니다.

재사용만 놓고 보면 값어치가 없습니다. useCommentInput 을 부르는 곳은 CommentForm 하나뿐이니까요. 파일만 하나 늘었죠.

그런데 훅을 뽑는 이유가 재사용만은 아닙니다. 읽는 순서를 바꾸는 것 도 큰 이유예요.

컴포넌트를 열었을 때 무엇이 먼저 보여야 할까요. 저는 "이 컴포넌트가 무슨 화면을 그리는가" 라고 생각합니다. 그게 컴포넌트의 정체니까요. 상태를 어떻게 관리하는지는 그다음이에요. 궁금할 때 찾아 들어가면 되는 세부 사항입니다.

옮기기 전 CommentForm 은 그 순서가 뒤집혀 있었습니다. 세부 사항이 열일곱 줄을 차지하고 정체는 맨 아래에 있었어요.

그리고 이렇게 뽑아두면 재사용할 일이 생겼을 때 준비가 끝나 있습니다. 게시물 상세 화면에도 댓글 폼이 들어가고, 답글 입력창도 같은 모습일 거예요. 그때 훅을 부르기만 하면 됩니다.

다만 균형은 필요합니다. 한 줄짜리 상태까지 전부 훅으로 빼면 파일만 늘고 읽기는 더 어려워져요. 묶음이 다섯 개쯤 되고 그 다섯이 하나의 일을 위해 존재할 때 가 좋은 신호입니다.


마무리

오늘은 만드는 이야기가 아니라 정리하는 이야기 였습니다. 더보기 버튼과 캡션 접기가 늘긴 했지만 화면은 시작할 때와 거의 같은데 파일 구성만 계속 바뀌었죠.

그런데 그게 오늘의 요점입니다. 실무에서 코드를 쓰는 시간보다 읽는 시간이 훨씬 깁니다. 같은 화면을 만드는 방법이 여럿일 때, 나중에 읽을 사람이 덜 헤매는 쪽을 고르는 것이 설계예요.

오늘 배운 핵심 세 가지

💡 하나 — 쪼개는 데도 기준이 있습니다. 자기 동작이 있는지, 묶을 것이 둘 이상인지, 다른 곳에서도 쓰는지. 셋 다 아니면 안 쪼갭니다. Avatar 를 그냥 감싸기만 한 껍데기와 댓글 한 줄을 안 만든 판단이 그 기준의 결과였어요. 안 만드는 것도 설계입니다.

💡 둘 — 재사용은 물려받는 것이 아니라 넣는 것입니다. Card 는 머리말·본문·꼬리말 세 칸의 이름과 순서만 정하고, 무엇이 채워질지는 끝까지 모릅니다. 그래서 게시물 카드에도 쓰고 프로필 카드에도 씁니다. 껍데기가 아는 게 적을수록 쓸 곳이 늘어나요.

💡 셋 — 훅은 로직을 공유하지 상태를 공유하지 않습니다. 커스텀 훅은 use 로 시작하는 평범한 함수이고, 부르면 그 안의 상태가 부른 컴포넌트에 등록됩니다. 그래서 같은 훅을 열 곳에서 불러도 상태는 열 개예요. 값을 진짜로 공유하려면 여전히 상태를 부모로 올려야 합니다.

레거시 코드에서 만날 것 — 클래스 컴포넌트

한 가지만 짚고 갑니다. 예전에는 컴포넌트를 클래스로 썼습니다. class PostCard extends React.Component 처럼 선언하고, 상태는 this.state 에 담고, 화면에 붙거나 떨어질 때 할 일은 정해진 이름의 메서드에 적었어요. 훅이 나오기 전에는 상태를 가지려면 이 방법밖에 없었습니다. 지금도 없어진 게 아니라 그대로 동작합니다. 다만 공식 문서가 레거시로 분류했고 새 코드에는 쓰지 않아요. 로직을 재사용하기가 까다로웠던 게 가장 큰 이유인데, 오늘 우리가 커스텀 훅으로 손쉽게 한 일이 클래스 시절에는 꽤 복잡한 방법을 동원해야 했거든요. 오래된 코드에서 만나면 "훅이 나오기 전 방식이구나" 하고 알아볼 정도면 충분합니다. 지난 시간에 만난 옛 방식들과 같은 자세로 보시면 돼요. 필요 없어졌을 뿐 사라진 것은 아닙니다.

다음 시간 예고

오늘 만든 훅 셋을 다시 떠올려 보세요. 좋아요가 눌렸는지, 캡션이 펼쳐졌는지, 입력창에 무엇이 쓰였는지. 전부 화면에 보이는 값 이었습니다. 값이 바뀌면 화면이 다시 그려지고, 그걸로 이야기가 끝났어요.

그런데 앱에는 화면 밖에서 벌어지는 일도 있습니다. 사용자가 피드를 어디까지 내렸는지 기억해뒀다가 돌아왔을 때 복원해주는 일, 몇 초 뒤에 알림을 사라지게 하는 타이머를 걸었다가 화면을 떠날 때 치우는 일, 브라우저 창 크기가 바뀌는 걸 계속 듣고 있는 일 같은 것들이요.

이런 일들은 화면을 그리는 것과 성격이 다릅니다. 시작한 다음 끝내주는 것까지 챙겨야 하거든요. 타이머를 걸어놓고 안 치우면 이미 사라진 화면을 건드리려 들고, 구독을 안 끊으면 같은 일이 두 번 세 번 일어납니다.

다음 시간에는 이런 일을 다루는 useEffect 를 배웁니다. 언제 필요하고, 더 중요하게는 언제 필요 없는지 를 함께 봅니다. 오늘 만든 훅들처럼 상태만으로 되는 일에 이걸 꺼내 쓰는 것이 입문자가 가장 자주 하는 실수거든요.


과제

[구현] 저장한 게시물을 훅으로 관리하기

인스타그램에는 게시물을 저장하는 기능이 있죠. 좋아요와 비슷해 보이지만 다릅니다. 좋아요는 게시물마다 개수가 붙지만, 저장은 내가 저장했는지 아닌지만 있고 개수는 "내가 저장한 게시물이 모두 몇 개인가" 하나뿐이에요.

  • apps/web-spa/src/hooks/useSavedPosts.tsuseSavedPosts 훅을 만들어 주세요. 저장한 게시물 id 목록을 상태로 들고, isSaved(id)·toggleSave(id)·savedCount 를 돌려줍니다.
  • 돌려주는 값이 셋이니 오늘 세운 기준대로 배열과 객체 중 맞는 쪽을 고르세요.
  • apps/web-spa/src/components/SaveButton.tsxSaveButton 을 만들어 주세요. 안쪽은 오늘 만든 Button 을 쓰고, 저장 여부에 따라 보이는 기호가 바뀌게 합니다. 기호만 보이는 버튼이니 읽어줄 이름도 챙겨 주세요.
  • SaveButtonPostHeader 의 더보기 버튼 왼쪽에 넣어 주세요. PostHeader 가 받아야 할 props 가 늘어납니다.
  • 저장 개수는 App 머리말에 "저장한 게시물 N개" 로 띄워 주세요. 좋아요 개수 옆입니다.
  • 훅을 어디서 부를지가 이 과제의 핵심입니다. PostCard 안에서 부르면 어떻게 되는지 먼저 생각해 보세요. Step 7 에서 확인한 성질이 그대로 적용됩니다.
  • 상태를 바꿀 때는 B-2 에서 배운 대로 원본 배열을 건드리지 말고 새 배열을 만들어 넣어 주세요.
  • 아직 안 배운 것을 끌어오지 마세요. 오늘 배운 것만으로 전부 됩니다. 다음 시간에 배울 useEffect 도 여기서는 필요 없어요.

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

[탐구] 훅 규칙을 어겨보고 언제 터지는지 확인하기

Step 5 에서 "조건이 안 바뀌면 안 터진다" 고 했죠. 말로만 듣지 말고 직접 확인해 봅시다. 아래를 순서대로 해보고 각 단계에서 무슨 일이 일어났는지 한두 줄씩 적어 주세요.

  • PostBodyuseToggle 호출을 if (isLong) 블록 안으로 옮겨 보세요. 에디터가 뭐라고 하나요?
  • 그 상태로 브라우저를 열어 보세요. 지금 피드의 캡션은 둘 다 열 글자를 넘습니다. 화면이 무너지나요?
  • feedPosts 에 캡션이 아주 짧은 게시물을 하나 더해 보세요. 이제 카드마다 훅 개수가 다른데도 멀쩡한가요? 왜 그런지 생각해 보세요.
  • 이번엔 같은 카드 안에서 isLong 이 바뀌게 만들어 보세요. 캡션을 늘렸다 줄였다 하는 버튼을 하나 두면 됩니다. 먼저 그 버튼과 캡션 상태를 PostCard 쪽에 두고 PostBody 에는 props 로만 내려보내 보세요. 눌러 보면 터지나요? 펼쳐둔 캡션은 어떻게 되나요?
  • 이제 그 캡션 상태를 PostBody 안으로 옮겨서 useToggle 앞에 useState 가 하나 서게 만들어 보세요. 같은 버튼을 눌러 보고, 앞의 실험과 무엇이 달라졌는지 적어 주세요. 훅이 늘어날 때와 줄어들 때 메시지도 각각 적어 주세요. 두 실험의 차이가 Step 5 에서 말한 그 갈림입니다.
  • 마지막으로 Cardheader={() => <p>jaehoon</p>} 을 넘겨 보세요. 이건 브라우저를 열기 전에 걸리나요, 열어야 걸리나요? 앞의 훅 실험들과 비교해서 무엇이 다른지 한 줄로 정리해 주세요.

확인이 끝나면 전부 원래대로 되돌려 주세요.


생각해볼 주제

1. 어디까지 쪼개야 할까

오늘 세 기준으로 컴포넌트를 나눴습니다. 그런데 기준을 엄격하게 적용하다 보면 조각이 계속 늘어나요.

파일이 스무 개가 넘어가면 화면 하나를 이해하려고 파일 여덟 개를 열어야 하는 상황이 옵니다. 반대로 안 쪼개면 한 파일이 300줄이 되고요. 여러분은 어느 쪽 고통이 더 크다고 보시나요. 그리고 그 판단은 팀 규모나 프로젝트 수명에 따라 달라질까요.

2. 슬롯이냐, 컴포넌트를 나누느냐

Cardheader·footer 슬롯을 뚫는 대신, PostCardHeader·PostCardFooter 처럼 카드 전용 컴포넌트를 따로 만들고 PostCard 가 직접 늘어놓는 방법도 있었습니다. 화면 결과는 같아요.

두 방법 중 무엇을 언제 고르시겠어요. 특히 이런 상황을 가정해 보세요. 나중에 카드 종류가 다섯 가지로 늘어나고, 그중 셋은 꼬리말이 없고, 하나는 머리말이 두 줄입니다. 어느 쪽이 그 변화를 더 잘 견딜까요.

3. 훅과 그냥 함수의 경계

오늘 만든 훅 셋은 전부 안에서 useState 를 불렀습니다. 그런데 상태를 전혀 안 쓰고 계산만 하는 함수는 어떨까요. 예를 들어 캡션을 잘라주는 계산을 따로 뺀다면요.

이걸 useCaption 이라고 이름 붙여 훅으로 만드는 것과, formatCaption 이라는 평범한 함수로 두는 것 중 무엇이 나을까요. 두 선택이 나중에 어떤 차이를 만들지 함께 생각해 보세요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 저장한 게시물을 훅으로 관리하기

채점 포인트

항목 확인 내용 배점
훅을 부른 위치 useSavedPostsApp 에서 한 번만 부르고 아래로 내려보냈는가 30
머리말 합계 카드에서 저장을 눌렀을 때 머리말 숫자가 함께 바뀌는가 10
돌려주는 모양 값이 셋이라 배열이 아니라 객체로 돌려줬는가 10
불변성 원본 배열을 건드리지 않고 새 배열을 만들어 넣었는가 15
props 통로 savedonToggleSaveFeedPostCardPostHeader 로 이어지는가 15
읽어줄 이름 기호만 보이는 SaveButton 에 읽어줄 이름을 줬는가 15
검사 통과 typechecklint 가 모두 통과하는가 5

첫 줄 배점이 유난히 큽니다. 나머지를 다 맞춰도 훅을 카드 안에서 부르면 머리말 숫자가 안 맞거든요. 그런데 화면은 멀쩡해 보여서 스스로 알아채기가 어렵습니다.

풀이 예시

훅부터 만듭니다.

TypeScript
// apps/web-spa/src/hooks/useSavedPosts.ts
import { useState } from 'react';

export function useSavedPosts() {
  const [savedIds, setSavedIds] = useState<number[]>([]);

  function isSaved(id: number) {
    return savedIds.includes(id);
  }

  function toggleSave(id: number) {
    // 원본 배열은 그대로 두고 새 배열을 만들어 넣는다
    setSavedIds(
      isSaved(id)
        ? savedIds.filter((savedId) => savedId !== id)
        : [...savedIds, id],
    );
  }

  // 돌려줄 값이 셋이라 배열 대신 객체로 준다
  return { isSaved, toggleSave, savedCount: savedIds.length };
}

파일 확장자가 .ts 입니다. JSX 가 한 줄도 없으니까요. Step 6 에서 useLikeToggle 을 만들 때와 같은 이유예요.

useState<number[]>([]) 에 꺾쇠를 적은 것을 봐주세요. 빈 배열만 넘기면 TypeScript 가 무엇이 담길 배열인지 알 방법이 없습니다. B-2 에서 이걸 안 적었다가 stringnever 에 넣을 수 없다는 메시지를 만났었죠.

savedCount 를 따로 상태로 두지 않은 것도 중요합니다. 목록의 길이에서 나오는 값이라 따로 기억할 이유가 없어요. 두 개를 각각 관리하면 둘이 어긋날 길이 열립니다.

돌려주는 모양은 객체입니다. Step 7 에서 세운 기준 그대로예요. 값이 셋이고, 쓰는 쪽마다 필요한 것만 꺼내 씁니다. App 은 셋 다 쓰지만 나중에 저장 개수만 필요한 화면이 생기면 const { savedCount } = useSavedPosts() 로 하나만 꺼내면 되고요.

다음은 버튼입니다.

tsx
// apps/web-spa/src/components/SaveButton.tsx
import { Button } from './Button';

interface SaveButtonProps {
  saved: boolean;
  onToggle: () => void;
}

export function SaveButton({ saved, onToggle }: SaveButtonProps) {
  return (
    <Button
      className={saved ? 'save-button saved' : 'save-button'}
      onClick={onToggle}
      // 기호만 보이는 버튼이라 읽어줄 이름을 따로 준다
      aria-label={saved ? '저장 취소' : '저장'}
    >
      {saved ? '⚑' : '⚐'}
    </Button>
  );
}

이 파일이 저장 목록을 전혀 모른다 는 점을 봐주세요. 자기가 저장됐는지(saved)와 눌렀을 때 부를 함수(onToggle) 만 받습니다. 그래서 훅을 App 에서 부르든 다른 데서 부르든 이 파일은 한 글자도 안 바뀝니다.

읽어줄 이름을 상태에 따라 바꾼 것도 눈여겨보세요. 저장된 상태에서 이 버튼을 누르면 저장이 취소되니, 화면을 못 보는 사용자에게는 "저장 취소" 라고 들려야 맞습니다.

머리 구역에 넣습니다.

tsx
// apps/web-spa/src/components/PostHeader.tsx
import { Avatar } from './Avatar';
import { Button } from './Button';
import { SaveButton } from './SaveButton';

interface PostHeaderProps {
  username: string;
  profileImageUrl: string;
  saved: boolean;
  onToggleSave: () => void;
}

export function PostHeader({
  username,
  profileImageUrl,
  saved,
  onToggleSave,
}: PostHeaderProps) {
  return (
    <div className="post-header">
      <Avatar username={username} profileImageUrl={profileImageUrl} />
      <SaveButton saved={saved} onToggle={onToggleSave} />
      <Button className="post-more" aria-label="게시물 메뉴">
        ⋯
      </Button>
    </div>
  );
}

카드는 저장 여부를 받아 머리 구역에 넘깁니다.

tsx
// apps/web-spa/src/components/PostCard.tsx
import { useState } from 'react';
import type { PostCardProps } from '../types/derived';
import type { DraftComment } from './CommentList';
import { Card } from './Card';
import { PostHeader } from './PostHeader';
import { PostImage } from './PostImage';
import { PostBody } from './PostBody';
import { CommentList } from './CommentList';
import { CommentForm } from './CommentForm';

interface PostCardViewProps extends PostCardProps {
  onToggleLike: (id: number) => void;
  saved: boolean;
  onToggleSave: (id: number) => void;
}

export function PostCard({
  id,
  username,
  profileImageUrl,
  imageUrl,
  content,
  liked,
  likeCount,
  commentCount,
  onToggleLike,
  saved,
  onToggleSave,
}: PostCardViewProps) {
  const [comments, setComments] = useState<DraftComment[]>([]);

  function addComment(text: string) {
    setComments([...comments, { id: comments.length + 1, content: text }]);
  }

  return (
    <Card
      className="post-card"
      header={
        <PostHeader
          username={username}
          profileImageUrl={profileImageUrl}
          saved={saved}
          onToggleSave={() => onToggleSave(id)}
        />
      }
      footer={<CommentForm onSubmit={addComment} />}
    >
      <PostImage
        imageUrl={imageUrl}
        username={username}
        onLike={() => onToggleLike(id)}
      />
      <PostBody
        username={username}
        content={content}
        liked={liked}
        likeCount={likeCount}
        commentCount={commentCount + comments.length}
        onToggle={() => onToggleLike(id)}
      />
      <CommentList comments={comments} />
    </Card>
  );
}

onToggleSave={() => onToggleSave(id)} 를 보세요. 카드는 자기 id 를 알고 있고 머리 구역은 몰라도 되니, 여기서 id 를 채워 넣은 함수로 바꿔 내려보냅니다. 좋아요에서 이미 쓰던 방식 그대로예요.

목록은 받은 것을 그대로 흘려보냅니다.

tsx
// apps/web-spa/src/components/Feed.tsx
import type { Post } from '../types/instagram';
import { PostCard } from './PostCard';

interface FeedProps {
  posts: Post[];
  onToggleLike: (id: number) => void;
  isSaved: (id: number) => boolean;
  onToggleSave: (id: number) => void;
}

export function Feed({ posts, onToggleLike, isSaved, onToggleSave }: FeedProps) {
  return (
    <>
      {posts.map((post) => (
        <PostCard
          key={post.id}
          {...post}
          onToggleLike={onToggleLike}
          saved={isSaved(post.id)}
          onToggleSave={onToggleSave}
        />
      ))}
    </>
  );
}

FeedisSaved 함수를 받아서 카드마다 isSaved(post.id) 를 계산해 넘깁니다. 카드에는 함수가 아니라 결과인 boolean 만 내려가요. 카드가 목록 전체를 알 이유가 없으니까요.

마지막으로 App 입니다. 이 과제의 답이 여기 한 줄에 있습니다.

tsx
// apps/web-spa/src/App.tsx
import { Feed } from './components/Feed';
import { Section } from './components/Section';
import { feedPosts } from './data/feed';
import { useLikeToggle } from './hooks/useLikeToggle';
import { useSavedPosts } from './hooks/useSavedPosts';

export function App() {
  const { posts, likedCount, toggle } = useLikeToggle(feedPosts);
  // 머리말의 합계와 카드의 저장 여부가 같은 목록을 봐야 하므로 여기서 한 번만 부른다
  const { isSaved, toggleSave, savedCount } = useSavedPosts();

  return (
    <main className="feed">
      <header className="feed-header">
        <h1 className="feed-title">인스타그램</h1>
        <span className="feed-liked-count">좋아요 누른 게시물 {likedCount}개</span>
        <span className="feed-saved-count">저장한 게시물 {savedCount}개</span>
      </header>
      <Section title="피드">
        <Feed
          posts={posts}
          onToggleLike={toggle}
          isSaved={isSaved}
          onToggleSave={toggleSave}
        />
      </Section>
    </main>
  );
}

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

CSS
.save-button {
  margin-left: auto;
  border: none;
  background: none;
  font-size: 18px;
  cursor: pointer;
}

.save-button.saved {
  color: #262626;
  font-weight: 700;
}

자주 나오는 실수

훅을 PostCard 안에서 부르는 것. 이 과제가 노린 실수입니다. PostCard 가 저장 여부를 알아야 하니 거기서 부르는 게 자연스러워 보이거든요.

텍스트
 훅을 App 에서 부른 경우 — 목록이 하나뿐이다

 App          useSavedPosts()    [1, 2]
 PostCard #1  saved={true}      props 로 내려받는다
 PostCard #2  saved={true}      props 로 내려받는다


 훅을 PostCard 안에서 부른 경우 — 저장 목록이 카드 수만큼 생긴다

 App          useSavedPosts()    []      머리말이 세는 목록
 PostCard #1  useSavedPosts()    [1]     1번 카드만 아는 목록
 PostCard #2  useSavedPosts()    [2]     2번 카드만 아는 목록

카드에서 저장을 눌러 보면 기호가 잘 바뀝니다. 그런데 머리말은 "저장한 게시물 0개" 에서 꼼짝도 안 해요. 카드가 만든 목록과 머리말이 보는 목록이 서로 남남이거든요.

무서운 건 에러도 경고도 안 뜬다 는 점입니다. 타입 검사도 통과하고 린트도 조용합니다. 훅 규칙을 어긴 게 아니니까요. Step 7 에서 두 카드의 캡션이 따로 펼쳐졌던 그 성질이, 여기서는 버그가 됩니다. 같은 성질이 어떤 화면에서는 이득이 되고 다른 화면에서는 손해가 돼요.

원본 배열을 그 자리에서 고치는 것.

TypeScript
function toggleSave(id: number) {
  const at = savedIds.indexOf(id);
  if (at === -1) {
    savedIds.push(id);
  } else {
    savedIds.splice(at, 1);
  }
  setSavedIds(savedIds);
}

값은 분명히 바뀌었는데 화면이 안 바뀝니다. React 는 넣은 값이 들고 있던 것과 같은 배열이면 다시 그릴 이유가 없다고 판단하거든요. push 는 배열 안을 고칠 뿐 새 배열을 만들지 않습니다. B-2 에서 toggleLike 를 만들 때 했던 이야기가 그대로예요.

돌려주는 값을 배열로 주는 것. return [isSaved, toggleSave, savedCount] 도 동작은 합니다. 다만 셋을 순서로 외워야 하고, 저장 개수만 필요한 화면에서도 앞의 둘을 건너뛰고 받아야 해요. Step 7 의 기준대로 셋부터는 객체가 편합니다.

기호만 두고 읽어줄 이름을 안 주는 것. 는 화면을 보는 사람에게만 의미가 있습니다. 화면을 못 보는 사용자에게는 아무 이름도 안 읽히거나 기호 이름이 그대로 읽혀요. 우리가 Buttonaria-label 을 열어둔 게 정확히 이럴 때 쓰라고 만든 겁니다.

💡 튜터의 한마디

이 과제의 주제는 저장 기능이 아니라 훅을 어디서 부르는가 입니다.

Step 6 과 Step 7 에서 같은 문장을 두 번 말했죠. 훅은 로직을 공유하지 상태를 공유하지 않는다고요. 말로 들을 때는 당연해 보이는데, 막상 새 기능을 만들면 손이 먼저 카드 안으로 갑니다. 거기가 그 값을 쓰는 곳이니까요.

판단 순서는 하나뿐입니다. 그 값을 누가 봐야 하는가. 저장 여부는 카드가 보고, 저장 개수는 머리말이 봅니다. 둘이 같은 목록을 봐야 하니 둘의 공통 부모인 App 이 그 목록의 주인이 되어야 해요. 훅으로 옮긴 건 그 판단 다음의 일이고, 판단 자체를 바꾸지 않습니다.

한 걸음 더 나가면 여기서 실무의 다음 질문이 나옵니다. App 이 저장 목록을 들고 있는데, 게시물 상세 화면에서도 저장 버튼을 눌러야 한다면요. 그 화면은 App 아래 다섯 단계쯤 떨어져 있을 텐데 savedonToggleSave 를 다섯 번 갈아타며 내려야 합니다. 이때부터가 전역 상태 도구를 꺼낼 만한 상황인데, 이건 C 카테고리에서 제대로 다룹니다. 지금은 props 로 두 단계 안에 닿으면 props 가 낫다 는 것만 기억해 두세요.

🎯 [과제 2 예시답안] 훅 규칙을 어겨보고 언제 터지는지 확인하기

채점 포인트

항목 확인 내용 배점
여섯 단계 각 단계를 실제로 돌려보고 결과를 적었는가 25
2·3단계 해석 왜 안 무너지는지, 훅 순서를 기억하는 단위가 무엇인지 답했는가 20
4·5단계 대비 같은 위반인데 결과가 갈린 이유를 훅 개수로 설명했는가 30
두 메시지 훅이 늘어날 때와 줄어들 때를 구분해 적었는가 15
6단계 정리 앞의 훅 실험과 무엇이 다른지 한 줄로 뽑았는가 10

4단계에서 "어? 안 터지는데" 하고 당황했다면 제대로 하신 겁니다. 그 당황이 이 과제의 목적이에요.

풀이 예시

1. useToggleif (isLong) 안으로 옮기기

브라우저를 열기도 전에 에디터에 빨간 줄이 뜹니다.

텍스트
error  React Hook "useToggle" is called conditionally. React Hooks must be called
       in the exact same order in every component render
       react-hooks/rules-of-hooks

메시지에 불린 이름이 useState 가 아니라 useToggle 입니다. 린트는 안을 들여다보지 않고 이름만 보고 훅이라고 판단해요. Step 5 에서 makeToggle 이 혼났던 것과 같은 규칙의 반대편입니다.

같은 요구를 짧은 캡션부터 먼저 내보내는 방식으로 푼 분도 계실 겁니다.

tsx
if (!isLong) {
  return (
    <>
      <LikeButton liked={liked} likeCount={likeCount} onToggle={onToggle} />
      <p className="post-content">
        <strong>{username}</strong> {content}
      </p>
      <p className="post-comments">댓글 {commentCount}개 모두 보기</p>
    </>
  );
}

const [captionOpen, toggleCaption] = useToggle();

눈에는 조건문이 안 보이는데 메시지는 똑같이 나옵니다. 위에서 먼저 나가버리면 이 줄까지 못 오니까요. 참고로 "혹시 이른 return 뒤에 훅을 두신 건 아닌가요" 라는 꼬리말은 이 두 판 어디에도 안 붙었습니다. 그 힌트는 Step 5 의 useCommentDraft 처럼 훅을 부른 if 블록이 곧장 return 으로 빠져나가는 모습일 때 붙어요. 여기서는 훅을 부르고 나서도 함수가 계속 이어지니까 도구가 이른 return 을 의심할 이유가 없었던 겁니다.

2. 그대로 브라우저 열기 — 안 무너집니다

지금 피드의 캡션 길이를 재보면 답이 나옵니다.

텍스트
 CAPTION_LIMIT = 10

 오늘 한강 노을이 미쳤다     13 자     isLong = true
 제주도 3박 4일 기록         12 자     isLong = true

둘 다 넘습니다. 그래서 두 카드 모두 isLong 이 처음부터 끝까지 true 예요. 조건이 안 바뀌면 훅 개수도 안 바뀌고, 훅 개수가 안 바뀌면 React 가 불평할 일이 없습니다. 카드 두 장이 멀쩡히 그려지고 접기와 펼치기도 그대로 동작합니다. 콘솔에 아무것도 안 뜹니다.

린트가 빨갛게 경고하는데 화면은 멀쩡한 상태예요. 여기서 "린트가 예민하네" 하고 넘어가는 게 위험합니다.

3. 짧은 캡션 게시물 추가 — 그래도 멀쩡합니다

feedPosts 에 캡션이 '노을' 인 게시물을 하나 더하면 카드가 세 장이 됩니다. 세 번째 카드는 캡션이 두 글자라 isLongfalse 고, 그래서 useToggle 을 아예 안 부릅니다. '더 보기' 버튼도 두 개만 보이고요.

카드마다 훅 개수가 다른데도 아무 일이 없습니다.

텍스트
 훅 목록은 컴포넌트 인스턴스마다 따로 있다

 PostBody #1   useToggle 1 개   매 렌더 그대로
 PostBody #2   useToggle 1 개   매 렌더 그대로
 PostBody #3   훅 0 개          매 렌더 그대로

React 가 훅 순서를 기억하는 단위가 컴포넌트 인스턴스 이기 때문입니다. 파일이나 함수 단위가 아니에요. 카드가 세 장이면 인스턴스도 셋이고 훅 목록도 셋이라, 서로 개수가 달라도 각자의 순서만 매 렌더 일정하면 됩니다.

Step 7 에서 두 카드의 캡션이 따로 펼쳐졌던 것과 같은 이야기입니다. 그때는 상태가 안 섞이는 이득이었고, 여기서는 규칙을 어겨도 안 걸리는 이유가 됩니다.

4. 캡션 상태를 PostCard 에 두고 길이 바꾸기 — 안 터집니다

이제 진짜 실험입니다. 캡션을 길게 짧게 바꾸는 버튼을 PostCard 에 두고, 바뀐 캡션을 PostBody 에 props 로 내려보냅니다. 같은 카드 안에서 isLong 이 뒤집히죠.

눌러 보면 — 아무 일도 안 일어납니다. 짧게 바꾸면 '더 보기' 버튼이 사라지고, 길게 바꾸면 다시 나타나요. 던져진 에러도 없고 콘솔 경고도 없습니다. 양방향 모두요.

대신 다른 일이 벌어집니다.

텍스트
 1  처음        "오늘 한강 노을이...더 보기"
 2  펼친 뒤     "오늘 한강 노을이 미쳤다접기"
 3  짧게 바꿈   "노을"
 4  다시 길게   "오늘 한강 노을이...더 보기"

 4 번에서 펼쳐둔 상태가 사라졌다

펼쳐뒀던 캡션이 다시 접혀 있습니다. Step 5 에서 말한 그 조용한 사고예요.

이유는 이렇습니다. PostBody 가 가진 훅은 조건부 useToggle 하나뿐이라 개수가 0개와 1개 사이를 오갑니다. 직전 렌더가 남긴 훅이 하나도 없으면 React 는 그 렌더를 처음 그리는 것 으로 취급해요. 비교할 대상이 없으니 에러를 낼 수도 없고, 그냥 새 상태를 만들어 버립니다. 반대 방향인 1개에서 0개도 비교할 훅이 없어 조용하고요.

5. 캡션 상태를 PostBody 안으로 — 그제서야 터집니다

같은 실험인데 버튼과 캡션 상태를 PostBody 안에 둡니다. 그러면 조건부 useToggle 앞에 useState 가 하나 서게 돼요. 훅 개수가 1개와 2개 사이를 오갑니다.

짧게에서 길게로 — 훅이 늘어날 때는 이렇게 나옵니다.

텍스트
Rendered more hooks than during the previous render.

에러가 던져지기 직전에 콘솔 경고가 하나 먼저 뜹니다.

텍스트
React has detected a change in the order of Hooks called by PostBody. This will
lead to bugs and errors if not fixed. For more information, read the Rules of
Hooks: https://react.dev/link/rules-of-hooks

   Previous render            Next render
   ------------------------------------------------------
1. useState                   useState
2. undefined                  useState
   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

직전 렌더에서 두 번째 자리가 비어 있었는데 이번 렌더에는 useState 가 들어왔다고 표까지 그려 알려줍니다. Step 5 에서 서랍 그림으로 설명한 것이 실제 출력으로 나온 모습이에요.

길게에서 짧게로 — 훅이 줄어들 때는 메시지가 다릅니다.

텍스트
Rendered fewer hooks than expected. This may be caused by an accidental early
return statement.

이쪽은 순서 비교표 없이 곧장 터집니다. 콘솔 경고가 0건이에요.

둘 다 화면이 통째로 사라집니다. 카드 한 장이 아니라 페이지 전체가요.

두 실험을 나란히 놓으면 갈림이 선명해집니다.

실험 PostBody 가 가진 훅 훅 개수 결과
4단계 — 캡션 상태를 카드에 둠 조건부 useToggle 하나뿐 0 ↔ 1 안 터짐. 펼쳐둔 상태만 조용히 사라짐
5단계 — 캡션 상태를 PostBody 에 둠 앞에 useState 가 하나 더 선다 1 ↔ 2 터짐. 화면이 통째로 사라짐

갈림의 기준은 조건부 훅 앞에 살아남는 훅이 하나라도 있느냐 입니다. 조건이 바뀌느냐가 아니에요. 4단계도 조건은 분명히 바뀌었지만 비교할 훅이 없어서 조용했습니다.

여기가 이 과제에서 가장 중요한 지점입니다. 같은 규칙 위반이 코드에 따라 다르게 나타나요. 그리고 그 차이를 만드는 건 그 컴포넌트에 훅이 하나 더 있느냐 인데, 이건 나중에 누가 기능 하나를 추가하는 것만으로 바뀝니다. 오늘 조용하던 코드가 다음 주에 페이지를 통째로 날릴 수 있다는 뜻이에요.

6. Card 에 함수를 슬롯으로 넘기기

tsx
<Card header={() => <p>jaehoon</p>}>노을 사진</Card>

이건 브라우저를 열기 전에 걸립니다. 에디터에 바로 빨간 줄이 뜨고, npm run typechecknpm run build 도 여기서 멈춰요.

텍스트
error TS2322: Type '() => React.JSX.Element' is not assignable to type 'ReactNode'.

억지로 타입 검사를 통과시켜 실행까지 끌고 가면 어떻게 될까요. 아무것도 안 터집니다. 페이지는 잘 뜨고 머리말 자리만 조용히 비어 있어요.

텍스트
<article class="post-card">노을 사진</article>

콘솔에 경고가 하나 뜹니다.

텍스트
Functions are not valid as a React child.

뒤에 "혹시 이 함수를 부르려던 건 아니었나요" 라고 묻는 안내가 이어집니다. React 는 함수를 받아도 그릴 방법을 모르고, 알아서 불러주지도 않으니 그냥 건너뜁니다.

앞의 훅 실험들과 정반대입니다.

실수 어디서 걸리나 뚫고 실행하면
조건부 훅 호출 린트만. 타입 검사는 멀쩡히 통과 조건과 훅 개수에 따라 갈린다. 조용하거나 화면이 사라지거나
슬롯에 함수 넘기기 타입 검사가 먼저. 빌드가 멈춘다 안 터진다. 그 자리만 비고 콘솔 경고 한 줄

한 줄로 정리하면 이렇습니다. 타입으로 표현되는 실수는 실행 전에 잡히고, 실행 중의 흐름에 관한 실수는 린트가 대신 잡아준다. 훅 규칙 위반은 타입상 아무 문제가 없어요. 조건문 안에서 함수를 부르는 건 문법적으로 완벽히 정상이니까요. 그래서 린트가 없으면 아무도 안 잡아주고, 실행 중 특정 조건에서만 터집니다.

자주 나오는 실수

2단계에서 "안 무너지네" 하고 끝내는 것. 지금 피드의 캡션이 둘 다 열 글자를 넘어서 조건이 처음부터 끝까지 안 바뀌는 것뿐입니다. 캡션 데이터를 하나만 바꿔도 상황이 달라져요. "지금 안 터진다" 는 "안 터지는 코드다" 와 다릅니다.

4단계와 5단계를 같은 실험으로 여기는 것. 요구사항이 둘로 나뉜 이유가 있습니다. 상태를 어디에 뒀느냐 하나로 결과가 완전히 갈리거든요. 4단계만 해보고 "조건이 바뀌어도 안 터진다" 고 결론 내면 절반만 본 겁니다.

에러만 보고 경고를 놓치는 것. 5단계의 순서 비교표는 던져진 에러가 아니라 그 직전 콘솔 경고에 들어 있습니다. 빨간 에러 화면만 보고 콘솔을 안 내려보면 가장 친절한 단서를 놓쳐요.

되돌리기를 안 하는 것. 다음 시간 코드가 오늘 코드 위에서 이어집니다. 훅을 if 안에 둔 채로 두면 다음 실습에서 엉뚱한 곳을 의심하게 돼요.

💡 튜터의 한마디

이 과제의 결론은 한 줄입니다. 린트 경고를 끄지 마세요.

너무 뻔한 결론 같지만, 실무에서 이 규칙을 어기는 순간은 대개 이렇게 옵니다. 급한 수정을 하다가 조건 하나를 추가했는데 훅이 그 안에 들어갔고, 린트가 빨갛게 뜨는데 화면은 멀쩡합니다. 바쁘니까 주석 한 줄로 규칙을 끄고 넘어가요. 몇 주 뒤 다른 사람이 그 컴포넌트에 상태 하나를 추가하고, 그 순간 4단계였던 코드가 5단계가 됩니다.

그래서 실무 팀은 린트를 개인의 선택으로 두지 않습니다. 코드를 합치기 전에 자동으로 돌려서 통과하지 못하면 아예 못 합치게 막아둬요. 사람의 주의력에 기대는 대신 도구에 맡기는 겁니다. 오늘 여러분이 본 것처럼 어긴 티가 안 나는 규칙일수록 그렇게 해야 합니다.

🤔 [생각해볼 주제 1] 어디까지 쪼개야 할까

문제 상황 요약

오늘 세 기준으로 컴포넌트를 나눴습니다. 그런데 기준을 성실하게 적용할수록 조각이 늘어나요.

파일이 스무 개가 넘으면 화면 하나를 이해하려고 파일 여덟 개를 열어야 합니다. 반대로 안 쪼개면 한 파일이 300줄이 되고요. 어느 쪽 고통이 더 클까요. 그리고 그 답이 팀 규모나 프로젝트 수명에 따라 달라질까요.

튜터의 가이드 및 해설

먼저 두 고통의 성격이 다르다는 것부터 짚고 갑시다.

300줄 한 파일의 고통은 한 번에 크게 옵니다. 처음 열었을 때 압도되지만, 한 번 읽고 나면 그 안에서는 스크롤만 하면 돼요. 파일 스무 개의 고통은 매번 조금씩 옵니다. 캡션 하나 고치려고 어느 파일인지 찾고, 열고, 그게 어디서 불리는지 다시 찾아 올라가고. 한 번은 견딜 만한데 하루에 열 번이면 다릅니다.

그래서 저는 실무에서 더 비싼 쪽이 대체로 의미 없는 조각 이라고 봅니다. 오늘 안 만들기로 한 그 PostHeader 껍데기 같은 것들이요. 파일 수는 늘리면서 새 정보를 하나도 안 줍니다. 파일이 스무 개여도 스무 개가 각각 이름값을 하면 오히려 읽기 편해요. 문제는 스무 개 중 여덟 개가 그냥 감싸기만 하는 경우입니다.

그래서 기준을 하나 더 얹으면 이렇습니다. 이름을 붙였을 때 그 이름이 새 정보를 주는가. PostBody 는 "사진 아래 본문 구역" 이라는 정보를 줍니다. AvatarWrapper 는 안쪽을 그대로 반복할 뿐이고요.

팀 규모는 실제로 답을 바꿉니다. 혼자 만드는 프로젝트는 큰 파일이 유리해요. 어디에 무엇이 있는지가 전부 머리에 있으니 탐색 비용이 거의 0 입니다. 여럿이면 반대예요. 같은 파일을 두 명이 동시에 고치면 합칠 때 충돌이 나고, 리뷰 단위도 커집니다. 쪼개져 있으면 "이 사람은 댓글 목록만 건드렸구나" 가 파일 이름에서 보이죠.

수명도 바꿉니다. 두 달 뒤 버릴 프로토타입은 크게 두는 게 맞습니다. 쪼개는 비용은 지금 내고 이득은 나중에 오는데, 그 나중이 안 오니까요. 2년 갈 제품이면 반대고요.

마지막으로 하나만 더. 되돌리기 비용이 한쪽으로 크게 기울어 있습니다.

텍스트
 큰 파일을 쪼개기        잘라서 붙이고 props 만 연결하면 끝
 쪼갠 것을 다시 합치기   쓰는 곳을 전부 찾아 훑어야 한다

그래서 확신이 없으면 안 쪼개는 쪽 에서 시작합니다. 지난 시간 타입 파생을 이야기할 때와 같은 원칙이에요. 되돌리기 싼 쪽에서 출발합니다.

그리고 신호를 기다립니다. "이 조각을 고치려는데 다른 파일을 함께 열어야 한다" 가 반복되면 잘못 쪼갠 것이고, "한 파일에서 관계없는 두 가지를 나란히 고치고 있다" 가 반복되면 안 쪼갠 것입니다. 둘 다 겪어봐야 아는 것이라 미리 정답을 정할 수 없어요.

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

"파일 개수는 기준이 아니라고 봅니다. 스무 개여도 스무 개가 각각 이름값을 하면 읽기 편하고, 다섯 개여도 그중 둘이 그냥 감싸기만 하면 그 둘이 비용입니다. 제 기준은 이름이 새 정보를 주는가 하나예요. PostBody 는 사진 아래 본문 구역이라는 정보를 주지만 AvatarWrapper 는 안쪽을 반복할 뿐이거든요. 그리고 확신이 없으면 안 쪼갭니다. 큰 파일을 쪼개는 건 잘라 붙이면 되지만 잘못 쪼갠 걸 되돌리려면 쓰는 곳을 전부 훑어야 하니까요. 대신 '이 조각 하나 고치려고 다른 파일을 같이 열어야 한다' 가 반복되면 그때 되돌립니다. 팀 규모도 실제로 답을 바꾼다고 보는데, 혼자면 탐색 비용이 거의 없어서 큰 파일이 유리하고 여럿이면 충돌과 리뷰 단위 때문에 쪼갠 쪽이 유리합니다."

🤔 [생각해볼 주제 2] 슬롯이냐, 컴포넌트를 나누느냐

문제 상황 요약

Cardheader·footer 슬롯을 뚫는 대신, PostCardHeader·PostCardFooter 를 따로 만들고 PostCard 가 직접 늘어놓는 방법도 있었습니다. 화면 결과는 같아요.

나중에 카드 종류가 다섯 가지로 늘어나고, 그중 셋은 꼬리말이 없고, 하나는 머리말이 두 줄입니다. 어느 쪽이 그 변화를 더 잘 견딜까요.

튜터의 가이드 및 해설

두 방법이 가르는 것은 딱 하나입니다. 칸의 순서와 간격을 누가 아는가.

슬롯 방식에서는 껍데기가 압니다. Card 안에 머리말이 위, 본문이 가운데, 꼬리말이 아래라는 규칙이 적혀 있어요. 그래서 카드가 다섯 종류로 늘어나도 그 규칙은 한 곳에 그대로 있습니다. 나중에 머리말과 본문 사이 간격을 바꾸려면 Card 파일 하나만 열면 돼요.

직접 늘어놓는 방식에서는 쓰는 쪽이 압니다. 자유롭죠. 카드마다 순서를 다르게 할 수도 있고요. 대신 다섯 종류가 생기면 같은 배치가 다섯 곳에 복사됩니다. 간격 하나 바꾸려면 다섯 곳을 찾아야 하고, 넷만 고치고 하나를 빠뜨리는 사고가 여기서 납니다.

질문에 나온 변화를 하나씩 대 봅시다.

꼬리말이 없는 카드 셋. 슬롯 방식은 아무것도 안 해도 됩니다. footer 를 안 넘기면 그 칸이 아예 안 그려져요. Step 3 에서 물음표 하나로 확인한 그 성질입니다. 직접 늘어놓는 방식도 물론 되지만, "꼬리말을 안 그린다" 는 판단이 세 파일에 각각 적히게 됩니다.

머리말이 두 줄인 카드 하나. 이것도 슬롯이 편합니다.

tsx
<Card
  header={
    <>
      <PostHeader username={username} profileImageUrl={profileImageUrl} />
      <p className="post-location">서울 한강공원</p>
    </>
  }
>
  <PostImage imageUrl={imageUrl} username={username} onLike={onLike} />
</Card>

Card 는 자기 머리말 칸에 무엇이 몇 줄로 들어왔는지 모릅니다. 알 필요도 없고요. 조각 하나가 왔든 프래그먼트로 묶인 둘이 왔든 그 자리에 놓기만 하면 됩니다. 껍데기가 아는 게 적을수록 견디는 변화가 늘어난다 는 이야기가 여기서 나옵니다.

그럼 슬롯이 항상 이기느냐 하면 아닙니다. 무너지는 경우가 둘 있어요.

하나는 칸의 순서가 종류마다 다를 때 입니다. 다섯 중 하나가 "본문 다음에 머리말" 순서라면 슬롯으로는 표현이 안 됩니다. Card 가 순서를 쥐고 있으니까요. 이때는 억지로 reverse 같은 props 를 만들지 말고 그 카드만 직접 늘어놓는 편이 낫습니다.

다른 하나는 슬롯이 계속 늘어날 때 입니다. 슬롯이 여섯 개쯤 되고 이름이 header·footer 가 아니라 saveButton·hashtagRow·locationLine 처럼 도메인 이름이 되기 시작하면, 그 껍데기는 이미 게시물 카드 전용입니다. 재사용하려고 만든 것이 한 화면에만 쓰이는 상태가 된 거예요. 그때는 이름을 PostCardLayout 으로 바꾸고 재사용을 포기하는 게 정직합니다.

그리고 오늘 우리가 한 것이 사실 둘 다 라는 점을 봐주세요. Card 슬롯을 쓰면서 그 슬롯에 PostHeader·CommentForm 이라는 별도 컴포넌트를 끼웠습니다. 둘은 대립하는 선택이 아니에요. 껍데기는 배치를 맡고 조각은 내용을 맡습니다.

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

"슬롯이냐 직접 늘어놓느냐는 배치 규칙을 어디에 둘 것인가 의 문제라고 봅니다. 슬롯을 뚫으면 순서와 간격이 껍데기 한 곳에 모여서, 카드 종류가 다섯으로 늘어도 배치는 한 번만 고치면 됩니다. 꼬리말 없는 카드는 안 넘기면 그 칸이 안 그려지고, 머리말이 두 줄인 카드는 프래그먼트로 묶어 넣으면 껍데기는 몰라도 되고요. 다만 무너지는 경우가 둘 있는데, 칸의 순서 자체가 종류마다 다르면 슬롯으로는 표현이 안 되고, 슬롯 이름이 header·footer 를 넘어 saveButton 같은 도메인 이름으로 늘어나기 시작하면 그건 이미 재사용 껍데기가 아니라 그 화면 전용 레이아웃입니다. 그리고 둘은 배타적이지 않습니다. 저희는 껍데기가 배치를 맡고 그 안에 끼울 조각은 따로 컴포넌트로 두는 조합을 씁니다."

🤔 [생각해볼 주제 3] 훅과 그냥 함수의 경계

문제 상황 요약

오늘 만든 훅 셋은 전부 안에서 useState 를 불렀습니다. 그런데 상태를 전혀 안 쓰고 계산만 하는 코드라면요. 캡션을 잘라주는 계산 같은 것이요.

useCaption 이라고 이름 붙여 훅으로 만드는 것과 formatCaption 이라는 평범한 함수로 두는 것 중 무엇이 나을까요.

튜터의 가이드 및 해설

기준은 하나입니다. 안에서 훅을 부르는가. 부르면 훅이고 안 부르면 함수예요. 이건 취향의 문제가 아니라 규칙입니다.

왜 그렇게까지 단호하냐면, use 로 시작하는 이름이 약속 이기 때문입니다. 오늘 Step 5 에서 봤죠. 린트는 함수 안을 들여다보지 않고 이름만 보고 판단합니다. makeToggle 이라고 쓰면 훅을 부르지 말라고 혼내고, useToggle 로 바꾸면 같은 코드가 통과했어요.

그러니까 useCaption 이라고 이름 붙이는 순간 두 가지 거짓말이 생깁니다. 린트는 "이 안에서 훅을 불러도 되겠구나" 하고 믿고, 그 함수를 쓰는 사람은 "조건문 안에서 부르면 안 되겠구나" 하고 믿습니다. 실제로는 그냥 문자열을 자르는 계산인데요. 사실이 아닌 제약을 스스로 걸어둔 셈입니다.

반대 방향으로 틀렸을 때가 훨씬 쌉니다. formatCaption 이라고 이름 붙였는데 나중에 안에서 useState 를 부르게 됐다고 해봅시다. 린트가 그 자리에서 잡아줘요.

텍스트
error  React Hook "useState" is called in function "formatCaption" that is neither
       a React function component nor a custom React Hook function. React component
       names must start with an uppercase letter. React Hook names must start with
       the word "use"

이름을 useCaption 으로 바꾸면 끝입니다. 30초짜리 일이고 도구가 먼저 알려줘요. 반대로 훅으로 만들어둔 것을 함수로 되돌리는 건 아무도 안 알려줍니다. 그냥 계속 훅인 척하고 살아요. 되돌리기 싼 쪽에서 시작한다 는 원칙이 여기서도 통합니다.

그리고 순수한 함수로 두면 얻는 게 하나 더 있습니다. React 없이 부를 수 있습니다.

훅은 컴포넌트 몸통 안에서만 부를 수 있어요. 그게 규칙이니까요. 그런데 캡션을 자르는 계산은 컴포넌트 밖에서도 쓸 일이 생깁니다. 목록을 정렬하기 전에 미리 자른 길이로 비교한다거나, 나중에 서버 쪽에서 같은 계산이 필요하다거나요. 훅으로 만들어두면 그때 못 씁니다. 아무 이유 없이 쓸 수 있는 범위를 좁혀둔 거예요.

우리 코드에 이미 그 예가 있습니다. lib/likes.tstoggleLike 요.

텍스트
 lib/likes.ts     toggleLike(posts, id)   배열을 받아 새 배열을 돌려준다   순수 함수
 hooks/           useLikeToggle(posts)    안에서 useState 를 부른다        훅

useLikeToggletoggleLike 를 부르는 구조입니다. 계산은 함수가 하고 상태는 훅이 쥡니다. 이렇게 나눠두면 계산 부분만 따로 확인하기도 쉽고, 나중에 그 계산을 다른 데서 쓰기도 편해요.

경계에 있는 경우도 하나 짚고 갑시다. "지금은 계산만 하는데 곧 상태가 붙을 게 확실하다" 면 어떻게 할까요. 그래도 함수로 시작합니다. 붙는 순간 개명하면 되고, 그때까지는 쓸 수 있는 범위를 넓게 두는 게 이득이거든요. 안 올지도 모르는 미래를 위해 지금 제약을 거는 건 오늘 Step 1 에서 CommentItem 을 미리 안 만든 것과 같은 판단입니다.

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

"훅이냐 함수냐의 기준은 안에서 훅을 부르는가 하나뿐이라고 봅니다. use 접두어는 스타일이 아니라 약속이라서, 린트도 그 함수를 쓰는 사람도 이름만 보고 판단하거든요. 상태를 안 쓰는 계산에 use 를 붙이면 사실이 아닌 제약을 스스로 거는 셈입니다. 그 함수를 컴포넌트 밖에서는 못 쓰게 되니까요. 반대로 함수로 뒀다가 나중에 상태가 필요해지면 린트가 '이 함수는 컴포넌트도 훅도 아니다' 라고 그 자리에서 잡아주고, 개명 한 번이면 끝납니다. 되돌리기 싼 쪽에서 시작하는 원칙이 여기서도 통해요. 그래서 저는 계산은 순수 함수로 빼두고 상태를 쥐는 부분만 훅으로 감쌉니다. 계산 쪽을 따로 확인하기 쉽고, 나중에 다른 화면에서 재사용할 여지도 남거든요."

전체 목록 리액트