B-2: 상태와 이벤트 — useState
목차 50
안녕하세요, 홍순구입니다. 지난 시간에 인스타 카드를 React 컴포넌트로 만들었죠. 그런데 마지막에 찜찜한 걸 하나 남겨뒀습니다.
// 지난 시간에 만든 PostCard 에서 좋아요와 관련된 부분만 뽑아 보면
export function PostCard({ liked, likeCount }: PostCardProps) {
return (
<article className="post-card">
<p className="post-likes">좋아요 {likeCount}개</p>
</article>
);
}
liked 를 받아놓고 한 번도 안 썼어요. likeCount 는 화면에 찍기만 했고요. 애초에 누를 버튼조차 없었습니다. 좋아요를 눌러도 아무 일이 안 일어나는 인스타그램이었죠.
지난 과목에서 순수 JS 로 좋아요를 만들 때는 이렇게 했습니다.
// 지난 과목에서 좋아요를 처리하던 방식
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: "버튼은 달았는데 아무 일도 안 일어난다"
좋아요를 만들려면 일단 누를 버튼이 있어야죠. 그런데 버튼을 달기 전에 감을 잡고 갈 게 하나 있습니다. 연습용 컴포넌트를 하나 만들어 봅시다.
// 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 이 오늘 처음 보는 것입니다. 지난 과목에서는 이렇게 했죠.
const button = document.querySelector('.like-button');
button.addEventListener('click', handleClick);
요소를 찾아서 이벤트를 붙였습니다. React 에서는 찾지 않아요. JSX 안에 onClick 속성으로 함수를 그냥 넘깁니다. 요소를 만드는 코드와 그 요소의 동작이 같은 데 있으니 왔다 갔다 할 일이 없어요.
이름이 카멜케이스인 것도 눈에 띌 겁니다. HTML 은 onclick 인데 JSX 는 onClick 이에요. 지난 시간에 class 대신 className 을 썼던 것과 같은 이유입니다. JSX 속성은 HTML 이 아니라 JavaScript 니까요.
함수를 넘기는 것과 함수를 부르는 것
여기서 입문자가 가장 많이 넘어집니다. 소괄호를 붙이느냐 마느냐예요.
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 — 화면이 기억하는 값"
이제 진짜 좋아요 버튼을 만듭니다.
// 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 의 기능을 끌어다 쓰는 통로라고 생각하시면 됩니다.
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 를 부르는 것이 "화면을 다시 그려라" 라는 신호입니다.
🙋 학생 질문 — "튜터님, 상태를 여러 개로 나누는 것과 객체 하나에 담는 것 중 뭐가 나은가요?"
기준은 간단합니다. 항상 같이 바뀌면 하나로 묶고, 따로 바뀌면 따로 둡니다.
좋아요 예를 들면 liked 와 likeCount 는 항상 같이 바뀝니다. 하트를 켜면 숫자가 오르고, 끄면 내려가죠. 그래서 객체 하나로 묶어도 자연스럽습니다.
반대로 댓글 입력창의 글자와 좋아요 상태는 아무 관계가 없어요. 이걸 한 객체에 담으면 댓글을 한 글자 칠 때마다 좋아요 값까지 통째로 새로 만들어야 합니다. 코드도 길어지고 실수하기도 쉬워요.
다음 Step 에서는 liked 와 likeCount 를 일부러 따로 둡니다. 아직 상태를 다루는 게 익숙하지 않으니 하나씩 눈으로 확인하는 게 나아서예요. 나중에 둘을 묶는 방법도 자연스럽게 나옵니다.
Step 3: "조건부 렌더링 — 상태에 따라 다른 것을 그린다"
숫자만 오르면 좋아요가 아니죠. 내가 눌렀는지 아닌지가 보여야 합니다. 하트가 채워지고, 버튼 색이 바뀌고요.
// 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>
);
}
상태에 따라 화면이 달라지는 방법이 세 가지 들어 있습니다.
글자를 갈아끼우기
{liked ? '♥ 좋아요 취소' : '♡ 좋아요'}
JSX 중괄호 안에는 값이 되는 것이면 뭐든 넣을 수 있다고 했죠. 삼항 연산자도 값이 됩니다. liked 가 참이면 왼쪽 문자열, 거짓이면 오른쪽 문자열이 그 자리에 들어가요.
스타일을 갈아끼우기
className={liked ? 'like-button liked' : 'like-button'}
className 도 그냥 문자열입니다. 문자열을 조건에 따라 고르면 스타일이 바뀌어요. 순수 JS 에서 classList.toggle('liked', post.liked) 로 하던 걸 여기서는 "지금 상태면 클래스 이름이 이거다" 라고 적기만 합니다.
아예 안 그리기
{likeCount > 0 && <p className="post-likes">좋아요 {likeCount}개</p>}
좋아요가 하나도 없으면 "좋아요 0개" 라고 쓰는 대신 문구 자체를 안 보이게 했습니다.
&& 는 왼쪽이 거짓이면 오른쪽을 아예 보지 않고 왼쪽 값을 돌려주죠. 여기서는 false 가 돌아옵니다. React 는 false 나 null 이나 undefined 를 받으면 아무것도 안 그려요. 그래서 문단이 통째로 사라집니다.
⚠️ 여기 유명한 함정이 있습니다. 왼쪽에 숫자를 그냥 두면 안 돼요.
{likeCount && <p>좋아요 {likeCount}개</p>}
likeCount 가 0 이면 && 는 숫자 0 을 돌려줍니다. false 가 아니라 0 이에요. 그리고 React 는 숫자를 받으면 그 숫자를 화면에 찍습니다. 좋아요가 없는 게시물에 덩그러니 0 하나가 뜹니다. 그래서 likeCount > 0 처럼 참거짓으로 먼저 만들어 넘겨야 합니다.
지금 값과 바뀔 값
handleClick 을 다시 보세요.
function handleClick() {
setLiked(!liked);
setLikeCount(liked ? likeCount - 1 : likeCount + 1);
}
둘째 줄에서 liked 를 읽습니다. 그런데 바로 윗줄에서 setLiked 를 불렀잖아요. 그럼 liked 는 이미 바뀐 값일까요?
아닙니다. 이번에 그려진 화면에서의 liked 는 끝까지 그대로예요. setLiked 는 다음에 그릴 때 쓸 값을 예약할 뿐입니다. 그래서 둘째 줄의 liked 는 "누르기 직전에 눌려 있었나" 를 뜻해요. 눌려 있었으면 취소니까 하나 빼고, 아니면 하나 더합니다. 의도한 대로 동작합니다.
카드에 붙이기
이제 카드에 넣습니다.
// 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 앞에서 미리 계산해 변수에 담으면 됩니다.
let label = '♡ 좋아요';
if (liked) {
label = '♥ 좋아요 취소';
}
return <button onClick={handleClick}>{label}</button>;
여기서는 if 를 마음껏 쓸 수 있어요. JSX 밖이니까요. 조건이 복잡해지면 이 방식이 훨씬 읽기 좋습니다.
컴포넌트 전체를 갈아끼워야 할 만큼 크게 갈리면 아예 return 을 두 번 쓰는 방법도 있습니다. 조건이 맞으면 이걸 돌려주고, 아니면 저걸 돌려주는 거죠.
Step 4: "배열을 화면으로 — map 과 key"
지난 시간 마지막에 이렇게 적었던 것 기억하시죠.
<PostCard {...firstPost} />
<PostCard {...secondPost} />
게시물이 백 개면 백 줄을 적어야 합니다. 그리고 게시물 개수는 서버가 정하는 거라 미리 알 수도 없어요. 해결책은 이미 알고 계십니다. 지난 과목에서 쓰던 map 이에요.
// 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 만 다르게 줍니다.
// 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 도 똑같이 만들되 딱 한 줄만 바꿉니다.
// 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 은 기억을 내려놓는다
// 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 를 채워서 내려준다
// 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>
);
}
구조분해에 id 와 onToggleLike 가 늘었습니다. 게시물 번호를 알아야 어느 게시물이 눌렸는지 알릴 수 있으니까요.
A-3 에서 만든 PostCardProps 에 함수 하나를 더한 타입을 만들었습니다. 처음부터 다시 적지 않고 extends 로 확장했어요.
onToggle={() => onToggleLike(id)} 를 보세요. LikeButton 은 자기가 어느 게시물인지 모릅니다. 알 필요도 없고요. 그냥 "눌렸다" 만 알립니다. 어느 게시물인지 채워 넣는 건 그걸 아는 PostCard 의 일이에요.
Feed 도 데이터를 밖에서 받는다
// 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 이 값을 쥔다
// 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 를 설명 없이 썼죠. 이제 그 안을 봅시다. 안 되는 방식부터 보는 게 빠릅니다.
// 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 인데 지난 과목에서 쓰던 === 와 거의 같아요.
여기가 핵심입니다. 객체와 배열은 내용이 아니라 참조로 비교합니다.
const a = { likeCount: 1240 };
const b = a;
b.likeCount = 1241;
a === b; // true — 내용이 바뀌어도 같은 객체를 가리키니까
toggleLikeInPlace 는 원래 배열의 내용을 고친 뒤 그 배열을 그대로 돌려줍니다. React 입장에서는 이전 값과 새 값이 같은 배열이에요. 안 바뀐 걸로 판단하고 다시 그리지 않습니다.
그 자리에서 고치기
이전 값 ─────▶ [ 게시물 배열 ] 내용은 바뀌었지만
▲ React 가 받은 건 같은 배열
새 값 ───────────────┘ → 다시 안 그린다
새로 만들기
이전 값 ─────▶ [ 예전 배열 ]
새 값 ───────▶ [ 새 배열 ] 서로 다른 배열
→ 다시 그린다
새로 만드는 버전
// 제대로 동작하는 버전 — 바뀐 게시물만 새로 만들고 나머지는 그대로 쓴다
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"
마지막으로 댓글을 답시다. 지금까지는 클릭만 다뤘는데 이번엔 글자를 칩니다.
// 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 입니다.
상태로 화면을 잠그기
<button className="comment-submit" type="submit" disabled={isEmpty}>
Step 3 에서는 상태로 무엇을 그릴지 골랐습니다. 여기서는 요소의 속성을 상태가 정해요. 입력창이 비어 있으면 게시 버튼이 안 눌립니다. trim() 을 거치니 공백만 쳐서도 안 됩니다.
event.preventDefault() 는 폼의 기본 동작을 막는 겁니다. 안 막으면 브라우저가 페이지를 통째로 새로고침해요. 이것도 지난 과목에서 쓰던 그대로입니다.
댓글을 목록에 쌓기
이제 카드가 이 폼을 품습니다. 오늘 여러 번 고쳐 온 파일이니 완성된 모습을 통째로 봅시다.
// 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-4 에서 배웁니다.
마무리
오늘 카드가 살아났습니다. 좋아요가 눌리고, 숫자가 오르내리고, 하트가 채워지고, 댓글이 달려요. 지난 시간까지는 예쁜 그림이었는데 이제 만질 수 있는 화면이 됐습니다.
그리고 오프닝에서 봤던 순수 JS 코드와 비교해 보세요. 오늘 우리는 화면을 고치는 코드를 한 줄도 안 썼습니다. textContent 도 classList 도 안 나왔어요. 값만 바꿨을 뿐인데 화면이 따라왔습니다.
오늘 배운 핵심 세 가지
💡 하나 — 화면이 기억해야 하는 값은 useState 에 둡니다. 일반 변수는 컴포넌트가 다시 불리면 초기화되고, 무엇보다 "다시 그려라" 라는 신호를 못 보냅니다. setter 를 부르는 것이 그 신호입니다.
💡 둘 — 상태를 바꿀 때는 새로 만들어 넣습니다. React 는 참조가 달라졌는지로 판단하기 때문에, 원본을 그 자리에서 고치면 값이 바뀌어도 화면이 안 따라옵니다. 목록에 key 를 다는 것도 같은 뿌리예요. 무엇이 그대로고 무엇이 달라졌는지 React 가 알아야 합니다.
💡 셋 — 상태는 필요한 만큼만 위로 올립니다. 두 곳 이상이 봐야 하면 가장 가까운 공통 부모까지, 한 곳만 보면 그 안에 둡니다. 올리면 값은 아래로 흐르고 알림은 함수를 타고 위로 올라옵니다.
다음 시간 예고
오늘 이벤트 객체의 타입을 한 번도 안 적었습니다. (event) => setContent(event.target.value) 에서 event 가 무슨 타입인지 TypeScript 가 알아서 채워줬거든요. JSX 안에 바로 적었기 때문에 가능했던 겁니다.
그런데 핸들러를 밖으로 빼서 이름 붙인 함수로 만들면 그 타입을 직접 적어야 해요. 다음 시간에는 React 컴포넌트에 타입을 제대로 붙이는 법을 배웁니다. props 타이핑, children, 이벤트 타입, 그리고 입력창을 직접 만져야 할 때 쓰는 ref 까지 다룹니다.
그리고 오늘 LikeButton 과 CommentForm 에서 똑같은 모습이 두 번 나왔습니다. useState 로 값을 하나 두고, 그 값을 바꾸는 함수를 만들고, 그 둘을 화면에 연결하는 흐름이요. 이 반복을 통째로 뽑아 이름 붙이는 방법이 B-3 에 있습니다.
과제
[구현] 해시태그로 피드 걸러내기
피드 위에 해시태그 버튼 줄을 만들어, 누르면 그 태그가 달린 게시물만 보이게 해주세요.
요구사항은 다음과 같습니다.
apps/web-spa/src/components/HashtagFilter.tsx에HashtagFilter컴포넌트를 만들어 주세요. 태그 목록과 지금 선택된 태그, 그리고 태그를 고르면 부를 함수를 props 로 받습니다.- 태그 목록은
feedPosts의hashtagNames를 모아 중복 없이 만들어 주세요. 손으로 적지 마시고 데이터에서 뽑아야 합니다. - 전체를 다시 보는 버튼도 함께 두세요.
- 지금 선택된 태그의 버튼은 다른 태그와 구별되게 보여 주세요.
- 선택된 태그 상태는
App에 둡니다.Feed에는 걸러낸 배열을 넘기세요. - 걸러낸 결과가 0 개면 "이 태그의 게시물이 없어요" 같은 안내를 그려 주세요.
- 태그를 골라도 좋아요는 그대로 유지돼야 합니다. 걸러내기가 원본
posts상태를 건드리면 안 됩니다.
다 만들고 나면 npm run typecheck -w web-spa 와 npm run lint -w web-spa 를 돌려 두 개 다 통과하는지 확인해 주세요.
useEffect 는 쓰지 마세요. 아직 안 배웠고, 오늘 배운 것만으로 충분히 됩니다.
[탐구] 일부러 어겨보기
아래 여섯 가지를 하나씩 시도해 보고, 화면이나 콘솔이나 에디터가 뭐라고 하는지와 그게 무슨 뜻인지 한 줄씩 적어 주세요.
onClick={handleToggleLike}를onClick={handleToggleLike(1)}로 바꿔보기- 상태 변수에 직접 대입해 보기 (
liked = true) useState를if블록 안에서 불러보기Feed의key={post.id}를 지워보기{likeCount > 0 && ...}를{likeCount && ...}로 바꾸고 좋아요를 0 으로 만들어보기CommentForm의onChange를 지우고 글자를 쳐보기
특히 세 번째는 에러 메시지에 "in the exact same order" 라는 말이 나옵니다. 무슨 순서를 말하는 건지, 왜 순서가 중요한지 생각해 보세요.
생각해볼 주제
1. 상태를 어디에 둘 것인가
오늘 좋아요는 App 까지 올렸고 댓글은 카드 안에 뒀습니다. 기준으로 "그 값을 필요로 하는 곳이 여럿인가" 를 제시했죠.
그런데 실무에서는 이 판단이 계속 흔들립니다. 지금은 한 곳만 쓰지만 다음 스프린트에 다른 화면에서도 쓸 게 뻔한 값이라면요? 미리 올려두는 것과 실제로 필요해졌을 때 올리는 것 중 어느 쪽이 나을까요. 그 판단에 영향을 주는 요소는 무엇일까요.
2. 불변성을 관례로 지킬 것인가 도구로 강제할 것인가
오늘 배열을 다룰 때 쓸 것과 피할 것을 표로 봤습니다. push 와 splice 를 안 쓰기로 한 건 결국 팀원 모두가 기억해야 하는 약속이에요.
이걸 강제하는 방법도 있습니다. 린트 규칙으로 막을 수도 있고, 타입을 읽기 전용으로 선언해 컴파일 단계에서 막을 수도 있고, 아예 그런 실수가 불가능한 라이브러리를 쓸 수도 있어요. 각각 무엇을 얻고 무엇을 잃을까요. 여러분이 팀 리드라면 어디까지 강제하시겠습니까.
3. 규칙을 외우는 것과 이유를 아는 것
"index 를 key 로 쓰지 마라" 는 규칙으로 자주 전해집니다. 그런데 오늘 확인했듯 조건이 맞으면 index 를 써도 아무 문제가 없어요.
이런 규칙을 이유 없이 외우고 있으면 어떤 일이 생길까요. 반대로 매번 이유를 따져가며 판단하는 것이 항상 나은 걸까요. 팀에 갓 들어온 신입에게는 어느 쪽으로 알려주시겠습니까.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 해시태그로 피드 걸러내기
채점 포인트
| 항목 | 확인 내용 | 배점 |
|---|---|---|
| 태그 목록 생성 | hashtagNames 를 데이터에서 모았는가 (손으로 적지 않았는가) |
15 |
| 중복 제거 | 같은 태그가 여러 게시물에 있어도 버튼이 하나만 나오는가 | 10 |
| 상태 위치 | 선택된 태그를 App 에 뒀는가 |
20 |
| 걸러내기 | 원본 posts 상태를 건드리지 않고 걸러낸 배열만 넘겼는가 |
20 |
| 선택 표시 | 지금 고른 태그 버튼이 구별되게 보이는가 | 10 |
| 빈 결과 안내 | 결과가 0 개일 때 안내를 그렸는가 | 10 |
| 검사 통과 | typecheck 와 lint 가 모두 통과하는가 |
15 |
걸러낸 뒤에도 좋아요가 그대로 남아 있다면 이 과제의 핵심을 잡으신 겁니다.
풀이 예시
먼저 태그를 모으고 걸러내는 함수부터 만듭니다. 화면과 상관없는 순수한 계산이라 컴포넌트 밖에 두는 게 좋아요.
// 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 는 손대지 않았으니 태그를 이리저리 골라도 좋아요 상태가 사라지지 않습니다.
다음은 버튼 줄입니다.
// 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 입니다.
// 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 를 그대로 넘긴 겁니다. 중간에 할 일이 없으면 굳이 감싸는 함수를 만들 필요가 없어요.
스타일은 이 정도면 충분합니다.
/* 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;
}
자주 나오는 실수
가장 흔한 것은 걸러낸 결과를 상태에 다시 담는 겁니다.
// 이렇게 하면 안 된다
const [visiblePosts, setVisiblePosts] = useState(feedPosts);
function handleSelectTag(tag: string | null) {
setSelectedTag(tag);
setVisiblePosts(visiblePostsOf(posts, tag));
}
동작은 합니다. 그런데 이제 같은 정보를 두 곳에 들고 있는 셈이에요. 좋아요를 누르면 posts 는 바뀌는데 visiblePosts 는 안 바뀝니다. 하트를 눌러도 화면이 그대로인 버그가 생겨요. Step 5 에서 짚은 "상태에서 계산할 수 있는 값은 상태로 두지 않는다" 가 바로 이 얘기입니다.
두 번째는 원본을 걸러버리는 겁니다.
// 이렇게 하면 안 된다
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. useState 를 if 블록 안에서 불러보기 — 린트에서 잡힘
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 는 숫자를 받으면 화면에 씁니다. false 나 null 이었다면 아무것도 안 그렸을 텐데요.
여섯 개 중 이것만 어디서도 안 잡힙니다. 그래서 가장 위험해요.
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-3 에서 다룰 주제입니다.
실무에서 자주 쓰는 신호는 이겁니다. props 를 세 단계 넘게 그냥 통과시키고만 있다면 위치가 잘못됐다는 뜻이에요. 중간의 컴포넌트들은 그 값에 관심도 없는데 배달만 하고 있는 거니까요. 그때가 다른 방법을 찾을 때입니다.
🎯 면접관을 홀리는 핵심 멘트
"상태 위치는 되돌리기 쉬운 결정이라 미리 정하지 않습니다. 두 번째 사용처가 생겼을 때 가장 가까운 공통 부모까지만 올려요. 다만 중간 컴포넌트가 관심도 없는 props 를 세 단계 넘게 배달만 하고 있다면, 그건 위치 문제가 아니라 전달 방식을 바꿔야 한다는 신호로 봅니다."
🤔 [생각해볼 주제 2] 불변성을 관례로 지킬 것인가 도구로 강제할 것인가
문제 상황 요약
오늘 push 와 splice 를 안 쓰기로 했습니다. 그런데 이건 결국 팀원 모두가 기억해야 하는 약속이에요. 신입이 들어오거나, 급한 날이거나, 리뷰가 느슨해지면 언제든 새어 들어옵니다.
강제하는 방법은 여러 층이 있습니다. 린트 규칙으로 막기, 타입을 읽기 전용으로 선언하기, 그런 실수가 불가능한 라이브러리 쓰기.
튜터의 가이드 및 해설
층마다 잡는 시점과 비용이 다릅니다. 이걸 나눠 보는 게 먼저예요.
린트로 막기. 특정 메서드 호출을 금지하는 규칙을 두는 방식입니다. 설정이 가장 싸고 즉시 효과가 나요. 대신 상태가 아닌 평범한 배열에까지 걸립니다. 함수 안에서 지역 배열을 만들어 push 로 채우는 건 아무 문제가 없는데도 막히죠. 예외를 계속 열어주다 보면 규칙이 유명무실해집니다.
타입으로 막기. 상태 타입을 읽기 전용으로 선언하면 컴파일 단계에서 잡힙니다. 정확도가 가장 높아요. 상태로 쓰이는 타입에만 걸리니 지역 배열은 자유롭습니다. 대신 읽기 전용 타입이 전염됩니다. 그 타입을 받는 함수들의 시그니처를 줄줄이 고쳐야 하고, 외부 라이브러리가 그냥 배열을 요구하면 거기서 막혀요.
라이브러리로 막기. 원본을 고치는 것처럼 쓰면 알아서 새 값을 만들어주는 도구들이 있습니다. 코드가 짧아지고 중첩이 깊을 때 특히 편해요. 대신 의존성이 하나 늘고, 팀원이 그 도구의 규칙을 새로 배워야 하고, 무엇보다 왜 이렇게 쓰는지를 안 배운 채 쓰게 됩니다.
여기서 제가 강조하고 싶은 건 순서입니다. 도구는 이해를 대체하는 게 아니라 이해 위에 얹는 겁니다. 참조 비교 때문에 새로 만들어야 한다는 걸 아는 사람이 도구를 쓰면 생산성이 오르지만, 모르는 사람이 쓰면 도구가 실패했을 때 아무것도 못 합니다. 그래서 이 과목에서는 스프레드를 손으로 쓰는 것부터 배웠어요.
실무 결론은 이렇습니다. 팀 규모가 작고 코드가 단순하면 관례와 리뷰로 충분합니다. 사람이 늘고 중첩이 깊어지기 시작하면 타입으로 막는 층을 먼저 넣으세요. 정확도가 높고 의존성이 없습니다. 그래도 코드가 계속 길어진다면 그때 라이브러리를 검토하면 됩니다.
한 가지 더. 오늘 배운 방식은 React 밖에서도 값을 합니다. 서버에서도, 다른 언어에서도, 값을 고치지 않고 새로 만드는 방식은 동시성 문제와 추적 가능성에서 같은 이득을 줍니다. React 의 규칙이라서가 아니라 좋은 방식이라서 React 가 채택한 쪽에 가까워요.
🎯 면접관을 홀리는 핵심 멘트
"불변성은 규칙이 아니라 React 가 변경을 감지하는 방식 자체라고 봅니다. 그래서 팀에는 먼저 참조 비교를 이해시키고, 그다음 층을 얹어요. 순서는 관례와 리뷰, 읽기 전용 타입, 마지막이 라이브러리입니다. 도구를 먼저 주면 실패했을 때 손을 못 씁니다."
🤔 [생각해볼 주제 3] 규칙을 외우는 것과 이유를 아는 것
문제 상황 요약
"index 를 key 로 쓰지 마라" 는 규칙으로 자주 전해집니다. 그런데 오늘 확인했듯 순서가 안 바뀌고, 추가·삭제가 없고, 항목이 상태를 안 가지면 index 를 써도 아무 문제가 없어요.
규칙만 외우고 있으면 어떤 일이 생길까요. 반대로 매번 이유를 따지는 게 항상 나을까요.
튜터의 가이드 및 해설
규칙을 외우는 데는 분명한 값이 있습니다. 빠르고, 틀릴 확률이 낮고, 팀 전체가 같은 코드를 씁니다. 신입이 첫날부터 지킬 수 있어요. "잘 모르겠으면 id 를 써라" 는 조언은 실제로 대부분의 경우에 맞습니다.
문제는 규칙이 적용되지 않는 상황을 만났을 때 생깁니다. 두 방향으로 갈려요.
한쪽은 규칙을 엉뚱한 데까지 적용하는 겁니다. 고정된 요일 목록처럼 절대 안 바뀌는 배열에까지 억지로 id 를 만들어 붙입니다. 데이터에 의미 없는 필드가 늘고, 그걸 유지하는 코드가 늘어요. 규칙은 지켰는데 코드는 나빠집니다.
다른 한쪽이 더 위험합니다. 규칙이 안 적힌 상황에서 아무 판단도 못 하는 거예요. 오늘 본 좋아요가 옮겨가는 버그를 만나면, 이유를 아는 사람은 30 초면 key 를 의심합니다. 규칙만 외운 사람은 "key 는 넣었는데요" 하고 다른 데를 몇 시간 뒤집어요.
그래서 저는 이렇게 봅니다. 규칙은 기본값이고 이유는 디버깅 도구입니다. 평소에는 규칙대로 씁니다. 매번 원리를 따져가며 코딩하면 속도가 안 나고, 사실 대부분의 코드는 그럴 가치가 없어요. 이유가 필요해지는 건 두 순간입니다. 규칙대로 했는데 문제가 생겼을 때, 그리고 규칙을 어기는 게 명백히 나아 보일 때.
신입에게 알려주는 순서라면 저는 규칙을 먼저 줍니다. 다만 반드시 두 가지를 붙여요. 첫째, 이 규칙이 막으려는 게 무엇인지 한 문장으로. 둘째, 그걸 직접 눈으로 보는 경험. 오늘 목록 두 개를 만들어 좋아요가 옮겨가는 걸 본 게 그 경험입니다.
한 문장 설명과 한 번의 목격이 있으면 규칙이 원리로 바뀝니다. 그게 없으면 규칙은 미신이 되고, 미신은 상황이 바뀌면 아무 도움이 안 됩니다.
🎯 면접관을 홀리는 핵심 멘트
"규칙은 기본값으로 쓰고 이유는 디버깅할 때 꺼내 씁니다. 평소에 매번 원리를 따지면 속도가 안 나지만, 규칙만 아는 사람은 규칙이 안 통하는 순간에 멈춰요. 그래서 팀에 규칙을 전할 때는 '무엇을 막는 규칙인지' 한 문장과 그걸 직접 재현해 보는 경험을 꼭 같이 줍니다."