Skip to content

[1팀 한아름] Chapter 1-3. React, Beyond the Basics#55

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

[1팀 한아름] Chapter 1-3. React, Beyond the Basics#55
areumH wants to merge 19 commits intohanghae-plus:mainfrom
areumH:main

Conversation

@areumH
Copy link

@areumH areumH commented Jul 24, 2025

과제 체크포인트

배포 링크

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

과제 셀프회고

기술적 성장

확실히 학습 자료를 먼저 읽고 과제를 시작하니 이번 과제에서 이뤄야하는 것이 뭔지, 어떻게 구현해야 하는지 감히 잡히는 것 같다. e2e 테스트 통과를 위해 ToastProvider 코드를 수정할 때 제일 많이 느꼈다..

그냥 '이런 훅이 있구나' 하고 아무 생각 없이 사용했던 리액트 훅들을 직접 구현해보면서 왜 이 훅이 필요한지, 어떻게 동작하는지, 언제 사용해야 하는 지에 대한 감이 생긴 것 같다. 단순히 '이 훅은 이런 인자를 넘겨줘야 한다' 라는 개념을 넘어서 훅 내부에서 어떤 로직으로 상태 변화가 일어나는지, 어떤 타이밍에서 값이 갱신되는지, 리렌더링에 어떤 영향을 주는지 이해할 수 있었다.

직접 구현한 훅이 다른 훅 내부에서 사용되며 하나씩 연결되는 게 너무 신기했다. 독립적으로 사용이 가능하면서도, 서로 연결되어 복잡하면서도 깔끔한 훅을 만들어 내는 것이 재밌었다. 비밀을 파헤친 느낌.!!

자랑하고 싶은 코드

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

shallowEqualsdeepEquals 함수에서 string, number, boolean과 같은 기본 타입 비교 구문을
if (a === b) return true; 로 단순하게 구현하였는데 올바른 방식인지 확실치 않다.

그리고 비교하는 값이 객체일 때 각 객체의 key 값을 문자열로 사용하기 위해 Record<string, unknown>와 같이 Record을 사용하였는데, 이 외에 객체를 비교하는 다른 방법이 있는지 궁금하다. solution 코드에는 어떻게 구현되어있을 지 제일 궁금한 부분이다..!!

학습 효과 분석

  • useAutoCallback

기존의 useCallback 함수는 의존성 배열을 넘겨주어야 하는데, 값을 빠뜨릴 경우 너무 오래된 값을 사용하게 되거나, 너무 많이 넣을 경우 과도한 리렌더링을 유발하는 문제가 생길 수 있다. useAutoCallback은 이러한 문제를 덜기 위해 의존성 배열을 넘겨주지 않아도 항상 최신값을 유지하도록 하는 훅이다.

// lib/src/hooks/useAutoCallback.ts

// 참조가 변경되지 않으면 항상 새로운 값을 참조
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
 const ref = useRef(fn);

 const callback = useCallback((...args: Parameters<T>) => {
   return ref.current(...args);
 }, []);

 ref.current = fn;
 return callback as T;
};

callback은 의존성 배열이 비어있기 때문에 최초 한 번만 생성되고, 최신 ref의 함수를 호출한다. ref.current = fn;에서 매 렌더마다 ref의 함수가 갱신되기 때문에 고정된 callback 내부에서는 항상 최신 상태의 함수를 참조하게 된다!

이로 인해 의존성 배열을 신경쓸 필요 없이, 매 렌더링 시점의 함수 로직을 유지하면서 불필요한 함수 재생성을 방지할 수 있다.


  • useSyncExternalStore
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

이는 컴포넌트 최상위 레벨에 호출하여 외부 저장소(store)의 상태를 구독하고 읽는 훅이다.

  • subscribe
    외부 저장소의 변경을 구독하는 함수

  • getSnapShot
    현재 저장소 데이터 상태를 반환하는 함수

  • getServerSnapShot
    서버 사이드 렌더링(SSR) 환경에서 사용되는 데이터의 초기 상태를 반환하는 함수 (선택)


// lib/src/createStore.ts

export const createStore = <S, A = (args: { type: string; payload?: unknown }) => S>(
  reducer: (state: S, action: A) => S,
  initialState: S,
) => {
  const { subscribe, notify } = createObserver();

  let state = initialState;

  const getState = () => state;

  const dispatch = (action: A) => {
    const newState = reducer(state, action);
    if (!Object.is(newState, state)) {
      state = newState;
      notify();
    }
  };

  return { getState, dispatch, subscribe };
};

store로 전역 상태를 만들고, 변경, 구독할 수 있는 관리 시스템을 구성하는 함수이다. 상태를 변경하는 함수인 reducer과 초기 상태인 initialState를 인자로 받는다.

state는 현재 store의 상태를 저장해두는 변수이며, dispatch를 호출할 때마다 바뀐다. 그리고 subscribe는 store의 상태 변화를 감지하는 함수, getState는 현재 store의 상태를 반환한다.

이렇게 구성된 createStore 함수로 만든 store을 useSyncExternalStore와 함께 사용하면 안전하고 일관되게 전역 상태를 구독할 수 있다!


  • Context.Provider

createContext 함수는 const SomeContext = createContext(defaultValue)와 같은 형태로 컨텍스트를 생성하고, 여러 컴포넌트 간에 데이터를 전역적으로 공유할 수 있게 해준다. 그리고 SomContext.Provider와 같이 Provider를 사용하여 값을 하위 컴포넌트에 전달한다.

// app/src/components/toast/ToastProvider.tsx

return (
  <ToastContext value={{ show: showWithHide, hide, ...state }}>
    {children}
    {visible && createPortal(<Toast />, document.body)}
  </ToastContext>
);


// 학습 자료 예시
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;

과제 시작 전에 훑어봤던 학습 자료의 코드에서도 .Provider가 사용되었는데, 이미 주어진 ToastProvider.tsx의 코드에서는 Provider가 붙지 않은 ToastContext만 사용하여 값을 전달해주고 있었다. 이를 보고 둘 사이에 기능적으로 차이가 있는지 궁금해졌다.

스크린샷 2025-07-24 041647

그래서 공식 문서를 찾아봤는데, 리액트 19부터는 Context 뒤에 Provider을 붙인 것과 안 붙인 것이 기능적으로 동일하게 작동한다는 것을 알 수 있었다..!!


  • shx

배포를 위해 pnpm run gh-pages를 실행했더니 터미널에 'cp'은(는) 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는 배치 파일이 아닙니다. 와 같은 에러가 떴다.

// app/package.json

  "scripts": {
    "build": "vite build && cp ./dist/index.html ./dist/404.html",
    // ...
  },

package.json의 스크립트 명령어인데, cp는 Unix 기반 환경에서의 shell 명령어이기 때문에 윈도우에서는 작동하지 않는 것이었다... 관련하여 찾아보니 Unix shell 명령어를 Node.js 스크립트에서 사용할 수 있게 해주는 유틸리티 패키지 shx 라는게 있었다!

해당 패키지 설치 후, 명령어를 "build": "vite build && shx cp ./dist/index.html ./dist/404.html" 로 수정하여 페이지 배포에 성공했다. 과제와 직접적인 연관이 있는 건 아니지만 그래도 하나 더 알게 되었다..!!
(cp 대신 copy로 실행하면 된다는 걸 뒤늦게 알았다........)

과제 피드백

사실 리액트에서 제공해주는 useCallback, useMemo와 같은 훅을 사용해본 경험 자체도 많지 않았기 때문에 과제를 시작할 때 많이 막막했던 것 같다. 그치만 useRef부터 차근차근 직접 구현해보면서 메모이제이션 동작 원리에 대해 조금이나마 더 알게 된 것 같다. 너무 좋은 기회였다! 이번 과제가 도움이 되었다고 직접적으로 체감할 수 있는 날이 얼른 오면 좋겠다는 생각이 든다.

학습 갈무리

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

  • 변경된 컴포넌트를 Virtual DOM에 렌더링한 후, 이전과 비교하여 변경된 최소의 부분만 실제 DOM에 적용한다.
  • 렌더링 최적화를 위해 memo, useMemo, useCallback 등의 훅을 사용할 수 있고, 불필요한 렌더링을 막아 성능을 개선한다.

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

  • 메모이제이션: 결과 값이나 콜백 함수를 캐싱해두고, 동일한 입력에 대하여 해당 캐싱 값을 재사용함으로써 렌더링 성능을 최적화한다.
  • 사용하지 않을 경우 불필요한 리렌더링이 발생하여 성능이 저하되지만, 과도하게 사용할 경우 오히려 값이 자주 바뀔 때 메모이제이션을 유지하려는 오버헤드가 발생할 수도 있다는 생각이 든다.
  • 의존성 배열도 관리를 잘못할 경우 예상 외의 결과가 나오는 등의 버그가 발생할 수 있다.

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

  • 전역적으로 데이터를 공유하거나, props 전달이 번거로울 때 컨텍스트로 상태 관리를 하는 것이 편리하다.
  • 컨텍스트의 값이 바뀌면 해당 컨텍스트를 사용하는 컴포넌트가 모두 리렌더링되기 때문에 역할에 맞게 분리가 필요하다.
  • 자주 변경되는 상태는 컨텍스트보다 전역 상태 관리 라이브러리를 사용하는 것이 효율적이다.

리뷰 받고 싶은 내용

이번 과제에서 useCallback과 useAutoCallback 훅을 구현하면서, 편의성과 명시성 측면에서 비슷하면서도 서로 다른 특징을 갖고 있다고 느꼈습니다. 특히 useAutoCallback 은 참조를 고정하면서도, 항상 새로운 값을 참조하도록 하는 게 굉장히 편리하다는 생각이 드는데, 아직 useCallback과 useAutoCallback 훅이 각각 어떤 상황에 더 적합한지 확실히 와닿지 않습니다. useAutoCallback을 사용할 때 조심해야 하는 부분이나, 문제가 생길 수 있는 경우가 있을 지 궁금합니다!

const shallowSelector = useShallowSelector(selector);
return shallowSelector(router);

return useSyncExternalStore(router.subscribe, () => shallowSelector(router));

Choose a reason for hiding this comment

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

저는 다 변수로 분리해서 useSyncExternalStore에 인자로 전달했는데, 오히려 이렇게 바로 반환해주니까 더 깔끔하네요 👍🏻

export const useShallowSelector = <T, S = T>(selector: Selector<T, S>) => {
// 이전 상태를 저장하고, shallowEquals를 사용하여 상태가 변경되었는지 확인하는 훅을 구현합니다.
return (state: T): S => selector(state);
const ref = useRef<S | null>(null);

Choose a reason for hiding this comment

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

포괄적인 'ref'라는 이름보단 어떤 데이터를 저장하고 있는지 명확하게 표현해주는 변수명으로 하면 더 이해가 잘 될 것 같아요! (ex. state, prevState, etc)

const ref = useRef<S | null>(null);

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

Choose a reason for hiding this comment

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

result도 selectedState 등과 같은 변수명으로 개선햐면 더 이해하기 쉬울 것 같아요!

export const deepEquals = (a: unknown, b: unknown) => {
return a === b;
// 기본 타입
if (a === b) return true;
Copy link

Choose a reason for hiding this comment

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

Suggested change
if (a === b) return true;
if (Object.is(a, b)) return true;

Object.is를 이용한 비교가 더 좋을 것 같아요!
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/is

Object.is()는 === 연산자와도 같지 않습니다. Object.is()와 ===의 유일한 차이는 부호 있는 0과 NaN 값들의 처리입니다. === 연산자(및 == 연산자)는 숫자값 -0과 +0을 같게 처리하지만, NaN은 서로 같지 않게 처리합니다.

Comment on lines +57 to +58
<ToastStateContext.Provider value={stateContextValue}>
<ToastCommandContext.Provider value={actionContextValue}>

Choose a reason for hiding this comment

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

마이너) ToastStateContext 가 리렌더링을 더 유발하니까 아무래도 ToastStateContext가 안쪽에 있는게 더 낫지 않을까 하는 의견 내봅니다.

Copy link
Author

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.

4 participants