이번 과제는 리액트를 깊이 이해하고 실제로 잘 활용하기 위해 필요한 여러 고민들을 했던 시간이었다.
이전 과제에서 Virtual DOM
을 직접 구현하면서 리액트의 렌더링 구조와 이벤트 위임, diffing
알고리즘의 기초 원리를 배웠다면, 이번 과제에서는 리액트 앱의 성능 최적화와 구조 설계, 렌더링 제어에 집중했다.
단순히 기능을 구현하는 것에서 벗어나, Context
분리, memoization
, 렌더링 트래킹, 컴포넌트 리렌더링 최적화까지, 실제 규모가 있는 리액트 앱을 운영할 때의 고민들을 작게나마 체험할 수 있었다.
이 과정에서 얻은 여러 교훈들을 이번 글에서 정리해보고자 한다.
리액트에서 성능을 고려한 렌더링 제어는 useMemo
, useCallback
, memo
등 다양한 도구를 통해 이루어진다.
이번 과제에서는 이 모든 도구를 직접 구현하며, 내부 원리와 동작 방식에 대한 깊은 이해를 쌓는 경험을 했다.
자바스크립트에서 객체와 배열은 모두 참조 타입으로, 같은 참조를 가진다면 내부 구조 또한 같다고 볼 수 있다.
하지만 두 값이 서로 다른 참조를 갖는 경우에는 비교가 필요하다.
이 비교는 목적에 맞게 얕은 비교(shallowEqual)
와 깊은 비교(deepEqual)
로 나눠서 할 수 있는데, 각 방법에 대해 어떻게 적용했는지 적어보았다.
shallowEquals
는 성능을 고려해 객체의 key
수가 같고, 각 key
의 값이 Object.is
를 기준으로 동일한지만 확인하도록 구현했다.
이 과정에서 NaN
비교와 0
과 -0
문제를 안전하게 처리하기 위해 ===
이 아닌 Object.is
를 사용해야 했다. Object.is
는 NaN
을 NaN
과 비교할 때 true
를 반환하고, -0
과 +0
을 구별할 수 있어 정확한 비교를 보장하는 데 필수적이었다.
또한 객체 여부를 판별하는 과정에서 isObject
헬퍼 함수를 함께 활용했다. 이 함수는 typeof === "object"
만으로는 null
이 포함될 수 있는 문제를 방지하기 위해 value !== null
추가 조건을 포함시켰다. 이로 인해 null
이나 undefined
같은 값들이 비교 과정에서 누락되지 않도록 안전하게 다룰 수 있었다.
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function haveSameKeys(a: object, b: object): boolean {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
return keysA.length === keysB.length;
}
function areShallowValuesEqual(
a: Record<string, unknown>,
b: Record<string, unknown>
): boolean {
return Object.keys(a).every((key) => key in b && Object.is(a[key], b[key]));
}
export function shallowEquals(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (!isObject(a) || !isObject(b)) return false;
if (!haveSameKeys(a, b)) return false;
return areShallowValuesEqual(a, b);
}
반면 deepEquals
는 구조적으로 훨씬 더 복잡했다. 배열과 객체를 구분하여 재귀적으로 순회하면서 모든 요소 또는 속성 값을 비교해야 했고, 순서까지 고려해야 하므로 반복 구조가 필수적이었다. 여기서도 타입 안정성을 고려하여, 각 비교 항목에 대해 타입을 확실히 정의하고 예외 처리를 추가하는 방식으로 진행했다.
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function areArraysEqual(a: unknown[], b: unknown[]): boolean {
if (a.length !== b.length) return false;
return a.every((item, index) => deepEquals(item, b[index]));
}
function areObjectsEqual(
a: Record<string, unknown>,
b: Record<string, unknown>
): boolean {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) => key in b && deepEquals(a[key], b[key]));
}
export function deepEquals(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (Array.isArray(a) && Array.isArray(b)) {
return areArraysEqual(a, b);
}
if (isObject(a) && isObject(b)) {
return areObjectsEqual(a, b);
}
return false;
}
깊은 비교에서 자주 발생할 수 있는 예외를 방지하기 위해 typeof
와 같은 안전한 타입 체크를 사용하여 정확한 타입만 비교하도록 했고, 이를 통해 발생할 수 있는 오류를 미리 방지했다.
비교 함수는 단순히 동작만 하면 되는 도구가 아니라, useMemo
, useCallback
, memo
와 같은 리액트 최적화 도구와 밀접하게 연결되어 있기 때문에, 타입 안전성은 성능 최적화와 함께 중요한 역할을 한다. 특히 deepEquals
를 잘못 사용할 경우 렌더링 최적화에 부정적인 영향을 미칠 수 있다.
useRef
를 직접 구현하면서 가장 중요한 점은 렌더링 사이에서도 값이 초기화되지 않고 유지되어야 한다는 것이다. 처음에는 const obj = { current: null }
처럼 객체를 만들어 저장하면 될 것 같았지만, 함수 컴포넌트는 매 렌더링마다 다시 실행되기 때문에 매번 새로 만들어져 초기화되는 구조가 된다. 이는 useRef
의 핵심 기능과 맞지 않기 때문에, 다른 방법을 고민해야 했다.
이를 해결하기 위해, 내부적으로 상태를 보존할 수 있는 저장소를 useState
로 만들어 state.current
로 접근 가능하게 설계했다. 또한 React
의 관용 스타일에 따라 기본값을 null
이 아닌 undefined
로 설정해 일관성을 유지했다.
import { useState } from "react";
export function useRef<T>(initialValue?: T | null): { current: T | null } {
const [ref] = useState({ current: initialValue ?? null });
return ref;
}
useMemo
는 의존성 배열이 변경되지 않으면 계산 결과를 재사용하는 원리로 작동한다. 이를 구현할 때 useRef
를 활용해 이전 결과와 의존성 배열을 저장하고, shallowEquals
를 이용해 비교하여 재계산 여부를 결정하는 방식으로 설계했다.
import { DependencyList } from "react";
import { shallowEquals } from "../../equalities";
import { useRef } from "./useRef";
export function useMemo<T>(
factory: () => T,
_deps: DependencyList,
_equals = shallowEquals
): T {
const valueRef = useRef<T>(null);
const depsRef = useRef<DependencyList>(null);
if (!depsRef.current || !_equals(_deps, depsRef.current)) {
valueRef.current = factory();
depsRef.current = _deps;
}
return valueRef.current as T;
}
특히 의존성 배열을 다룰 때마다 리액트의 "불변성 유지" 원칙이 얼마나 중요한지 실감할 수 있었다. useMemo
를 사용할 때 가장 중요한 점은 계산된 결과를 반환하는 것이 아니라, 언제 재계산을 해야 하는지를 정확히 판단하는 것이다.
의존성 배열을 제대로 관리하지 않으면 불필요한 재계산이 발생하고, 그로 인해 성능 저하가 초래될 수 있다는 점을 다시 한번 깨달았다.
useCallback
은 useMemo
와 비슷하지만, 주로 함수를 메모이제이션할 때 사용된다. 이 훅은 의존성 배열이 변경되지 않는 한, 동일한 함수 인스턴스를 반환하게 해준다.
처음에는 useMemo
처럼 단순히 결과값을 메모이제이션하는 것처럼 느껴졌지만, useCallback
이 주로 함수에 적용되는 이유는 함수 인스턴스를 메모이제이션하는 것이 중요하기 때문이다.
특히 컴포넌트에 props
로 전달되는 함수는 변경되지 않더라도, 렌더링될 때마다 새로운 함수 인스턴스가 생성되면 memo
로 감싼 자식 컴포넌트들이 불필요하게 리렌더링될 수 있다. 이 문제를 해결하기 위해 useCallback
을 사용해 함수를 메모이제이션함으로써, 리렌더링을 최적화할 수 있다.
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { DependencyList } from "react";
import { useMemo } from "./useMemo";
export function useCallback<T extends Function>(
factory: T,
_deps: DependencyList
) {
const fn = useMemo(() => factory, _deps);
return fn as T;
}
useMemo
와 useCallback
의 가장 큰 차이점은 의존성 배열을 어떻게 다루느냐에 따라 성능 최적화의 효과가 달라진다는 점이다. 함수 인스턴스와 계산된 값이 언제 변경되는지 정확히 판단하는 것은 불필요한 리렌더링을 방지하는 데 매우 중요하다.
memo
는 리액트에서 동일한 props
가 들어올 경우 컴포넌트의 리렌더링을 건너뛰게 해주는 고차 컴포넌트(HOC)
다.
이번 과제에서는 이를 직접 구현하면서 HOC
의 작동 방식과 내부 흐름을 더 구체적으로 이해할 수 있었다. 핵심은 이전 props와 현재 props를 비교해 같을 경우 컴포넌트를 다시 호출하지 않고 이전 결과를 그대로 반환하는 구조였다.
이를 구현하기 위해 useRef
를 활용해 이전 props
와 컴포넌트 실행 결과를 저장하고, 매 렌더링마다 shallowEquals
로 비교한 뒤 필요 시에만 컴포넌트를 다시 실행하도록 했다. 이렇게 함으로써 불필요한 리렌더링을 막고 성능을 최적화할 수 있었다.
import { shallowEquals } from "../equalities";
import { ComponentType } from "react";
import { useRef } from "../hooks";
import React from "react";
export function memo<P extends object>(
Component: ComponentType<P>,
_equals = shallowEquals
) {
return function MemoizedComponent(props: P): JSX.Element {
const prevPropsRef = useRef<P>(null);
const prevResultRef = useRef<JSX.Element>(null);
if (
prevPropsRef.current !== undefined &&
_equals(prevPropsRef.current, props)
) {
return prevResultRef.current!;
}
const result = React.createElement(Component, props);
prevPropsRef.current = props;
prevResultRef.current = result;
return result;
};
}
처음에는 하나의 AppContext
에서 모든 상태를 관리하고 있었지만, 실제 컴포넌트들이 필요로 하는 데이터가 각기 다르기 때문에 기능별로 Context
를 분리하는 것이 더 나은 설계라는 판단을 내렸다.
이에 따라 Theme
, User
, Notification
각각의 상태와 액션을 독립된 Context
로 분리하고, 필요한 범위에서만 Provider
를 적용하는 방식으로 구조를 재설계했다.
또한 Context
내부의 값을 state
와 actions
로 나누어 관리함으로써, 실제 컴포넌트에서 필요한 데이터만 구독하게 해 불필요한 리렌더링을 줄이는 데 집중했다.
예를 들어, 테마 정보를 사용하는 컴포넌트는 테마 값만 구독하고, 토글 함수를 사용하는 쪽에서는 액션만 가져가도록 설계했다.
const ThemeStateContext = createContext<"light" | "dark">("light");
const ThemeActionContext = createContext<() => void>(() => {});
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggleTheme = useCallback(() => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
}, []);
return (
<ThemeStateContext.Provider value={theme}>
<ThemeActionContext.Provider value={toggleTheme}>
{children}
</ThemeActionContext.Provider>
</ThemeStateContext.Provider>
);
};
// src/components/Header/ThemeToggle.tsx
export const ThemeToggle = memo(() => {
const theme = useThemeState();
const toggleTheme = useThemeAction();
return (
<Button onClick={toggleTheme} className="mr-2">
{theme === "light" ? "다크 모드" : "라이트 모드"}
</Button>
);
});
// src/components/layout/MainLayout.tsx
export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
const theme = useThemeState();
return (
<div
className={`min-h-screen ${theme === "light" ? "bg-gray-100" : "bg-gray-900 text-white"}`}
>
...
</div>
);
};
이렇게 관심사에 따라 Context
를 분리하고 역할 기반으로 Provider
를 구성하면서, 코드의 응집도를 높이고 컴포넌트 간 결합도를 줄일 수 있었다.
구조는 최종적으로 Theme
, User
, Notification
각각의 Provider
를 최상단에서 한 번에 감싸는 구조를 선택했다. 이렇게 하면 각 컴포넌트들이 필요한 Context
에 자유롭게 접근할 수 있고, Provider
중첩 구조를 신경쓰지 않아도 된다.
const App: React.FC = () => {
return (
<ThemeProvider>
<NotificationProvider>
<UserProvider>
<HomePage />
</UserProvider>
</NotificationProvider>
</ThemeProvider>
);
};
물론 일부 Context
만 필요한 컴포넌트가 존재할 때는 하위 범위에서 Provider
를 감싸는 방식이 더 적절할 수도 있겠지만, 현재 과제의 구조와 요구사항을 고려했을 때는 최상단에서 한 번에 감싸는 것이 가장 단순하고 효율적인 방법이라고 판단해 적용했다.
ComplexForm
은 여러 입력 필드를 포함한 폼 컴포넌트로, 처음에는 useState
를 사용해 전체 폼 데이터를 관리했다. 하지만 이 방식에는 문제가 있었다. 각 입력 필드가 변경될 때마다 전체 폼이 리렌더링되면서 하위 컴포넌트인 InputField
까지 모두 리렌더링되는 비효율적인 구조가 발생했다. 이런 구조에서는 불필요한 리렌더링을 방지하기 어려운 상황이 발생했다.
이 문제를 해결하기 위해 useReducer
를 도입했다. useReducer
는 상태 업데이트를 명확하게 처리하고, 액션을 통해 상태 변경을 분리하는 장점이 있다. 이를 통해 상태 변경이 각 입력 필드에 독립적으로 이루어져 불필요한 리렌더링을 줄일 수 있을 것이라고 생각했다.
// formReducer.ts
export function formReducer(state: FormState, action: Action): FormState {
switch (action.type) {
case "UPDATE_FIELD":
return {
...state,
[action.name]:
action.name === "age" ? Number(action.value) || 0 : action.value,
};
case "TOGGLE_PREFERENCE":
return {
...state,
preferences: state.preferences.includes(action.preference)
? state.preferences.filter((p) => p !== action.preference)
: [...state.preferences, action.preference],
};
default:
return state;
}
}
하지만 formData
객체 전체가 갱신되는 구조에서는 여전히 상위 컴포넌트인 ComplexForm
이 리렌더링되고, 하위 컴포넌트들도 리렌더링되는 문제가 해결되지 않았다. 결국 상태 관리 방법에 있어 더 깊은 고민이 필요함을 알게 되었다.
비제어 컴포넌트(uncontrolled input
)로 전환하여 리렌더링 최적화를 시도하기도 했다. 비제어 컴포넌트는 값이 변경될 때마다 리렌더링하지 않기 때문에 최적화에 유리할 수 있지만, 과제의 테스트 조건을 만족시키지 못했다. 테스트에서는 "폼 입력 시 ComplexForm
만 리렌더링되어야 한다"는 조건이 있었고, 비제어 컴포넌트는 ComplexForm
이 리렌더링되지 않아 이 조건을 충족할 수 없었다.
결국 이 구조에서는 formData
가 하나의 객체로 묶여 있는 한, 입력 하나가 바뀔 때마다 전체 폼이 리렌더링되는 문제를 완전히 피할 수 없었다. useReducer
는 상태 관리의 명확성을 높여주었지만, 테스트 조건을 충족시키는 최적화를 이루지는 못했다.
이는 단순한 훅 변경이나 memo
적용만으로 해결될 수 있는 문제가 아니라, 컴포넌트 간 상태 구조와 렌더링 책임을 처음부터 어떻게 설계하느냐에 대한 문제라는 점을 다시금 깨닫게 해줬다.
렌더링 최적화는 과제 내내 중요한 고민거리였고, memo
, useMemo
, useCallback
과 같은 도구들을 활용하여 불필요한 렌더링을 방지하고 성능을 개선하는 데 집중했다.
처음에는 1000개의 아이템을 기준으로 최적화를 적용했지만, 아이템의 수가 늘어나면서 성능에 한계가 보이기 시작했다. 그래서 아이템 수를 10000개로 늘려가며 실제로 성능 차이를 분석하고, 최적화가 얼마나 효과적인지 확인했다.
React Profiler
를 활용해 렌더링 성능을 분석한 결과, 아이템의 수가 많아질수록 렌더링 시간이 급격히 증가하는 문제를 확인할 수 있었다. 이를 해결하기 위해 filter === ''
일 때 연산을 하지 않는 방법을 시도했지만, 이 방법은 근본적인 성능 향상에는 한계가 있었다.
filter 적용 전
filter 적용 후
성능을 개선하기 위한 더 효과적인 방법으로 virtual list
방식을 적용해보기로 했다. virtual list
는 화면에 보이는 아이템만 렌더링하여 성능을 크게 향상시킬 수 있었고, 몇 만개씩 아이템을 렌더링해도 렌더링 시간이 현저히 단축되었다.
Virtual List 적용 전 10000개 렌더링 시간 (기존 로직)
Virtual List 적용 후 렌더링 시간
- 10000개씩 총 5번을 추가해도 각 렌더링 시간은 매우 적게 소요
- 실제로 렌더링되는 것이 아니기 때문에 최초 2~30개의 리스트만 렌더링하는데 시간 소요
// VisibleItemList.tsx
import { memo } from "../../hocs";
import { Item } from "../../types";
import { ItemRow } from "./ItemRow";
interface Props {
getItem: (index: number) => Item;
itemCount: number;
theme: "light" | "dark";
startIndex: number;
endIndex: number;
itemHeight: number;
}
export const VisibleItemList: React.FC<Props> = memo(
({ getItem, itemCount, theme, startIndex, endIndex, itemHeight }) => {
const visibleCount = endIndex - startIndex;
return (
<>
{Array.from({ length: visibleCount }).map((_, i) => {
const actualIndex = startIndex + i;
const item = getItem(actualIndex);
if (!item) return null;
return (
<div
key={actualIndex}
style={{
position: "absolute",
top: actualIndex * itemHeight,
left: 0,
right: 0,
height: itemHeight,
}}
>
<ItemRow item={item} theme={theme} />
</div>
);
})}
</>
);
}
);
// ItemList.tsx
const ITEM_HEIGHT = 40;
export const ItemList: React.FC<{
items: Item[];
onAddItemsClick: () => void;
}> = memo(({ items, onAddItemsClick }) => {
...
const listRef = useRef<HTMLDivElement>(null);
const { startIndex, endIndex } = useVirtualScroll({
containerRef: listRef,
itemHeight: ITEM_HEIGHT,
itemCount: filteredItems.length,
});
return (
<div className="mt-8">
...
<div
ref={listRef}
style={{
position: "relative",
height: filteredItems.length * ITEM_HEIGHT,
}}
>
<VisibleItemList
getItem={(index) => filteredItems[index]}
itemCount={filteredItems.length}
theme={theme}
startIndex={startIndex}
endIndex={endIndex}
itemHeight={ITEM_HEIGHT}
/>
</div>
</div>
);
});
// 미사용 코드
import { useEffect, useState } from "react";
interface UseVirtualScrollProps {
containerRef: React.RefObject<HTMLElement>;
itemHeight: number;
itemCount: number;
overscan?: number;
}
export function useVirtualScroll({
containerRef,
itemHeight,
itemCount,
overscan = 10,
}: UseVirtualScrollProps) {
const [startIndex, setStartIndex] = useState(0);
const [endIndex, setEndIndex] = useState(20);
const requestIdleCallback =
window.requestIdleCallback ||
function (cb: () => void) {
return setTimeout(cb, 1);
};
const cancelIdleCallback =
window.cancelIdleCallback ||
function (id: number) {
clearTimeout(id);
};
useEffect(() => {
const onScroll = () => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const offsetTop = rect.top + window.scrollY;
const scrollTop = window.scrollY;
const viewportHeight = window.innerHeight;
const visibleStart = Math.floor((scrollTop - offsetTop) / itemHeight);
const visibleEnd = Math.ceil(
(scrollTop - offsetTop + viewportHeight) / itemHeight
);
setStartIndex(Math.max(0, visibleStart - overscan));
setEndIndex(Math.min(itemCount, visibleEnd + overscan));
};
const idleId = requestIdleCallback(() => {
onScroll();
});
window.addEventListener("scroll", onScroll);
window.addEventListener("resize", onScroll);
return () => {
cancelIdleCallback(idleId);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, [containerRef, itemHeight, itemCount, overscan]);
const visibleItems = Array(endIndex - startIndex).fill(null);
return {
startIndex,
endIndex,
visibleItems,
};
}
하지만 virtual list
는 테스트 요구 사항을 충족시키지 못해 최종적으로 적용하지 못했다. 테스트에서는 ItemList
가 "단 한 번만 렌더링"되어야 한다는 조건이 있었고, virtual list
에서 보여야 할 아이템 개수가 브라우저 크기에 따라 동적으로 변하는 과정에서 ItemList
가 재렌더링되는 문제가 발생했다. 이로 인해 성능 최적화를 위해 적용한 virtual list
방식이 테스트 조건과 충돌하여 구현하지 못한 점이 아쉬웠다.
이번 과정을 통해 성능 최적화를 위한 여러 접근 방식의 효과를 직접 체감할 수 있었고, 실제 프로젝트에서 발생할 수 있는 성능 문제를 어떻게 해결할지에 대해 고민할 기회가 되었다.
이번 과제를 통해 리액트 성능 최적화와 구조 설계에 대한 깊은 이해를 얻을 수 있었다. useMemo
, useCallback
, memo
등 다양한 도구들을 직접 구현하고 적용하며 컴포넌트 리렌더링을 제어하고 최적화하는 방식에 대한 감각이 생겼다. 또한, virtual list
를 도입해 대규모 렌더링을 최적화하는 과정에서 성능 향상의 효과를 경험할 수 있었다.
특히 Context
분리와 Provider
설계는 리액트 앱을 효율적으로 관리하기 위한 중요한 기초라는 걸 깨달았다. 최적화와 상태 관리 방식에 대한 고민은 과제를 진행하면서 끊임없이 이어졌고, 결국 이런 고민들이 실제로 더 큰 규모의 프로젝트에서 어떻게 적용될 수 있을지를 구체적으로 생각해볼 수 있는 기회가 됐다.
비록 virtual list
와 같은 일부 최적화 방법은 테스트 조건을 충족하지 못해 프로젝트에 직접 적용하지는 못했지만, 성능 최적화의 중요성과 다양한 방법들을 직접 적용해보면서 많은 교훈을 얻었다. 앞으로는 이런 최적화 기법들이 실제 프로젝트에서 어떻게 더 유연하게 적용될 수 있을지, 그리고 테스트와 실제 성능 개선을 어떻게 일치시킬 수 있을지 고민해볼 필요가 있다.
이번 과제를 통해 성능 최적화와 리액트의 구조적 설계에 대한 중요한 교훈을 얻었고, 앞으로 더 큰 프로젝트에서 이러한 경험을 어떻게 적용할 수 있을지 고민해볼 기회가 되었다.
BP를 받았습니다...!
역시 멋있는 사람