Skip to content

[4팀 김수민] Chapter 1-3. React, Beyond the Basics#50

Open
nimusmix wants to merge 19 commits intohanghae-plus:mainfrom
nimusmix:main
Open

[4팀 김수민] Chapter 1-3. React, Beyond the Basics#50
nimusmix wants to merge 19 commits intohanghae-plus:mainfrom
nimusmix:main

Conversation

@nimusmix
Copy link

@nimusmix nimusmix commented Jul 23, 2025

과제 체크포인트

배포 링크

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의 장점은 무엇인가요!

@eveneul
Copy link

eveneul commented Jul 24, 2025

피알 다 썻다!! 이제 주무쇼!!

Comment on lines +6 to +25
const MemoizedComponent = (props: P) => {
const prevRef = useRef<{
props: P | null;
element: ReactNode;
}>({
props: null,
element: null,
});

const shouldUpdate = !equals(prevRef.current.props, props);

if (shouldUpdate) {
prevRef.current = {
props,
element: createElement(Component, props),
};
}

return prevRef.current.element;
};
Copy link
Author

Choose a reason for hiding this comment

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

4팀 x 7팀 페어 코드 리뷰

자랑하고 싶은 코드
비교 조건을 shouldUpdate, shouldRecompute 같은 변수로 추상화해서, 조건의 목적이 드러나도록 선언형으로 작성했습니다.

Copy link

Choose a reason for hiding this comment

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

아주 칭찬해

Choose a reason for hiding this comment

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

이해하기 쉬워서 좋아요!

Choose a reason for hiding this comment

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

가독성이 확실히 좋아지네요! 저도 도입해야게써요~~!! 멋져어엉

Choose a reason for hiding this comment

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

MemoizedComponent는 표면적으로 드러나는 컴포넌트가 아닌 메모이제이션을 위한 컴포넌트로서 생각하기 때문에 개발자 입장에서 MemoizedComponent의 추상화 수준이 크게 중요시되지 않을 수도 있지 않을까!? 하는 생각이 드는데 추상화하신 이유가 궁금해요!

Choose a reason for hiding this comment

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

오 좋은 것 같아요!
말씀하신 것처럼 변수명만으로도 비즈니스 로직의 목적이 분명하게 드러나서 좋아요. 저는 다른 사람이 이해하기 쉬운 코드를 짜는 능력도 중요하다고 생각하는데, 지난 과제에서도 느꼈는데 수민님은 그런 걸 고려를 잘하시는 것 같아요 👍

Comment on lines 3 to 19
export const shallowEquals = (a: unknown, b: unknown) => {
return a === b;
if (a === b) return true;

if (!isObject(a) || !isObject(b)) return false;

const aKeys = Object.keys(a);
const bKeys = Object.keys(b);

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

for (const key of aKeys) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (a[key] !== b[key]) return false;
}

return true;
};
Copy link
Author

@nimusmix nimusmix Jul 25, 2025

Choose a reason for hiding this comment

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

4팀 x 7팀 페어 코드 리뷰

고민되는 코드
두 값이 정확히 같은지 확인할 때, Object.is를 많이 사용하시던데 a === b로 해도 잘 돌아가요.
+0, -0, NaN의 처리에서 차이가 있다는 것을 알지만,
a === b로 처리했을 때 비교 함수에서 발생할 수 있는 문제점을 알지 못해요.
명확히 이해하지 못하는 상태에서 Object.is로 바꾸는 것도 이상한 것 같아서
그대로 a === b로 제출했는데, 정확히 문제점을 이해하고 싶어요.

Copy link

@eveneul eveneul Jul 25, 2025

Choose a reason for hiding this comment

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

4팀 x 7팀 페어 코드 리뷰

고민되는 코드 두 값이 정확히 같은지 확인할 때, Object.is를 많이 사용하시던데 a === b로 해도 잘 돌아가요. +0, -0, NaN의 처리에서 차이가 있다는 것을 알지만, a === b로 처리했을 때 비교 함수에서 발생할 수 있는 문제점을 알지 못해요. 명확히 이해하지 못하는 상태에서 Object.is로 바꾸는 것도 이상한 것 같아서 그대로 a ===b로 제출했는데, 정확히 문제점을 이해하고 싶어요.

안녕하세요~ 수민 공주님! 죽지도 않고 제가 돌아왔습니다.
https://medium.com/jung-han/js-2%ED%83%84-%EA%B7%B8%EB%A6%AC%EA%B3%A0-object-is-e58b72e90443
검색하다가 재미있는 글을 찾았어요. 오프 코치님의 글입니다! (코치님의 과거를 찾은 게 재미있어서 올려 봅니다.)

+0, -0, NaN을 비교했을 때 일치 연산자와 Object.is에서 다른 결과를 뱉는 건은 수민 님도 잘 알고 계실 테고,
저는 모던 리액트 딥 다이브책을 읽고 Object.is를 사용했는데요,

MDN에서는 === 일치 연산자로도 충분하다는 입장을 밝히고 있네요.

In general, the only time Object.is's special behavior towards zeros is likely to be of interest is in the pursuit of certain meta-programming schemes, especially regarding property descriptors, when it is desirable for your work to mirror some of the characteristics of Object.defineProperty. If your use case does not require this, it is suggested to avoid Object.is and use === instead. Even if your requirements involve having comparisons between two NaN values evaluate to true, generally it is easier to special-case the NaN checks (using the isNaN method available from previous versions of ECMAScript) than it is to work out how surrounding computations might affect the sign of any zeros you encounter in your comparison.

일반적으로 Object.is가 +0과 -0을 구별하는 특수한 동작이 실제로 의미를 가지는 경우는 드뭅니다. 주로 메타프로그래밍, 특히 Object.defineProperty와 같은 API의 동작 특성을 정밀하게 모방해야 할 때처럼 **속성 설명자(property descriptor)**를 다룰 때에나 유용할 수 있습니다. 이런 특수한 상황이 아니라면, Object.is 대신 ===을 사용하는 것이 권장됩니다. 만약 여러분의 코드에서 두 NaN 값을 비교할 일이 있고 true가 나와야 하는 경우라 해도, 보통은 Object.is를 쓰기보다는 isNaN 같은 기존 메서드로 NaN 여부를 별도로 처리하는 것이 훨씬 쉽습니다. 왜냐하면 Object.is를 사용하면 **0의 부호(+0, -0)**가 연산 도중 어떻게 영향을 줄지까지도 고려해야 하는데, 이는 상당히 복잡해질 수 있기 때문입니다.

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Equality_comparisons_and_sameness

저도 수민 님 질문에 대한 해답을 찾다가 많이 배웠네요. 감사합니다!

Choose a reason for hiding this comment

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

저도 비슷한 궁금증이 있었는데, 잘 읽고 갑니다 :)

Choose a reason for hiding this comment

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

우왓 저도 궁금했던 부분인데 덕분에 알아갑니다!

@BangDori
Copy link

이야.. 그저 대 수 민

Lazy Initialization이 궁금해서 React 패키지를 직접 까보셨군요... React 패키지 까서 코드 확인하는게 여간 쉬운일이 아닌데 멋있읍니다.

지금 설명으로도 사실 충분히 이해가 갔지만 궁금한 점이 있어서 저도 React 패키지를 조금 뒤적뒤적 해봤어요.

"함수를 전달하면 왜 첫 렌더 시에만 호출되는 걸까?"

https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js#L1919-L1943

// 마운트
function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState); //  ✅  여기서 lazy init 수행
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

// 업데이트
function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, initialState); // 📌 이때 initialState는 무시됨!
}

// 리렌더링
function rerenderState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return rerenderReducer(basicStateReducer, initialState);  // 📌 이때 initialState는 무시됨!
}

제가 보기엔 이 코드가 위 질문에 대한 힌트를 주는 것 같았어요. 마운트 시점에는 mountState가 호출되고, 내부적으로는 mountStateImpl을 통해 상태를 초기화하는데, 이때 전달된 함수가 실행됩니다.

하지만 이후 렌더링부터는 updateStatererenderState가 호출되는데, 이 함수들에서는 초기값 함수가 호출되지 않아요.

즉, 초기값 함수는 오직 마운트 시에만 한 번 실행되고, 그 이후에는 완전히 무시되는 구조라는 것을 알 수 있었어요. 함수가 한 번만 실행된다는 lazy init의 특성이 바로 이 구조에서 보장되고 있더라고요.

정리

렌더링 상태 사용되는 함수 초기값 처리 방식
마운트 시 mountState 초기값이 함수이면 직접 호출해서 평가
업데이트 시 updateState 함수로 넘겼더라도 절대 호출하지 않음
재렌더 시 rerenderState 마찬가지로 초기값 함수 호출 안 함

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