nimusmix 님의 상세페이지[4팀 김수민] Chapter 1-3. React, Beyond the Basics

과제 체크포인트

배포 링크

https://nimusmix.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챕터가 끝이 나는군요! 리액트를 나름대로 잘 쓴다고 생각했던 저는 진짜로 잘 '쓰고' 있기만 했더라고요? 액트 너 없인 아무 것도 못해 javascript로 하나씩 구현하면서 리액트가 무얼 해결하려고 했는지, 각 함수가 어떤 의미를 지니는지 고민하고 해답을 찾아가는 과정이 기억에 많이 남아요.

기술적인 성장도 물론 있었지만요, 저에겐 공부는 이렇게 하는 거다 라는 방법론을 배운 것이 더욱 의미가 깊다고 느껴집니다!

기술적 성장

1. 값은 useMemo, 함수는 useCallback 아니었나?

export const ToastProvider = memo(({ children }: PropsWithChildren) => {
  ...
  const hideAfter = debounce(hide, DEFAULT_DELAY);
  ...
});

토스트 렌더링 최적화 과정 중, hideAfter를 메모이제이션해야 하는 상황이 있었습니다. 함수니까 당연히 useCallback으로 감쌌는데, 이게 웬걸 자꾸만 틀렸다지 뭐예요? 검색해보니까 useMemo가 맞다는데 도무지 이해가 안 갔죠.. 분명히 함수는 useCallback이고 값이 useMemo인데? 네 아니었습니다


📍 useCallback(fn, deps)

  • 함수 자체를 메모이제이션
  • 즉 위의 경우, () => debounce(hide, DEFAULT_DELAY) 형식이었다면 useCallback이 맞음

📍 useMemo(factory, deps)

  • 어떤 값을 계산한 결과를 메모이징
  • debounce(hide, DEFAULT_DELAY)를 계산한 결과가 함수이므로, useMemo를 사용함

2. useState에 초기값을 직접 넘겼을 때와 함수로 넘겼을 때 동작이 다른 이유

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

useRef에서 내부적으로 상태를 저장하기 위해 useState를 사용했어요. initialValue로 위 코드처럼 객체를 바로 넣었더니 리렌더링이 되어도 useRef의 참조값이 유지된다.라는 테스트 코드가 자꾸 실패하는 문제가 있었어요. 어 그럴 수 있겠다 생각했는데, 검색해보니 초기값으로 함수를 넘기면 된다고 하더라구요? 이해가 안 갔죠.. 왜 다른 건대 ??????? 액트는 계획이 다 있구나

// packages/react-reconciler/src/ReactFiberHooks.js

function mountState(initialState) {
  const hook = mountWorkInProgressHook();

  let memoizedState;
  if (typeof initialState === 'function') {
    memoizedState = initialState(); // lazy init
  } else {
    memoizedState = initialState;
  }
...
}

리액트에서는 마운트 시점에 mountState가 호출이 됩니다. 이 때 initialState가 함수면 실행하고, 값이면 그대로 저장됩니다. 근데 저걸 보고 또 lazt init이라고 하지 뭐예요? 아니~ 마운트 시점에 실행하는데 뭐가 lazy냐! 라는 생각이 들었죠


📍 Lazy Initialization

  • 리액트가 실행 시점을 제어할 수 있도록 초기값을 함수로 미뤄두는 게 lazy init의 핵심
  • useState(fn()) 식으로 값을 전달하면 리액트가 실행 시점을 제어할 수 없기 때문에,
  • useState(() => fn()) 이렇게 넘기는 것이다!

즉, 값으로 넘긴다고 매 렌더마다 평가되고, 함수로 넘기면 초기에만 평가되고 이런 게 아니라 { current: initialValue } 이 객체가 렌더링마다 새로 생성되어서 그랬던 거였습니다!


3. useShallowState, useAutoCallback 등 새로운 hook 이해 📍 useShallowState

  • 얕은 비교를 활용해서 상태 변경 시 불필요한 리렌더링을 방지하는 구조
  • 일반 useState와 달리 객체나 배열 상태에서 참조 변경만으로 발생하는 리렌더링을 막을 수 있음

📍 useAutoCallback

  • 의존성 배열 없이도 항상 최신 스코프를 캡처한 콜백을 제공
  • useCallback(fn, dep)에서 dep 누락으로 생기는 문제를 줄일 수 있음

자랑하고 싶은 코드

비교 조건을 shouldUpdate, shouldRecompute 같은 변수로 추상화해서, 조건의 목적이 드러나도록 선언형 스타일로 구현했습니다.

export const useShallowSelector = <T, S = T>(selector: Selector<T, S>) => {
  ...
  return (state: T): S => {
    const selected = selector(state);
    const shouldUpdate = !shallowEquals(prevRef.current, selected);

    if (shouldUpdate) {
      prevRef.current = selected;
    }
    return prevRef.current as S;
  };
};
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
    ...
  const shouldRecompute = !ref.current.deps || !_equals(ref.current.deps, _deps);
  if (shouldRecompute) {
    ...
}

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

비교 함수에서 두 값이 정확히 같은지 확인할 때, Object.is를 많이들 사용하시던데 저는 냅다 a === b로 해도 잘 돌아가길래 이렇게 했습니다.. 코쓱

export const shallowEquals = (a: unknown, b: unknown) => {
  if (a === b) return true;
...
};

다들 왜들 그렇게 Object.is를 쓰는가! 해서 좀 공부를 해봤는데요~ -0, +0, NaN 정도에서 차이가 나는 것 같아요 근데 저는 -0과 +0은 같은 값이라고 생각합니다! (당당) 왜 다른 값이어야 하는지 모르겠고.. NaN은 달라야 할 수도 있을 것 같은데 정확한 예시를 잘 모르겠어서 좀 더 공부해서 이해해보고 싶어요!ㅎㅎ

학습 효과 분석

- 가장 큰 배움이 있었던 부분 useSyncExternalStore를 이해하는 과정이 가장 큰 배움이 있었던 부분인 것 같아요. 공부하면서 TearingConcurrent Feature에 대해 알게 되었는데요! Suspense와 StartTransition을 잘 쓰고 있으면서도 그게 동시성을 위한 거다! 라고 생각한 적이 없어서 머릿속에서 퍼즐 맞추듯이 정리가 되는 것 같았습니다.

또한 useShallowSelector의 역할에 대해서도 알게 되었습니다. 불필요한 렌더링을 막을 수 있는 귀한 역할을 하고 있는 친구예요.

const snapshot = useSyncExternalStore(store.subscribe, store.getState)

위 코드에서 snapshot은 항상 전체 상태 객체를 반환하기 때문에, 상태의 일부만 변경되어도 이전 값과는 다른 객체가 되는 바, 불필요한 렌더링이 잦아지죠. 그걸 해결하기 위해 얕은 비교로 진짜 변경된 부분만 쏙쏙 골라낼 수 있는 selector가 바로바로 useShallowSelector!

const snapshot = useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState()));

- 추가 학습이 필요한 영역 jotai가 리액트 렌더 트리와 연동하는 방식! 멘토링 때 추가로 공부해서 블로그 포스팅을 해서 꼭 피알에 넣어야지 다짐했건만.. 인생이 제 맘처럼 되지 않네요.. 이번 주 안으로 꼭 시간 내서 블로그를 써보고 싶어요 진짜 궁금혀요 ,,

과제 피드백

과제의 요구사항이 명확해서 구현하기 편했습니다! 이번 주차 과제를 하면서 차근차근 공부하는 것의 힘을 느끼고 있어요. 3주차가 만족도 최강!

학습 갈무리

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

- 리액트의 렌더링 과정 1️⃣ createVNode : JSX가 호출될 때 가상 DOM 노드를 만듭니다. 자식들을 평탄화하고 null | undefined | false를 필터링합니다.

2️⃣ normalizeVNode : 재귀적으로 자식들을 정리하고, 컴포넌트 타입에 따라 각기 다른 처리를 하여 정규화합니다.

3️⃣ createElement : 가상 노드를 기반으로 실제 DOM 요소를 생성합니다. props 및 이벤트 등록까지 마칩니다.

4️⃣ renderElement : 첫 렌더링일 경우 createElement로 DOM을 생성하고, root에 이벤트를 등록합니다. 업데이트일 경우, updateElement로 기존 DOM과 비교하여 갱신합니다.

5️⃣ updateElement : 새로운 VNode와 이전 VNode를 비교합니다. (diff 알고리즘) 속성과 자식을 차례로 업데이트합니다. (Reconciliation)

- 리액트의 렌더링 최적화 방법 1️⃣ memo HOC : 함수 컴포넌트의 props가 바뀌지 않으면 리렌더링을 하지 않습니다.

2️⃣ useMemo : 연산 결과를 메모이징하여 리렌더링 시 재계산을 방지합니다.

3️⃣ useCallback : 함수 재생성을 막아 불필요한 props의 변경으로 리렌더링이 되는 것을 막습니다.

- 리액트의 렌더링과 관련된 라이프사이클 메서드 검색하면 나오기는 하지만 함수형 컴포넌트를 주로 써서 써본 적이 없고 잘 모릅니다..! useEffect, useLayoutEffect 등으로 라이프사이클에 따른 함수 실행을 처리했습니다.

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

- 메모이제이션이 언제 필요할까? 복잡한 연산을 수행할 때, 불필요한 렌더링을 줄이고 싶을 때, 남의 의존성 배열에 내가 들어갔을 때 (ㅋㅋ)

- 메모이제이션을 사용하지 않으면 어떤 문제가 발생할까? 1️⃣ 복잡한 연산을 수행할 때 : 시간이 오래 걸리는 연산을 불필요하게 여러 번 실행하게 되어 성능이 저하될 것입니다.

2️⃣ 불필요한 렌더링을 줄이고 싶을 때 : 불필요한 렌더링이 발생할 것입니다 (?)

3️⃣ 남의 의존성 배열에 내가 들어갔을 때 : lint err.. 뿐만 아니라 불필요한 렌더링의 나비효과 연쇄효과 체험이 가능할 것 같아요 예상하지 못한 side effect도 발생할 것 같습니다.

- 메모이제이션을 사용했을 때의 장점과 단점은 무엇일까? ✅ 장점 : 렌더링을 최소화하여 성능을 최적화할 수 있습니다.

⚠️ 단점 : 남용하면 오히려 메모리 사용량을 증가시켜 성능 저하의 우려가 있습니다.

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

- 컨텍스트와 상태관리가 필요한 이유는 무엇일까? : 각기 다른 위치에 있는 컴포넌트들이 같은 상태를 사용해야 하는 경우가 종종 발생합니다. 어느 한 쪽에서 해당 데이터를 관리하는 것도 이상하고, 그렇다고 양쪽에서 데이터를 모두 관리할 수도 없는 노릇이죠. 그래서 전역으로 관리해서 데이터가 안전하게 조작될 수 있도록 하는 것이 좋습니다!

- 컨텍스트와 상태관리를 사용하지 않으면 어떤 문제가 발생할까? : props drilling이 발생하여 코드가 복잡해지고 유지보수가 어려워집니다. 같은 데이터를 여러 군데에서 변경할 가능성이 있어 예상치 못한 버그가 발생할 수 있습니다. 어디서 수정하고 또 어디서 수정하다 보면 추적이 어려워져 디버깅이 골치아플 수 있지요.

- 컨텍스트와 상태관리를 사용했을 때의 장점과 단점은 무엇일까? ✅ 장점 : 컴포넌트가 어디에 위치해 있든 전역 상태를 쉽게 참조할 수 있습니다. 여러 컴포넌트에서 하나의 상태를 바라볼 수 있습니다.

⚠️ 단점 : Context의 경우 value가 변경되면 하위 컴포넌트 전체가 리렌더링되기 때문에 주의해야 합니다.

리뷰 받고 싶은 내용

코드 리뷰는 아니지만..! 저의 이력서에 HOC를 만들어서 문제를 해결했다 라는 내용이 있는데, 면접에 갈 때마다 HOC 왜 썼냐고 물어보고, 어떤 회사는 좀 지나간(?) 방식이고 다른 방법도 있었을텐데 왜 그걸 선택했냐고 물어보더라구요 솔직히 사수가 HOC로 해결해보는 게 어떻겠냐고 해서 그냥 쓴 거라서.. 할 말이 없었습니다ㅠㅠ 이후 학습을 통해 HOC의 장점에 대해 말을 할 수는 있는 상태에 이르렀으나, hook에 비해 어떤 부분이 좋다 이런 건 아직 잘 모르겠어요ㅎㅎ 코치님이 생각하는 HOC의 장점은 무엇인가요!

과제 피드백

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

  1. 값은 useMemo, 함수는 useCallback 아니었나?

저는 둘 다 사용할 수 있다고 생각해요 ㅋㅋ

useMemo로 만드는 경우를 보자면

useMemo(() => debounce(hide, DEFAULT_DELAY), []);

요로코롬 만들 수 있겠죠!?

그리고 다시 useCallback을 구현할 때 useMemo를 이렇게 사용하고 있어요.

function useCallback(fn, deps) {
	return useMemo(() => fn, deps)
}

그래서 useMemo로 만든걸 useCallback으로 변환하다고 치면... 이런 모습이겠죠!?

useCallback(debounce(hide, DEFAULT_DELAY), []);

{ current: initialValue } 이 객체가 렌더링마다 새로 생성되어서 그랬던 거였습니다!

이게 무척 좋은 인사이트라고 생각합니다 ㅎㅎ state에 초기값에 큰 배열이나 연산비용이 필요한 그런 값이 들어간다면.. 예를들자면 이런 모습인거죠.

const randomValues = () => Array.from({ length: 10000000 }).map(v => Math.random())

const Component = () => {
	const [values, setValue] = useState(randomValues())
}

이렇게 작성할 경우, randomValues가 렌더링을 할 때 마다 실행이 되고, 불필요한 컴퓨팅 연산을 사용하게 된답니다! 그래서 이럴 때 요로코롬 표현할 수 있어요.

const randomValues = () => Array.from({ length: 10000000 }).map(v => Math.random())

const Component = () => {
	const [values, setValue] = useState(() => randomValues())
}

이러면 최초에 한 번 렌더링 할 때만 randomValues를 실행하는거죠.

코드 리뷰는 아니지만..! 저의 이력서에 HOC를 만들어서 문제를 해결했다 라는 내용이 있는데, 면접에 갈 때마다 HOC 왜 썼냐고 물어보고, 어떤 회사는 좀 지나간(?) 방식이고 다른 방법도 있었을텐데 왜 그걸 선택했냐고 물어보더라구요 솔직히 사수가 HOC로 해결해보는 게 어떻겠냐고 해서 그냥 쓴 거라서.. 할 말이 없었습니다ㅠㅠ 이후 학습을 통해 HOC의 장점에 대해 말을 할 수는 있는 상태에 이르렀으나, hook에 비해 어떤 부분이 좋다 이런 건 아직 잘 모르겠어요ㅎㅎ 코치님이 생각하는 HOC의 장점은 무엇인가요!

제가 생각하는 장점은, 훅보다 높은 추상화 수준을 유지할 수 있다는 점!? 생각보다 여기저기 많이 쓰이고 있어요.

https://static.toss.im/slash24/QR/slash24-09.pdf

이건 토스 슬래시 24 발표 자료 중에 하나인데요, 한 번 살펴보시면 좋겠어요! 쉽게 말해서 로깅을 HoC를 자동화 하는 방법? 이라고 봐주시면 좋을 것 같습니다 ㅎㅎ

HoC가 지나간 방식이라기보단, 어떤 문제는 더 잘 해결해줄 수 있는 방법이라고 생각합니다.