chan9yu 님의 상세페이지[5팀 여찬규] Chapter 1-3. React, Beyond the Basics

과제 체크포인트

배포 링크

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 분리에 대해 코치님의 의견이 궁금합니다

과제 피드백

찬규님 고생하셨어요! 글에는 하나밖에 적지 않아 아쉽다고 적어주셨지만, 사실 과제보다 더 무언가를 하는 것 자체가 놀라운거니까요. 너무 잘하셨습니다. 회고도 잘 작성해주셨고 과제도 잘 해주셨어요. 자랑해주신 함수도 변화된 모습을 보니 함수도 훨씬 가독성이 좋아졌네요 ㅎㅎ :+1

질문 주신 부분 빠르게 답변 들어가보면요!

  1. shallowEquals

큰 차이는 분~명 없을것 같긴 하지만, 그럼에도 자주 호출되는 함수이기 때문에 실제로 체크를 한번 해보면 좋을 것 같아요. 작성해주신것처럼 가독성은 좋아지지만 결국 함수가 익명으로 호출이 될때마다 선언이 매번 되기 때문에요. 테스트를 해보는게 그렇게 어렵지 않을 것 같아서 한번 데이터의 양을 임의로 깊게하거나 크게 해서 비교해보면 좋을것 같아요! (몇 ms차이는 비교가 어려우니 명시적으로 차이가 나도록 크게요.) 이런 부분도 한번 정리해서 공유해줘도 좋을것 같네요 ㅎㅎ

  1. useShallowSelector

넵! 아마 언마운트 될때 GC가 될 것 같아요. 다만, 이것과 별개로 비슷하게 메모이제이션이나 클로저 같이 메모리상으로 이점을 누리기 위해 최적화 방식을 쓰는 경우나 DOM 이벤트 할당처럼 명시적으로 해제를 하는게 가독성 측면이나 혹시나 예측이 안되는 부분에 있어서 혹시 있을 일을 미연에 방지하는 좋은 습관일 수 있어요. 물론 브라우저가 똑똑해지고 잘 해주겠지만,이런 부분도 함께 챙겨보시면 더 좋은 습관이 되지 않을까 싶습니다!

  1. ToastProvider

성능 관점에서 나누는 것도 좋겠지만, 같은 이야기를 하는 같은 관심사의 내용이 분리될만큼 성능적으로 최적화가 필요할까! 라는 지점에 대해서도 함께 고민해보면 좋을것 같아요. 상황에 따라 판단하겠지만, 지금의 상황이라면 메모이제이션을 사용해도 될 만큼 복잡한 상황은 아니지 않을까.. 함께 두는게 더 가독성이나 관심사 분리 측면에서도 좋지 않을까 라는 매우 개인적인 의견이 있습니다 ㅎㅎ 언제 나누고 안나누고는 늘 상황에 따라 다를 것 같고 사람마다도 다를 것 같아요. 대신 일관된 규칙을 갖고 분리하는게 제일 중요한 것 같아요. 찬규님 입장에서 한번 다시 고민해보는것도 좋을 것 같습니다! 정답은 없어요.

이번 주도 고생 많이 하셨고 다음주도 화이팅입니다!