항해플러스 3주차 - Equals, Hooks
React 내부 동작 이해하기: 메모이제이션부터 상태 관리까지
3주차 발제
3주차는 React의 핵심 개념인 메모이제이션을 이해하고 직접 구현해보는 과제였다. 얕은 비교부터 깊은 비교까지, 그리고 React의 기본 훅들을 직접 만들어보며 라이브러리의 내부 동작을 깊이 이해할 수 있었다. 그 과정에서 얻은 인사이트를 살짝 정리하고 멘토링 내용을 정리하며 이번주 회고를 진행해보려 한다.
React 메모이제이션이 필요한 이유
React에서 성능 최적화의 핵심은 불필요한 리렌더링 방지입니다. 먼저 실제 개발에서 마주치는 문제를 살펴보겠습니다.
참조 동등성 문제 사례
// 문제가 있는 코드
function App() {
const [count, setCount] = useState(0);
return (
<ThemeContext.Provider value={{ theme: 'dark', toggleTheme: () => {} }}>
<ExpensiveComponent />
</ThemeContext.Provider>
);
}
이 코드에서 value prop은 매 렌더링마다 새로운 객체를 생성합니다. count가 변경되어 App이 리렌더링되면, ExpensiveComponent도 불필요하게 리렌더링됩니다.
이런 문제를 해결하려면 언제 이전 결과를 재사용할지 판단하는 메모이제이션 메커니즘이 필요합니다.
비교 함수
메모이제이션을 구현하려면 먼저 값이 변경되었는지 비교할 수 있어야 합니다.
Object.is의 필요성
JavaScript의 === 연산자는 몇 가지 예외 상황에서 예상과 다르게 동작합니다
NaN === NaN; // false
+0 === -0; // true
Object.is(NaN, NaN); // true
Object.is(+0, -0); // false
Object.is는 이런 예외 상황에도 올바른 결과를 반환하며 다른 경우에 대해서는 ===와 동일한 결과를 반환합니다.
얕은 비교 함수
export const shallowEquals = (a: unknown, b: unknown) => {
if (Object.is(a, b)) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
return Object.is(a, b);
}
// 배열 처리
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length &&
a.every((value, index) => Object.is(value, b[index]));
}
// 객체 처리
const keysA = Object.keys(a);
const keysB = Object.keys(b);
return keysA.length === keysB.length &&
keysA.every(key => Object.is(a[key], b[key]));
};
- Object.is로 기본 비교하여 대부분의 경우를 빠르게 처리
- 배열을 먼저 체크한 후 일반 객체 처리 (JavaScript에서 배열도 typeof 결과가 "object"이므로 먼저 구분해야 함)
- 1depth만 비교하여 성능과 정확성의 균형 유지
깊은 비교 함수
export const deepEquals = (a: unknown, b: unknown) => {
if (Object.is(a, b)) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
return Object.is(a, b);
}
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, i) => deepEquals(item, b[i]));
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
return keysA.length === keysB.length &&
keysA.every(key => deepEquals(a[key], b[key]));
};
- 성능 비용이 크므로 신중하게 사용
- 복잡한 중첩 객체가 자주 변경되지 않는 경우에만 고려
- 대부분의 경우 데이터 구조를 flat하게 만드는 것이 더 좋은 해결책
React 훅 구현하기 (Preact 기준)
이제 비교 함수를 활용해서 React 훅들을 직접 구현해보겠습니다.
useRef
export function useRef<T>(initialValue: T): { current: T } {
const [ref] = useState(() => ({ current: initialValue }));
return ref;
}
useRef의 핵심은 변경 가능한 객체를 생성하되, 변경 시 리렌더링을 발생시키지 않는 것입니다. useState의 lazy initialization을 활용해 초기값으로 객체를 생성하면 됩니다.
useMemo
export function useMemo<T>(factory: () => T, deps: DependencyList): T {
const valueRef = useRef<T | null>(null);
const depsRef = useRef<DependencyList>([]);
if (valueRef.current === null || !shallowEquals(depsRef.current, deps)) {
valueRef.current = factory();
depsRef.current = deps;
}
return valueRef.current;
}
- 이전 의존성 배열과 현재 배열을 shallowEquals로 비교
- 변경되지 않았으면 이전에 계산된 값을 그대로 반환
- 변경되었으면 새로 계산하고 결과를 저장
useCallback
export function useCallback<T extends Function>(factory: T, deps: DependencyList): T {
return useMemo(() => factory, deps);
}
useCallback은 함수를 메모이제이션하는 useMemo의 특수한 형태입니다.
React.memo
React.memo는 컴포넌트의 props가 변경되지 않았을 때 이전 렌더링 결과를 재사용하는 고차 컴포넌트입니다.
import { createElement, type FunctionComponent, type ReactElement } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "../hooks";
export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
const MemoizedComponent = (props: P) => {
const prevPropsRef = useRef<P | null>(null);
const prevComponentRef = useRef<ReactElement<P> | null>(null);
// 이전 props와 현재 props 비교
if (prevPropsRef.current && equals(prevPropsRef.current, props)) {
// props가 같으면 이전 렌더 결과를 반환 (메모이제이션)
return prevComponentRef.current;
}
// props가 다르면 새로운 렌더링
const result = createElement(Component, props);
prevPropsRef.current = props;
prevComponentRef.current = result;
return result;
};
return MemoizedComponent;
}
- useRef로 이전 값 저장: props와 렌더링 결과를 각각 ref에 저장하여 리렌더링 간에 값을 유지
- 비교 함수 활용: 기본적으로 shallowEquals를 사용하되, 커스텀 비교 함수도 허용 (deepEquals)
- createElement로 컴포넌트 생성: JSX 없이 React 엘리먼트를 직접 생성하여 HOC 구현
- 조건부 렌더링: props가 같으면 이전 결과 반환, 다르면 새로 렌더링
memo와 useMemo의 차이점
memo는 컴포넌트 레벨에서 동작(hoc)하는 반면, useMemo는 값 레벨에서 동작합니다. memo는 전체 컴포넌트의 렌더링을 건너뛰고, useMemo는 특정 계산 결과만 메모이제이션합니다. useCallback은? 함수자체를 메모이제이션합니다.
외부 상태와 React 동기화
React 18에서는 외부 상태와의 동기화를 위한 새로운 훅이 도입되었습니다.
export const useStore = <T, S = T>(
store: Store<T>,
selector: (state: T) => S = defaultSelector
) => {
const shallowSelector = useShallowSelector(selector);
return useSyncExternalStore(
(onStoreChange) => store.subscribe(onStoreChange),
() => shallowSelector(store.getState()),
);
};
- subscribe 함수가 구독 해제 함수를 반환해야 함
- getSnapshot이 매번 같은 참조를 반환해야 불필요한 리렌더링 방지
- React 18의 Concurrent Features와 호환되어 tearing 현상 방지
Observer 패턴 개선
기존 Observer 패턴을 수정하여 useSyncExternalStore와 호환되도록 만들었습니다:
const subscribe = (fn: Listener) => {
listeners.add(fn);
// unsubscribe 함수를 반환
return () => {
listeners.delete(fn);
};
};
};
- 구독 해제 함수를 반환하여 cleanup 시 호출
- 이전 코드에서는 구독 해제 함수를 반환하지 않아 메모리 누수 발생
회고
코치님 멘토링에 대해 느낀바를 좀 적어보려 했는데 또 다음주 과제가 있다. 너무 바쁘니 다음에 추가예정,,