콘솔로그가 이상한건 setState가 비동기 함수여서가 아닙니다. (feat: fiber architecture)

dante Yoon·2023년 4월 2일
150

react

목록 보기
18/19

영상으로 보고 싶다면?

https://www.youtube.com/watch?v=RD5ZWoUMmMk

글을 시작하며

안녕하세요, 단테입니다.

아래의 onDoubleClick 함수는 호출시에 count가 2씩 증가하게 작성되었습니다.
유감스럽게도 잘못 작성되었는데요. 리렌더링 시 count는 2가 아닌 1이 됩니다.

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

const onDoubleClick = () => {
  setCount(count + 1);
  console.log(count);
  setCount(count + 1); 
  console.log(count);
}

잘못 작성된 이유에 대해 setState 함수가 비동기 함수이기 때문이다라고 설명한다면 이는 정확하지 않은 답변입니다.
오늘은 setState를 비동기함수라고 불러야 하는 것이 올바른 설명인가에 대해 알아보겠습니다.

setState를 비동기 함수라고 오해하는 이유

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

const onClick = () => {
  setCount(count+1);
  console.log(count) // 1 ? 
}

return (
  <div>
    <button onClick={onClick
}>click me</button>
  </div>

함수 시그니처를 보면 분명 리턴 타입이 Promise가 아니다.

당신이 closure를 배워야 하는 이유

학교에서 시험기간에 달달 외우던 수 많은 ppt들은 중간고사/기말고사가 종료와 동시에 머리 속에서 휘발되어 버립니다.

코딩 인터뷰를 위해 달달 외웠던 closure에 대한 개념또한 인터뷰를 마치면 대부분 망각되어집니다.

우리가 코딩을 좋아하는 이유는 공부한 내용을 실생활에 바로 사용할 수 있어서입니다.

동일한 관점으로 closure 공부의 즐거움은 리엑트 코드를 통해 느낄 수 있습니다.

stale closure

리엑트에서 코드를 작성하는 것은 마치 영사기 안에 돌아가는 필름 뭉치를 돌리는 것과 같습니다.

Jason Leung: https://unsplash.com/ko/%EC%82%AC%EC%A7%84/bsmHmD1g1nE

개발자가 작성하는 코드라는 영화 배경은 동일한 것 같지만 그 안에서 동작하는 리엑트 렌더링 트리는 매 프레임마다 열심히 움직이고 있죠.

그러니까 마치 앞서 봤었던 예제 코드를 영사기의 필름에 빗대어 표현한다면 개발자는 setState호출을 통해 다음 프레임으로 넘어가는 두 가지의 영상 화면을 보고 있는 것입니다.

위의 함수형 컴포넌트에서는 렌더링 함수를 명시적으로 정의하지 않았습니다. 대신 컴포넌트 자체가 JSX 코드를 반환합니다. 이 때, 컴포넌트가 참조하는 상태 값은 클로져를 통해 접근할 수 있습니다. 클로져란 내부 함수가 외부 함수의 지역 변수에 접근할 수 있게 하는 기능입니다. 예를 들어, useState 훅을 사용하여 상태 값을 정의하면, 해당 상태 값은 컴포넌트 내부에서만 접근할 수 있습니다. 이 상태 값은 렌더링 함수가 생성될 때 클로져에 의해 캡처됩니다. 즉, 렌더링 함수는 자신이 생성될 때의 상태 값을 기억합니다.

도식화

React Component Function - .tsx, .jsx 파일 내부에서 작성하는 React.FC 혹은 React.ReactNode 타입의 함수

JS closure - 한 스냅샷에서 FC가 기억하고 있는 변수들

Snapshot - 리렌더링이 발생할 때마다 리엑트가 스케쥴링하는 각각의 컴포넌트 형상

Counter 컴포넌트는 한번 리렌더링 할 때마다 또 다른 프레임, 즉 스냅샷을 만듭니다. 스냅샷은 프레임이라는 단어와 더불어 각 렌더링 시점의 컴포넌트가 참조하는 closure가 다르다는 것을 나타내기 위해 제가 임의로 사용하는 단어입니다.

onDoubleClick 내부에서 setCount가 1번 호출되는 여러 번 호출되든 다음 렌더링 시점(closure에서 참조되는 변수 값이 업데이트되기 전)까지 closure 내부에서 참조하는 count 값은 항상 0이기 때문에 console.log는 항상 0을 가르키는 것입니다.

모던 프레임워크는 closure를 활용하기 때문에 생산성이 높다.


리엑트는 선언적인 프로그래밍을 통해 돔을 업데이트하는 코드를 묘사합니다.

명령형 프로그래밍은 어떤 상황어떤 돔어떤 값으로 업데이트 해 라고 직접 코딩하는 방식입니다.

$(".count").text("count 1") // JQuery

jquery를 이용한 명령형 프로그래밍 방식

명령형 프로그래밍이든 선언형 프로그래밍이든 closure를 사용하는 것은 마찬가지지만

선언형 프로그래밍에서 (특히 리엑트에서) 각 프레임, 스냅샷 (렌더링 타임)에서 컴포넌트가 참조해야할 값을 상태 값이라는 개념으로 선언하고 이 상태 값을 closure를 이용해 구현했기 때문에 setState가 호출 시 다음 렌더링 시점의 컴포넌트 함수가 참조하는 상태 값이 업데이트 되고 이를 통해 특정 영역의 노출 값을 사용자가 선언할 수 있게 된 것이죠.

리엑트의 렌더링 원리가 비동기적으로 작동한다. - 가상돔

면접관: 리엑트가 왜 빨라요?
단테: 리엑트가 빨라요? 그냥 개발자 생산성을 올리는데 도움을 줄 만큼 충분히 빠른건데요
면접관: (음.. 이게 아닌데) ...
단테: 가상돔을 사용한다는 답변을 듣기 원하신건가요? 리엑트가 기존 jquery를 사용한 웹 작성과 다른 부분은 돔을 명령형 방식으로 업데이트 하지 않고 선언형으로 업데이트하는 것입니다. 이를 가능하게 하기 위해 리엑트에서는 가상돔을 사용합니다. 가상돔은 실제 돔을 업데이트시키 전에 화면을 구성하기 위해 필요한 각 참조 값을 자바스크립트의 클로저 개념을 이용해 메모리에 저장합니다. 각 리렌더링 시점에 참조할 수 있는 클로저의 변수들을 리엑트에서는 상태라는 개념으로 관리하고 이 상태 덕분에 복잡한 인터렉션을 구성하는데 필요한 값들을 더 수월하게 관리할 수 있게 되었습니다. 리엑트의 리렌더링이 각 컴포넌트가 참조하고 있는 대단히 많은 상태 값이 실제 돔 업데이트로 이어지는 과정을 효율적으로 관리하기 위해 리엑트에서는 가상돔이라는 자료구조를 메모리에 저장합니다. 이 가상돔은 트리구조로 되어있는데 이 구조를 사용해 리엑트는 리렌더링 될 때마다 변경되는 부분만 골라 실제 돔을 업데이트 시킬 수 있습니다.

리엑트로 만든 앱이 바닐라 자바스크립트로 만든 앱보다 항상 빠르다고 할 수 없습니다.
개인적으로 위와 같은 질문은 잘못된 질문이라고 생각합니다.

흔히 리엑트는 가상돔을 사용합니다 -> 그래서 빠릅니다. 라는 설명을 많이 합니다.
그래서 처음 리엑트를 배우는 사람들은 가상돔은 성능과 연관된 부분이라고 알고 있으면 되는구나라고 착각하게 됩니다.

제가 처음 리엑트를 배우고 면접 준비를 할 때 도움을 받은 영상이 있습니다.

https://www.youtube.com/watch?v=BYbgopx44vo

이 훌륭하고 간단한 영상을 처음 접했을 때는 사람보다 로봇이 당연히 빠르지(ㅋㅋ) 라는 철없는 생각으로 사청했습니다. 그리고 리엑트는 성능이 항상 좋다 왜냐면 가상돔을 사용하니까. 이렇게 정리했던 것 같습니다.

하지만 여기서 멈춘다면 코드가 생각처럼 동작하지 않을 때 왜 이렇게 동작하지? 라는 질문에 대한 답을 찾는데 있어 가상돔과의 연관점을 쉽게 연상하지 못할 수도 있습니다.

리엑트의 setState가 동기적 함수이고 마치 비동기 함수처럼 보이는 이유는 리엑트의 리렌더링 원리가 비동기적으로 작동하기 때문입니다. 리엑트는 가상 돔이라는 자신만의 돔 이미지를 유지하고 있습니다. 리엑트는 렌더링 함수를 호출하여 가상 돔을 업데이트합니다. 렌더링 함수는 컴포넌트의 상태나 속성이 변경될 때마다 호출됩니다. 이렇게 하면 리엑트는 가상 돔이 최신 상태로 유지되도록 합니다. 즉, 컴포넌트가 어떻게 보여야 하는지에 대한 최신 정보를 반영합니다. 그런 다음 리엑트는 가상 돔과 실제 돔을 비교하여 실제 돔을 업데이트합니다.

즉 setState가 비동기 함수처럼 보이는 이유는 setState 함수 그 자체가 비동기 함수여서가 아니라 리엑트가 가상돔을 사용하게 설계되어있기 때문입니다.

그리고 앞서 설명했던 렌더링 함수는 컴포넌트의 상태나 속성이 변경될 때마다 호출됩니다.에서 각 컴포넌트는 렌더링 시점에 참조하는 상태를 해당 시점의 스냅샷에 참조하고 있는 closure에 따라 console.log에 출력하는 것이지요.

알겠습니다. 근데 왜 비동기적으로 동작해야 하는데요 - fiber architecture

리엑트의 가상돔는 리엑트 fiber architecture와 밀접한 관련이 있습니다.
가상돔이란 결국 리엑트가 실제 돔을 추상화하여 메모리에 유지하는 자료구조입니다.

렌더링 함수를 호출할 때 가상돔을 생성하고 이전 가상돔(스냅샷)과 비교하여 변경된 부분만 실제 돔에 반영합니다. 이때 비교하여 실제 돔에 반영하는 과정을 reconciliation(조정)이라고 하는데 리엑트 fiber architecture는 조정 알고리즘을 구현할 때 가상돔 트리를 render phase, commit phase 두 가지로 나누어 변경된 부분을 찾고 실제 돔에 변경사항하는 작업을 나누어 진행합니다.

변경점을 찾고 실제 돔을 업데이트하는 과정을 동기적으로 진행한다면 렌더링 작업이 오래 걸린다면 메인 스레드가 차단되고 프레임 드롭이나 응답 지연이 발생하기 때문에 UX를 저해하는 요소가 됩니다.

fiber node가 실제로 어떻게 생겼는지 밑에서 보고 commit phase, render phase에 일어나는 일을 실제로 알아보겠습니다.

fiber node가 실제로 어떻게 생겼을까

실제 코드는 리엑트 깃헙 레포에서 확인할 수 있습니다.

export type Fiber = {
  // These first fields are conceptually members of an Instance. This used to
  // be split into a separate type and intersected with the other Fiber fields,
  // but until Flow fixes its intersection bugs, we've merged them into a
  // single type.

  // An Instance is shared between all versions of a component. We can easily
  // break this out into a separate object to avoid copying so much to the
  // alternate versions of the tree. We put this on a single object for now to
  // minimize the number of objects created during the initial render.

  // Tag identifying the type of fiber.
  tag: WorkTag,

  // Unique identifier of this child.
  key: null | string,

  // The value of element.type which is used to preserve the identity during
  // reconciliation of this child.
  elementType: any,

  // The resolved function/class/ associated with this fiber.
  type: any,

  // The local state associated with this fiber.
  stateNode: any,

  // Conceptual aliases
  // parent : Instance -> return The parent happens to be the same as the
  // return fiber since we've merged the fiber and instance.

  // Remaining fields belong to Fiber

  // The Fiber to return to after finishing processing this one.
  // This is effectively the parent, but there can be multiple parents (two)
  // so this is only the parent of the thing we're currently processing.
  // It is conceptually the same as the return address of a stack frame.
  return: Fiber | null,

  // Singly Linked List Tree Structure.
  child: Fiber | null,
  sibling: Fiber | null,
  index: number,

  // The ref last used to attach this node.
  // I'll avoid adding an owner field for prod and model that as functions.
  ref:
    | null
    | (((handle: mixed) => void) & {_stringRef: ?string, ...})
    | RefObject,

  refCleanup: null | (() => void),

  // Input is the data coming into process this fiber. Arguments. Props.
  pendingProps: any, // This type will be more specific once we overload the tag.
  memoizedProps: any, // The props used to create the output.

  // A queue of state updates and callbacks.
  updateQueue: mixed,

  // The state used to create the output
  memoizedState: any,

  // Dependencies (contexts, events) for this fiber, if it has any
  dependencies: Dependencies | null,

  // Bitfield that describes properties about the fiber and its subtree. E.g.
  // the ConcurrentMode flag indicates whether the subtree should be async-by-
  // default. When a fiber is created, it inherits the mode of its
  // parent. Additional flags can be set at creation time, but after that the
  // value should remain unchanged throughout the fiber's lifetime, particularly
  // before its child fibers are created.
  mode: TypeOfMode,

  // Effect
  flags: Flags,
  subtreeFlags: Flags,
  deletions: Array<Fiber> | null,

  // Singly linked list fast path to the next fiber with side-effects.
  nextEffect: Fiber | null,

  // The first and last fiber with side-effect within this subtree. This allows
  // us to reuse a slice of the linked list when we reuse the work done within
  // this fiber.
  firstEffect: Fiber | null,
  lastEffect: Fiber | null,

  lanes: Lanes,
  childLanes: Lanes,

  // This is a pooled version of a Fiber. Every fiber that gets updated will
  // eventually have a pair. There are cases when we can clean up pairs to save
  // memory if we need to.
  alternate: Fiber | null,

  // Time spent rendering this Fiber and its descendants for the current update.
  // This tells us how well the tree makes use of sCU for memoization.
  // It is reset to 0 each time we render and only updated when we don't bailout.
  // This field is only set when the enableProfilerTimer flag is enabled.
  actualDuration?: number,

  // If the Fiber is currently active in the "render" phase,
  // This marks the time at which the work began.
  // This field is only set when the enableProfilerTimer flag is enabled.
  actualStartTime?: number,

  // Duration of the most recent render time for this Fiber.
  // This value is not updated when we bailout for memoization purposes.
  // This field is only set when the enableProfilerTimer flag is enabled.
  selfBaseDuration?: number,

  // Sum of base times for all descendants of this Fiber.
  // This value bubbles up during the "complete" phase.
  // This field is only set when the enableProfilerTimer flag is enabled.
  treeBaseDuration?: number,

  // Conceptual aliases
  // workInProgress : Fiber ->  alternate The alternate used for reuse happens
  // to be the same as work in progress.
  // __DEV__ only

  _debugSource?: Source | null,
  _debugOwner?: Fiber | null,
  _debugIsCurrentlyTiming?: boolean,
  _debugNeedsRemount?: boolean,

  // Used to verify that the order of hooks does not change between renders.
  _debugHookTypes?: Array<HookType> | null,
};

속성 설명 상세

type: 컴포넌트의 타입입니다. 예를 들어, ‘div’, ‘span’, ‘App’ 등입니다.
key: 컴포넌트의 고유 식별자입니다. 리액트가 컴포넌트를 재사용하거나 재배치할 때 사용합니다.
ref: 컴포넌트에 대한 참조입니다. 실제 돔 노드나 클래스 인스턴스에 접근할 수 있게 해줍니다.
props: 컴포넌트의 속성 객체입니다. 예를 들어, style, className, children 등입니다.
stateNode: 컴포넌트의 상태 노드입니다. 함수형 컴포넌트는 null이고, 클래스 컴포넌트는 인스턴스이고, 호스트 컴포넌트는 돔 노드입니다.
return: 부모 섬유 노드입니다. 섬유 노드들은 단일 연결 리스트로 구성됩니다.
child: 첫 번째 자식 섬유 노드입니다. 각 섬유 노드는 자식들을 가질 수 있습니다.
sibling: 다음 형제 섬유 노드입니다. 같은 부모를 가진 섬유 노드들은 형제 관계를 맺습니다.
alternate: 이전 렌더링에서 사용된 섬유 노드입니다. 리액트는 이전과 현재의 섬유 노드를 번갈아가면서 사용하여 메모리를 절약합니다.
effectTag: 섬유 노드에 적용할 작업의 종류를 나타내는 태그입니다. 예를 들어, PLACEMENT, UPDATE, DELETION 등입니다.
firstEffect: 이펙트 리스트(effect list)에서 첫 번째 섬유 노드입니다. 이펙트 리스트는 실제 돔에 반영할 변경사항을 담고 있는 섬유 노드들의 부분 리스트입니다.
lastEffect: 이펙트 리스트에서 마지막 섬유 노드입니다.
nextEffect: 이펙트 리스트에서 다음 섬유 노드입니다.

렌더 단계와 커밋 단계 예시

렌더 단계는 가상돔 트리를 순회하면서 변경된 부분을 찾고, 필요한 작업들을 섬유 노드에 저장하는 단계입니다. 이 단계에서는 작업을 일시정지하거나 재개하거나 취소하거나 우선순위를 변경할 수 있습니다.

예를 들어, 다음과 같은 가상돔 트리가 있다고 가정해봅시다.

<div>
  <h1>Hello</h1>
  <p>World</p>
</div>

이 트리에 대응하는 fiber node들은 다음과 같이 구성됩니다.

이 트리에서 <p>World</p><p>React</p>로 변경한다고 해봅시다. 이 경우, 리액트는 다음과 같은 과정을 거칩니다.

  • 렌더 단계: 가상돔 트리를 순회하면서 변경된 부분을 찾습니다. <p>World</p><p>React</p>가 다르다는 것을 알아내고, 해당 fiber node의 effectTag를 UPDATE로 설정합니다. 그리고 이펙트 리스트에 추가합니다.
  • 커밋 단계: 이펙트 리스트에 있는 fiber node의 effectTag에 따라 작업을 수행합니다.

    effectTag: 섬유 노드에 적용할 작업의 종류를 나타내는 태그입니다. 예를 들어, PLACEMENT, UPDATE, DELETION 등입니다.

UPDATE인 경우, 해당 fiber node의 stateNode(실제 돔 노드)의 속성을 변경합니다. 즉, <p>World</p><p>React</p>로 바꿉니다.

커밋 단계는 아래와 같은 세가지 하위 단계로 이루어져 있습니다.

  • before mutation: 돔에 변화를 주기 전에 실행되는 생명주기 메서드들을 호출하는 단계입니다. 예를 들어, getSnapshotBeforeUpdate 등입니다.
  • mutation: 돔에 변화를 주는 단계입니다. 이펙트 리스트에 저장된 작업들을 순서대로 실행합니다. 예를 들어, 노드 삽입, 삭제, 수정 등입니다.
  • layout: 돔에 변화를 준 후에 실행되는 생명주기 메서드들을 호출하는 단계입니다. 예를 들어, componentDidMount, componentDidUpdate 등입니다.
profile
성장을 향한 작은 몸부림의 흔적들

20개의 댓글

comment-user-thumbnail
2023년 4월 5일

항상 많이 배우고 갑니다 ..

1개의 답글
comment-user-thumbnail
2023년 4월 6일

맛있게 읽고 갑니다..

1개의 답글
comment-user-thumbnail
2023년 4월 6일

유익한 글 감사해요~

1개의 답글
comment-user-thumbnail
2023년 4월 7일

글이랑 유튜브 영상 잘 시청했습니다! 좋은 내용 공유 감사해요.

영상에서도 언급해주셨는데, 본문중 스냅샷 이미지에서 3번째 스냅샷의 경우 아래처럼 수정 되는게 맞는걸까요? 헷갈려서 확인차 댓글 남겨요 🙂

<Counter />
SnapShot 3
count= 2
1개의 답글
comment-user-thumbnail
2023년 4월 9일

오.. 정말 좋은 글이네요 뭔가 다양한 블로그 글들이나 사람들이 얘기하는거 대다수가 setState는 비동기라서 그런식으로 동작안한다 뭐 그런식으로 얘기를 많이하는데 실제 리액트 코드보면서 설명해주시니 확실히 신뢰가 가고 재미있게 읽었어요. 감사합니다 단테~

1개의 답글
comment-user-thumbnail
2023년 4월 10일

리액트 공식 홈페이지에서
setState 호출은 비동기적으로 이뤄진다고 말하고 있는데
여기에 대해서는 어떻게 생각하시나요?

1개의 답글
comment-user-thumbnail
2023년 4월 16일

setState의 반환형이 Promise 가 아니기 때문에 asynchronous function 이 아니라는 의견이실까요? setTimeout의 반환형은 number 이지만 이것은 비동기 함수입니다.

"Calls to setState are asynchronous" 이라는 표현이 모호하다고, @gentlee 님과의 댓글 스레드에서 말씀을 해주셨는데요, 그럼 공식문서의 이 표현은 "setState는 동기 함수인데 그걸 부르는 행위는 비동기적이다"라고 해석해야된다는 뜻일까요? 이는 너무나도 어색한 번역이라고 생각합니다.

정리하자면, setState 는 (다른 많은 자바스크립트 함수들과 동일하게) 비동기 함수가 맞다는게 제 의견입니다. 어떻게 생각하시는지요?

1개의 답글
comment-user-thumbnail
2023년 5월 17일

안녕하세요?
늘 좋은 글 감사합니다.
저는 단테님만큼 지식이 두텁지 않기 때문에 무엇이 잘못되었다 이야기 드리고 싶은게 아닌, 담고 있는 내용에 대해 말씀드리고 싶은게 있습니다.

글의 첫번째 예시를 보고 제가 알고 있었던 지식은 다음과 같습니다.

첫 예시에서 두번 작성된 setCount(count +1)가
정상적으로 동작하지 않는 것은 2개의 setCount가 count를 직접 수정하는데, 상태변경을 위임하지 않고 직접 변경함으로 인해 이전 상태(previous snapshot)를 참조하지 않고 현재 상태를 참조하여 변경하고 있다.
그러므로 React가 상태 변경을 감지하지 못하게 되고, 두 setCount 모두 현재 상태의 자유 변수 0을 참조하여 1이 된다.
그래서 직전 상태(previoys snapshot)를 참조하여 새로운 상태를 업데이트 하게끔 setCount(prev => prev + 1)로 변경하면 정상적으로 동작한다.

이 주제에서 진일보적인 지식이나 잘못 알고 있었던 부분을 다시 알게되는 점을 기대했으나 내용이 너무 심오하고 결론에서 궁금한점이 풀리지 않았습니다..
또한 이게 비동기 함수와 관련이 있는 것인가 하는.. 제 지식의 부족함으로 인한 의문도 듭니다

글에 깊이가 있어 늘 새로운 것을 알게 되고, 단테님 덕분에 정말 많은 지식을 얻고 있는 입장에서 조심스럽지만, setState의 비동기적인 호출에서 fiber architecture는 너무 딥한것 아닌가 싶습니다
물론 깊게 알게 되어 감사한 마음을 갖고 있습니다.

좋은 글 감사합니다

답글 달기