리액트/기초

리액트 기초 01 - 핵심 요소 3 - Hooks : useState란 무엇인가?

lamarcK 2025. 4. 7. 21:22

📌 useState란 무엇인가?

useState는 React의 기본 훅(Hook) 중 하나이다. 함수형 컴포넌트 내에서 **상태(state)**를 관리하고, 해당 상태가 변경될 때 컴포넌트가 자동으로 다시 렌더링되도록 하는 핵심적인 기능을 제공한다. 이는 JSX 문법만으로는 동적인 데이터 변화를 화면에 반영하기 어려운 점을 해결하기 위해 도입되었다.

React는 자바스크립트를 이용한 직접적인 DOM(Document Object Model) 조작의 복잡성과 비효율성을 줄이기 위해 설계된 라이브러리다. 개발자가 DOM을 직접 제어하기보다는, React가 **선언적(Declarative)**으로 UI를 관리하도록 한다. 즉, 개발자는 "어떤 상태일 때 UI가 어떠해야 한다"고 정의하면, React가 상태 변경에 따라 화면 업데이트를 자동으로 처리한다.

JSX는 HTML과 유사해 정적인 템플릿처럼 보일 수 있지만, 실제로는 자바스크립트의 확장 문법이다. 이를 통해 자바스크립트 표현식({})이나 로직(조건부 렌더링, 반복 등)을 사용하여 동적인 UI 구조를 만들 수 있다. 하지만 JSX 자체만으로는 "사용자 인터랙션 등으로 데이터가 변했을 때 화면을 어떻게 업데이트할지"를 직접 명령하기 어렵다.

이때 useState가 등장한다. useState를 통해 관리되는 상태(state)가 변경되면(정확히는 상태 변경 함수 setState가 호출되면), React에게 "UI를 업데이트해야 할 시점"이라는 신호를 보내고, React는 효율적인 방식으로 화면을 다시 그린다(리렌더링).

🤔 useState는 왜 필요한가?

1. 함수형 컴포넌트의 상태 유지 문제

React의 함수형 컴포넌트는 기본적으로 단순한 자바스크립트 함수이다. 컴포넌트가 렌더링될 때마다 해당 함수는 다시 호출된다. 이는 함수 내부에 선언된 지역 변수들이 렌더링마다 초기화된다는 의미이다.

💡 예시: 함수 내부에 let count = 0;을 선언하면, 버튼 클릭 등으로 컴포넌트가 리렌더링될 때마다 count는 다시 0으로 돌아간다. 증가하는 카운터처럼 이전 상태를 기억해야 하는 UI를 만들 수 없다.

2. 상태 기억의 필요성

웹 애플리케이션의 UI는 사용자의 행동(클릭, 입력 등)이나 외부 데이터(API 응답 등)에 따라 동적으로 변화하며, 이전 상태를 기억하고 있어야 하는 경우가 많다. (예: 카운터 값, 입력 필드의 내용, 토글 버튼의 ON/OFF 상태 등)

3. 전역 변수의 한계

이 문제를 해결하기 위해 함수 외부(전역 스코프)에 변수를 선언할 수도 있지만, 이는 여러 단점을 가진다.

  • 추적의 어려움: 애플리케이션 어디서든 수정 가능하여 상태 변경의 원인을 파악하기 어렵다.
  • 예상치 못한 부작용 (Side Effects): 의도치 않은 곳에서 값이 변경될 위험이 있다.
  • 재사용성 및 독립성 저해: 컴포넌트가 전역 상태에 의존하게 되어 독립적인 재사용이 어렵다.

4. useState의 해결책

useState는 바로 이 지점에서 핵심적인 역할을 수행한다.

  • 상태 유지: useState는 함수 컴포넌트가 리렌더링되어도 값이 초기화되지 않고 유지되는 특별한 변수(상태)를 생성하고 관리한다. React는 이 상태를 컴포넌트 인스턴스와 연결하여 별도로 기억한다.
  • 지역 스코프: 상태는 해당 컴포넌트 내에서만 유효하므로 전역 변수의 문제를 피할 수 있다.
  • 자동 리렌더링 트리거: 상태 변경 함수(setState)를 호출하면, React는 해당 컴포넌트의 리렌더링을 자동으로 예약하여 변경된 상태를 화면에 반영한다.

결론적으로 useState는 함수형 컴포넌트가 상태를 가지고(Stateful), 그 상태 변화에 따라 반응적으로(Reactive) UI를 업데이트할 수 있도록 만드는 필수적인 도구이다.

✨ useState 사용법 (How to Use useState)

1. 기본 문법 (Basic Syntax)

useState를 사용하려면 먼저 React로부터 가져와야(import) 한다.

import React, { useState } from 'react';

컴포넌트 내에서 useState를 호출하면 배열(Array)을 반환한다. 이 배열은 일반적으로 **구조 분해 할당(Destructuring Assignment)**을 사용하여 두 개의 요소로 받는다.

// 기본 형태
const [state, setState] = useState(initialState);
  • state: 현재 상태 값을 저장하는 변수. 이름은 자유롭게 지정할 수 있다. (예: count, name, isVisible)
  • setState: 상태를 업데이트하는 함수. 이름은 보통 set 접두사 뒤에 상태 변수 이름을 붙여 만든다. (예: setCount, setName, setIsVisible)
  • initialState: 상태의 초기 값. 컴포넌트가 처음 렌더링될 때 한 번만 사용된다. 어떤 데이터 타입이든 가능하다(아래 참조).

2. 초기값 설정 (initialState)

초기값은 다양한 데이터 타입을 가질 수 있다.

  • 원시 타입 (Primitive Types):
    • const [count, setCount] = useState(0); // 숫자
    • const [name, setName] = useState(""); // 문자열
    • const [isLoading, setIsLoading] = useState(false); // 불리언
    • const [data, setData] = useState(null); // null
    • const [userId, setUserId] = useState(undefined); // undefined
  • 참조 타입 (Reference Types):
    • const [items, setItems] = useState([]); // 배열
    • const [user, setUser] = useState({ name: "", age: 0 }); // 객체
  • 지연 초기화 (Lazy Initial State): 초기값을 계산하는 비용이 크다면, 함수를 전달하여 처음 렌더링 시에만 함수가 실행되도록 할 수 있다.
    const [state, setState] = useState(() => {
      const initialValue = someExpensiveComputation(); // 비용이 큰 계산
      return initialValue;
    });
    

3. 상태 업데이트 (setState 함수)

상태를 변경하려면 setState 함수를 호출한다. 호출 방식은 두 가지가 있다.

  • 일반 업데이트 (Passing a New Value): 새로운 상태 값을 직접 전달한다.주의: 이 방식은 호출 시점의 state 값을 기준으로 동작한다. 짧은 시간 안에 여러 번 호출될 경우 문제가 발생할 수 있다 (배치 처리 섹션 참조).
    const [count, setCount] = useState(0);
    const [name, setName] = useState("Guest");
    
    const handleLogin = () => {
      setName("Alice"); // 직접 새 값을 전달
    };
    
    const handleReset = () => {
      setCount(0); // 직접 새 값을 전달
    };
    
  • 함수형 업데이트 (Passing a Function): **이전 상태 값(previous state)**을 인자로 받는 함수를 전달한다. 이 함수는 이전 상태 값을 기반으로 새로운 상태 값을 반환해야 한다.✨ 장점:
    • 최신 상태 보장: 여러 업데이트가 짧은 간격으로 발생해도, 각 업데이트는 바로 이전의 가장 최신 상태를 기반으로 계산된다. 비동기 상황이나 연속적인 업데이트에 안전하다.
    • 배치 처리 호환성: React의 배치 처리와 잘 동작하여 의도한 대로 상태가 순차적으로 변경된다.
    📌 언제 함수형 업데이트를 사용해야 할까?
    1. 새로운 상태가 이전 상태에 의존할 때: 카운터 증가/감소, 배열에 요소 추가 등. (setCount(prev => prev + 1))
    2. 짧은 시간 안에 여러 번의 상태 업데이트가 발생할 수 있을 때: 이벤트 핸들러 내에서 여러 번 setState를 호출하는 경우.
      const [count, setCount] = useState(0);
      
      const handleIncrease = () => {
        // 함수형 업데이트: prevCount는 현재 시점의 가장 최신 count 값
        setCount(prevCount => prevCount + 1);
      };
      
      const handleMultipleIncreases = () => {
        setCount(prev => prev + 1); // 이전 상태(0) -> 1
        setCount(prev => prev + 1); // 이전 상태(1) -> 2
        setCount(prev => prev + 1); // 이전 상태(2) -> 3
        // 최종적으로 count는 3이 됨
      };
      

4. 작동 원리 요약: 상태 변경과 렌더링

  1. useState(initialValue): 컴포넌트 초기 렌더링 시 상태 변수와 상태 설정 함수를 생성하고, 초기값을 할당한다. React는 이 상태를 내부적으로 기억한다.
  2. setState(newValue) 또는 setState(updaterFn): 상태 설정 함수가 호출된다.
  3. React 스케줄링: React는 상태 변경이 발생했음을 인지하고, 해당 컴포넌트의 **리렌더링을 예약(schedule)**한다. (바로 렌더링하지 않을 수 있음 - 배치 처리 참조)
  4. 리렌더링: React는 예약된 시점에 컴포넌트 함수를 다시 호출한다. 이때 useState는 가장 최신 상태 값을 반환한다.
  5. DOM 업데이트: React는 변경된 내용을 Virtual DOM을 통해 효율적으로 비교하고, 실제 DOM에는 최소한의 변경만 반영한다.

⚙️ useState와 Virtual DOM/최적화

useState는 단순히 상태를 저장하고 변경하는 것을 넘어, React의 효율적인 렌더링 메커니즘과 깊이 연관되어 있다.

1. Virtual DOM (가상 DOM)

React는 실제 DOM을 직접 조작하는 대신, 메모리상에 **가상 DOM 트리(Virtual DOM Tree)**라는 가벼운 복사본을 유지한다.

  • 기존 방식의 문제: 전통적인 자바스크립트 방식에서는 DOM 요소 하나하나를 직접 변경할 때마다 브라우저가 **리플로우(Reflow)**와 **리페인트(Repaint)**를 반복하며 성능 저하를 유발할 수 있었다.
  • React의 해결책:
    1. 상태 변경 발생 (setState 호출): useState가 관리하는 상태가 변경되면 React에게 신호를 보낸다.
    2. 새로운 Virtual DOM 생성: React는 변경된 상태를 반영하여 메모리상에 새로운 Virtual DOM 트리를 만든다.
    3. 비교 (Diffing): 이전 Virtual DOM 트리새로운 Virtual DOM 트리를 비교하여 실제로 변경된 부분만을 정확히 찾아낸다. (Diffing 알고리즘 사용)
    4. 재조정 (Reconciliation): 찾아낸 변경 사항들을 모아서(Batching) 실제 DOM에 한 번에 적용한다.

useState는 이 과정에서 **"어떤 상태가 변경되었으니 Virtual DOM 업데이트가 필요하다"**는 시작 신호를 제공하는 중요한 역할을 한다.

2. 최적화 (Optimizations)

useState와 React 렌더링 시스템은 불필요한 작업을 줄이기 위한 최적화 기능을 내장하고 있다.

  • 동일 값 업데이트 건너뛰기 (Skipping Same-Value Updates): setState 함수에 현재 상태와 동일한 값을 전달하면, React는 이를 감지하고 리렌더링 및 Virtual DOM 비교 과정을 생략한다. 이는 불필요한 연산을 방지한다.
    const [count, setCount] = useState(0);
    
    const updateToSame = () => {
      console.log("Updating to 0...");
      setCount(0); // 현재 count가 0이면, 이 호출은 리렌더링을 유발하지 않음
      console.log("Update attempted."); // 이 로그는 찍히지만, 컴포넌트 함수는 재실행되지 않음
    };
    
  • 배치 업데이트 (Batching): 하나의 이벤트 핸들러나 특정 컨텍스트 내에서 여러 번의 setState 호출이 발생하면, React는 이를 **하나의 그룹(배치)**으로 묶어서 처리한다. 즉, 모든 setState가 처리된 후 단 한 번의 리렌더링만 발생시킨다.React 18 이후: **자동 배치(Automatic Batching)**가 도입되어, setTimeout, Promise, 네이티브 이벤트 핸들러 등 이전에는 배치 처리가 되지 않던 비동기 상황에서도 대부분 자동으로 업데이트를 묶어서 처리해주어 성능이 더욱 향상되었다.
    function ProfileUpdater() {
      const [name, setName] = useState("Guest");
      const [age, setAge] = useState(0);
    
      const handleUpdateProfile = () => {
        console.log("Updating profile...");
        setName("Alice"); // 업데이트 1
        setAge(30);     // 업데이트 2
        // 두 업데이트가 배치 처리되어 리렌더링은 한 번만 발생
        console.log("Profile update scheduled.");
      };
    
      console.log("Component rendered"); // 리렌더링 시 이 로그 확인 가능
    
      return (
        <div>
          <p>Name: {name}, Age: {age}</p>
          <button onClick={handleUpdateProfile}>Update Profile</button>
        </div>
      );
    }
    

이러한 최적화 덕분에 개발자는 상태 변경 로직에 집중할 수 있으며, React가 내부적으로 효율적인 업데이트를 보장해준다.

💡 기본 예제 (Basic Example: Counter)

가장 대표적인 useState 사용 예제인 카운터 컴포넌트를 통해 전체 흐름을 살펴보자.

import React, { useState } from 'react'; // 1. useState 임포트

function Counter() {
  // 2. useState 호출: 초기값 0으로 count 상태와 setCount 함수 생성
  const [count, setCount] = useState(0);
  console.log("Counter component rendered with count:", count); // 렌더링 확인용

  // 3. 이벤트 핸들러: 버튼 클릭 시 호출될 함수
  const handleIncrease = () => {
    console.log("Increase button clicked. Current count:", count);
    // 4. 상태 업데이트: 함수형 업데이트 사용 (이전 값 + 1)
    setCount(prevCount => prevCount + 1);
    // React는 이 호출로 리렌더링을 예약함
  };

  const handleDecrease = () => {
    console.log("Decrease button clicked. Current count:", count);
    setCount(prevCount => prevCount - 1); // 감소
  };

  // 5. JSX 반환: 현재 count 값을 화면에 표시하고, 버튼에 핸들러 연결
  return (
    <div>
      <h1>카운터: {count}</h1>
      <button onClick={handleIncrease}>증가 (+)</button>
      <button onClick={handleDecrease}>감소 (-)</button>
    </div>
  );
}

export default Counter;

실행 흐름:

  1. 초기 렌더링: Counter 컴포넌트가 처음 마운트될 때 useState(0)이 호출되어 count는 0, setCount는 상태 업데이트 함수가 된다. 화면에는 "카운터: 0"이 표시된다. (console.log "Counter component rendered with count: 0" 출력)
  2. '증가' 버튼 클릭: handleIncrease 함수가 호출된다. (console.log "Increase button clicked..." 출력)
  3. 상태 업데이트 예약: setCount(prevCount => prevCount + 1)가 호출된다. React는 count 상태를 1로 업데이트해야 함을 인지하고 리렌더링을 예약한다.
  4. 리렌더링: React가 Counter 함수를 다시 호출한다. 이때 useState(0)은 가장 최근 상태인 1을 반환하여 count는 1이 된다. (console.log "Counter component rendered with count: 1" 출력)
  5. DOM 업데이트: React는 Virtual DOM 비교를 통해 h1 태그의 내용만 변경되었음을 파악하고, 실제 DOM의 해당 부분만 1로 업데이트한다. 사용자는 화면에서 "카운터: 1"을 보게 된다.

⚠️ useState를 사용하지 않을 경우의 문제점

만약 React에서 useState 훅을 사용하지 않고 동적인 UI를 구현하려고 한다면 어떤 문제들에 직면하게 될까?

  1. 상태 유지 불가 (Inability to Maintain State):
    • 앞서 설명했듯, 함수형 컴포넌트는 렌더링마다 내부 변수가 초기화된다. useState 없이 let이나 const로 선언된 변수는 컴포넌트가 다시 그려질 때마다 이전 값을 잃어버린다.
    • 사용자의 입력 값, 토글 상태, API로부터 받아온 데이터 등 지속적으로 기억해야 할 정보를 컴포넌트 내에서 안정적으로 관리할 수 없다. 카운터 예제에서 버튼을 눌러도 숫자가 증가하지 않고 계속 초기값으로 돌아가는 현상이 발생한다.
      // Counter 예제: useState vs 일반 변수 비교
      
      // 1. useState를 사용하는 올바른 방법
      function CounterWithState() {
        // useState를 통해 상태값과 상태 변경 함수를 생성
        // count: 현재 상태값
        // setCount: 상태를 업데이트하는 함수
        const [count, setCount] = useState(0);
      
        // 클릭 이벤트 핸들러
        // setCount를 통해 상태를 업데이트하면 자동으로 리렌더링됨
        const handleClick = () => {
          setCount(count + 1);
        };
      
        return (
          <div className="counter">
            <h2>useState 사용 예제</h2>
            <p>현재 카운트: {count}</p>
            <button onClick={handleClick}>증가</button>
          </div>
        );
      }
      
      // 2. 일반 변수를 사용하는 잘못된 방법
      function CounterWithoutState() {
        // 일반 변수는 컴포넌트가 리렌더링될 때마다 초기화됨
        let count = 0;
      
        // 클릭 이벤트 핸들러
        // 변수는 변경되지만 리렌더링이 발생하지 않아 UI에 반영되지 않음
        const handleClick = () => {
          count += 1;
          console.log('현재 카운트:', count); // 콘솔에는 증가된 값이 표시되지만
        };
      
        return (
          <div className="counter">
            <h2>일반 변수 사용 예제</h2>
            <p>현재 카운트: {count}</p> {/* 항상 0으로 표시됨 */}
            <button onClick={handleClick}>증가</button>
          </div>
        );
      }
      
      // 두 방식을 비교하는 메인 컴포넌트
      function ComparisonExample() {
        return (
          <div className="comparison-container">
            <h1>useState vs 일반 변수 비교</h1>
            <CounterWithState />
            <hr />
            <CounterWithoutState />
            
            {/* 참고 설명 */}
            <div className="note">
              <p>💡 위쪽 카운터는 클릭할 때마다 값이 증가합니다.</p>
              <p>💡 아래쪽 카운터는 클릭해도 화면에 표시되는 값이 변하지 않습니다.</p>
            </div>
          </div>
        );
      }
      
      export default ComparisonExample;

       

      버튼을 클릭해도 colsole.log 의 값은 증가하지만 실제 반영되지 않는 이유는 function CounterWithoutState() 의 렌더링이 실제론 한번만 이루어졌기 때문이다.

      2번 버튼 <button onClick={handleClick}>증가</button>과 연결된 함수는

      const handleClick = () => {
          count += 1;
          console.log('현재 카운트:', count); // 콘솔에는 증가된 값이 표시되지만
        };
      부분이기 때문에 렌더링부분과 아무런 연관이 없다. 때문에 변수 값만 증가하지 렌더링 되지 않기 때문에 화면은 바뀌지 않는다.

       

      반대로 1번 버튼과 연결된 함수는

       const handleClick = () => {
          setCount(count + 1);
        };

      부분이기 때문에 count + 1과 함께 리렌더링 되어 화면이 업데이트 된다.

      만약에 2번 버튼을 클릭시에도 리렌더링이 일어나도록 조작한다면 count가 반영된다.

  2. 수동 DOM 조작의 비효율성과 복잡성 (Inefficiency and Complexity of Manual DOM Manipulation):
    • 상태 변화를 화면에 반영하기 위해 document.getElementById()나 document.querySelector() 같은 바닐라 자바스크립트 DOM API를 직접 사용해야 한다.
    • 이는 React가 제공하는 선언적 프로그래밍의 이점을 포기하는 것이다. "어떻게(How)" DOM을 변경할지 모든 단계를 직접 명시해야 하므로 코드가 길고 복잡해지며 오류 발생 가능성이 높아진다.
    • React의 Virtual DOM과 배치 업데이트 최적화를 활용할 수 없게 된다. 빈번한 DOM 조작은 성능 저하의 주요 원인이 된다.
  3. React의 렌더링 시스템과 통합 불가 (Incompatibility with React's Rendering System):
    • React는 상태(state)나 속성(props)이 변경될 때 컴포넌트를 리렌더링하여 UI를 업데이트하는 방식으로 작동한다.
    • useState를 사용하지 않고 일반 변수를 변경하는 것은 React에게 "상태가 변경되었으니 리렌더링하라"는 신호를 보내지 못한다. 따라서 변수 값이 내부적으로 변경되더라도 화면에는 반영되지 않는다.
  4. 전역 변수 사용의 문제점 (Issues with Global Variables):
    • 상태 유지를 위해 전역 변수를 남용하게 될 수 있다. 이는 앞서 언급한 대로 상태 추적의 어려움, 예상치 못한 부작용, 컴포넌트 간 강한 결합(Coupling), 테스트의 어려움 등 많은 문제를 야기한다.

결론적으로, useState는 함수형 컴포넌트에서 상태를 안전하고 효율적으로 관리하며, React의 선언적 UI 업데이트 방식과 매끄럽게 통합되기 위해 필수적인 도구이다. useState가 없다면 React를 사용하는 근본적인 이점 중 많은 부분을 잃게 될 것이다.

💾 요약 (Summary)

  • useState는 React 함수형 컴포넌트에서 **상태(state)**를 추가하고 관리하기 위한 **기본 훅(Hook)**이다.
  • [state, setState] = useState(initialState) 형태로 사용하며, 현재 상태 값상태를 업데이트하는 함수를 반환한다.
  • 함수형 컴포넌트가 리렌더링되어도 상태 값을 유지시켜주며, 지역 변수 초기화 문제를 해결한다.
  • setState 함수 호출은 React에게 리렌더링이 필요함을 알리는 신호 역할을 한다.
  • **함수형 업데이트 (setState(prev => ...))**는 이전 상태를 기반으로 값을 안전하게 업데이트할 때 유용하다.
  • React의 Virtual DOM, Diffing, Reconciliation, Batching 메커니즘과 연동하여 효율적인 DOM 업데이트를 가능하게 한다.
  • useState를 사용하지 않으면 상태 유지, 효율적인 렌더링, 코드 관리 측면에서 많은 어려움이 발생한다.

useState는 React에서 동적인 사용자 인터페이스를 구축하는 데 있어 가장 기본적이면서도 강력한 도구 중 하나이다.