요즘IT
위시켓
새로 나온
인기요즘 작가들컬렉션
물어봐
새로 나온
인기
요즘 작가들
컬렉션
물어봐
개발
AI
IT서비스
기획
디자인
비즈니스
프로덕트
커리어
트렌드
스타트업
서비스 전체보기
위시켓요즘IT
고객 문의
02-6925-4867
10:00-18:00주말·공휴일 제외
[email protected]
요즘IT
요즘IT 소개작가 지원
기타 문의
콘텐츠 제안하기광고 상품 보기
요즘IT 슬랙봇크롬 확장 프로그램
이용약관
개인정보 처리방침
청소년보호정책
㈜위시켓
대표이사 : 박우범
서울특별시 강남구 테헤란로 211 3층 ㈜위시켓
사업자등록번호 : 209-81-57303
통신판매업신고 : 제2018-서울강남-02337 호
직업정보제공사업 신고번호 : J1200020180019
제호 : 요즘IT
발행인 : 박우범
편집인 : 노희선
청소년보호책임자 : 박우범
인터넷신문등록번호 : 서울,아54129
등록일 : 2022년 01월 23일
발행일 : 2021년 01월 10일
© 2013 Wishket Corp.
로그인
요즘IT 소개
콘텐츠 제안하기
광고 상품 보기
개발

리액트 리렌더링 문제, FGR(Fine Grained Reactivity) 로 해결하기

스벨트전도사
13분
9시간 전
462
에디터가 직접 고른 실무 인사이트 매주 목요일에 만나요.
newsletter_profile0명 뉴스레터 구독 중

리액트의 리렌더링을 줄이는 방법을 알고 싶으신가요?

 

리액트 성능 최적화 경험, 다들 해보신 적 있으신가요? 개발을 하다 보면 성능 문제로 골머리를 앓은 경험이 한 번쯤은 있을 겁니다. 특히 상태 관리를 할 때 말이죠.

 

간단한 예시를 한번 볼까요? 사용자 정보를 전역 상태로 관리하는 상황입니다.

 

// 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)’입니다.

 

FGR을 소개합니다

리액트에서 리렌더링을 줄이는 방법은 다양합니다. 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 Open Source, 작가 편집>

 

그래서 ‘Legend State’ 코드를 중심으로 FGR의 구현 원리를 알아보겠습니다.

 

 

FGR의 핵심 동작 원리

Legend State의 코드를 뜯어보면서 FGR이 어떻게 구현되는지 알아보겠습니다. 물론 실제 코드는 훨씬 복잡하지만, 핵심 개념을 이해할 수 있도록 유형화하고 단순화해서 설명해 드릴게요.

 

FGR 감 잡기: set만 하면 effect가 알아서 실행된다

우선 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 객체의 내부 구조

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의 역할

// 전역 변수: 현재 실행 중인 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으로 어떤 값을 할당했느냐에 따라 정확히 타겟팅하기 때문에 불필요한 원본값 비교가 없죠.

 

 

리액트에서는 use$로 사용합니다

FGR의 원리를 이해했으니, 이제 리액트에서는 어떻게 사용하는지 알아봅시다. Legend State에서는 use$라는 훅을 제공해서 FGR을 리액트와 연결합니다.

 

use$ 훅 사용법

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$ 내부 구현

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의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.

forceUpdate();
// 리렌더링 강제 실행
return
unsubscribe;
const
currentValue = selector(store.getState());
return
currentValue;
=>
handler());
function
FoodPreference
(
)
{
const
preference = use$(() => user.foodPreference.get());
return
<
button
>
{preference} 파
</
button
>
;