취미중독

하고 싶은 일이 너무 많아

학습자료/Next.js 초보자 학습 과정

# 챕터 12: State - 변하는 데이터 관리하기

depilled 2025. 8. 16. 11:48

# 챕터 12: State - 변하는 데이터 관리하기

## 서론

지금까지 우리가 만든 컴포넌트들은 Props를 통해 데이터를 받아 표시하기만 했습니다. 하지만 실제 웹 애플리케이션에서는 사용자의 행동에 따라 데이터가 변경되어야 하는 경우가 많습니다. 버튼을 클릭하면 숫자가 증가하고, 입력창에 텍스트를 입력하면 화면에 반영되고, 체크박스를 선택하면 상태가 바뀌어야 합니다.

이런 동적인 데이터를 관리하기 위해 React는 State라는 개념을 제공합니다. State는 컴포넌트가 자체적으로 관리하는 데이터로, 시간이 지남에 따라 변할 수 있는 값입니다. 이번 챕터에서는 useState Hook을 사용하여 State를 관리하는 방법을 배워보겠습니다.

## 본론

### State란 무엇인가

State는 컴포넌트의 '기억'이라고 생각할 수 있습니다. 컴포넌트가 현재 상태를 기억하고, 그 상태가 변경되면 화면을 다시 그립니다. 예를 들어, 좋아요 버튼의 클릭 횟수, 입력 폼의 현재 값, 모달의 열림/닫힘 상태 등이 모두 State입니다.

Props와 State의 차이점:
- Props: 부모로부터 전달받는 읽기 전용 데이터
- State: 컴포넌트가 자체적으로 관리하는 변경 가능한 데이터

### useState Hook 시작하기

React에서 State를 사용하려면 useState Hook을 import해야 합니다:

'use client'  // Next.js에서 클라이언트 컴포넌트임을 명시

import { useState } from 'react'

export default function Home() {
  // useState는 배열을 반환합니다: [현재값, 값을변경하는함수]
  const [count, setCount] = useState(0)
  
  return (
    <div className="p-8 text-center">
      <h2 className="text-2xl font-bold mb-4">카운터</h2>
      <p className="text-4xl font-bold text-blue-600 mb-4">{count}</p>
      <button 
        onClick={() => setCount(count + 1)}
        className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded"
      >
        증가
      </button>
    </div>
  )
}


여기서 중요한 점:
1. `'use client'`를 파일 최상단에 추가해야 합니다 (Next.js App Router 사용 시)
2. `useState(0)`에서 0은 초기값입니다
3. `count`는 현재 상태값입니다
4. `setCount`는 상태를 변경하는 함수입니다



### 다양한 타입의 State

State는 어떤 타입의 데이터든 저장할 수 있습니다:

'use client'

import { useState } from 'react'

export default function Home() {
  // 숫자 State
  const [age, setAge] = useState(25)
  
  // 문자열 State
  const [name, setName] = useState('')
  
  // 불린 State
  const [isVisible, setIsVisible] = useState(false)
  
  // 배열 State
  const [items, setItems] = useState(['사과', '바나나'])
  
  // 객체 State
  const [user, setUser] = useState({
    name: '홍길동',
    email: 'hong@example.com'
  })
  
  return (
    <div className="p-8 space-y-4">
      <div>
        <h3 className="font-bold">숫자 State</h3>
        <p>나이: {age}</p>
        <button 
          onClick={() => setAge(age + 1)}
          className="bg-blue-500 text-white px-3 py-1 rounded"
        >
          나이 증가
        </button>
      </div>
      
      <div>
        <h3 className="font-bold">문자열 State</h3>
        <input 
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          className="border p-2 rounded"
          placeholder="이름을 입력하세요"
        />
        <p>입력한 이름: {name}</p>
      </div>
      
      <div>
        <h3 className="font-bold">불린 State</h3>
        <button 
          onClick={() => setIsVisible(!isVisible)}
          className="bg-green-500 text-white px-3 py-1 rounded"
        >
          토글
        </button>
        {isVisible && <p>보입니다!</p>}
      </div>
      
      <div>
        <h3 className="font-bold">배열 State</h3>
        <ul>
          {items.map((item, index) => (
            <li key={index}>{item}</li>
          ))}
        </ul>
        <button 
          onClick={() => setItems([...items, '오렌지'])}
          className="bg-purple-500 text-white px-3 py-1 rounded"
        >
          아이템 추가
        </button>
      </div>
    </div>
  )
}




### State 업데이트 패턴

State를 업데이트할 때 주의해야 할 중요한 패턴들이 있습니다:

1. 직접 변경하지 말고 새로운 값으로 교체하기
State를 직접 수정하면 React가 변화를 감지하지 못합니다. 항상 새로운 값으로 교체해야 합니다:

// ❌ 잘못된 방법 - 직접 수정
const [user, setUser] = useState({ name: '홍길동', age: 30 })
user.age = 31  // React가 변화를 감지하지 못함

// ✅ 올바른 방법 - 새 객체로 교체
setUser({ ...user, age: 31 })

 

2. 이전 상태값을 기반으로 업데이트할 때는 함수 사용하기
현재 상태값을 기반으로 새로운 값을 계산할 때는 함수를 사용하는 것이 안전합니다:

// ❌ 문제가 될 수 있는 방법
setCount(count + 1)

// ✅ 안전한 방법 - 함수 사용
setCount(prevCount => prevCount + 1)


이렇게 하는 이유는 State 업데이트가 비동기적으로 처리되기 때문입니다. 여러 번의 업데이트가 동시에 일어날 때 예상치 못한 결과가 나올 수 있어서, 함수를 사용하면 가장 최신의 상태값을 보장받을 수 있습니다.

3. 배열 State 업데이트 시 불변성 유지하기
배열을 업데이트할 때도 원본을 직접 수정하지 말고 새로운 배열을 만들어야 합니다:

// ❌ 잘못된 방법 - 원본 배열 수정
items.push(newItem)  // 원본 수정
setItems(items)

// ✅ 올바른 방법 - 새 배열 생성
setItems([...items, newItem])  // 스프레드 연산자 사용
setItems(items.concat(newItem))  // concat 메서드 사용


4. 객체 State 업데이트 시 스프레드 연산자 활용하기
객체의 일부 속성만 변경할 때는 스프레드 연산자를 사용하여 기존 속성들을 유지하면서 특정 속성만 업데이트합니다:

const [user, setUser] = useState({
  name: '홍길동',
  age: 30,
  email: 'hong@example.com'
})

// ✅ 올바른 방법 - 스프레드 연산자로 불변성 유지
setUser({
  ...user,  // 기존 속성들 복사
  age: 31   // age만 새로운 값으로 변경
})


이런 패턴들을 지키지 않으면 컴포넌트가 제대로 리렌더링되지 않거나, 예상치 못한 버그가 발생할 수 있습니다. 처음에는 복잡해 보일 수 있지만, 몇 번 연습하면 자연스럽게 사용할 수 있게 됩니다.


위 내용을 바탕으로 예시코드를 만들어 봅시다.

'use client'

import { useState } from 'react'

export default function StateUpdatePatterns() {
  const [count, setCount] = useState(0)
  const [items, setItems] = useState([])
  const [user, setUser] = useState({ name: '', age: 0 })
  
  // 이전 상태값을 기반으로 업데이트
  const incrementCorrect = () => {
    setCount(prevCount => prevCount + 1)  // ✅ 안전한 방법
  }
  
  // 배열 State 업데이트
  const addItem = (newItem) => {
    setItems(prevItems => [...prevItems, newItem])  // 새 배열 생성
  }
  
  const removeItem = (index) => {
    setItems(prevItems => prevItems.filter((_, i) => i !== index))
  }
  
  // 객체 State 업데이트
  const updateUser = (field, value) => {
    setUser(prevUser => ({
      ...prevUser,  // 기존 속성 복사
      [field]: value  // 특정 필드만 업데이트
    }))
  }
  
  return (
    <div className="p-8">
      <h2 className="text-2xl font-bold mb-4">State 업데이트 패턴</h2>
      
      {/* 카운터 예제 */}
      <div className="mb-6">
        <p className="text-xl">카운트: {count}</p>
        <button 
          onClick={incrementCorrect}
          className="bg-blue-500 text-white px-4 py-2 rounded"
        >
          안전하게 증가
        </button>
      </div>
      
      {/* 리스트 예제 */}
      <div className="mb-6">
        <h3 className="font-bold mb-2">할 일 목록</h3>
        <button 
          onClick={() => addItem(`할 일 ${items.length + 1}`)}
          className="bg-green-500 text-white px-4 py-2 rounded mb-2"
        >
          할 일 추가
        </button>
        <ul>
          {items.map((item, index) => (
            <li key={index} className="flex items-center gap-2 mb-1">
              <span>{item}</span>
              <button 
                onClick={() => removeItem(index)}
                className="text-red-500 text-sm"
              >
                삭제
              </button>
            </li>
          ))}
        </ul>
      </div>
      
      {/* 객체 예제 */}
      <div>
        <h3 className="font-bold mb-2">사용자 정보</h3>
        <input 
          type="text"
          placeholder="이름"
          onChange={(e) => updateUser('name', e.target.value)}
          className="border p-2 rounded mr-2"
        />
        <input 
          type="number"
          placeholder="나이"
          onChange={(e) => updateUser('age', e.target.value)}
          className="border p-2 rounded"
        />
        <p className="mt-2">
          이름: {user.name}, 나이: {user.age}
        </p>
      </div>
    </div>
  )
}



### 실습: Todo List 만들기

State를 활용한 실용적인 Todo List를 만들어봅시다:

'use client'

import { useState } from 'react'

export default function TodoList() {
  const [todos, setTodos] = useState([])
  const [inputValue, setInputValue] = useState('')
  const [filter, setFilter] = useState('all')  // all, active, completed
  
  // 할 일 추가
  const addTodo = () => {
    if (inputValue.trim() === '') return
    
    const newTodo = {
      id: Date.now(),
      text: inputValue,
      completed: false
    }
    
    setTodos([...todos, newTodo])
    setInputValue('')
  }
  
  // 할 일 완료 토글
  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id 
        ? { ...todo, completed: !todo.completed }
        : todo
    ))
  }
  
  // 할 일 삭제
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id))
  }
  
  // 필터링된 할 일 목록
  const filteredTodos = todos.filter(todo => {
    if (filter === 'active') return !todo.completed
    if (filter === 'completed') return todo.completed
    return true
  })
  
  // 완료된 할 일 개수
  const completedCount = todos.filter(todo => todo.completed).length
  
  return (
    <div className="max-w-md mx-auto p-8">
      <h1 className="text-3xl font-bold text-center mb-8">📝 Todo List</h1>
      
      {/* 입력 영역 */}
      <div className="flex gap-2 mb-6">
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && addTodo()}
          placeholder="할 일을 입력하세요..."
          className="flex-1 border rounded px-4 py-2 focus:outline-none focus:border-blue-500"
        />
        <button
          onClick={addTodo}
          className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded transition-colors"
        >
          추가
        </button>
      </div>
      
      {/* 필터 버튼 */}
      <div className="flex gap-2 mb-4">
        <button
          onClick={() => setFilter('all')}
          className={`px-4 py-1 rounded ${
            filter === 'all' 
              ? 'bg-blue-500 text-white' 
              : 'bg-gray-200 text-gray-700'
          }`}
        >
          전체 ({todos.length})
        </button>
        <button
          onClick={() => setFilter('active')}
          className={`px-4 py-1 rounded ${
            filter === 'active' 
              ? 'bg-blue-500 text-white' 
              : 'bg-gray-200 text-gray-700'
          }`}
        >
          진행중 ({todos.length - completedCount})
        </button>
        <button
          onClick={() => setFilter('completed')}
          className={`px-4 py-1 rounded ${
            filter === 'completed' 
              ? 'bg-blue-500 text-white' 
              : 'bg-gray-200 text-gray-700'
          }`}
        >
          완료 ({completedCount})
        </button>
      </div>
      
      {/* Todo 목록 */}
      <div className="space-y-2">
        {filteredTodos.length === 0 ? (
          <p className="text-center text-gray-500 py-8">
            할 일이 없습니다
          </p>
        ) : (
          filteredTodos.map(todo => (
            <div
              key={todo.id}
              className="flex items-center gap-3 p-3 bg-white border rounded hover:shadow-md transition-shadow"
            >
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id)}
                className="w-5 h-5"
              />
              <span
                className={`flex-1 ${
                  todo.completed 
                    ? 'line-through text-gray-500' 
                    : 'text-gray-800'
                }`}
              >
                {todo.text}
              </span>
              <button
                onClick={() => deleteTodo(todo.id)}
                className="text-red-500 hover:text-red-700 transition-colors"
              >
                삭제
              </button>
            </div>
          ))
        )}
      </div>
      
      {/* 통계 */}
      {todos.length > 0 && (
        <div className="mt-6 text-center text-gray-600">
          <p>
            완료율: {Math.round((completedCount / todos.length) * 100)}%
          </p>
          <div className="w-full bg-gray-200 rounded-full h-2 mt-2">
            <div
              className="bg-green-500 h-2 rounded-full transition-all duration-300"
              style={{ width: `${(completedCount / todos.length) * 100}%` }}
            />
          </div>
        </div>
      )}
    </div>
  )
}


### 여러 State 관리하기

복잡한 컴포넌트는 여러 개의 State를 가질 수 있습니다:

'use client'

import { useState } from 'react'

export default function RegistrationForm() {
  // 폼 데이터
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: ''
  })
  
  // UI 상태
  const [isLoading, setIsLoading] = useState(false)
  const [errors, setErrors] = useState({})
  const [isSubmitted, setIsSubmitted] = useState(false)
  
  // 입력 처리
  const handleChange = (field, value) => {
    setFormData(prev => ({
      ...prev,
      [field]: value
    }))
    // 에러 메시지 제거
    if (errors[field]) {
      setErrors(prev => {
        const newErrors = { ...prev }
        delete newErrors[field]
        return newErrors
      })
    }
  }
  
  // 유효성 검사
  const validate = () => {
    const newErrors = {}
    
    if (!formData.username) {
      newErrors.username = '사용자명을 입력하세요'
    }
    if (!formData.email) {
      newErrors.email = '이메일을 입력하세요'
    } else if (!formData.email.includes('@')) {
      newErrors.email = '올바른 이메일 형식이 아닙니다'
    }
    if (!formData.password) {
      newErrors.password = '비밀번호를 입력하세요'
    } else if (formData.password.length < 6) {
      newErrors.password = '비밀번호는 6자 이상이어야 합니다'
    }
    
    return newErrors
  }
  
  // 제출 처리
  const handleSubmit = async (e) => {
    e.preventDefault()
    
    const newErrors = validate()
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors)
      return
    }
    
    setIsLoading(true)
    
    // 실제로는 API 호출
    setTimeout(() => {
      setIsLoading(false)
      setIsSubmitted(true)
    }, 2000)
  }
  
  if (isSubmitted) {
    return (
      <div className="max-w-md mx-auto p-8 text-center">
        <div className="text-6xl mb-4">✅</div>
        <h2 className="text-2xl font-bold text-green-600">
          가입이 완료되었습니다!
        </h2>
      </div>
    )
  }
  
  return (
    <form onSubmit={handleSubmit} className="max-w-md mx-auto p-8">
      <h2 className="text-2xl font-bold mb-6">회원가입</h2>
      
      <div className="mb-4">
        <label className="block text-gray-700 mb-2">사용자명</label>
        <input
          type="text"
          value={formData.username}
          onChange={(e) => handleChange('username', e.target.value)}
          className={`w-full border rounded px-4 py-2 ${
            errors.username ? 'border-red-500' : 'border-gray-300'
          }`}
          disabled={isLoading}
        />
        {errors.username && (
          <p className="text-red-500 text-sm mt-1">{errors.username}</p>
        )}
      </div>
      
      <div className="mb-4">
        <label className="block text-gray-700 mb-2">이메일</label>
        <input
          type="email"
          value={formData.email}
          onChange={(e) => handleChange('email', e.target.value)}
          className={`w-full border rounded px-4 py-2 ${
            errors.email ? 'border-red-500' : 'border-gray-300'
          }`}
          disabled={isLoading}
        />
        {errors.email && (
          <p className="text-red-500 text-sm mt-1">{errors.email}</p>
        )}
      </div>
      
      <div className="mb-6">
        <label className="block text-gray-700 mb-2">비밀번호</label>
        <input
          type="password"
          value={formData.password}
          onChange={(e) => handleChange('password', e.target.value)}
          className={`w-full border rounded px-4 py-2 ${
            errors.password ? 'border-red-500' : 'border-gray-300'
          }`}
          disabled={isLoading}
        />
        {errors.password && (
          <p className="text-red-500 text-sm mt-1">{errors.password}</p>
        )}
      </div>
      
      <button
        type="submit"
        disabled={isLoading}
        className={`w-full py-2 rounded text-white transition-colors ${
          isLoading
            ? 'bg-gray-400 cursor-not-allowed'
            : 'bg-blue-500 hover:bg-blue-600'
        }`}
      >
        {isLoading ? '처리중...' : '가입하기'}
      </button>
    </form>
  )
}


## 결론

State는 React 컴포넌트에 생명을 불어넣는 핵심 기능입니다. 이번 챕터에서 우리는 useState Hook을 사용하여 다양한 타입의 State를 관리하는 방법, State 업데이트 패턴, 그리고 실제 애플리케이션에서 State를 활용하는 방법을 배웠습니다.

State를 사용하면 정적인 컴포넌트를 동적이고 상호작용이 가능한 컴포넌트로 변환할 수 있습니다. Todo List, 폼, 카운터 등 모든 인터랙티브한 기능은 State를 기반으로 동작합니다.

중요한 것은 State를 언제 사용해야 하는지 판단하는 것입니다. 시간이 지나도 변하지 않는 데이터는 Props로, 사용자 상호작용이나 시간에 따라 변하는 데이터는 State로 관리하세요. 다음 챕터에서는 이벤트 처리를 더 자세히 다루어, State와 함께 사용하여 더욱 풍부한 사용자 경험을 만드는 방법을 배워보겠습니다!