과제 체크포인트
배포 링크
https://adds9810.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 개선
과제 셀프회고
이번 과제는 React를 그냥 쓰기만 하던 라이브러리에서 진짜 어떻게 돌아가는지 알 수 있게 해준 과제였습니다. 강의에서 배워서 알고있던 useRef, useMemo 같은 것들을 useState부터 하나하나 직접 만들어보면서, 이런 기능들이 왜 만들어졌는지 알 수 있었습니다. useSyncExternalStore 써서 외부 상태 연결하고, Context API 쓸 때 성능 문제 생기는 것도 경험해보면서 최적화가 왜 중요한지 알게 됐습니다. 이 모든 과정이 그냥 따로따로 알던 지식들을 하나로 연결해주고, React가 실제로 어떻게 작동하는지 깊게 이해할 수 있게 해줬습니다. 또한 AI 사용하는 방식도 1, 2주차에는 무작정 의존하던 것에서 이번에는 문서 보고 시도한 다음에 AI한테 질문하는 식으로 더 나아졌습니다.
기술적 성장
- React 훅의 내부 동작 원리 이해
// packages/lib/src/hooks/useRef.ts - 렌더링 되어도 참조값 유지
export function useRef<T>(initialValue: T): { current: T } {
const [ref] = useState(() => ({ current: initialValue }));
return ref;
}
// packages/lib/src/hooks/useMemo.ts - 의존성 비교 기반 메모이제이션
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
// 1. 이전 의존성과 결과를 저장할 ref 생성
const prevDeps = useRef<DependencyList>([]);
const prevResult = useRef<T | null>(null);
// 2. 현재 의존성과 이전 의존성 비교
if (prevResult.current === null || !_equals(prevDeps.current, _deps)) {
// 3. 의존성이 변경된 경우 factory(새로운 값을 계산) 함수 실행 및 결과 저장
prevDeps.current = _deps;
prevResult.current = factory();
}
// 4. 메모이제이션된 값 반환
return prevResult.current;
}
강의에서 배웠던 훅들을 직접 구현해보니 useState 기반으로 동작한다는 것을 알게 되었습니다.
- useAutoCallback 패턴
// packages/lib/src/hooks/useAutoCallback.ts
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
// 콜백함수가 참조하는 값은 항상 렌더링 시점에 최신화
const fnRef = useRef(fn);
fnRef.current = fn;
// 항상 같은 함수 반환
const stableCallback = useCallback((...args: unknown[]) => {
return fnRef.current(...args);
}, []); // 빈 의존성 배열로 참조 고정
return stableCallback as T;
};
"참조는 고정하되 최신 값은 참조"라는 까다로운 요구사항을 useRef + useCallback 조합으로 해결하는 패턴을 알게 되었습니다.
- Observer 패턴과 useSyncExternalStore 연동
// packages/lib/src/createObserver.ts
export const createObserver = () => {
const listeners = new Set<Listener>();
const unsubscribe = (fn: Listener) => {
listeners.delete(fn);
};
const subscribe = (fn: Listener) => {
listeners.add(fn); // 리스너 등록
// 구독 취소 함수 반환
return () => {
unsubscribe(fn);
};
};
const notify = () => listeners.forEach((listener) => listener());
return { subscribe, notify };
};
useSyncExternalStore와 호환되려면 subscribe 함수가 구독 취소 함수를 반환해야 한다는 스펙을 학습했습니다.
기존 지식의 재발견/심화:
- 메모이제이션: 강의에서 배웠던
useMemo,useCallback의 실제 구현 방식 이해 이론적으로만 알고 있던 메모이제이션이 실제로는useRef를 사용해서 이전 값과 현재 값을 비교하고, 의존성이 변경되었을 때만 새로운 값을 계산하는 방식으로 동작한다는 것을 직접 구현해보며 이해할 수 있었다. 특히shallowEquals함수를 사용해서 의존성 배열을 비교하는 부분에서, 단순히===비교가 아닌 얕은 비교를 통해 객체의 내용을 비교하는 것이 중요하다는 것을 체감했습니다. - 함수 참조 안정성: 이론으로만 알고 있던 개념을 실제로 구현하며 체감
React에서 함수 참조가 불안정하면 하위 컴포넌트가 불필요하게 리렌더링된다는 이론을 알고 있었지만, 실제로
ToastProvider에서 함수들이 매번 새로 생성되어ProductCard가 계속 리렌더링되는 문제를 겪어보면서 그 심각성을 직접 체감했다. 특히Context value로 전달되는 함수들의 참조 안정성이 얼마나 중요한지, 그리고useMemo,useCallback,useAutoCallback을 적절히 조합해서 사용해야 한다는 것을 실제 문제 해결 과정에서 깊이 이해할 수 있었습니다.
구현 과정에서의 기술적 도전과 해결:
useAutoCallback을 만들 때 타입 때문에 고생했습니다. 처음에는 타입을 몰라서 이렇게 코드만 적었었습니다.:
// 초기 코드 - 타입 에러 발생
export const useAutoCallback = (fn) => {
const fnRef = useRef(fn);
fnRef.current = fn;
const stableCallback = useCallback((...args) => {
return fnRef.current(...args);
}, []);
return stableCallback;
};
그런데 TypeScript에서 타입 에러가 나서 제네릭이라는 걸 처음 써봤는데, 함수의 타입을 미리 정해놓지 않고 나중에 정할 수 있게 하는 건데, 이게 생각보다 복잡했습니다.
// 수정한 코드
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
// 콜백함수가 참조하는 값은 항상 렌더링 시점에 최신화
const fnRef = useRef(fn);
fnRef.current = fn;
// 대신 항상 동일한 참조를 유지해야 한다 (useCallback 활용)
// 어떤 인자가 올지 모르니 (...args: unknown[])로 모두 받아서 전달
const stableCallback = useCallback((...args: unknown[]) => {
return fnRef.current(...args);
}, []);
// 반환 타입을 T로 맞춰주기 위해 as T 사용
return stableCallback as T;
};
처음에는 unknown[]라는 타입을 봤을 때 "이게 뭐지?" 싶었습니다. unknown은 "아무 타입이나 될 수 있다"는 뜻인데, 배열로 만들어서 함수의 인자들을 받는 거였다. 근데 이걸 다시 원래 함수의 타입으로 바꿔줘야 하는데, TypeScript가 자동으로 해주지 않아서 as T라는 타입 단언을 써야 했습니다.
이렇게 해서 "참조는 고정하되 최신 값은 참조"라는 요구사항을 만족할 수 있었습니다. 강의에서 배운 개념들이지만 실제로 구현해보니까 하나하나가 다 어려웠습니다. 이후로도 shallowEquals나 createObserver 같은 다른 함수들을 만들 때도 비슷한 타입 에러들을 종종 마주쳤는데, 하나씩 해결해나가면서 TypeScript를 조금씩 이해할 수 있게 되었습니다.
자랑하고 싶은 코드
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
// 콜백함수가 참조하는 값은 항상 렌더링 시점에 최신화
const fnRef = useRef(fn);
fnRef.current = fn;
// 대신 항상 동일한 참조를 유지해야 한다 (useCallback 활용)
// ...args 어떤 인자든 그대로 실행
// 어떤 인자가 올지 모르니 (...args: unknown[])로 모두 받아서 전달
const stableCallback = useCallback((...args: unknown[]) => {
return fnRef.current(...args);
}, []);
// 반환 타입을 T로 맞춰주기 위해 as T 사용
return stableCallback as T;
};
"참조는 고정하되 최신 값은 참조"라는 까다로운 요구사항을 useRef + useCallback 조합으로 해결한 부분이 가장 만족스럽습니다. 처음에는 타입 때문에 고생했지만, 제네릭과 unknown[], as T 타입 단언을 사용해서 TypeScript의 복잡한 타입 시스템을 해결할 수 있었습니다.
개선이 필요하다고 생각하는 코드
useMemo 과도한 사용
// packages/app/src/components/toast/ToastProvider.tsx - 현재 5번의 useMemo 사용
const { show, hide } = useMemo(() => createActions(dispatch), [dispatch]);
const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]);
const showWithHide = useAutoCallback((...args) => { ... }); // 내부에서 useMemo 사용
const commandValue = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]);
const stateValue = useMemo(() => ({ message: state.message, type: state.type }), [state.message, state.type]);
E2E 테스트 통과 과정에서 하나씩 메모이제이션을 추가하다 보니 이렇게 됐는데, useMemo를 이렇게 많이 써도 되는 건지 잘 모르겠습니다. 특히 { message: state.message, type: state.type } 같은 경우도 useMemo로 감싸야 하는 건지, 그리고 이런 식으로 계속 메모이제이션을 추가하는 게 올바른 방법인지 판단이 서지 않습니다. 더 나은 구조가 있다면 개선하고 싶습니다.
학습 효과 분석
가장 큰 배움이 있었던 부분
- 리렌더링 과정의 이해: 이론으로만 알고 있던 React의 리렌더링 과정을 직접 겪어보면서 깊이 이해할 수 있었습니다. 특히
ToastProvider에서 함수들이 매번 새로 생성되어ProductCard가 불필요하게 리렌더링되는 문제를 해결하면서, Context value의 참조 안정성이 얼마나 중요한지 알게 되었습니다. - 메모이제이션의 중요성: 단순히
useMemo나useCallback을 사용하는 것이 아니라, 모든 불안정한 참조를 하나씩 메모이제이션해야 한다는 것을 배웠습니다. 하나라도 빠뜨리면 전체가 다시 리렌더링되는 경험을 통해 메모이제이션의 중요성을 깊이 이해할 수 있었습니다.
추가 학습이 필요한 영역
- 타입스크립트 고급 기능: 제네릭과 타입 단언을 사용하면서 TypeScript의 복잡한 타입 시스템에 대한 이해가 부족하다는 것을 느꼈습니다. 특히
unknown[]타입과as T타입 단언을 사용할 때 타입 안전성에 대한 고민이 필요할 것 같습니다. - 성능 최적화 감각: 언제 메모이제이션을 사용해야 하는지, 어떤 부분에서 성능 이슈가 발생할 수 있는지에 대한 경험과 감각이 부족하다는 것을 깨달았습니다. E2E 테스트를 통해서만 문제를 발견할 수 있었던 점이 아쉬웠습니다. 실제 개발 과정에서 미리 예측하고 최적화하는 감각을 기르고 싶습니다.
실무 적용 가능성
- 상태 관리 라이브러리 이해: 직접 상태 관리 라이브러리를 구현해보면서 Zustand나 Redux 같은 라이브러리들이 내부적으로 어떻게 동작하는지 이해할 수 있었습니다. 이는 실제 프로젝트에서 상태 관리 라이브러리를 선택하고 사용할 때 도움이 될 것 같습니다.
- 커스텀 훅 설계:
useAutoCallback,useShallowSelector같은 커스텀 훅을 직접 구현하면서 재사용 가능한 로직을 설계하는 방법을 배웠습니다. 이는 실제 프로젝트에서 공통 로직을 추상화할 때 유용할 것 같습니다.
과제 피드백
과제에서 좋았던 부분:
- 단계별 구현: useRef → useMemo → useCallback 순서로 의존성을 가진 구현이 학습에 효과적이었습니다.
- 실제 동작하는 애플리케이션: 단순한 예제가 아닌 실제 쇼핑몰을 통한 학습이 재밌었습니다.
- E2E 테스트: 실제 성능 문제를 발견할 수 있는 현실적인 테스트가 도움되었습니다.
- 자립적인 학습 경험: 1, 2주차에 비해 AI에 무작정 의존하지 않고 조언이나 공유해주신 문서를 보고 ai에게 물어보며 해결을 해서 이전보다 기억에 더 남았습니다. 특히 useSyncExternalStore나 Observer 패턴 같은 개념들을 직접 찾아보고 이해하려고 노력한 게 도움이 되었습니다.
과제에서 어려웠던 부분:
- 메모이제이션 기준: 가이드를 보고 상황에 맞게 적용하는 것도 제 능력인데, 아직 그런 경험과 감각이 부족해서 언제 useMemo를 사용해야 하는지를 잘 몰랐던 것 같습니다.
- 타입 에러: TypeScript 활용을 잘 못해 관련 에러 해결에 시간이 많이 소요되었습니다.
학습 갈무리
리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.
리액트의 렌더링은 컴포넌트의 state나 props가 변경될 때 발생하며, 크게 세 단계로 이루어집니다.
- 렌더(Render) 단계:
- state/props 변경, 부모 컴포넌트 리렌더링, 또는
forceUpdate호출 시 렌더링이 실행됩니다. - React는 컴포넌트 함수를 호출하여 어떤 UI를 그려야 할지 결정하고, 그 결과로 가상돔(Virtual DOM) 객체가 만들어 집니다. 이 과정은 실제 DOM을 변경하지 않으므로 비용이 저렴합니다.
- state/props 변경, 부모 컴포넌트 리렌더링, 또는
- 조정(Reconciliation) 단계:
- 새로 생성된 가상돔과 이전 가상돔을 비교하여 변경된 부분을 찾아냅니다.(Diffing)
- React는 효율적인 Diffing 알고리즘을 사용하여 최소한의 변경사항을 계산합니다.
- 커밋(Commit) 단계:
- 조정 단계에서 찾아낸 변경사항들을 실제 DOM에 한 번에 적용하여 UI를 업데이트 합니다. 이 과정은 실제 브라우저가 화면을 새로 그려(렌더링 유발) 비용이 가장 크게 발생합니다.
이러한 과정을 최적화하기 위해 React는 memo, useMemo, useCallback과 같은 도구를 제공합니다. 이들은 렌더 단계에서 props나 의존성이 변경되지 않았을 경우, 이전 렌더 결과를 재사용하여 불필요한 가상돔 생성 및 Diffing 과정을 건너뛰게 해줍니다.
메모이제이션에 대한 나의 생각을 적어주세요.
메모이제이션은 "비용이 비싼 연산의 결과를 저장해두고, 동일한 입력에 대해서는 저장된 결과를 재사용하는 기술"이라고 생각합니다.
-
필요한 시점:
- 복잡하고 무거운 계산이 포함된 함수의 반복 호출을 피하고 싶을 때 (useMemo).
- 자식 컴포넌트에 함수(콜백)를 props로 전달할 때, 불필요한 리렌더링을 방지하기 위해 함수의 참조 동등성을 유지해야 할 때 (useCallback).
- 컴포넌트의 props가 변경되지 않았음에도 부모의 리렌더링 때문에 불필요하게 다시 렌더링되는 것을 막고 싶을 때 (React.memo).
-
장점과 단점:
- 장점: 불필요한 연산과 렌더링을 줄여 애플리케이션의 성능을 크게 향상시킬 수 있습니다.
- 단점: 메모리를 추가로 사용하여 이전 값과 의존성을 저장해야 하므로, 메모리 사용량이 늘어납니다. 또한, 모든 곳에 메모이제이션을 적용하면 오히려 의존성 비교 비용 때문에 성능이 저하되거나 코드가 복잡해질 수 있습니다.
결론적으로, 메모이제이션은 성능 저하가 실제로 발생하는 지점을 프로파일링 도구로 측정한 후, 꼭 필요한 곳에 전략적으로 사용하는 것이 중요합니다.(그렇지만 저는 이번 과제에서 남발한게 아닌가 하는 느낌;;;)
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
컨텍스트와 상태관리는 "컴포넌트 트리 전반에 걸쳐 흩어져 있는 데이터를 효율적으로 관리하고 공유하기 위한 솔루션"이라고 생각합니다.
-
필요한 이유:
- Prop Drilling 해결: 여러 단계의 자식 컴포넌트로 props를 계속해서 내려주는 'Prop Drilling' 문제를 해결하고, 필요한 컴포넌트가 데이터에 직접 접근할 수 있게 해줍니다.
- 상태의 중앙화: 애플리케이션의 상태를 한 곳에서 관리하여 데이터 흐름을 예측 가능하고 디버깅하기 쉽게 만듭니다.
-
장점과 단점:
- 장점: 코드 구조가 단순해지고, 상태 관리가 용이해지며, 컴포넌트 간의 결합도를 낮출 수 있습니다.
- 단점: React Context API는 컨텍스트 값이 변경되면 해당 컨텍스트를 구독하는 모든 컴포넌트가 리렌더링되는 문제가 있습니다. 이는 성능 저하의 원인이 될 수 있습니다.
-
주의점 및 해결책:
- Context 분리의 중요성
// 잘못된 예: 모든 것을 하나의 Context에
<ToastContext value={{ show, hide, message, type }}>
// 올바른 예: 용도별로 분리
<ToastCommandContext value={{ show, hide }}>
<ToastStateContext value={{ message, type }}>
- Provider value 메모이제이션
// 문제: 매번 새로운 객체 생성
<ToastCommandContext value={{ show: showWithHide, hide }}>
// 해결: 메모이제이션
const commandValue = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]);
const stateValue = useMemo(() => ({ message: state.message, type: state.type }), [state.message, state.type]);
<ToastCommandContext value={commandValue}>
<ToastStateContext value={stateValue}>
상태관리 라이브러리 직접 구현 경험: Observer 패턴을 기반으로 한 상태 관리 시스템을 구현하면서:
- useSyncExternalStore와의 호환성을 고려해야 한다는 것
- subscribe 함수가 구독 취소 함수를 반환해야 한다는 것
Context vs 상태관리 라이브러리:
- Context: React 내장, 간단한 전역 상태에 적합
- 상태관리 라이브러리: 복잡한 상태 로직, 성능 최적화에 유리
이번 과제를 통해 둘 다 결국 "상태 변경 시 구독자들에게 알림"이라는 동일한 패턴을 사용한다는 것을 알게 되었습니다.
리뷰 받고 싶은 내용
- ToastProvider에서 useMemo 과도한 사용 : 해당 페이지에서 useMemo를 5번 사용하고 있는데 과도하게 사용되고 있다고 생각하지만 다른 방법을 모르겠습니다. 어찌저찌 요구사항과 테스트 통과를 위해 구현해내긴 했으나 더 좋은 방법이 있을지 궁금합니다.
- useAutoCallback 패턴이 올바른지 : useRef와 useCallback을 조합해서 만든 건데, 이런 패턴이 실제로 쓰이는 방법인지 궁금합니다. 다른 더 좋은 방법이 있을까요?
과제 피드백
안녕하세요 지혜님! 3주차 과제 잘 진행해주셨네요 ㅎㅎ 고생하셨습니다.
"참조는 고정하되 최신 값은 참조"라는 까다로운 요구사항을 useRef + useCallback 조합으로 해결한 부분이 가장 만족스럽습니다. 처음에는 타입 때문에 고생했지만, 제네릭과 unknown[], as T 타입 단언을 사용해서 TypeScript의 복잡한 타입 시스템을 해결할 수 있었습니다.
ref와 callback 등을 같이 사용하는 과정이 지혜님께 큰 챌린지였군요..! 그럼에도 불구하고 잘 학습하고 계셔서 다행이네요 ㅎㅎ
E2E 테스트 통과 과정에서 하나씩 메모이제이션을 추가하다 보니 이렇게 됐는데, useMemo를 이렇게 많이 써도 되는 건지 잘 모르겠습니다. 특히 { message: state.message, type: state.type } 같은 경우도 useMemo로 감싸야 하는 건지, 그리고 이런 식으로 계속 메모이제이션을 추가하는 게 올바른 방법인지 판단이 서지 않습니다. 더 나은 구조가 있다면 개선하고 싶습니다.
지금은 state를 메모이제이션 하지 않아도 무방해보여요. 잘 보면 state.message와 state.type 중 하나만 변화하는 경우가 없답니다. 둘의 변화가 똑같이 발생하는거죠.
메모이제이션을 하지 않아도 어차피 똑같은 메모리를 가르키게 된달까..? 그렇습니다. 그래서 다만 state를 2개 이상 사용하여 조합하는 값이 있을 때에는 useMemo를 사용하면 좋답니다!
ToastProvider에서 useMemo 과도한 사용 : 해당 페이지에서 useMemo를 5번 사용하고 있는데 과도하게 사용되고 있다고 생각하지만 다른 방법을 모르겠습니다. 어찌저찌 요구사항과 테스트 통과를 위해 구현해내긴 했으나 더 좋은 방법이 있을지 궁금합니다.
솔루션 코드도 살펴보시면 5번 정도 사용하고 있답니다 ㅎㅎ 저는 지금이 최선의 모습이라고 생각해요! 다만 state를 아예 contextValue로 사용하고 있기 때문에 메모이제이션을 해주지 않아도 무방해보이네요..!
useAutoCallback 패턴이 올바른지 : useRef와 useCallback을 조합해서 만든 건데, 이런 패턴이 실제로 쓰이는 방법인지 궁금합니다. 다른 더 좋은 방법이 있을까요?
실제로 쓰일 수 있답니다 ㅎㅎ 이를 대체할 수 잇는 더 좋은 방법? 에 대해 질문을 주신거라면... 아직은 잘 모르겠어요.
어떤 상황에 사용할 수 있는지는 제가 이야기 드리기보단 지혜님께서 추후에 필요할 때 "이런게 있구나!?" 라고 떠올리고 사용할 수 있는 순간이 있으리라 생각해요.