PromleeBlog
sitemap
aboutMe

posting thumbnail
useState와 setState 상태 관리의 원리 - React, 알고 쓰자 2일차
Deep Dive into useState & setState State Management Secrets - React Explained Day 2

📅

🚀

들어가기 전에 🔗

React 원리 파악 2일차 입니다. 어제는 React가 화면을 그리고 업데이트하는 큰 그림, 즉 렌더링 파이프라인과 Fiber 아키텍처에 대해 알아보았죠. 오늘은 그 과정에서 아주 중요한 역할을 하는 '상태(State)'를 관리하는 방법, 그중에서도 가장 기본적이면서 핵심적인 useState 훅에 대해 깊이 탐구해볼 시간입니다.
컴포넌트가 기억해야 하는 값, 그리고 그 값이 변했을 때 화면을 다시 그리게 만드는 힘. 이것이 바로 상태의 역할인데요, useState는 함수형 컴포넌트에서 이런 상태를 아주 간편하게 사용할 수 있도록 도와줍니다. 하지만 단순히 사용하는 것을 넘어,
어떻게
동작하는지 그 원리를 알면 더욱 강력하고 효율적인 코드를 작성할 수 있겠죠?!
오늘은 useState가 내부적으로 상태를 어떻게 기억하고, setState 함수가 어떻게 상태 업데이트를 처리하는지, 그 비밀스러운 과정들을 하나씩 파헤쳐 보겠습니다. 중간중간 면접 단골 질문들도 포함되어 있으니, React 면접 준비하시는 분들께도 많은 도움이 될 거예요.

🚀

useState, 어떻게 동작할까요? 🔗

가장 먼저, 우리가 useState를 사용할 때 React 내부에서는 어떤 일이 일어나는지 알아봅시다.
import React, { useState } from 'react';
 
function Counter() {
  const [count, setCount] = useState(0); // useState 사용!
    // ... 컴포넌트 로직 ...
    return (
    // ... JSX ...
  );
}
위 코드처럼 useState(0)를 호출하면, count라는 상태 변수와 setCount라는 상태 업데이트 함수를 얻게 됩니다. 그런데 React는 여러 컴포넌트, 또는 한 컴포넌트 내의 여러 useState 호출을 어떻게 구분하고 각자의 상태를 정확히 기억할 수 있을까요?

훅 호출 순서 🔗

React 훅에는 중요한 규칙이 있습니다.
👨‍💻
훅은 항상 최상위 레벨에서만 호출되어야 하며, 반복문이나 조건문, 중첩 함수 내에서 호출하면 안 된다
왜 이런 규칙이 있을까요?
그 이유는 React가 훅의 상태를
호출 순서
에 의존하여 관리하기 때문입니다. React는 컴포넌트가 렌더링될 때마다 훅이 항상 동일한 순서로 호출될 것이라고 가정합니다.
Q: "React 훅의 규칙은 무엇이고, 왜 중요한가요?"
A: "훅은 최상위 레벨에서만 호출되어야 하며, 이는 React가 훅의 상태를 호출 순서에 기반하여 관리하기 때문입니다. 순서가 달라지면 상태가 잘못 연결될 수 있습니다."

Hook 객체와 연결 리스트: React는 훅을 어떻게 기억할까? 🔗

React는 내부적으로 각 함수형 컴포넌트에 대한 정보를 Fiber 노드에 저장한다고 어제 배웠습니다. 그리고 각 Fiber 노드는 자신이 가지고 있는 훅들의 정보를
연결 리스트(Linked List)
형태로 관리합니다.
➡️

연결 리스트 🔗

데이터(값)와 다음 데이터 조각을 가리키는 참조(포인터)를 함께 묶은 노드들이 기차처럼 줄줄이 연결된 자료구조입니다. 배열처럼 순서가 있지만, 메모리상에 연속적으로 위치하지 않아도 되고 중간에 삽입/삭제가 용이하다는 특징이 있습니다.

컴포넌트가 처음 렌더링될 때 useState가 호출되면, React는 해당 훅에 대한 정보(Hook 객체)를 생성하고 이 연결 리스트에 추가합니다. 이 Hook 객체에는 현재 상태 값(memoizedState), 업데이트 대기열(queue) 등의 정보가 들어있습니다.
Hook 객체 연결 리스트
Hook 객체 연결 리스트
컴포넌트가 다시 렌더링될 때는, React는 이 저장된 연결 리스트를 순서대로 탐색하면서 각 useState 호출에 맞는 Hook 객체를 찾아 현재 상태 값을 반환합니다. 만약 훅 호출 순서가 달라진다면, React는 잘못된 Hook 객체를 참조하게 되어 예상치 못한 버그가 발생하게 되는 것이죠.

memoizedState: 현재 상태값 저장 🔗

각 Hook 객체 안에 있는
memoizedState
속성이 바로 해당 useState 훅의
현재 상태 값
을 저장하는 곳입니다. 컴포넌트가 처음 렌더링될 때 useState(초기값)의 초기값이 여기에 저장됩니다. 이후 setState 함수를 통해 상태 업데이트가 발생하고 커밋 단계에서 반영되면, 이 memoizedState 값이 새로운 값으로 갱신됩니다.
const [count, setCount] = useState(0); // count의 초기값은 0
// setCount(1) 호출 시, Hook 객체의 memoizedState가 1로 업데이트됨

🚀

상태 업데이트 요청 setState 🔗

useState가 상태를 기억하는 방법을 알았으니, 이제
setState
함수 (예: setCount)가 호출되었을 때 어떤 일이 벌어지는지 살펴봅시다. 단순히 상태 값을 바로 바꾸는 것 같지만, 실제로는 더 정교한 과정을 거칩니다.

Update Queue: 업데이트 요청 저장하기 🔗

setState 함수가 호출되면, React는 즉시 상태를 변경하지 않습니다. 대신, '상태를 이렇게 바꿔주세요'라는
업데이트 요청(Update)
객체를 만들어서 해당 Hook 객체의
queue
속성에 추가합니다. 이 queue는 업데이트 요청들을 순서대로 처리하기 위한 대기열이며, 이 역시 단일 연결 리스트(Singly Linked List) 구조로 구현되어 있습니다.
여러 개의 Update 객체를 연결 리스트 형태로 가지는 Queue
여러 개의 Update 객체를 연결 리스트 형태로 가지는 Queue
React는 다음 렌더링 주기(Render Phase)에 이 Update Queue를 처리하여 최종 상태 값을 계산합니다. 이렇게 업데이트를 바로 적용하지 않고 모아서 처리하는 방식 덕분에 여러 상태 업데이트를 효율적으로 관리하고, 이전 포스팅에서 배웠던 동시성(Concurrency) 기능을 구현할 수 있습니다.

함수형 업데이트: 안전하게 이전 상태 사용하기 🔗

setState 함수에는 값을 직접 전달하는 대신, 함수를 전달할 수도 있습니다. 이를
함수형 업데이트
라고 부릅니다.
// 값으로 업데이트
setCount(count + 1); // 이전 count 값에 의존
// 함수형 업데이트
setCount(prevCount => prevCount + 1); // 이전 상태를 인자로 받아 새 상태 반환
함수형 업데이트는 언제 유용할까요? 바로
이전 상태 값에 기반하여 다음 상태를 결정
해야 할 때입니다. 특히 짧은 시간 안에 여러 번의 상태 업데이트가 예약되는 경우(배치 처리될 때), 함수형 업데이트를 사용하면 각 업데이트가 실행되는 시점의
가장 최신 상태
를 안전하게 참조하여 상태를 계산할 수 있습니다.
만약 setCount(count + 1)을 여러 번 호출하면, 각 호출 시점의 count 값은 동일할 수 있기 때문에 예상대로 동작하지 않을 수 있습니다.
하지만 setCount(prevCount => prevCount + 1)을 사용하면, React는 Update Queue를 처리할 때 각 업데이트 함수를 순서대로 실행하면서 이전 업데이트 결과가 반영된 최신 상태(prevCount)를 다음 함수에 넘겨주므로 항상 정확한 결과를 보장합니다.

배치(Batching): 업데이트 모아서 처리하기 🔗

React는 성능 최적화를 위해 여러 setState 호출을 하나로 묶어서 처리하는
배치(Batching)
기능을 가지고 있습니다. 예를 들어, 하나의 이벤트 핸들러 내에서 setState를 여러 번 호출하더라도, React는 이를 모아서 단 한 번의 리렌더링만 수행합니다.
function handleClick() {
  setCount(count + 1); // 업데이트 요청 1
  setSomethingElse(false); // 업데이트 요청 2
  // React는 이 두 업데이트를 모아서 처리하고, 리렌더링은 한 번만 발생
}
배치 과정
배치 과정
import { flushSync } from 'react-dom';
function handleClick() {
  flushSync(() => {
    setCount(count + 1); // 즉시 렌더링
  });
  setSomethingElse(false); // 자동 배치로 묶임
}

🚀

훅 setState vs 클래스 setState 🔗

혹시 클래스형 컴포넌트를 사용해보셨다면, 거기에도 this.setState()라는 상태 업데이트 메서드가 있다는 것을 아실 겁니다. 함수형 컴포넌트의 setState와 어떤 점이 비슷하고 다를까요?

병합 vs 교체 🔗

➡️

클래스 setState 🔗

여러 상태 값을 객체 하나로 관리하며, setState 호출 시 전달한 객체와 기존 상태 객체를
얕게 병합(shallow merge)
합니다. 즉, 변경하려는 속성만 업데이트하고 나머지는 그대로 유지합니다.
// 클래스 컴포넌트 예시
this.state = { count: 0, name: 'React' };
this.setState({ count: 1 }); // { count: 1, name: 'React' } 로 병합됨
➡️

훅 setState 🔗

useState는 보통 단일 값(숫자, 문자열, 불리언 등)이나 객체/배열 하나를 관리합니다.
setState 호출 시 전달한 값으로 기존 상태를
완전히 교체(replace)
합니다. 만약 객체 상태의 일부만 업데이트하려면, 직접 이전 상태를 기반으로 새로운 객체를 만들어 전달해야 합니다.
// 함수형 컴포넌트 예시
const [userInfo, setUserInfo] = useState({ name: 'React', age: 8 });
// 잘못된 예: setUserInfo({ age: 9 }); // { age: 9 } 로 교체되어 name 정보 사라짐
// 올바른 예:
setUserInfo(prevInfo => ({ ...prevInfo, age: 9 })); // 이전 상태를 복사하고 age만 덮어씀

비동기 동작의 유사점과 차이점 🔗

두 방식 모두 setState는 기본적으로
비동기적
으로 동작할 수 있습니다. 즉, setState 호출 직후에 상태가 바로 반영되지 않을 수 있습니다. 이는 주로 배치 처리 때문입니다.
클래스 setState
: 업데이트 이후에 특정 작업을 수행해야 할 경우, 두 번째 인자로 콜백 함수를 전달할 수 있습니다.
this.setState({ count: 1 }, () => console.log('상태 업데이트 완료:', this.state.count));`
훅 setState
: 별도의 콜백 함수 인자는 없습니다. 대신 useEffect 훅을 사용하여 상태 변경 후에 실행될 로직을 작성하는 것이 일반적입니다.
useEffect(() => {
  console.log('count 상태 변경됨:', count);
}, [count]); // count 상태가 변경될 때마다 이 효과 함수 실행

🚀

useState 기타 활용법 🔗

useState를 더 효과적으로 사용하는 몇 가지 팁을 알아봅시다.

초기 상태 지연 계산 (Lazy Initial State) 🔗

useState의 초기값은 컴포넌트가 처음 렌더링될 때만 사용됩니다. 그런데 만약 초기값을 계산하는 데 비용이 많이 드는 작업(예: 복잡한 연산, localStorage 접근)이 필요하다면 어떻게 될까요?
// 매 렌더링마다 computeExpensiveValue 함수가 호출될 수 있음 (비효율적)
const [value, setValue] = useState(computeExpensiveValue());
이 경우, useState에 함수를 전달하여
초기 상태를 지연 계산
할 수 있습니다. 이렇게 하면 초기값을 계산하는 함수는 오직 맨 처음 렌더링될 때 한 번만 호출되어 불필요한 계산을 피할 수 있습니다.
// computeExpensiveValue 함수는 최초 렌더링 시에만 호출됨
const [value, setValue] = useState(() => {
  const initialValue = computeExpensiveValue();
  return initialValue;
});
Q: "Lazy initial state가 무엇이고 언제 사용해야 하나요?"
A: "초기 상태값을 계산하는 비용이 비쌀 때, useState에 함수를 전달하여 최초 렌더링 시에만 계산하도록 하는 최적화 기법입니다."

상태 업데이트와 렌더링 스케줄러 - Fiber와의 관계 🔗

setState가 호출되어 Update 객체가 생성되면, React Fiber 아키텍처의
스케줄러
(Scheduler)가 이 업데이트의 우선순위를 판단하고 렌더링 작업을 예약합니다. 어제 배운 것처럼, Fiber는 작업을 작은 단위로 나누고 우선순위에 따라 처리할 수 있습니다.
사용자 입력에 대한 반응과 같이 높은 우선순위의 업데이트는 즉시 처리될 가능성이 높고, 덜 긴급한 업데이트는 다른 작업 중간이나 브라우저가 유휴 상태일 때 처리될 수 있습니다.
React 18의 동시성 기능(startTransition 등)은 개발자가 직접 업데이트의 우선순위를 낮출 수 있게 하여, 중요한 상호작용이 부드럽게 유지되도록 돕습니다.

🚀

결론 🔗

오늘은 함수형 컴포넌트의 심장과도 같은 useState 훅과 setState 함수의 내부 동작 원리를 자세히 살펴보았습니다.
useState의 동작 원리를 이해하는 것은 React 컴포넌트의 동작 방식을 더 깊이 이해하고, 발생할 수 있는 문제를 예측하며, 성능을 최적화하는 데 큰 도움이 됩니다. 특히 훅의 규칙, 연결 리스트 구조, Update Queue, 배치, Lazy Initial State 등은 잘 기억해두시면 좋겠습니다.
다음 시간에는 React 18의 핵심 변화 중 하나인
자동 배칭
(Automatic Batching)과
동시성
(Concurrency)에 대해 더 자세히 알아보겠습니다. 상태 업데이트가 실제로 어떻게 묶이고 처리되는지, 그리고 동시성이 어떻게 사용자 경험을 향상시키는지 함께 알아볼 예정입니다.

참고 🔗