일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- TypeScript
- CI/CD
- 주니어개발자
- 프로세스
- CS
- React.memo
- Compound Component
- Sparkplug
- prettier-plugin-tailwindcss
- 암묵적 타입 변환
- useMemo
- JavaScript
- type assertion
- 명시적 타입 변환
- docker
- react
- 좋은 PR
- linux 배포판
- useEffect
- useLayoutEffect
- useCallback
- 타입 단언
- Render Queue
- AJIT
- Microtask Queue
- github actions
- Event Loop
- Custom Hook
- task queue
- Headless 컴포넌트
- Today
- Total
구리
[React] React 성능 개선의 여정 (144ms에서 61ms까지) 본문
React 리렌더링 성능 개선 경험을 공유하는 글로 피드백은 언제나 환영입니다!
서론
회사에서 작업했던 영상 데이터를 차트로 보여주는 React 기반의 대시보드 프로젝트를 진행했었다. 프로젝트를 진행하며 성능 개선은 크게 신경쓰지 않았었기에 프로젝트 성능을 분석하고 일부 개선한 경험을 공유하려 한다.
본론
성능이 느리다는 기준이 뭘까?
사용자의 상호작용을 통해 React는 상태가 업데이트되면 리렌더링이 되면서 화면이 다시 그려진다. 그런데 리렌더링 속도가 느려지면 사용자 경험도 낮아지기에 렌더링 속도가 성능의 기준이 될 것이다. 그렇다면 렌더링 속도가 느리다는 기준은 무엇일까?
여러 자료들을 찾아보며 내린 결론은 다음과 같다.
- 상호작용에 대한 응답 : 100ms이하로 응답해야 한다.
- 애니메이션 : 각 프레임당 16ms이하로 완료해야 한다.
- 페이지 로드 : 1초 이하로 페이지 로드가 완료되어야 한다.
따라서 React Dev Tools를 이용해 위 조건에 부합하지 못하는 곳들을 찾아 분석하고 개선해보도록 한다.
참고로 개선할 프로젝트를 간단히 설명하면 영상 관련 데이터를 차트로 보여주는 대시보드로 예를 들면 Grafana나 OpenSearch 대시보드의 UI와 비슷하다.
카드 편집 페이지에서의 불필요한 리렌더링 발생
대시보드를 구성하는 각 카드들이 존재하고 카드는 아래와 같은 정보를 포함하고 있으며 카드 상태는 redux toolkit으로 관리하고 있다.
카드
├─ 필수 옵션 : 제목, 설명, CSV 다운로드 여부 등...
├─ API 관련 정보 : 선택한 API ID, 선택한 TimeInterval 등..
└─ 스타일 관련 옵션 : width, height, color 등..
또한 카드 편집 페이지에서 각 항목들을 수정할 수 있다. 따라서 카드 편집 페이지 컴포넌트 구조는 카드 데이터 형태와 비슷하게 나눠져있는데 여기서 문제는 카드 필수 정보 중 어떤 값을 변경하면 관련이 없는 DatasourceStyle, CardEditToolbar 컴포넌트도 같이 렌더링되는 것이 문제였다. 또한 UI상에서 input창에 텍스트 입력시 텍스트가 바로 변경되지 않는 것처럼 보이는 현상이 발생했다.
CardEdit
├─ CardEditToolbar
├─ DatasourceStyle
├─ CardBasicStyle
├─ ChartStyle
└─ CardStyle
React Dev Tools Profiler로 분석시 연관성이 없는 컴포넌트도 리렌더링되며 리렌더링 시간이 최대 144ms 소요되었다. 따라서 불필요한 렌더링이 발생하지 않도록 개선해본다.
(1) API 선택 컴포넌트 (DatasourceStyle) 리렌더링 분석
1. 카드의 특정 property 변경시 카드 state 전체를 업데이트하는 문제
카드 property를 변경하는 컴포넌트(CardBasicStyle)에서 카드 property 변경시 redux toolkit의 reducer를 사용하는데 내부 로직을 보니 card state 전체를 업데이트하고 있었다. 따라서 card state를 구독하고 있던 모든 컴포넌트가 리렌더링되는 현상이 발생했다.
따라서 카드 전체 state가 아닌 해당 property만 업데이트되도록 변경하였다.
참고로 redux toolkit은 내부적으로 immer를 사용하고 있어 nested object의 특정 property을 변경하면 해당 property과 관련된 상위 property만 참조값이 바뀌도록 해준다. 즉, card의 특정 property를 변경해도 관련이 없는 property는 참조가 변경되지 않아 렌더링을 촉발시키지 않는다. (이 부분은 별도의 글로 다루도록 한다.)
// 개선 전 (redux toolkit reducer)
styleUpdate: (state, action) => {
const {type, value} = action.payload
const newStyle = {...state.styles, [type]: value}
state.styles = newStyle
},
// 개선 후 (redux toolkit reducer)
styleUpdateDetail: (state, action) => {
const {type, value} = action.payload
state.styles[type] = value
},
2. DatasourceStyle 컴포넌트의 불필요한 리렌더링 발생
DatasourceStyle 컴포넌트는 prefix, index props를 받고 있는데 해당 값은 윈시형 데이터로 거의 변경되지 않는다. (원래는 카드에서 다양한 API 데이터를 한꺼번에 보여주기 위해 선택된 Datasource마다 index를 부여하는 방식이었지만 현재는 1개의 API만 선택할 수 있어 index property는 큰 의미는 없는 데이터가 되어버렸다…)
따라서 memo를 사용해 부모 컴포넌트가 렌더링되어도 리렌더링되지 않도록 방지하였다.
// 개선 전
const CardEdit = () => {
return (
<DatasourceStyle prefix='datasource' index={0} />
// etc...
)
}
// DatasourceStyle 컴포넌트
type DatasourceStyleType = {
prefix: string
index: number
}
const DatasourceStyle = (props: DatasourceStyleType) => {
// etc...
}
// 개선 후
// DatasourceStyle 컴포넌트
type DatasourceStyleType = {
prefix: string
index: number
}
const DatasourceStyle = React.memo((props: DatasourceStyleType) => {
// etc...
})
3. redux state 접근시 특정 property가 아닌 전체 state를 사용하는 문제
DatasourceStyle 컴포넌트는 API 관련 옵션만 사용하며 카드의 다른 옵션과는 연관이 없기에 card state에서 datasource 관련된 옵션만 접근하도록 수정하였다.
이건 React보다는 Redux Toolkit을 잘못 사용해 발생했던 문제였다…
// 개선 전
const DatasourceStyle = React.memo((props: DatasourceStyleType) => {
const cardStyles = useAppSelector((state) => state.editCard.styles)
const timeInterval = cardStyles['card.time_interval']
const datasourceId = cardStyles[`${prefix}_${index}.${DATASOURCE_STYLE_KEYS.ID}`]
const datasourceParams = cardStyles[`${prefix}_${index}.${DATASOURCE_STYLE_KEYS.PARAMS}`]
const useLimit = cardStyles[`${prefix}_${index}.${DATASOURCE_STYLE_KEYS.USE_LIMIT}`]
// etc...
})
// 개선 후
const DatasourceStyle = React.memo((props: DatasourceStyleType) => {
const {prefix, index} = props
const timeInterval = useAppSelector((state) => state.editCard.styles['card.time_interval'])
const datasourceId = useAppSelector(
(state) => state.editCard.styles[`${prefix}_${index}.${DATASOURCE_STYLE_KEYS.ID}`]
)
const datasourceParams = useAppSelector(
(state) => state.editCard.styles[`${prefix}_${index}.${DATASOURCE_STYLE_KEYS.PARAMS}`]
)
const useLimit = useAppSelector(
(state) => state.editCard.styles[`${prefix}_${index}.${DATASOURCE_STYLE_KEYS.USE_LIMIT}`]
)
})
(2) CardEditToolbar 컴포넌트 리렌더링 분석
CardEditToolbar 컴포넌트는 대시보드 옵션을 조정하는 역할로 Datapicker 등의 기능을 포함하고 있다. 하지만 CardEdit 컴포넌트 내부에 속하면서 CardEdit 컴포넌트가 리렌더링되면서 CardEditToolbar 컴포넌트가 불필요하게 리렌더링되고 있었다.
따라서 대시보드 데이터를 관리하는 CardEditToolbar와 Card 데이터를 관리하는 CardEditBody로 분리시켰다.
// 개선 전
CardEdit
├─ CardEditToolbar
├─ DatasourceStyle
├─ CardBasicStyle
├─ ChartStyle
└─ CardStyle
// 개선 후
CardEdit
├─CardEditToolbar
└─CardEditBody
├─DatasourceStyle
├─ChartStyle
├─CardBasicStyle
├─CardStyle
└─etc...
카드 편집 페이지 개선 결과
위 코드로 개선 후 성능 측정 결과 최대 61ms가 소요되며 기존 144ms 소요시간과 비교시 최대 57% 감소하였다. 또한 100ms 이하로 개선하여 input 변경시 딜레이되는 버벅임 현상도 사라졌다.
API 편집 페이지에서 불필요한 리렌더링 발생
대시보드에는 차트로 표현하는 데이터를 반환하는 API 정보를 추가, 수정할 수 있는 편집 페이지가 있다. 해당 페이지에는 API 호출시 전달하는 파라미터, header, API 필수 정보를 입력할 수 있으며 컴포넌트 구조는 다음과 같다.
ApiDetailOverview
├─ ApiDetailEditor // API 필수정보 수정
├─ ApiParamsEditor // API 파라미터 수정
├─ ApiHeadersEditor // API Header 수정
└─ etc...
해당 컴포넌트는 input이 많기에 상태 업데이트가 자주 일어날 수 밖에 없는데 API 필수 정보 입력시 input 업데이트가 버벅이는 현상이 발생하였기에 개선이 필요했다. React Dev Tools로 분석한 결과 특정 input 변경시 페이지 내 모든 컴포넌트가 리렌더링되었으며 최대 152ms가 소요되었다.
1. API 필수 정보 변경시 ApiParamsEditor, ApiHeadersEditor 컴포넌트 리렌더링 발생
ApiParamsEditor, ApiHeadersEditor는 API 필수 정보 state를 사용하지 않았지만 부모 컴포넌트가 리렌더링되면서 하위 컴포넌트가 같이 리렌더링되고 있었다. 따라서 props로 전달하는 함수는 useCallback으로, 각 컴포넌트는 memo를 이용해 메모이제이션하여 불필요한 리렌더링을 방지하였다.
// 변경 전
const ApiDetailOverview = ({datasource}: ApiDataSourceItem) => {
const [apiInfo, setApiInfo] = useState<APIInfo>({
title: datasource?.title || '',
url: datasource?.config?.url || '',
api_type: datasource?.api_type || 'custom',
api_group: datasource?.api_group || 'custom',
interval: datasource?.interval.map((item) => item.code) || [],
desc: datasource?.desc || '',
method: datasource?.config?.method || 'get',
content_type: datasource?.config?.contentType || '',
})
const onChangeApiInfo = (newValue, infoKey) => {
const newInfo = {...apiInfo, [infoKey]: newValue}
setApiInfo(newInfo)
}
const [paramRows, setParamRows] = useState<APIParams[]>(() => datasource.config.params)
const onChangeParamRows = useCallback((newRows) => {
setParamRows(newRows)
}, [])
const [headerRows, setHeaderRows] = useState<APIHeaders[]>(() => datasource.config.headers)
const onChangeHeaderRows = useCallback((newRows) => {
setHeaderRows(newRows)
}, [])
return (
<ApiDetailEditor
apiInfo={apiInfo}
onChangeApiInfo={onChangeApiInfo}
/>
<ApiParamsEditor rows={paramRows} onChangeRows={onChangeParamRows} />
<ApiHeadersEditor rows={headerRows} onChangeRows={onChangeHeaderRows} />
)
}
const ApiParamsEditor = (props: ParamsEditorProps) => {
const {rows, onChangeRows} = props
// 생략
}
const ApiHeadersEditor = (props: HeadersEditorProps) => {
const {rows, onChangeRows} = props
// 생략
}
// 변경 후
const ApiDetailOverview = ({datasource}: ApiDataSourceItem) => {
const [apiInfo, setApiInfo] = useState<APIInfo>({
title: datasource?.title || '',
url: datasource?.config?.url || '',
api_type: datasource?.api_type || 'custom',
api_group: datasource?.api_group || 'custom',
interval: datasource?.interval.map((item) => item.code) || [],
desc: datasource?.desc || '',
method: datasource?.config?.method || 'get',
content_type: datasource?.config?.contentType || '',
})
const onChangeApiInfo = (newValue, infoKey) => {
const newInfo = {...apiInfo, [infoKey]: newValue}
setApiInfo(newInfo)
}
const [paramRows, setParamRows] = useState<APIParams[]>(() => datasource.config.params)
const onChangeParamRows = useCallback((newRows) => {
setParamRows(newRows)
}, [])
const [headerRows, setHeaderRows] = useState<APIHeaders[]>(() => datasource.config.headers)
const onChangeHeaderRows = useCallback((newRows) => {
setHeaderRows(newRows)
}, [])
return (
<ApiDetailEditor
apiInfo={apiInfo}
onChangeApiInfo={onChangeApiInfo}
/>
<ApiParamsEditor rows={paramRows} onChangeRows={onChangeParamRows} />
<ApiHeadersEditor rows={headerRows} onChangeRows={onChangeHeaderRows} />
)
}
const ApiParamsEditor = memo((props: ParamsEditorProps) => {
const {rows, onChangeRows} = props
// 생략
})
const ApiHeadersEditor = memo((props: HeadersEditorProps) => {
const {rows, onChangeRows} = props
// 생략
})
2. API 필수 정보 변경시 ApiDetailEditor 컴포넌트 전체가 리렌더링 발생
ApiDetailEditor 컴포넌트는 API 필수정보를 변경하는 컴포넌트로 하위에는 필수 속성들을 변경하는 element들이 있다. (제목, 설명, api type 등)
ApiDetailOverview
├─ ApiDetailEditor // API 필수정보 수정
├─ ApiParamsEditor // API 파라미터 수정
├─ ApiHeadersEditor // API Header 수정
└─ etc...
이때 제목을 변경했을 때 다른 property를 다루는 컴포넌트도 리렌더링되는 문제가 발생했다. 물론 하나의 state로 묶여있지만 각 property마다 업데이트가 빈번히 일어나기에 각 property마다 컴포넌트로 분리 후 React.memo를 적용해 불필요한 리렌더링을 방지하였다.
// 개선 후
// 폴더 구조
ApiDetailOverview
├─ ApiDetailEditor // API 필수정보 수정
│ ├─ ApiTitleInput
│ ├─ApiTypeInput
│ ├─ApiGroupInput
│ ├─ApiIntervalInput
│ ├─ApiDescInput
│ └─ApiMethodInput
├─ ApiParamsEditor // API 파라미터 수정
├─ ApiHeadersEditor // API Header 수정
└─ etc...
// ApiDetailOverview
const ApiDetailOverview = ({datasource}: {datasource: ApiDataSourceItem | undefined}) => {
const [apiInfo, setApiInfo] = useState<APIInfo>({
title: datasource?.title || '',
url: datasource?.config?.url || '',
api_type: datasource?.api_type || 'custom',
api_group: datasource?.api_group || 'custom',
interval: datasource?.interval.map((item) => item.code) || [],
desc: datasource?.desc || '',
method: datasource?.config?.method || 'get',
content_type: datasource?.config?.contentType || '',
})
const onChangeApiInfo = useCallback((newValue, infoKey) => {
setApiInfo((prev) => {
return {...prev, [infoKey]: newValue}
})
}, [])
return (
<ApiDetailEditor
apiInfo={apiInfo}
onChangeApiInfo={onChangeApiInfo}
/>
)
}
// ApiDetailEditor
const ApiDetailEditor = memo((props: ApiDetailEditorProps) => {
const {apiInfo, onChangeApiInfo} = props
return (
<>
<ApiTitleInput
title={apiInfo.title}
onChange={onChangeApiInfo}
/>
<ApiTypeInput
apiType={apiInfo.api_type}
onChange={onChangeApiInfo}
/>
<ApiGroupInput
apiGroup={apiInfo.api_group}
onChange={onChangeApiInfo}
/>
<ApiIntervalInput
interval={apiInfo.interval}
onChange={onChangeApiInfo}
/>
<ApiDescInput desc={apiInfo.desc} onChange={onChangeApiInfo} />
<ApiMethodInput
method={apiInfo.method}
onChange={onChangeApiInfo}
/>
</>
)
})
// ApiTitleInput
type ApiTitleInputType = {
title: string
onChange: (newValue: string, infoKey: string) => void
}
const ApiTitleInput = memo((props: ApiTitleInputType) => {
const {title, onChange} = props
const intl = useIntl()
return (
<div className='mt-7 mb-10'>
<TextField
variant='standard'
label={intl.formatMessage({id: 'MANAGE.DETAIL.API.TITLE'})}
sx={{
width: '100%',
'& label': {
fontSize: '15px',
fontWeight: 'bold',
},
}}
value={title}
onChange={(e) => onChange(e.currentTarget.value, 'title')}
/>
</div>
)
})
API 편집 페이지 개선 결과
위 코드로 개선 후 성능 측정 결과 최대 14.5ms가 소요되며 기존 152ms 소요시간과 비교시 최대 90% 감소하였다. 또한 100ms 이하로 개선하여 input 변경시 딜레이되는 버벅임 현상도 사라졌다.
결론
상태 변경이 자주 일어나는 페이지의 성능을 개선하며 배운 점은 다음과 같다.
- 다음 조건을 만족하지 못하면 성능 개선이 필요하다.
- 상호작용에 대한 응답 : 100ms이하로 응답해야 한다.
- 애니메이션 : 각 프레임당 16ms이하로 완료해야 한다.
- 페이지 로드 : 1초 이하로 페이지 로드가 완료되어야 한다.
상태 업데이트가 자주 일어나는 컴포넌트의 경우, useCallback, React.memo, 컴포넌트 분리를 통해 불필요한 리렌더링을 방지해 렌더링 시간을 약 57%, 90% 감소시켰다.
코드만 봤을 땐 엄청난 작업을 한 건 아니기에 그동안 개발하면서 사소한 부분을 많이 놓쳤던 것 같다는 점이 아쉬웠다.
좋았던 점은 성능 개선이 필요한 순간과 useCallback, useMemo, React.memo를 어떤 상황에 사용해야 할지 정확히 알게 된 것 같다.
다음 번에는 현재 프로젝트에서 사용하는 Redux Toolkit, React Query을 딥다이브해서 코드 리팩토링을 진행해봐야겠다.
참고
- https://alexsidorenko.com/blog/react-performance-slow-renders
- https://www.smashingmagazine.com/2015/10/rail-user-centric-model-performance/#putting-the-user-in-the-center-of-performance
- https://stackoverflow.com/questions/71018743/what-is-an-acceptable-render-time-for-a-react-app
- https://stackoverflow.com/questions/71018743/what-is-an-acceptable-render-time-for-a-react-app
'React' 카테고리의 다른 글
[React] React Hook 파헤쳐보기 - useEffect (2) | 2024.11.08 |
---|---|
React 동작원리(Virtual DOM과 Fiber) (1) | 2024.11.03 |
[React] React Hook 파헤쳐보기 - useState (0) | 2024.10.21 |
[React] 변경에 유연한 Headless 컴포넌트 만들기 (0) | 2024.01.16 |
[Webpack] 모듈 번들러, 그리고 ESBuild를 통한 빌드 속도 개선 (1) (0) | 2023.09.21 |