과제 체크포인트
배포 링크
https://angielxx.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. 실제 React가 hook을 관리하는 방식
Hook을 구현하기 전 실제 React에서는 어떻게 하고 있는지 알아봤습니다. React는 훅을 상태 배열(Linked List)과 인덱스를 기반으로 동작하는 것을 알게 됐습니다. 이 내용을 통해 왜 hook을 조건문이나 반복문 안에서 쓰면 안되는지 이해하게 됐습니다.
🔸 핵심 개념
“React는 각 훅이 어느 순서로 호출되었는지 기억하고, 렌더링마다 그 순서에 맞는 상태값을 꺼내서 반환한다.”
🔸 Hook 상태 저장 구조 (개념적 예시)
// Component마다 존재하는 구조 (추상화된 구조)
let hooks: Hook[] = [];
let currentHookIndex: number = 0;
hooks
- 현재 렌더링 중인 컴포넌트 인스턴스에 대한 모든 훅의 상태를 저장하는 배열 또는 연결 리스트의 시작점
- 훅 호출 순서(index)에 따라 저장되며, 리렌더링 시에도 그 순서를 유지함
- 새로운 렌더링이 발생해도 기존 상태를 재사용하며, currentHookIndex = 0부터 훅을 순차적으로 접근
currentHookIndex
- 렌더링 도중에 현재 어떤 훅을 실행하고 있는지 나타내는 인덱스
- 각 훅 호출 시 hooks[currentHookIndex]를 사용하며, 훅 호출이 끝나면 currentHookIndex++를 해서 다음 훅으로 넘어간다.
React의 내부에서 Hook 상태는 실제로 객체 형태로 저장되며, 연결 리스트 형태로 되어 있습니다. 이것을 내부에서는 workInProgressHook이라는 포인터로 순차적으로 탐색하며 useState, useEffect 등에서 상태를 저장하거나 꺼냅니다.
🔸 예시: useState 구현 흉내
// React 내부처럼 훅 상태를 저장할 배열
let hooks = [];
let currentHookIndex = 0;
// useState 흉내
function useState(initialValue) {
const hookIndex = currentHookIndex;
// 최초 렌더 시만 초기값 설정
if (hooks[hookIndex] === undefined) {
hooks[hookIndex] = initialValue;
}
const setState = (newValue) => {
hooks[hookIndex] = newValue;
render(); // 상태 바뀌면 리렌더링
};
currentHookIndex++;
return [hooks[hookIndex], setState];
}
만약 컴포넌트가 이렇게 생겼다면
function MyComponent() {
const [count, setCount] = useState(0); // 첫 번째 훅
const [text, setText] = useState("Hi"); // 두 번째 훅
const ref = useRef(null); // 세 번째 훅
}
React는 이 컴포넌트를 렌더링할 때, hooks[] 배열을 다음처럼 구성합니다:
// 최초 렌더링 시
hooks = [
0, // hooks[0] = count의 상태
"Hi", // hooks[1] = text의 상태
{ current: null }, // hooks[2] = ref의 상태
];
2. hook의 호출 순서가 중요한 이유
1번 내용을 기반으로 hook 호출 순서가 왜 중요한 지 hook 상태 관리 구조를 기반으로 살펴보겠습니다.
❌ hook의 호출 순서가 꼬이는 예:
// hook의 호출 순서 규칙을 위반하는 경우 - 조건문, 반복문
if (condition) {
const [a, setA] = useState(0); // ❌ Hook이 조건문 안에 있음
}
const [b, setB] = useState(0);
1차 렌더링 시 condition === true라면
hooks[0] = 0; // a
hooks[1] = 1; // b
2차 렌더링 시 condition === false라면
// 첫 번째 useState 호출이 생략됨!
hooks[0] = 1; // b라고 생각했지만, a의 자리 덮어씀 → 꼬임 발생
React는 각 컴포넌트의 상태를 렌더링 “순서(index)”로 저장하고 꺼내오기 때문에, Hook의 호출 순서가 바뀌면 완전히 잘못된 상태를 꺼내오게 됩니다!
🔸 React의 실제 hooks 구조 맛보기:
React는 내부적으로 hooks[] 배열이 아닌, **연결 리스트(Linked List)**로 훅 상태를 저장합니다:
type Hook = {
memoizedState: any;
next: Hook | null;
};
hook0: { memoizedState: 0, next: hook1 } // 연결 리스트이기 때문에 next 사용
hook1: { memoizedState: "Hi", next: hook2 }
hook2: { memoizedState: { current: null }, next: null }
각 Hook은 다음 Hook을 .next로 가리키고 memoizedState는 훅의 실제 상태값
🔸 React가 연결 리스트를 사용하는 이유
- 배열보다 유연하게 Hook을 추가, 삭제가 쉬우므로 React 내부 최적화 작업에 유리
- 각 훅마다 별도 정보를 저장할 수 있음
- 연결 리스트는 다음 훅을 .next로 따라가며 순회하기 때문에 훅 호출 순서를 정확히 따라갈 수 있음
- React는 렌더링 상태를 FiberNode라는 구조로 관리하는데, 이 Fiber 구조와 통합하기 쉬움 (추가 공부 필요)
React의 훅은 상태를 “호출 순서” 기반으로 저장하며, 이 상태들을 FiberNode.memoizedState에 연결 리스트로 관리합니다. 이러한 구조 덕분에 React는 함수형 컴포넌트로도 상태를 유지할 수 있으며, 렌더링 최적화와 내부 동기화를 효율적으로 처리할 수 있습니다.
2. React의 얕은 비교
얕은 비교, 깊은 비교에 대해 정확하게 이해하지 못하고 있는 것 같아 비교함수를 구현하기 전에 얕은 비교와 깊은 비교의 개념과 React에서 쓰이는 얕은 비교의 사용이유와 예시를 학습했습니다.
🔸 얕은 비교 (shallow comparison)
객체의 참조값 또는 1단계 프로퍼티만 비교 숫자, 문자열 등의 원시타입은 값을 비교하고 배열, 객체 등 참조 타입은 값 혹은 속성을 비교하지 않고, 참조값을 비교합니다. 예: ===, Object.is, shallowEqual() 등
🔸 깊은 비교(deep comparison)
객체의 모든 하위 프로퍼티를 재귀적으로 비교 예: Lodash의 _.isEqual(), 커스텀 재귀 함수 등
🔸 예시:
const obj1 = { a: 1, b: { c: 2 } };
const obj2 = { a: 1, b: { c: 2 } };
obj1 === obj2; // 얕은 비교 false (참조 다름)
shallowEqual(obj1, obj2); // 얕은 비교 false (b의 참조값 다름)
_.isEqual(obj1, obj2); // 깊은 비교 true
🔸 얕은 비교가 사용되는 곳
React는 퍼포먼스를 위해 기본적으로 얕은 비교를 사용합니다.
- React.memo(Component) : props가 얕게 같으면 리렌더링 방지
- useMemo, useCallback : deps 배열 안의 항목 비교
- PureComponent : 클래스 컴포넌트 최적화
- useEffect : 값이 바뀌었는지 확인할 때 얕은 비교
🔸 얕은 비교가 사용하는 이유
- 빠르다 : 깊은 비교는 재귀로 인해 느리고 비용이 큼
- 충분함 : 대부분의 경우 객체가 불변성 원칙을 따르므로, 얕은 비교로도 변화 감지가 가능
3. Object.is
React 내부적으로 Object.is를 사용한다고 2번을 통해 알게 됐는데, 비교 함수를 구현할 때 Object.is를 사용하자니 정확히 Object.is가 어떻게 동작하는지 몰라 먼저 정리해봤습니다.
Object.is()는 자바스크립트에서 값의 **동일성(sameness)**을 비교하는 내장 함수로, 자주 쓰이는 ===과 유사하지만, 미묘한 차이점이 존재한다.
🔸 Object.is()란?
Object.is(value1, value2);
- 두 값이 정확히 같은지 여부를 판단
- 두 가지 경우에서만 ===과 다르게 동작:
+0 vs -0,NaN vs NaN
| 비교 대상 | === 결과 | Object.is() 결과 | 설명 |
|---|---|---|---|
1, 1 | true | true | 동일 |
[], [] | false | false | 참조 다름 |
null, undefined | false | false | 타입 다름 |
+0, -0 | true | false | ✅ 차이 있음 |
NaN, NaN | false | true | ✅ 차이 있음 |
🔸 왜 Object.is를 사용해야 하는가?
+0과 -0을 구분해야 하는 경우 : 수학적 연산에서 양/음 0을 구분해야 할 때 유용
Object.is(+0, -0); // false
+0 === -0; // true
NaN을 자기 자신과 비교 가능하게
NaN === NaN; // false
Object.is(NaN, NaN); // true
🔸 Object.is Polyfill
어떤 분이 디스코드에서 폴리필 얘기를 하셔서 궁금해서 찾아봤습니다. Object.is()는 ES6(ECMAScript 2015)에서 도입된 함수이기 때문에, **구형 브라우저(특히 IE)**에서는 지원하지 않을 수 있습니다. 그래서 구형 브라우저에서도 같은 동작을 하도록 대체 함수를 만들어 사용할 수 있습니다.
// Object.is가 없는 경우를 위한 대체 함수 (폴리필)
function is(x, y) {
if (x === y) {
// +0과 -0 구분
return x !== 0 || 1 / x === 1 / y;
}
// NaN과 NaN 비교
return x !== x && y !== y;
}
// 폴리필 적용 방식 예시
if (!Object.is) {
Object.is = function (x, y) {
if (x === y) {
return x !== 0 || 1 / x === 1 / y;
}
return x !== x && y !== y;
};
}
이렇게 하면 모든 코드에서 Object.is()를 안전하게 호출할 수 있게 됩니다.
4. useSyncExternalStore 학습
태어나서 처음 들어보는 useSyncExternalStore 훅을 사용했지만, 생성 배경이나 해결해주는 문제들에 대해선 자세히 몰라 과제를 모두 완성한 후 내용을 더 찾아봤습니다. (영감을 주신 오하늘님께 감사를..글 재밌게 잘 읽었습니다.)
🔸 useSyncExternalStore란?
외부 상태 저장소(external store)의 상태를 React 컴포넌트와 동기적으로 연결(sync)해주는 훅
🔸 왜 useSyncExternalStore가 생겼는가?
useSyncExternalStore는 React 18에서 새롭게 추가된 훅인데, 이 훅이 생겨난 배경은 실제 React 사용자들의 문제점을 해결하기 위해서라고 합니다.
React 개발자들이 외부 상태(store)를 연결하기 위해 기존에 아래와 같은 방식을 사용한다면,
// 예전 방식 (useEffect + useState)
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setState(store.getState());
});
return unsubscribe;
}, []);
이 방식에서 발생하는 문제점은,
- 렌더링 타이밍 문제 (깜빡임)
- useEffect는 렌더 이후에 동작하기 때문에 외부 상태가 이미 바뀌었는데도, 오래된 값으로 화면을 그린 후 → 다시 수정됩니다. 그래서 깜빡이는 UI, 불일치된 렌더링이 발생합니다.
- React Concurrent Mode(동시성 모드)에서 위험
- React 18부터는 렌더링을 일시 중단하고 다시 재시작할 수 있습니다. 이런 경우 외부 상태가 바뀌면 React 내부 상태와 외부 상태가 불일치할 수 있습니다.
예를 들어:
- React가 렌더링 중간에 작업을 멈췄는데, 그 사이 store 값이 바뀐다.
- 나중에 다시 렌더를 재개하면 오래된 값을 기반으로 렌더링되어 버그가 발생한다.
- SSR(서버 사이드 렌더링) 지원 어려움
useEffect는 서버에서는 실행되지 않기 때문에 서버에서 클라이언트로 넘어올 때 상태가 달라지는 “hydration mismatch” 발생할 수 있습니다.
🔸 useSyncExternalStore가 해결한 방법
- 렌더링 “이전”에 상태 스냅샷을 동기적으로 읽음
useSyncExternalStore(subscribe, getSnapshot);
getSnapshot()은 렌더링이 시작되기 전에 호출되어, 컴포넌트가 항상 최신 상태를 기준으로 렌더링되도록 보장합니다.
- useEffect로 늦게 상태를 반영하는 등의 작업은 필요 없게 됐습니다.
- 결과적으로, 깜빡임이나 렌더-상태 불일치 문제가 사라졌습니다.
- 외부 상태의 변화 시점을 추적하여, 렌더링이 확정되기 전 snapshot이 바뀌면 렌더를 무효화
useSyncExternalStore는 외부 상태가 렌더링 중에 변경되었는지를 감지하고, 렌더링이 커밋되기 전에 변경이 감지되면 렌더링을 무효화하고 다시 시작합니다.
- 중간 상태가 오염되는 것을 막고, 외부 상태와 React 상태의 일관성을 유지합니다.
- 외부 상태의 예측 불가능한 변경에도 안정적으로 대응할 수 있습니다.
- 서버사이드 렌더링(SSR)에서의 상태 불일치를 방지
세 번째 인자인 getServerSnapshot()을 통해 서버에서 사용할 초기 상태를 미리 정의할 수 있습니다.
- 서버에서의 렌더링 결과와 클라이언트의 첫 렌더링 결과가 동일한 스냅샷 기반으로 일치되므로, hydration mismatch(서버-클라이언트 불일치) 오류를 방지할 수 있습니다.
🔸 사용법
const state = useSyncExternalStore(
subscribe, // 변화 감지 함수 (필수)
getSnapshot, // 현재 상태를 가져오는 함수 (필수)
getServerSnapshot? // SSR 전용 스냅샷 (선택)
);
- subscribe: 외부 상태가 바뀔 때 호출될 콜백 등록 (store.subscribe)
- getSnapshot: 현재 상태 값을 반환 (store.getState)
- getServerSnapshot: 서버 사이드에서 사용할 초기 상태 (SSR용)
hook 구현 및 트러블 슈팅
1. useRef
useRef는 리렌더링을 트리거하지 않는 내부적으로 값이 유지되는 객체를 반환해야 합니다. 클로저, 글로벌 변수 등 다양한 방법을 시도해봤지만 모두 제대로 동작하지 않아, React의 훅 시스템을 이용하여 ref 객체를 생성하는 방식을 사용했습니다.
export function useRef<T>(initialValue: T): { current: T } {
const [ref] = useState(() => ({ current: initialValue }));
return ref;
}
useState에 함수를 인자로 넘기면, 이 함수는 컴포넌트가 처음 마운트될 때 딱 한 번만 실행되기 때문에 하나의 useRef의 인스턴스의 값을 유지할 수 있는 객체 하나를 만들어 사용할 수 있습니다.
이 방법이 맘에 들지 않아 React의 useRef 구현 방식을 살펴보았으나 hooks의 memoizedState에 useRef의 상태를 보관한다고 하여, 실제로 구현해보고 싶은 마음이 있었지만 이번 과제에서 실제로 구현해보기에는 무리일 것 같아 학습으로만 남깁니다!
function useRef(initialValue) {
// 내부적으로 React는 이 값을 fiber tree에 저장함
const ref = mountRef(initialValue);
return ref;
}
2. useMemo
"계산된 값과 의존성 배열을 저장해놓고, 의존성 배열의 변경 여부를 확인하고 그에 따라 새로 계산하거나 이전에 계산한 값 반환"하는 것을 useMemo의 요구사항으로 정의했습니다.
❌ 첫번째 시도 : 매번 재계산함
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
let hasMemoMounted = false;
const memoizedState = useRef<{ value: T; deps: DependencyList } | null>(null);
if (!hasMemoMounted) {
hasMemoMounted = true;
memoizedState.current = {
value: factory(),
deps: _deps,
};
return memoizedState.current.value as T;
}
const compareFunc = _equals || shallowEquals;
if (!memoizedState.current || !compareFunc(memoizedState.current.deps, _deps)) {
memoizedState.current = {
value: factory(),
deps: _deps,
};
}
return memoizedState.current.value as T;
}
원인
- hasMemoMounted는 함수 내부의 지역 변수이기 때문에 React 함수형 컴포넌트(또는 훅)는 매 렌더마다 함수가 새로 실행되므로, 매 렌더링마다 hasMemoMounted는 false로 시작하게 됩니다.
✅ hasMemoMounted 변수를 함수 외부에 저장
let hasMemoMounted = false;
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
const memoizedState = useRef<{ value: T; deps: DependencyList } | null>(null);
if (!hasMemoMounted) {
hasMemoMounted = true;
memoizedState.current = {
value: factory(),
deps: _deps,
};
return memoizedState.current.value as T;
}
const compareFunc = _equals || shallowEquals;
if (!memoizedState.current || !compareFunc(memoizedState.current.deps, _deps)) {
memoizedState.current = {
value: factory(),
deps: _deps,
};
}
return memoizedState.current.value as T;
}
hasMemoMounted를 useMemo 함수 외부에 두어, 함수가 재호출되더라도 이전 렌더링 시점을 기억하게 만들면, 매번 재계산되지 않고 의존성 비교를 통해 메모이징된 값을 재사용할 수 있게 됩니다.
모든 테스트를 통과하지만 문제가 있습니다...hasMemoMounted를 useMemo 바깥에 두면, 모든 컴포넌트 인스턴스가 이 전역 값을 공유하게 되어 버그가 발생할 수 있습니다.
✅ 근본적인 해결: 렌더링 컨텍스트별 상태 저장
React는 각 컴포넌트마다 자체적인 Hook 상태 저장소를 가지고 있고, 각 훅 (useMemo, useState, useRef 등)의 호출 순서를 따라 인덱스를 기반으로 상태를 구분하게 하려면, useRef를 사용할 수 밖에 없을 것으로 결론을 지었습니다.
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
// deps와 value는 1:1 대응
const memoizedState = useRef<{ value: T; deps: DependencyList } | null>(null);
const compareFunc = _equals || shallowEquals;
// 초기 렌더링 시 초기값 설정 or 의존성 배열이 변경되었을 때 새로운 값 계산
if (!memoizedState.current || !compareFunc(memoizedState.current.deps, _deps)) {
memoizedState.current = {
value: factory(),
deps: _deps,
};
}
// 의존성 배열이 변경되지 않았을 때 이전 값 반환
return memoizedState.current.value;
}
3. useAutoCallback
요구사항 "useAutoCallback으로 만들어진 함수는, 참조가 변경되지 않으면서 항상 새로운 값을 참조한다."를 분석해보았습니다.
❓ "참조가 변경되지 않는다"란?
함수 객체의 참조(주소)가 변하지 않는다는 뜻입니다. 즉, 리렌더링이 여러 번 일어나도, callback === callback이 항상 true입니다.
❓ "항상 새로운 값을 참조한다"란?
함수 내부에서 사용하는 값(예: state, props 등)이 항상 최신 값을 참조한다는 뜻입니다. 즉, 함수가 오래전에 만들어졌더라도, 내부에서 사용하는 값은 "현재 시점의 최신 값"입니다.
const [count, setCount] = useState(0);
const callback = useAutoCallback(() => {
console.log(count); // 항상 최신 count 값이 출력됨
});
❓ 왜 이런 패턴이 필요할까?
- 일반적으로 useCallback은 의존성 배열이 비어 있으면 함수 참조는 변하지 않지만, 내부에서 사용하는 값은 "생성 시점의 값"에 고정됩니다(클로저 문제).
- 콜백 함수가 오래된 상태(state)나 props를 참조하게 되어, 실제로는 최신 값을 사용해야 하는 상황에서 의도치 않은 동작이 발생할 수 있습니다.
- setInterval, 이벤트 핸들러, 외부 라이브러리의 콜백 등에서 최신 상태를 항상 참조해야 할 때, useCallback만으로는 이 문제를 해결할 수 없습니다.
// 의존성 배열이 비어 있으므로, handleAlert는 최초 렌더 시점의 count만 기억함
const handleAlert = useCallback(() => {
alert(`현재 count: ${count}`);
}, []);
❌ 1차 시도
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
// 항상 같은 함수 참조를 반환
const stableCallback = useRef((...args: any[]) => {
// 여기서 fn(...args)를 하면 클로저가 생성
// 최신 상태, props를 사용하는 fn을 받아도 처음 fn만 사용하게 됨
return fn(...args);
});
return stableCallback.current as T;
};
원인
- stableCallback은 최신값을 사용하지 못합니다.
- fn의 클로저가 생성되므로, 처음 실행될 때의 fn을 클로저로 "캡처"하게 됩니다. 즉, 컴포넌트가 리렌더링되어 fn이 바뀌어도, 이 함수는 처음 fn만 계속 사용하므로 useAutoCallback을 사용하여 해결하고자 한 문제와 동일하게 동작하고 있습니다.
✅ 해결 : 최신 fn을 useRef로 저장
=export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
const fnRef = useRef(fn);
fnRef.current = fn; // 최신 fn을 항상 즉시 반영
// 항상 같은 함수 참조를 반환
const stableCallback = useRef((...args: unknown[]) => {
// 여기서 fn(...args)를 하면 클로저가 생성
// 최신 상태, props를 사용하는 fn을 받아도 처음 fn만 사용하게 됨
return fnRef.current(...args);
});
return stableCallback.current as T;
};
내부에서 사용하는 fnRef.current는 항상 최신 fn을 가리키므로, 콜백 함수가 항상 최신 상태/props를 사용할 수 있습니다.
4. useStore
useStore를 구현하면서 제가 생각했던 구현 방향성은 아래와 같습니다.
상태 비교 로직(shallowEquals)은 useShallowSelector에서만 적용 useStore에서는 단순히 스토어의 상태를 구독하고 사용할 수 있도록 최소한의 역할만 담당
🔸 useSyncExternalStore의 동작 원리
- getSnapshot의 반환값이 Object.is로 비교되어 값이 다르면 리렌더가 발생
- 즉, store의 상태 객체가 내용은 같아도 참조가 다르면(예: {a:1} → {a:1}) 리렌더가 발생
- getSnapshot에서 shallowEquals를 사용하지 않으면, store가 notify만 하면 무조건 리렌더가 발생하는 문제가 있음
❌ 동일 상태값으로 업데이트해도 리렌더링되는 문제
원인
- 처음에는 getSnapshot의 반환값을 항상 새로운 객체로 만들어서 반환했했습니다. 이 경우 내용이 같아도 참조가 달라져서 리렌더가 계속 발생하는 문제가 있었습니다.
✅ 해결
- getSnapshot에서 selector를 적용한 결과가 shallowEquals로 이전 값과 같으면, 이전 참조를 그대로 반환하도록 개선했습니다.
- store의 상태가 바뀌더라도 selector 결과가 같으면 리렌더가 발생하지 않습니다.
function getSelectedSnapshot() {
return shallowSelector(store.getState());
}
트러블 슈팅
1. 장바구니 추가 시 ProductCard가 모두 재렌더링
❌ 같은 memo 컴포넌트에서 다른 prevProps, props가 찍힘
import { type FunctionComponent } from "react";
import { shallowEquals } from "../equals";
export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
let prevProps: P | null = null;
let prevRendered: ReturnType<typeof Component> | null = null;
return function (props: P) {
console.log("memo");
console.log("prevProps: ", prevProps);
console.log("props: ", props);
console.log("");
// 첫 렌더링 시
if (prevProps === null) {
prevProps = props;
prevRendered = Component(props);
return prevRendered;
}
if (equals(prevProps, props)) {
return prevRendered;
}
prevProps = props;
prevRendered = Component(props);
return prevRendered;
};
}
위 구조에서 prevProps, props를 관리자 도구에서 찍어보면 아래처럼 각 ProductCard 컴포넌트가 렌더링될 때마다 prevProps와 props가 서로 다른 객체로 출력되는 현상을 확인했습니다.
ProductCard: 85067212996
memo.ts:9 memo
memo.ts:10 prevProps: {title: 'PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장', link: 'https://smartstore.naver.com/main/products/7522712674', image: 'https://shopping-phinf.pstatic.net/main_8506721/85067212996.1.jpg', lprice: '220', hprice: '', …}
memo.ts:11 props: {title: '샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이', link: 'https://smartstore.naver.com/main/products/9396357056', image: 'https://shopping-phinf.pstatic.net/main_8694085/86940857379.1.jpg', lprice: '230', hprice: '', …}
❌ 원인
- memo.ts의 커스텀 memo 함수에서 prevProps와 prevRendered를 컴포넌트 함수의 클로저 변수로 선언했습니다.
- 모든 ProductCard 인스턴스가 같은 memo 함수의 클로저를 공유하게 됩니다.
- 즉, 여러 ProductCard가 각각의 상태를 가지지 못하고, 마지막으로 렌더링된 컴포넌트의 props만 기억하게 되어, memoization이 제대로 동작하지 않습니다.
✅ 해결
- prevProps와 prevRendered를 클로저가 아닌, 각 컴포넌트 인스턴스별로 관리해야 합니다.
- 함수형 컴포넌트에서 값을 인스턴스별로 유지하려면 React의 useRef나 useState를 사용해야 합니다.
- memo 함수 내부가 아닌 memo 함수가 반환하는 함수형 컴포넌트 코드 내부에 prevProps, prevRendered 값을 저장하여 각 컴포넌트 인스턴스 별로 해당 값을 저장하고 관리할 수 있도롣 수정했습니다.
export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
return function (props: P) {
const prevProps = useRef<P | null>(null);
const prevRendered = useRef<ReturnType<typeof Component> | null>(null);
// props가 같으면 이전 결과 반환
if (prevProps.current !== null && equals(prevProps.current, props)) {
return prevRendered.current;
}
// props가 다르면 새로 렌더링 결과 저장
prevProps.current = props;
prevRendered.current = createElement(Component, props);
return prevRendered.current;
};
}
2. 장바구니를 추가하거나 삭제했을 때, 토스트 호출로 인하여 리렌더링
ToastProvider 최적화 후에도 계속 ProductCard가 재렌더링되는 문제가 있었습니다. ToastProvider에 사용된 useMemo 콜백에 콘솔로그 찍어봤는데 장바구니 담기 버튼 클릭할 때마다 콘솔 찍히는 거 확인하고, useMemo가 제대로 동작하고 있지 않은 것을 발견했습니다.
❌ useMemo 이전 구현 방식
const memoizedState: { value: unknown; deps: DependencyList | null } = {
value: null,
deps: null,
};
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
const compareFunc = _equals || shallowEquals;
if ((!memoizedState.value && !memoizedState.deps) || !compareFunc(memoizedState.deps, _deps)) {
memoizedState.value = factory();
memoizedState.deps = _deps;
}
return memoizedState.value as T;
}
원인
- memoizedState가 함수 바깥에 선언되어 모든 ProductCard 인스턴스가 memoizedState를 공유하여 메모이제이션이 제대로 동작하지 않고 있었습니다.
해결
- memoizedState를 함수 내 useRef로 저장하고 사용하도록 수정했습니다.
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
// deps와 value는 1:1 대응
const memoizedState = useRef<{ value: T; deps: DependencyList } | null>(null);
const compareFunc = _equals || shallowEquals;
// 초기 렌더링 시 초기값 설정 or 의존성 배열이 변경되었을 때 새로운 값 계산
if (!memoizedState.current || !compareFunc(memoizedState.current.deps, _deps)) {
memoizedState.current = {
value: factory(),
deps: _deps,
};
}
// 의존성 배열이 변경되지 않았을 때 이전 값 반환
return memoizedState.current.value;
}
❓ 잘못 구현했음에도 테스트 통과한 이유
- 테스트는 보통 하나의 인스턴스에서만 동작을 확인하기 때문에, 여러 컴포넌트에서 동시에 useMemo를 사용할 때 발생하는 문제는 테스트에서 검증할 수 없습니다.
- 실제 앱에서는 여러 컴포넌트가 useMemo를 동시에 사용할 수 있으므로, 이때 값이 꼬이는 문제가 발생했습니다.
학습 효과 분석
- React 훅의 내부 동작 원리: 훅이 어떻게 상태를 저장하고, 왜 호출 순서가 중요한지, 그리고 인스턴스별로 상태가 분리되어야 하는 이유를 깊이 이해할 수 있었습니다.
- 상태 관리와 최적화: useSyncExternalStore, selector, shallowEquals 등 실제 라이브러리에서 사용하는 패턴을 직접 구현해보며, 상태 관리와 리렌더링 최적화의 핵심 원리를 배웠습니다.
- 실무 적용 가능성: 커스텀 훅, HOC, 상태 관리 로직을 직접 구현해본 경험은 앞으로 실무에서 라이브러리 없이도 필요한 기능을 직접 만들거나, 라이브러리의 동작을 더 잘 이해하는 데 큰 도움이 될 것 같습니다.
학습 갈무리
리액트의 렌더링이 어떻게 이루어지는지 정리해주세요.
리액트의 렌더링 과정
- 상태나 props 변경
- 변경된 컴포넌트 함수 재실행 (→ JSX 반환)
- Virtual DOM 생성
- 이전 Virtual DOM과 비교(diffing)
- 실제 DOM과 최소한의 차이만 업데이트 (Reconciliation)
렌더링 최적화 방법
- memo를 사용해 props가 같다면 컴포넌트 재실행을 막을 수 있다.
- useMemo로 연산 결과를 캐시해 불필요한 계산을 방지할 수 있다.
- useCallback으로 함수를 메모이제이션해 자식 컴포넌트 리렌더링을 줄일 수 있다.
- 리스트를 렌더링할 때는 key를 고유하게 지정하여 효율적인 DOM 업데이트가 가능하게 해야 한다.
- React.lazy와 Suspense를 통해 컴포넌트를 지연 로딩할 수 있다.
- useTransition을 통해 UI를 우선순위에 따라 나눠서 업데이트할 수 있다.
- 외부 스토어와 동기화할 때는 useSyncExternalStore로 안정적인 구독과 렌더링 최적화가 가능하다.
메모이제이션에 대한 나의 생각을 적어주세요.
메모이제이션은 "비싼 연산의 결과를 저장해두고, 동일한 입력이 들어오면 저장된 값을 재사용하는 최적화 기법"입니다.
언제 필요할까?
- 연산 비용이 큰 함수(예: 복잡한 계산, 대용량 데이터 가공 등)
- React에서 컴포넌트가 자주 리렌더링되지만, props나 state가 바뀌지 않은 경우
- selector, 콜백, 렌더링 결과 등 재사용 가능한 값이 있을 때
사용하지 않으면?
- 동일한 연산이 불필요하게 반복되어 성능 저하
- React에서는 불필요한 리렌더링이 발생해 UI가 느려질 수 있음
장점
- 성능 최적화(불필요한 연산/렌더링 방지)
- React에서는 useMemo, useCallback, React.memo 등으로 쉽게 적용 가능
단점
- 메모리 사용량 증가(캐시 공간 필요)
- 의존성 관리가 잘못되면 오히려 버그나 불필요한 캐싱이 발생할 수 있음
❌ useCallback 남용 예시
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("clicked");
}, []); // ✅ 참조는 안정적이지만...
return (
<div>
<button onClick={() => setCount(count + 1)}>increase</button>
<Child onClick={handleClick} />
</div>
);
}
const Child = React.memo(({ onClick }: { onClick: () => void }) => {
console.log("Child rendered");
return <button onClick={onClick}>click me</button>;
});
- handleClick은 useCallback 덕분에 참조가 고정됨 → Child는 리렌더링 안 됨
- 그런데… Child는 원래 handleClick이 변경되지 않아도 재렌더 안 될 상황
- 즉, useCallback을 써도 체감 성능 차이 없음 + 메모리 사용 늘어남
-> 로직이 단순한 함수에 useCallback 쓰는 건 과도한 최적화, 특히 자식 컴포넌트가 memo되지 않은 경우엔 의미 없음
결론
- 메모이제이션은 "필요할 때만, 신중하게" 사용하는 것이 중요하며, React에서는 props/state의 변화와 연산 비용을 잘 고려해 적용해야 한다고 생각합니다.
컨텍스트와 상태관리에 대한 나의 생각을 적어주세요.
컨텍스트와 상태관리가 필요한 이유
- 여러 컴포넌트에서 동일한 데이터(상태)를 공유해야 할 때, props drilling 없이 효율적으로 상태를 전달/관리할 수 있습니다.
사용하지 않으면?
- props를 여러 단계로 전달해야 하거나, 상태 동기화가 어려워져 코드가 복잡해집니다.
장점
- 전역적으로 상태를 공유할 수 있어, 코드의 가독성과 유지보수성이 높아집니다.
- Context API, Redux, Zustand 등 다양한 상태 관리 도구를 활용할 수 있습니다.
단점
- 상태 관리가 복잡해질수록 코드가 무거워질 수 있고, 불필요한 리렌더링이 발생할 수 있습니다.
- Context는 값이 바뀌면 하위 트리 전체가 리렌더링될 수 있으므로, 최적화가 필요합니다.
대안/주의점
- 꼭 필요한 데이터만 Context/전역 상태로 관리하고, 나머지는 지역 상태(useState 등)로 관리하는 것이 좋습니다.
- 불필요한 리렌더링을 막기 위해 selector, memoization, 분리된 context 등 다양한 최적화 기법을 적용해야 합니다.
결론
- 컨텍스트와 상태관리는 대규모 애플리케이션에서 필수적인 도구이지만, "적재적소에, 필요한 만큼만" 사용하는 것이 가장 중요하다고 생각합니다.
- 상태의 범위와 변경 빈도, 성능 이슈 등을 고려해 적절한 도구와 패턴을 선택하는 것이 실무에서의 핵심이라고 느꼈습니다.
리뷰 받고 싶은 내용
- useRef를 useState 없이 구현할 방법이 없을까요?
- useMemo를 useRef 사용없이 구현할 방법이 없을까요?
과제 피드백
안녕하세요 은지, 수고했습니다! 이번 과제는 React의 내장 훅들을 직접 구현해보면서 프레임워크가 어떻게 상태를 관리하고 최적화하는지 깊이 이해하는 것이 목표였습니다.
단순히 과제를 완성하는 것에 그치지 않고, React의 실제 동작 원리부터 Object.is의 세부 사항, useSyncExternalStore의 탄생 배경까지 깊이 있게 탐구하신 모습이 인상적이네요. 아주 잘했습니다!
특히 Hook의 상태 관리가 연결 리스트 구조로 되어있다는 점을 발견하고, 왜 훅을 조건문 안에서 쓰면 안 되는지 원리적으로 이해하신 부분이 참 좋네요. 이론으로만 접근하면 단순히 하면 안되는 Rule정도로 치부할 수 있지만 실제로 만들어 보니 그럴 수 밖에 없구나 하는 식으로 귀결이 되는 지식이 쌓이는 것이 깊이를 이해하는 것이지요.
그밖에도 isArray, isObject 등 유틸리티를 별도 파일로 분리해서 예측가능한 코드를 잘 분리해둔 부분도 좋았습니다.
이번 과제를 통해 React가 제공하는 편리한 API들이 내부적으로 어떤 문제를 해결하고 있는지 몸소 체험하셨을 거예요. 특히 "메모이제이션은 만능이 아니다"라는 깨달음과 함께 구조적 개선의 중요성을 인식하신 점이 훌륭합니다.
Q) useRef를 useState 없이 구현할 방법이 없을까요?
=> 함수는 특성상 재실행될 때마다 내부 변수들이 초기화되기 때문에, 상태를 유지하려면 함수 외부의 값을 사용해야 하지요.
그렇게 하기 위해서는 전역변수, 혹은 레퍼런스 객체, 혹은 클로저 등을 사용하는 방식이 있습니다. 이중에서 전역변수는 답이 아니니 React도 내부적으로 FiberNode의 memoizedState에 저장하는 방식을 사용하죠.
그리고 두번째는 값을 보관해서 가져오는 방식입니다. 기존의 데이터에서 값을 가져오기 위해서는 정확한 위치를 알아야 하죠. 그러면 선택할 수 있는 방법은 같은 객체를 사용해서 값을 가져와서 prop에 접근하거나 index혹은 key이름을 통해서 가져오는 방식등이 있습니다.
useState는 index를 통해 접근하는 방식, useRef는 객체에 접근하고 current를 사용하는 방식을 사용했죠.
위와 같은 응용법을 이용해서 useRef와 useState의 체계를 별도로 구성하는 방법이 없지는 않겠지만 같은 리렌더링 체계를 공유하고 있으므로 상태를 관리하는 공통적인 방법을 가지고 만드는 방식이 가장 효율적인 체계입니다.
Q) useMemo를 useRef 사용없이 구현할 방법이 없을까요?
=> 안될 이유는 없지만 이미 상태관리의 추상화 계층을 만들어 두었는데 활용하지 않는 건 매우 비효율적인 방식입니다. 이미 Array라는 배열을 다루는 방식이 있는데 굳이 List라는 새로운 계층을 사용하는 거죠.
렌더링 간 값을 유지해야 하는데, useState를 사용하면 setState로 인한 불필요한 리렌더링이 발생하고, 전역 변수를 사용하면 컴포넌트 인스턴스 간 상태가 공유되는 문제가 생깁니다. useRef가 바로 이런 문제를 해결하기 위한 훅이므로, 메모제이션의 값을 보관하기에는 적절한 도구이죠. 대안을 찾기보다는 적절한 도구를 사용하는 것이 맞다고 생각해요.
수고하셨습니다. 깊이 있는 탐구와 실험 정신이 정말 인상적이었어요. 다음 클린코드 챕터에서도 이런 열정으로 좋은 인사이트를 얻으시길 바랍니다. 화이팅입니다! :)