문서 읽는 데 80분 · B2

B-2: 상태와 이벤트 — useState

목차 50
전체 59강 중 8강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

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

안녕하세요, 홍순구입니다. 지난 시간에 인스타 카드를 React 컴포넌트로 만들었습니다. 그런데 마지막에 찜찜한 걸 하나 남겨뒀습니다.

tsx
// 지난 시간에 만든 PostCard 에서 좋아요와 관련된 부분만 뽑아 보면
export function PostCard({ liked, likeCount }: PostCardProps) {
  return (
    <article className="post-card">
      <p className="post-likes">좋아요 {likeCount}개</p>
    </article>
  );
}

liked를 받아놓고 한 번도 안 썼는데, likeCount도 화면에 찍기만 했어요. 애초에 누를 버튼조차 없었으니, 좋아요를 눌러도 아무 일이 안 일어나는 인스타그램이었죠.

지난 과목에서 순수 JS로 좋아요를 만들 때는 이렇게 했습니다.

JavaScript
// 지난 과목에서 좋아요를 처리하던 방식
likeButton.addEventListener('click', () => {
  post.liked = !post.liked;
  post.likeCount += post.liked ? 1 : -1;

  likeCountEl.textContent = `좋아요 ${post.likeCount}개`;
  likeButton.classList.toggle('liked', post.liked);
});

두 가지 일을 하고 있는데, 데이터를 고치고 화면도 직접 고치니 항상 두 번 일하는 셈입니다. 화면 고치는 쪽을 하나라도 빠뜨리면 데이터와 화면이 어긋나기 시작합니다.

오늘 배울 방식은 값만 고칩니다. 화면은 React가 맞춰요.

텍스트
 오늘의 여정

 Step 1~2   버튼에 이벤트 붙이기 · useState 로 화면이 기억하기
    │
 Step 3~4   조건부 렌더링 · 배열을 화면으로 (map 과 key)
    │
 Step 5~6   상태 끌어올리기 · 바꾸지 말고 새로 만들기
    │
 Step 7     입력창의 값도 상태다 (onChange)
    
 좋아요가 눌리고 댓글이 달리는 인스타 피드로 마무리

💡 오늘 수업의 핵심 — "값만 고치면 화면은 React가 맞춘다"

지난 시간의 컴포넌트는 받은 값을 그리기만 했는데, 오늘은 그 값이 바뀌는 방법을 배웁니다. 값을 어디에 두고 어떻게 바꾸고 여러 컴포넌트가 같은 값을 봐야 할 때 어디까지 올릴지가 오늘의 전부예요.

🎯 학습 목표

  • useState로 화면이 기억해야 하는 값을 선언하고, 이벤트로 그 값을 바꿀 수 있다
  • 배열을 map으로 그리고 key가 왜 필요한지 설명할 수 있다
  • 상태를 어디에 둘지 판단하고, 상태를 바꿀 때 새 값을 만들어 넣을 수 있다

Step 1: "버튼은 달았는데 아무 일도 안 일어난다"

좋아요를 만들려면 일단 누를 버튼이 있어야죠. 그런데 버튼을 달기 전에 감을 잡고 갈 게 하나 있습니다. 연습용 컴포넌트를 하나 만들어 봅시다.

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

export function ClickCounter() {
  // 상태를 배우기 전 단계 — 일반 변수로 세어 본다
  let clickCount = 0;

  function handleClick() {
    clickCount += 1;
    console.log('지금 clickCount 는', clickCount);
  }

  return (
    <button className="like-button" onClick={handleClick}>
      눌린 횟수: {clickCount}
    </button>
  );
}

onClick이 오늘 처음 보는 것인데, 지난 과목에서는 이렇게 했죠.

JavaScript
const button = document.querySelector('.like-button');
button.addEventListener('click', handleClick);

요소를 찾아서 이벤트를 붙였는데, React에서는 찾지 않아요. JSX 안에 onClick 속성으로 함수를 그냥 넘기는데, 요소를 만드는 코드와 그 요소의 동작이 같은 데 있으니 왔다 갔다 할 일이 없어요.

이름이 카멜케이스인 것도 눈에 띌 텐데, HTML은 onclick인데 JSX는 onClick이에요. 지난 시간에 class 대신 className을 썼던 것과 같은 이유인데, JSX 속성은 HTML이 아니라 JavaScript 니까요.

함수를 넘기는 것과 함수를 부르는 것

여기서 입문자가 가장 많이 넘어집니다. 소괄호를 붙이느냐 마느냐예요.

tsx
onClick={handleClick}     // 함수 자체를 넘긴다
onClick={handleClick()}   // 함수를 지금 불러서 그 결과를 넘긴다

아래쪽으로 쓰면 클릭을 기다리는 게 아니라 화면을 그리는 순간 함수가 실행돼 버리는데, 다행히 TypeScript가 먼저 잡아줘요.

텍스트
error TS2322: Type 'void' is not assignable to type 'MouseEventHandler<HTMLButtonElement> | undefined'.

handleClick()은 아무것도 안 돌려주니 결과가 void입니다. React는 나중에 부를 함수를 원하는데 void를 받았다고 말하고 있어요.

눌러 보면

이제 브라우저에서 눌러 봅시다. 콘솔에는 1, 2, 3이 차례로 찍힙니다. 그런데 버튼 글씨는 계속 "눌린 횟수: 0"이에요.

값은 분명히 바뀌었는데 화면이 안 따라옵니다.

이유를 따라가 봅시다. 컴포넌트는 함수라고 했는데, ClickCounter()가 한 번 실행돼서 화면 한 조각을 돌려주고 끝났습니다. clickCount는 그 함수 안의 지역 변수라 이미 할 일을 마쳤으니, 나중에 그 값을 아무리 고쳐도 React는 컴포넌트를 다시 부를 이유를 모릅니다.

텍스트
 순수 JS                        React

 값을 바꾼다                     값을 바꾼다
    +                              
 화면도 직접 고친다               React 가 컴포넌트를 다시 부른다
                                   
                                새로 그려진 화면으로 바뀐다

 React 에게는 "값이 바뀌었다" 는 신호가 필요하다

일반 변수에는 그 신호가 없는데, 설령 다른 이유로 컴포넌트가 다시 불렸다 해도 let clickCount = 0이 다시 실행되니 0으로 돌아가요.

린터가 먼저 말해준다

사실 이 코드를 치는 순간 에디터에 빨간 줄이 그어집니다. 지난 시간에 켜 둔 린트가 잡아요.

텍스트
error  Cannot modify local variables after render completes

This argument is a function which may reassign or mutate `clickCount` after
render, which can cause inconsistent behavior on subsequent renders.
Consider using state instead.

마지막 문장을 보세요. 상태를 쓰는 걸 고려하라고 하는데, 다음 Step에서 할 일이 정확히 그거예요.

💡 한 줄 정리

JSX에 onClick으로 함수를 넘기면 이벤트가 붙습니다. 하지만 일반 변수를 바꾸는 것만으로는 화면이 다시 그려지지 않습니다.

🙋 학생 질문 — "튜터님, React 안에서도 addEventListener를 쓰면 안 되나요?"

쓸 수는 있는데, 버튼 클릭 같은 걸 그렇게 처리하면 손해가 큽니다.

세 가지를 직접 관리해야 하거든요. 첫째, 요소를 찾아야 하는데, React가 화면을 다시 그리면서 요소를 갈아끼우면 방금 찾아둔 요소가 이미 화면에 없는 요소일 수 있습니다. 둘째, 언제 붙이고 언제 뗄지를 정해야 하는데, 안 떼면 이벤트가 쌓이니까 한 번 클릭에 핸들러가 세 번 불리는 일이 생겨요. 셋째, 붙이는 코드와 그리는 코드가 떨어져 있어서 나중에 읽기 어렵습니다.

onClick으로 넘기면 React가 이 셋을 전부 대신하는데, 요소가 갈아끼워져도 핸들러가 따라가고 요소가 사라지면 알아서 정리돼요.

다만 화면 밖의 것을 들어야 할 때는 얘기가 다릅니다. 브라우저 창 크기 변화나 문서 전체의 스크롤 같은 건 JSX로 감쌀 요소가 없으니까 직접 붙여야 해요. 그건 B-4에서 다룹니다.


Step 2: "useState — 화면이 기억하는 값"

이제 진짜 좋아요 버튼을 만듭니다.

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

interface LikeButtonProps {
  initialLikeCount: number;
}

export function LikeButton({ initialLikeCount }: LikeButtonProps) {
  const [likeCount, setLikeCount] = useState(initialLikeCount);

  function handleClick() {
    setLikeCount(likeCount + 1);
  }

  return (
    <button className="like-button" onClick={handleClick}>
      좋아요 {likeCount}개
    </button>
  );
}

이번엔 눌러보면 숫자가 실제로 올라가는데, 한 줄씩 뜯어봅시다.

useState는 React가 주는 함수인데, 이름이 use로 시작하는 이런 함수를 훅(hook, 갈고리)이라고 불러요. 컴포넌트가 React의 기능을 끌어다 쓰는 통로라고 생각하시면 됩니다.

tsx
const [likeCount, setLikeCount] = useState(initialLikeCount);

오른쪽부터 봅시다. useState는 두 개짜리 배열을 돌려주는데, 왼쪽의 대괄호는 지난 과목에서 배운 배열 구조분해예요. 첫 번째로 나오는 게 지금 값이고, 두 번째로 나오는 게 그 값을 바꾸는 함수입니다.

이름은 마음대로 지어도 되지만 관례가 확고한데, 값이 likeCount면 바꾸는 함수는 setLikeCount로 씁니다.

useState에 넘긴 initialLikeCount는 처음 한 번만 쓰이는데, 두 번째로 그릴 때부터는 React가 기억하고 있는 값을 돌려줘요. props 이름에 initial을 붙인 게 그 뜻인데, 처음 값만 여기서 정한다는 표시죠.

값을 바꾸면 무슨 일이 일어나나

setLikeCount(likeCount + 1)이 하는 일은 두 가지입니다. React가 기억하는 값을 새 값으로 바꾸고, 이 컴포넌트를 다시 부릅니다.

텍스트
 클릭
   
 setLikeCount(1241)
   
 React 가 이 컴포넌트의 값을 1241 로 기억한다
   
 LikeButton() 을 다시 부른다
   
 이번엔 useState 가 1241 을 돌려준다
   
 "좋아요 1241개" 가 그려진다

Step 1에서 없던 신호가 바로 이것인데, setter를 부르는 것이 곧 "다시 그려라"라는 신호예요.

그래서 값을 직접 대입하면 안 됩니다. 애초에 const라 TypeScript가 막아줘요.

텍스트
error TS2588: Cannot assign to 'likeCount' because it is a constant.

바꾸는 길은 항상 setter 하나뿐입니다.

훅에는 규칙이 두 개 있다

useState를 아무 데서나 부를 수는 없습니다.

첫째, 컴포넌트 함수의 최상단에서만 부르는데, if 안에서도 반복문 안에서도 다른 함수 안에서도 안 됩니다. 둘째, 컴포넌트나 다른 훅 안에서만 부르는데, 평범한 함수 안에서는 못 써요.

어기면 린트가 바로 잡습니다.

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

error  React Hook "useState" is called in function "makeLikeState" 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"

첫 번째 메시지에 이유가 들어 있는데, React는 여러 개의 상태를 이름이 아니라 부른 순서로 구분합니다. 첫 번째로 부른 useState가 첫 번째 상태이고, 두 번째가 두 번째 상태이니까 매번 같은 순서로 불려야 해요. if 안에 넣으면 어떤 날은 부르고 어떤 날은 안 부르니까 순서가 어긋납니다.

두 번째 메시지도 재미있는데, 컴포넌트 이름은 대문자로 시작하고 훅 이름은 use로 시작해야 한다고 말하고 있어요. 지난 시간에 컴포넌트 이름을 대문자로 쓴 이유가 하나 더 늘었는데, 린트가 이름만 보고 훅을 부를 자격이 있는지 판단하거든요.

💡 한 줄 정리

useState는 값과 그 값을 바꾸는 함수를 돌려주는데, setter를 부르는 것이 "화면을 다시 그려라"라는 신호입니다.

🙋 학생 질문 — "튜터님, 상태를 여러 개로 나누는 것과 객체 하나에 담는 것 중 뭐가 나은가요?"

기준은 간단한데, 항상 같이 바뀌면 하나로 묶고 따로 바뀌면 따로 둡니다.

좋아요 예를 들면 likedlikeCount는 항상 같이 바뀌는데, 하트를 켜면 숫자가 오르고 끄면 내려가니까 객체 하나로 묶어도 자연스럽습니다.

반대로 댓글 입력창의 글자와 좋아요 상태는 아무 관계가 없어요. 이걸 한 객체에 담으면 댓글을 한 글자 칠 때마다 좋아요 값까지 통째로 새로 만들어야 하니까, 코드도 길어지고 실수하기도 쉬워요.

다음 Step에서는 likedlikeCount를 일부러 따로 두는데, 아직 상태를 다루는 게 익숙하지 않으니까 하나씩 눈으로 확인하는 게 나아서 그렇습니다. 나중에 둘을 묶는 방법도 자연스럽게 나옵니다.


Step 3: "조건부 렌더링 — 상태에 따라 다른 것을 그린다"

숫자만 오르는데 내가 눌렀는지 아닌지도 보여야죠. 하트가 채워지고, 버튼 색이 바뀌고요.

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

interface LikeButtonProps {
  initialLiked: boolean;
  initialLikeCount: number;
}

export function LikeButton({ initialLiked, initialLikeCount }: LikeButtonProps) {
  const [liked, setLiked] = useState(initialLiked);
  const [likeCount, setLikeCount] = useState(initialLikeCount);

  function handleClick() {
    setLiked(!liked);
    setLikeCount(liked ? likeCount - 1 : likeCount + 1);
  }

  return (
    <div className="like-area">
      <button
        className={liked ? 'like-button liked' : 'like-button'}
        onClick={handleClick}
      >
        {liked ? '♥ 좋아요 취소' : '♡ 좋아요'}
      </button>
      {likeCount > 0 && <p className="post-likes">좋아요 {likeCount}개</p>}
    </div>
  );
}

상태에 따라 화면이 달라지는 방법이 세 가지 들어 있습니다.

글자를 갈아끼우기

tsx
{liked ? '♥ 좋아요 취소' : '♡ 좋아요'}

JSX 중괄호 안에는 값이 되는 것이면 뭐든 넣을 수 있다고 했는데, 삼항 연산자도 값이 됩니다. liked가 참이면 왼쪽 문자열, 거짓이면 오른쪽 문자열이 그 자리에 들어가요.

스타일을 갈아끼우기

tsx
className={liked ? 'like-button liked' : 'like-button'}

className도 그냥 문자열이니까, 문자열을 조건에 따라 고르면 스타일이 바뀌어요. 순수 JS에서 classList.toggle('liked', post.liked)로 하던 걸 여기서는 "지금 상태면 클래스 이름이 이거다"라고 적기만 합니다.

아예 안 그리기

tsx
{likeCount > 0 && <p className="post-likes">좋아요 {likeCount}개</p>}

좋아요가 하나도 없으면 "좋아요 0개"라고 쓰는 대신 문구 자체를 안 보이게 했습니다.

&&는 왼쪽이 거짓이면 오른쪽을 아예 보지 않고 왼쪽 값을 돌려주는데, 여기서는 false가 돌아옵니다. React는 falsenull이나 undefined를 받으면 아무것도 안 그려요. 그래서 문단이 통째로 사라집니다.

⚠️ 여기 유명한 함정이 있습니다. 왼쪽에 숫자를 그냥 두면 안 돼요.

tsx
{likeCount && <p>좋아요 {likeCount}개</p>}

likeCount가 0이면 &&는 숫자 0을 돌려주는데, false가 아니라 0이에요. React는 숫자를 받으면 그 숫자를 화면에 찍으니까, 좋아요가 없는 게시물에 덩그러니 0 하나가 뜨는 겁니다. 그래서 likeCount > 0처럼 참거짓으로 먼저 만들어 넘겨야 합니다.

지금 값과 바뀔 값

handleClick을 다시 보세요.

tsx
function handleClick() {
  setLiked(!liked);
  setLikeCount(liked ? likeCount - 1 : likeCount + 1);
}

둘째 줄에서 liked를 읽는데, 바로 윗줄에서 setLiked를 불렀잖아요. 그럼 liked는 이미 바뀐 값일까요?

아닙니다. 이번에 그려진 화면에서의 liked는 끝까지 그대로인데, setLiked는 다음에 그릴 때 쓸 값을 예약할 뿐이니까 둘째 줄의 liked는 "누르기 직전에 눌려 있었나"를 뜻합니다. 눌려 있었으면 취소니까 하나 빼고, 아니면 하나 더하니까 의도한 대로 동작합니다.

카드에 붙이기

이제 카드에 넣습니다.

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

export function PostCard({
  username,
  profileImageUrl,
  imageUrl,
  content,
  liked,
  likeCount,
  commentCount,
}: PostCardProps) {
  return (
    <article className="post-card">
      <Avatar username={username} profileImageUrl={profileImageUrl} />
      <img className="post-image" src={imageUrl} alt={`${username} 의 게시물`} />
      <LikeButton initialLiked={liked} initialLikeCount={likeCount} />
      <p className="post-content">
        <strong>{username}</strong> {content}
      </p>
      <p className="post-comments">댓글 {commentCount}개 모두 보기</p>
    </article>
  );
}

구조분해에 liked를 더했고, 좋아요 숫자를 찍던 문단이 LikeButton으로 바뀌었습니다.

지난 시간에 받아만 두고 안 쓰던 liked를 드디어 쓰는데, 브라우저에서 카드의 좋아요를 눌러보면 하트가 채워지고 숫자가 올라가요.

💡 한 줄 정리

JSX 중괄호 안에서 삼항 연산자로 무엇을 그릴지 고르고, &&로 아예 안 그릴 수 있습니다. && 왼쪽에는 숫자가 아니라 참거짓을 두세요.

🙋 학생 질문 — "튜터님, JSX 안에서 if 문은 못 쓰나요?"

못 쓰는데, 정확히는 JSX 중괄호 안에 들어갈 수 있는 건 값이 되는 것뿐이에요. 1 + 1은 2라는 값이 되고 삼항 연산자도 값이 되지만, if는 값이 아니라 문장입니다.

조건이 세 갈래 이상으로 갈리면 삼항을 겹쳐 쓰게 되고 금방 읽기 어려워지는데, 그럴 때는 return 앞에서 미리 계산해 변수에 담으면 됩니다.

tsx
let label = '♡ 좋아요';
if (liked) {
  label = '♥ 좋아요 취소';
}

return <button onClick={handleClick}>{label}</button>;

여기서는 if를 마음껏 쓸 수 있는데, JSX 밖이니까요. 조건이 복잡해지면 이 방식이 훨씬 읽기 좋습니다.

컴포넌트 전체를 갈아끼워야 할 만큼 크게 갈리면 아예 return을 두 번 쓰는 방법도 있는데, 조건이 맞으면 이걸 돌려주고 아니면 저걸 돌려주는 거죠.


Step 4: "배열을 화면으로 — map과 key"

지난 시간 마지막에 이렇게 적었던 것 기억하시죠.

tsx
<PostCard {...firstPost} />
<PostCard {...secondPost} />

게시물이 백 개면 백 줄을 적어야 합니다. 그리고 게시물 개수는 서버가 정하는 거라 미리 알 수도 없어요. 해결책은 이미 알고 계시는데, 지난 과목에서 쓰던 map이에요.

tsx
// apps/web-spa/src/components/Feed.tsx
import { feedPosts } from '../data/feed';
import { PostCard } from './PostCard';

export function Feed() {
  return (
    <>
      {feedPosts.map((post) => (
        <PostCard key={post.id} {...post} />
      ))}
    </>
  );
}

feedPosts.map(...)은 JSX가 담긴 배열을 돌려주는데, 중괄호 안에 배열이 들어오면 React가 원소를 차례대로 그려요. 게시물이 몇 개든 이 코드는 그대로입니다.

key를 안 주면

key={post.id}를 빼면 화면은 일단 나옵니다. 대신 콘솔에 경고가 떠요.

텍스트
Each child in a list should have a unique "key" prop.
See https://react.dev/link/warning-keys for more information.

목록의 자식마다 겹치지 않는 key를 달라는 얘기입니다. 왜 필요할까요.

React는 화면을 다시 그릴 때 이전 결과와 새 결과를 비교해서 달라진 데만 고친다고 했는데, 목록에서는 그 비교가 까다롭습니다. 항목이 세 개에서 두 개로 줄었을 때, 어느 항목이 사라진 건지 알아야 나머지를 그대로 둘 수 있으니까요.

key는 그 판단을 위한 이름표이니까, 위치가 아니라 이름표로 항목을 짝짓습니다.

텍스트
 key 가 순서 번호일 때            key 가 게시물 id 일 때

 0: jaehoon                      1: jaehoon
 1: minji                        2: minji
      │ jaehoon 을 지우면              │ jaehoon 을 지우면
                                      
 0: minji                        2: minji

 "0번 항목의 내용이 바뀌었네"      "1번 항목이 사라졌네. 2번은 그대로네"
  0번 자리를 재활용한다           minji 를 건드리지 않는다

눈으로 확인하기

말로만 하면 안 와닿으니까 직접 만들어 봅시다. 줄마다 자기 좋아요 상태를 가진 목록을 두 벌 만들고 key만 다르게 줍니다.

tsx
// apps/web-spa/src/components/KeyDemo.tsx
import { useState } from 'react';
import { feedPosts } from '../data/feed';

// 줄마다 자기 좋아요 상태를 들고 있다
function DemoRow({ username }: { username: string }) {
  const [liked, setLiked] = useState(false);

  return (
    <li className="key-demo-row">
      <strong>{username}</strong>
      <button
        className={liked ? 'like-button liked' : 'like-button'}
        onClick={() => setLiked(!liked)}
      >
        {liked ? '♥' : '♡'}
      </button>
    </li>
  );
}

// key 를 순서 번호로 준 목록
export function IndexKeyList() {
  const [posts, setPosts] = useState(feedPosts);

  return (
    <section className="key-demo">
      <h3>key = 순서 번호</h3>
      <ul className="key-demo-list">
        {posts.map((post, index) => (
          <DemoRow key={index} username={post.username} />
        ))}
      </ul>
      <button className="hide-button" onClick={() => setPosts(posts.slice(1))}>
        맨 위 게시물 숨기기
      </button>
    </section>
  );
}

IdKeyList도 똑같이 만들되 딱 한 줄만 바꿉니다.

tsx
// key 를 게시물 id 로 준 목록 — 위와 딱 한 줄 다르다
{posts.map((post) => (
  <DemoRow key={post.id} username={post.username} />
))}

이제 두 목록에서 똑같이 해보세요. 맨 위 jaehoon의 하트를 눌러 채운 다음, "맨 위 게시물 숨기기"를 누릅니다.

순서 번호를 쓴 쪽에서는 남은 minji 줄의 하트가 채워진 채로 남는데, minji는 누른 적이 없어요. jaehoon이 사라지면서 minji가 0 번 자리로 올라왔고, React는 "0 번 항목은 그대로 있고 이름만 바뀌었네"라고 판단해 그 자리의 상태를 재활용한 겁니다.

게시물 id를 쓴 쪽에서는 minji의 하트가 비어 있는데, 1 번 이름표가 통째로 사라진 걸 알아채고 2 번은 건드리지 않았어요.

이게 index를 key로 쓰면 안 된다고 하는 이유인데, 화면에 이상한 값이 남는데 코드를 아무리 읽어도 원인이 안 보이거든요.

⚠️ index가 항상 금지는 아니지만, 목록의 순서가 바뀌지 않고 중간에 추가되거나 삭제되지 않고 항목이 자기 상태를 갖지 않는다면 index를 써도 아무 문제가 없어요. 세 조건이 전부 맞을 때뿐이니까, 하나라도 어긋날 여지가 있으면 id를 쓰세요.

💡 한 줄 정리

배열을 map으로 그리면 데이터 개수만큼 화면이 나오는데, key는 항목의 이름표라서 목록이 바뀔 때 어느 항목이 어느 항목인지 알려줍니다.

🙋 학생 질문 — "튜터님, 데이터에 id가 없으면 key를 뭘로 주나요?"

가장 좋은 건 서버가 준 고유 값이에요. 우리 게시물의 id처럼 백엔드가 데이터베이스에서 뽑아주니까 겹칠 일이 없습니다.

서버가 안 주면 데이터를 만드는 시점에 붙이는데, 이게 다음 선택입니다. 목록에 항목을 추가할 때 그 자리에서 고유한 값을 하나 만들어 함께 넣어두는 거죠.

내용 문자열을 key로 쓰는 건 위험합니다. 댓글 목록에서 두 사람이 똑같이 "좋네요"를 달면 key가 겹치는데, 그러면 React가 둘을 같은 항목으로 착각합니다.

그릴 때마다 새 값을 만들어 넣는 것은 하면 안 되는데, 매번 이름표가 달라지면 React가 모든 항목을 새 항목으로 보고 통째로 다시 만들어요. key를 안 준 것보다 나쁩니다.


Step 5: "상태 끌어올리기 — 자식은 알리기만 한다"

새 요구가 들어왔는데, 피드 맨 위에 "좋아요 누른 게시물 N개"를 띄워달라는 겁니다.

간단해 보이는데 지금 구조로는 못 합니다. liked가 각 LikeButton 안에 들어 있으니까, App은 그 값을 볼 방법이 없습니다.

텍스트
 지금

 App          아무것도 모른다
   │
 Feed
   │
 PostCard
   │
 LikeButton   liked 를 혼자 들고 있다    값이 여기 갇혀 있다

지난 시간 마지막 질문에서 답을 미리 봤는데, 자식이 부모에게 알려야 하면 부모가 함수를 props로 넘겨주면 된다고 했죠. 그 방법을 지금 씁니다.

두 곳 이상이 같은 값을 알아야 하면 그 값을 공통 부모로 옮기는데, 이걸 상태 끌어올리기(lifting state up)라고 불러요.

세 파일이 바뀝니다.

LikeButton은 기억을 내려놓는다

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

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>
  );
}

useState가 통째로 사라졌는데, 이제 이 컴포넌트는 아무것도 기억하지 않아요. 받은 대로 그리고, 눌리면 받은 함수를 부르기만 합니다.

이렇게 값을 밖에서 받아 그리기만 하는 컴포넌트를 제어 컴포넌트(controlled component)라고 부르는데, 반대로 Step 3처럼 자기 안에 상태를 두는 쪽은 비제어 컴포넌트고요.

PostCard는 자기 id를 채워서 내려준다

tsx
// apps/web-spa/src/components/PostCard.tsx
import type { PostCardProps } from '../types/derived';

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

export function PostCard({
  id,
  username,
  profileImageUrl,
  imageUrl,
  content,
  liked,
  likeCount,
  commentCount,
  onToggleLike,
}: PostCardViewProps) {
  return (
    <article className="post-card">
      <Avatar username={username} profileImageUrl={profileImageUrl} />
      <img className="post-image" src={imageUrl} alt={`${username} 의 게시물`} />
      <LikeButton
        liked={liked}
        likeCount={likeCount}
        onToggle={() => onToggleLike(id)}
      />
      <p className="post-content">
        <strong>{username}</strong> {content}
      </p>
      <p className="post-comments">댓글 {commentCount}개 모두 보기</p>
    </article>
  );
}

구조분해에 idonToggleLike가 늘었는데, 게시물 번호를 알아야 어느 게시물이 눌렸는지 알릴 수 있으니까요.

A-3에서 만든 PostCardProps에 함수 하나를 더한 타입을 만들었는데, 처음부터 다시 적지 않고 extends로 확장했어요.

onToggle={() => onToggleLike(id)}를 보세요. LikeButton은 자기가 어느 게시물인지 모르는데, 알 필요도 없고요. 그냥 "눌렸다"만 알리는데, 어느 게시물인지 채워 넣는 건 그걸 아는 PostCard의 일이에요.

Feed도 데이터를 밖에서 받는다

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

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

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

아까는 feedPosts를 직접 가져다 썼는데 이제 props로 받습니다. 데이터가 바뀔 수 있게 됐으니 그 데이터를 가진 쪽에서 받아야 하니까요.

App이 값을 쥔다

tsx
// apps/web-spa/src/App.tsx
import { useState } from 'react';
import { Feed } from './components/Feed';
import { feedPosts } from './data/feed';
import { toggleLike } from './lib/likes';

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>
      <Feed posts={posts} onToggleLike={handleToggleLike} />
    </main>
  );
}

toggleLike는 다음 Step에서 만드는 함수니까, 지금은 "그 id의 게시물만 좋아요를 뒤집은 새 배열을 돌려준다" 정도로 알고 넘어가세요. 왜 굳이 새 배열이어야 하는지가 다음 Step의 주제입니다.

likedCount를 보세요. 이건 상태가 아니라 계산인데, posts만 있으면 언제든 셀 수 있으니까요.

여기 중요한 원칙이 하나 있습니다. 상태에서 계산할 수 있는 값은 상태로 두지 않습니다. 좋아요 개수를 따로 useState로 두면 게시물을 토글할 때마다 두 곳을 같이 고쳐야 하는데, 한 번이라도 빠뜨리면 화면 안에서 숫자가 서로 안 맞습니다. 오프닝에서 순수 JS가 겪던 문제와 똑같아요.

값과 알림이 도는 방향

텍스트
 App        posts 상태를 가진다 · handleToggleLike 를 만든다
   │  posts 와 onToggleLike 를 내려보낸다
   
 Feed       받은 배열을 map 으로 그린다
   │  각 카드에 그 게시물과 onToggleLike 를 내려보낸다
   
 PostCard   내 id 를 채운 onToggle 을 만들어 버튼에 준다
   │
   
 LikeButton 눌리면 받은 함수를 부른다

 값은 위에서 아래로 · 알림은 함수를 타고 위로

지난 시간에 배운 단방향 데이터 흐름이 깨지지 않았는데, 값은 여전히 아래로만 흐르고 자식은 "이런 일이 있었어요"라고 알릴 뿐이에요. 값을 바꿀 권한은 그 값을 가진 App에 그대로 있습니다.

⚠️ 덤으로 Step 3의 문제도 사라졌는데, initialLiked 방식은 처음 값만 받아 쓰고 나중에 props가 바뀌어도 안 따라옵니다. 서버에서 새 데이터를 받아와도 하트가 옛날 상태로 남는데, 지금처럼 위에서 관리하면 그럴 일이 없습니다.

💡 한 줄 정리

두 곳 이상이 같은 값을 알아야 하면 그 값을 공통 부모로 올리고, 자식에게는 값과 함께 알릴 함수를 내려줍니다.

🙋 학생 질문 — "튜터님, 그럼 상태는 무조건 위로 올리는 게 안전한가요?"

아닙니다. 올리는 데도 비용이 있어요.

첫째, 그 값이 바뀌면 부모부터 다시 그려지는데, 부모 아래 전부가 다시 그려지는 셈이라 필요 없는 컴포넌트까지 계산이 돌아요.

둘째, 코드가 멀어집니다. 버튼을 고치려는데 상태는 세 단계 위에 있고 함수도 거기 있고, props로 세 번 갈아타서 내려오니까 값 하나를 따라가려고 파일 네 개를 열어야 해요.

셋째, 부모가 뚱뚱해지는데, 다 올리다 보면 App 하나가 화면의 모든 걸 아는 컴포넌트가 됩니다.

기준은 "필요한 만큼만"이니까, 그 값을 필요로 하는 컴포넌트들의 가장 가까운 공통 부모까지만 올립니다. 좋아요는 헤더가 알아야 해서 App까지 올라간 건데, Step 7에서는 반대로 안 올리는 예를 봅니다.


Step 6: "바꾸지 말고 새로 만들어라 — 상태 불변성"

앞 Step에서 toggleLike를 설명 없이 썼는데, 이제 그 안을 봅시다. 안 되는 방식부터 보는 게 빠릅니다.

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

// 화면이 안 바뀌는 버전 — 원래 있던 객체를 그 자리에서 고친다
export function toggleLikeInPlace(posts: Post[], id: number): Post[] {
  const target = posts.find((post) => post.id === id);

  if (target) {
    target.liked = !target.liked;
    target.likeCount = target.liked ? target.likeCount + 1 : target.likeCount - 1;
  }

  return posts;
}

읽어보면 멀쩡하지만, 해당 게시물을 찾아서 좋아요를 뒤집고 숫자를 조정해요. 값도 정확하게 바뀝니다.

그런데 App에서 이걸 쓰면 좋아요를 눌러도 화면이 꿈쩍도 안 합니다. 헤더 숫자도 그대로, 카드 숫자도 그대로, 하트도 안 채워져요. Step 1의 그 답답한 상황이 돌아온 겁니다.

React는 어떻게 바뀐 걸 알아채나

setter를 부르면 React는 새 값과 이전 값을 비교하는데, 이때 쓰는 게 Object.is인데 지난 과목에서 쓰던 ===와 거의 같아요.

여기가 핵심입니다. 객체와 배열은 내용이 아니라 참조로 비교합니다.

JavaScript
const a = { likeCount: 1240 };
const b = a;

b.likeCount = 1241;

a === b;  // true — 내용이 바뀌어도 같은 객체를 가리키니까

toggleLikeInPlace는 원래 배열의 내용을 고친 뒤 그 배열을 그대로 돌려주는데, React 입장에서는 이전 값과 새 값이 같은 배열이니까 안 바뀐 걸로 판단하고 다시 그리지 않습니다.

텍스트
 그 자리에서 고치기

   이전 값 ───── [ 게시물 배열 ]        내용은 바뀌었지만
                                        React 가 받은 건 같은 배열
   새 값 ───────────────┘                 다시 안 그린다


 새로 만들기

   이전 값 ───── [ 예전 배열 ]
   새 값 ─────── [ 새 배열 ]            서로 다른 배열
                                          다시 그린다

새로 만드는 버전

TypeScript
// 제대로 동작하는 버전 — 바뀐 게시물만 새로 만들고 나머지는 그대로 쓴다
export function toggleLike(posts: Post[], id: number): Post[] {
  return posts.map((post) =>
    post.id === id
      ? {
          ...post,
          liked: !post.liked,
          likeCount: post.liked ? post.likeCount - 1 : post.likeCount + 1,
        }
      : post,
  );
}

map은 언제나 새 배열을 돌려주는데, 그래서 배열 참조가 달라지고 React가 알아채요.

안쪽도 봅시다. 바꿔야 하는 게시물은 { ...post }로 복사한 뒤 두 필드를 덮어쓰는데, 나머지 게시물은 손대지 않고 원래 객체를 그대로 넣어요.

전부 복사하지 않는 게 중요하니까, 안 바뀐 게시물이 같은 객체로 남아 있으면 React는 그 카드를 다시 그릴 필요가 없다는 걸 알 수 있습니다. 이렇게 바뀐 데까지만 새로 만드는 방식을 얕은 복사(shallow copy)라고 불러요.

⚠️ 스프레드는 한 겹만 복사하는데, { ...post }를 해도 안쪽의 hashtagNames 배열은 원본과 같은 배열이에요. 그 배열을 고쳐야 한다면 그것도 새로 만들어야 합니다.

배열을 다룰 때 쓸 것과 피할 것

하고 싶은 일 쓸 것 피할 것
뒤에 추가 [...list, item] list.push(item)
앞에 추가 [item, ...list] list.unshift(item)
하나 빼기 list.filter(...) list.splice(...)
하나 바꾸기 list.map(...) list[i] = ...
정렬 [...list].sort(...) list.sort(...)

오른쪽은 지난 과목에서 자주 쓰던 메서드들인데, 전부 원본을 고치니까 상태에 대고 쓰면 화면이 안 바뀝니다.

Step 4의 posts.slice(1)이 왼쪽에 속하는데, slice는 원본을 두고 새 배열을 잘라서 돌려주니까 그때 화면이 제대로 바뀐 겁니다.

💡 한 줄 정리

React는 참조가 달라졌는지로 판단하니까, 상태를 바꿀 때는 원본을 고치지 말고 새 배열과 새 객체를 만들어 넣으세요.

🙋 학생 질문 — "튜터님, 어차피 다시 그리기만 하면 되는 거면 원본을 고치고 새 배열로 감싸서 넣어도 되지 않나요?"

setPosts([...posts])처럼요? 화면은 실제로 다시 그려지는데, 배열 참조가 달라졌으니까요.

두 가지를 잃습니다.

첫째, 이전 값이 사라지는데, 안쪽 객체는 그대로라 이미 고쳐졌거든요. 되돌리기 기능을 만들거나, 바뀌기 전후를 비교하거나, 서버 저장에 실패했을 때 원래대로 돌려놓는 일이 전부 이전 값에 기댑니다.

둘째, 안 바뀐 항목까지 전부 다시 그리는데, 배열은 새것이어도 안쪽 객체가 전부 그대로라 React는 어느 게 바뀐 건지 구별할 근거가 없어요. 게시물이 백 개면 백 장을 다 다시 그립니다.

여기에 하나 더 있는데, 지난 시간에 켠 React Compiler도 값이 안 바뀌었는지를 보고 계산을 건너뜁니다. 몰래 고친 값은 컴파일러도 속이는데, 화면이 실제 데이터와 어긋나는데 원인을 찾기가 아주 어려워져요.

그래서 "새로 만든다"는 번거로운 관례가 아니라 React가 동작하는 방식 그 자체입니다.


Step 7: "입력창의 값도 상태다 — onChange"

마지막으로 댓글을 답시다. 지금까지는 클릭만 다뤘는데 이번엔 글자를 칩니다.

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() === '';

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

value와 onChange는 한 짝

value={content}는 "이 입력창에 보일 글자는 상태의 값"이라는 뜻입니다. onChange는 "글자가 바뀌면 상태를 갱신하라"는 뜻이고요.

둘이 한 바퀴를 이룹니다.

텍스트
 사용자가 글자를 친다
   
 onChange 가 불린다 (event.target.value 에 새 글자가 들어 있다)
   
 setContent(새 글자)
   
 컴포넌트가 다시 그려진다 (value 가 새 글자)
   
 화면에 그 글자가 보인다

한쪽만 있으면 안 되는데, onChange를 빼면 키를 눌러도 글자가 안 들어가요. 보일 글자를 상태가 정하는데 그 상태를 바꿀 길이 없으니까요. 콘솔이 친절하게 알려줍니다.

텍스트
You provided a `value` prop to a form field without an `onChange` handler.
This will render a read-only field. If the field should be mutable use
`defaultValue`. Otherwise, set either `onChange` or `readOnly`.

값을 React 상태가 쥐고 있는 이런 입력창도 제어 컴포넌트이니까, Step 5의 LikeButton과 같은 이름으로 부르는 이유가 여기 있어요. 화면에 보이는 것을 자기가 정하지 않고 밖에서 받은 값이 정하니까요.

event.target.value는 지난 과목에서 쓰던 DOM 이벤트 그대로입니다. React가 새로 만든 게 아니에요.

⚠️ 여기서 event를 통째로 넘기는 실수가 잦습니다.

텍스트
error TS2345: Argument of type 'ChangeEvent<HTMLInputElement, HTMLInputElement>'
is not assignable to parameter of type 'SetStateAction<string>'.

상태는 문자열인데 이벤트 객체를 받았다는 얘기예요. 우리가 원하는 건 그 안의 value입니다.

상태로 화면을 잠그기

tsx
<button className="comment-submit" type="submit" disabled={isEmpty}>

Step 3에서는 상태로 무엇을 그릴지 골랐는데, 여기서는 요소의 속성을 상태가 정해요. 입력창이 비어 있으면 게시 버튼이 안 눌립니다. trim()을 거치니까 공백만 쳐서도 안 됩니다.

event.preventDefault()는 폼의 기본 동작을 막는데, 안 막으면 브라우저가 페이지를 통째로 새로고침해요. 이것도 지난 과목에서 쓰던 그대로입니다.

댓글을 목록에 쌓기

이제 카드가 이 폼을 품습니다. 오늘 여러 번 고쳐 온 파일이니 완성된 모습을 통째로 봅시다.

tsx
// apps/web-spa/src/components/PostCard.tsx
import { useState } from 'react';
import type { PostCardProps } from '../types/derived';
import { Avatar } from './Avatar';
import { LikeButton } from './LikeButton';
import { CommentForm } from './CommentForm';

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

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

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">
      <Avatar username={username} profileImageUrl={profileImageUrl} />
      <img className="post-image" src={imageUrl} alt={`${username} 의 게시물`} />
      <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>
  );
}

[...comments, 새 댓글]이 보이시는데, Step 6에서 배운 그 방식입니다. 원본에 밀어 넣지 않고 새 배열을 만드는데, 목록을 map으로 그리면서 key를 답니다. Step 4와 Step 6이 여기서 함께 쓰입니다.

useState<DraftComment[]>([])의 꺾쇠가 눈에 띄는데, 빈 배열로 시작하면 TypeScript가 무슨 배열인지 알 방법이 없어서 직접 알려준 거예요. 안 알려주고 값을 넣으려 하면 이런 에러가 납니다.

텍스트
error TS2322: Type 'string' is not assignable to type 'never'.

빈 배열의 추론이 왜 이렇게 되는지는 A-5에서 훅에 타입을 붙이는 방법과 함께 제대로 짚습니다.

좋아요는 올렸는데 댓글은 안 올린 이유

여기서 한 번 멈춰봅시다. 좋아요는 App까지 올렸는데 댓글은 카드 안에 뒀어요. 왜 다르게 했을까요.

좋아요는 헤더가 알아야 하는데, 카드 밖의 누군가가 그 값을 보죠. 댓글은 그 카드 말고 아무도 안 봅니다.

기준이 여기 있습니다. 그 값을 필요로 하는 곳이 여럿이면 공통 부모로, 하나뿐이면 그 안에 둡니다. 나중에 필요할 것 같다고 미리 올려두면 App이 화면의 모든 걸 아는 컴포넌트가 되는데, 실제로 필요해졌을 때 올려도 늦지 않아요.

지금 이 폼에는 빈 값만 막는 검증밖에 없는데, 글자 수 제한, 금지어 확인, 서버가 돌려준 에러 표시까지 붙이면 이 컴포넌트가 금방 복잡해져요. 그 문제는 B-5에서 폼 전용 도구로 해결합니다.

💡 한 줄 정리

입력창의 값도 상태이니까, value로 보여줄 값을 정하고 onChange로 그 값을 갱신하는 한 바퀴가 돌아야 글자가 쳐집니다.

🙋 학생 질문 — "튜터님, 글자 하나 칠 때마다 컴포넌트를 다시 그리면 느리지 않나요?"

Step 1에서 나왔던 걱정이 다시 나오네요. 좋은 감각입니다.

실제로는 대부분 안 느립니다. React가 다시 그린다는 건 컴포넌트 함수를 다시 부른다는 뜻이지 화면을 통째로 새로 만든다는 뜻이 아니에요. 비교해서 실제로 달라진 DOM만 건드리는데, 입력창 하나의 글자가 바뀌면 그 글자만 바뀌어요.

문제가 되는 경우는 있는데, 폼 하나에 필드가 수십 개고 글자 하나 칠 때마다 그 수십 개가 전부 다시 계산되는 상황이요. 그때는 상태를 폼 전체가 아니라 필드마다 두거나, 아예 값을 React 상태에 안 담는 방법을 씁니다. B-5에서 그 도구를 다뤄요.

성능은 느껴질 때 측정하고 고치니까, 미리 걱정해서 코드를 비틀면 읽기만 어려워지고 실제로 빨라지지도 않는 경우가 대부분이에요. 측정하는 방법은 C-8에서 배웁니다.


마무리

오늘 카드가 살아났는데, 좋아요가 눌리고 숫자가 오르내리고 하트가 채워지고 댓글이 달려요. 지난 시간까지는 예쁜 그림이었는데 이제 만질 수 있는 화면이 됐습니다.

오프닝에서 봤던 순수 JS 코드와 비교해 보면, 오늘 우리는 화면을 고치는 코드를 한 줄도 안 썼습니다. textContentclassList도 안 나왔는데, 값만 바꿨을 뿐인데 화면이 따라왔습니다.

오늘 배운 핵심 세 가지

💡 하나 — 화면이 기억해야 하는 값은 useState에 둡니다. 일반 변수는 컴포넌트가 다시 불리면 초기화되고, 무엇보다 "다시 그려라"라는 신호를 못 보내는데, setter를 부르는 것이 그 신호입니다.

💡 둘 — 상태를 바꿀 때는 새로 만들어 넣습니다. React는 참조가 달라졌는지로 판단하기 때문에, 원본을 그 자리에서 고치면 값이 바뀌어도 화면이 안 따라옵니다. 목록에 key를 다는 것도 같은 뿌리인데, 무엇이 그대로고 무엇이 달라졌는지 React가 알아야 합니다.

💡 셋 — 상태는 필요한 만큼만 위로 올립니다. 두 곳 이상이 봐야 하면 가장 가까운 공통 부모까지, 한 곳만 보면 그 안에 두는데, 올리면 값은 아래로 흐르고 알림은 함수를 타고 위로 올라옵니다.

다음 시간 예고

오늘 이벤트 객체의 타입을 한 번도 안 적었는데, (event) => setContent(event.target.value)에서 event가 무슨 타입인지 TypeScript가 알아서 채워줬거든요. JSX 안에 바로 적었기 때문에 가능했던 겁니다.

그런데 핸들러를 밖으로 빼서 이름 붙인 함수로 만들면 그 타입을 직접 적어야 해요. 다음 시간에는 React 컴포넌트에 타입을 제대로 붙이는 법을 배웁니다. props 타이핑, children, 이벤트 타입, 그리고 입력창을 직접 만져야 할 때 쓰는 ref까지 다룹니다.

오늘 LikeButtonCommentForm에서 똑같은 모습이 두 번 나왔는데, useState로 값을 하나 두고 그 값을 바꾸는 함수를 만들고 그 둘을 화면에 연결하는 흐름이요. 이 반복을 통째로 뽑아 이름 붙이는 방법이 B-3에 있습니다.


과제

[구현] 해시태그로 피드 걸러내기

피드 위에 해시태그 버튼 줄을 만들어, 누르면 그 태그가 달린 게시물만 보이게 해주세요.

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

  • apps/web-spa/src/components/HashtagFilter.tsxHashtagFilter 컴포넌트를 만들어 주세요. 태그 목록과 지금 선택된 태그, 그리고 태그를 고르면 부를 함수를 props로 받습니다.
  • 태그 목록은 feedPostshashtagNames를 모아 중복 없이 만들어 주세요. 손으로 적지 마시고 데이터에서 뽑아야 합니다.
  • 전체를 다시 보는 버튼도 함께 두세요.
  • 지금 선택된 태그의 버튼은 다른 태그와 구별되게 보여 주세요.
  • 선택된 태그 상태는 App에 둡니다. Feed에는 걸러낸 배열을 넘기세요.
  • 걸러낸 결과가 0 개면 "이 태그의 게시물이 없어요" 같은 안내를 그려 주세요.
  • 태그를 골라도 좋아요는 그대로 유지돼야 합니다. 걸러내기가 원본 posts 상태를 건드리면 안 됩니다.

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

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

[탐구] 일부러 어겨보기

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

  • onClick={handleToggleLike}onClick={handleToggleLike(1)}로 바꿔보기
  • 상태 변수에 직접 대입해 보기 (liked = true)
  • useStateif 블록 안에서 불러보기
  • Feedkey={post.id}를 지워보기
  • {likeCount > 0 && ...}{likeCount && ...}로 바꾸고 좋아요를 0으로 만들어보기
  • CommentFormonChange를 지우고 글자를 쳐보기

특히 세 번째는 에러 메시지에 "in the exact same order"라는 말이 나옵니다. 무슨 순서를 말하는 건지, 왜 순서가 중요한지 생각해 보세요.


생각해볼 주제

1. 상태를 어디에 둘 것인가

오늘 좋아요는 App까지 올렸고 댓글은 카드 안에 뒀습니다. 기준으로 "그 값을 필요로 하는 곳이 여럿인가"를 제시했죠.

그런데 실무에서는 이 판단이 계속 흔들립니다. 지금은 한 곳만 쓰지만 다음 스프린트에 다른 화면에서도 쓸 게 뻔한 값이라면요? 미리 올려두는 것과 실제로 필요해졌을 때 올리는 것 중 어느 쪽이 나을까요. 그 판단에 영향을 주는 요소는 무엇일까요.

2. 불변성을 관례로 지킬 것인가 도구로 강제할 것인가

오늘 배열을 다룰 때 쓸 것과 피할 것을 표로 봤는데, pushsplice를 안 쓰기로 한 건 결국 팀원 모두가 기억해야 하는 약속이에요.

이걸 강제하는 방법도 있는데, 린트 규칙으로 막을 수도 있고 타입을 읽기 전용으로 선언해 컴파일 단계에서 막을 수도 있고 아예 그런 실수가 불가능한 라이브러리를 쓸 수도 있어요. 각각 무엇을 얻고 무엇을 잃을까요. 여러분이 팀 리드라면 어디까지 강제하시겠습니까.

3. 규칙을 외우는 것과 이유를 아는 것

"index를 key로 쓰지 마라"는 규칙으로 자주 전해집니다. 그런데 오늘 확인했듯 조건이 맞으면 index를 써도 아무 문제가 없어요.

이런 규칙을 이유 없이 외우고 있으면 어떤 일이 생길까요. 반대로 매번 이유를 따져가며 판단하는 것이 항상 나은 걸까요. 팀에 갓 들어온 신입에게는 어느 쪽으로 알려주시겠습니까.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 해시태그로 피드 걸러내기

채점 포인트

항목 확인 내용 배점
태그 목록 생성 hashtagNames를 데이터에서 모았는가 (손으로 적지 않았는가) 15
중복 제거 같은 태그가 여러 게시물에 있어도 버튼이 하나만 나오는가 10
상태 위치 선택된 태그를 App에 뒀는가 20
걸러내기 원본 posts 상태를 건드리지 않고 걸러낸 배열만 넘겼는가 20
선택 표시 지금 고른 태그 버튼이 구별되게 보이는가 10
빈 결과 안내 결과가 0 개일 때 안내를 그렸는가 10
검사 통과 typechecklint가 모두 통과하는가 15

걸러낸 뒤에도 좋아요가 그대로 남아 있다면 이 과제의 핵심을 잡으신 겁니다.

풀이 예시

먼저 태그를 모으고 걸러내는 함수부터 만드는데, 화면과 상관없는 순수한 계산이라 컴포넌트 밖에 두는 게 좋아요.

TypeScript
// apps/web-spa/src/lib/hashtags.ts
import type { Post } from '../types/instagram';

export function collectHashtags(posts: Post[]): string[] {
  return [...new Set(posts.flatMap((post) => post.hashtagNames))];
}

export function visiblePostsOf(posts: Post[], tag: string | null): Post[] {
  return tag === null
    ? posts
    : posts.filter((post) => post.hashtagNames.includes(tag));
}

flatMap으로 게시물마다 흩어져 있는 태그 배열을 하나로 펴고, Set에 넣었다가 다시 배열로 꺼내 중복을 없앴는데, 지난 과목에서 배운 그대로예요.

visiblePostsOf에서는 filter를 쓰는데, 이게 중요합니다. filter는 원본을 두고 새 배열을 돌려주는데, 원본 posts는 손대지 않았으니까 태그를 이리저리 골라도 좋아요 상태가 사라지지 않습니다.

다음은 버튼 줄입니다.

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

interface HashtagFilterProps {
  tags: string[];
  selectedTag: string | null;
  onSelect: (tag: string | null) => void;
}

export function HashtagFilter({
  tags,
  selectedTag,
  onSelect,
}: HashtagFilterProps) {
  return (
    <nav className="hashtag-filter">
      <button
        className={selectedTag === null ? 'tag-button selected' : 'tag-button'}
        onClick={() => onSelect(null)}
      >
        전체
      </button>
      {tags.map((tag) => (
        <button
          key={tag}
          className={selectedTag === tag ? 'tag-button selected' : 'tag-button'}
          onClick={() => onSelect(tag)}
        >
          #{tag}
        </button>
      ))}
    </nav>
  );
}

LikeButton과 똑같이 생겼는데, 자기 상태를 갖지 않고 받은 값만 그리며 눌리면 받은 함수를 부릅니다. "지금 뭐가 선택됐나"를 아는 건 App 이지 이 버튼 줄이 아니에요.

key={tag}를 태그 이름으로 준 것도 봐주세요. 태그는 중복을 없앤 뒤라 이 목록 안에서 겹치지 않으니까, 순서 번호를 쓸 이유가 없어요.

마지막으로 App입니다.

tsx
// apps/web-spa/src/App.tsx
import { useState } from 'react';
import { Feed } from './components/Feed';
import { HashtagFilter } from './components/HashtagFilter';
import { feedPosts } from './data/feed';
import { toggleLike } from './lib/likes';
import { collectHashtags, visiblePostsOf } from './lib/hashtags';

export function App() {
  const [posts, setPosts] = useState(feedPosts);
  const [selectedTag, setSelectedTag] = useState<string | null>(null);

  // 셋 다 상태가 아니라 계산이다
  const tags = collectHashtags(posts);
  const visiblePosts = visiblePostsOf(posts, selectedTag);
  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>
      <HashtagFilter
        tags={tags}
        selectedTag={selectedTag}
        onSelect={setSelectedTag}
      />
      {visiblePosts.length === 0 ? (
        <p className="empty-feed">이 태그의 게시물이 없어요</p>
      ) : (
        <Feed posts={visiblePosts} onToggleLike={handleToggleLike} />
      )}
    </main>
  );
}

상태가 둘로 늘었는데, 게시물 목록과 선택된 태그이고 이 둘은 따로 바뀌니까 따로 두는 게 맞습니다.

그 아래 세 줄이 이 답안에서 가장 중요한 이유가 있는데, tags, visiblePosts, likedCount는 전부 상태가 아니라 계산이기 때문입니다. 태그 목록을 useState로 따로 들고 있으면 게시물이 바뀔 때마다 태그 목록도 같이 갱신해야 하는데, 한 번 빠뜨리면 없는 태그의 버튼이 화면에 남습니다.

onSelect={setSelectedTag}는 setter를 그대로 넘기는데, 중간에 할 일이 없으면 굳이 감싸는 함수를 만들 필요가 없어요.

스타일은 이 정도면 충분합니다.

CSS
/* apps/web-spa/src/styles/globals.css */

.hashtag-filter {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  margin-bottom: 16px;
}

.tag-button {
  border: 1px solid #dbdbdb;
  background: #ffffff;
  border-radius: 999px;
  padding: 4px 12px;
  font-size: 13px;
  cursor: pointer;
}

.tag-button.selected {
  background: #262626;
  border-color: #262626;
  color: #ffffff;
}

.empty-feed {
  text-align: center;
  color: #8e8e8e;
  padding: 32px 0;
}

자주 나오는 실수

가장 흔한 것은 걸러낸 결과를 상태에 다시 담는 겁니다.

tsx
// 이렇게 하면 안 된다
const [visiblePosts, setVisiblePosts] = useState(feedPosts);

function handleSelectTag(tag: string | null) {
  setSelectedTag(tag);
  setVisiblePosts(visiblePostsOf(posts, tag));
}

동작은 하는데, 이제 같은 정보를 두 곳에 들고 있는 셈이에요. 좋아요를 누르면 posts는 바뀌는데 visiblePosts는 안 바뀝니다. 하트를 눌러도 화면이 그대로인 버그가 생기는데, Step 5에서 짚은 "상태에서 계산할 수 있는 값은 상태로 두지 않는다"가 바로 이 얘기입니다.

두 번째는 원본을 걸러버리는 겁니다.

tsx
// 이렇게 하면 안 된다
function handleSelectTag(tag: string) {
  setPosts(posts.filter((post) => post.hashtagNames.includes(tag)));
}

filter 자체는 새 배열을 돌려주니까 화면은 잘 바뀝니다. 걸러낸 결과를 원본 자리에 덮어썼다는 게 문제이니까, 한 번 걸러내면 나머지 게시물이 영영 사라집니다. 전체 보기를 눌러도 돌아올 데이터가 없어요.

세 번째는 태그 목록을 손으로 적는 겁니다. 지금은 게시물이 두 개뿐이라 태그를 눈으로 세어 적을 수 있는데, 게시물이 서버에서 오기 시작하면 그 목록은 곧바로 틀린 값이 됩니다.

💡 튜터의 한마디 — 이 과제의 진짜 주제는 걸러내기가 아니라 "무엇을 상태로 둘 것인가"입니다. 화면에 보이는 것 중 상당수는 상태가 아니라 계산에서 나오는데, 상태를 하나 늘리기 전에 "이거 지금 있는 값으로 계산할 수 있나"를 먼저 물어보세요. 계산할 수 있으면 계산하는 쪽이 거의 항상 맞습니다.

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

채점 포인트

항목 확인 내용 배점
여섯 가지 시도 여섯 개를 다 해보고 결과를 적었는가 30
메시지 인용 실제로 뜬 메시지를 옮겨 적었는가 (짐작으로 쓰지 않았는가) 20
원인 설명 왜 그런 메시지가 뜨는지 자기 말로 적었는가 30
잡히는 곳 구분 타입 검사·린트·콘솔 중 어디서 잡혔는지 구분했는가 20

풀이 예시

같은 실수라도 잡히는 데가 다르니까, 그 구분이 이 과제의 핵심이에요.

1. onClick={handleToggleLike(1)}로 바꿔보기 — 타입 검사에서 잡힘

텍스트
error TS2322: Type 'void' is not assignable to type
'MouseEventHandler<HTMLButtonElement> | undefined'.

handleToggleLike(1)은 함수를 지금 부른 결과인데, 그 함수는 아무것도 안 돌려주니까 void예요. React는 나중에 부를 함수를 원하는데 값을 받은 겁니다. 만약 타입이 없었다면 화면을 그리는 순간 좋아요가 눌리고, 그게 다시 그리기를 부르고, 또 눌리는 무한 반복에 빠졌을 겁니다.

2. 상태 변수에 직접 대입해 보기 — 타입 검사에서 잡힘

텍스트
error TS2588: Cannot assign to 'liked' because it is a constant.

const [liked, setLiked] = useState(...) 니까 liked는 상수입니다. 여기서 배울 건 "const라서 못 바꾼다"가 아니라 "바꾸면 안 되니까 const로 받는다"예요. 대입이 통했다 해도 React는 그 사실을 모르니까 화면이 안 바뀝니다.

3. useStateif 블록 안에서 불러보기 — 린트에서 잡힘

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

"in the exact same order"가 이 과제에서 생각해 보라고 한 부분이죠. React는 한 컴포넌트의 여러 상태를 이름으로 구분하지 않습니다. 부른 순서로 구분해요. 첫 번째로 불린 useState가 첫 번째 상태입니다.

조건 안에 넣으면 어떤 렌더에서는 부르고 어떤 렌더에서는 안 부르는데, 그러면 두 번째 useState가 어떤 때는 두 번째, 어떤 때는 첫 번째가 돼요. 좋아요 상태 자리에 댓글 글자가 들어가는 일이 생깁니다.

타입 검사로는 이걸 못 잡는데, 문법과 타입은 완벽히 정상이니까요. 그래서 린트가 필요합니다.

4. key={post.id}를 지워보기 — 브라우저 콘솔에서 잡힘

텍스트
Each child in a list should have a unique "key" prop.
See https://react.dev/link/warning-keys for more information.

화면은 멀쩡히 나오는데, 타입 검사도 린트도 통과해요. 실제로 브라우저에서 돌려봐야만 보이는데, 이건 에러가 아니라 경고라서 무시하고 넘어가도 당장은 아무 일이 없습니다. 목록의 순서가 바뀌거나 항목이 삭제되기 시작할 때 이상한 값이 남는 것으로 나타나요.

5. {likeCount && ...}로 바꾸고 좋아요를 0으로 만들어보기 — 아무 데서도 안 잡힘

메시지가 하나도 안 뜹니다. 대신 화면에 0이 덩그러니 찍혀요.

&&는 왼쪽이 거짓이면 왼쪽 값을 그대로 돌려주는데, 0은 거짓 같은 값이지만 false는 아니에요. 숫자 0이 JSX 자리에 들어가고 React는 숫자를 받으면 화면에 쓰는데, falsenull이었다면 아무것도 안 그렸을 텐데요.

여섯 개 중 이것만 어디서도 안 잡힙니다. 그래서 가장 위험해요.

6. onChange를 지우고 글자를 쳐보기 — 브라우저 콘솔에서 잡힘

텍스트
You provided a `value` prop to a form field without an `onChange` handler.
This will render a read-only field. If the field should be mutable use
`defaultValue`. Otherwise, set either `onChange` or `readOnly`.

키를 눌러도 글자가 안 들어갑니다. 입력창에 보일 글자는 value가 정하는데, value는 상태에서 오고, 그 상태를 바꿀 길이 없으니 영원히 빈 문자열이에요.

메시지가 해결책까지 알려주는데, 값을 React가 쥐고 있을 거면 onChange를 달고 그럴 게 아니면 defaultValue를 쓰라고요.

자주 나오는 실수

메시지를 짐작으로 적으면 안 되는데, 특히 4 번과 6 번은 브라우저를 열고 개발자 도구의 콘솔 탭을 봐야만 보여요. 에디터에 빨간 줄이 없다고 해서 문제가 없는 게 아닙니다.

그리고 5 번을 "에러가 안 났으니 괜찮은 코드"로 적는 경우가 있습니다. 반대예요. 아무도 안 잡아주는 실수가 가장 오래 살아남습니다.

💡 튜터의 한마디 — 오늘 여섯 가지가 세 군데로 갈렸는데, 타입 검사가 둘, 린트가 하나, 콘솔이 둘, 아무도 안 잡는 게 하나예요. 이게 프론트엔드 도구들이 어떻게 층을 이루는지 보여줍니다. 타입은 값의 모양을, 린트는 프레임워크의 규칙을, 런타임 경고는 실제로 돌려봐야 아는 것을 잡아요. 셋 다 켜두고 셋 다 읽는 습관이 실력 차이를 만듭니다.

🤔 [생각해볼 주제 1] 상태를 어디에 둘 것인가

문제 상황 요약

오늘 좋아요는 App까지 올렸고 댓글은 카드 안에 뒀는데, 기준은 "그 값을 필요로 하는 곳이 여럿인가"였어요.

그런데 실무에서는 이 판단이 계속 흔들립니다. 지금은 한 곳만 쓰지만 곧 다른 화면에서도 쓸 게 뻔한 값이라면 미리 올려둬야 할까요.

튜터의 가이드 및 해설

먼저 "미리 올려두면 안전하다"는 직관부터 깨야 하는데, 올리는 건 공짜가 아니에요.

값이 위로 갈수록 그 값이 바뀔 때 다시 그려지는 범위가 넓어지는데, 댓글 글자 하나를 App에 올려두면 글자를 칠 때마다 피드 전체가 다시 계산돼요. 값 하나를 따라가려고 파일 네 개를 열게 되는데, 버튼에서 시작해 카드로, 피드로, App까지 거슬러 올라가야 어디서 정해지는지 알 수 있어요.

반대로 "필요해질 때 올리면 된다"도 그냥 미루기는 아니지만, 이게 성립하는 전제가 있어요. 끌어올리기는 되돌리기 쉬운 결정이라는 것. 상태를 위로 옮기는 작업은 기계적이니까, useState를 위로 옮기고 props를 하나 늘리고 콜백을 내려주면 끝이에요. 30 분이면 되는데, 데이터베이스 스키마를 바꾸는 것과는 무게가 다릅니다.

그래서 판단 기준을 이렇게 잡는 게 좋습니다.

되돌리기 쉬운 결정은 나중에, 되돌리기 어려운 결정은 미리. 상태 위치는 전자이니까, 실제로 두 번째 사용처가 생겼을 때 옮기세요.

예외를 알아두면 좋은데, 값이 서버와 오가는 것이라면 얘기가 달라져요. 좋아요를 눌렀을 때 서버에 저장하고, 실패하면 되돌리고, 다른 화면에서도 같은 값을 봐야 한다면 그건 화면 상태가 아니라 서버 상태이니까, 그런 값은 컴포넌트 트리를 오르내리는 대신 전용 도구가 관리하는 게 맞아요. C-6에서 다룰 주제입니다.

실무에서 자주 쓰는 신호가 있는데, props를 세 단계 넘게 그냥 통과시키고만 있다면 위치가 잘못됐다는 뜻이에요. 중간의 컴포넌트들은 그 값에 관심도 없는데 배달만 하고 있는 거니까요. 그때가 다른 방법을 찾을 때입니다.

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

"상태 위치는 되돌리기 쉬운 결정이라 미리 정하지 않습니다. 두 번째 사용처가 생겼을 때 가장 가까운 공통 부모까지만 올려요. 다만 중간 컴포넌트가 관심도 없는 props를 세 단계 넘게 배달만 하고 있다면, 그건 위치 문제가 아니라 전달 방식을 바꿔야 한다는 신호로 봅니다."

🤔 [생각해볼 주제 2] 불변성을 관례로 지킬 것인가 도구로 강제할 것인가

문제 상황 요약

오늘 pushsplice를 안 쓰기로 했습니다. 그런데 이건 결국 팀원 모두가 기억해야 하는 약속이에요. 신입이 들어오거나, 급한 날이거나, 리뷰가 느슨해지면 언제든 새어 들어옵니다.

강제하는 방법은 여러 층이 있는데, 린트 규칙으로 막기, 타입을 읽기 전용으로 선언하기, 그런 실수가 불가능한 라이브러리 쓰기예요.

튜터의 가이드 및 해설

층마다 잡는 시점과 비용이 다르니까, 이걸 나눠 보는 게 먼저예요.

린트로 막기. 특정 메서드 호출을 금지하는 규칙을 두는 방식인데, 설정이 가장 싸고 즉시 효과가 나요. 대신 상태가 아닌 평범한 배열에까지 걸리는데, 함수 안에서 지역 배열을 만들어 push로 채우는 건 아무 문제가 없는데도 막히죠. 예외를 계속 열어주다 보면 규칙이 유명무실해집니다.

타입으로 막기. 상태 타입을 읽기 전용으로 선언하면 컴파일 단계에서 잡히는데, 정확도가 가장 높아요. 상태로 쓰이는 타입에만 걸리니까 지역 배열은 자유롭습니다. 대신 읽기 전용 타입이 전염되는데, 그 타입을 받는 함수들의 시그니처를 줄줄이 고쳐야 하고 외부 라이브러리가 그냥 배열을 요구하면 거기서 막혀요.

라이브러리로 막기. 원본을 고치는 것처럼 쓰면 알아서 새 값을 만들어주는 도구들이 있는데, 코드가 짧아지고 중첩이 깊을 때 특히 편해요. 대신 의존성이 하나 늘고, 팀원이 그 도구의 규칙을 새로 배워야 하고, 무엇보다 왜 이렇게 쓰는지를 안 배운 채 쓰게 됩니다.

여기서 제가 강조하고 싶은 게 있는데, 도구는 이해를 대체하는 게 아니라 이해 위에 얹는 겁니다. 참조 비교 때문에 새로 만들어야 한다는 걸 아는 사람이 도구를 쓰면 생산성이 오르지만, 모르는 사람이 쓰면 도구가 실패했을 때 아무것도 못 합니다. 그래서 이 과목에서는 스프레드를 손으로 쓰는 것부터 배웠어요.

실무 결론은 이러니까, 팀 규모가 작고 코드가 단순하면 관례와 리뷰로 충분합니다. 사람이 늘고 중첩이 깊어지기 시작하면 타입으로 막는 층을 먼저 넣으세요. 정확도가 높고 의존성이 없습니다. 그래도 코드가 계속 길어진다면 그때 라이브러리를 검토하면 됩니다.

한 가지 더. 오늘 배운 방식은 React 밖에서도 값을 하는데, 서버에서도 다른 언어에서도 값을 고치지 않고 새로 만드는 방식은 동시성 문제와 추적 가능성에서 같은 이득을 줍니다. React의 규칙이라서가 아니라 좋은 방식이라서 React가 채택한 쪽에 가까워요.

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

"불변성은 규칙이 아니라 React가 변경을 감지하는 방식 자체라고 봅니다. 그래서 팀에는 먼저 참조 비교를 이해시키고, 그다음 층을 얹어요. 순서는 관례와 리뷰, 읽기 전용 타입, 마지막이 라이브러리입니다. 도구를 먼저 주면 실패했을 때 손을 못 씁니다."

🤔 [생각해볼 주제 3] 규칙을 외우는 것과 이유를 아는 것

문제 상황 요약

"index를 key로 쓰지 마라"는 규칙으로 자주 전해집니다. 그런데 오늘 확인했듯 순서가 안 바뀌고 추가·삭제가 없고 항목이 상태를 안 가지면 index를 써도 아무 문제가 없어요.

규칙만 외우고 있으면 어떤 일이 생길까요. 반대로 매번 이유를 따지는 게 항상 나을까요.

튜터의 가이드 및 해설

규칙을 외우는 데는 분명한 값이 있는데, 빠르고 틀릴 확률이 낮고 팀 전체가 같은 코드를 씁니다. 신입이 첫날부터 지킬 수 있는데, "잘 모르겠으면 id를 써라"는 조언은 실제로 대부분의 경우에 맞습니다.

문제는 규칙이 적용되지 않는 상황을 만났을 때 생기는데, 두 방향으로 갈려요.

한쪽은 규칙을 엉뚱한 데까지 적용하는데, 고정된 요일 목록처럼 절대 안 바뀌는 배열에까지 억지로 id를 만들어 붙입니다. 데이터에 의미 없는 필드가 늘고 그걸 유지하는 코드가 느는데, 규칙은 지켰는데 코드는 나빠집니다.

다른 한쪽이 더 위험하니까, 규칙이 안 적힌 상황에서 아무 판단도 못 하는 거예요. 오늘 본 좋아요가 옮겨가는 버그를 만나면, 이유를 아는 사람은 30 초면 key를 의심합니다. 규칙만 외운 사람은 "key는 넣었는데요" 하고 다른 데를 몇 시간 뒤집어요.

저는 이렇게 보는데, 규칙은 기본값이고 이유는 디버깅 도구입니다. 평소에는 규칙대로 쓰는데, 매번 원리를 따져가며 코딩하면 속도가 안 나고 사실 대부분의 코드는 그럴 가치가 없어요. 이유가 필요해지는 건 두 순간인데, 규칙대로 했는데 문제가 생겼을 때, 그리고 규칙을 어기는 게 명백히 나아 보일 때입니다.

신입에게 알려주는 순서라면 저는 규칙을 먼저 주는데, 반드시 두 가지를 붙여요. 첫째, 이 규칙이 막으려는 게 무엇인지 한 문장으로. 둘째, 그걸 직접 눈으로 보는 경험. 오늘 목록 두 개를 만들어 좋아요가 옮겨가는 걸 본 게 그 경험입니다.

한 문장 설명과 한 번의 목격이 있으면 규칙이 원리로 바뀌는데, 그게 없으면 규칙은 미신이 되고 미신은 상황이 바뀌면 아무 도움이 안 됩니다.

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

"규칙은 기본값으로 쓰고 이유는 디버깅할 때 꺼내 씁니다. 평소에 매번 원리를 따지면 속도가 안 나지만, 규칙만 아는 사람은 규칙이 안 통하는 순간에 멈춰요. 그래서 팀에 규칙을 전할 때는 '무엇을 막는 규칙인지' 한 문장과 그걸 직접 재현해 보는 경험을 꼭 같이 줍니다."

전체 목록 리액트