- Published on
Redux Toolkit 렌더링 폭주 7분 진단 - selector·memo
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 네트워크 이슈가 아닌데도 화면이 버벅이고, DevTools에서 리렌더링이 폭주하는 경우가 있습니다. Redux Toolkit(RTK) 기반 앱에서 이런 증상은 대개 useSelector가 “매번 다른 값”을 반환하거나, selector가 너무 넓은 범위를 구독하거나, 컴포넌트 경계에서 memo/useMemo/useCallback이 참조 동일성을 깨뜨리는 방식으로 발생합니다.
이 글은 “7분 진단”을 목표로, 렌더링 폭주의 원인을 빠르게 좁히고, 가장 흔한 패턴을 selector와 memo 관점에서 정리합니다.
또한 성능 문제를 추적하는 방법론은 프론트엔드에만 국한되지 않습니다. 브라우저 Long Task를 추적하는 방식은 Chrome INP 점수 급락 - Long Task 5분 추적법과도 결이 같습니다. 그리고 Next.js 환경이라면 캐시/데이터 흐름 문제와 렌더링 문제가 섞여 보일 수 있어 Next.js 14 RSC 캐시 꼬임·stale 데이터 해결법도 함께 참고하면 원인 분리가 쉬워집니다.
0분: 증상 정의 — “폭주”가 진짜 리렌더인가
먼저 “리렌더 폭주”를 두 가지로 나눕니다.
- React 리렌더가 과도: 컴포넌트 함수가 너무 자주 호출됨(React DevTools Profiler에서 확인 가능)
- Redux 업데이트가 과도: store 업데이트가 너무 자주 발생함(Redux DevTools에서 action 빈도 확인)
둘 중 어느 쪽이든, RTK 앱에서는 대개 아래 흐름으로 연결됩니다.
- action이 자주 디스패치됨
- reducer가 state를 갱신함
useSelector가 구독 중인 값이 “바뀌었다고 판단”됨- 컴포넌트 리렌더
여기서 핵심은 “바뀌었다고 판단”의 기준이 기본적으로 참조 동일성(===) 이라는 점입니다.
1분: Redux DevTools로 action 폭부터 확인
Redux DevTools에서 다음을 봅니다.
- 동일 action이 초당 수십 번 찍히는가
- 타이핑/스크롤/마우스 이동 같은 UI 이벤트에 action이 붙어 있는가
- WebSocket/폴링/타이머가 action을 발사하는가
action이 지나치게 많다면 selector 최적화 전에 업데이트 빈도 자체를 줄이는 게 1순위입니다(디바운스, 스로틀, 배치, 서버 이벤트 통합 등). 하지만 action 빈도가 정상인데도 리렌더가 많은 경우, 다음 단계로 갑니다.
2분: useSelector가 “새 객체”를 만들고 있지 않은가
가장 흔한 실수는 selector에서 매번 새 객체/배열을 만들어 반환하는 것입니다.
나쁜 예: 매번 새 배열 반환
// 매 렌더마다 filter 결과가 새 배열 => 참조가 매번 달라짐
const visibleTodos = useSelector((state: RootState) =>
state.todos.items.filter((t) => !t.done)
)
이 selector는 store 업데이트가 일어나기만 하면(심지어 todos와 무관한 업데이트여도) filter가 실행되고, 새 배열이 만들어져 === 비교에서 항상 다르다고 판단될 수 있습니다.
빠른 처방 1: createSelector로 메모이제이션
RTK에는 Reselect가 기본 포함이므로 createSelector를 적극 사용합니다.
import { createSelector } from "@reduxjs/toolkit"
const selectTodos = (state: RootState) => state.todos.items
export const selectVisibleTodos = createSelector(
[selectTodos],
(items) => items.filter((t) => !t.done)
)
// 컴포넌트
const visibleTodos = useSelector(selectVisibleTodos)
이렇게 하면 state.todos.items 참조가 바뀌지 않는 한 filter 결과도 캐시되어 동일 참조를 재사용합니다.
빠른 처방 2: shallowEqual은 “임시 봉합”일 때만
useSelector에 shallowEqual을 넣으면 얕은 비교로 “같다”고 판단할 수 있지만, 매번 새 객체를 만드는 구조 자체를 고치지 않으면 계산 비용은 계속 발생합니다.
import { shallowEqual, useSelector } from "react-redux"
const userView = useSelector(
(state: RootState) => ({
id: state.user.id,
name: state.user.name,
}),
shallowEqual
)
이 패턴은 단기적으로는 도움이 되지만, 장기적으로는 createSelector나 state 구조 개선이 더 안전합니다.
3분: selector가 너무 “넓은 state”를 구독하고 있지 않은가
다음 패턴은 폭주를 키웁니다.
useSelector((s) => s)또는 feature 전체 slice를 통째로 구독- 컴포넌트가 사실 일부 필드만 쓰는데 slice 전체를 가져옴
나쁜 예: slice 전체 구독
const todosState = useSelector((s: RootState) => s.todos)
// 실제로는 todosState.items.length만 씀
slice 안의 어느 필드가 바뀌어도 리렌더됩니다.
개선: 필요한 최소 단위만 selector로 가져오기
const todoCount = useSelector((s: RootState) => s.todos.items.length)
혹은 파생 데이터가 필요하면 createSelector로 “정확히 필요한 값만” 계산해 반환합니다.
4분: createSelector를 잘못 쓰면 캐시가 깨진다
createSelector는 입력 셀렉터의 결과가 동일 참조일 때만 캐시가 유지됩니다. 다음은 흔한 함정입니다.
함정 1: 입력 셀렉터가 매번 새 객체를 반환
const selectQuery = (s: RootState) => ({
q: s.search.q,
page: s.search.page,
})
export const selectResult = createSelector(
[selectQuery, (s: RootState) => s.items],
(query, items) => {
// query가 매번 새 객체면 캐시가 사실상 무력화
return items.filter((x) => x.name.includes(query.q))
}
)
해결: 입력은 가능한 한 원시값/안정 참조로
const selectQ = (s: RootState) => s.search.q
const selectPage = (s: RootState) => s.search.page
const selectItems = (s: RootState) => s.items
export const selectResult = createSelector(
[selectQ, selectPage, selectItems],
(q, page, items) => {
const filtered = items.filter((x) => x.name.includes(q))
return filtered.slice(page * 20, page * 20 + 20)
}
)
함정 2: selector factory를 매 렌더마다 생성
파라미터가 필요한 selector를 만들 때, 컴포넌트 내부에서 factory를 매번 호출하면 캐시가 매번 새로 생깁니다.
// 안티패턴: 렌더마다 createSelector 실행
const Comp = ({ userId }: { userId: string }) => {
const selectUserById = createSelector(
[(s: RootState) => s.users.entities, (_: RootState, id: string) => id],
(entities, id) => entities[id]
)
const user = useSelector((s: RootState) => selectUserById(s, userId))
return null
}
해결: factory는 컴포넌트 밖에서, 인스턴스는 useMemo
import { createSelector } from "@reduxjs/toolkit"
const makeSelectUserById = () =>
createSelector(
[(s: RootState) => s.users.entities, (_: RootState, id: string) => id],
(entities, id) => entities[id]
)
const Comp = ({ userId }: { userId: string }) => {
const selectUserById = useMemo(makeSelectUserById, [])
const user = useSelector((s: RootState) => selectUserById(s, userId))
return null
}
이 패턴은 리스트 아이템처럼 “컴포넌트 인스턴스가 많을 때” 특히 중요합니다.
5분: memo는 만능이 아니다 — props 참조 동일성을 먼저 맞춰라
React.memo는 props가 동일하면 리렌더를 막습니다. 그런데 props에 함수/객체/배열을 인라인으로 넘기면 매번 새 참조가 되어 memo가 무력화됩니다.
나쁜 예: 인라인 객체/함수 props
const Row = React.memo(function Row({ style, onClick }: {
style: { color: string }
onClick: () => void
}) {
return <button style={style} onClick={onClick}>row</button>
})
function List() {
const dispatch = useAppDispatch()
return (
<Row
style={{ color: "red" }}
onClick={() => dispatch(clicked())}
/>
)
}
개선: useMemo/useCallback로 참조 고정
function List() {
const dispatch = useAppDispatch()
const style = useMemo(() => ({ color: "red" }), [])
const onClick = useCallback(() => dispatch(clicked()), [dispatch])
return <Row style={style} onClick={onClick} />
}
여기서 중요한 포인트는 dispatch가 보통 안정 참조라서 의존성에 넣어도 괜찮다는 점입니다(팀 규칙에 따라 생략하기도 합니다).
6분: “selector는 가볍게, 계산은 밖으로”가 항상 정답은 아니다
가끔 성능 튜닝 조언으로 “selector에서 계산하지 말고 컴포넌트에서 하라”는 말을 듣는데, 이는 조건부입니다.
- 컴포넌트에서 계산: 컴포넌트가 리렌더될 때마다 계산이 반복
- 메모이즈된 selector에서 계산: 입력이 바뀔 때만 계산, 결과 참조도 안정적
따라서 파생 데이터(정렬/필터/그룹핑)가 있고 그 비용이 크다면, 컴포넌트 밖의 createSelector로 옮기는 편이 대개 유리합니다.
다만 selector가 너무 무거워져서 “입력 변경 시 UI가 멈추는” 문제가 생기면, 다음을 고려하세요.
- 서버/백엔드에서 정렬/필터
- pagination/virtualization
- Web Worker
- 계산 단계를 쪼개고 캐시 레이어를 추가
7분: 체크리스트 — 원인별 처방을 한 장으로
아래는 RTK 렌더링 폭주에서 가장 자주 맞닥뜨리는 원인과 처방입니다.
A. useSelector가 새 객체/배열을 반환
- 증상: store 업데이트마다 관련 없어도 리렌더
- 처방:
createSelector로 메모이즈, 입력 셀렉터 안정화
B. slice 전체 구독
- 증상: 작은 변경에도 큰 컴포넌트가 리렌더
- 처방: 필요한 최소 필드만 selector로 구독, 컴포넌트 분리
C. selector factory를 렌더마다 생성
- 증상:
createSelector캐시가 전혀 먹지 않음 - 처방: factory는 외부로, 인스턴스는
useMemo로 1회 생성
D. React.memo가 안 먹힘
- 증상: memo를 씌웠는데도 계속 리렌더
- 처방: 인라인 객체/함수 props 제거,
useMemo/useCallback
E. action 폭주
- 증상: Redux DevTools에 action이 초당 수십~수백
- 처방: 디바운스/스로틀, 입력 이벤트 설계 변경, 폴링 주기 조정
실전 예시: “검색 결과 리스트” 폭주를 줄이는 리팩터링
검색 UI는 리렌더 폭주의 종합선물세트입니다(입력 이벤트, 파생 계산, 리스트 렌더).
1) 상태 구조
// searchSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
type SearchState = {
q: string
page: number
}
const initialState: SearchState = { q: "", page: 0 }
const searchSlice = createSlice({
name: "search",
initialState,
reducers: {
setQuery(state, action: PayloadAction<string>) {
state.q = action.payload
state.page = 0
},
setPage(state, action: PayloadAction<number>) {
state.page = action.payload
},
},
})
export const { setQuery, setPage } = searchSlice.actions
export default searchSlice.reducer
2) 메모이즈된 selector
// selectors.ts
import { createSelector } from "@reduxjs/toolkit"
const selectQ = (s: RootState) => s.search.q
const selectPage = (s: RootState) => s.search.page
const selectItems = (s: RootState) => s.catalog.items
export const selectPagedResults = createSelector(
[selectQ, selectPage, selectItems],
(q, page, items) => {
const filtered = q.trim()
? items.filter((x) => x.name.toLowerCase().includes(q.toLowerCase()))
: items
const pageSize = 20
const start = page * pageSize
return {
total: filtered.length,
pageItems: filtered.slice(start, start + pageSize),
}
}
)
3) 컴포넌트: 최소 구독 + 안정 props
function SearchPage() {
const dispatch = useAppDispatch()
const q = useSelector((s: RootState) => s.search.q)
const { total, pageItems } = useSelector(selectPagedResults)
const onChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(setQuery(e.target.value))
},
[dispatch]
)
return (
<div>
<input value={q} onChange={onChange} placeholder="Search" />
<div>total: {total}</div>
<ResultList items={pageItems} />
</div>
)
}
const ResultList = React.memo(function ResultList({ items }: { items: Item[] }) {
return (
<ul>
{items.map((it) => (
<li key={it.id}>{it.name}</li>
))}
</ul>
)
})
포인트는 다음과 같습니다.
ResultList는items참조가 안정적일 때만 리렌더selectPagedResults가 입력이 동일하면 동일 결과 참조를 유지onChange는useCallback으로 안정화
마무리: “selector 참조 동일성”이 80%를 해결한다
RTK에서 렌더링 폭주를 가장 빠르게 잡는 방법은, useSelector가 반환하는 값이 불필요하게 새 참조를 만들고 있지 않은지부터 보는 것입니다. 그 다음이 구독 범위 축소, selector 캐시 유지, 그리고 마지막이 컴포넌트 경계에서의 memo/useMemo/useCallback 정리입니다.
정리하면 다음 한 줄로 귀결됩니다.
- 구독은 좁게, 반환은 안정적으로, 파생은 메모이즈된 selector로, props는 참조를 고정
이 4가지만 지켜도 “이유 없는 리렌더 폭주”의 대부분은 7분 안에 진단하고 멈출 수 있습니다.