lieblichoi 님의 상세페이지[9팀 최재환] Chapter 1-3. React, Beyond the Basics

과제 체크포인트

배포 링크

https://lieblichoi.github.io/front_6th_chapter1-3/

기본과제

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 개선

과제 셀프회고

이번 과제는 React를 그저 사용만 하는 걸 넘어 왜 이렇게 만들었는지를 직접 짜보면서 고민해볼 수 있었던 좋은 기회였습니다. 이전에는 그냥 공식 문서에 나와 있어서, 아니면 그냥 최적화에 좋다해서 사용했던 훅들을 직접 만들어보니 각 기능이 어떤 문제를 해결하기 위해 나왔는지, 어느 부분을 고려해 만들어진건지 알 수 있었습니다.

useAutoCallback 구현 과정이 좀 빡셌습니다. 처음에는 useLayoutEffect를 쓰면 간단히 해결될 줄 알았는데, 계속 테스트가 실패해서 당황했습니다. 원인이 렌더링과 이펙트 실행 사이의 타이밍 문제인가 싶어서 다른 방식으로 접근해봤는데 어찌저찌 해결이 되었습니다. 완전한 이해는 아니지만 훅의 동작 원리를 어느정도 이해할 수 있게 되었습니다.

심화 과제에서 useSyncExternalStore를 다루고 Context API의 렌더링 문제를 직접 해결하면서, React에서 '상태'와 '렌더링'이 어떻게 맞물려 동작하는지에 대한 감도 익힐 수 있었습니다. 모든 기능을 100% 완벽하게 이해했다고 말하기는 어렵지만 이제는 문제가 터졌을 때 어디를 봐야 할지, 어떤 질문을 던져야 할지 조금은 알 것 같습니다. 그동안 궁금했던 부분들이 어느정도 풀린 것 같아 재밌었습니다.

ToastProvider를 리팩토링하면서 Context API의 렌더링 문제 직접 해결했던 것도 재미는 있었습니다. 단순히 useMemo를 쓰는 게 다가 아니라, 상태와 함수를 분리해서 애초에 불필요한 의존성을 끊어내는게 더 중요하다는 걸 배웠습니다. 역시 설계적인 측면에서의 접근이 중요한 것 같ㅡㅂ니다.

자랑하고 싶은 코드

useAutoCallback.ts

앞에서도 잠깐 이야기 했지만 useLayoutEffect를 사용했다가 잘 되지 않고 나서 문제의 원인이 어디일까 고민하다 렌더링과 이펙트 실행 사이의 타이밍에 있다고 생각하고 두 개의 useRef를 사용하는 방법으로 해결했습니다. fnRef는 매번 렌더링될 때마다 최신 함수를 담아두고, memoizedFn은 맨 처음 렌더링될 때 딱 한 번만 생성됩니다. memoizedFn은 일종의 임시(?)함수인데, 실행될 때 fnRef에 담긴 최신 함수를 꺼내서 실행하는 구조입니다. 결과적으로 겉으로 보이는 함수 주소(참조)는 항상 똑같이 유지하면서 실제 로직은 항상 최신 상태를 따라가게 만들 수 있었습니다. 구조를 이해하는데에 AI의 도움을 많이 받았지만 그래도 가장 많이 공부가 되었던 부분이라 가져왔습니다.

// packages/lib/src/hooks/useAutoCallback.ts
import { useRef } from "./useRef";
import type { AnyFunction } from "../types";

export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
  const fnRef = useRef(fn);
  fnRef.current = fn; // 1. 매 렌더링마다 최신 함수를 여기에 담는다.

  const memoizedFn = useRef<T>();

  if (!memoizedFn.current) {
    // 2. 최초 렌더링 시에만 함수를 생성하여 참조를 고정한다.
    memoizedFn.current = ((...args: Parameters<T>): ReturnType<T> => fnRef.current(...args)) as T;
  }

  return memoizedFn.current as T; // 3. 고정된 참조의 함수를 반환한다.
};

ToastProvider.tsx

Context API가 편리하지만 렌더링에 취약하다는 말만 들었지 직접 해결해본건 처음입니다. 기존에는 토스트 메시지와 토스트를 띄우는 함수가 한 Context에 있어서, 메시지가 바뀔 때마다 함수만 필요한 다른 컴포넌트들까지 덩달아 리렌더링되는 문제가 있었습니다.

이걸 해결하려고 상태는 ToastStateContext에, 함수는 ToastCommandsContext에 담아 분리했습니다. 이렇게 나누니 상태 변화가 필요한 컴포넌트에만 영향을 미치게 되었습니다. useMemouseCallback으로 참조가 바뀌지 않도록 처리해서 불필요한 렌더링이 일어날 여지를 잘 없앴다고 생각합니다.

// packages/app/src/components/toast/ToastProvider.tsx
const ToastStateContext = createContext(initialState);
const ToastCommandsContext = createContext<{
  show: ShowToast;
  hide: Hide;
}>({ show: () => null, hide: () => null });

export const useToastCommand = () => useContext(ToastCommandsContext);
export const useToastState = () => useContext(ToastStateContext);

export const ToastProvider = memo(({ children }: PropsWithChildren) => {
  const [state, dispatch] = useReducer(toastReducer, initialState);
  // ...
  const commands = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]);

  return (
    <ToastStateContext.Provider value={state}>
      <ToastCommandsContext.Provider value={commands}>
        {children}
        {visible && createPortal(<Toast />, document.body)}
      </ToastCommandsContext.Provider>
    </ToastStateContext.Provider>
  );
});

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

deepEquals.ts

재귀를 통해 객체를 깊게 비교하면서 구현은 했지만 실제 프로젝트에 쓰기에는 부족하다 생각합니다.

// packages/lib/src/equals/deepEquals.ts
export function deepEquals(a: unknown, b: unknown): boolean {
  if (Object.is(a, b)) return true;

  if (a && b && typeof a === "object" && typeof b === "object") {
    // ...
    // Set, Map, Date 등에 대한 처리가 없다.
  }
  // ...
}

솔직히 이 부분은 알고리즘이 복잡해서 AI의 도움을 많이 받았습니다. 완벽한 코드를 짜는 것보다는,, 깊은 비교라는 게 이렇게 복잡하고 고려할 게 많구나하는 전체적인 틀을 이해하는 데 의미를 두었습니다. lodash.isEqual 같은 검증된 라이브러리가 얼마나 소중한지 느꼈습니다.

학습 효과 분석

React의 성능 최적화는 단순히 useMemo 같은 훅을 쓰는게 아니라 왜 리렌더링이 일어나는지 원인을 파악하고 컴포넌트 구조와 데이터 흐름의 설계를 고민하는 것에 달려있다는 걸 가장 많이 느꼈던 것 같습니다. 문제가 생겼을 때 일단 훅부터 추가하고 보는 게 아니라 컴포넌트를 나누거나 상태 위치를 바꾸는 등 더 근본적인 해결책을 먼저 고민해야겠다는 생각이 들었습니다.

과제 피드백

단계별로 구현되는 구조가 좋았습니다. 기본 훅을 만들고, 그 훅을 이용해 심화 과제를 해결하는 과정이 자연스럽게 다가와서 흐름이 눈에 보여 좋았습니다.

학습 갈무리

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

  1. 시작: setState 같은 상태 변경이 일어나서 이제 렌더링을 다시 해야한다는 신호를 보냅니다.
  2. 계산: 컴포넌트 함수를 실행해서, 이번엔 UI가 어떻게 그려져야 하는지 계산합니다. 이 단계에서 JSX가 Virtual DOM(가상돔)이라는, 진짜 DOM의 설계도 같은 객체로 바뀝니다. 그리고 전과 비교해서 바뀐 부분만 찾아냅니다.
  3. 반영: 계산 단계에서 찾아낸 바뀐 부분만 실제 화면(DOM)에 적용합니다. 이 단계가 끝나고 나서 useEffect 같은 부가적인 작업들이 실행됩니다.
  • 렌더링 최적화는 이 과정 중 계산 단계를 건너뛰거나(메모이제이션), 반영 단계의 작업을 최소화하는 것입니다.

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

메모이제이션은 편하지만 쓰기 전에 이 계산이 정말 오래 걸리는지, 이 함수가 다시 만들어지는 게 정말 문제가 되는지에 대한 고민을 항상 먼저해야 합니다.

  • 정말 복잡한 계산 결과를 재사용할 때나 자식 컴포넌트에 함수나 객체를 넘겨줄 때 주소값이 바뀌지 않게 해서 불필요한 리렌더링을 막아야 때 필요합니다.
  • 성능 향상이라는 확실한 장점이 있지만, 예전 값을 기억하기 위해 메모리를 더 써야 하고, 의존성 배열을 관리해야 해서 코드가 조금 더 복잡해진다는 단점이 있습니다.
  • 컴포넌트 구조를 잘게 나누거나, 상태를 정말 필요한 곳에만 두는 설계적 측면의 접근이 더 근본적인 해결책이 될 수 있습니다.

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

Context는 멀리 떨어져 있는 컴포넌트들끼리 데이터를 쉽게 주고받게 해주는 것입니다.

  • props를 여러 단계에 걸쳐 계속 내려주는 'Prop Drilling'의 불편함을 없애고, 여러 곳에서 쓰는 데이터를 한 곳에서 관리하기 위해 필요합니다.
  • 렌더링이 문젱니데, Context의 값이 바뀌면 이걸 쓰는 모든 컴포넌트가 리렌더링 될 수 있기 때문입니다. 상태와 함수를 분리하고, Context에 넘겨주는 값의 주소값이 바뀌지 않도록 useMemo 등으로 감싸주는 것이 중요합니다.

리뷰 받고 싶은 내용

  1. useAutoCallback의 안정성 useLayoutEffect 대신 두 개의 useRef를 사용하는 방식으로 구현했습니다. 그런데 이 방식이 React가 렌더링을 하다가 중간에 멈추거나 다시 시작하는 환경에서도 문제없이 잘 동작할지, 혹은 제가 생각하지 못한 다른 문제가 있을지 궁금합니다.

    // packages/lib/src/hooks/useAutoCallback.ts
    export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
      const fnRef = useRef(fn);
      fnRef.current = fn;
    
      const memoizedFn = useRef<T>();
    
      if (!memoizedFn.current) {
        memoizedFn.current = ((...args: Parameters<T>): ReturnType<T> => fnRef.current(...args)) as T;
      }
    
      return memoizedFn.current as T;
    };
    
  2. useRef의 설계에 대한 고민 과제 요구사항을 맞추기 위해, 직접 만든 useRef에 함수 오버로딩을 추가했습니다. 덕분에 useRef()처럼 인자 없이도 호출할 수 있게 되었지만 코드는 좀 더 복잡해졌습니다. 라이브러리를 만드는 관점에서 이렇게 API 사용성을 맞추기 위해 복잡도를 조금 높이는 게 괜찮은지, 아니면 단순하게 초기값을 꼭 받도록 하는 게 더 나았을지 궁금합니다.

    // packages/lib/src/hooks/useRef.ts
    export function useRef<T>(initialValue: T): { current: T };
    export function useRef<T = undefined>(): { current: T | undefined };
    export function useRef<T>(initialValue?: T) {
      const [ref] = useState({ current: initialValue });
      return ref;
    }
    

과제 피드백

수고하셧어요 재환! 이번 과제는 React의 핵심 원리를 직접 구현해보면서 프레임워크가 내부적으로 어떻게 상태를 관리하고 최적화하는지 깊이 이해하는 것이 목표였습니다. 해당 취지가 잘 느껴지는 지난 3주간이었길 바래요!

useAutoCallback 구현 과정에서의 고민들이 회고에 잘 드러난 것 같아서 좋았습니다. useLayoutEffect로 시작해서 렌더링 타이밍 문제를 발견하고, 두 개의 useRef를 활용한 해결책으로 발전시킨 과정을 세세하게 잘 풀어낸 부분 좋습니다.

React의 동시성 모드에서의 안정성 우려를 표현하셨는데, 실제로는 매우 안전한 구현입니다. React가 컴포넌트를 재시작하더라도 useRef는 동일한 객체를 유지하므로 예상치 못한 부작용은 발생하지 않을 거예요.

useRef에서는 라이브러리 사용성을 위한 복잡도 증가를 고민하셨다고 하셨는데, 분명 고민을 해볼 부분이죠. useRef의 오버로딩이 복잡해보이지만 코드로 보았을때의 복잡도나 일관성 그리고 학습곡선이 낮다면 해볼만한 선택입니다.

만약 그렇지 않았다면 useRef, useRefFn, useRefWithNull 등 여러가지 함수를 만들었어야 하는데 복잡도와 간결성과 직관성등을 모두 고려해 최적을 찾아가보는게 개발자의 관점이죠. 수고하셨습니다.

앞으로 클린코드 챕터에서는 이런 구조적 사고를 더욱 발전시켜 나가봅시다!! 아자 아자! 2주차도 지금과 같은 해보고 익힉다의 정신으로 화이팅입니다!