tooth-is-silver 님의 상세페이지[3팀 이가은] Chapter 1-3. React, Beyond the Basics

과제 체크포인트

배포 링크

https://tooth-is-silver.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 개선

과제 셀프회고

기술적 성장

  • 옵저버 패턴과 메모리 관리 createObserver

    • 옵저버 패턴의 1:N 의존성 구조와 발행-구독 모델에 대해 코드로 풀어보며 이해하는 시간을 가졌습니다.
    • 메모리 누수 방지를 위한 이벤트 리스너 정리를 이해할 수 있었습니다.
  • React 내부 구현 탐구 useStore

    • useSyncExternalStoreWithSelector의 내부 구현 로직을 파악하고 과제에 적용할 수 있는 고민과 이해의 시간을 가졌습니다.
    • 당연하게 사용하던 React hooks의 의존성 배열과 메모이제이션의 관계성을 명확히 알게 되었습니다.
  • 고차 컴포넌트 패턴 memo, deepMemo

    • useRef를 이용한 이전 props 참조를 유지하고 어떻게 리렌더링이 트리거 되는지 이해하는 시간을 가졌습니다.
  • 얕은 비교와 깊은 비교의 차이점 shallowEquals, deepEquals

    • 프로토타입, 생성자, Date/RegExp/function 타입의 예외처리를 추가하고 테스트 코드로 오버엔지니어링도 해보는 유익한 시간이었습니다.
    • 깊은 비교에서 재귀로 동작시 WeakSet을 활용하여 순환 참조에 대해 고려해보는 시간을 가졌습니다.
  • Symbol.iterator의 동작 원리 이해 shallowEquals

    • Zustand의 shallow 함수를 확인하였고 for ...of와의 비교를 통해 Symbol.iterator의 성능과 구현 목적에 대해 확인해보는 시간이었습니다.
  • useCallback과 useAutoCallback 비교 useAutoCallback

    • useAuthCallback의 존재 이유(성능 최적화, 의존성 관리 불필요, 항상 최신 함수 참조)에 대해 분석해보는 시간이었습니다.

자랑하고 싶은 코드

  • shallowEquals.ts
    • zustand의 shallow 함수에서 감명을 받아 오버엔지니어링을 시도한 부분입니다.
    • 테스트 코드를 항플 들어와서 처음 접했는데 테스트코드를 추가해보고 테스트 통과까지 진행해본 부분도 얼마 안되는 코드이지만 적용해보길 잘 했다고 생각합니다.
    // 동일한 함수인가요?
    if (typeof a === "function" && typeof b === "function") {
      return a.toString() === b.toString();
    }
    
    // object가 아닌가요?
    if (typeof a !== "object" || typeof b !== "object") {
      return false;
    }
    
    // 생성자가 같은가요?
    if (a.constructor !== b.constructor) {
      return false;
    }
    
    // 동일한 Date 값인가요?
    if (a instanceof Date && b instanceof Date) {
      // .getTime()으로 비교하면 값이 같음으로 true로 반환 할 수 있다.
      return a === b;
    }
    
    // 동일한 RegExp 값인가요?
    if (a instanceof RegExp && b instanceof RegExp) {
      return a.source === b.source && a.flags === b.flags;
    }
    
    // basic.test.tsx
    const date = new Date("2023-01-01");
    const obj = { getTime: () => new Date("2023-01-01").getTime() };
    expect(shallowEquals(date, obj)).toBe(false); // 생성자 비교
    
    const dateA = new Date("2023-01-01T00:00:00Z");
    const dateB = new Date("2023-01-01T00:00:00Z");
    expect(shallowEquals(dateA, dateB)).toBe(false); // 다른 Date 참조
    
    const regexA = /abc/gi;
    const regexB = new RegExp("abc", "gi");
    expect(shallowEquals(regexA, regexB)).toBe(true); // 정규식 내용이 같음
    
    const functionA = () => {
      console.log("펑션A입니다.");
    };
    const functionB = () => {
      alert("알럿 발생");
    };
    expect(shallowEquals(functionA, functionB)).toBe(false); // 내부 동작이 다름
    

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

  • useShallowState.ts
    • ESLint exhaustive-deps 규칙 위반으로 워닝이 뜨고 있으나 기능은 정상 동작중입니다. 동일한 기능을 하면서도 의존성 배열을 사용하지 않는 방법으로 개선하고 싶은데 useAutoCallback을 사용하면 의존성 배열을 사용하지 않아도 되지만 차선책일 뿐 최선은 아니라고 생각되었습니다.
    • 내부 return문에서 아래와 같이 의존성 배열을 제거하는 방식으로 사용하고 싶은데 newValue가 function으로 들어올 수 있는 부분때문에 initialValue의 타입 지정이 어려웠습니다.
    const setShallowValue = useCallback((newValue: SetStateAction<T>) => {
      setValue((prev) => {
        const resolved =
          typeof newValue === "function"
            ? newValue(prev) // newValue 타입 지정이 복잡함
            : newValue;
        return shallowEquals(prev, resolved) ? prev : resolved;
      });
    }, []);
    

학습 효과 분석

  • React 생태계의 내부 동작 원리를 자세히 알게되었습니다. 특히 useSyncExternalStore는 학습 전에는 전혀 모르던 개념이었는데 학습하고나니 React가 외부 라이브러리와의 상태 동기화를 위해 만들어졌으며, 동일한 스냅샷을 기억하기 위해 내부에선 어떤 방식을 채택하게 되었는지 알게된 아주 뜻 깊은 시간이었습니다. 게다가 서버 렌더링도 지원하는 부분도 인상깊었습니다.
  • Zustand의 shallow 함수를 보면서 edge case처리에 대한 고민을 할 수 있었으며, 테스트 코드도 작성하며 어떻게 테스트 코드를 작성해야 확실하게 검증을 할 수 있을지 고민하는 방법을 배웠습니다.
  • useAutoCallback은 나중에 적극적으로 도입해보고 싶어요. ESLint exhaustive-deps 규칙 위반으로 의존성 배열에 eslint-disable 주석 처리하던 부분을 사용하지 않고 의존성 배열을 신경쓰지 않아도 되서 실무에서 사용해도 너무 좋을 것 같다는 생각이 들었습니다.
  • 테스트 코드에 대한 학습이 필요하다고 느꼈습니다. 테스트 주도 개발에 대해 익숙하지 않다보니 아직도 테스트 코드 기반으로 내부 로직을 이해하는데 시간이 오래걸립니다. (물론 발제 자료가 훌륭해서 많은 도움이 됩니다!) 하지만 주차별 과제를 진행하면서 계속 나아질 수 있는 부분이라고 생각하고 열심히 진행해보려고합니다 ☀️

과제 피드백

  • 발제 자료가 너무 많아서 배터질 것 같아요. 아직 다 못먹었는데 소화 시키면서 열심히 보고 있습니다. 감사합니다. 🙇‍♂️🙇‍♂️🙇‍♂️
  • Zustand, React, Preact를 통해서 코드를 분석하고 의도를 파악하고 과제를 완성하는 플로우의 경험이 너무 좋았습니다.

학습 갈무리

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

  • 리액트는 렌더링이 일어나는 동안 최상위 루트에서부터 쭈욱 마지막 차일드 엘리먼트까지 훑어 내려가면서 플래그가 지정되어있는 컴포넌트들을 기억합니다. 그리고 수집이 끝나면 잘 알고있는 가상 돔 VirtualDom을 만들어 놓고 실제 돔과 비교하며(diff 알고리즘 사용) 변경되는 사항들을 체크하는데 이 과정을 재조정 Reconcilation이라고 합니다.
  • 리액트는 컴포넌트 생성시 트리에 컴포넌트가 삽입된 직후에 componentDidMount를 호출한다. 컴포넌트의 상태가 변경이 되면 componentDidUpdate가 호출됩니다. 컴포넌트가 언마운트 되며 제거되기 바로 직전에 componentWillUnmount가 호출됩니다. 타이머를 제거하거나 구독 해제와 같은 동작을 합니다.
  • useState는 컴포넌트 내부의 정보를 기억하며 업데이트합니다. useContext는 props드릴링 없이 부모 컴포넌트에서 전달받은 상태를 구독하여 정보를 업데이트할 수 있습니다. useRef는 렌더링을 트리거 하지 않는 DOM의 정보들을 담을 수 있습니다. useEffect는 외부 시스템, 네트워크, 다른 라이브러리를 연결할 때 사용합니다. 흔히 API를 렌더 전 fetching할 때 사용하지만 리액트에서는 지양하는 방법입니다. useMemo는 연산 후 기억해야할 값이 있을때 사용합니다. 기억해야할 값은 객체, 함수, 원시 값을 포함합니다. useCallback은 정의된 함수를 기억해야할 때 사용합니다. 매번 새로운 함수를 생성하지 않고 동일한 이전 함수를 참조합니다.
  • 리액트에서 렌더링을 최적화 하려면 상태나 함수를 구독하고 있는 컴포넌트를 관심사별로 쪼개거나 useMemo, useCallback을 적절히 사용하며 메모제이션하고 이전 상태를 기억하여 성능 이슈가 발생되지 않도록 합니다.

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

  • 메모이제이션은 큰 프로젝트에서는 성능 최적화를 적용하는 모든 단계를 찾는데 드는 리소스 비용이 훨씬 크기 때문에 필수적으로 적용하는게 좋다고 생각합니다. 코치님도 QnA시간에 그렇게 말씀하셨고요!
  • 메모이제이션을 사용하면 성능 최적화를 해결할 수 있으나 메모이제이션에 의존하게 되는 코드가 많아지게 되어 다른 코드들도 동일하게 메모이제이션을 적용해야하는 리소스가 생깁니다.
  • 메모이제이션을 쓰지않고 성능 최적화를 하려면 설계적인 부분에 좀 더 신경써야 할 것 같아요. 컴포넌트를 세부적으로 독립적인 컴포넌트로 분리하거나 children props를 사용하여 부모 상태와는 무관한 렌더링 상태를 유지하도록 고려해야 합니다. 또한 Ref를 적극적으로 사용하며 상태를 사용할때 객체 형태가 아닌 useState로 독립적으로 사용해야합니다.

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

  • 컨텍스트는 컴포넌트에서 사용하는 작은 단위의 데이터를 연관된 컴포넌트와 공유하고자 할때 사용합니다. 하지만 컨텍스트도 관리해야할 데이터가 많아지면 분리해야하며 이로인해 컨텍스트 관리 포인트가 증가합니다.
  • 상태관리 라이브러리는 컨텍스트보다 큰 단위의 데이터를 여러 컴포넌트에서 나눠서 사용할 수 있도록 최적화되어있습니다. 우리가 과제에서 만든 useStore처럼 상태 저장소와 컴포넌트를 연결하여 성능을 최적화하는 방법이 핵심 요소입니다.

리뷰 받고 싶은 내용

  • useStore
const defaultSelector = <T, S = T>(state: T) => state as unknown as S;

export const useStore = <T, S = T>(store: Store<T>, selector: (state: T) => S = defaultSelector<T, S>) => {
  // useSyncExternalStore와 useShallowSelector를 사용해서 store의 상태를 구독하고 가져오는 훅을 구현해보세요.
  const shallowSelector = useShallowSelector(selector);
  const { getState, subscribe } = store;

  const value = useSyncExternalStore(
    subscribe,
    () => shallowSelector(getState()),
    () => shallowSelector(getState()),
  );
  return value;
};

현재 기능상에 문제는 없지만 useSyncExternalStore에 전달하고 있는 세번째 인자를 다음과 같이 사용해도 되는건지 궁금합니다. useStore에 serverSnapshot을 받는 경우를 생각하고 다음과 같은 코드로 사용해도 괜찮을까요?

export const useStore = <T, S = T>(
  store: Store<T>, 
  selector: (state: T) => S = defaultSelector<T, S>,
  serverSnapshot?: S // 서버사이드 스냅샷 옵셔널로 명시
) => {
  const shallowSelector = useShallowSelector(selector);
  const { getState, subscribe } = store;
  const value = useSyncExternalStore(
    subscribe,
    () => shallowSelector(getState()),
    () => serverSnapshot ?? shallowSelector(getState())
  );
  return value;
};
  • ToastProvider
// useState
  const [value] = useState(() => ({
    show: showWithHide,
    hide,
  }));

// useMemo
  const value = useMemo(
    () => ({
      show: showWithHide,
      hide,
    }),
    [showWithHide, hide],
  );

ToastProvider에서 show와 hide 엑션을 최적화 하기 위해 코치님께서 언급해주신 최적화 방법중에 하나인 useState를 사용했습니다. useMemo를 사용하여 최적화 하신 분들도 많이 보였는데 기능상의 문제는 없으나 useState를 사용할 떄와 useMemo를 사용할때의 차이점이 궁금합니다. 분명 차이가 있을 것 같은데 어떤 점을 고려하고 선택해야할지 고민이에요.

과제 피드백

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

useAutoCallback은 나중에 적극적으로 도입해보고 싶어요. ESLint exhaustive-deps 규칙 위반으로 의존성 배열에 eslint-disable 주석 처리하던 부분을 사용하지 않고 의존성 배열을 신경쓰지 않아도 되서 실무에서 사용해도 너무 좋을 것 같다는 생각이 들었습니다.

맞아요.. 저도 최근에 발견(?) 해서 유용하게 쓰일 수 있을 것 같다고 생각했고 부랴부랴 과제로 만든거였답니다 ㅋㅋ 알아봐주시니 감사할따름..

테스트 코드에 대한 학습이 필요하다고 느꼈습니다. 테스트 주도 개발에 대해 익숙하지 않다보니 아직도 테스트 코드 기반으로 내부 로직을 이해하는데 시간이 오래걸립니다. (물론 발제 자료가 훌륭해서 많은 도움이 됩니다!) 하지만 주차별 과제를 진행하면서 계속 나아질 수 있는 부분이라고 생각하고 열심히 진행해보려고합니다 ☀️

원하지 않아도 7주차에 디테일하게 학습할 수 있답니다 ㅎㅎ 짜증도 내면서

현재 기능상에 문제는 없지만 useSyncExternalStore에 전달하고 있는 세번째 인자를 다음과 같이 사용해도 되는건지 궁금합니다. useStore에 serverSnapshot을 받는 경우를 생각하고 다음과 같은 코드로 사용해도 괜찮을까요?

이게 금요일 코드리뷰 세션에서 했던 이야기군요! 결론은 "서버에서 어떤식으로 동작할지에 대한 고찰이 필요함" 이라고 생각해요. 이걸 라이브러리 코드 내부에 바로 지정하기보단 인자로 받아와서 처리해주는게 안전할 것 같네요!

export const useStore = <T, S = T>(
  store: Store<T>, 
  selector: (state: T) => S = defaultSelector<T, S>,
  serverSnapshot?: S // 서버사이드 스냅샷 옵셔널로 명시
) => {
  const shallowSelector = useShallowSelector(selector);
  const { getState, subscribe } = store;
  const value = useSyncExternalStore(
    subscribe,
    () => shallowSelector(getState()),
    serverSnapshot ? () => serverSnapshot : undefined
  );
  return value;
};

요로코롬..

ToastProvider에서 show와 hide 엑션을 최적화 하기 위해 코치님께서 언급해주신 최적화 방법중에 하나인 useState를 사용했습니다. useMemo를 사용하여 최적화 하신 분들도 많이 보였는데 기능상의 문제는 없으나 useState를 사용할 떄와 useMemo를 사용할때의 차이점이 궁금합니다. 분명 차이가 있을 것 같은데 어떤 점을 고려하고 선택해야할지 고민이에요.

이 상황에서는 useMemo 를 사용하는게 목적에 더 적합하다고 생각해요! state는 "상태 변이의 가능성"을 염두하고 정의하는건고, useMemo는 "값을 저장"하는 목적으로 사용되는거라서요. 그래서 지금 케이스에서는 useState보다는 useMemo가 좋다고 생각합니다!


고생하셨어요 가은님!!