heojungseok 님의 상세페이지[5팀 허정석] Chapter 1-3. React, Beyond the Basics

과제 체크포인트

배포 링크

PR만이 유일하게 이 잔혹한 세계에 저항할 방법이다!!

기본과제

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, Beyond the Basics.... 기초를 넘어서기 위한 3주차 과제가 끝났다. 1, 2주차에서 JS 로 SPA 를 구현하면서 3주차가 되게 힘들거라고 예상했다. 리액트를 써보지 않은 입장에서 React hooks, React 메모이제이션, ... 험난했다. (+ Typescript 또한 안써봤다...) 그래도 1, 2 주차에 예방주사를 좀 맞아 놓은 덕에 내 예상보다는 조금은 수월하게 진행했다. 그렇다고 절대 쉽다는 말은 아니다. 1주였지만 리액트라는 것을 조금은 알아간거에 만족하고있다. 더 알아갈 일만 남았다. 그리고 양질의 과제 준비하시느라 고생 많으셨습니다. 준일 코치님! "To infinity… and beyond!" image

TIL 링크

기술적 성장

새로 학습한 개념들은 너무나도 많았다. 어쩌면 전부 다 새로 학습한 개념이라해도 무방하다. 나는 최대한 덜 움직이고 효율적이게 효과를 내는 것을 좋아하기에 메모이제이션 이 제일 기억에 남는다.

memo (컴포넌트 메모이제이션), useMemo (값을 메모이제이션), useCallback (함수를 메모이제이션)

  • 불필요한 렌더링을 방지
  • 복잡한 계산과 큰 데이터 세트를 다룰 때 이전 결과를 재사용함으로써 비용을 줄일 수 있음 ⬇️ ⬇️ ⬇️ ⬇️ ⬇️
  • 컴포넌트 예측 가능성을 높임
  • 버그를 줄임
  • 확장성과 유지보수성 증대

메모이제이션에 대한 생각은 아래 항목에 정리하겠다.

자랑하고 싶은 코드

커밋 링크: refactor: ToastProvider 리팩토링

/* eslint-disable react-refresh/only-export-components */
import { createContext, memo, type PropsWithChildren, useContext, useReducer } from "react";
import { createPortal } from "react-dom";
import { Toast } from "./Toast";
import { createActions, initialState, toastReducer, type ToastType } from "./toastReducer";
import { debounce } from "../../utils";
import { useCallback, useMemo } from "@hanghae-plus/lib/src/hooks";

type ShowToast = (message: string, type: ToastType) => void;
type Hide = () => void;
// Context 분리
// 상태 Context
const ToastStateContext = createContext<{
  message: string;
  type: ToastType;
}>({ ...initialState });
// 이벤트 Context
const ToastCommandContext = createContext<{ show: ShowToast; hide: Hide }>({
  show: () => null,
  hide: () => null,
});

const DEFAULT_DELAY = 3000;
// Hook 수정
export const useToastCommand = () => useContext(ToastCommandContext);
export const useToastState = () => useContext(ToastStateContext);

export const ToastProvider = memo(({ children }: PropsWithChildren) => {
  const [state, dispatch] = useReducer(toastReducer, initialState);
  const { show, hide } = useMemo(() => createActions(dispatch), [dispatch]);
  const visible = state.message !== "";
  // 메모이제이션
  const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]);
  // 메모이제이션
  const showWithHide: ShowToast = useCallback(
    (...args) => {
      show(...args);
      hideAfter();
    },
    [show, hideAfter],
  );
  // props 로 넘길 값 (메모이제이션)
  const commandValue = useMemo(
    () => ({
      show: showWithHide,
      hide: hide,
    }),
    [showWithHide, hide],
  );
  return (
    // Provider 중첩
    <ToastCommandContext.Provider value={commandValue}>
      <ToastStateContext.Provider value={state}>
        {children}
        {visible && createPortal(<Toast />, document.body)}
      </ToastStateContext.Provider>
    </ToastCommandContext.Provider>
  );
});


구현_힌트

위 이미지는 Gemini CLI 를 통해서 리팩토링을 위해서는 어떻게 해야하는지에 대한 구현 힌트를 얻은 부분이다. (+ 3팀분들의 도움) 처음부터 끝 까지 스스로 하진 못했지만 내가 생각하기엔 배운 내용을 정리 및 활용하기에 좋았다. 좋은 테스트 항목을 넣어주신거 같다. 약간 엑기스 같은 느낌이 강하다.

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

import type { AnyFunction } from "../types";
import { useCallback } from "./useCallback";
import { useRef } from "./useRef";

export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
  // 매 렌더링마다 latestFnRef.current 값이 업데이트됩니다.
  const latestFnRef = useRef(fn);
  latestFnRef.current = fn;

  // dispatcherRef: "latestFnRef을 여는 행위"를 하는 함수를 담을겁니다. 해당 함수는 자체는 변하지 않음.
  const dispatcherRef = useRef<(...args: any[]) => any>();

  // "latestFnRef을 여는 행위"를 정의합니다.
  // 이 함수는 매 렌더링마다 새로 생성됩니다.
  // 따라서 항상 최신 스코프의 `latestFnRef`를 참조합니다.
  const dispatcher = (...args: any[]) => {
    return latestFnRef.current(...args);
  };

  // 새로 생성된 dispatcher 함수를 dispatcherRef의 내용물로 업데이트.
  dispatcherRef.current = dispatcher;

  // autoCallback: 이 함수의 참조는 절대 변하면 안 됩니다.
  // autoCallback은 오직 "dispatcherRef를 열어서 그 안의 dispatcher를 실행"하는 것입니다.
  const autoCallback = useCallback((...args: any[]) => {
    // 이 함수가 호출될 때, 그 시점의 dispatcherRef를 열고,
    // 그 안에 있는 최신 dispatcher를 실행합니다.
    return dispatcherRef.current?.(...args);
  }, []); // 참조 고정

  return autoCallback as T;
};

두 개의 ref 를 사용하여 코드의 복잡성을 증가 시킴.

학습 효과 분석

가장 큰 배움은 전반적인 리액트의 훅을 알게된 점이다. 과제지만 조금은 리액트를 뜯어본 느낌이였고 리액트가 어떤 방식으로 실행하는지 알아가는 과정 덕에 다음 과제 또는 다른 프로젝트에서 리액트를 써본다면 이번보다는 더 친해지지 않을까 싶다.

추가적으로는 Typescript 도 학습을 해야한다고 느꼈다. 어떻게 쓰겠다고 명시한다는게 좋지만 과제를 하면서 살짝 귀찮다고 느껴지는 경우도 많았다. 아직 손에 익지 않아서라고 생각이 든다. 익숙해지면 정말 개발에 많이 유용할 것 같다.

과제 피드백

e2e 테스트에서 맨 마지막 항목이 제일 인상 깊습니다. 아무래도 과제의 마침표를 찍는 테스트이기도 하지만 위에서도 얘기했듯이 3 주차 과제의 주제를 명확히 담고 있다고 개인적으로 생각합니다. (내가 만들고 내가 활용한다.) 하지만 심화 까지 가야지만 누릴 수 있는 테스트이기에 기본에서도 비슷한 포맷의 테스트가 있다면 좋을 것 같습니다. (기본에도 해당 뉘앙스의 테스트가 있는데 제가 못 알아챘을 수 있습니다. 그렇다면 죄송합니다.)

학습 갈무리

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

렌더링이 발생 되는 상황

  1. 최초 렌더링: 애플리케이션이 처음 로드 될 때
  2. 상태 변경: 컴포넌트 내에서 useStateuseReducer 의 setter 함수가 호출되어 상태가 변경 될 때
  3. 부모 컴포넌트의 리렌더링: 특별한 최적화 처리를 하지 않았다면 모든 자식 컴포넌트 리렌더링

렌더 와 커밋

  1. 렌더: 계획 세우기 (DOM 변경 없음)
  • 리렌더링 트리거: state 변경등으로 리렌더링 시작
  • Virtual DOM 생성
  1. 커밋: DOM 에 변경 사항 적용
  • 재조정(Reconciliation): 새로 만들어진 Virtual DOM과 이전 렌더링 때 만들었던 Virtual DOM을 비교 (Diffing)
  • 최소한의 변경 사항 계산: Diffing 알고리즘을 통해서 계산
  • DOM 업데이트

📍정리

  1. State나 Props가 변경되면 컴포넌트의 리렌더링이 시작됩니다.
  2. React는 컴포넌트를 실행하여 Virtual DOM이라는 가상의 UI 구조를 만듭니다.
  3. 이전의 Virtual DOM과 비교하여 바뀐 부분만 찾아냅니다 (Reconciliation).
  4. 바뀐 부분만 실제 DOM에 적용하여 성능을 최적화합니다.

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

성격, 성향적으로 효율성을 추구하는 나에게는 되게 신선하게 다가온 개념이었다. 먼저, 리액트가 해당 개념을 첫 도입한게 18년 10월이라고 한다. 그 뒤로 Hooks가 공식 도입되면서 코드 구조가 깔끔해지고 입문 장벽이 낮아졌다고 한다. 물론 남발하면 좋지 않지만

  1. 불필요한 캐싱 비용
  2. 의존성 관리 어려움
  3. 코드 가독성 저하

적재적소에 사용하면 성능 최적화는 보장이 되니까 굉장히 매력적이다. 특정 상황에 따라 사용하는 Hooks 가 달라지는 것도 좋다. 개발자에게 맥가이버 칼을 쥐어준 느낌이랄까... 과제로 인해 처음 접해본 개념이니까 아직은 사용 빈도가 높진 않지만 나중엔 꽤나 밥 먹듯이 사용할 수 있을 것 같은 예감이 든다.

내가 구현한 hooks

// useMemo.ts
import type { DependencyList } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "./useRef";

export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
  // 단 한 번만 초기화. 컴포넌트가 사라지기 전까지 유지.
  const memoizedRef = useRef<{ deps: DependencyList; result: T } | null>(null);

  if (memoizedRef.current === null || !_equals(memoizedRef.current.deps, _deps)) {
    // 만약 새로운 값으로 업데이트 해야한다면 랜더링을 유발하지 않는 방식으로..
    memoizedRef.current = { deps: _deps, result: factory() };
  }
  // 마지막엔 저장된 result 반환
  return memoizedRef.current.result;
}

// useCallback.ts
import type { DependencyList } from "react";
import { useMemo } from "./useMemo";

export function useCallback<T extends Function>(factory: T, _deps: DependencyList) {
  const callbackFunc = useMemo(() => factory, _deps);
  return callbackFunc as T;
}

// memo.ts
import { type FunctionComponent } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "../hooks";

export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
  // 새로운 컴포넌트 정의, 렌더링 할 때 props 넘겨줌.
  const memoizedComponent: FunctionComponent<P> = (props) => {
    const cache = useRef<{ preProps: P; result: React.ReactElement } | null>(null);
    // 최초 렌더링 또는 props 변경 시 컴포넌트 호출하여 렌더링 값 설정
    if (cache.current === null || !equals(cache.current.preProps, props)) {
      const newResult = Component(props);
      cache.current = { preProps: props, result: newResult };
    }
    return cache.current.result;
  };
  return memoizedComponent;
}


</div>

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

리뷰 받고 싶은 내용

  1. 개선이 필요한 코드에서도 useAutoCallback 를 언급했습니다. latestFnRef와 dispatcherRef라는 두 개의 ref를 사용하여 구현했습니다. 주석으로 표기를 해놨으나 패턴을 쉽게 이해하기는 어려울 것으로 보입니다. 구조를 명확하지만 쉽게 파악할 수 있는 방법에 대해 리뷰 부탁드리겠습니다. 감사합니다.
useAutoCallback
import type { AnyFunction } from "../types";
import { useCallback } from "./useCallback";
import { useRef } from "./useRef";

export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
  // 매 렌더링마다 latestFnRef.current 값이 업데이트됩니다.
  const latestFnRef = useRef(fn);
  latestFnRef.current = fn;

  // dispatcherRef: "latestFnRef을 여는 행위"를 하는 함수를 담을겁니다. 해당 함수는 자체는 변하지 않음.
  const dispatcherRef = useRef<(...args: any[]) => any>();

  // "latestFnRef을 여는 행위"를 정의합니다.
  // 이 함수는 매 렌더링마다 새로 생성됩니다.
  // 따라서 항상 최신 스코프의 `latestFnRef`를 참조합니다.
  const dispatcher = (...args: any[]) => {
    return latestFnRef.current(...args);
  };

  // 새로 생성된 dispatcher 함수를 dispatcherRef의 내용물로 업데이트.
  dispatcherRef.current = dispatcher;

  // autoCallback: 이 함수의 참조는 절대 변하면 안 됩니다.
  // autoCallback은 오직 "dispatcherRef를 열어서 그 안의 dispatcher를 실행"하는 것입니다.
  const autoCallback = useCallback((...args: any[]) => {
    // 이 함수가 호출될 때, 그 시점의 dispatcherRef를 열고,
    // 그 안에 있는 최신 dispatcher를 실행합니다.
    return dispatcherRef.current?.(...args);
  }, []); // 참조 고정

  return autoCallback as T;
};

과제 피드백

정석님 배포 링크에 감동을 받았네요. 그나저나 gemini cli 결과를 보는 부분에서는 저 파랑색은 참 색이 화려하네요.

전에 말했던것 처럼 이전 항해를 하셨던 분들 중 정말 많은 분들 중에 리액트를 사용해보지 않았던 분들이 계셨지만 이렇게 문서까지 남기시면서 하셨던 분들은 정말 없었어요. 너무너무 잘해주고 계십니다 :+1 이런 부분들이 이제 추후에 리액트를 활용해 과정을 해나가시는데 있어서 적절하게 요소요소마다 API를 고르고 사용하는데 큰 도움이 될 것 같네요.

질문 주신거 바로 살펴보면요.

개선이 필요한 코드에서도 useAutoCallback 를 언급했습니다. latestFnRef와 dispatcherRef라는 두 개의 ref를 사용하여 구현했습니다. 주석으로 표기를 해놨으나 패턴을 쉽게 이해하기는 어려울 것으로 보입니다. 구조를 명확하지만 쉽게 파악할 수 있는 방법에 대해 리뷰 부탁드리겠습니다. 감사합니다.

우선 지금 구현을 보면 중간 과정에 어떤 의도가 있었는지 제가 파악은 못했지만 지금의 구조를 수정한다면 dispatcherRef가 불필요한 것 같아요! 바로 latestFn을 호출 하는 방식으로 하면 충분할 것 같아요. 혹시 궁금하셨던 부분이나 의도하셨던 부분이 제가 답변 드린 부분과 달랐다면 꼭 다시 질문주세요! (지훈님 코드리뷰 하시면서 본 것 같네요 ㅋㅋㅋ 동일한 의견입니다)

지치신거 아니죠? 정석님 지켜보겠습니다.. 다음 주도 화이팅입니다!