Skip to content

[7팀 정건휘] Chapter 1-3. React, Beyond the Basics#44

Open
geonhwiii wants to merge 20 commits intohanghae-plus:mainfrom
geonhwiii:main
Open

[7팀 정건휘] Chapter 1-3. React, Beyond the Basics#44
geonhwiii wants to merge 20 commits intohanghae-plus:mainfrom
geonhwiii:main

Conversation

@geonhwiii
Copy link

@geonhwiii geonhwiii commented Jul 22, 2025

과제 체크포인트

배포 링크

https://geonhwiii.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가 왜 이런 API를 제공하는지 이해할 수 있었습니다. 복잡한 내부 로직을 간단한 API로 감싸주는 추상화로 인해 사용자가 얼마나 편하게 사용할 수 있는지 배웠습니다.

또, React에서 제공하는 useState를 제외한 api는 custom hook의 일종이라는 것도 깨달았습니다.

기술적 성장

  • useRefuseState의 lazy initialization을 활용한다는 원리를 이해
  • useCallbackuseMemo의 관계
  • 얕은 비교와 깊은 비교의 내부적인 동작에 대한 이해

자랑하고 싶은 코드

// useRef.ts
import { useState } from "react";

interface MutableRefObject<T> {
  current: T;
}

/**
 * 1. DOM 요소 null 초기화 대응
 * const ref = useRef<HTMLDivElement>(null)
 *
 * 2. 초기값이 없는 경우 대응
 * const ref = useRef<string>();
 */
export function useRef<T>(initialValue: T): MutableRefObject<T>;
export function useRef<T>(initialValue: T | null): MutableRefObject<T | null>;
export function useRef<T = undefined>(): MutableRefObject<T | undefined>;

export function useRef<T>(initialValue?: T): MutableRefObject<T | undefined> {
  const [ref] = useState(() => ({ current: initialValue }));
  return ref;
}

실제 useRef사용과 비슷하게 사용할 수 있도록 타입 추론을 보완했어요.

추가적인 타입 선언이 없을 경우,

const ref = useRef<HTMLDivElement>(null)는 타입 오류가 발생해요.


// deepEquals.ts
 export const deepEquals = (a: unknown, b: unknown) => {
    if (a === b) return true;
    if (a == null || b == null) return false;
    if (typeof a !== typeof b) return false;

    if (Array.isArray(a) && Array.isArray(b)) {
      if (a.length !== b.length) return false;
      // 각 요소를 재귀적으로 비교
      for (let i = 0; i < a.length; i++) {
        if (!deepEquals(a[i], b[i])) return false;
      }
      return true;
    }

    if (typeof a === "object" && typeof b === "object") {
      const objA = a as Record<string, unknown>;
      const objB = b as Record<string, unknown>;
      const keysA = Object.keys(objA);
      const keysB = Object.keys(objB);

      if (keysA.length !== keysB.length) return false;
      // 각 키의 값을 재귀적으로 비교
      for (const key of keysA) {
        if (!deepEquals(objA[key], objB[key])) return false;
      }
      return true;
    }

    return false;
  };

각 조건문이 의미하는 바가 명확하고, 이해가 간단하다고 생각해요.

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

// AS-IS
export function useShallowState<T>(initialValue: T | (() => T)): [T, React.Dispatch<React.SetStateAction<T>>] {
  const [state, setState] = useState(initialValue);

  const setShallowState = useCallback((newValue: T | ((prev: T) => T)) => {
    setState((prevState) => {
      const nextState = typeof newValue === "function" ? (newValue as (prev: T) => T)(prevState) : newValue;
      // 1. 얕은 비교 값이 같다면 이전 상태 반환
      if (shallowEquals(nextState, prevState)) {
        return prevState;
      }
      // 2. 값이 다르다면 새로운 상태 반환
      return nextState;
    });
  }, []);

  return [state, setShallowState];
}

// TO-BE
export function useShallowState<T>(initialValue: T | (() => T)): [T, React.Dispatch<React.SetStateAction<T>>] {
  const [state, setState] = useState(initialValue);

  const setShallowState = useCallback((newValue: T | ((prev: T) => T)) => {
    setState((prevState) => {
      const nextState = isFunction(newValue) ? newValue(prevState) : newValue;

      // 1. 얕은 비교로 값이 같다면 이전 상태 반환
      if (shallowEquals(nextState, prevState)) {
        return prevState;
      }

      // 2. 값이 다르다면 새로운 상태 반환
      return nextState;
    });
  }, []);

  return [state, setShallowState];
}

function isFunction<T>(value: T | ((prev: T) => T)): value is (prev: T) => T {
  return typeof value === "function";
}

강제 타입캐스팅을 강제하는 부분을 줄이고 싶었어요.

타입 가드 함수를 활용해서, 강제 타입 캐스팅을 줄이고, 타입 안정성을 보완할 수 있을 것 같아요.

학습 효과 분석

이전의 사고방식:

  • React Hooks는 그냥 주어진 API로만 생각하였어요.
  • "어떻게 쓸까?"에만 집중하였어요.

현재의 사고방식:

  • "왜 이렇게 설계되었을까?"를 항상 고민하게 되었어요.
  • "내부적으로 어떻게 동작할까?"를 생각할 수 있게 되었어요

과제 피드백

학습 갈무리

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

리렌더링이 발생하는 조건:

  1. 상태 변경: 상태 비교로 이전 값과 다를 때
  2. Props 변경: 부모로부터 받은 props가 변경될 때
  3. Context 변경: 구독 중인 Context 값이 변경될 때

렌더링 최적화

  1. 불필요한 리렌더링 방지:
// ❌ 매번 새로운 객체 생성
<Component style={{ margin: 10 }} />
// ✅ 참조 안정화
const style = useMemo(() => ({ margin: 10 }), []);
<Component style={style} />

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

메모이제이션은 비용이 많이 드는 곳에 사용하면 이점을 갖지만,
남용을 하게 될 경우, 코드의 복잡성이 증대될 수 있어요.

최근 리액트 컴파일러 발표 영상을 보면, 리액트 개발팀조차도 메모이제이션을 실수할 때도 있다고 언급한 부분을 보았을 때, 쉬운 영역이라고도 생각이 들지 않아요.

의도를 명확히 사용하고, 유지보수성을 고려해서 사용하면 좋을 것 같아요.

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

FSD 아키텍처가 유행하는 이유는 대규모 아키텍처에 대한 고민도 있지만,

도메인, 기능에 대해서 관리 포인트를 가장 가까운 곳에서 관리하려는 것도 있다고 생각해요.

이와 마찬가지로, 상태도 가장 가까운 곳에서 관리하는 것이 중요해요.

컨텍스트는 Provider를 통해 의존성을 깊게 주입해야 하는 상황에서 사용하면 좋을 것 같아요.

useSyncExternalStore의 등장으로 리렌더링을 줄이면서, selector를 통해 선택적으로 상태에 대해 구독을 할 수 있게 되었어요.

따라서, 개발자는 항상 비용을 고려해서 사용할 수 있도록 노력해야해요.

리뷰 받고 싶은 내용

useSyncExternalStore에 대한 이해가 아직은 부족한 것 같습니다.

이번 과제에서 사용한 useStore와 같은 store 외에 활용 방법이 있을까요?

그리고, useSyncExternalStore를 단일로 사용하는 것보다

context api와 결합해서 사용하는 패턴이 더 좋은 것일지,

상황에 따라 선택하는 것이 맞을지 궁금합니다.

@geonhwiii
Copy link
Author

⭐ 팀 코드 리뷰용

자랑하고 싶은 코드

// useRef.ts
import { useState } from "react";

interface MutableRefObject<T> {
  current: T;
}

/**
 * 1. DOM 요소 null 초기화 대응
 * const ref = useRef<HTMLDivElement>(null)
 *
 * 2. 초기값이 없는 경우 대응
 * const ref = useRef<string>();
 */
export function useRef<T>(initialValue: T): MutableRefObject<T>;
export function useRef<T>(initialValue: T | null): MutableRefObject<T | null>;
export function useRef<T = undefined>(): MutableRefObject<T | undefined>;

export function useRef<T>(initialValue?: T): MutableRefObject<T | undefined> {
  const [ref] = useState(() => ({ current: initialValue }));
  return ref;
}

실제 useRef사용과 비슷하게 사용할 수 있도록 타입 추론을 보완했어요.

추가적인 타입 선언이 없을 경우,

const ref = useRef<HTMLDivElement>(null)는 타입 오류가 발생해요.


// deepEquals.ts
 export const deepEquals = (a: unknown, b: unknown) => {
    if (a === b) return true;
    if (a == null || b == null) return false;
    if (typeof a !== typeof b) return false;

    if (Array.isArray(a) && Array.isArray(b)) {
      if (a.length !== b.length) return false;
      // 각 요소를 재귀적으로 비교
      for (let i = 0; i < a.length; i++) {
        if (!deepEquals(a[i], b[i])) return false;
      }
      return true;
    }

    if (typeof a === "object" && typeof b === "object") {
      const objA = a as Record<string, unknown>;
      const objB = b as Record<string, unknown>;
      const keysA = Object.keys(objA);
      const keysB = Object.keys(objB);

      if (keysA.length !== keysB.length) return false;
      // 각 키의 값을 재귀적으로 비교
      for (const key of keysA) {
        if (!deepEquals(objA[key], objB[key])) return false;
      }
      return true;
    }

    return false;
  };

각 조건문이 의미하는 바가 명확하고, 이해가 간단하다고 생각해요.

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

// AS-IS
export function useShallowState<T>(initialValue: T | (() => T)): [T, React.Dispatch<React.SetStateAction<T>>] {
  const [state, setState] = useState(initialValue);

  const setShallowState = useCallback((newValue: T | ((prev: T) => T)) => {
    setState((prevState) => {
      const nextState = typeof newValue === "function" ? (newValue as (prev: T) => T)(prevState) : newValue;
      // 1. 얕은 비교 값이 같다면 이전 상태 반환
      if (shallowEquals(nextState, prevState)) {
        return prevState;
      }
      // 2. 값이 다르다면 새로운 상태 반환
      return nextState;
    });
  }, []);

  return [state, setShallowState];
}

// TO-BE
export function useShallowState<T>(initialValue: T | (() => T)): [T, React.Dispatch<React.SetStateAction<T>>] {
  const [state, setState] = useState(initialValue);

  const setShallowState = useCallback((newValue: T | ((prev: T) => T)) => {
    setState((prevState) => {
      const nextState = isFunction(newValue) ? newValue(prevState) : newValue;

      // 1. 얕은 비교로 값이 같다면 이전 상태 반환
      if (shallowEquals(nextState, prevState)) {
        return prevState;
      }

      // 2. 값이 다르다면 새로운 상태 반환
      return nextState;
    });
  }, []);

  return [state, setShallowState];
}

function isFunction<T>(value: T | ((prev: T) => T)): value is (prev: T) => T {
  return typeof value === "function";
}

강제 타입캐스팅을 강제하는 부분을 줄이고 싶었어요.

타입 가드 함수를 활용해서, 강제 타입 캐스팅을 줄이고, 타입 안정성을 보완할 수 있을 것 같아요.

@adds-bug
Copy link

메모이제이션에 대한 생각을 정리해주신 부분이 인상적입니다!
지난 주 과제에서 Virtual Dom이 만능키가 아니라는 것을 알게 됐을 때의 느낌이에요.

@BangDori
Copy link

BangDori commented Jul 28, 2025

그리고 useSyncExternalStore를 단일로 사용하는 것보다 context api와 결합해서 사용하는 패턴이 더 좋은 것일지, 상황에 따라 선택하는 것이 맞을지 궁금합니다.

useSyncExternalStore는 주로 전역 상태 관리 도구에서 핵심적으로 활용되는 훅으로, Context API와의 조합 여부는 상황에 따라 달라진다고 생각해요. 그래서 여기서는 전역 상태 관리 도구와 Context API의 사용성에 대한 저의 생각을 공유드리겠습니다!

Context API는 리액트 컴포넌트 트리 안에서 데이터를 공유할 수 있는 수단으로, 구조적으로 "상위 → 하위" 방향의 데이터 흐름에 적합해요. 하지만 값이 자주 바뀌는 상태를 Context로 전달하게 되면, 해당 Context를 구독 중인 모든 하위 컴포넌트가 리렌더링되는 문제가 생길 수 있어요. 이런 점에서 성능 측면의 단점이 존재하죠...!

반면, useSyncExternalStore는 외부 스토어의 구독-해제를 명확하게 제어할 수 있어, 필요한 컴포넌트만 리렌더링되도록 할 수 있는 장점이 있습니다. 이 훅은 전역 상태가 컴포넌트와 독립적으로 관리되고, 변경 사항에 대해서만 필요한 컴포넌트가 반응해야 할 때 특히 유용해요.

따라서 useSyncExternalStore를 Context API와 결합해 사용하는 패턴은, 구독 대상은 많지만 값의 변화가 자주 발생하지 않는 구조에서 성능과 구조의 균형을 맞추는 데 도움이 됩니다. 예를 들어 테마, 로케일, 인증 정보처럼 전역에서 필요한 값이지만 자주 바뀌지 않는 경우에는 Context만으로도 충분하고, 반대로 실시간으로 자주 바뀌는 상태(예: 커서 위치, 실시간 피드 등)는 외부 스토어와 useSyncExternalStore의 조합이 적절하다고 생각해요!


약간 TMI인데,,, 이건 제가 Context를 사용하는 또 다른 하나의 방법입니다!

제가 다른 회사의 사전 과제 테스트를 수행할 때, 데이터 페칭이 많지 않거나 캐싱 등 복잡한 옵션이 필요하지 않은 경우에는 외부 라이브러리 도움 없이 react에서 제공하는 api들을 최대한 이용해서 다음과 같이 처리해요!

<ErrorBoundary>               -> Error 상태 처리
  <Suspense>                  -> Loading 상태 처리
    <CategoryFetcher>         -> 서버에 저장된 카테고리 정보를 가져와서 저장하는 Context
      <CategoryList />        -> Context에 저장된 카테고리 목록을 렌더링하는 UI
    </CategoryFetcher>
  </Suspense>
</ErrorBoundary>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants