CHUG ALONG

[React] 리액트를 처음부터 배워보자. — 02. 클래스형 컴포넌트는 어떻게 해서 상태를 갖는가? 함수형 컴포넌트는 왜 자체 상태를 가질 수 없을까?

January 22, 2024

이전 포스트를 통해 React가 어떻게 JSX로 표현된 컴포넌트가 함수형인지, 클래스형인지 판단할 수 있었고, 컴포넌트의 정보를 어떻게 처리하여 관리하는지 간략하게 살펴볼 수 있었습니다. 그렇다면 가장 궁금했던 클래스형은 가능했지만 함수형은 불가능했던 상태 관리와 라이프 사이클 메서드가 어떤 차이가 있었기 때문에 그러했는지 그 사실을 파악해 보겠습니다.

이전에 클래스형이든 함수형이든 결국 JSX문법을 활용하거나 React.createElement()를 호출하여 결국 ReactElement가 되고 이렇게 생성된 ReactElement는 다시 createFiberFromElement()를 통해 VDOM에서 관리되는 Fiber 객체로 탄생하게 됩니다.

그리고 React.Component를 상속받은 경우에는 prototype 체이닝을 통해 isReactComponent의 유무를 확인하여 둘을 비교할 수 있었죠.

function Component(props, context, updater) {
  ...
}

Component.prototype.isReactComponent = {}; // isReactComponent를 객체(truth)로 할당 한 뒤

이렇게 내부적으로 함수와 클래스형을 구분짓는것은 내부적으로 클래스인 경우엔 new 연산자를 필요로 하기 때문입니다. 함수형은 실행 결과로 바로 렌더링할 ReactElement를 리턴하지만 클래스형의 경우에는 static 메서드가 아닌 경우에는 반드시 new 연산자를 통해 instance화 시킨 뒤 render 메서드를 호출해야지만이 ReactElement를 리턴할 수 있습니다.

class Greeting extends React.Component {
	render() {
		return <p>Hello</p>;
	}
}

// If Greeting is a function
const result = Greeting(props); // <p>Hello</p>

// If Greeting is a class
const instance = new Greeting(props); // Greeting {}
const result = instance.render(); // <p>Hello</p>

실제 리액트 코드에서도 class형 컴포넌트인 경우에는 를 인스턴스화 시킨 뒤 Fiber객체의 stateNode에 해당 인스턴스를 저장하고 있습니다.

// 클래스형 컴포넌트
// ReactFiberClassComponent.new.js

const classComponentUpdater = {
  isMounted,
  enqueueSetState(inst, payload, callback) {
    const fiber = getInstance(inst);
    const eventTime = requestEventTime();
    const suspenseConfig = requestCurrentSuspenseConfig();
    const lane = requestUpdateLane(fiber, suspenseConfig);

    const update = createUpdate(eventTime, lane, suspenseConfig);
    update.payload = payload;
    if (callback !== undefined && callback !== null) {
      if (__DEV__) {
        warnOnInvalidCallback(callback, 'setState');
      }
      update.callback = callback;
    }

    enqueueUpdate(fiber, update);
    scheduleUpdateOnFiber(fiber, lane, eventTime);
  },
  enqueueReplaceState(inst, payload, callback) {
		...
  },
  enqueueForceUpdate(inst, callback) {
		...
  },
};

function adoptClassInstance(workInProgress: Fiber, instance: any): void {
  instance.updater = classComponentUpdater;
  workInProgress.stateNode = instance;
  // The instance needs access to the fiber so that it can schedule updates
  setInstance(instance, workInProgress);
  if (__DEV__) {
    instance._reactInternalInstance = fakeInternalInstance;
  }
}

function constructClassInstance(...) {
	...

	const instance = new ctor(props, context);

	...

	adoptClassInstance(workInProgress, instance); // Fiber 객체의 stateNode에 updater(classComponentUpdater)를 추가한 instance를 추가
}

// 함수형 컴포넌트
// ReactFiberHooks.new.js

function renderWithHooks( ... ) {
	...

	let children = Component(props, secondArg);

	...

	return children
}

이를 통해 클래스형 컴포넌트의 경우에는 instance화 되어 자신만의 Context를 가지게 되고, 이 Context가 바로 해당 컴포넌트의 state가 되는 것 입니다. 반면에, 함수형 컴포넌트의 경우 render를 호출하면 인스턴스화 된 객체를 참조하는 것이 아니라 바로 호출되어 리턴값을 갖기 때문에 자신만의 Context를 가질 수 없고 자체 Context가 없기 때문에 독자적인 라이프 사이클 메서드 또한 사용할 수 없게 되는게 아닐까 생각됩니다.

class 컴포넌트는 React.Component에서 상속받은 setState 메서드를 호출하면 this.updater.enqueueUpdate() 메서드를 호출하는데, 비로서 updater가 어떤 녀석인지 알아낼 수 있게 되었네요.

Component.prototype.setState = function (partialState, callback) {
	invariant(
		typeof partialState === 'object' ||
			typeof partialState === 'function' ||
			partialState == null,
		'setState(...): takes an object of state variables to update or a ' +
			'function which returns an object of state variables.',
	);
	this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

그러면 함수형 컴포넌트는 어떻게 이를 극복할 수 있었을까요? 흔히들 함수형 컴포넌트는 Hook을 통해 자신만의 ContextState를 가질 수 있게 되었고 이는 자체 라이프 사이클 메서드 또한 가질 수 있게 되었다고 많은 블로그에서 설명되어있죠. 위에서 실제 위의 리액트 코드 안의 함수형 컴포넌트의 render 함수에서 알 수 있듯이 함수형 컴포넌트는 Hook과 함께 렌더한다라고 표현되어 있듯이 말이죠.

함수형에서는 updater 속성을 사용하는 대신 리액트 패키지 전역적으로 공유되고 있는 Dispatcher라는 녀석을 사용합니다.

// ReactSharedInternals.js

const ReactSharedInternals = {
	ReactCurrentDispatcher,
	ReactCurrentBatchConfig,
	ReactCurrentOwner,
	IsSomeRendererActing,
	// Used by renderers to avoid bundling object-assign twice in UMD bundles:
	assign,
};

실제 ReactHooks.js 파일을 살펴보면 ReactSharedInternals 객체를 사용하는 것을 확인할 수 있었습니다. 좀 더 자세한 구현은 ReactFiberHooks.new.js에서 확인하실 수 있었구요.

// In React (simplified a bit)
const React = {
	// Real property is hidden a bit deeper, see if you can find it!
	__currentDispatcher: null,

	useState(initialState) {
		return React.__currentDispatcher.useState(initialState);
	},

	useEffect(initialState) {
		return React.__currentDispatcher.useEffect(initialState);
	},
	// ...
};

함수형 컴포넌트 안에서 Dispatcher객체가 갖고 있는 다양한 Hook들(useState, useEffect, ...)를 호출해서 사용합니다. 심지어는 클래스형과 마찬가지로 해당 컴포넌트의 라이프 사이클 시점에 따라 다른 Dispatcher가 주입되고 있었습니다. 예시로는 MountUpdate 시점에서의 Dispatcher만 확인해보겠습니다.

function renderWithHooks(...) {
	renderLanes = nextRenderLanes;
  currentlyRenderingFiber = workInProgress;
	...

	ReactCurrentDispatcher.current =
		current === null || current.memoizedState === null
			? HooksDispatcherOnMount
			: HooksDispatcherOnUpdate;

	...
}

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  return hook;
}

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}


const HooksDispatcherOnMount: Dispatcher = {
	...
  useState: mountState,
	...
};

const HooksDispatcherOnUpdate: Dispatcher = {
	...
  useState: updateState,
	...
};

우선 useState()를 실행하게 되면 실행되는 함수는 mountState()가 되겠습니다. 이 함수를 실행시킨 리턴값인 [hook.memoizedState, dispatch]가 바로 흔히 const [state, setState] = useState(); 와 같이 구조분해 할당을 통해 사용하는 값이 되는 것이죠. mountWorkInProgressHook()에서는 hook에 memoizedState, queue, next값이 있는 객체를 할당하는데요, 차례대로

  • memoizedState는 컴포넌트에 적용된 마지막 상태값으로 mountState()에서 상태값을 리턴하는데 사용되고,
  • queuemountStateImpl()에서 훅이 호출될 때마다 update를 연결 리스트로 queue에 집어넣습니다.
  • 그리고 next는 workInProgressHook이 있을 때 다음 hook을 가리키는 포인터입니다.
setState() - dispatchSetState
function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
): void {
  if (__DEV__) {
		...
  }

  const lane = requestUpdateLane(fiber);

  const update: Update<S, A> = {
		...
  };

  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    const alternate = fiber.alternate;
    ...
  }

  markUpdateInDevTools(fiber, lane, action);
}

currentlyRenderingFiberrenderWithHooks()에서 workInProgress를 할당받았습니다. 즉, workInProgress를 할당받았다는 것은 render Phase가 진행 중이라는 뜻이 됩니다. render phase는 이전 포스팅에서도 언급했듯이 VDOM과 DOM을 비교하는 diffing 알고리즘 처리 과정입니다. 해당 과정이 끝나고 비로서 화면에 적용하는 과정이 commit phase라고 했죠. 이(setState)를 통해 전달된 fiberworkInProgress인 fiber가 같다면 render phase에서 update가 일어났다는 뜻이기 때문에 queueupdatepush하게 됩니다.

이를 통해 왜 함수형 컴포넌트에서 let으로 선언한 로컬 변수의 변화를 리액트가 인지하지 못하는지 이해할 수 있었습니다. 해당 컴포넌트에 어떠한 변화/업데이트가 있으면 setState를 통해서만이 내부적으로 이를 알려주는 Dispatch 함수가 실행되기 때문에 리액트의 reconciler 알고리즘을 통해 업데이트가 발생합니다.

function useRef(initialValue) {
  ...

  const ref = {current: initialValue};
  hook.memoizedState = ref;
  return ref;
}

function updateRef<T>(initialValue: T): {current: T} {
  const hook = updateWorkInProgressHook();
  return hook.memoizedState;
}

더욱이 useRef도 컴포넌트의 리렌더링을 발생시키지 않는다고 알고 있었는데, 실제 내부 코드를 살펴보니 Dispatcher를 호출하는 코드가 없더군요. 다만, 이전의 리렌더링이 발생했다 하더라도 이전의 값을 기억하기 위해 hook 자체에 ref 객체를 연결시켜놓음으로써 저장된 값을 컴포넌트의 리렌더링 없이 사용할 수 있다는 것을 알게 되었습니다. 컴포넌트에 let과 같은 로컬 변수를 선언하면 결국 컴포넌트가 렌더링될 때 마다 해당 변수가 재선언 되지만 useRef를 사용하면 hook안에 memoized된 값을 재사용 하기 때문에 메모리 할당에 필요한 연산을 조금이나 줄일 수 있겠네요(아주 미세하겠지만).

사실상 함수형 컴포넌트에서 어떻게 상태를 갖을 수 있는지가 궁금한것이였기 때문에 자세한 리액트 reconciler 알고리즘에 대해서는 추후에 더 알아보기로 하겠습니다. 여기까지 확인하는것만 해도 저한테는 너무 어려운 일이네요.. 그래도 실제 함수형 컴포넌트가 어떻게 상태를 가질 수 있게 되었는지, useState가 어떻게 동작하는지 react의 실제 코드를 뜯어보면서 많은 것을 알아가는 과정이어서 앞으로도 자주 코드를 뜯어보면서 동작 원리를 이해하는 시간을 종종 가져야겠다고 다짐하게 되었습니다.