Skip to content

[5팀 여찬규] Chapter 1-3. React, Beyond the Basics#37

Open
chan9yu wants to merge 31 commits intohanghae-plus:mainfrom
chan9yu:main
Open

[5팀 여찬규] Chapter 1-3. React, Beyond the Basics#37
chan9yu wants to merge 31 commits intohanghae-plus:mainfrom
chan9yu:main

Conversation

@chan9yu
Copy link
Member

@chan9yu chan9yu commented Jul 21, 2025

과제 체크포인트

배포 링크

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

과제 셀프회고

벌써 리액트 딥다이브 마지막 주차를 마무리하게 되었네요
1주차와 2주차에서 많은 것들을 배워둔 덕에 3주차 과제를 정말 재미있게 진행할 수 있었던 거 같습니다!
이번 주차를 통해 리액트의 핵심 개념들을 깊이 이해할 수 있었어요.
특히 리액트가 왜 얕은 비교를 사용하는지, 불변성을 권장하는 이유가 무엇인지,
그리고 hook들이 실제로 어떤 방식으로 구현되어 있는지를 직접 체험해볼 수 있어서 정말 의미있는 시간이였습니다

이번 주도 5팀은 각자 과제에 대해 구현하고 정리한 내용을 공유했었는데요,
아쉽게도 저는외근과 야근 이슈가 겹치면서 하나밖에 정리하지 못했네요 🥲
그래도 점심시간이나 자투리 시간을 최대한 활용해서 틈틈이 과제를 진행했고,
다행이 모든 과제를 완료할 수 있었습니다. 정말 힘들었던 한 주 였지만, 무사히 마무리해서 뿌듯?합니다 ㅎㅎ
이렇게 바쁜 상황에서도 과제를 이어나갈 수 있었던 것은 팀원분들의 도움과 응원? 그리고 자극 덕분이였던 것 같습니다.

image image

(과제에 대해 항상 적극적이고 서로 정보를 공유해 주는 팀원분들 보기 좋아요)

현재까지 정리한 내용들:

기술적 성장

실제 리액트 내부에서 구현된 훅들을 직접 구현해보면서 동작 원리를 깊이 이해할 수 있어서 정말 좋았습니다!
예를 들어 useRef는 생각보다 단순하게 구현할 수 있었는데요

export function useRef<T>(initialValue: T) {
  const [refObject] = useState<RefObject<T>>({ current: initialValue });
  return refObject;
}

useState를 이용해서 참조를 유지하는 객체를 생성하는 방식으로도 구현할 수 있다는 것을 배웠습니다.

하지만 나중에 위 코드에서 문제를 발견했는데요,
useState를 사용하면 initialValue가 변경될 때 마다 새로운 객체가 생성될 수 있는데 이를 방지하기 위해
lazy initialization을 사용해서 초기값을 한 번만 설정하고 이후 변경되지 않게 개선했습니다.

export function useRef<T>(initialValue: T) {
  const [refObject] = useState<RefObject<T>>(() => ({ current: initialValue })); // lazy initialization
  return refObject;
}

그리고 useMemo와 useCallback도 구현하면서 내부 로직에 대해 파악할 수 있었습니다

export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals) {
  // useRef로 메모이제이션 상태 저장
  const memoRef = useRef<MemoRef<T> | null>(null);

  // 의존성 배열이 없거나, 이전과 다르면 새로 계산
  if (!memoRef.current || !_equals(memoRef.current.deps, _deps)) {
    const value = factory();
    memoRef.current = { deps: _deps, value };
  }

  return memoRef.current.value;
}

export function useCallback<T extends AnyFunction>(factory: T, _deps: DependencyList) {
  return _useMemo(() => factory, _deps);
}

이렇게 내부적으로 얕은 비교를 통한 메모이제이션이 다양한 곳에서 활용되고 있구나 하고 깨달았습니다.

자랑하고 싶은 코드

shallowEquals 함수 구현부입니다.

항상 하던 것처럼 하나의 함수에 로직을 때려 넣어서 구현했는데,
"하나의 함수에 너무 많은 일을 하고 있지 않을까? 또 가독성이 안 좋아 보이는데 다른 사람들이 읽을 때 이해할 수 있을까?"라는 고민이었습니다.
이 고민은 2주 차까지 했었던 것 같아요 그래서 2주차 과제 제출할 때도 클린 코드 측면에서도 리뷰를 부탁드렸던 거 같습니다

image (받았던 리뷰 내용도 참고가 많이 되었습니다 감사합니다 오프 코치님 😉)

다행히도 이번 과제 발제 때 준일 코치님이 제 고민을 시원하게 긁어주셨는데, 바로 선언적으로 코드를 리팩토링 해주시고 설명해주셨습니다
그때를 참고해서 shallowEquals를 선언적 구조로 리팩토링을 진행해 봤어요.

커밋 기록

AS-IS 하나의 함수 내부에 여러 로직이 결합됨

export const shallowEquals = (a: unknown, b: unknown) => {
  // 두 값이 정확히 같은지 확인 (참조가 같은 경우)
  if (Object.is(a, b)) return true;

  // 둘 다 객체가 아니면 false
  if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;

  const isArrayA = Array.isArray(a);
  const isArrayB = Array.isArray(b);

  // 서로다른 타입을 받을 경우 false (ex: a: [], b: {} 인 경우 false)
  if (isArrayA !== isArrayB) return false;

  // 둘 다 배열이면 배열 비교
  if (isArrayA && isArrayB) {
    if (a.length !== b.length) {
      return false;
    }

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

    return true;
  }

  // 둘 다 객체면 객체 비교
  const aObj = a as Record<string, unknown>;
  const bObj = b as Record<string, unknown>;

  const aKeys = Object.keys(aObj);
  const bKeys = Object.keys(bObj);

  if (aKeys.length !== bKeys.length) {
    return false;
  }

  for (const key of aKeys) {
    if (!(key in bObj)) {
      return false;
    }

    if (!Object.is(aObj[key], bObj[key])) {
      return false;
    }
  }

  return true;
};

TO-BE 헬퍼 함수를 통해 선언적으로 리팩토링

가독성과 간결함이 높아졌고, 함수의 결합도가 느슨해짐

const isArray = (value: unknown) => {
  return Array.isArray(value);
};

const isObject = (value: unknown) => {
  return typeof value === "object" && value !== null;
};

const compareArrays = (a: unknown[], b: unknown[]) => {
  return a.length === b.length && a.every((item, index) => Object.is(item, b[index]));
};

const compareObjects = (a: object, b: object) => {
  const aObj = a as Record<string, unknown>;
  const bObj = b as Record<string, unknown>;

  const keysA = Object.keys(a);
  const keysB = Object.keys(b);

  return keysA.length === keysB.length && keysA.every((key) => key in bObj && Object.is(aObj[key], bObj[key]));
};

export const shallowEquals = (a: unknown, b: unknown) => {
  // 두 값이 정확히 같은지 확인 (참조가 같은 경우)
  if (Object.is(a, b)) return true;

  // 둘 다 객체가 아니면 false
  if (!isObject(a) || !isObject(b)) return false;

  // 서로다른 타입을 받을 경우 false (ex: a: [], b: {} 인 경우 false)
  if (isArray(a) !== isArray(b)) return false;

  // 둘 다 배열이면 배열 비교
  if (isArray(a) && isArray(b)) return compareArrays(a, b);

  // 둘 다 객체면 객체 비교
  return compareObjects(a, b);
};

이렇게 리팩토링 함으로서 가독성이 올라가고 함수를 이해하는데 더 쉬워졌습니다
하지만 여기서 더 리팩토링을 해봤는데요
준일코치님의 코드를 참고해서 여기서 한 단계 더 리팩토링을 진행했습니다.

최종 리팩토링 결과물

// ... 헬퍼함수들

type Condition<T> = (param: T) => boolean;
type Handler<T, R> = (param: T) => R;
type ConditionHandlerPair<T, R> = [condition: Condition<T>, handler: Handler<T, R>];

// dispatchWithCondition 함수 추가구현
export function dispatchWithCondition<T, R>(...args: [...ConditionHandlerPair<T, R>[], Handler<T, R>]) {
  const pairs = args.slice(0, -1) as ConditionHandlerPair<T, R>[];
  const defaultHandler = args[args.length - 1] as Handler<T, R>;

  return (param: T) => {
    for (const [condition, handler] of pairs) {
      if (condition(param)) {
        return handler(param);
      }
    }

    return defaultHandler(param);
  };
}

export const shallowEquals = (a: unknown, b: unknown) => {
  return dispatchWithCondition<[typeof a, typeof b], boolean>(
    // 두 값이 정확히 같은지 확인 (참조가 같은 경우)
    [([a, b]) => Object.is(a, b), () => true],
    // 둘 다 객체가 아니면 false
    [([a, b]) => !isObject(a) || !isObject(b), () => false],
    // 서로 다른 타입을 받을 경우 false (ex: a: [], b: {} 인 경우 false)
    [([a, b]) => isArray(a) !== isArray(b), () => false],
    // 둘 다 배열이면 배열 비교
    [([a, b]) => isArray(a) && isArray(b), ([a, b]) => compareArrays(a as unknown[], b as unknown[])],
    // 둘 다 객체면 객체 비교
    ([a, b]) => compareObjects(a as object, b as object),
  )([a, b]);
};

굉장히 직관적으로 코드가 리팩토링된 거 같아서 개인적으로 제일 마음에들고 자랑하고 싶은 코드입니다!

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

useShallowSelector 조금 아쉬운 부분이 있습니다

export const useShallowSelector = <T, S = T>(selector: Selector<T, S>) => {
  const prevResult = useRef<S | null>(null);

  const memoizedSelector = (state: T) => {
    const result = selector(state);

    if (prevResult.current && shallowEquals(prevResult.current, result)) {
      return prevResult.current;
    }

    prevResult.current = result;
    return result;
  };

  return memoizedSelector;
};

매번 새로운 함수를 반환하고 있어서, 이 자체로 인한 리렌더링이 발생할 수 있을 것 같다?라는 생각이 듭니다
useCallback으로 한 번 더 감싸주면 좋을 것 같은데, 순환 참조 문제 때문에 고민이 되고있습니다
시간상 재대로 보진 못했지만 개선이 될 수 있지 않을까 생각이 되네요

학습 효과 분석

이번 과제에서 가장 큰 배움이 있었던 부분은 리액트의 내부 동작 원리를 직접 구현해보면서 이해할 수 있었던 점입니다.
특히 useSyncExternalStore를 사용해서 외부 스토어와 리액트를 연결하는 방식이 정말 재미있었습니다.

useSyncExternalStore를 사용하는 훅중에 useStorage 부분이 있는데,

export const useStorage = <T>(storage: Storage<T>) => {
  const storageStore = useSyncExternalStore(storage.subscribe, storage.get);
  return storageStore;
};

이렇게 간단하게 localStorage 변화를 리액트가 감지할 수 있게 만들 수 있다는 게 신기했습니다.
그리고 observer 패턴에 대해서도 조금 더 익숙해진 거 같습니다.
createObserver를 통해 Publisher-Subscriber 패턴을 직접 구현해보니, 상태 관리 라이브러리들이 어떤 방식으로 동작하는지 조금은 알 거 같습니다

과제 피드백

3주차 밖에 안되었지만 이번주차가 개인적으로 제일 재미?있었던 주차였던 거 같아요
시간문제로 대충 훑고 넘긴 개념들도 많아서 나중에 시간내서 좀 더 보충하면서 공부해보겠습니다
좋은 과제 감사합니다

학습 갈무리

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

  1. 트리거 단계
    • 상태 변경이나 props 변경이 발생하면 리렌더링이 트리거됩니다
    • 이때 useMemo나 useCallback에서 의존성 배열을 비교하는 부분이 바로 이 단계에서 일어납니다
  2. 렌더 단계
    • 컴포넌트 함수를 실행해서 새로운 Virtual DOM을 만드는 단계입니다
    • 이 과정에서 이번에 구현한 shallowEquals 같은 비교 함수들이 활용되어서 불필요한 계산을 방지하게 됩니다
  3. 커밋 단계
    • 실제 DOM을 업데이트하는 단계입니다
    • useEffect나 useLayoutEffect 같은 side effect들이 실행됩니다

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

개인적으로 메모이제이션은 양날의 검 이라고 생각을 합니다

일단 메모이제이션은 언제 필요할까? 라고했을 때

  • 복잡한 계산이 반복적으로 일어날 때
  • 자식 컴포넌트에 전달되는 props가 자주 변경될 때
  • 외부 API 호출이나 무거운 로직이 있을 때

정도로 생각해볼 수 있을 거 같아요

이번 과제에서 useMemo를 구현해보니까 메모이제이션도 결국 이전 값을 저장하고 비교하는 오버헤드?가 있다는 것을 알 수 있었는데요

if (!memoRef.current || !_equals(memoRef.current.deps, _deps)) {
  const value = factory();
  memoRef.current = { deps: _deps, value };
}

이 비교 과정 자체도 비용이 든다고 생각합니다.
그래서 모든 곳에 메모이제이션을 적용하면 성능이 정말 좋아질까..? 잘못 사용하면 어쩌면 더 비용이 많이들지 않을까?
라고 생각을 했었고 잘못사용한다면 오히려 악효과가 난다는 결론을 지었습니다.

장점 단점
불필요한 재계산 방지 메모리 사용량 증가 (이전 값들을 계속 저장)
자식 컴포넌트 리렌더링 방지 비교 로직 자체의 오버헤드
사용자 경험 개선 개발자 경험 악화, 코드 복잡성 증가

결론적으로, 메모이제이션은 정말 필요한 곳에만 사용해야 한다고 생각해요.
성능 측정을 통해 실제로 병목이 되는 부분을 찾아서 적용하는 게 맞는 것 같습니다.

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

컨텍스트가 필요한 이유

  • Props drilling 문제 해결
  • 전역 상태 공유
  • 관심사의 분리

라고 생각합니다

하지만 컨텍스트도 만능이 아니다라는 걸 깨달았는데,
이번 과제에서 리팩토링을 했던 ToastProvider를 보면

export const ToastProvider = memo(({ children }: PropsWithChildren) => {
  const [state, dispatch] = useReducer(toastReducer, initialState);
  // ... 로직들
  
  return (
    <ToastCommandContext.Provider value={toastCommandContextValue}>
      <ToastStateContext.Provider value={toastStateContextValue}>
        {children}
      </ToastStateContext.Provider>
    </ToastCommandContext.Provider>
  );
});

Command와 State를 분리해서 불필요한 리렌더링 방지하고 useMemo로 최적화 및 타입 안전성을 보장하지만,
만약 Context가 너무 많아지면 Provider 지옥이 될 수 있을 거 같고 전역 상태가 너무 커지면 관리가 어려워질 수 있을 거 같다라는 생각이 듭니다.

이런 부분들 때문에 외부 상태관리 라이브러리를 사용하는게 아닌가 싶어요
간단한 상태관리는 컨텍스트로도 충분하지만, 복잡한 상태 로직이나 여러 컴포넌트에서 독립적으로 사용해야 하는 상태들은 외부 라이브러리가 더 적합할 수 있다고 생각하게 되었습니다.

리뷰 받고 싶은 내용

1. shallowEquals

현재 dispatchWithCondition 함수를 활용해서 선언적으로 리팩토링한 shallowEquals 코드가 실제 성능 면에서도 이점이 있을지 궁금합니다.

export const shallowEquals = (a: unknown, b: unknown) => {
  return dispatchWithCondition<[typeof a, typeof b], boolean>(
    [([a, b]) => Object.is(a, b), () => true],
    [([a, b]) => !isObject(a) || !isObject(b), () => false],
    // ... 더 많은 조건들
  )([a, b]);
};

가독성은 좋아졌지만, 매번 배열을 생성하고 함수 호출 스택이 깊어지는 것이 성능상 문제가 될 수 있을까요?
특히 메모이제이션에서 자주 호출되는 함수인데, 이런 추상화가 적절한 지 궁금합니다

2. useShallowSelector

useShallowSelector에서 useRef로 이전 결과를 저장하고 있는데

export const useShallowSelector = <T, S = T>(selector: Selector<T, S>) => {
  const prevResult = useRef<S | null>(null);
  
  const memoizedSelector = (state: T) => {
    const result = selector(state);
    if (prevResult.current && shallowEquals(prevResult.current, result)) {
      return prevResult.current;
    }
    prevResult.current = result;
    return result;
  };

  return memoizedSelector;
};

큰 객체를 선택하는 selector의 경우, prevResult.current에 계속 참조가 남아있어서 가비지 컬렉션?이 되지 않을 수 있을 것 같습니다.
컴포넌트가 언마운트되거나 selector가 변경될 때 이전 결과를 정리해주는 로직이 필요할까요?

생각해보니 컴포넌트가 언마운트될 때 자동으로 정리가 되어 메모리까지 해체될 거 같네요

3. ToastProvider

현재 ToastProvider에서 Command와 State Context를 분리했는데

<ToastCommandContext.Provider value={toastCommandContextValue}>
  <ToastStateContext.Provider value={toastStateContextValue}>
    {children}
  </ToastStateContext.Provider>
</ToastCommandContext.Provider>

이런 방식이 실제 프로젝트에서도 유지보수하기 좋은 패턴일까요?
아니면 하나의 Context로 합치되 useMemo로 최적화하는 것이 더 나을까요?
Context 분리에 대해 코치님의 의견이 궁금합니다

chan9yu added 30 commits July 20, 2025 14:20
- spa 우회하기 위한 404.html 추가
- deploy workflow 추가
chore: 배포를 위한 설정 추가
refactor: dispatchWithCondition 함수를 활용한 deepEquals, shallowEquals 리팩토링
@heojungseok
Copy link

부수자

@chan9yu
Copy link
Member Author

chan9yu commented Jul 25, 2025

부수자

박살내버려

@JiHoon-0330
Copy link

멘토링 받은 내용 바로 적용하는 모습이 인상적이네요

@Yangs1s
Copy link

Yangs1s commented Jul 25, 2025

글 잘읽었습니다. 저도 지훈님과 생각이 같아요 멘토링 받은내용을 회고에 녹이는게 인상적이였습니다.

Copy link

@YeongseoYoon-hanghae YeongseoYoon-hanghae left a comment

Choose a reason for hiding this comment

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

바쁜 와중에도 최선을 다하신 점 너무 좋습니다 4주차도 킵고잉~

const showWithHide: ShowToast = (...args) => {
show(...args);
const showWithHide = useAutoCallback((message: string, type: ToastType) => {
show(message, type);

Choose a reason for hiding this comment

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

message, type 을 입력받도록 변경한 것이 참신한 것 같아요

*/
export const deepEquals = (a: unknown, b: unknown) => {
return a === b;
return dispatchWithCondition<[typeof a, typeof b], boolean>(

Choose a reason for hiding this comment

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

지난 시간에 배운 방법을 적용하려 하는 모습이 좋은 것 같습니다


const shouldRender = !equals(cache.current.prevProps, props);
if (shouldRender) {
cache.current.lastResult = Component(props);

Choose a reason for hiding this comment

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

리액트 컴포넌트를 함수로 호출했을 때 예상하지 못한 동작을 유발할 수 있어서 React.createElement 를 사용하는 걸 추천 드립니다!

Copy link
Member Author

Choose a reason for hiding this comment

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

감사합니다 배워가네요~

// 매 렌더링마다 최신 함수로 업데이트
fnRef.current = fn;

const autoCallback = useCallback((...args: Parameters<T>) => {

Choose a reason for hiding this comment

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

파라미터에 타입을 명시한 것이 좋네요


const setShallow = useCallback((newValue: SetStateAction<T>) => {
setValue((prev) => {
const nextValue = typeof newValue === "function" ? (newValue as (prevValue: T) => T)(prev) : newValue;

Choose a reason for hiding this comment

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

as 키워드를 사용하지 않는 방법도 고려해보면 좋을 것 같아요


const hideAfter = debounce(hide, DEFAULT_DELAY);
const { show, hide } = useMemo(() => createActions(dispatch), [dispatch]);
const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]);

Choose a reason for hiding this comment

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

useMemo 활용해서 리팩토링 잘 한 것 같아요!

*/
export const shallowEquals = (a: unknown, b: unknown) => {
return a === b;
return dispatchWithCondition<[typeof a, typeof b], boolean>(
Copy link

Choose a reason for hiding this comment

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

따로 빼서 가져와서 쓰이니까 가독성이 확실히 좋아지네요.!

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.

6 participants