j2h30728 님의 상세페이지[6팀 이지현] Chapter 1-3. React, Beyond the Basics

과제 체크포인트

배포 링크

기본과제

equalities

  • shallowEquals 구현 완료
  • deepEquals 구현 완료

hooks

  • useRef 구현 완료
  • useMemo 구현 완료
  • useCallback 구현 완료
  • useDeepMemo 구현 완료
  • useShallowState 구현 완료
  • useAutoCallback 구현 완료

High Order Components

  • memo 구현 완료
  • deepMemo 구현 완료

심화 과제

hooks

  • createObserver를 useSyncExternalStore에 사용하기 적합한 코드로 개선
  • useShallowSelector 구현
  • useStore 구현
  • useRouter 구현
  • useStorage 구현

context

  • ToastContext, ModalContext 개선

과제 셀프회고

기술적 성장

useState로 useRef를 만들 수도 있다.

계속 전역변수로 값을 유지하려는 방법으로만 생각하려 했기에, 레퍼런스를 얻고자 오픈소스 라이브러리를 찾아봤었습니다.
하지만, react의 useRef 내부구현은 생각보다 이해하기 어려웠었습니다.

preact의 경우에는 저희 과제는 useRef를 사용해 useMemo를 만들기 때문에 다소 당황스럽기도 했습니다.

// react
export function useRef<T>(initialValue: T): {current: T} {
  const dispatcher = resolveDispatcher();
  return dispatcher.useRef(initialValue);
}

// 내부 구현 
// 첫 렌더링시에는 초기 값을 저장하는 로직
function mountRef<T>(initialValue: T): {current: T} {
  const hook = mountWorkInProgressHook();
  const ref = {current: initialValue}; // ref에 저장
  hook.memoizedState = ref; 
  return ref;
}

// 두 번쨰 렌더링 부터는 저장한 값만 가져오는 로직
function updateRef<T>(initialValue: T): {current: T} {
  const hook = updateWorkInProgressHook(); 
  return hook.memoizedState; 
}

// preact
/** @type {(initialValue: unknown) => unknown} */
export function useRef(initialValue) {
	currentHook = 5;
	return useMemo(() => ({ current: initialValue }), []);
}

과제 요구사항 중 주석으로 useState를 활용하는 힌트를 가지고 다시 확장된 사고를 이어갔습니다. useState의 인자에 함수를 넣으면 최초렌더링시에 한 번 만 실행하는 것을 생각해냈습니다.

useState의 인자에 함수를 넣으면 최초 렌더링 시에만 실행되는 특성을 활용해, 참조 타입 객체를 state로 저장하여 메모리 주소값을 고정시키고, setter 함수 없이 state 객체에 직접 접근하는 방식으로 구현했습니다.

그리고 리액트 내부 코드를 들여다보니 이전에 확인했던 useRef 구현체가 조금이나마 이해할 수 있었습니다. useState는 initailState가 함수타입이라면 실행시켜서 hook.memoizedState에 저장하는 것과 useRef에서 직접 {current: initialValue} 로 current 값을 저장하는 유사점을 확인 할 수 있었습니다.

// 실제 첫 렌더링 시에 useState 내부에서 실행되는 함수
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') { // useState 상태 초기값이 함수일 때
    const initialStateInitializer = initialState;
    initialState = initialStateInitializer(); 
    
    // 중략
    }
  }
  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;
}

Object.is===

기존에는 일치 연산자(===)를 관성적으로 사용했었는데, 이번 과제를 통해 Object.is의 특성과 리액트에서의 활용 맥락을 더 깊이 이해하게 되었습니다.

// packages/lib/src/equals/shallowEquals.ts
// before
  // 4. 모든 키에 대해 얕은 비교 수행
  for (const key of kyesA) {
    if (!(key in b) || (a as Record<string, unknown>)[key] !== (b as Record<string, unknown>)[key]) {
      return false;
    }

// after
  for (const key of keysA) {
    if (!(key in b) || !Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
      return false;
    }

NaN, +0, -0 등의 특수 케이스를 정확하게 다루기 위해 Object.is를 사용하도록 리팩토링했으며, 얕은 비교 함수가 리액트의 의존성 배열 비교 로직과 별개로 동작한다는 점도 명확하게 이해할 수 있었습니다.

리액트 프로파일러

ToastProvider, ModalProvider 최적화 과제를 수행하면서 리액트 프로파일러를 본격적으로 활용했습니다. 어떤 트리거에서 어떤 렌더링이 일어나는지, 어떤 부분을 메모이제이션하면 리렌더링을 방지할 수 있는지를 시각적으로 파악할 수 있어 매우 유용했습니다.

자랑하고 싶은 코드

useState를 활용한 useRef 구현이 가장 만족스러웠습니다. 처음에는 전역 상태 관리 방식으로만 접근하려 했었고, useState 힌트조차 이해하지 못했었습니다. 하지만 결국 useState의 기능과 원리를 다시 복습하며 완성한 코드라서 특별한 의미가 있습니다.

export function useRef<T>(initialValue: T): { current: T } {
  const [refObejct] = useState(() => ({
    current: initialValue,
  }));
  
  return refObejct;
}

개선이 필요하다고 생각하는 코드

ToastProvider에서 상태와 액션 프로바이더를 분리하면서 복잡도가 높아진 것 같은 기분이 듭니다. 기분이...

학습 효과 분석

실무 관련 내용

현재 디자인 시스템 컴포넌트 개발에서 컴파운드 컴포넌트 패턴과 Context API를 자주 사용하고 있습니다. 이번 3주차 과제를 통해 어떤 값을 메모이제이션해야 하는지, 어디에 집중해야 하는지에 대한 방향의 이정표가 될 수 있었습니다.

같은 날 업무로 아코디언 컴포넌트를 개발했는데, 멀티 아코디언이 아닌 이상 특정 인터랙션에 하나의 아코디언 컴포넌트만 리렌더링 되는 게 자연스러워, 무분별한 메모이제이션이 오히려 불필요할 수 있다는 점을 깨달았습니다.

그동안 관성적으로 메모이제이션을 사용해왔을 가능성이 있지만, 이번 경험을 계기로 메모이제이션이 실제 유의미한 상황인지 프로파일링과 함께 고민할 수 있었습니다.

가장 큰 배움이 있었던 부분

사실, react 라이브러리의 내부 구현을 찾아볼 때 다음과 같은 코드를 만나면 더 이상 찾아보려 하지 않았었습니다.

import * as React from 'react';

const ReactSharedInternals =
  React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;

export default ReactSharedInternals;

하지만 이번 과제를 계기로 **"다른 개발자들은 어떻게 내부 구현을 추적하고 분석하는가?"**에 대한 궁금증이 생겼습니다. 여러 블로그와 자료를 찾아보며 코드 흐름을 따라가는 방법들도 함께 학습할 수 있었습니다.

과제 피드백

진행한 과제 중 3주차 요구사항이 가장 친절하고 재미있었습니다. 덕분에 오픈소스 라이브러리 코드를 확인해보는 시간을 가질 수 있어서 부가적인 학습 효과까지 얻을 수 있었습니다.

학습 갈무리

리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.

리액트 렌더링 과정

  1. 트리거(Trigger): 렌더링 시작 신호
  • 상태(state)나 props가 변경될 때, 혹은 ReactDOM.createRoot(...).render(<App />) 코드처럼 최초 마운트 시 렌더링이 시작됩니다.
  • 여러 상태 변화를 한 번에 처리하는 Batching 기법이 적용되어, 실제로는 한 번만 렌더링됩니다.
  • React 18부터는 비동기 콜백 내부에서 발생하는 여러 렌더링 트리거도 한 번에 묶어 처리할 수 있게 개선되었습니다.
  1. Render 단계: 가상 DOM 생성 및 준비
  • JSX 문법을 통해 만들어진 UI 구조는 내부적으로 JS 객체(React Element)로 변환됩니다.
  • 실제 DOM 대신 Virtual DOM(가상 DOM)이라는 메모리 내 구조가 만들어집니다.
  • Virtual DOM상에 Fiber라는 단위 객체가 각 컴포넌트별로 생성되어, 상태, 이펙트, 업데이트 정보 등을 보관합니다.
  1. Reconciliation(재조정): 변화점 감지
  • 새롭게 만들어진 Virtual DOM 트리와 직전의 트리를 비교(diffing)합니다.
  • 변화가 필요한 부분만 찾아내기 위해 key 등 휴리스틱 알고리즘을 사용, 성능을 O(n)까지 최적화합니다.
  • 변경이 발생한 요소만 선별해 실제 DOM 변경이 이루어지도록 준비합니다.
  1. Commit 단계
  • 변화가 감지된 부분만 실제 DOM에 반영합니다.
  • 이 시점에 컴포넌트의 생명주기 메서드(componentDidMountcomponentDidUpdatecomponentWillUnmount 등) 또는 함수형 컴포넌트의 이펙트 훅(useEffectuseLayoutEffect 등)이 실행됩니다.
렌더링 최적화 방법
  • memo : 동일한 props에 대해 불필요한 컴포넌트 리렌더링 방지합니다.
  • 커스텀훅 useMemouseCallback: 값 또는 함수를 메모이제이션하여 무거운 새로운계산 및 재생성을 방지합니다.
  • index가 아닌 key 사용: 리스트 컴포넌트에서 알맞은 key을 지정할 경우에 diff 알고리즘의 효율을 높일 수 있습니다. index를 사용할 경우에는 순서가 바뀌거나 리스트 아이템이 바뀔 경우에 알아채지 못하게 되어 효율성이 저하됩니다.
  • 상태 관리: 자주 변경되는 상태와 그렇지 않은 상태를 분리하고, 전역 상태보다는 지역 상태를 사용하는 것으로 사이드 이펙트로 인한 리렌더링을 줄입니다.

메모이제이션에 대한 나의 생각을 적어주세요.

커스텀훅을 만들 때, 해당 훅이 실제로 어디서 어떻게 사용될지 모르기 때문에 보통 외부로 내보내는 함수는 useCallback으로 감싸서 export하려고 합니다. 실제로 prop으로 핸들러 함수가 내려가는 경우, 이 참조가 바뀔 때마다 하위 컴포넌트가 불필요하게 리렌더링되는 현상을 방지하는 용도로 많이 쓰게 됩니다.

또한, 데이터(리스트 등)가 변경되지 않으면 React.memouseMemo를 활용해서 컴포넌트나 연산 결과를 캐싱하는 경험도 자주 했습니다. 복잡하거나 연산 비용이 비싼 작업이 반복될 경우에도 useMemo로 값을 캐싱해 연산 횟수를 줄입니다.

UI에서 리렌더링이 자주 일어나서 성능에 영향을 줄 수 있는 상황에는 useMemo 혹은 useCallback으로 불필요한 재계산과 리렌더를 최소화하려고 노력합니다.

대신, 필요 이상으로 메모이제이션을 남용하는 것은 오히려 코드의 복잡도만 올릴 수 있으니, 가능하면 상태나 함수를 외부 모듈로 분리하여 참조가 바뀌지 않게 설계하거나, 상태 분리로 리렌더링 범위를 최대한 좁히는 것에도 신경씁니다.

컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.

3주차 발제 시간 후 팀별 활동에서 주제3 Context와 전역상태 라이브러리의 차이점은 뭘까요? 에 대해서 이야기를 나누게 되었었습니다.

당시에는 저는 이렇게 작성했었습니다.

이지현: context는 상태를 공유하고싶은 원하는 범위가 명확하거나 프롭드릴링을 해소하기 위해 사용합니다. 전역상태 라이브러리의 경우에는 사용 범위가 프로젝트 전체 단위로 광범위적일 때 많이 사용하고 있습니다.

대체로 contextAPI로는 컴파운드 컴포넌트를 만들 때 자주 활용했습니다. 이때까지의 경험기반으로 작성하고 이야기를 했었는데 과제를 하면서 또 다른 생각을 가지게 되었습니다.

ToastProvider 같은 리렌더링에 민감한 케이스에서 contextAPI만으로는 성능 최적화가 까다롭다는 걸 새삼 느꼈습니다. 특히 state와 action 분리, provider value의 메모이제이션, 관련 함수들의 useCallback 처리 등 수동 최적화 작업이 상당히 많다는 것을 경험했습니다.

이 과정을 겪으면서, 그동안 useMemouseCallback을 습관적으로 사용해왔고 실제 리렌더링 여부를 프로파일링하지 않았다는 반성도 하게 됐습니다.

이러한 작업을 진행하다보니, 리렌더링에 민감한 케이스라면 contextAPI 보다는 상태 관리 라이브러리를 사용하여 성능 최적화 및 개발 편의성까지 동시에 고려하는 것도 더 좋은 선택일 수 있겠다는 생각이 들었습니다

리뷰 받고 싶은 내용

  • ** contextAPI를 활용한 코드를 작성하다보면 어떻게 파일 및 코드 분리를 해놓을지 고민이 되기도합니다.**

    예를들면, 컴파운트 컴포넌트를 만들다고 했을때 context provider와 다른요소들을 한 파일에 둘지 하나의 폴더에 하위 파일로 요소마다 분리할지 고민입니다. 이 부분은 주관이기도 하고 컨벤션이기도 하지만, 만약 컨벤션이 없는 상황이라면 코치님은 어떻게 하시는지 궁금합니다.

  • Object.is와 shallowEquals를 보면서 궁금했던 부분이 있습니다.

    상태저장소를 만드는 createStore에서는 Object.is사용하여 빠른 최적화과 함께 참조값을 비교하여 상태를 업데이트하고있습니다. 이는 리액트내부에서 dispatch가 빈번하게 발생할 수도 있다는 관점으로 생각하면 합리적이라고 생각합니다. 그렇다면 store보다는 set하는 빈도수가 적게 사용할 것 같은 createStorage는 shallowEquals를 사용하여 얕은 비교까지 하여 업데이트하는 방식으로 최적화하는 것에 대해서는 어떻게 생각하시나요? 이 또한, 어떤 대량의 데이터가 저장될지 모르니 Object.is가 더 괜찮은 선택일까요?

과제 피드백

지현님 고생하셨어요! 필요한 부분에 대해서 명확하게 잘 구현해주셨네요. 과제를 진행하시면서 실제로 preact나 react구현도 살펴보시고 직접 옮겨오시면서 많은 부분을 학습하실 수 있었던 것 같아서 좋네요 :+1

궁금해하셨던 부분 먼저 답변 드려보면요.

contextAPI를 활용한 코드를 작성하다보면 어떻게 파일 및 코드 분리를 해놓을지 고민이 되기도합니다.

일반적으로 제가 쓰는 경우에는 해당 로직이 복잡해지는 경우 Context, Provider와 사용하는 훅을 분리해서 파일로 관리하기도 하는데요. 다만, 요즘의 상태가 그리고 전역 상태를 사용하는 케이스가 UI관련된 단순한 케이스들이 많기 때문에 하나의 파일로 관리하는 경우도 종종 있는거같아요!

규모가 어느정도로 커지냐를 기준으로 잡고, 그리고 컨텍스트를 프로젝트에서 얼마나 사용하는지를 기준으로 해서 판단하고 나누면 좋을 것 같아요 ㅎㅎ

Object.is와 shallowEquals를 보면서 궁금했던 부분이 있습니다.

넵 나름 합리적인 접근 방법인 것 같아요 ㅎㅎ 밑에 희진님이 남겨주신 강조의 표시 처럼 ""확실"하다면 저장되는 데이터를 기준으로 비교 로직을 다르게 가져갈 수 있을 것 같아요. 다만, 그렇지 않다면 일관적이게 비교를 하고 옵션 형태로 제공해서 사용하는 측에서 성능 최적화를 할 수 있게 하는 것도 방법일 수 있을 것 같아요.

고생하셨고 다음 주 과제도 화이팅입니다!!