
리액트의 리렌더링을 줄이는 방법을 알고 싶으신가요?
리액트 성능 최적화 경험, 다들 해보신 적 있으신가요? 개발을 하다 보면 성능 문제로 골머리를 앓은 경험이 한 번쯤은 있을 겁니다. 특히 상태 관리를 할 때 말이죠.
간단한 예시를 한번 볼까요? 사용자 정보를 전역 상태로 관리하는 상황입니다.
// Context로 전역 상태 관리
const UserContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState({
name: "김개발",
age: 25,
foodPreference: "부먹",
theme: "dark"
});
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
// 이름만 사용하는 컴포넌트
function UserName() {
const { user } = useContext(UserContext);
console.log("UserName 리렌더링!");
return <div>{user.name}</div>;
}
// 테마만 사용하는 컴포넌트
function ThemeButton() {
const { user } = useContext(UserContext);
console.log("ThemeButton 리렌더링!");
return <button>{user.theme} 모드</button>;
}
// 음식 취향만 바꾸는 컴포넌트
function FoodPreference() {
const { user, setUser } = useContext(UserContext);
const toggleFood = () => {
setUser(prev => ({
...prev,
foodPreference: prev.foodPreference === "부먹" ? "찍먹" : "부먹"
}));
};
return <button onClick={toggleFood}>{user.foodPreference}</button>;
}
여기서 문제가 뭘까요? `FoodPreference` 컴포넌트에서 음식 취향만 바꿔도, `UserName`과 `ThemeButton` 컴포넌트까지 모두 리렌더링 됩니다. 분명히 이들은 `foodPreference`와 아무 관련이 없는데도 말이죠.
"그럼 Context를 여러 개로 나누면 되지 않나?" 생각할 수 있지만, 현실은 그렇게 단순하지 않습니다.
// 이렇게 나누면?
const UserNameContext = createContext();
const UserThemeContext = createContext();
const UserFoodContext = createContext();
const UserAgeContext = createContext();
setState의 개수만 늘어날뿐더러, 의미론적으로 하나여야 하는 사용자 정보를 억지로 쪼개야 합니다. 더군다나 사용자 정보는 보통 API 응답으로 하나의 객체로 받아오는데, 이걸 일일이 분리해서 관리하는 것도 번거롭죠. 이보다 더 큰 문제는 현실적인 개발 환경에 있습니다. 컴포넌트는 생각보다 잘게 쪼개지고, 기획은 수시로 바뀝니다. 처음에는 특정 컴포넌트에서만 사용하려던 데이터가 갑자기 다른 곳에서도 필요해지죠.
"사용자 나이도 헤더에 표시해 주세요.", "프로필 이미지도 사이드바에 넣어주세요.", "음식 취향에 따라 추천 메뉴를 다르게 보여주세요."... 이런 요구사항들이 쏟아집니다. 그렇다고 API를 매번 호출하기에는 부담스럽고, 한 번 호출해서 여러 곳에 쓰고 싶은데... 결국 글로벌 스테이트를 도입하게 됩니다. 그리고 다시 리렌더링 지옥에 빠지게 되죠. 리렌더링이 많아질수록 애플리케이션은 느려집니다. 특히 모바일 환경에서는 더욱 체감되죠. 사용자는 버벅거리는 앱을 보며 "이 앱 왜 이렇게 느려?"라고 생각할 겁니다.
React DevTools의 Profiler를 켜보면, 상상 이상으로 많은 컴포넌트들이 불필요하게 리렌더링되고 있는 걸 확인할 수 있습니다. `useMemo`, `useCallback`, `React.memo`를 써서 최적화를 시도할 수 있지만, 근본적인 해결책은 아니죠. 그렇다면 정말로 사용하지 않는 프로퍼티를 바꿔도 리렌더링이 일어나지 않는 방법이 있을까요?
놀랍게도 있습니다. 바로 ‘FGR(Fine Grained Reactivity)’입니다.
리액트에서 리렌더링을 줄이는 방법은 다양합니다. useMemo, useCallback, React.memo 같은 최적화 기법들을 써보셨을 텐데요. 이러한 방법들은 리액트가 이전값과 현재 값을 비교해서 리랜더링을 결정합니다.
또한 프론트엔드 개발을 위해 다양한 상태 관리 라이브러리를 써보셨을 텐데요. Redux, Zustand, Jotai, Valtio 정말 많죠? 이런 라이브러리들도 ‘selector’라는 개념을 제공합니다. 예를 들어, Zustand에서는 이렇게 쓰죠.
const name = useStore(state => state.user.name);
const age = useStore(state => state.user.age);
언뜻 보면 user.name만 바뀔 때 해당 컴포넌트만 리렌더링 될 것 같습니다. 실제로도 그렇게 동작하고요. 그러나 내부적으로 변경 알림은 모두 발생하고, 단지 항상 값을 비교해서 이전값과 다른지 확인하는 작업을 거칠 뿐입니다.
useStore 내부 코드를 보면 이런 식으로 동작합니다.
// Zustand의 useStore 내부 (단순화)
function useStore(selector) {
const [, forceUpdate] = useReducer(c => c + 1, 0);
const prevValueRef = useRef();
useEffect(() => {
const unsubscribe = store.subscribe((state) => {
const newValue = selector(state);
const prevValue = prevValueRef.current;
// 이전 값과 현재 값 비교
if (!isEqual(newValue, prevValue)) {
prevValueRef.current = newValue;
}
});
}, [selector]);
prevValueRef.current = currentValue;
}
"이게 결국 리렌더링 안 하니까 괜찮은 거 아닌가요?" 맞습니다. selector를 사용하면 실제로 값이 같을 때는 리렌더링이 일어나지 않죠. 하지만 리렌더링을 안 하게 하려고 값 비교를 하면, 리액트의 불변성 원칙에 따라 객체의 일부만 변경해도, 전체 객체를 새로 만들어야 합니다. 그래서 user.name 하나만 바뀌어도 user 객체 전체가 새로 생성되죠.
이렇게 변경되면 해당 상태를 쓰는 곳은 일단 모두 리렌더링 대상이 됩니다. 그런 다음에 selector로 이전값과 현재 값을 비교하는 겁니다. "이전 name과 현재 name이 같나?" 하고 말이죠. 값이 같으면 리렌더링을 하지 않고, 값이 다르면 리렌더링 함수를 호출합니다.
그런데 이 값 비교를 누가 해주는 건가요? 결국 컴퓨터가 매번 계산해야 하는 일입니다. 컴포넌트가 많아질수록, 상태가 복잡해질수록 이런 비교 작업도 늘어나죠.
애초에 변경하는 순간에 "어디가 변경되었는지"를 핀포인트로 알 수 있다면 어떨까요? user.name을 바꾸는 순간에 "아, name이 바뀌었네. 그럼 name을 사용하는 컴포넌트들만 업데이트하자"라고 즉시 판단할 수 있다면 말이죠. 값 비교도 필요 없고, 불필요한 연산도 없고, 정말 필요한 곳만 정확히 업데이트할 수 있을 겁니다.
이런 접근 방식을 Fine Grained Reactivity, 줄여서 FGR이라고 합니다. 기존 방식이 "변경 감지 → 전체 알림 → 개별 필터링"이라면, FGR은 처음부터 특정 값을 사용하는 곳만 정확히 타겟팅합니다.
각각의 상태 관리 라이브러리들이 나름의 철학과 장점이 있지만, 리액트의 근본적인 리렌더링 방식을 바꾸지는 못합니다. 그런데 이런 FGR 개념을 정말 잘 반영한 라이브러리가 하나 있습니다.
‘Legend State’라는 라이브러리인데요. 진짜 이름이 "Legend"입니다. Expo에서 공식으로 밀어주고 있는 라이브러리로, React Native 개발을 위한 플랫폼인데 많은 사람들이 사용합니다. 또한 Legend State는 FGR의 개념을 잘 반영한 글로벌 상태 라이브러리인데요. 리액트의 리렌더링을 압도적으로 줄여주고, 성능이 가장 빠르다고 합니다.
그래서 ‘Legend State’ 코드를 중심으로 FGR의 구현 원리를 알아보겠습니다.
Legend State의 코드를 뜯어보면서 FGR이 어떻게 구현되는지 알아보겠습니다. 물론 실제 코드는 훨씬 복잡하지만, 핵심 개념을 이해할 수 있도록 유형화하고 단순화해서 설명해 드릴게요.
우선 FGR이 얼마나 신기한지 감을 잡아봅시다.
// Legend State 사용법
import { observable } from '@legendapp/state';
const user = observable({
name: "김개발",
foodPreference: "부먹"
});
// 이렇게만 써도
effect(() => {
console.log(`${user.name.get()}님은 ${user.foodPreference.get()} 파`);
});
// 나중에 이것만 호출하면
user.foodPreference.set("찍먹");
// 위의 effect가 자동으로 다시 실행됩니다!
// 별도 의존성 배열도, 구독 설정도 필요 없어요
어떻게 이게 가능할까요?
FGR 객체는 대개 이런 내부 구조를 가지고 있습니다.
const observableValue = {
_value: "김개발",
_handlers: [], // 이벤트 핸들러들이 저장되는 배열
get() {
// get할 때: 현재 실행 중인 effect를 핸들러로 등록
if (currentTracker) {
this._handlers.push(currentTracker);
}
return this._value;
},
set(newValue) {
this._value = newValue;
// set할 때: 등록된 이벤트 핸들러들을 호출
this._handlers.forEach(handler }
};
get()을 호출하면 이벤트 핸들러가 등록되고, set()을 호출하면 그 핸들러들이 실행되는 구조입니다.
// 전역 변수: 현재 실행 중인 effect를 추적
let currentTracker = null;
function effect(callback) {
// 1. 현재 실행 중인 effect로 설정
currentTracker = callback;
// 2. 콜백 실행 (이때 get()들이 호출되면서 자동 등록됨)
callback();
// 3. 추적 완료
currentTracker = null;
}
effect는 전역 추적 상태를 설정한 다음 콜백을 실행합니다. 콜백 실행 중에 get()이 호출되면 자동으로 이벤트 핸들러로 등록되는 거죠.
// 1. effect 실행
effect(() => {
console.log(user.name.get()); // name에 이 effect가 핸들러로 등록됨
console.log(user.age.get()); // age에 이 effect가 핸들러로 등록됨
// foodPreference는 사용하지 않음 → 등록되지 않음
});
// 2. 나중에 값 변경
user.name.set("박개발"); // effect 실행됨
user.age.set(30); // effect 실행됨
user.foodPreference.set("찍먹"); // effect 실행되지 않음!
이것이 바로 Fine Grained Reactivity의 핵심입니다. set으로 어떤 값을 할당했느냐에 따라 정확히 타겟팅하기 때문에 불필요한 원본값 비교가 없죠.
FGR의 원리를 이해했으니, 이제 리액트에서는 어떻게 사용하는지 알아봅시다. Legend State에서는 use$라는 훅을 제공해서 FGR을 리액트와 연결합니다.
import { observable, use$ } from '@legendapp/state';
const user = observable({
name: "김개발",
age: 25,
foodPreference: "부먹"
});
function UserProfile() {
const name = use$(() => user.name.get());
const age = use$(() => user.age.get());
return <div>{name} ({age}세)</div>;
}
}
이제 user.name이나 user.age가 바뀌면 UserProfile 컴포넌트만 리렌더링되고, user.foodPreference가 바뀌면 FoodPreference 컴포넌트만 리렌더링됩니다.
use$ 훅이 어떻게 동작하는지 간단히 구현해 봅시다.
function use$(callback) {
const [, forceUpdate] = useReducer(x => x + 1, 0);
useEffect(() => {
// effect 내에서 forceUpdate도 함께 콜백에 포함
effect(() => {
callback();
forceUpdate();
});
}, [callback]);
return callback();
}
use$는 useEffect 내에서 effect를 호출하고, 콜백과 함께 forceUpdate도 넣어서 값이 변경되면 컴포넌트가 리렌더링되도록 합니다. (실제는 리액트의 useSyncExternalStore로 구현합니다.)
기존 Context API 방식:
// 전체 user 객체가 바뀌면 모든 컴포넌트가 리렌더링
const { user } = useContext(UserContext);
FGR 방식:
// 사용하는 값만 바뀌면 해당 컴포넌트만 리렌더링
const name = use$(() => user.name.get());
const age = use$(() => user.age.get());
이런 FGR 개념을 더욱 극단적으로 밀고 나간 프레임워크들도 있습니다. 가상돔을 아예 사용하지 않고 컴파일 타임에 최적화하는 방식이죠.
예를 들어,
<div>{user.name}</div>
컴파일 타임에 이렇게 변환됩니다.
// 컴파일된 결과 (단순화)
effect(() => {
element.textContent = user.name.get();
});
Svelte에서는 이런 코드가 가상돔 비교 과정 없이도, 필요한 부분만 직접 업데이트할 수 있는 거죠. 이것이 FGR의 궁극적인 형태라고 할 수 있습니다.
지금까지 Fine Grained Reactivity가 무엇인지, 그리고 Legend State를 통해 어떻게 구현되는지 살펴봤습니다. 저희 앱에서도 Legend State를 사용하고 있지만, 도입하기 전에 반드시 고려해야 할 점들이 있습니다.
FGR의 효과를 제대로 보려면 사용자가 직접 많은 것들을 판단해야 합니다. 어느 부분까지 반응형 객체로 만들지, 큰 객체 변환 비용을 줄이기 위해 어디서 선을 그을지 결정하는 것이 생각보다 까다로운데요.
특히 리액트에서 FGR을 제대로 활용하려면 특정 값만 선택적으로 사용해야 합니다.
// 이렇게 하면 의미 없음 (전체 객체 사용)
const user = use$(() => userState.get());
// 이렇게 해야 효과 있음 (특정 값만 선택)
const name = use$(() => userState.name.get());
const age = use$(() => userState.age.get());
결국 기존 selector 패턴과 비슷한 보일러플레이트가 발생하게 됩니다. 모든 값에 대해 개별적으로 사용해야 하니까요.
가장 위험한 건 원리를 제대로 이해하지 않고 사용하는 경우입니다. 전체 객체를 use$로 사용하려고 하면, 모든 값이 변할 때마다 리렌더링되어서 사실상 리렌더링을 줄이는 효과가 없습니다. 무거운 반응형 객체만 추가로 생성될 뿐입니다. 또한 큰 객체를 observable로 변환하는 비용도 만만치 않습니다. 프록시 객체 자체의 런타임 오버헤드도 있고, 깊은 중첩 객체일 경우 초기 변환 시간이 상당할 수 있어요.
Legend State의 경우 리액트의 내부 동작에 관여하지 않아 좀 더 안정적이지만, 리렌더링을 줄이는 데 한계가 있습니다. 결국 리액트의 라이브러리이기 때문이죠. 한편, 리액트의 내부나 컴파일러 등을 간섭해서 줄이는 방식은 좀 더 급진적으로 줄일 수 있으나, 리액트의 버전이 올라갈 때마다 버그가 생길 확률이 높고요. 그래서 SolidJS 같은 독자적인 프레임워크가 나오기도 하죠. FGR은 제작자 입장에서 까다로운 방식일 겁니다.
이러한 한계에도 불구하고 FGR은 분명히 매력적인 접근 방식입니다. 다만 도입하기 전에 팀의 기술 수준, 프로젝트 규모, 그리고 실제로 성능 개선이 필요한 상황인지를 신중하게 판단해 보세요. 무작정 도입하기보다는 Legend State의 코드를 직접 읽어보면서 원리를 이해하고, 작은 부분부터 점진적으로 적용해 보는 것을 추천합니다.
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.