아하❓ (6) - Redux-Toolkit 왜 씀?!

김태완·2025년 3월 16일

프로젝트를 하면서 전역 상태 관리를 위해서 Redux-Toolkit을 써보았다.

다만, 전역 상태 관리를 위해 어떤 라이브러리를 사용할 것인가에 대해서 팀 내에서도 박빙이였다.
당시에는 Context API vs Redux 라는 주제를 가지고 의사 결정 과정이 진행되었다.

팀에서 Redux를 사용하기로 결정한 이유는
1. Context API는 설정이 간단하다.
2. Context API는 하위 컴포넌트까지 불필요한 리렌더링이 발생한다.
3. Redux를 사용해본 팀원들이 있었고, Redux DevTools을 사용하면 디버깅이 용이하다.

기한이 정해져있고 여유롭지 못한 상태에서 팀 프로젝트 결과물을 제시해야 했다고 해도,
빈약하기 짝이 없는 이유다 (민망);;

다시 돌아보고, 사건의 재구성을 해보면서 보충하는 시간이 필요하겠다.🤔

그렇다면 왜 Redux를 선택했는지부터 다시 구성해보자.
(아니 근데, 이전의 복잡했던 Redux 코드들이 공홈에서는 레거시로 분류되었어서 놀랬고, Redux Toolkit(RTK)가 생각보다 간단해서 놀랬다.)

🌱 Context API vs Redux Toolkit(RTK) 비교
RTK를 선택한 이유는 결국 리렌더링 최적화 때문이였다.

1️⃣ Context API를 고려했던 이유

  • 설정이 간단함 ✔️ → Redux처럼 store나 slice를 설정할 필요 없이 createContext()와 useContext()만 사용하면 된다.
  • 별도의 라이브러리 설치 없이 사용 가능 ✔️

2️⃣ Context API의 단점
그러나 Context API를 사용하기에는 그냥 지나칠 수 없는 단점이 있다.

1. 리렌더링 문제 ⁉️
Context API의 가장 큰 단점은 하위 컴포넌트까지 불필요한 리렌더링이 발생한다는 점이다.
예를 들어,
최상위 컴포넌트에서 UserContext를 통해 사용자 정보를 제공한다고 가정한다면,
UserContext.Provider 내부에 있는 모든 컴포넌트는 사용자 정보가 변경될 때마다 리렌더링 된다.
결론적으로는 일부 컴포넌트에서는 사용자 정보가 필요하지 않더라도 리렌더링이 발생하여 성능 저하가 생길 것이다.
(물론, Context를 여러 개로 분리해서 리렌더링을 최소화할 수 있지만, 관리가 어려워지겠지? -> 2번과 연결)
하지만!
Redux Toolkit을 사용하면 useSelector를 통해 필요한 상태만 선택적으로 구독할 수 있어 리렌더링을 방지할 수 있다.

2. 상태 관리의 복잡성 증가 ⁉️
Context API는 단순한 상태 공유에는 적합하지만, 여러개의 상태를 관리해야한다면, 코드가 복잡해진다.
예를 들어,
여러 개의 Context를 만들어야 하는 경우가 있을 것이다. (예: ThemeContext, UserContext, SettingsContext)
하지만!
Redux Toolkit을 사용하면 상태 관리와 업데이트 로직을 slice 단위로 깔끔하게 분리할 수 있다.

3️⃣ 결론
✅ Context API를 고려했으나 리렌더링 문제로 적합하지 않다고 판단.
✅ RTK를 활용한 useSelector를 활용하여 불필요한 리렌더링 방지.
✅ createSlice를 사용하여 다양한 상태의 효율적인 관리.

🌱 코드 다시보기

위 코드는 createSlice를 사용하여 내가 관리하고자 하는 상태와 그를 위한 액션을 정의해줬다.
(주석은 모두가 알기쉽도록 과하게 달았었다...)

그리고 스토어를 설정해주고, 아래와 같이 해당 상태가 필요한 컴포넌트에서 useSelector를 통해서 userInfo의 user를 구독하도록 했다.

만약 사용자 정보가 변경되는 요청을 실행할때, 곧바로 상태를 업데이트해서 동기화하려고 했다.

그런데 프로젝트할때는 바빠서 아무 생각 없이 했는데,
문득 이렇게 useSelector만 사용한다고 RTK가 알아서 리렌더링을 막아주고, 렌더링 성능을 최적화 해주는걸까?? 하는 의문이 들었다.

🌱 일단 useSelector의 동작 원리를 살펴보자.

1. 렌더링 될 때 selector 함수를 실행한다.
useSelector는 컴포넌트가 렌더링 되거나 action이 dispatch 될 때마다 selector 함수를 실행한다.

내가 작성한 코드에서 selector 함수는 state => state.userInfo.user 이다.
이 함수는 redux store의 state를 매개변수로 받아서 state를 변경한 후 반환한다.
useSelector는 selector 함수의 반환값을 반환한다.

-> useSelector는 선택자 함수라고 부르는 단일 함수를 인자로 받는다. 선택자는 전체 Redux 스토어 상태를 인자로 받아, 특정 값을 상태(state)에서 읽어 그 결과를 반환하는 함수다.

2. selector 함수의 반환값을 비교
action이 dispatch 되면 redux store의 state가 변경된다.
이때 useSelector는 이전 selector 함수의 반환값과 현재 selector 함수의 반환값을 비교한다.

3. 컴포넌트 리렌더링
비교 결과 두 값이 다르다면 컴포넌트를 리렌더링한다. 만약 두 값이 같다면 컴포넌트를 리렌더링하지 않는다.

🌱 그렇다면 어떻게 비교가 이루어지나?

-> useSelector는 참조 비교(===)를 사용하여 결과를 비교한다.
따라서 선택자의 결과가 새로운 참조일 때마다 컴포넌트가 다시 렌러딩된다.
이는 선택자에게 새로운 참조를 생성하여 반환할 경우, 실제 데이터에 변화가 없더라도 액션이 디스패치될 때마다 컴포넌트가 다시 렌더링될 수 있음을 의미한다.

⁉️ Redux는 reference equality checks(참조 동등성 검사)를 한다. 얕은 비교(shallow comparison)를 하는것은 아니지만 자바스크립트의 특징으로 인해 문제가 발생할 수 있다.

참고! JavaScript는 원시 타입의 값은 값 자체를 비교하기 때문에 실제로 값이 변경되었을 때만 다르다고 판단한다. 하지만, 객체는 참조 값(메모리 주소)을 비교하므로 내용이 같더라도 새로운 객체나 배열을 생성하면 다른 참조로 간주된다.

'그렇다면 내가 작성한 코드에서 user 객체 내부의 값 중 하나만 변경되어도 selector 함수가 실행되어서 state가 업데이트 될 것이고, ModifyProfile 컴포넌트가 렌더링이 될 것이다! 그러면 자식 컴포넌트들도 모두 한 번씩 더 렌더링 될 것이다.'
는 결론에 도달했다.

해결은 어떻게 해야할까??
가장 먼저 떠오르는 방법은 useSelector를 여러번 사용하는 방법이다.
코드는 길어지겠지만, 여러번 사용하면 각 selector 함수가 반환하는 값의 변경 여부를 정확히 판단할 수 있을 것이다.
✔️ user 객체를 통째로 가져오면 참조 변경이 발생할 때마다 리렌더링 된다.
✔️ 따라서 필요한 값만 개별적으로 useSelector를 사용하면 값이 바뀔 때만 해당 부분이 갱신된다.
✔️ 여러 개의 useSelector를 사용한다면 최적화를 노려볼 수 있겠다.

그리고 shallowEqual 사용하는 방법!(도 있다고 한다.)
useSelector의 두 번째 인자로 shallowEqual을 전달하면, 객체 내부의 각 속성 값을 얕은 비교(===)로 판단하여 리렌더링을 최소화할 수 있다. (다만 깊은 비교를 할 때는 적용되지 않는다는 단점?!)
✔️ user.nickname이 변경되지 않았다면 profileImageUrl만 바뀌어도 리렌더링 발생하지 않음.
✔️ user.profileImageUrl이 변경되지 않았다면 nickname만 바뀌어도 리렌더링 발생하지 않음.
-> 즉, 불필요한 리렌더링을 방지할 수 있다!

역시나 useMemo와 useCallback과 같은 Memoization을 적절하게 같이 활용하면서 리렌더링을 방지하는 방법도 고려하면 좋겠다.


profile
중고

0개의 댓글