과제 체크포인트
배포 링크
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 코드를 수정할 때 제일 많이 느꼈다..
그냥 '이런 훅이 있구나' 하고 아무 생각 없이 사용했던 리액트 훅들을 직접 구현해보면서 왜 이 훅이 필요한지, 어떻게 동작하는지, 언제 사용해야 하는 지에 대한 감이 생긴 것 같다. 단순히 '이 훅은 이런 인자를 넘겨줘야 한다' 라는 개념을 넘어서 훅 내부에서 어떤 로직으로 상태 변화가 일어나는지, 어떤 타이밍에서 값이 갱신되는지, 리렌더링에 어떤 영향을 주는지 이해할 수 있었다.
직접 구현한 훅이 다른 훅 내부에서 사용되며 하나씩 연결되는 게 너무 신기했다. 독립적으로 사용이 가능하면서도, 서로 연결되어 복잡하면서도 깔끔한 훅을 만들어 내는 것이 재밌었다. 비밀을 파헤친 느낌.!!
자랑하고 싶은 코드
개선이 필요하다고 생각하는 코드
shallowEquals와 deepEquals 함수에서 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만 사용하여 값을 전달해주고 있었다. 이를 보고 둘 사이에 기능적으로 차이가 있는지 궁금해졌다.
그래서 공식 문서를 찾아봤는데, 리액트 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을 사용할 때 조심해야 하는 부분이나, 문제가 생길 수 있는 경우가 있을 지 궁금합니다!
과제 피드백
안녕하세요 아름! 수고하셨습니다! 이번 과제는 React의 내장 훅들을 직접 구현해보면서 프레임워크의 내부 동작 원리를 깊이 이해하고, 상태 관리와 최적화 메커니즘을 체득하는 것이 목표였습니다.
"비밀을 파헤친 느낌" ㅋ 재밌네요. 맞아요! 개발에서 깊이를 파내려가는 공부라는 이런 방식이죠. React 훅의 내부 구조를 직접 탐구하며 얻은 통찰이 정말 값진 경험이 되었기를 바랍니다. 특히 직접 구현한 훅들이 서로 연결되어 복잡하면서도 깔끔한 구조를 만들어내는 과정을 즐기신 점이 멋져요
코드 리뷰에서 언급된 타입 가드류 유틸리티 함수를 별개로 만들어 두는 방식은 좋은 방식이죠. 타입 가드를 별도 함수로 분리하면 가독성과 재사용성이 향상됩니다. 참고해두시면 좋을 것에요.
const isObject = (target: unknown): target is Record<string, unknown> => { return target !== null && typeof target === "object" && !Array.isArray(target); };
Q) useCallback vs useAutoCallback 사용 시나리오
useCallback이 React가 기본적으로 제공하는 훅이며 useAutoCallback은 사용자(커뮤니티)가 만들어 낸 custom hook입니다. 공식적인거라기 보다는 편의를 제공하기 위해서 만들어 낸 거죠.
엄밀하게는 useCallback에 의존성 배열을 두는게 맞습니다. 그러나 이런 방식은 휴먼에러가 발생하기 쉽죠. 그래서 메모가 되면 안될때 메모가 되어버리면 오동작을 하는데 이걸 찾아내기가 상당히 어렵습니다.
useCallback을 안쓰면 항상 함수가 새롭게 만들어지므로 이런 문제가 해결이 되죠. 그렇지만 이 함수를 자식 컴포넌트의 props로 전달하면 자식 컴포넌트의 memo가 전혀 동작하지 않습니다. 왜냐하면 늘 새로운 함수를 만들어 낼테니까요.
useAutoCallback은 의존성 배열을 통한 엄밀한 비교는 하지 말고 항상 최신의 값을 만들지만 자식 컴포넌트에서 React.memo를 쓰고 있을때 메모가 가능하도록 만들어주기 위해 만든 편의성 유틸리티 함수입니다.
정리하자면 해당 함수가 자식 컴포넌트의 props로 전달되고 있는 경우, 엄밀한 의존성 관리의 복잡함을 사용하지 않는 대신 자식 컴포넌트의 memo는 가능하도록 하기 위한 편의성 hook이라고 생각해주세요.
Q) shallowEquals와 deepEquals 구현 관련
=> 제출하신 구현 방식도 좋지만, 재귀적 접근을 통해 더 견고하게 만들 수 있습니다:
const baseEquals = (a: unknown, b: unknown, equalsFn: typeof Object.is) => {
if (Object.is(a, b)) return true;
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length &&
a.every((val, idx) => equalsFn(val, b[idx]));
}
if (isObject(a) && isObject(b)) {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
return aKeys.length === bKeys.length &&
aKeys.every(key => equalsFn(a[key], b[key]));
}
return false;
};
export const shallowEquals = (a: unknown, b: unknown) =>
baseEquals(a, b, Object.is);
export const deepEquals = (a: unknown, b: unknown) =>
baseEquals(a, b, deepEquals); // 재귀적으로 자기 자신 호출
수고하셨습니다. React의 내부 동작을 직접 구현해보며 얻은 이번 경험이 앞으로의 개발 여정에서 든든한 기초가 되기를 바랍니다. 클린코드 챕터에서도 이런 깊이 있는 탐구 정신을 계속 이어가시길 응원합니다! 화이팅입니다! :)