React 애플리케이션 개발에서 널리 사용되는 접근 방식은 모든 것을 메모로 처리하는 것입니다. 많은 개발자는 불필요한 재렌더링을 방지하기 위해 메모이제이션에 구성 요소를 래핑하여 이 최적화 기술을 자유롭게 적용합니다. 표면적으로는 성능을 향상시키기 위한 완벽한 전략처럼 보입니다. 그러나 Social Discovery Group 팀은 메모 기능을 잘못 적용하면 실제로 예상치 못한 방식으로 중단되어 성능 문제가 발생할 수 있다는 사실을 발견했습니다. 이 기사에서 우리는 메모화가 불안정해질 수 있는 놀라운 장소와 React 애플리케이션에서 이러한 숨겨진 함정을 피하는 방법을 탐구할 것입니다.
메모화는 비용이 많이 드는 작업의 결과를 저장하고 동일한 입력이 다시 발생할 때 이를 재사용하는 프로그래밍 최적화 기술입니다. 메모이제이션의 핵심은 동일한 입력 데이터에 대한 중복 계산을 방지하는 것입니다. 이 설명은 실제로 전통적인 메모에 적용됩니다. 코드 예제에서 모든 계산이 캐시되는 것을 볼 수 있습니다.
const cache = { } function calculate (a) { if (Object.hasOwn(cache, a)) { return cache[a] } cache[a] = a * a return cache[a] }
메모이제이션은 성능 향상, 리소스 절약, 결과 캐싱 등 여러 가지 이점을 제공합니다. 그러나 React의 메모이제이션은 새로운 props가 들어올 때까지 작동합니다. 즉, 마지막 호출의 결과만 저장됩니다.
const prev = { value: null, result: null } function calculate(a) { if (prev.value === a) { return prev.result } prev.value = a prev.result = a * a return prev.result }
React 라이브러리는 메모를 위한 여러 도구를 제공합니다. 이는 HOC React.memo, 후크 useCallback, useMemo 및 useEvent뿐만 아니라 React.PureComponent 및 클래스 구성 요소의 수명 주기 메서드인 shouldComponentUpdate입니다. 처음 세 가지 메모 도구를 살펴보고 React에서 해당 도구의 목적과 사용법을 살펴보겠습니다.
리액트.메모
이 고차 구성 요소(HOC)는 구성 요소를 첫 번째 인수로 받아들이고 선택적 비교 함수를 두 번째 인수로 받아들입니다. 비교 기능을 사용하면 이전 소품과 현재 소품을 수동으로 비교할 수 있습니다. 비교 함수가 제공되지 않으면 React는 기본적으로 얕은 동등성을 사용합니다. 얕은 동일성은 표면 수준 비교만 수행한다는 점을 이해하는 것이 중요합니다. 결과적으로 props에 상수가 아닌 참조 유형이 포함된 경우 React는 구성 요소의 다시 렌더링을 트리거합니다.
const Button = memo((props) => { return ( <button onClick={props.onClick}> {props.title} </button> ) }, (prevProps, props) => { return props.title === prevProps.title })
React.useCallback
useCallback 후크를 사용하면 초기 렌더링 중에 전달된 함수에 대한 참조를 유지할 수 있습니다. 후속 렌더링에서 React는 후크의 종속성 배열의 값을 비교하고 종속성이 변경되지 않은 경우 지난번과 동일한 캐시된 함수 참조를 반환합니다. 즉, useCallback은 종속성이 변경될 때까지 렌더링 간 함수에 대한 참조를 캐시합니다.
const callback = useCallback(() => { // do something }, [a, b, c])
React.use메모
useMemo 후크를 사용하면 렌더링 간의 계산 결과를 캐시할 수 있습니다. 일반적으로 useMemo는 비용이 많이 드는 계산을 캐시하는 데 사용되며, 메모 HOC에 래핑된 다른 구성 요소 또는 후크의 종속성으로 개체를 전달할 때 개체에 대한 참조를 저장하는 데 사용됩니다.
const value = useMemo(() => { return [1, 2, 3, 4, 5].filter(it => it % 2 === 0) }, [])
React 개발팀에서는 포괄적인 메모화가 널리 퍼져 있습니다. 이 접근 방식에는 일반적으로 다음이 포함됩니다.
그러나 개발자는 이 전략의 복잡성과 메모이제이션이 얼마나 쉽게 손상될 수 있는지 항상 파악하지 못합니다. 메모 HOC에 래핑된 구성 요소가 예기치 않게 다시 렌더링되는 것은 드문 일이 아닙니다. Social Discovery Group에서는 동료들이 "모든 것을 메모하는 것이 전혀 메모하지 않는 것보다 비용이 많이 들지 않습니다"라고 말하는 것을 들었습니다.
우리는 모든 사람이 메모이제이션의 중요한 측면을 완전히 이해하고 있는 것은 아니라는 점을 알아냈습니다. 메모이제이션은 기본 요소가 메모이제이션된 구성 요소에 전달될 때만 추가 조정 없이 최적으로 작동합니다.
이러한 경우에는 prop 값이 실제로 변경된 경우에만 구성 요소가 다시 렌더링됩니다. 소품의 기본 요소가 좋습니다.
두 번째 요점은 props에 참조 유형을 전달할 때입니다. React에는 마법이 없다는 것을 기억하고 이해하는 것이 중요합니다. React는 JavaScript 규칙에 따라 작동하는 JavaScript 라이브러리입니다. props(함수, 객체, 배열)의 참조 유형은 위험합니다.
예를 들어:
const a = { c: 1 } const b = { c: 1 } a === b // false First call: MemoComponent(a) Second call: MemoComponent(b) const MemoComponent = memo(({object}) => { return <div /> }, (prevProps, props) => (prevProps.object === props.object)) // false
객체를 생성하는 경우 동일한 속성과 값을 가진 다른 객체는 참조가 다르기 때문에 첫 번째 객체와 동일하지 않습니다.
컴포넌트의 후속 호출에서 동일한 객체인 것처럼 보이지만 실제로는 다른 객체인 경우(참조가 다르기 때문에) React가 사용하는 얕은 비교는 이러한 객체를 다른 객체로 인식합니다. 이렇게 하면 메모에 포함된 구성 요소가 다시 렌더링되어 해당 구성 요소의 메모가 중단됩니다.
메모된 컴포넌트의 안전한 동작을 위해서는 메모, useCallback, useMemo를 조합하여 사용하는 것이 중요합니다. 이렇게 하면 모든 참조 유형이 상수 참조를 갖게 됩니다.
팀워크: 메모, useCallback, useMemo
위에서 설명한 모든 내용은 논리적이고 단순해 보이지만 이 접근 방식을 사용할 때 발생할 수 있는 가장 일반적인 실수를 함께 살펴보겠습니다. 그 중 일부는 미묘할 수도 있고 일부는 다소 과장될 수도 있지만, 우리는 이를 인식하고 가장 중요하게는 전체 메모이제이션으로 구현하는 논리가 중단되지 않도록 이해해야 합니다.
메모이제이션에 인라인
고전적인 실수부터 시작하겠습니다. 이후 Parent 구성 요소를 렌더링할 때마다 메모된 구성 요소 MemoComponent가 매개 변수에 전달된 개체에 대한 참조가 항상 새롭기 때문에 지속적으로 다시 렌더링됩니다.
const Parent = () => { return ( <MemoComponent params={[1, 2 ,3]} /> ) }
이 문제를 해결하려면 앞서 언급한 useMemo 후크를 사용하는 것으로 충분합니다. 이제 객체에 대한 참조는 항상 일정합니다.
const Parent = () => { const params = useMemo(() => { return [1, 2 ,3] ), []) return ( <MemoComponent params={params} /> ) }
또는 배열에 항상 정적 데이터가 포함되어 있는 경우 이를 구성 요소 외부의 상수로 이동할 수 있습니다.
const params = [1, 2 ,3] const Parent = () => { return ( <MemoComponent params={params} /> ) }
이 예제의 비슷한 상황은 메모하지 않고 함수를 전달하는 것입니다. 이 경우 이전 예제와 마찬가지로 MemoComponent의 메모이제이션은 Parent 구성 요소의 각 렌더링에 대한 새 참조를 갖는 함수를 전달하여 중단됩니다. 결과적으로 MemoComponent는 기억되지 않은 것처럼 새로 렌더링됩니다.
const Parent = () => { return ( <MemoComponent onClick={() => {}} /> ) }
여기서는 useCallback 후크를 사용하여 상위 구성 요소의 렌더링 간에 전달된 함수에 대한 참조를 유지할 수 있습니다.
const Parent = () => { const handleClick = useCallback(() => console.log('click') }, []) return ( <MemoComponent onClick={handleClick} /> ) }
메모를 작성했습니다.
또한 useCallback에서는 다른 함수를 반환하는 함수를 전달하는 것을 방해하는 것이 없습니다. 그러나 이 접근 방식에서는 `someFunction` 함수가 모든 렌더링에서 호출된다는 점을 기억하는 것이 중요합니다. `someFunction` 내에서 복잡한 계산을 피하는 것이 중요합니다.
function someFunction() { // expensive calculations (?) ... return () => {} } .............................. const Parent = () => { const cachedFunction = useCallback(someFunction(), []) return ( <MemoComponent onClick={cachedFunction} /> ) }
다음으로 흔한 상황은 소품이 퍼지는 것입니다. 구성요소 체인이 있다고 상상해 보십시오. InitialComponent에서 전달된 데이터 소품이 얼마나 멀리 이동할 수 있는지, 이 체인의 일부 구성 요소에 잠재적으로 불필요할 수 있는지 얼마나 자주 고려하시나요? 이 예에서 이 소품은 ChildMemo 구성 요소의 메모 기능을 중단합니다. 왜냐하면 초기 구성 요소를 렌더링할 때마다 해당 값이 항상 변경되기 때문입니다. 메모된 구성 요소의 체인이 길 수 있는 실제 프로젝트에서는 끊임없이 변화하는 값을 가진 불필요한 소품이 전달되기 때문에 모든 메모가 손상됩니다.
const Child = () => {} const ChildMemo = React.memo(Child) const Component = (props) => { return <ChildMemo {...props} /> } const InitialComponent = (props) => { // The only component that has state and can trigger a re-render return ( <Component {...props} data={Math.random()} /> ) }
자신을 보호하려면 필요한 값만 메모된 구성 요소에 전달되는지 확인하세요. 그 대신에:
const Component = (props) => { return <ChildMemo {...props} /> }
사용(필요한 소품만 전달):
const Component = (props) => { return ( <ChildMemo firstProp={prop.firstProp} secondProp={props.secondProp} /> ) )
다음 예를 고려해 보겠습니다. 익숙한 상황은 JSX를 자식으로 받아들이는 컴포넌트를 작성할 때입니다.
const ChildMemo = React.memo(Child) const Component = () => { return ( <ChildMemo> <div>Text</div> </ChildMemo> ) }
언뜻 보면 무해해 보이지만 실제로는 그렇지 않습니다. JSX를 메모된 구성 요소의 하위 항목으로 전달하는 코드를 자세히 살펴보겠습니다. 이 구문은 이 'div'를 'children'이라는 prop으로 전달하기 위한 구문 설탕일 뿐입니다.
자식은 컴포넌트에 전달하는 다른 소품과 다르지 않습니다. 우리의 경우에는 JSX를 전달하고 JSX는 `createElement` 메소드에 대한 구문적 설탕이므로 기본적으로 `div` 유형의 일반 객체를 전달합니다. 그리고 여기에서는 메모된 구성 요소에 대한 일반적인 규칙이 적용됩니다. 메모되지 않은 개체가 props에 전달되면 이 개체에 대한 참조가 매번 새로워지므로 부모가 렌더링될 때 구성 요소가 다시 렌더링됩니다.
이러한 문제에 대한 해결책은 앞서 메모되지 않은 객체 전달에 관한 블록에서 몇 가지 요약에서 논의되었습니다. 따라서 여기서 전달되는 콘텐츠는 useMemo를 사용하여 메모할 수 있으며, 이를 ChildMemo의 하위 항목으로 전달해도 이 구성 요소의 메모가 깨지지 않습니다.
const Component = () => { const childrenContent = useMemo( () => <div>Text</div>, [], ) return ( <ChildMemo> {childrenContent} </ChildMemo> ) }
좀 더 흥미로운 예를 생각해 보겠습니다.
const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo> <ChildMemo /> </ParentMemo> ) }
언뜻 보면 무해해 보입니다. 두 가지 구성 요소가 있으며 둘 다 기억됩니다. 그러나 이 예에서 ParentMemo는 자식인 ChildMemo가 기억되지 않기 때문에 마치 메모에 싸여 있지 않은 것처럼 동작합니다. ChildMemo 구성 요소의 결과는 JSX가 되며 JSX는 객체를 반환하는 React.createElement의 구문 설탕일 뿐입니다. 따라서 React.createElement 메소드가 실행된 후 ParentMemo 및 ChildMemo는 일반 JavaScript 객체가 되며 이러한 객체는 어떤 방식으로든 메모되지 않습니다.
부모메모 | 아이메모 |
---|---|
| |
결과적으로 메모되지 않은 객체를 props에 전달하여 Parent 구성 요소의 메모를 깨뜨립니다.
const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo children={<ChildMemo />} /> ) }
이 문제를 해결하려면 전달된 하위 항목을 메모하여 상위 앱 구성 요소를 렌더링하는 동안 해당 참조가 일정하게 유지되도록 하는 것으로 충분합니다.
const App = () => { const child = useMemo(() => { return <ChildMemo /> }, []); return ( <ParentMemo> {child} </ParentMemo> ) }
또 다른 위험하고 암시적인 영역은 사용자 정의 후크입니다. 사용자 정의 후크를 사용하면 구성 요소에서 논리를 추출하여 코드를 더 읽기 쉽게 만들고 복잡한 논리를 숨길 수 있습니다. 그러나 데이터와 함수에 지속적인 참조가 있는지 여부도 숨깁니다. 내 예에서는 제출 기능의 구현이 사용자 정의 후크 useForm에 숨겨져 있으며 상위 구성 요소가 렌더링될 때마다 후크가 다시 실행됩니다.
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> };
제출 메소드를 메모된 구성요소 ComponentMemo에 대한 prop으로 전달하는 것이 안전한지 코드를 통해 이해할 수 있습니까? 당연히 아니지. 최악의 경우 사용자 정의 후크의 구현은 다음과 같을 수 있습니다.
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = () => {} return { submit } }
제출 메소드를 메모이제이션된 구성요소에 전달하면 제출 메소드에 대한 참조가 상위 구성요소를 렌더링할 때마다 새로워지기 때문에 메모이제이션이 중단됩니다. 이 문제를 해결하려면 useCallback 후크를 사용할 수 있습니다. 그러나 제가 강조하고 싶은 주요 요점은 구현한 메모 기능을 깨뜨리고 싶지 않다면 맹목적으로 사용자 정의 후크의 데이터를 사용하여 메모 기능을 갖춘 구성 요소에 전달해서는 안 된다는 것입니다.
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = useCallback(() => {}, []) return { submit } }
모든 접근 방식과 마찬가지로 전체 메모는 신중하게 사용해야 하며, 노골적으로 과도한 메모를 피하도록 노력해야 합니다. 다음 예를 고려해 보겠습니다.
export function App() { const [state, setState] = useState('') const handleChange = (e) => { setState(e.target.value) } return ( <Form> <Input value={state} onChange={handleChange}/> </Form> ) } export const Input = memo((props) => (<input {...props} />))
이 예에서는 현재 형식으로 handlerChange를 전달하면 handlerChange에 대한 참조가 항상 새 것이므로 입력 구성 요소의 메모화가 중단되기 때문에 useCallback에서 handlerChange 메서드를 래핑하고 싶은 유혹이 있습니다. 그러나 상태가 변경되면 입력 구성 요소는 value prop에서 새 값이 전달되기 때문에 어쨌든 다시 렌더링됩니다. 따라서 useCallback에서 handlerChange를 래핑하지 않았다는 사실이 입력 구성 요소가 지속적으로 다시 렌더링되는 것을 막지는 않습니다. 이 경우 useCallback을 사용하는 것은 과도합니다. 다음으로, 코드 리뷰 중에 본 실제 코드의 몇 가지 예를 제공하고 싶습니다.
const activeQuestionNumber = useMemo(() => { return activeQuestionIndex + 1 }, [activeQuestionIndex]) const userAnswerImage = useMemo(() => { return `/i/call/quiz/${quizQuestionAnswer.userAnswer}.png` }, [quizQuestionAnswer.userAnswer])
두 개의 숫자를 더하거나 문자열을 연결하는 작업이 얼마나 간단한지, 그리고 이러한 예제에서 기본 요소를 출력으로 얻는다는 점을 고려하면 여기에서 useMemo를 사용하는 것이 의미가 없다는 것이 분명합니다. 비슷한 예가 여기에 있습니다.
const cta = useMemo(() => { return activeOverlayName === 'photos' ? 'gallery' : 'profile' }, [activeOverlayName]) const attendeeId = useMemo(() => { return userId === senderId ? recipientId : senderId }, [userId, recipientId, senderId])
useMemo의 종속성에 따라 다른 결과를 얻을 수 있지만 역시 기본 요소이며 내부에 복잡한 계산이 없습니다. 구성 요소의 각 렌더링에서 이러한 조건을 실행하는 것은 useMemo를 사용하는 것보다 저렴합니다.
애플리케이션 코드의 성능을 측정하기 위해 이러한 도구 중 최소 4개를 권장할 수 있습니다.
반응.프로파일러
와 함께
반응 개발자 도구
반응 렌더 추적기
또 다른 흥미로운 도구는
Storybook-addon-performance 애드온이 포함된 스토리북.
또한 Storybook에서는 다음과 같은 흥미로운 플러그인을 설치할 수 있습니다.
** 작성: Social Discovery Group의 수석 소프트웨어 엔지니어인 Sergey Levkovich