컴포넌트 내부의 useCallback 남용 이대로 괜찮은걸까?
진행 중인 프로젝트에서 React 함수 컴포넌트 내부에서 사용하는 함수들의 경우, 재사용(자식에게 내려주는 Props 용도 등)에 대한 안정성이라는 명목으로 useCallback으로 대부분 감싸주고 있었다.
memoization이라는 기능 자체 그리고 종속성의 존재가 어떠한 영향을 주는지 모른 채, 이대로 사용하는 것이 옳은 것인가에 대한 의문이 들었다.
Memoization 기능은 공짜가 아니다!
(참고자료)React Memoization(useMemo vs. useCallback vs. React.memo) 알아보기
React에서 활용할 수 있는 Memoization 기능들에 대한 학습을 토대로 Memoization 기능을 활용한 성급한 최적화 시도가 오히려 성능을 저하시킬 수 있다는 것을 확인할 수 있었다.
React의 useCallback Hook 경우, 함수를 재정의해야 하는지 여부를 결정하기 위해 다시 렌더링할 때마다 종속성 배열의 종속성을 비교해야 하고 이 계산은 오히려 단순히 함수를 재정의하는 것보다 비용이 클 수 있다.
이에, 이러한 Memoization 기능에 대해 능동적으로 접근하는 것 보다는 성능 문제를 도출한 후에 이를 개선하기 위한 대응책으로 활용하는 등, 분명히 필요할 때 근거를 가지고 사용하는 것이 옳다는 결론을 내릴 수 있었다.
최소한 기존에 사용하던대로 함수 컴포넌트 내부의 함수들을 모두 useCallback으로 래핑하는 것만큼은 하지 말아야겠다고 생각했다.
Refactoring
(예) 회원가입화면 ID 입력 컴포넌트
Before
/** @jsxImportSource @emotion/react */
import React, { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
import { css } from '@emotion/react';
import { API, RESULT } from 'utils/constants';
import {
// ...중략...
} from './styles';
export const IdInput = ({ setId }: { setId: Dispatch<SetStateAction<string>> }) => {
const [idDraft, setIdDraft] = useState<string>('');
const [idWarning, setIdWarning] = useState<string>('');
const [idDuplicationCheckResult, setIdDuplicationCheckResult] = useState<string>('');
// 아이디값 입력에 따른 상태관리
const handleOnChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setIdDraft(e.target.value);
}, []);
// 서버측 id 유효성 검사를 위해 fetch 통신(쿼리스트링)
const sendIdToServer = useCallback(() => {
fetch(`${process.env.REACT_APP_FETCH_URL}${API.VALIDATE}?${new URLSearchParams({ id: idDraft })}`)
.then((res) => res.json())
// ...중략...
}, [idDraft]);
// 클라이언트측 id 유효성 검사
// 아이디 요소 확인
const isValidIdStr = useCallback((id: string) => {
// ...중략...
}, []);
// 아이디 길이 확인
const isValidIdLength = useCallback((id: string) => {
// ...중략...
}, []);
// 아이디 유효성 검사
const isValidId = useCallback(() => {
// ...중략...
}, [idDraft]);
// id값이 유효하면 서버로 보내주기
const handleClick = useCallback(() => {
// ...중략...
}, [idDraft]);
// 사용자가 id값을 입력할때마다 검사
useEffect(() => {
// ...중략...
}, [idDraft]);
const isAllValid = useCallback(() => {
// ...중략...
}, [idWarning, idDuplicationCheckResult]);
return (
<div>
<div css={registerPageInputWrapperStyle}>
<input
css={css(registerPageInputStyle, { width: 300 })}
placeholder='아이디'
value={idDraft}
onChange={handleOnChange}
/>
<button type='button' css={registerPageIdButtonStyle} onClick={handleClick}>
<span>중복확인</span>
</button>
</div>
{isAllValid() !== RESULT.NULL && (
<span css={idValidationStyle(isAllValid())}>
{isAllValid() === RESULT.FAIL ? idWarning : idDuplicationCheckResult}
</span>
)}
</div>
);
};
Refactoring 과정
useCallback 남용 리팩토링
- useCallback을 모두 지운 후, useCallback이 필요하거나 useCallback을 적용해도 비용이 크지 않으리라 생각되는 함수에만 적용하고자 하였음
// 사용자의 입력값 변화마다 호출되므로 useCallback으로 최적화
const handleOnChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setIdDraft(e.target.value);
}, []);
// id값이 유효하면 서버로 보내주기
// 버튼 클릭이 발생할 때만 일어나는 이벤트이고 id입력 시마다 client측 유효성 검사를 진행하고 있으므로 굳이 useCallback을 적용할만큼 자주 일어나진 않음
const handleClick = () => {
if (!isValidId(idDraft)) {
return;
}
// 아이디값 서버측 유효성 검사
checkIdServerValidation(idDraft)
// ...중략...
- 기존에 useCallback으로 감싸져있던 내부 함수들 중에 자주 사용되지만 함수 컴포넌트 내의 상태관리에 관여하지 않고 주요 비즈니스 로직이라 판단되지 않는 함수는 별도 util 파일에 분리 후 import해서 사용
// 기존 id 유효성 검사를 별도 util파일로 분리
import { isValidId, isValidIdLength, isValidIdStr } from './util';
기타 추가 Refactoring 사항
- 비즈니스 로직을 파악하기 쉽도록 fetch기능은 service.ts라는 별도 service 파일로 분리
import { checkIdServerValidation } from './service';
- id 유효성에 대해 여러개로 나뉘어져 있던 상태 기능을 하나의 상태로만 관리할 수 있도록 통합하고 상태코드와 상태안내문은 상수화하여, 코드 안정성을 높임
import { VALIDATION_INFO, VALIDATION_RESULT } from './constants';
const [validationType, setValidationType] = useState<number>(VALIDATION_RESULT.NULL);
// constants.ts
export const VALIDATION_RESULT: Readonly<Record<string, number>> = {
NULL: 0,
SUCCESS: 1,
WRONG_STR: 2,
WRONG_LENGTH: 3,
DUPLICATED: 4,
CLIENT_FAIL: 5,
};
export const VALIDATION_INFO: Readonly<Record<string, string>> = {
1: '유효한 아이디 입니다.',
2: '알파벳과 숫자로만 이루어져야 합니다.',
3: '4글자 이상 15글자 이하만 가능합니다.',
4: '중복되는 Id 입니다.',
5: '4글자 이상, 15글자 이하의 알파벳과 숫자로 작성바랍니다.',
};
After
/** @jsxImportSource @emotion/react */
import React, { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
import { checkIdServerValidation } from './service';
import { isValidId, isValidIdLength, isValidIdStr } from './util';
import { VALIDATION_INFO, VALIDATION_RESULT } from './constants';
import { idButtonStyle, idInputStyle, idInputWrapperStyle, idValidationStyle } from './idInput.styles';
export const IdInput = ({ setId }: { setId: Dispatch<SetStateAction<string>> }) => {
// 유효성이 확정되지 않은 예비 ID 값
const [idDraft, setIdDraft] = useState<string>('');
const [validationType, setValidationType] = useState<number>(VALIDATION_RESULT.NULL);
// 아이디값 입력에 따른 상태관리
// 사용자의 입력값 변화마다 호출되므로 useCallback으로 최적화
const handleOnChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setIdDraft(e.target.value);
}, []);
// id값이 유효하면 서버로 보내주기
// 버튼 클릭이 발생할 때만 일어나는 이벤트이고 id입력 시마다 client측 유효성 검사를 진행하고 있으므로 굳이 useCallback을 적용할만큼 자주 일어나진 않음
const handleClick = () => {
// ...중략...
};
// 사용자가 id값을 입력할때마다 유효성 검사 결과를 알려주어 UX 향상
useEffect(() => {
// ...중략...
}, [idDraft]);
return (
<>
<div css={idInputWrapperStyle(validationType)}>
<input placeholder='아이디' value={idDraft} onChange={handleOnChange} css={idInputStyle} />
<button type='button' onClick={handleClick} css={idButtonStyle}>
<span>중복확인</span>
</button>
</div>
{validationType !== VALIDATION_RESULT.NULL && (
<span css={idValidationStyle(validationType)}>{VALIDATION_INFO[validationType]}</span>
)}
</>
);
};
이번 리팩토링은 성능에 유의미한 영향이 있었을까?
테스팅 동작
Before
- 라이트하우스 결과
- (주요 리팩토링 대상) IdInput 컴포넌트 검사 결과
- 성능
- Profiler
After
- 라이트하우스 결과
- (주요 리팩토링 대상) IdInput 컴포넌트 검사 결과
- 성능
- Profiler
결과에 대한 분석
- 비교결과, 라이트하우스 결과에서 보듯이 useCallback을 사용하지 않더라도 전반적인 성능에 큰 영향이 없었다.
- 오히려, 리팩토링 과정에서 추가적으로 내부에서 useState로 관리하는 상태값을 줄인 덕분에 오히려 랜더링 횟수가 감소하였고 속도도 빨라졌다.
- 이를 통해, useCallback의 유무가 현재 코드 상에서는 전체적인 성능에 영향을 주지 않았고 리팩토링 과정에서 추가적으로 진행한 상태값을 줄이는 리팩토링이 성능향상에 유의미한 결과를 주었음을 확인할 수 있었다.
- useCallback의 사용 유무와 상관없다면, 코드복잡성을 줄이기 위해 useCallback을 사용하지 않는 것이 좋을 것이다.
앞으로 남은 과제
- 앞으로는 다른 페이지(컴포넌트)들에 대해서도 성능분석을 진행한 후에, 분석 결과를 바탕으로 React Memoization(useMemo, useCallback, React.memo 등) 기능이 필요한 지에 대한 근거를 먼저 검토하고 필요한 경우에 적재적소에 활용하여 성능을 향상시킬 수 있도록 하고자 한다.
부탁드리는 사항
혹시 잘못된 내용이나, 문제의 소지가 되는 내용이 있다면 언제든 알려주시면 큰 도움이 될 것 같습니다! 또한, 미처 고려하지 못한 Refactoring 사항에 대해서도 피드백 주시면 언제든 환영입니다!
'나의 개발일지' 카테고리의 다른 글
[개발일지] Lighthouse 검사로 웹페이지 성능 향상시키기 (0) | 2022.12.07 |
---|---|
[개발일지] 반응형 레이아웃 적용하기 (2) | 2022.12.04 |