useState 동작원리?

useState의 동작원리
avatar
2025.02.12
·
16 min read

📌 useState란?

  • React에서 제공하는 여러가지 hook중 하나로, React 컴포넌트 내부에서 상태를 관리할때 사용한다.

  • 주로 화면 렌더링에 영향을 주는 변수를 선언할때 사용한다.

useState는 구조 분해 할당을 통해 다음 두 가지 아이템을 받는다.

  1. 상태변수: 현재 값(state)

  2. 상태 업데이트 함수: 상태변수를 변경할 때 사용하는 함수(setState)

const [state, setState] = useState(initialState);

useState의 기본적인 동작방식

state에 해당하는 값은 첫 렌더링 때 useState의 인자로 받은 초기값(initialState)를 통해서 상태를 할당한다.

이후 setState을 통해 다른 값을 주입해 상태를 업데이트 한 후 리렌더링한다.


📌 상태 업데이트 함수 인자에 들어오는 값들

1. 일반 값

const [count, setCount] = useState(0);

  // ...

   setCount(count+1); // 상태변수값을 1로 변경

2. 콜백함수

const [count, setCount] = useState(0); // 제네릭 생략가능

   //...

   setCount((count)=>count+1) // 상태변수값을 1로 변경

인자에 어떤 값들이 들어오든 둘다 같은 값을 반환하고 있다.

이 둘은 정확히 어떤 차이가 있는 걸까? 🤔

지금부터 알아보도록 하자!


상태 업데이트 함수에 일반 값을 넣을 때

const [count, setCount] = useState(0);

// 일반 값을  인자에 전달

setCount(count + 1);

특징

  • 현재 상태 값에 직접 접근해서 새로운 값을 전달.

  • 상태 업데이트 함수는 전달받은 값을 상태로 설정.

  • 간단한 연산이나 상태 변경에 적합.

  • 비동기 상태 업데이트 시 문제 발생

상태 업데이트 함수에 콜백 함수를 넣을 때

const [count, setCount] = useState(0);

// 콜백 함수를 인자에 전달

setCount((prevCount) => prevCount + 1);

특징

  • 콜백 함수의 인자로 이전 상태값 (prevCount)을 전달받아, 그 값을 기반으로 새로운 상태를 계산.

  • 비동기적 상태 업데이트가 발생할 때 안정적으로 상태 계산 가능

우리는 여기서 비동기적 상태 업데이트라는 말에 집중해봐야한다.

비동기적 상태 업데이트란 무엇일까?


비동기적 상태 업데이트

React의 상태 업데이트 함수는 기본적으로 비동기적으로 처리된다.

만약 상태 업데이트가 동기적으로 처리된다면, 상태를 변경할 때마다 컴포넌트가 리렌더링이 일어날 것이고 이는 성능저하를 야기할 수 있을 것이다.

(상태 업데이트 함수의 실행이 끝날 때까지 다른 작업은 대기할 수 밖에 없고, 불필요한 렌더링이 발생)

그러므로 상태 업데이트 함수는 비동기적으로 동작하도록 설계되어 있다.


+) 강제로 동기처리 하고싶은 경우

배치 업데이트가 기본 적용되더라도, 강제로 동기 렌더링을 해야 한다면 flushSync를 사용할 수 있다.

import { flushSync } from "react-dom";

setTimeout(() => {

  flushSync(() => {

    setCount((prev) => prev + 1);

  });

  flushSync(() => {

    setCount((prev) => prev + 1);

  });

  // 렌더링이 두 번 발생

}, 0);

flushSync 는 업데이트 Queue에 담아둔 상태 업데이트 함수와 함께 동기적으로 상태를 업데이트 한다.

다만 이 방법은 성능 저하가 일어날 수 있다는 단점이 있기 때문에 사용에 유의해야한다.


그렇다면 어떻게 비동기적으로 동작할 수 있는 것일까?

배치 업데이트

React는 효율적인 렌더링을 하기위해 배치 업데이트(batch update)라는 최적화 기법을 사용한다.

배치 업데이트란?

React는 모든 이벤트 핸들러가 종료된 후에 상태를 업데이트한다. 이를 배치 업데이트라고 한다.

장점

불필요한 렌더링을 줄이고, 성능을 향상시킬 수 있다.

setState 동작 원리

배치 업데이트 기법에 의해 상태 업데이트가 즉시 일어나지 않고 업데이트 큐에 쌓이게 된다.

업데이트 큐란 상태 업데이트 함수가 담기는 큐를 말한다.

이후 랜더링 사이클이 끝나면 업데이트 큐에 있는 모든 상태가 업데이트되고 변경된 부분만 실제DOM에 적용하여 리렌더링이 되는 것이다.

이를 통해 가상돔을 활용한 렌더링 과정을 다시 한번 이해할 수 있다.

3378

아래 코드를 보자.

const [count, setCount] = useState(0);

setState(state + 1); // ✔ 0 + 1

setState(state + 2); // ✔ 0 + 2

setState(state + 3); // ✔ 0 + 3

리렌더링이 일어날때 최종적으로 count에는 3이 저장된다.

배치 업데이트에 의해서 count가 리렌더링 되기 전까지 상태가 업데이트되지 않고

0의 값(현재 상태값)을 가지기 때문임을 알 수 있다.

React에서는 이를 스냅샷을 찍는다고 얘기하곤 한다.

3379
Rachel Lee Nabors
Rachel Lee Nabors is a seasoned American technologist, investor, and community builder residing in London.
https://nearestnabors.com/

결론

상태 업데이트 함수가 실행됐다 해도 리렌더링 이전엔 상태 업데이트가 반영되지 않음을 알고 있어야한다.


📌 불변성?

흔히 React에선 불변성을 유지해야 한다고 자주 언급한다. 그 이유는 JS의 메모리 구조에서 비롯된다.

useState에 배열을 담고 상태 업데이트 함수를 통해 값을 추가할 때

보통 concat 혹은 spread 연산자를 사용한다. 만약 push를 사용하면 리렌더링이 일어나지 않는다.

왜냐하면 React에서 리렌더링이 일어나는 조건은 변수를 재할당하여 콜 스택의 메모리가 변할 때 발생하기 때문이다.

객체나 배열은 값이 추가되거나 삭제되어도 콜 스택의 메모리는 바뀌지 않는다.

객체에 할당된 콜 스택의 메모리는 객체의 주소값을 참조하고 있기 때문이다.

즉, 객체의 내부 값을 변경하더라도 참조하고 있는 객체의 주소값은 변하지 않는다.

  • push: 객체의 값을 바꾼다. 이때 메모리 주소는 유지한 채 메모리 값만 바꾼다.

    즉 콜스택이 가리키는 메모리 주소는 동일하다.

  • concat,Spread 연산자: 새로운 객체를 생성하므로 새로운 메모리 영역을 부여 받는다.

    그리고 이전에 사용한 메모리 영역은 가비지 콜렉터에 의해 제거된다.

3380

React 공식 사이트에서는 불변성을 지키기위한 가이드를 제공한다

Updating Arrays in State – React
The library for web and native user interfaces
https://react.dev/learn/updating-arrays-in-state#updating-arrays-without-mutation


📌 상태 업데이트 함수는 클로저로 동작한다?

우리는 useState를 사용하여 여러 상태를 저장한다. 그렇다면 상태를 어떻게 저장하는 걸까?

useState는 각 함수마다 고유의 상태값을 기억하고 있다. React에선 이를 클로저를 통해 해결한다.

useState 함수의 내부 구조를 추측해보자.

const [count,setCount] = useState(0);

  function useState(initVal) {

    let pVal = initVal; // pVal은 자유 변수 역할

    const state = () => pVal; // pVal 참조

    const setState = (newVal) => {

      pVal = newVal;

    }

    return [state, setState]

  }

초기값인 initVal을 Parameter로 받아 private 변수인 pVal에 저장한다.

이제 pVal은 함수 내부의 state, setState로만 참조가 가능하다.

클로저를 통해 함수가 선언될 당시 값을 기억하여 사용하는 원리임을 알 수 있다.

그러나 만약 여러 useState를 사용할 경우 각각 독립적인 상태여야 한다. 만약 위와 같은 구조라면 하나의 pVal만 존재하기 때문에 여러 상태를 구현할 수 없을 것이다.

배열과 인덱스를 도입하여 해결

// hooks 배열 안에 상태를 저장

let hooks = [];

let idx = 0;  // idx값은 내부동작에 의해 1씩 증가

function useState(initVal) {

	const state = hooks[idx] || initVal;

	const _idx = idx;

	const setState = (newVal) => {

		hooks[_idx] = newVal;

	}

	idx++;

	return [state, setState]

}
  1. 첫 useState가 실행될 때 고유의 인덱스 값을 부여 받는다.

  2. 인덱스를 통해 hooks 배열에 상태를 저장하고 이후에 useState가 사용될 때마다 hooks[_idx]를 참조하여 상태를 읽는다.

  3. 이러한 원리로 useState는 선언될 때마다 독립된 상태를 가질 수 있게 된다.

hooks 규칙?

앞서 살펴봤듯이 useState는 각각의 고유한 상태를 hooks 배열에 저장하고 상태 업데이트 함수는 업데이트 Queue를 통해 상태를 업데이트 한다.

이 동작 원리를 지키기 위하여 React에선 Hooks 규칙을 도입하였다.

Hook의 규칙 – React
A JavaScript library for building user interfaces
https://ko.legacy.reactjs.org/docs/hooks-rules.html

최상위에서만 호출

반복문, 조건문 혹은 중첩된 함수 내에서 Hook을 호출할 수 없다.

항상 React 함수의 최상위에서 Hook을 호출해야 한다.

useState의 실행이 조건에 따라 다르다면, 컴포넌트가 렌더링 될 때마다 Hook의 실행 순서가

달라질 수 있기 때문이다.

React는 컴포넌트가 렌더링될 때마다 Hook을 동일한 순서로 호출할 것을 가정하고 상태를 추적한다.

만약 Hook 호출 순서가 바뀌면, React는 어떤 상태가 어떤 Hook에 연결돼 있는지 혼란스러워한다.

결과적으로 React는 상태와 Hook을 잘못 매핑하게 되고, 앱이 제대로 작동하지 않을 수 있다.

function Counter() {

  function handleClick() {

	  // ❌ 함수 내부에서 선언한다면, hook의 실행 순서가 달라질 수 있다.

	  // ❌ handleClick이 실행될 때마다 새로운 count 상태가 생성되고 초기화 된다.

    const [count, setCount] = useState(0);

    setCount(count + 1);

  }

  return (

    <div>

      <p>Count: {count}</p>

      <button onClick={handleClick}>Increment</button>

    </div>

  );

}


📌 콜백 함수가 더 안전한 이유

우리는 앞에서 useState의 동작 원리에 대해서 자세히 알아보았다.

이를통해 이전 상태 값이 다음 상태 값에 영향을 미쳐야하는 경우에

상태 업데이트 함수에 콜백 함수를 사용하는 것이 더 안전한 이유를 알 수 있다.

일반 값으로 상태를 업데이트하면 생기는 문제 😈

React는 상태값을 즉시 업데이트하지 않기 때문에,

일반 값이 들어올 경우 상태는 현재 렌더링 시점의 상태값을 참조한다.

import { useState } from "react";

export default function App() {

  const [count, setCount] = useState(0);

  console.log(count);

  const handleClick = () => {

    setCount(count + 1);  // count === 0이므로 1로 예약

    setCount(count + 1); //   여전히 count === 0이므로 다시 1로 예약

  };

  return (

    <>

      <button onClick={handleClick}>클릭</button>

    </>

  );

}

// 결과 count= 1 (2가 되지 못함)

콜백 함수로 해결

const handleClick = () => {

    setCount((prevCount) => prevCount + 1); // 이전상태 + 1

    setCount((prevCount) => prevCount + 1); //  이전 상태 + 1

  };

// 결과: count = 3 (정상 작동!)

콜백함수를 사용하면 이전 상태를 안전하게 참조하기 때문에

리렌더링이 일어나 이전 상태값을 참조하여 상태를 업데이트한다.


참고 사이트

useState 동작 원리와 클로저 - 사툰사툰 REACT
꾸준히 성장하고 싶은 프론트엔드 엔지니어입니다. 저만의 경험과 기록을 담아두었습니다 | Error Typescript Frontend React Next.js Nginx
https://jaehan.blog/posts/react/useState-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EC%99%80-%ED%81%B4%EB%A1%9C%EC%A0%80







- 컬렉션 아티클