jeongmingi123 님의 상세페이지[2팀 정민기] Chapter 1-3. React, Beyond the Basics

과제 체크포인트

배포 링크

https://jeongmingi123.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 개선

과제 셀프회고

기술적 성장

기본적인 hook을 구현하며, 너무 어려웠지만. 기본개념을 알수있어 너무 좋았습니다. react에서는 얕은비교를 좀 더 다르게 비교하고 있다는 것에 대해 알고 있어 좋았습니다.

자랑하고 싶은 코드

사실 그렇게까지 만족스러운 구현은 아니였지만, shallowEqulas에 대해 기본적으로 다시 개념을 정리하면서 짜서 기분이 좋았습니다. 그리고 그리고 함수를 분리하여 좀 더 행위를 구체적으로 알수있도록 shallowEqualsArray, shallowEqualsObject 나눈 부분에 대해 나쁘지 않았다고 생각합니다.
/**
 * 두 값이 얕은 비교(===)로 동일한지 확인합니다.
 * 객체와 배열의 경우 참조가 아닌 값 자체를 비교합니다.
 */
export const shallowEquals = (a: unknown, b: unknown) => {
  // 기본 동등성 체크
  if (a === b) {
    return true;
  }

  // null 체크
  if (a === null || b === null) {
    return false;
  }

  // 객체 타입 체크
  if (!isObject(a) || !isObject(b)) {
    return false;
  }

  // 배열 비교
  if (Array.isArray(a) && Array.isArray(b)) {
    return shallowEqualsArray(a, b);
  }

  // 객체 비교
  return shallowEqualsObject(a, b);
};

/**
 * 값이 객체인지 확인합니다 (null 제외)
 */
const isObject = (value: unknown): value is Record<string, unknown> => {
  return typeof value === "object" && value !== null;
};

/**
 * 두 배열을 얕은 비교로 확인합니다
 */
const shallowEqualsArray = (a: unknown[], b: unknown[]): boolean => {
  if (a.length !== b.length) {
    return false;
  }

  for (let i = 0; i < a.length; i++) {
    if (a[i] !== b[i]) {
      return false;
    }
  }

  return true;
};

/**
 * 두 객체를 얕은 비교로 확인합니다
 */
const shallowEqualsObject = (a: Record<string, unknown>, b: Record<string, unknown>): boolean => {
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);

  if (keysA.length !== keysB.length) {
    return false;
  }

  for (const key of keysA) {
    if (!(key in b)) {
      return false;
    }

    if (a[key] !== b[key]) {
      return false;
    }
  }

  return true;
};

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

useRouter를 구현하는 과정중에, 희원님이 따로 코드리뷰를 해주셨는데 코드의 중복 제거를 발견하였고, 가독성을 좀 더 향상 했다고 생각합니다. getRouterState라는 새로운 헬퍼 함수가 도입시켜 기능적인 변화는 없지만, 전반적인 가독성을 조금 더 높였고, 유지보수성을 조금 더 높였다고 생각합니다.

(이전)

export const useRouter = <T extends RouterInstance<AnyFunction>, S>(
  router: T,
  selector = defaultSelector<RouterState, S>,
) => {
  // useShallowSelector를 사용하여 선택된 상태의 얕은 비교를 수행
  const shallowSelector = useShallowSelector(selector);

  return useSyncExternalStore(
    router.subscribe,
    () => {
      const state = {
        route: router.route,
        params: router.params,
        query: router.query,
        target: router.target,
      };
      return shallowSelector(state);
    },
    () => {
      const state = {
        route: router.route,
        params: router.params,
        query: router.query,
        target: router.target,
      };
      return shallowSelector(state);
    },
  );
};

(이후)

const defaultSelector = <S = RouterState>(state: RouterState): S => state as unknown as S;

function getRouterState(router: RouterInstance<AnyFunction>): RouterState {
  return {
    route: router.route,
    params: router.params,
    query: router.query,
    target: router.target,
  };
}

/**
 * useRouter 훅: RouterInstance의 상태를 구독하고 selector로 필요한 값만 반환함
 * @param router RouterInstance 객체
 * @param selector 상태 선택 함수 (기본값: 전체 상태 반환)
 */
export const useRouter = <T extends RouterInstance<AnyFunction>, S = RouterState>(
  router: T,
  selector: (state: RouterState) => S = defaultSelector,
) => {
  const shallowSelector = useShallowSelector(selector);

  return useSyncExternalStore(
    router.subscribe,
    () => shallowSelector(getRouterState(router)),
    () => shallowSelector(getRouterState(router)),
  );
};

학습 효과 분석

아무래도 react에서 얕은비교, 깊은비교 대해 자세하게 좀더 이해할수 있어서 좋았습니다. 이전에는 useCallback, useMemo, memo와 같은 메모이제이션을 개념도 모르고 사용하여, 왜 적용이 안되는지 헷갈렸습니다. 그러나 어느정도 개념이 잡히니 어느정도 실무에 적용도 가능할 거같고, 다른 프로젝트에도 사용을 할 수 있을거같아 이번에 많은 도움이 되었다고 생각합니다.

참고 링크 : https://github.com/jeongmingi123/front_6th_chapter1-3/issues/5

과제 피드백

아무래도 React hook의 기본을 구현해보며 기본기를 다질 수 있었고, 더나아가 상태관리까지 직접 만들어봐서 좀 더 심층적으로 이해한거같아 좋았습니다.

학습 갈무리

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

🎨 1단계: 그림을 그릴 준비 (렌더 단계) 화가가 붓을 들고 물감을 고르는 것처럼, React는 "자, 이제 화면을 바꿀 시간입니다!" 하면 렌더링을 시작함. 첫번째 단계인 트리거는 "어? 뭔가 바뀌었네요?" 내부 상태 변경 (state 변경): useState를 이용해 값을 바꾸시거나, 클래스 컴포넌트에서 setState를 호출하시면, React는 "알겠습니다, 이 컴포넌트는 새롭게 보여줄 것이 생겼네요!" 하고 알아차림. 부모로부터의 새로운 정보 (props 변경): 우리 컴포넌트의 부모 컴포넌트가 다시 렌더링되면서, 저에게 새로운 props를 전달해 주는 경우도 마찬가지임 마치 부모님이 새로운 옷을 사주시면 자식이 새 옷을 입고 나타나는 것과 같음.

두번째 단계인 새로운 그림을 스케치하는 작업을 말함. 컴포넌트 호출: React는 변화가 필요하다고 판단한 컴포넌트부터 시작하여, 그 아래에 있는 자식 컴포넌트들을 하나하나 실행함. 마치 가족 구성원들을 한 명씩 불러내는 것과 같음.

이 후 설계도 만들기를 하는데, 각 컴포넌트는 화면에 어떻게 보일지에 대한 '청사진' 같은 JSX를 반환함. 이 JSX는 React 엘리먼트라는 자바스크립트 객체로 변환함. 이 엘리먼트들이 모여서 하나의 큰 '그림 스케치북' 같은 가상 돔(Virtual DOM)을 만듬. 이것은 아직 브라우저 화면에 보이는 실제 그림이 아니고, 컴퓨터 메모리 속에만 있음.

이제 세번째 단계에서는 외부에 영향을 주는 행동(사이드 이펙트)을 하시면 안 됨. 왜냐하면 React는 이 스케치를 필요에 따라 몇 번이고 다시 그릴 수도 있고, 중간에 멈췄다가 다시 시작할 수도 있기 때문. 따라서 이 단계는 오직 UI를 어떻게 그릴지에 대한 정보만 생성하는 순수한 작업만 해야함.

🖼️ 2단계: 실제 그림을 완성하는 시간 (커밋 단계) 스케치가 다 끝났으면, 이제 진짜 캔버스에 그림을 옮겨 그릴 시간. 첫번째 단계는 "어디가 달라졌지?" 틀린 그림 찾기를 함. 새 스케치 vs. 옛 스케치 비교 (재조정): React는 방금 만든 '새로운 그림 스케치(새 가상 돔)'를 가져와서, 이전에 그렸던 '옛날 그림 스케치(이전 가상 돔)'와 꼼꼼하게 비교함. "요 부분만 고치자!" (Diffing 알고리즘): 이 비교 과정에서 Diffing 알고리즘이라는 매우 똑똑한 방법을 사용하여, 딱 어떤 부분만 바뀌었는지, 즉 최소한의 차이점을 찾아냄. 전체를 다 다시 그리는 것은 매우 힘든 일이기에, 바뀐 부분만 효율적으로 찾아내는 것입니다.

두번째 단계는 캔버스에 실제로 옮겨 그리는 단계 딱 바뀐 부분만 쓱싹하고 업데이트를 함. Diffing 알고리즘이 찾아낸 최소한의 변경 사항만 가지고, 드디어 브라우저의 실제 화면, 즉 DOM(Document Object Model)에 그림을 그려 넣음. 이 실제 DOM을 건드리는 작업은 컴퓨터 자원을 많이 소모하기 때문에, React는 최대한 효율적으로 딱 필요한 만큼만 건드리려고 노력합니다.

마지막으로 그림 완성이 되는 단계 (사이드 이펙트 발생): 그림이 완전히 그려지고 화면에 나타나면, 이제야 안전하게 외부와 소통하는 작업들 (예: 서버에 데이터 요청해서 가져오기, 화면에 뭔가 애니메이션을 할 수 있음) 이 시점이 바로 사이드 이펙트를 실행하는 적절한 위치임.

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

이번 멘토링에서 준일 멘토님께서 useCallback, useMemo을 어떻게 적절히 사용하는 것이 좋은가에 대한 답변을 받았다. 이전부터 어떨 때 사용해야할까에 대한 고민이 있었고, 인강에서는 너무 많이 사용하면 오히려 안좋다라는 의견을 받았는데 왜 사용을 많이 하면 안좋을까에 대한 궁금한 부분이 있었다. 이번 멘토링을 통해 답변을 받을 수 있어 어느정도 해결이 되었다.

준일님이 공유해주신 URL : https://attardi.org/why-we-memo-all-the-things/ 멘토링 노트 참고 URL : https://www.notion.so/3-2-2372dc3ef51480f19e4cd1ed271cdf04?source=copy_link

해결방안

  1. 문제가 있을 때 혹은 문제를 해결해야 한다고 인지했을 때에만 메모이제이션을 적용하기
  2. 아니면 처음부터 다 적용하기

2번의 무조건 메모이제이션 전략은 과연 괜찮을까에 대한 고민이 있었다. 개발 과정에서 메모이제이션(Memoization)을 적용할지 말지를 고민하는 것 자체가 때로는 더 큰 비용과 병목 현상을 초래한다. 적절한 순간에 메모이제이션을 해야지라고 판단하고 적용 포인트를 찾는 데 드는 시간과 인지적 부하가, 실제로 얻게 될 성능 이득보다 커질 수 있다는 의미가 됨. 내나름의 결론은 그냥 다 적용하자라고 생각이든다.

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

모든 상태를 컨텍스트에 넣으면 안될거같다. 특히 자주 업데이트되는 상태를 컨텍스트에 넣으면 불필요한 전역 리렌더링으로 인해 성능 문제가 발생할 수 있어 필요한 부분에만 적절히 사용하는 것이 중요하다 생각한다. 또한 규모에 따라 적절한 상태관리 도구를 사용하는 것이 중요하다고 생각한다.

리뷰 받고 싶은 내용

ToastProvider 구현중 질문드립니다.
export const ToastProvider = memo(({ children }: PropsWithChildren) => {
  const [state, dispatch] = useReducer(toastReducer, initialState);
  const { show, hide } = createActions(dispatch);
  const visible = state.message !== "";

  // debounce 함수를 useRef로 안정화
  const hideAfterRef = useRef<ReturnType<typeof debounce> | null>(null);

  if (!hideAfterRef.current) {
    hideAfterRef.current = debounce(hide, DEFAULT_DELAY);
  }

  // show와 hide 함수를 useCallback으로 안정화
  const stableShow = useCallback(show, []);
  const stableHide = useCallback(hide, []);

  const showWithHide = useCallback<ShowToast>(
    (...args) => {
      stableShow(...args);
      hideAfterRef.current?.();
    },
    [stableShow],
  );

  // 액션 Context value - 안정화된 함수들만 포함
  const actionValue = useMemo(
    () => ({
      show: showWithHide,
      hide: stableHide,
    }),
    [showWithHide, stableHide],
  );

  // 상태 Context value - 상태만 포함
  const stateValue = useMemo(
    () => ({
      message: state.message,
      type: state.type,
    }),
    [state.message, state.type],
  );

  return (
    <ToastActionContext.Provider value={actionValue}>
      <ToastStateContext.Provider value={stateValue}>
        {children}
        {visible && createPortal(<Toast />, document.body)}
      </ToastStateContext.Provider>
    </ToastActionContext.Provider>
  );
});

위의 코드중 이 부분에 대한 질문이 있습니다. 저는 useCallback을 사용하였는데요. 다른분들은 useAutoCallback을 사용하신분들이 몇분 있으시더라구요. showWithHide는 stableShow만 의존성으로 갖고 있고, stableShow는 useCallback(show, [])로, 사실상 불변. hideAfterRef는 ref이므로, ref 객체 자체는 변하지 않아보입니다. 즉, 현재의 의존성 배열([stableShow])은 사실상 빈 배열과 동일한 효과를 내는거같아보입니다. 현재처럼 의존성이 명확하고, 특별히 바뀔 일이 없는 경우에 useAutoCallback을 쓰는게 오히려 더 좋은 판단이였을까요? 아님 더 좋은 방안이 있었을까요..?

  const showWithHide = useCallback<ShowToast>(
    (...args) => {
      stableShow(...args);
      hideAfterRef.current?.();
    },
    [stableShow],
  );

과제 피드백

안녕하세요 민기님! 3주차 과제 잘 진행해주셨네요 ㅎㅎ 고생하셨습니다!


리액트 렌더링 과정을 무척 재밌게 서술해주셨어요 ㅋㅋ 다른 사람들도 이해하기 쉽도록 만들어주셨네요!! 재밌게 작성해주셔서 감사합니다 ㅎㅎ

2번의 무조건 메모이제이션 전략은 과연 괜찮을까에 대한 고민이 있었다. 개발 과정에서 메모이제이션(Memoization)을 적용할지 말지를 고민하는 것 자체가 때로는 더 큰 비용과 병목 현상을 초래한다. 적절한 순간에 메모이제이션을 해야지라고 판단하고 적용 포인트를 찾는 데 드는 시간과 인지적 부하가, 실제로 얻게 될 성능 이득보다 커질 수 있다는 의미가 됨. 내나름의 결론은 그냥 다 적용하자라고 생각이든다.

물론 저의 의견을 따라가는 것도 좋지만, 추후에 민기님께서 스스로 납득할 수 있는 경험을 해보는게 중요하다고 생각해요! 그러기 위해선 많이 해보는 방법 밖에는.... 없을 것 같네요 ㅋㅋㅋ 여튼 화이팅입니다!

ToastProvider 구현중 질문드립니다. 저는 useCallback을 사용하였는데요. 다른분들은 useAutoCallback을 사용하신분들이 몇분 있으시더라구요. showWithHide는 stableShow만 의존성으로 갖고 있고, stableShow는 useCallback(show, [])로, 사실상 불변. hideAfterRef는 ref이므로, ref 객체 자체는 변하지 않아보입니다. 즉, 현재의 의존성 배열([stableShow])은 사실상 빈 배열과 동일한 효과를 내는거같아보입니다. 현재처럼 의존성이 명확하고, 특별히 바뀔 일이 없는 경우에 useAutoCallback을 쓰는게 오히려 더 좋은 판단이였을까요? 아님 더 좋은 방안이 있었을까요..?

잘 살펴보시면 구현된 내용이 useAutoCallback과 굉장히 유사한데요, 지금 코드 그대로 useAutoCallback으로 바꾸면 사실 동작은 똑같습니다.

const hideAfter = useAutoCallback(debounce(hide, DEFAULT_DELAY));

다만 useAutoCallback의 문제는, 함수를 고정시키는게 아니라 변화시킨다는 점입니다. 그래서 이럴 때 오히려 useAutoCallback이 아니라 useMemo나 useCallback을 사용해야 정상적으로 동작할 것 같아요!

// useMemo로 사용할 경우
const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), []);

// useCallback으로 사용할 경우
const hideAfter = useCallback(debounce(hide, DEFAULT_DELAY), []);

이렇게 작성해야 함수가 변하지 않고 고정이 되니까요!

useAutoCallback을 사용하면 렌더링이 될 때 마다 최신 함수로 변경하기 때문에 문제가 될 수 있답니다 ㅎㅎ