Skip to content

[4팀 김지혜] Chapter 1-3. React, Beyond the Basics#48

Open
adds9810 wants to merge 7 commits intohanghae-plus:mainfrom
adds9810:main
Open

[4팀 김지혜] Chapter 1-3. React, Beyond the Basics#48
adds9810 wants to merge 7 commits intohanghae-plus:mainfrom
adds9810:main

Conversation

@adds9810
Copy link

@adds9810 adds9810 commented Jul 23, 2025

과제 체크포인트

배포 링크

https://adds9810.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를 그냥 쓰기만 하던 라이브러리에서 진짜 어떻게 돌아가는지 알 수 있게 해준 과제였습니다. 강의에서 배워서 알고있던 useRef, useMemo 같은 것들을 useState부터 하나하나 직접 만들어보면서, 이런 기능들이 왜 만들어졌는지 알 수 있었습니다.
useSyncExternalStore 써서 외부 상태 연결하고, Context API 쓸 때 성능 문제 생기는 것도 경험해보면서 최적화가 왜 중요한지 알게 됐습니다.
이 모든 과정이 그냥 따로따로 알던 지식들을 하나로 연결해주고, React가 실제로 어떻게 작동하는지 깊게 이해할 수 있게 해줬습니다. 또한 AI 사용하는 방식도 1, 2주차에는 무작정 의존하던 것에서 이번에는 문서 보고 시도한 다음에 AI한테 질문하는 식으로 더 나아졌습니다.

기술적 성장

  1. React 훅의 내부 동작 원리 이해
// packages/lib/src/hooks/useRef.ts - 렌더링 되어도 참조값 유지
export function useRef<T>(initialValue: T): { current: T } {
  const [ref] = useState(() => ({ current: initialValue }));
  return ref;
}

// packages/lib/src/hooks/useMemo.ts - 의존성 비교 기반 메모이제이션
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
  // 1. 이전 의존성과 결과를 저장할 ref 생성
  const prevDeps = useRef<DependencyList>([]);
  const prevResult = useRef<T | null>(null);

  // 2. 현재 의존성과 이전 의존성 비교
  if (prevResult.current === null || !_equals(prevDeps.current, _deps)) {
    // 3. 의존성이 변경된 경우 factory(새로운 값을 계산) 함수 실행 및 결과 저장
    prevDeps.current = _deps;
    prevResult.current = factory();
  }

  // 4. 메모이제이션된 값 반환
  return prevResult.current;
}

강의에서 배웠던 훅들을 직접 구현해보니 useState 기반으로 동작한다는 것을 알게 되었습니다.

  1. useAutoCallback 패턴
// packages/lib/src/hooks/useAutoCallback.ts
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
 // 콜백함수가 참조하는 값은 항상 렌더링 시점에 최신화
  const fnRef = useRef(fn);
  fnRef.current = fn; 

  // 항상 같은 함수 반환
  const stableCallback = useCallback((...args: unknown[]) => {
    return fnRef.current(...args);
  }, []); // 빈 의존성 배열로 참조 고정

  return stableCallback as T;
};

"참조는 고정하되 최신 값은 참조"라는 까다로운 요구사항을 useRef + useCallback 조합으로 해결하는 패턴을 알게 되었습니다.

  1. Observer 패턴과 useSyncExternalStore 연동
// packages/lib/src/createObserver.ts
export const createObserver = () => {
  const listeners = new Set<Listener>();

  const unsubscribe = (fn: Listener) => {
    listeners.delete(fn);
  };

  const subscribe = (fn: Listener) => {
    listeners.add(fn); // 리스너 등록
    // 구독 취소 함수 반환
    return () => {
      unsubscribe(fn);
    };
  };

  const notify = () => listeners.forEach((listener) => listener());
  return { subscribe, notify };
};

useSyncExternalStore와 호환되려면 subscribe 함수가 구독 취소 함수를 반환해야 한다는 스펙을 학습했습니다.

기존 지식의 재발견/심화:

  • 메모이제이션: 강의에서 배웠던 useMemo, useCallback의 실제 구현 방식 이해
    이론적으로만 알고 있던 메모이제이션이 실제로는 useRef를 사용해서 이전 값과 현재 값을 비교하고, 의존성이 변경되었을 때만 새로운 값을 계산하는 방식으로 동작한다는 것을 직접 구현해보며 이해할 수 있었다. 특히 shallowEquals 함수를 사용해서 의존성 배열을 비교하는 부분에서, 단순히 === 비교가 아닌 얕은 비교를 통해 객체의 내용을 비교하는 것이 중요하다는 것을 체감했습니다.
  • 함수 참조 안정성: 이론으로만 알고 있던 개념을 실제로 구현하며 체감
    React에서 함수 참조가 불안정하면 하위 컴포넌트가 불필요하게 리렌더링된다는 이론을 알고 있었지만, 실제로 ToastProvider에서 함수들이 매번 새로 생성되어 ProductCard가 계속 리렌더링되는 문제를 겪어보면서 그 심각성을 직접 체감했다. 특히 Context value로 전달되는 함수들의 참조 안정성이 얼마나 중요한지, 그리고 useMemo, useCallback, useAutoCallback을 적절히 조합해서 사용해야 한다는 것을 실제 문제 해결 과정에서 깊이 이해할 수 있었습니다.

구현 과정에서의 기술적 도전과 해결:
useAutoCallback을 만들 때 타입 때문에 고생했습니다. 처음에는 타입을 몰라서 이렇게 코드만 적었었습니다.:

// 초기 코드 - 타입 에러 발생
export const useAutoCallback = (fn) => {
  const fnRef = useRef(fn);
  fnRef.current = fn;

  const stableCallback = useCallback((...args) => {
    return fnRef.current(...args);
  }, []);

  return stableCallback;
};

그런데 TypeScript에서 타입 에러가 나서 제네릭이라는 걸 처음 써봤는데, 함수의 타입을 미리 정해놓지 않고 나중에 정할 수 있게 하는 건데, 이게 생각보다 복잡했습니다.

// 수정한 코드
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
  // 콜백함수가 참조하는 값은 항상 렌더링 시점에 최신화
  const fnRef = useRef(fn);
  fnRef.current = fn;

  // 대신 항상 동일한 참조를 유지해야 한다 (useCallback 활용)
  // 어떤 인자가 올지 모르니 (...args: unknown[])로 모두 받아서 전달
  const stableCallback = useCallback((...args: unknown[]) => {
    return fnRef.current(...args);
  }, []);

  // 반환 타입을 T로 맞춰주기 위해 as T 사용
  return stableCallback as T;
};

처음에는 unknown[]라는 타입을 봤을 때 "이게 뭐지?" 싶었습니다. unknown은 "아무 타입이나 될 수 있다"는 뜻인데, 배열로 만들어서 함수의 인자들을 받는 거였다. 근데 이걸 다시 원래 함수의 타입으로 바꿔줘야 하는데, TypeScript가 자동으로 해주지 않아서 as T라는 타입 단언을 써야 했습니다.
이렇게 해서 "참조는 고정하되 최신 값은 참조"라는 요구사항을 만족할 수 있었습니다. 강의에서 배운 개념들이지만 실제로 구현해보니까 하나하나가 다 어려웠습니다. 이후로도 shallowEqualscreateObserver 같은 다른 함수들을 만들 때도 비슷한 타입 에러들을 종종 마주쳤는데, 하나씩 해결해나가면서 TypeScript를 조금씩 이해할 수 있게 되었습니다.

자랑하고 싶은 코드

export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
  // 콜백함수가 참조하는 값은 항상 렌더링 시점에 최신화
  const fnRef = useRef(fn);
  fnRef.current = fn;

  // 대신 항상 동일한 참조를 유지해야 한다 (useCallback 활용)
  // ...args 어떤 인자든 그대로 실행
  // 어떤 인자가 올지 모르니 (...args: unknown[])로 모두 받아서 전달
  const stableCallback = useCallback((...args: unknown[]) => {
    return fnRef.current(...args);
  }, []);

  // 반환 타입을 T로 맞춰주기 위해 as T 사용
  return stableCallback as T;
};

"참조는 고정하되 최신 값은 참조"라는 까다로운 요구사항을 useRef + useCallback 조합으로 해결한 부분이 가장 만족스럽습니다. 처음에는 타입 때문에 고생했지만, 제네릭과 unknown[], as T 타입 단언을 사용해서 TypeScript의 복잡한 타입 시스템을 해결할 수 있었습니다.

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

useMemo 과도한 사용

// packages/app/src/components/toast/ToastProvider.tsx - 현재 5번의 useMemo 사용
const { show, hide } = useMemo(() => createActions(dispatch), [dispatch]);
const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]);
const showWithHide = useAutoCallback((...args) => { ... }); // 내부에서 useMemo 사용
const commandValue = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]);
const stateValue = useMemo(() => ({ message: state.message, type: state.type }), [state.message, state.type]);

E2E 테스트 통과 과정에서 하나씩 메모이제이션을 추가하다 보니 이렇게 됐는데, useMemo를 이렇게 많이 써도 되는 건지 잘 모르겠습니다. 특히 { message: state.message, type: state.type } 같은 경우도 useMemo로 감싸야 하는 건지, 그리고 이런 식으로 계속 메모이제이션을 추가하는 게 올바른 방법인지 판단이 서지 않습니다. 더 나은 구조가 있다면 개선하고 싶습니다.

학습 효과 분석

가장 큰 배움이 있었던 부분

  • 리렌더링 과정의 이해: 이론으로만 알고 있던 React의 리렌더링 과정을 직접 겪어보면서 깊이 이해할 수 있었습니다. 특히 ToastProvider에서 함수들이 매번 새로 생성되어 ProductCard가 불필요하게 리렌더링되는 문제를 해결하면서, Context value의 참조 안정성이 얼마나 중요한지 알게 되었습니다.
  • 메모이제이션의 중요성: 단순히 useMemouseCallback을 사용하는 것이 아니라, 모든 불안정한 참조를 하나씩 메모이제이션해야 한다는 것을 배웠습니다. 하나라도 빠뜨리면 전체가 다시 리렌더링되는 경험을 통해 메모이제이션의 중요성을 깊이 이해할 수 있었습니다.

추가 학습이 필요한 영역

  • 타입스크립트 고급 기능: 제네릭과 타입 단언을 사용하면서 TypeScript의 복잡한 타입 시스템에 대한 이해가 부족하다는 것을 느꼈습니다. 특히 unknown[] 타입과 as T 타입 단언을 사용할 때 타입 안전성에 대한 고민이 필요할 것 같습니다.
  • 성능 최적화 감각: 언제 메모이제이션을 사용해야 하는지, 어떤 부분에서 성능 이슈가 발생할 수 있는지에 대한 경험과 감각이 부족하다는 것을 깨달았습니다. E2E 테스트를 통해서만 문제를 발견할 수 있었던 점이 아쉬웠습니다. 실제 개발 과정에서 미리 예측하고 최적화하는 감각을 기르고 싶습니다.

실무 적용 가능성

  • 상태 관리 라이브러리 이해: 직접 상태 관리 라이브러리를 구현해보면서 Zustand나 Redux 같은 라이브러리들이 내부적으로 어떻게 동작하는지 이해할 수 있었습니다. 이는 실제 프로젝트에서 상태 관리 라이브러리를 선택하고 사용할 때 도움이 될 것 같습니다.
  • 커스텀 훅 설계: useAutoCallback, useShallowSelector 같은 커스텀 훅을 직접 구현하면서 재사용 가능한 로직을 설계하는 방법을 배웠습니다. 이는 실제 프로젝트에서 공통 로직을 추상화할 때 유용할 것 같습니다.

과제 피드백

과제에서 좋았던 부분:

  • 단계별 구현: useRef → useMemo → useCallback 순서로 의존성을 가진 구현이 학습에 효과적이었습니다.
  • 실제 동작하는 애플리케이션: 단순한 예제가 아닌 실제 쇼핑몰을 통한 학습이 재밌었습니다.
  • E2E 테스트: 실제 성능 문제를 발견할 수 있는 현실적인 테스트가 도움되었습니다.
  • 자립적인 학습 경험: 1, 2주차에 비해 AI에 무작정 의존하지 않고 조언이나 공유해주신 문서를 보고 ai에게 물어보며 해결을 해서 이전보다 기억에 더 남았습니다. 특히 useSyncExternalStore나 Observer 패턴 같은 개념들을 직접 찾아보고 이해하려고 노력한 게 도움이 되었습니다.

과제에서 어려웠던 부분:

  • 메모이제이션 기준: 가이드를 보고 상황에 맞게 적용하는 것도 제 능력인데, 아직 그런 경험과 감각이 부족해서 언제 useMemo를 사용해야 하는지를 잘 몰랐던 것 같습니다.
  • 타입 에러: TypeScript 활용을 잘 못해 관련 에러 해결에 시간이 많이 소요되었습니다.

학습 갈무리

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

리액트의 렌더링은 컴포넌트의 stateprops가 변경될 때 발생하며, 크게 세 단계로 이루어집니다.

  1. 렌더(Render) 단계:
    • state/props 변경, 부모 컴포넌트 리렌더링, 또는 forceUpdate 호출 시 렌더링이 실행됩니다.
    • React는 컴포넌트 함수를 호출하여 어떤 UI를 그려야 할지 결정하고, 그 결과로 가상돔(Virtual DOM) 객체가 만들어 집니다. 이 과정은 실제 DOM을 변경하지 않으므로 비용이 저렴합니다.
  2. 조정(Reconciliation) 단계:
    • 새로 생성된 가상돔과 이전 가상돔을 비교하여 변경된 부분을 찾아냅니다.(Diffing)
    • React는 효율적인 Diffing 알고리즘을 사용하여 최소한의 변경사항을 계산합니다.
  3. 커밋(Commit) 단계:
    • 조정 단계에서 찾아낸 변경사항들을 실제 DOM에 한 번에 적용하여 UI를 업데이트 합니다.
      이 과정은 실제 브라우저가 화면을 새로 그려(렌더링 유발) 비용이 가장 크게 발생합니다.

이러한 과정을 최적화하기 위해 React는 memo, useMemo, useCallback과 같은 도구를 제공합니다. 이들은 렌더 단계에서 props나 의존성이 변경되지 않았을 경우, 이전 렌더 결과를 재사용하여 불필요한 가상돔 생성 및 Diffing 과정을 건너뛰게 해줍니다.

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

메모이제이션은 "비용이 비싼 연산의 결과를 저장해두고, 동일한 입력에 대해서는 저장된 결과를 재사용하는 기술"이라고 생각합니다.

  • 필요한 시점:

    • 복잡하고 무거운 계산이 포함된 함수의 반복 호출을 피하고 싶을 때 (useMemo).
    • 자식 컴포넌트에 함수(콜백)를 props로 전달할 때, 불필요한 리렌더링을 방지하기 위해 함수의 참조 동등성을 유지해야 할 때 (useCallback).
    • 컴포넌트의 props가 변경되지 않았음에도 부모의 리렌더링 때문에 불필요하게 다시 렌더링되는 것을 막고 싶을 때 (React.memo).
  • 장점과 단점:

    • 장점: 불필요한 연산과 렌더링을 줄여 애플리케이션의 성능을 크게 향상시킬 수 있습니다.
    • 단점: 메모리를 추가로 사용하여 이전 값과 의존성을 저장해야 하므로, 메모리 사용량이 늘어납니다. 또한, 모든 곳에 메모이제이션을 적용하면 오히려 의존성 비교 비용 때문에 성능이 저하되거나 코드가 복잡해질 수 있습니다.

결론적으로, 메모이제이션은 성능 저하가 실제로 발생하는 지점을 프로파일링 도구로 측정한 후, 꼭 필요한 곳에 전략적으로 사용하는 것이 중요합니다.(그렇지만 저는 이번 과제에서 남발한게 아닌가 하는 느낌;;;)

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

컨텍스트와 상태관리는 "컴포넌트 트리 전반에 걸쳐 흩어져 있는 데이터를 효율적으로 관리하고 공유하기 위한 솔루션"이라고 생각합니다.

  • 필요한 이유:

    • Prop Drilling 해결: 여러 단계의 자식 컴포넌트로 props를 계속해서 내려주는 'Prop Drilling' 문제를 해결하고, 필요한 컴포넌트가 데이터에 직접 접근할 수 있게 해줍니다.
    • 상태의 중앙화: 애플리케이션의 상태를 한 곳에서 관리하여 데이터 흐름을 예측 가능하고 디버깅하기 쉽게 만듭니다.
  • 장점과 단점:

    • 장점: 코드 구조가 단순해지고, 상태 관리가 용이해지며, 컴포넌트 간의 결합도를 낮출 수 있습니다.
    • 단점: React Context API는 컨텍스트 값이 변경되면 해당 컨텍스트를 구독하는 모든 컴포넌트가 리렌더링되는 문제가 있습니다. 이는 성능 저하의 원인이 될 수 있습니다.
  • 주의점 및 해결책:

  1. Context 분리의 중요성
// 잘못된 예: 모든 것을 하나의 Context에
<ToastContext value={{ show, hide, message, type }}>

// 올바른 예: 용도별로 분리
<ToastCommandContext value={{ show, hide }}>
  <ToastStateContext value={{ message, type }}>
  1. Provider value 메모이제이션
// 문제: 매번 새로운 객체 생성
<ToastCommandContext value={{ show: showWithHide, hide }}>

// 해결: 메모이제이션
const commandValue = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]);
const stateValue = useMemo(() => ({ message: state.message, type: state.type }), [state.message, state.type]);
<ToastCommandContext value={commandValue}>
  <ToastStateContext value={stateValue}>

상태관리 라이브러리 직접 구현 경험:
Observer 패턴을 기반으로 한 상태 관리 시스템을 구현하면서:

  • useSyncExternalStore와의 호환성을 고려해야 한다는 것
  • subscribe 함수가 구독 취소 함수를 반환해야 한다는 것

Context vs 상태관리 라이브러리:

  • Context: React 내장, 간단한 전역 상태에 적합
  • 상태관리 라이브러리: 복잡한 상태 로직, 성능 최적화에 유리

이번 과제를 통해 둘 다 결국 "상태 변경 시 구독자들에게 알림"이라는 동일한 패턴을 사용한다는 것을 알게 되었습니다.

리뷰 받고 싶은 내용

  • ToastProvider에서 useMemo 과도한 사용 : 해당 페이지에서 useMemo를 5번 사용하고 있는데 과도하게 사용되고 있다고 생각하지만 다른 방법을 모르겠습니다. 어찌저찌 요구사항과 테스트 통과를 위해 구현해내긴 했으나 더 좋은 방법이 있을지 궁금합니다.
  • useAutoCallback 패턴이 올바른지 : useRef와 useCallback을 조합해서 만든 건데, 이런 패턴이 실제로 쓰이는 방법인지 궁금합니다. 다른 더 좋은 방법이 있을까요?

adds9810 added 6 commits July 22, 2025 17:01
- useRef, useMemo, useCallback, useDeepMemo 커스텀 훅 구현
- shallowEquals, deepEquals 함수 직접 구현 및 타입 안전성 강화
- useMemo/Callback에서 의존성 비교 로직 개선
- dev 스크립트에 nvm 자동 적용 추가
- useAutoCallback: useRef + useCallback 패턴으로 참조 고정과 최신 값 참조 동시 구현
- useShallowState: 얕은 비교를 통한 불필요한 리렌더링 방지 및 함수형 setState 지원
- useCallback: 주석 정리 및 코드 간소화
- memo HOC: 클로저를 활용한 props 얕은 비교 및 메모이제이션 구현
- deepMemo HOC: memo 함수 재사용하여 깊은 비교 기반 메모이제이션 구현
- HOC 패턴을 통한 컴포넌트 리렌더링 최적화
- createObserver: useSyncExternalStore 호환 subscribe 함수 구현
- useShallowSelector: zustand 스타일 얕은 비교 메모이제이션 구현
- useStore: useSyncExternalStore와 useShallowSelector 연동 구현
- Observer 패턴 기반 상태 구독 시스템 완성
- Context를 Command와 State로 분리하여 리렌더링 최적화
- useMemo/useAutoCallback으로 함수 참조 고정
- SPA 라우팅을 위한 404.html 추가
@eveneul
Copy link

eveneul commented Jul 24, 2025

지혜 님 파이팅!!

@adds-bug
Copy link

PR 정말 상세히 잘 적어주시네요.. 배워갑니다!

// 다양한 타입(기본값, 배열, 객체 등)을 받아야 하므로 unknown 타입으로 선언
export function shallowEquals(objA: unknown, objB: unknown): boolean {
// 1. 두 값이 정확히 같은지 확인 (참조가 같은 경우)
if (Object.is(objA, objB)) return true;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<자랑하고 싶어요>
처음 요구사항을 접했을 때는 단순한 비교 연산자(===)를 떠올렸지만, 주어진 자료들을 찾아보며 Object.is() 메서드를 알게 되었습니다. 이 메서드를 활용하여 NaN이나 -0과 같은 특수한 값들까지 정확하게 비교하도록 구현했습니다.

Copy link

@BangDori BangDori Jul 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 shallowEquals를 구현할 때 단순히 일치 연산자(===)를 사용하는 방식을 선택했는데, 단순한 기능을 구현하는 과정에서도 끊임없이 더 나은 방법이 있는지를 탐색하고 이를 적용하려는 모습이 상당히 짱짱걸입니다 지혜님 👏 (멋있음)

아 그리구 첨언해보자면, shallowEquals 함수 내부에서 objA, objB에 대한 타입 에러가 발생해서 as 단언문을 사용해주신 것으로 보이는데 타입 단언문을 사용하지 않고 해결하는 방법도 한 번 고려해보시면 어떨까요!?

Comment on lines +61 to +66
<ToastCommandContext value={commandValue}>
<ToastStateContext value={stateValue}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastStateContext>
</ToastCommandContext>
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<궁금해요>
Context를 사용할 때 ToastCommandContextToastCommandContext.Provider 중 어떤 방식을 사용해야 하는지 궁금합니다. 저는 공식문서 보고 ToastCommandContext만 사용했는데, .provider를 붙여서 하신 분들이 많으셔서.. 어떻게 하셨고 왜 사용하셨는지 궁금합니다.

@BangDori
Copy link

지혜님 PR 너무 상세히 잘 작성해주셔서 감탄하면서 봤읍니다아.. 이거시 냥집사인가요

PR을 읽으면서, 지혜님이 과제를 해결해나가시는 사고 방향이 상상이 간다는게 참 글을 잘 쓰신게 아닌가 하는 생각이 드네요. 저도 다음에 지혜님 처럼 의사 결정의 순간들을 모두 담아서 글을 작성하도록 노력해봐야 할 거 같아요!

근데 pr 작성하는 도중에 냥이가 노트북이나 컴퓨터 꺼버리면 어떻게 되나요

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