frontend

immer, Redux Toolkit 성능 문제

하리하링웹 2024. 10. 11. 18:25

immer란

React에서 상태의 불변성은 성능에 큰 영향을 미친다. 상태가 불변하게 유지되면 React는 변경된 부분만 효율적으로 다시 렌더링할 수 있기 때문에 성능 최적화가 가능해진다. 하지만 불변성을 수동으로 관리하는 것은 번거로울 수 있는데, 이때 immer 라이브러리는 개발자가 불변성을 유지하는 데 신경을 많이 쓰지 않더라도 이를 쉽게 관리할 수 있도록 도와준다.

 

immer프록시(proxy)를 사용하여, 객체나 배열을 직접 수정하는 요청을 가로채어 새로운 객체에서 해당 요청을 작업한다. immer의 핵심 함수인 produce는 두 가지 인자를 받는데, 첫 번째는 원본 상태, 두 번째는 상태를 변경하는 콜백 함수이다. 이 함수 내에서 원본 객체는 수정할 수 있는 것처럼 보이지만, 실제로는 프록시 객체를 통해 변경 사항이 기록된다.

 

이후, immer변경된 부분만 복사하여 새로운 객체를 생성하고 반환한다. 즉, 원본 상태는 불변성을 유지하고, 새로운 객체가 생성됨으로써 React는 이 변경된 객체를 사용해 효율적으로 상태 변경을 처리한다. 이러한 방식은 개발자가 객체나 배열의 깊은 중첩 구조에서도 불변성을 쉽게 관리할 수 있게 해준다.

 

따라서 immer불변성을 보장하면서도 React의 상태 관리에서 발생할 수 있는 복잡한 작업을 간소화하고, 개발자가 상태 변경 로직에 집중할 수 있게 만들어준다.

 

produce 예시 코드

import produce from "immer";

const state = {
  name: "John",
  age: 30,
  address: {
    city: "New York",
    zip: "10001",
  },
};

// immer의 produce를 사용해 불변 객체 업데이트
const newState = produce(state, (draft) => {
  draft.age = 31; // age 값 변경
  draft.address.city = "San Francisco"; // address 내부 값 변경
});

console.log(state); // 원래 객체는 그대로 유지됨
console.log(newState); // 변경된 객체가 새롭게 반환됨

immer의 문제

여기까지만 보면 immer 가 굉장히 좋은 라이브러리처럼 보이지만 여기에는 함정이 숨어있다.

바로 성능적인 문제이다. immer는 내부적으로 프록시를 생성하고 변경 사항을 추적하는데, 이 과정에서 큰 객체깊이 중첩된 구조의 상태를 다룰 때 성능 저하가 발생할 수 있다. 객체의 모든 프로퍼티를 프록시로 감싸고, 변경된 부분을 찾아내 복사하는 작업은 단순한 상태 변경보다 더 많은 메모리와 CPU 리소스를 소모한다. 특히 빈번한 상태 변경이 일어나는 애플리케이션에서는 이러한 오버헤드가 누적되어 성능이 저하될 수 있다.

 

규모가 작은 프로젝트에서는 이러한 성능 문제를 잘 느끼지 못할 수 있지만, 규모가 큰 프로젝트에서는 특히 테이블처럼 객체가 선형적으로 커질 수밖에 없는 경우에 성능 문제가 발생할 가능성이 높아진다.

 

예를 들어, 현재 우리 회사의 경우, 개발자가 페이지 설정 정보를 서버에 전송하고, 서버는 그 설정에 맞는 결과 데이터를 반환하여 하나의 큰 상태로 관리하는 방식을 채택하고 있다. 이처럼 대규모 상태를 한 번에 관리하는 상황에서는 immer의 성능적인 문제가 두드러지게 나타난다. 변경이 많아질수록 불변성을 유지하기 위해 더 많은 리소스를 사용하기 때문이다.

또한, GraphQL 서버를 사용하는 경우에도 비슷한 문제가 발생할 수 있다. GraphQL의 베스트 시나리오 중 하나로 상위 레벨에서 하나의 큰 데이터를 내려받고, 이를 애플리케이션에서 하나의 객체로 관리하는 방식이 있다. 그러나 이 데이터가 커지고, 여러 부분에서 빈번하게 변경이 발생하게 된다면, immer가 처리해야 할 프록시 생성과 불변성 유지 작업이 과도하게 늘어나게 되어 성능 저하를 겪게 될 가능성이 크다.

따라서, 규모가 커질수록 immer의 성능 문제를 미리 고려하는 것이 중요하다. 큰 상태복잡한 데이터 구조를 관리하는 상황에서 immer가 꼭 필요한지, 또는 더 가벼운 방식으로 상태 관리를 할 수 있는지를 신중하게 검토할 필요가 있다.

Redux Toolkit의 문제

Redux Toolkit은 Redux의 복잡한 설정과 보일러플레이트 코드를 줄이기 위해 만들어진 도구로, 기본적으로 immer를 내부적으로 사용한다. 즉, 개발자가 직접 객체의 불변성을 신경 쓰지 않고도 불변성을 유지할 수 있게 해주는 편리한 기능을 제공한다. createSlice, createReducer와 같은 함수가 그 예로, immer를 사용해 상태를 안전하게 변경할 수 있게 해준다.

(참고. https://redux-toolkit.js.org/usage/immer-reducers)

하지만 Redux Toolkit 역시 immer를 기반으로 하기 때문에, 상태가 매우 크거나 복잡해질 경우 앞서 언급한 성능 문제를 겪을 가능성이 있다. 특히 상태가 커질수록 상태를 관리하고 변경 사항을 감지하는 데 필요한 비용이 늘어나면서 성능 이슈가 발생할 수 있다. Redux Toolkit을 사용할 때도 마찬가지로, 큰 객체나 대규모 데이터를 관리하는 경우에는 신중한 접근이 필요하다

 

Performance 측정 결과 reducer 내의 immer 사용으로 인해 성능 저하가 측정된 이미지

 

실제로 테스트를 진행해보아도 제법 눈에 띄는 지연을 확인할 수 있다.

 

테스트 코드

import { produce } from "immer";

// 큰 배열과 중첩된 객체 생성
const largeObject = {
  users: Array.from({ length: 100000 }, (_, i) => ({
    id: i,
    name: `User_${i}`,
    address: {
      city: `City_${i}`,
      zip: `${100000 + i}`,
    },
  })),
};

// Immer를 이용한 상태 변경 성능 테스트
console.time("immer performance");

const updatedObjectImmer = produce(largeObject, (draft) => {
  // 배열의 중간 값을 변경
  draft.users[5000].name = "Updated_User_5000";
  draft.users[5000].address.city = "Updated_City";
});

console.timeEnd("immer performance");

// 직접 복사하여 상태 변경 성능 테스트
console.time("manual copy performance");

const updatedObjectManual = {
  ...largeObject,
  users: largeObject.users.map((user, index) =>
    index === 5000
      ? { ...user, name: "Updated_User_5000", address: { ...user.address, city: "Updated_City" } }
      : user
  ),
};

console.timeEnd("manual copy performance");

// 결과 비교
// console.log("Immer updated object:", updatedObjectImmer.users[5000]);
// console.log("Manual copy updated object:", updatedObjectManual.users[5000]);

 

테스트 결과

PS D:\\programming\\bun-test> node .\\immer.js
immer performance: 1.647s 
manual copy performance: 2.163ms 

 

Redux Toolkit의 이러한 성능 저하에 대해 검색을 해 본 결과 이미 관련하여 성능 저하를 겪고있다는 글, 이슈를 찾을 수 있었으며 Redux 팀에서도 이러한 문제를 인지하고 있는 것으로 보였다.

참고

문제 해결

현재 회사의 프로젝트에서는 실제로 이러한 성능저하를 겪고있고 문제를 인지하고 있기에 여러가지 해결책을 고려해보고 있다.

1. 상태 분리

큰 상태를 한 번에 관리하는 대신, 작은 상태 단위로 분리하여 변경할 수 있는 구조로 바꾸는 것,이로 인해 immer가 처리해야 할 상태의 크기가 줄어들어 성능이 향상될 수 있다.

→ 가장 먼저 든 생각이지만 현실적으로 프로젝트의 구조를 이런 방향으로 바꾸는 것은 불가능하다. 또한 구조적으로 만족스러운 구조가 나오지 않을것으로 예상되기에 채택하지 않기로 결정하였다.

2. 상태 변경 통합

현재 상태 변경 시 여러개의 Reducer가 호출 되는 경우도 존재하기에 이를 통합하여 한 번의 Reducer만 호출할 수 있는 로직을 개발, 이로 인해 immer 가 여러번 프록시를 설정하는 것을 방지할 수 있다.

→ 가장 도입 확률이 높은 방안이다. 기존 코드를 크게 바꿀 필요도 없으며 해당 로직을 잘 만들어 놓는다면 성능 문제가 발생하는 특정 부분에서만 해당 로직을 사용하여 성능을 개선할 수 있게 될 것이라고 예상하고 있다.

3. 타 라이브러리 사용

immer 를 내부적으로 사용하지 않는 다른 라이브러리 (Jotai, Zustand)를 사용하는 방안

→ 일단 해당 라이브러리는 정말로 불변성을 유지하면서 성능적인 문제를 잘 해결해 주는지를 찾아봐야하며, 변경 가능성에 대해서도 확인해봐야한다. 변경이 가능하더라도 이미 너무 많은 부분에서 Redux Toolkit에 의존하고 있기에 많은 사전준비가 필요하다.

결론

위의 테스트에서 보았듯이, 눈에 띄는 성능 문제를 확인하려면 일반적으로 사용하지 않을 정도로 큰 객체를 상태로 사용해야 한다. 따라서 대부분의 Redux Toolkit 사용 사례에서는 이러한 문제를 겪지 않을 것이다.

하지만 우리 회사의 경우, 프로젝트의 규모가 점점 커질 수 밖에 없는 구조에 프로젝트의 수명이 20년이 넘어가면서 이러한 성능 문제를 경험하게 되었다. 이렇게 규모가 큰 웹 프로젝트를 진행하는 회사는 한국에서도 그리 많지 않을 것이기에, 이러한 성능 문제를 경험해보는 것은 쉽지 않을 것이라고 생각한다. 따라서 일반적인 개발에서는 immer의 이러한 문제를 크게 고려하지 않아도 될 것이라고 예상하고 있다.

  • 물론 Redux의 복잡한 상태 관리 방법과 제한된 구조에 대해서는 Redux 도입과는 별개로 고려해봐야 한다고 생각한다. (대부분의 경우 JotaiZustand와 같은 라이브러리로도 충분하다.)