[React] 상태에 대해 알아보자

또이·2024년 10월 28일

💡상태에 대해 알아보자.

상태는 React에서 가장 중요한 개념 중 하나이다.

React의 성능을 위해서는 리렌더링을 방지해야 하고, 이를 위해서는 상태관리를 올바르게 해야하기 때문이다.

React에서 상태가 과연 무엇인지 한번 딥다이브 해보고 싶어서 글을 작성하게 되었다.

🤔상태가 뭐야?

상태는 컴포넌트 내부의 데이터 값으로,

바뀔 수 있는 동적인 값을 말한다.

즉, 사용자와 상호작용할 때 사용하는 값으로 생각할 수 있다.

상태는 웹 애플리케이션을 렌더하는데 있어서 영향을 미치는데,

이 변화를 감지해서 화면에 영향을 주기 위해 사용한다.

상태의 종류

상태는 사용되는 곳을 기준으로 크게 3가지로 나눠볼 수 있다.

  1. 전역 상태

    : 프로젝트 전체에 영향을 끼치는 상태

  2. 컴포넌트 간 상태

    : 여러가지 컴포넌트에서 관리되는 상태

  3. 지역 상태

    : 특정 컴포넌트 안에서만 관리되는 상태


관리해야 하는 상태?

개발자가 관리해야 하는 상태는 무엇이 있을까?

  • 사용자 인증 정보 : 로그인 여부에 따라 로직이 달라지기 때문에 필요하다.
  • 앱 설정 정보 : 앱 전체에 사용되는 변수들을 관리해야 한다.
  • 앱 상태 정보 : 현재 앱이 작동하고 있는지, 로딩 중인지, 오류가 발생했는지에 맞는 조치가 다르기 때문에 필요하다.
  • 서버에서 가져오는 데이터 : 렌더링에 사용되고, 사용자의 요청에 따라 자주 업데이트되기 때문에 관리해야 한다.
  • 컴포넌트 상태 정보 : 컴포넌트에서 관리해야 하는 로컬 상태 정보로, 예를 들어 토글 상태가 있다.

🧐상태 관리가 왜 필요한데?

관리해야 하는 상태가 무엇인지 알아보았다.

그런데 왜 상태 관리가 필요하다는 것일까?

각 컴포넌트 간의 직접적인 데이터 전달이 어렵기 때문에,

  1. 부모 컴포넌트에 보내고
  2. 다시 해당 상태 데이터를 필요한 컴포넌트로 전달해야 한다.

이런 과정을 Props Drilling이라고 하는데,

깊이 5 이상이라면 Prop의 출처와 Context를 찾기 힘들다.

따라서 복잡한 시스템을 다룰 때에는

어떻게 구동되는지 예측 가능하도록 상태 관리를 해야 한다.

상태 관리를 잘하면

  1. 유지보수가 쉽고,
  2. 데이터 공유가 용이해서 중복 코드를 방지할 수 있으며,
  3. 불필요한 렌더링과 네트워크 요청을 최소화하여 성능 최적화가 가능하다.

⚖️상태를 관리하는 법

Context API

React Context 자체는 상태 관리가 아니지만,

Hook과 함께 사용하면 상태관리 솔루션이 될 수 있는데,

useReducer 조합으로 많은 UI 상태를 해결할 수 있다.

Props Drilling을 해결하는 방법으로 사용되기도 한다.

그렇지만 context 값이 변경되면 해당 값을 소비하는 모든 컴포넌트는 다시 렌더링되기 때문에

성능 저하의 문제가 발생하기도 한다.

useMemo, React.momo 를 활용하여 메모이제이션을 통해 리렌더링을 줄일 수 있으나, 작업량이 많아지며 이러한 작업 없이 대체할 수 있는 라이브러리들이 많다.


상태 머신

상태 머신은 시간에 따른 명시적 모델이다.

시간에 따른다는 것은

예를 들어, 신호등이 녹색→노란색→빨간색으로 바뀌지만 녹색→빨간색으로 바뀌지 않는 것과 같다.

유한 상태 머신은 컴퓨터 과학 개념으로 React와 관련된 것은 아니지만,

상태 관리 문제를 해결할 수 있다.

특히 form 상태를 관리하는데 사용된다.

switch (state) {
  case state === 'loading':
    // 로딩 화면 출력
    break;
  case state === 'success':
    // 성공한 화면 출력
    break;
  default:
  // 에러 화면 출력
}

상태관리 라이브러리

상태관리 라이브러리가 존재하는 이유가 무엇일까?

로컬 상태 외부의 다른 요소에 영향을 미치는 컨트롤 툴바가 있거나,

구성 요소가 렌더링되는 곳이 있다.

이렇게 복잡한 상태관리 솔루션이 필요할 때,

성능과 프레임 속도는 UX에 영향을 끼치기 때문에 언제 어떻게 다시 렌더링할지를 제어할 수 있어야 한다.

이렇게 상태 관리 공간에서 많은 탐색을 할 수 있도록 상태 관리 라이브러리가 필요하다.


아래는 Zustand, Jotai, Valtio를 개발한 다이시 카토가 상태관리 라이브러리의 차이점을 요약한 것이다.

  • Valtio : 프록시를 사용하여 돌연변이 스타일 API를 제공
  • Jotai : "계산된 값" 및 비동기 작업에 최적화
  • Zustand : 모듈 상태에 특별히 초점을 맞춘 매우 얇은 라이브러리
  • Recoil : 데이터 흐름 그래프를 사용하는 실험적 라이브러리

🔄state와 리렌더링

state가 setState, dispatch 등으로 교체되면 리렌더링이 발생한다.

교체가 되는 컴포넌트는 무조건 렌더링이 이루어지고,

하위 컴포넌트들도 렌더링 queue에 들어가게 된다.

state는 불변성을 유지해야 한다.

아래와 같은 코드에서 handleClick에서는 리렌더링이 일어나지 않고,

handleClick2에서만 리렌더링이 일어난다.

const [array, setArray] = useState(["1", "2"]);
  
  const handleClick = () => {
    array.push("push")
    setArray(array);
  };
  
   const handleClick2 = () => {
    setArray(array.concat("concat"));
  };

이유는 아래와 같다.

리렌더링은 변수를 재할당하여 콜 스택의 메모리가 변할 때 발생하는데,

객체나 배열은 힙 영역에 저장하기 때문에 객체에 할당된 콜 스택 메모리는 힙 영역의 주소값이다.

객체나 배열의 값이 추가되거나 삭제되어도(push의 경우) 콜 스택이 가리키는 메모리 주소는 동일하다.

concat이나, spread 연산자를 사용하면 새로운 객체를 생성하기 때문에

새로운 메모리 영역을 부여받아 주소값이 변하기 때문에 리렌더링이 발생하는 것이다.


“불변성을 유지해야 한다” == “상태 변경을 추적하기 쉬워야 한다”

재할당 이외에 변수 값을 변경할 수 있다면 값이 의도와는 다르게 변경이 가능하다.

리렌더링의 기준이 모호해지지 않도록 불변성을 유지해야 한다.


Reconciliation

React는 상태가 변경될 때마다 VDOM을 업데이트한다.

이때 상태 변경에 따라 실제 DOM에 반영될 최소한의 변경 사항을 추적하는 역할을 한다.

이렇게 VDOM을 사용하면 리렌더링 시 성능을 최적화할 수 있다.


Reconciliation의 과정

  1. VDOM 트리 생성
  2. Diffing 알고리즘 적용
  3. DOM 업데이트

리액트가 컴포넌트를 다시 렌더링 하는 과정

  1. React가 함수 호출
  2. 스냅샷 계산
  3. DOM 트리 업데이트

상태 변화가 많을수록 이 방식의 효과는 더욱 커진다.

특히 Snap State와 같은 기능을 통해 중간 상태가 아닌 최종 상태만 반영하는 방식으로 최적화가 된다.


📸Snap State

state 변수는 읽고 쓸 수 있는 JS 변수처럼 보일 수 있다.

하지만, state는 스냅샷처럼 동작한다.

state 변수를 설정해도 이미 갖고 있는 state 변수는 변경되지 않고, 대신 리렌더링이 실행된다.


렌더링은 그 시점의 스냅샷을 찍는다.

렌더링은 그 시점의 스냅샷을 찍는다라는 뜻을 이해하기 위해 차근차근 코드를 보면 알아보자!

<h1>{number}</h1>
<button onClick={() => {
  setNumber(number + 1);
  setNumber(number + 1);
  setNumber(number + 1);
}}>+3</button>

이런 코드가 있다면, +3버튼을 클릭하면 setNumber를 3번 호출하므로 3으로 증가할 것 같지만 실제로는 1이 된다.


그 이유는 state를 설정하면 다음 렌더링에 대해서만 변경되기 때문이다.

  1. React는 다음 렌더링에서 number를 1로 변경할 준비
  2. React는 다음 렌더링에서 number를 1로 변경할 준비
  3. React는 다음 렌더링에서 number를 1로 변경할 준비

실제로 처음 버튼을 클릭했을 때 위의 과정을 거친다.

1씩 증가하는 행위를 3번 하는 것이 아니라, 1로 변경할 준비를 3번 하는 것이다.

<button onClick={() => {
  setNumber(number + 3);
  setTimeout(() => {
    alert(number);
  }, 3000);
}}>+3</button>

이번에는 setTimeout을 활용해서 지연을 시켜 렌더링이 완료 되기를 기다린 후 number의 값을 알아보자.

0에서 3으로 화면에서는 바뀐 값이 보이지만, alert에서는 3을 보여준다.

사용자가 상호작용한 시점에 state 스냅샷을 사용하는 건 이미 예약되었기 때문이다.


state는 컴포넌트의 메모리로서, 함수가 반환된 후 사라지는 일반 변수와는 다르게

함수 외부에 존재해서 리액트가 컴포넌트를 호출하면 특정 렌더링에 대한 state의 스냅샷을 제공한다.

  1. React에게 state를 업데이트 하도록 명령

  2. React가 state 값을 업데이트

  3. React가 state 값의 스냅샷을 컴포넌트에 보냄


즉, 과거에 생성된 이벤트 핸들러는 이벤트가 생성된 렌더링 시점의 state 값을 갖는다.

React는 하나의 렌더링 이벤트 핸들러 내에서 state 값을 “고정”한다.

따라서 코드가 실행되는 동안 state가 변경되었는지 걱정할 필요가 없다!


Snap State의 기능

Snap State는 여러 상태 변화가 연속적으로 일어날 때, 그 중간 상태들이 불필요하게 렌더링되는 것을 방지한다.

상태가 연속으로 변경될 때 변경이 완료된 최종 상태를 기반으로 컴포넌트를 렌더링하므로

중간 상태들이 불필요하게 렌더링되는 것을 방지할 수 있다.


Snap State는 리액트의 내부 최적화 원리 중 하나로,

이해하면 리액트의 동작원리를 더 깊이 알게 되고, 상태를 어떻게 최적화하는지 인식할 수 있다.


그런데 왜 state가 이렇게 동작되는 것인지 궁금해졌다.

이해하기 위해 useState를 조금 더 알아보자.

⚙️useState 더 알아보기

useState는 클로저로 작동한다.

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

// 이해를 돕기 위한 예시 코드로 실제 코드와는 차이가 있다.
function useState(initVal) {
	let _val = initVal;
	const state = () => _val;
	const setState = newVal => {
		_val = newVal;
	}
	return [state, setState]
}

초기값 Hook Queue

const [state, setState] = useState(0);
const [state2, setState2] = useState("imddoy");

이렇게 useState를 사용해서 state를 선언했다면 어떻게 초기값이 저장될까?


const hook: Hook = {
   memoizedState: null,
   baseState: null,
   baseQueue: null,
   queue: null,
   next: null,
}

훅의 초기값은 위와 같은 객체 형태이다.

여기에 state의 초기값을 할당하면

hook.memoizedState과 hook.baseState에 초기값을 할당하고

hook.queue에 기본 Queue도 할당한다.

const hook: Hook = {
   memoizedState: 0,
   baseState: 0,
   baseQueue: null, // hook가 업데이트 될 때 쌓이는 Queue
   queue: {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  },
   next: null,
}

그 다음 state2도 이어서 할당하면

hook.next에 할당한다.

const hook: Hook = {
   memoizedState: 0,
   baseState: 0,
   baseQueue: null,
   queue: {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  },
   next: {
     memoizedState: "imddoy",
     baseState: "imddoy",
     baseQueue: null,
     queue: {
      pending: null,
      lanes: NoLanes,
      dispatch: null,
      lastRenderedReducer: basicStateReducer, // 이 함수를 통해 상태를 업데이트
      lastRenderedState: (initialState: any),
    },
    next: null, 
  },
}

이렇게 유기적으로 연결되는 구조를 가지게 된다.


이어서 state를 업데이트해보자.

const [state, setState] = useState(0);
setState((prev) => prev + 1);
setState((prev) => prev + 2);

setStatedispatchSetState 함수 인자에 여러 값을 담아 실행한다.

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
): void {
  ...
  const update: Update<S, A> = {
    lane,
    revertLane: NoLane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };
 ...
  try {
    const currentState: S = (queue.lastRenderedState: any); // 렌더링된 최신값인 0이 할당
    const eagerState = lastRenderedReducer(currentState, action);
    update.hasEagerState = true; // update가 다음 상태로서 유효함을 알림
    update.eagerState = eagerState;  // 새로운 상태 정보를 update 객체에 저장
  }
 ...
}
  1. 이전에 렌더링된 상태인 queue.lastRenderedStatecurrentState에 할당한다.

  2. lastRenderedReducer 함수를 통해 update 객체에 eagerState라는 새로운 상태 값을 할당한다.

  3. lastRenderedReducer에 basicStateReducer의 함수를 할당

    // prev를 사용하면 이전 상태값을 가져올 수 있는 이유!!!
    // action은 set 함수 내부에 기입한 값으로, set 함수 내부에서 이전 상태를 인자로 받을 수 있음
    function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
      // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
      return typeof action === 'function' ? action(state) : action;
    }

set함수가 이렇게 2개 이상으로 여러개 존재한다면,

Hook Queue와 같이 update.next안에 유기적으로 연결되어 원형 큐를 형성한다.

만들어진 update 객체는 hook의 basicQueue로 들어간다.

const update: Update<S, A> = {
   lane,
   revertLane: NoLane,
   action, // (prev) => prev + 1
   hasEagerState: true,
   eagerState: 1,
   next: {
     lane,
     revertLane: NoLane,
     action, // (prev) => prev + 2
     hasEagerState: true,
     eagerState: 4,
     next : null, // set 함수가 추가된다면 이 곳에 들어가서 유기적으로 연결된다.
   },
}
...
 try {
    const currentState: S = 0;
    const eagerState = lastRenderedReducer(currentState, action);
    update.hasEagerState = true;
    update.eagerState = eagerState;
  }

정리하자면,

useState의 상태값은 컴포넌트 외부에 있는 hook이라는 배열 형태의 객체에 저장되고 Queue에 들어간다.

그 후, 모든 이벤트 핸들러가 종료되면 Queue를 실행한다.

따라서 1로 바꿔주고 렌더링되고, 2로 바꿔주고 렌더링되는 것이 아니라

1로 바꾸는 작업, 2로 바꾸는 작업을 queue에 넣고 업데이트가 시작된다.

  1. 1로 바꾸는 update를 찾고 원형 큐를 끊어주고
  2. 2로 바꾸는 update를 찾고 원형 큐를 끊어주고
  3. 더 이상 찾을 update가 없어서 updateReducer는 최종 결과를 반환하고
  4. updateState는 이 반환 값을 받아 해당 state의 최종 값을 반환하고
  5. 반환된 값으로 리렌더링된다.

따라서 0→1→2가 아닌, 0→2로 한번에 업데이트된다.


📌결론

상태는 동적인 값으로 사용자와 상호작용하는 값으로

값이 바뀔 때마다 리렌더링된다.

상태 관리는 복잡한 시스템을 다룰 때

어떻게 앱이 작동되는지 예측하고, 성능 최적화를 하기 위해 꼭 필요하다.

상태는 스냅샷처럼 동작해서 리액트의 성능을 최적화한다.

state를 교체하는 set함수는 비동기적으로 동작하고

모든 이벤트 핸들러가 종료되면 Queue를 실행해서 한번에 렌더링된다.

🔚마치며…

이번 아티클을 작성하면서

어떻게 상태가 변하는지를 살펴볼 수 있었고,

상태 관리의 중요성을 알 수 있었다.

React 최적화를 공부하기 위해서는 꼭 공부해야 하는 부분이었던 것 같고,

앞으로 state를 사용할 때 유의하면서 코드를 작성할 것 같다.

상태관리를 앞으로 어떻게 해야하는지에 관해 길잡이 표를 끝으로

이번 아티클을 마무리합니다~~


참고

https://react.dev/learn/state-as-a-snapshot#rendering-takes-a-snapshot-in-time

https://jaehan.blog/posts/react/useState-동작-원리와-클로저

https://archive.leerob.io/blog/react-state-management

profile
또이의 개발새발 개발일기

3개의 댓글

comment-user-thumbnail
2024년 10월 30일

혼자 공부하면서 Snap State는 현재 시점의 값을 변화시키는 게 아니라 리렌더링을 시켜준다!라고 단순하게 이해했었는데 중간 상태들이 불필요하게 렌더링되는 것을 방지하는 기능이라는 걸 알게 됐습니다. 또한, useState가 어떻게 동작하는지 자세하게 설명해주셔서 이해하는 데 도움이 됐습니다. 앞으로 리액트를 사용할 때 State를 좀 더 잘 사용할 수 있을 것 같아요! 잘 읽었습니당:)

답글 달기
comment-user-thumbnail
2024년 10월 30일

너무 자세히.. 스터디 키워드를 제외하고도 원리에 대한 내용을 정말 잘 작성해주신 것 같아요..! 저도 조금 더 깊은 내용들을 많이 다뤄보면 좋았을 걸 반성 중입니다..ㅎㅎ

일단 상태 머신에 대한 내용이 흥미로웠습니다. 유한 상태 머신이라는 용어 자체를 처음 들어봐서 올려주신 내용과 아티클을 더 찾아보게 되었는데, 일종의 디자인 패턴이라고 생각하면 편한 것 같아요. 그래서 말씀하신 form 상태를 관리할 수도 있고, 함수의 동작에서 조건들에 따른 동작이 나눠져야 하는 경우에 적용할 수 있을 것 같습니다! 물론 이 FSM(Finite State Machine, 유한 상태 기계)를 사용하지 않아도 구현이 가능하겠지만 가독성 측면에도 좋은 것 같고, Xstate라는 라이브러리를 사용해서도 구현이 가능한 것 같아요! 올려주신 내용에 대해서 조금 더 심화적인 내용이긴 하지만 kakao FE 기술 블로그에 있는 내용 읽어보시면 좋은 지식 얻으실 수 있을 것 같아 첨부드리겠습니다!!
https://fe-developers.kakaoent.com/2022/220922-make-cart-with-xstate/

그리고 상태 관리 라이브러리 항목에 zustand, jotai 등을 언급하시고 설명을 해주셨는데, 전역 상태 관리 라이브러리를 말씀해주신거겠죠?? 혹시 단순 상태 관리에 대한 내용이라면 이 라이브러리로 상태 관리를 하는 방법이나 좋은 방향성이 있는지 궁금합니다!!

또 state 불변성 유지 내용에서 handleClick으로 리렌더링이 되는지 안되는지를 콜 스택, 힙 영역으로 설명해주신 내용이 굉장히 좋은 내용인 것 같아요. 이런 부분을 생각해본 적이 없는 것 같은데, 불변성을 유지하면서 즉, 상태 변경을 잘 추적하도록 state를 어떻게 관리해야 할지 이제 생각해보면서 개발을 해야 할 것 같습니다... 마지막으로 hook queue/원형 큐에 대한 내용은 아직 정확히 이해하지 못해서 올려주신 내용 참고해서 더 공부해보겠습니다!!

답글 달기
comment-user-thumbnail
2024년 10월 30일

되게 원리적으로 잘 이해를 돕게해주는 아티클이었습니다! useState의 동작 원리에 대해 예시까지 자세히 설명해주셔서 상태에 대해 더 잘 아는 계기가 되었어요. 아티클을 어떻게 작성해야하는지 또이님 아티클 보고 방향성 잡고갑니다! 고생하셨어요.!!

답글 달기