Props vs State - 올바른 컴포넌트 설계 가이드

React를 처음 배우면 누구나 이런 고민을 합니다. “이 데이터를 props로 받아야 할까, state로 관리해야 할까?” 코드를 작성하다 보면 어느 순간 props drilling에 빠지거나, 불필요한 state를 만들고 있는 자신을 발견하게 됩니다.

이 글에서는 props와 state를 언제, 어떻게 사용해야 하는지 명확한 기준을 제시합니다. 단순히 “props는 부모에서 받는 것, state는 컴포넌트 내부에서 관리하는 것”이라는 정의를 넘어서, “왜 이렇게 설계해야 하는가”에 대한 깊은 이해를 얻게 될 것입니다.

먼저, 기초부터 이해하기

props와 state를 비교하기 전에, 각각이 무엇인지 정확히 이해해야 합니다.

Props란 무엇인가?

Props는 “properties”의 줄임말로, 부모 컴포넌트가 자식 컴포넌트에게 전달하는 데이터입니다. 함수의 매개변수(parameter)와 비슷한 역할을 합니다.

// 함수의 매개변수와 비슷함
function greet(name) {  // name은 매개변수
  return `안녕하세요, ${name}님!`;
}

greet('철수');  // '철수'를 인자로 전달

// React 컴포넌트의 props
function Greeting({ name }) {  // name은 prop
  return <h1>안녕하세요, {name}!</h1>;
}

<Greeting name="철수" />  // '철수'를 prop으로 전달

Props의 특징:

  1. 읽기 전용(Read-only): 자식 컴포넌트는 받은 props를 절대 수정할 수 없습니다
  2. 위에서 아래로 흐름: 부모 → 자식 방향으로만 전달됩니다
  3. 재사용성: 같은 컴포넌트를 다른 props로 여러 번 사용할 수 있습니다
// ❌ props를 수정하려고 하면 안 됨
function Button({ label }) {
  label = "새 버튼";  // 이렇게 하면 안 됨!
  return <button>{label}</button>;
}

// ✅ props는 읽기만 가능
function Button({ label }) {
  return <button>{label}</button>;
}

// 같은 컴포넌트를 다른 props로 재사용
<Button label="저장" />
<Button label="취소" />
<Button label="확인" />

State란 무엇인가?

State는 컴포넌트가 기억하는 데이터입니다. 시간이 지나면서 변할 수 있고, 변경되면 컴포넌트가 다시 렌더링됩니다.

import { useState } from 'react';

function Counter() {
  // count는 state, setCount는 state를 변경하는 함수
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        증가
      </button>
    </div>
  );
}

State의 특징:

  1. 변경 가능(Mutable): setState 함수로 값을 변경할 수 있습니다
  2. 컴포넌트 내부에서 관리: 컴포넌트가 직접 생성하고 관리합니다
  3. 변경 시 리렌더링: state가 변경되면 컴포넌트가 다시 렌더링됩니다
  4. 비공개(Private): 다른 컴포넌트는 이 state에 직접 접근할 수 없습니다

Props와 State의 근본적인 차이

비유로 이해해보면 이렇습니다.

Props는 “설정값”입니다.

  • 에어컨의 온도 설정 같은 것
  • 외부(부모)에서 정해주는 값
  • 에어컨 자체는 설정값을 바꿀 수 없음

State는 “현재 상태”입니다.

  • 에어컨의 현재 온도나 작동 여부
  • 내부에서 변할 수 있는 값
  • 에어컨이 직접 관리하고 변경함
// 에어컨 컴포넌트 비유
function AirConditioner({ targetTemperature }) {  // props: 목표 온도 (외부에서 설정)
  const [currentTemp, setCurrentTemp] = useState(28);  // state: 현재 온도 (내부에서 변경)
  const [isPowerOn, setIsPowerOn] = useState(false);   // state: 작동 여부

  return (
    <div>
      <p>목표 온도: {targetTemperature}°C</p>
      <p>현재 온도: {currentTemp}°C</p>
      <button onClick={() => setIsPowerOn(!isPowerOn)}>
        {isPowerOn ? '끄기' : '켜기'}
      </button>
    </div>
  );
}

// 사용
<AirConditioner targetTemperature={24} />

Props를 사용해야 하는 경우

props는 컴포넌트를 “재사용 가능하고 설정 가능한 조각”으로 만들 때 사용합니다.

1. 부모가 제어해야 하는 데이터

자식 컴포넌트의 표시 내용이나 동작을 부모가 결정해야 할 때는 props를 사용합니다.

// ❌ 잘못된 설계: 내부에서 고정된 값 사용
function WelcomeMessage() {
  return <h1>안녕하세요, 철수님!</h1>;
}

// 철수가 아닌 다른 사람 이름을 표시하려면?
// 새 컴포넌트를 또 만들어야 함... 재사용성 0%

// ✅ 올바른 설계: props로 받아서 재사용 가능
function WelcomeMessage({ userName }) {
  return <h1>안녕하세요, {userName}!</h1>;
}

// 누구에게든 사용 가능
<WelcomeMessage userName="철수" />
<WelcomeMessage userName="영희" />
<WelcomeMessage userName="민수" />

2. 표시 전용 데이터 (Display-only Data)

컴포넌트가 데이터를 보여주기만 하고 변경하지 않는다면 props가 적합합니다.

function UserProfile({ user }) {
  // user 정보를 보여주기만 함 (변경하지 않음)
  return (
    <div className="profile">
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>가입일: {user.joinDate}</p>
    </div>
  );
}

// 부모 컴포넌트에서 사용
function App() {
  const currentUser = {
    name: "김철수",
    email: "kim@example.com",
    avatar: "/avatars/kim.jpg",
    joinDate: "2024-01-15"
  };

  return <UserProfile user={currentUser} />;
}

왜 이런 경우 state가 아닌 props를 쓸까?

  • UserProfile은 사용자 정보를 표시만 합니다
  • 정보를 변경하는 책임은 부모 컴포넌트에 있습니다
  • 같은 UserProfile을 다른 사용자 정보로 재사용할 수 있습니다

3. 설정(Configuration) 값

컴포넌트의 동작이나 모양을 조정하는 설정값은 props로 전달합니다.

function Button({
  label,           // 버튼 텍스트
  variant,         // 스타일 종류: 'primary', 'secondary', 'danger'
  size,            // 크기: 'small', 'medium', 'large'
  disabled,        // 비활성화 여부
  onClick          // 클릭 핸들러
}) {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      disabled={disabled}
      onClick={onClick}
    >
      {label}
    </button>
  );
}

// 다양한 설정으로 재사용
<Button label="저장" variant="primary" size="large" onClick={handleSave} />
<Button label="취소" variant="secondary" size="medium" onClick={handleCancel} />
<Button label="삭제" variant="danger" size="small" disabled={true} />

4. 콜백 함수 (이벤트 핸들러)

자식 컴포넌트에서 발생한 이벤트를 부모에게 알려야 할 때는 콜백 함수를 props로 전달합니다.

// 자식 컴포넌트
function SearchBox({ onSearch }) {
  const [query, setQuery] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    onSearch(query);  // 부모에게 검색어 전달
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="검색어를 입력하세요"
      />
      <button type="submit">검색</button>
    </form>
  );
}

// 부모 컴포넌트
function SearchPage() {
  const handleSearch = (query) => {
    console.log('검색:', query);
    // API 호출, 결과 표시 등
  };

  return (
    <div>
      <h1>검색 페이지</h1>
      <SearchBox onSearch={handleSearch} />
    </div>
  );
}

이 패턴을 “Lifting State Up”이라고 합니다:

  • querySearchBox의 로컬 state
  • 검색 실행은 부모(SearchPage)의 책임
  • 자식은 props로 받은 콜백을 호출해서 부모에게 알림

State를 사용해야 하는 경우

state는 컴포넌트가 “시간에 따라 변하는 데이터를 기억”해야 할 때 사용합니다.

1. 사용자 입력 (User Input)

폼 입력, 검색어, 텍스트 에디터 등 사용자가 입력하는 데이터는 state로 관리합니다.

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('로그인:', { email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="이메일"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="비밀번호"
      />
      <button type="submit">로그인</button>
    </form>
  );
}

왜 props가 아닌 state일까?

  • 입력값은 컴포넌트 내부에서 변경됩니다
  • 다른 컴포넌트가 알 필요 없는 임시 데이터입니다
  • 제출 버튼을 누를 때만 부모에게 전달하면 됩니다

2. UI 상태 (UI State)

모달 열림/닫힘, 탭 선택, 아코디언 펼침/접힘 등 UI의 현재 상태는 state로 관리합니다.

function Accordion({ title, children }) {
  const [isOpen, setIsOpen] = useState(false);  // UI 상태

  return (
    <div className="accordion">
      <button onClick={() => setIsOpen(!isOpen)}>
        {title}
        {isOpen ? '' : ''}
      </button>
      {isOpen && (
        <div className="accordion-content">
          {children}
        </div>
      )}
    </div>
  );
}

// 각 Accordion은 독립적으로 열림/닫힘 상태 관리
<Accordion title="섹션 1">내용 1</Accordion>
<Accordion title="섹션 2">내용 2</Accordion>
<Accordion title="섹션 3">내용 3</Accordion>

왜 state가 필요할까?

  • 각 아코디언은 독립적인 열림/닫힘 상태를 가집니다
  • 버튼 클릭 시 상태가 토글되어야 합니다
  • 부모 컴포넌트가 각 아코디언의 상태를 관리할 필요가 없습니다

3. 서버에서 가져온 데이터

API에서 받아온 데이터를 저장하고 표시할 때는 state를 사용합니다.

function UserList() {
  const [users, setUsers] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/users')
      .then(response => response.json())
      .then(data => {
        setUsers(data);
        setIsLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setIsLoading(false);
      });
  }, []);

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage message={error} />;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

서버 데이터를 state로 관리하는 이유:

  • 초기에는 데이터가 없다가 시간이 지나면서 생김 (비동기)
  • 로딩, 성공, 에러 등 여러 상태를 추적해야 함
  • 컴포넌트가 마운트될 때 자동으로 데이터를 가져와야

4. 파생되지 않은 내부 데이터

다른 값으로부터 계산할 수 없는, 컴포넌트가 직접 관리하는 데이터는 state입니다.

function ShoppingCart() {
  const [items, setItems] = useState([]);

  const addItem = (product) => {
    setItems([...items, { ...product, quantity: 1 }]);
  };

  const removeItem = (productId) => {
    setItems(items.filter(item => item.id !== productId));
  };

  const updateQuantity = (productId, quantity) => {
    setItems(items.map(item =>
      item.id === productId
        ? { ...item, quantity }
        : item
    ));
  };

  // totalPrice는 state가 아닌 계산된 값
  const totalPrice = items.reduce((sum, item) =>
    sum + (item.price * item.quantity), 0
  );

  return (
    <div>
      <h2>장바구니</h2>
      {items.map(item => (
        <div key={item.id}>
          <span>{item.name}</span>
          <input
            type="number"
            value={item.quantity}
            onChange={(e) => updateQuantity(item.id, Number(e.target.value))}
          />
          <button onClick={() => removeItem(item.id)}>삭제</button>
        </div>
      ))}
      <p> 금액: {totalPrice}</p>
    </div>
  );
}

주목할 점:

  • items는 state (직접 관리하는 데이터)
  • totalPrice는 state가 아님 (items로부터 계산 가능)

헷갈리는 경우: Props vs State 판단 기준

실전에서는 “이게 props여야 할까, state여야 할까?” 고민되는 상황이 자주 있습니다. 명확한 판단 기준을 알아봅시다.

판단 기준 1: “누가 이 데이터를 소유하는가?”

다음 질문들에 답해보세요:

  1. 이 데이터를 여러 컴포넌트가 공유하는가?
    • Yes → 부모의 state → 자식들에게 props로 전달
    • No → 해당 컴포넌트의 로컬 state
  2. 이 데이터가 변경될 때 다른 컴포넌트도 알아야 하는가?
    • Yes → 공통 부모의 state
    • No → 로컬 state
  3. 이 데이터의 변경 권한이 누구에게 있는가?
    • 부모 → 부모의 state + props로 전달
    • 자식 → 자식의 state
    • 둘 다 → Controlled Component 패턴 (아래서 설명)
// ❌ 잘못된 설계: 같은 데이터를 각 컴포넌트가 따로 관리
function SearchBox() {
  const [query, setQuery] = useState('');
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

function SearchResults() {
  const [query, setQuery] = useState('');  // 중복!
  const results = searchProducts(query);
  return <div>{results.map(...)}</div>;
}

// ✅ 올바른 설계: 공통 부모에서 state 관리
function SearchPage() {
  const [query, setQuery] = useState('');  // 한 곳에서만 관리

  return (
    <div>
      <SearchBox value={query} onChange={setQuery} />
      <SearchResults query={query} />
    </div>
  );
}

function SearchBox({ value, onChange }) {
  return (
    <input
      value={value}
      onChange={e => onChange(e.target.value)}
    />
  );
}

function SearchResults({ query }) {
  const results = searchProducts(query);
  return <div>{results.map(...)}</div>;
}

판단 기준 2: “이 데이터가 시간에 따라 변하는가?”

// 변하지 않는 설정값 → props
function ProductCard({ name, price, imageUrl }) {
  // 상품 정보는 외부에서 받아서 표시만 함
  return (
    <div className="card">
      <img src={imageUrl} alt={name} />
      <h3>{name}</h3>
      <p>{price}</p>
    </div>
  );
}

// 시간에 따라 변하는 값 → state
function LikeButton() {
  const [isLiked, setIsLiked] = useState(false);  // 좋아요 상태는 변함
  const [likeCount, setLikeCount] = useState(0);  // 좋아요 수도 변함

  const handleClick = () => {
    if (isLiked) {
      setIsLiked(false);
      setLikeCount(likeCount - 1);
    } else {
      setIsLiked(true);
      setLikeCount(likeCount + 1);
    }
  };

  return (
    <button onClick={handleClick}>
      {isLiked ? '❤️' : '🤍'} {likeCount}
    </button>
  );
}

판단 기준 3: “이 값을 계산할 수 있는가?”

다른 state나 props로부터 계산 가능한 값은 별도의 state로 만들지 마세요.

// ❌ 잘못된 설계: 계산 가능한 값을 state로
function TodoList() {
  const [todos, setTodos] = useState([]);
  const [completedCount, setCompletedCount] = useState(0);  // 불필요!

  const addTodo = (text) => {
    setTodos([...todos, { id: Date.now(), text, completed: false }]);
  };

  const toggleTodo = (id) => {
    const newTodos = todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    );
    setTodos(newTodos);

    // 완료 개수를 수동으로 계산해서 업데이트... 버그 발생 가능!
    setCompletedCount(newTodos.filter(t => t.completed).length);
  };
}

// ✅ 올바른 설계: 계산된 값은 그냥 변수로
function TodoList() {
  const [todos, setTodos] = useState([]);

  // completedCount는 todos로부터 계산 가능
  const completedCount = todos.filter(t => t.completed).length;

  const addTodo = (text) => {
    setTodos([...todos, { id: Date.now(), text, completed: false }]);
  };

  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  return (
    <div>
      <p>완료: {completedCount} / {todos.length}</p>
      {/* ... */}
    </div>
  );
}

계산된 값을 state로 만들면 안 되는 이유:

  1. 동기화 버그: 원본 state와 계산된 state가 불일치할 수 있음
  2. 코드 복잡도: 원본이 변경될 때마다 계산된 값도 수동으로 업데이트해야 함
  3. 성능 낭비: 불필요한 state로 인해 리렌더링 증가

Controlled vs Uncontrolled Components

props와 state를 함께 사용하는 두 가지 중요한 패턴이 있습니다.

Controlled Component (제어 컴포넌트)

부모 컴포넌트가 자식의 state를 완전히 제어하는 패턴입니다.

// 부모가 input의 값을 완전히 제어
function ParentComponent() {
  const [inputValue, setInputValue] = useState('');

  return (
    <div>
      <ControlledInput
        value={inputValue}
        onChange={setInputValue}
      />
      <p>입력값: {inputValue}</p>
    </div>
  );
}

function ControlledInput({ value, onChange }) {
  return (
    <input
      value={value}  // 부모가 준 값을 표시
      onChange={(e) => onChange(e.target.value)}  // 변경을 부모에게 알림
    />
  );
}

Controlled Component의 특징:

  • 부모가 “진실의 원천(Single Source of Truth)”
  • 부모가 값을 검증하거나 변환 가능
  • 여러 컴포넌트 간 값 동기화 쉬움

언제 사용할까?

  • 폼 검증이 필요할 때
  • 여러 input의 값을 조합해야 할 때
  • 입력값을 실시간으로 변환해야 할 때 (예: 대문자 변환, 숫자만 입력)
// 실전 예: 전화번호 포맷팅
function PhoneNumberInput() {
  const [phone, setPhone] = useState('');

  const handleChange = (value) => {
    // 숫자만 추출
    const numbers = value.replace(/\D/g, '');

    // 포맷 적용: 010-1234-5678
    let formatted = numbers;
    if (numbers.length > 3) {
      formatted = numbers.slice(0, 3) + '-' + numbers.slice(3);
    }
    if (numbers.length > 7) {
      formatted = numbers.slice(0, 3) + '-' + numbers.slice(3, 7) + '-' + numbers.slice(7, 11);
    }

    setPhone(formatted);
  };

  return (
    <input
      value={phone}
      onChange={(e) => handleChange(e.target.value)}
      placeholder="010-0000-0000"
    />
  );
}

Uncontrolled Component (비제어 컴포넌트)

컴포넌트가 자체적으로 state를 관리하는 패턴입니다.

function UncontrolledInput() {
  // 내부에서 자체적으로 state 관리
  const [value, setValue] = useState('');

  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

// 부모는 값을 직접 제어하지 않음
function ParentComponent() {
  return (
    <div>
      <UncontrolledInput />
      {/* 부모는 input의 현재 값을 모름 */}
    </div>
  );
}

Uncontrolled Component의 특징:

  • 컴포넌트가 자체적으로 state 관리
  • 부모는 최종 값만 알면 될 때 사용
  • ref를 사용해서 필요할 때만 값에 접근

언제 사용할까?

  • 폼 제출 시에만 값이 필요할 때
  • 파일 input (파일 선택은 제어할 수 없음)
  • 써드파티 라이브러리 통합 시
// ref를 사용한 Uncontrolled Component
import { useRef } from 'react';

function LoginForm() {
  const emailRef = useRef();
  const passwordRef = useRef();

  const handleSubmit = (e) => {
    e.preventDefault();

    // 제출할 때만 값에 접근
    const email = emailRef.current.value;
    const password = passwordRef.current.value;

    console.log('로그인:', { email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={emailRef} type="email" />
      <input ref={passwordRef} type="password" />
      <button type="submit">로그인</button>
    </form>
  );
}

Hybrid Pattern (하이브리드 패턴)

둘을 섞어서 사용하는 패턴도 있습니다. 기본값은 props로 받되, 내부에서도 변경 가능합니다.

function Counter({ initialCount = 0, onChange }) {
  const [count, setCount] = useState(initialCount);  // props로 초기값 받음

  const handleIncrement = () => {
    const newCount = count + 1;
    setCount(newCount);
    onChange?.(newCount);  // 부모에게도 알림 (선택적)
  };

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={handleIncrement}>증가</button>
    </div>
  );
}

// 사용
<Counter initialCount={10} onChange={(value) => console.log('변경됨:', value)} />

주의: 이 패턴은 props가 변경되어도 state가 업데이트되지 않습니다. 의도적인 경우에만 사용하세요.

실전 패턴과 함정

실무에서 자주 마주치는 상황과 올바른 해결 방법을 알아봅니다.

패턴 1: Props Drilling 문제

props를 여러 단계 거쳐 전달해야 할 때 코드가 복잡해집니다.

// ❌ Props Drilling: 중간 컴포넌트들이 불필요하게 props를 전달
function App() {
  const [user, setUser] = useState({ name: '김철수', role: 'admin' });

  return <Dashboard user={user} />;
}

function Dashboard({ user }) {
  return (
    <div>
      <Sidebar user={user} />
      <MainContent user={user} />
    </div>
  );
}

function Sidebar({ user }) {
  return (
    <nav>
      <UserMenu user={user} />
    </nav>
  );
}

function UserMenu({ user }) {
  return <div>안녕하세요, {user.name}</div>;
}

// ✅ 해결책 1: Context API 사용
const UserContext = createContext();

function App() {
  const [user, setUser] = useState({ name: '김철수', role: 'admin' });

  return (
    <UserContext.Provider value={user}>
      <Dashboard />
    </UserContext.Provider>
  );
}

function Dashboard() {
  return (
    <div>
      <Sidebar />
      <MainContent />
    </div>
  );
}

function Sidebar() {
  return (
    <nav>
      <UserMenu />
    </nav>
  );
}

function UserMenu() {
  const user = useContext(UserContext);  // 직접 접근
  return <div>안녕하세요, {user.name}</div>;
}

Context를 사용하면 좋은 경우:

  • 3단계 이상 깊이 전달해야 할 때
  • 많은 컴포넌트가 같은 데이터를 필요로 할 때
  • 테마, 언어, 인증 정보 같은 전역 설정

주의: Context를 과도하게 사용하면 컴포넌트 재사용성이 떨어집니다. 2-3단계 정도는 props로 전달하는 것이 더 명확할 수 있습니다.

패턴 2: 상태 끌어올리기 (Lifting State Up)

형제 컴포넌트 간 데이터를 공유해야 할 때는 공통 부모로 state를 올립니다.

// ❌ 잘못된 설계: 각 컴포넌트가 독립적으로 state 관리
function TemperatureInput() {
  const [temperature, setTemperature] = useState('');

  return (
    <input
      value={temperature}
      onChange={e => setTemperature(e.target.value)}
    />
  );
}

function App() {
  return (
    <div>
      <TemperatureInput />  {/* 섭씨 */}
      <TemperatureInput />  {/* 화씨 */}
      {/* 두 입력이 동기화되지 않음! */}
    </div>
  );
}

// ✅ 올바른 설계: 공통 부모에서 state 관리
function TemperatureInput({ scale, temperature, onTemperatureChange }) {
  return (
    <fieldset>
      <legend>{scale === 'c' ? '섭씨' : '화씨'}</legend>
      <input
        value={temperature}
        onChange={e => onTemperatureChange(e.target.value)}
      />
    </fieldset>
  );
}

function App() {
  const [temperature, setTemperature] = useState('');
  const [scale, setScale] = useState('c');

  const handleCelsiusChange = (temp) => {
    setTemperature(temp);
    setScale('c');
  };

  const handleFahrenheitChange = (temp) => {
    setTemperature(temp);
    setScale('f');
  };

  const celsius = scale === 'f' ? tryConvert(temperature, toCelsius) : temperature;
  const fahrenheit = scale === 'c' ? tryConvert(temperature, toFahrenheit) : temperature;

  return (
    <div>
      <TemperatureInput
        scale="c"
        temperature={celsius}
        onTemperatureChange={handleCelsiusChange}
      />
      <TemperatureInput
        scale="f"
        temperature={fahrenheit}
        onTemperatureChange={handleFahrenheitChange}
      />
    </div>
  );
}

함정 1: Props를 State 초기값으로 사용하기

props를 state의 초기값으로만 사용하면, props가 변경되어도 state가 업데이트되지 않습니다.

// ❌ 함정: props 변경이 반영되지 않음
function Counter({ initialCount }) {
  const [count, setCount] = useState(initialCount);

  // initialCount가 변경되어도 count는 업데이트되지 않음!
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>증가</button>
    </div>
  );
}

// 부모에서 initialCount를 10 → 20으로 변경해도
// Counter의 count는 여전히 10

// ✅ 해결책 1: Props를 직접 사용 (state 불필요)
function Counter({ count, onIncrement }) {
  return (
    <div>
      <p>{count}</p>
      <button onClick={onIncrement}>증가</button>
    </div>
  );
}

// ✅ 해결책 2: useEffect로 props 변경 감지
function Counter({ initialCount }) {
  const [count, setCount] = useState(initialCount);

  useEffect(() => {
    setCount(initialCount);  // props 변경 시 state 업데이트
  }, [initialCount]);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>증가</button>
    </div>
  );
}

// ✅ 해결책 3: key를 사용해서 컴포넌트 리셋
function ParentComponent() {
  const [initialCount, setInitialCount] = useState(10);

  return (
    <div>
      <Counter key={initialCount} initialCount={initialCount} />
      {/* key가 변경되면 컴포넌트가 완전히 새로 마운트됨 */}
    </div>
  );
}

함정 2: 너무 많은 State 만들기

계산 가능한 값을 state로 만들면 버그와 성능 문제가 발생합니다.

// ❌ 불필요한 state
function ShoppingCart({ items }) {
  const [totalPrice, setTotalPrice] = useState(0);
  const [itemCount, setItemCount] = useState(0);

  // items가 변경될 때마다 수동으로 업데이트해야 함
  useEffect(() => {
    const total = items.reduce((sum, item) => sum + item.price, 0);
    const count = items.length;
    setTotalPrice(total);
    setItemCount(count);
  }, [items]);

  // ... 버그 발생 가능!
}

// ✅ 계산된 값은 그냥 변수로
function ShoppingCart({ items }) {
  // 렌더링할 때마다 계산 (충분히 빠름)
  const totalPrice = items.reduce((sum, item) => sum + item.price, 0);
  const itemCount = items.length;

  // 버그 없고 코드도 간결함
}

계산이 비싸다면 useMemo 사용:

function ShoppingCart({ items }) {
  // 복잡한 계산이라면 캐싱
  const totalPrice = useMemo(
    () => items.reduce((sum, item) => sum + item.price, 0),
    [items]
  );

  const itemCount = items.length;  // 간단한 계산은 그냥 변수로
}

함정 3: State 업데이트 타이밍 오해

state 업데이트는 비동기적입니다.

// ❌ 잘못된 이해
function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    console.log(count);  // 여전히 0! (즉시 변경되지 않음)
    setCount(count + 1);  // count는 여전히 0이므로 0 + 1 = 1
  }

  // 버튼 클릭해도 count는 1만 증가
  return <button onClick={handleClick}>{count}</button>;
}

// ✅ 함수형 업데이트 사용
function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(prev => prev + 1);  // 이전 값 기준으로 업데이트
    setCount(prev => prev + 1);  // 또 이전 값 기준으로 업데이트
  }

  // 버튼 클릭하면 count가 2씩 증가
  return <button onClick={handleClick}>{count}</button>;
}

의사결정 플로우차트

“이 데이터를 어떻게 관리해야 할까?” 고민될 때 다음 순서로 질문해보세요.

데이터를 어떻게 관리할까?
  ↓
[1] 이 값을 다른 state/props로부터 계산할 수 있는가?
  Yes → 그냥 변수로 (또는 useMemo)
  No → 다음 질문으로
  ↓
[2] 이 값이 시간에 따라 변하는가?
  No → Props로 전달받기
  Yes → 다음 질문으로
  ↓
[3] 여러 컴포넌트가 이 값을 공유하는가?
  No → 로컬 State
  Yes → 다음 질문으로
  ↓
[4] 공유 범위가 얼마나 넓은가?
  2-3개 컴포넌트 → 공통 부모의 State + Props 전달
  전역적으로 필요 → Context 또는 상태 관리 라이브러리

예시로 익히기

// 질문: userName을 어떻게 관리해야 할까?
function UserProfile({ userName }) {  // ← Props로 받기
  // userName은 부모가 결정하는 값
  // UserProfile은 표시만 함

  return <h1>{userName}님의 프로필</h1>;
}

// 질문: isOpen을 어떻게 관리해야 할까?
function Modal() {
  const [isOpen, setIsOpen] = useState(false);  // ← 로컬 State
  // 모달의 열림/닫힘은 Modal 컴포넌트만 관리
  // 다른 컴포넌트가 알 필요 없음

  return (
    <>
      <button onClick={() => setIsOpen(true)}>열기</button>
      {isOpen && <div className="modal">...</div>}
    </>
  );
}

// 질문: searchQuery를 어떻게 관리해야 할까?
function SearchPage() {
  const [query, setQuery] = useState('');  // ← 공통 부모의 State

  return (
    <>
      <SearchBox value={query} onChange={setQuery} />  {/* Props */}
      <SearchResults query={query} />  {/* Props */}
      {/* SearchBox와 SearchResults가 공유해야 하므로 */}
      {/* 공통 부모(SearchPage)에서 state 관리 */}
    </>
  );
}

// 질문: totalPrice를 어떻게 관리해야 할까?
function ShoppingCart({ items }) {
  const totalPrice = items.reduce(  // ← 계산된 값 (변수)
    (sum, item) => sum + item.price, 0
  );
  // items로부터 계산 가능하므로 별도 state 불필요

  return <div>총액: {totalPrice}</div>;
}

// 질문: theme을 어떻게 관리해야 할까?
const ThemeContext = createContext();  // ← Context (전역)

function App() {
  const [theme, setTheme] = useState('light');
  // 앱 전체에서 사용하는 테마 설정

  return (
    <ThemeContext.Provider value=>
      <Header />
      <MainContent />
      <Footer />
    </ThemeContext.Provider>
  );
}

실전 사례 연구

실제로 마주칠 법한 상황을 단계별로 분석해봅시다.

사례 1: 검색 가능한 드롭다운

요구사항: 사용자가 검색어를 입력하면 필터링된 옵션 목록을 보여주는 드롭다운

function SearchableDropdown({ options, value, onChange, placeholder }) {
  // [분석]
  // - options: 외부에서 제공 → Props
  // - value: 선택된 값, 부모가 관리 → Props (Controlled)
  // - onChange: 선택 알림 → Props (콜백)
  // - searchQuery: 내부에서만 사용 → State
  // - isOpen: 드롭다운 열림 상태 → State
  // - filteredOptions: options + searchQuery로 계산 → 변수

  const [isOpen, setIsOpen] = useState(false);
  const [searchQuery, setSearchQuery] = useState('');

  // 계산된 값: state 아님!
  const filteredOptions = options.filter(option =>
    option.label.toLowerCase().includes(searchQuery.toLowerCase())
  );

  const handleSelect = (option) => {
    onChange(option);
    setIsOpen(false);
    setSearchQuery('');
  };

  return (
    <div className="dropdown">
      <button onClick={() => setIsOpen(!isOpen)}>
        {value?.label || placeholder}
      </button>

      {isOpen && (
        <div className="dropdown-menu">
          <input
            type="text"
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            placeholder="검색..."
          />
          <ul>
            {filteredOptions.map(option => (
              <li key={option.value} onClick={() => handleSelect(option)}>
                {option.label}
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

// 사용
function App() {
  const [selectedCountry, setSelectedCountry] = useState(null);

  const countries = [
    { value: 'kr', label: '대한민국' },
    { value: 'us', label: '미국' },
    { value: 'jp', label: '일본' },
  ];

  return (
    <SearchableDropdown
      options={countries}
      value={selectedCountry}
      onChange={setSelectedCountry}
      placeholder="국가 선택"
    />
  );
}

설계 결정 요약:

  • options, value, onChange: Props (부모가 제어)
  • isOpen, searchQuery: State (내부에서만 사용)
  • filteredOptions: 변수 (계산 가능)

사례 2: 탭 컴포넌트

요구사항: 여러 탭을 보여주고 클릭하면 해당 내용 표시

// 방법 1: Controlled (부모가 제어)
function ControlledTabs({ tabs, activeTab, onTabChange }) {
  return (
    <div className="tabs">
      <div className="tab-buttons">
        {tabs.map(tab => (
          <button
            key={tab.id}
            className={activeTab === tab.id ? 'active' : ''}
            onClick={() => onTabChange(tab.id)}
          >
            {tab.label}
          </button>
        ))}
      </div>
      <div className="tab-content">
        {tabs.find(tab => tab.id === activeTab)?.content}
      </div>
    </div>
  );
}

// 부모가 현재 탭 관리
function App() {
  const [currentTab, setCurrentTab] = useState('home');

  const tabs = [
    { id: 'home', label: '', content: <HomePage /> },
    { id: 'profile', label: '프로필', content: <ProfilePage /> },
    { id: 'settings', label: '설정', content: <SettingsPage /> },
  ];

  return (
    <ControlledTabs
      tabs={tabs}
      activeTab={currentTab}
      onTabChange={setCurrentTab}
    />
  );
}

// 방법 2: Uncontrolled (자체 제어)
function UncontrolledTabs({ tabs, defaultTab }) {
  const [activeTab, setActiveTab] = useState(defaultTab || tabs[0].id);

  return (
    <div className="tabs">
      <div className="tab-buttons">
        {tabs.map(tab => (
          <button
            key={tab.id}
            className={activeTab === tab.id ? 'active' : ''}
            onClick={() => setActiveTab(tab.id)}
          >
            {tab.label}
          </button>
        ))}
      </div>
      <div className="tab-content">
        {tabs.find(tab => tab.id === activeTab)?.content}
      </div>
    </div>
  );
}

// 부모는 초기 탭만 지정
function App() {
  const tabs = [
    { id: 'home', label: '', content: <HomePage /> },
    { id: 'profile', label: '프로필', content: <ProfilePage /> },
    { id: 'settings', label: '설정', content: <SettingsPage /> },
  ];

  return <UncontrolledTabs tabs={tabs} defaultTab="home" />;
}

언제 Controlled를 쓸까?

  • 다른 컴포넌트가 현재 탭을 알아야 할 때
  • URL과 탭을 동기화해야 할 때
  • 탭 변경 시 특별한 로직이 필요할 때

언제 Uncontrolled를 쓸까?

  • 탭 전환이 순전히 UI용일 때
  • 다른 컴포넌트가 관여할 필요 없을 때
  • 간단한 탭 전환만 필요할 때

사례 3: 폼 입력 동기화

요구사항: 이메일과 비밀번호 확인이 실시간으로 표시되어야 함

function SignupForm() {
  // [분석]
  // - email, password, passwordConfirm: 사용자 입력 → State
  // - emailError, passwordError: email/password로 계산 가능 → 변수
  // - isFormValid: 모든 필드로 계산 가능 → 변수

  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [passwordConfirm, setPasswordConfirm] = useState('');

  // 계산된 값들 (state 아님!)
  const emailError = email && !email.includes('@')
    ? '유효한 이메일을 입력하세요'
    : '';

  const passwordError = password && password.length < 8
    ? '비밀번호는 8자 이상이어야 합니다'
    : '';

  const passwordMatchError = passwordConfirm && password !== passwordConfirm
    ? '비밀번호가 일치하지 않습니다'
    : '';

  const isFormValid =
    email &&
    password &&
    passwordConfirm &&
    !emailError &&
    !passwordError &&
    !passwordMatchError;

  const handleSubmit = (e) => {
    e.preventDefault();
    if (isFormValid) {
      console.log('가입:', { email, password });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="이메일"
        />
        {emailError && <p className="error">{emailError}</p>}
      </div>

      <div>
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          placeholder="비밀번호"
        />
        {passwordError && <p className="error">{passwordError}</p>}
      </div>

      <div>
        <input
          type="password"
          value={passwordConfirm}
          onChange={(e) => setPasswordConfirm(e.target.value)}
          placeholder="비밀번호 확인"
        />
        {passwordMatchError && <p className="error">{passwordMatchError}</p>}
      </div>

      <button type="submit" disabled={!isFormValid}>
        가입하기
      </button>
    </form>
  );
}

왜 에러 메시지를 state로 만들지 않았을까?

  • emailErroremail 값으로 계산 가능
  • state로 만들면 email이 변경될 때마다 emailError도 수동으로 업데이트해야 함
  • 동기화 버그 가능성 있음

모범 사례 체크리스트

컴포넌트를 설계할 때 다음을 확인하세요.

State 관련

  • 최소한의 state: 계산 가능한 값을 state로 만들지 않았는가?
  • 단일 진실 원천: 같은 데이터를 여러 state에 복제하지 않았는가?
  • 적절한 위치: state가 가장 적합한 컴포넌트에 있는가?
  • 불변성 유지: state 업데이트 시 불변성을 지켰는가?
// ✅ 좋은 예
function TodoList() {
  const [todos, setTodos] = useState([]);

  // 계산된 값: state가 아님
  const completedCount = todos.filter(t => t.completed).length;
  const activeCount = todos.length - completedCount;

  return (
    <div>
      <p>전체: {todos.length} | 완료: {completedCount} | 미완료: {activeCount}</p>
      {/* ... */}
    </div>
  );
}

// ❌ 나쁜 예
function TodoList() {
  const [todos, setTodos] = useState([]);
  const [completedCount, setCompletedCount] = useState(0);  // 불필요한 state
  const [activeCount, setActiveCount] = useState(0);  // 불필요한 state

  // todos가 변경될 때마다 수동으로 업데이트... 버그 가능성!
  useEffect(() => {
    setCompletedCount(todos.filter(t => t.completed).length);
    setActiveCount(todos.filter(t => !t.completed).length);
  }, [todos]);
}

Props 관련

  • 명확한 이름: props 이름이 역할을 명확히 나타내는가?
  • 타입 명시: PropTypes 또는 TypeScript로 타입을 정의했는가?
  • 기본값 제공: 선택적 props에 기본값을 제공했는가?
  • 콜백 네이밍: 이벤트 핸들러 props는 on~ 패턴을 따르는가?
// ✅ 좋은 예
function Button({
  label,
  variant = 'primary',  // 기본값 제공
  size = 'medium',
  disabled = false,
  onClick,  // 명확한 콜백 이름
}) {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      disabled={disabled}
      onClick={onClick}
    >
      {label}
    </button>
  );
}

// TypeScript 사용 시
interface ButtonProps {
  label: string;
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  onClick?: () => void;
}

function Button({
  label,
  variant = 'primary',
  size = 'medium',
  disabled = false,
  onClick,
}: ButtonProps) {
  // ...
}

컴포넌트 설계

  • 단일 책임: 컴포넌트가 하나의 명확한 목적을 가지는가?
  • 재사용성: 다른 맥락에서도 사용할 수 있는가?
  • 테스트 가능성: 쉽게 테스트할 수 있는 구조인가?
  • 명확한 인터페이스: props가 컴포넌트의 API를 명확히 정의하는가?
// ❌ 나쁜 예: 너무 많은 책임
function UserDashboard() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [comments, setComments] = useState([]);
  const [isEditing, setIsEditing] = useState(false);
  // ... 수십 개의 state와 로직

  // 사용자 정보, 게시글, 댓글, 편집 등 모든 것을 한 컴포넌트에서 관리
  return (
    <div>
      {/* 수백 줄의 JSX */}
    </div>
  );
}

// ✅ 좋은 예: 적절히 분리
function UserDashboard({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  if (!user) return <Spinner />;

  return (
    <div>
      <UserProfile user={user} />
      <UserPosts userId={userId} />
      <UserComments userId={userId} />
    </div>
  );
}

function UserProfile({ user }) {
  const [isEditing, setIsEditing] = useState(false);
  // 프로필 표시/편집만 담당
}

function UserPosts({ userId }) {
  const [posts, setPosts] = useState([]);
  // 게시글 관리만 담당
}

function UserComments({ userId }) {
  const [comments, setComments] = useState([]);
  // 댓글 관리만 담당
}

마치며

Props와 state는 React 컴포넌트 설계의 가장 기본이면서도 가장 중요한 개념입니다.

핵심 정리

  1. Props는 설정값, State는 현재 상태
    • Props: 부모가 자식에게 주는 읽기 전용 데이터
    • State: 컴포넌트가 기억하고 변경할 수 있는 데이터
  2. 계산 가능하면 state가 아니다
    • 다른 state나 props로 계산할 수 있는 값은 변수로 충분
  3. 데이터는 한 곳에서만 관리
    • 같은 데이터를 여러 곳에 복제하지 말고 단일 진실 원천 유지
  4. 적절한 위치에 state 배치
    • 로컬 state: 한 컴포넌트만 사용
    • 공유 state: 공통 부모에서 관리
    • 전역 state: Context 또는 상태 관리 라이브러리
  5. Controlled vs Uncontrolled 선택
    • 부모가 제어 필요 → Controlled
    • 컴포넌트 자체 제어 → Uncontrolled

의사결정 간단 요약

이 데이터를 어떻게 관리할까?

1. 계산 가능? → 변수
2. 시간에 따라 변함? No → Props
3. 변하지만 로컬? → State
4. 여러 곳에서 공유? → 공통 부모 State + Props
5. 전역적으로 필요? → Context

다음 단계

Props와 state를 올바르게 사용하는 것은 좋은 React 컴포넌트 설계의 시작입니다. 이 가이드가 명확한 기준을 세우는 데 도움이 되었기를 바랍니다.

참고 자료

댓글