리액트 최적화(1) 기본
들어가기전에 참고
렌더링이 일어나는 시점
1. 자신이 전달받은 props 가 변경될 때.
2. 자신의 state가 바뀔때
3. 부모 컴포넌트가 리렌더링될 때
4. forcUpdate 함수가 실행될 때
일반적인 업데이트 방법
- 예제1
// 예시 setCount('계산한 새로운 상태'); // 구현 setCount(count + 100);
- 예제2
import React, { useState } from 'react'; function ExampleComponent() { const [count, setCount] = useState(0); const handleIncrement = () => { setCount(count + 1); // 직접 접근하여 상태를 업데이트 }; return ( <div> <p>Count: {count}</p> <button onClick={handleIncrement}>Increment</button> </div> ); }
💡set 으로 직접 접근하고 있다.
함수형 업데이트 방법
- 예제1
// 예시 setCount(blang => { // 이전 상태 값을 기반으로 새로운 상태를 계산 return '계산한 새로운 상태' }); // 구현 setCount(blang => blang + 100);
💡 매개변수로 함수를 사용했고, 이 함수는 콜백함수이다.
💡 콜백함수의 매개변수 이름은 blang 이고, 이전 상태 값을 나타내는 매개변수다.
💡 useState 기능에서 업데이트시 콜백 함수의 매개변수로 이전 상태 값을 받아오도록 구현한 기능이다. -
예제2
import React, { useState } from 'react'; function ExampleComponent() { const [count, setCount] = useState(0); const handleIncrement = () => { setCount(prevCount => prevCount + 1); // 콜백 함수를 이용하여 상태를 업데이트 }; return ( <div> <p>Count: {count}</p> <button onClick={handleIncrement}>Increment</button> </div> ); }
setCount(prevCount => prevCount + 1)
💡setCount 함수를 호출할 때, 화살표 함수를 사용하여 최신 count 배열을 인자로 받아와서 새로운 배열을 생성했다.
💡setCount 함수를 호출할 때, 함수형업데이트(콜백 함수)를 사용하여 이전의 count 상태를 받아와서 1을 더한 값으로 상태를 업데이트 했다.
업데이트 방법 비교
const [value, setValue] = useState(0);
const onClick = () => {
setValue(value + 1);
setValue(value + 1);
setValue(value + 1);
console.log(value);
}
<button onClick={onClick} >테스트</button>
❗결과: 1
💡 비동기식으로 동작한다.
const [value, setValue] = useState(0);
const onClick = () => {
setValue(value => value + 1);
setValue(value => value + 1);
setValue(value => value + 1);
console.log(value);
}
<button onClick={onClick} >테스트</button>
❗결과: 3
💡 동기식으로 동작한다.
비동기가 좋은거 아니야?
❗기본 매커니즘을 무시하면 성능이 떨어지는게 아닌가?
💡 사실 setState를 동기적으로 사용하는 것 자체가 최적화의 목적은 아니다.
💡 진짜 목적은 컴포넌트의 useCallback 함수로부터 state의 의존성을 제거함으로써(비 의존성 useCallback)
리렌더링을 방지한다.
최적화
[ 불변성 지키기 ] 개념
-
기본적인 불변성 지키기
const onToggle = useCallback((id) => { setTodos(todos => ( todos.map((todo) => todo.id === id ? { ...todo, checked: !todo.checked } : todo, ) )); }, []);
❗불변성 지키기
💡 “불변성을 유지하면서”란, 객체나 배열의 내용이 변경되더라도 해당 객체나 배열의 메모리 주소가 변경되지 않는 것을 의미합니다. 즉, 기존의 데이터를 직접 수정하는 대신 새로운 객체나 배열을 생성하여 변경된 값을 반영하는 것을 말합니다.❗과정
💡 …todos 전개연산자로 얕은복사를 수행.
💡 기존 객체의 속성들을 그대로 복사한다. 즉, todo 와 동일한 객체를 바라본다.
💡 이렇게 얕은 복사된 새로운 객체를 사용하여 변경이 필요한 checked 속성을 반전시킨다. -
기본적인 불변성 지키지 못한경우
// 예시1) const onToggle = useCallback((id) => { setTodos(todos => { todos.map((todo) => { if (todo.id === id) { todo.checked = !todo.checked; // 불변성을 위배하는 직접 수정 } return todo; }); return todos; // 업데이트된 배열을 반환하지 않음 }); }, []);
// 예시2) const onToggle = useCallback((id) => { for (let i = 0; i < todos.length; i++) { if (todos[i].id === id) { todos[i].checked = !todos[i].checked; break; } } setTodos(todos); }, []);
❗불변성 지키기 실패
💡 todos 배열을 직접 수정하여 업데이트하고 있다.❗문제점
-
- 불변성 위반
- todos 배열을 직접 수정하면서 불변성을 위반합니다. 이전 상태의 todos 배열과 업데이트된 배열이 동일한 메모리를 공유하게 됩니다.
-
- 리렌더링 최적화 어려움
- React에서 상태가 변경되었음을 감지하려면 얕은 비교(shallow comparison)를 통해 이루어집니다. 직접 수정하는 방식으로 상태를 업데이트하면 배열의 참조가 변경되지 않기 때문에 React가 변경을 감지하지 못하고 불필요한 리렌더링이 발생할 수 있습니다.
-
- 예상치 못한 동작
- 내부 객체가 중첩되어 있을 때, 변경된 내부 객체의 참조가 원본과 복사된 배열 두 곳에 모두 반영됩니다. 이로 인해 예상치 못한 동작이 발생할 수 있습니다.
-
-
객체안에 있는 객체 불변성 키기
// 초기 상태 설정 const initialState = { user: { id: 1, name: 'John', address: { city: 'New York', zipcode: '12345', }, }, }; // 중첩 객체를 복사하고 업데이트하는 함수 정의 const updateAddress = (state, newAddress) => { return { ...state, // 가장 먼저 원본값을 뿌리고 user: { ...state.user, // 불변성을 지키면서 수정 address: newAddress, }, }; }; // 초기 상태 출력 console.log('Initial State:', initialState); // 주소 업데이트 후 상태 출력 const newAddress = { city: 'Los Angeles', zipcode: '98765', }; const updatedState = updateAddress(initialState, newAddress); console.log('Updated State:', updatedState); // 초기 상태는 변경되지 않았음을 확인 console.log('Initial State (Unchanged):', initialState);
❗객체안에 객체 불변성 지키기
💡 위 코드에서 updateAddress 함수는 기존 상태를 변경하지 않으면서 중첩된 객체 user의 address를 업데이트 한다.
💡 spread 연산자를 사용하여 중첩 객체들을 복사하고 변경된 값을 적용.
💡 불변성을 유지하면서 상태를 업데이트가 구현되었다.
[ 최적화 ] 개념
화면 렌더링이 발생하면 함수는 계속 갱신된다. 하지만 아래 내용을 구현하면 동일한 컴포넌트중에 1개의 수정이 일어나도 각각의 컴포넌트별로 렌더링이 동작 하게된다. (비용을 아낀다.)
- 우선 useCallback( 원하는동작, []) 기능으로 함수 갱신을 막는다. (재사용하니까 좋네!)
-
이제 (불변성을 지키며) 함수형 업데이트 통한 접근으로 개별적으로 useState를 핸들링한다. (재사용하는 함수내에서 최신값을 받으니까 더 좋네!)
⭐ “함수가 갱신되지 않도록 [재사용함수를 선언한 상태]에서 [함수형 업데이트]를 통해 최신 갱신된 useState 를 얻은 뒤 필터링된 새로운 배열로 리턴하는것이 핵심 ! “
⭐ “기존의 값을 직접 수정하지 않으면서 새로운 값을 만들어 내는것을 “불변성을 지킨다” 라고 한다. “
방법1. 의존성 useCallback 최적화
STEP1. 의존성 useCallback 훅 생성
// AS-IS
const onRemove = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
}
// TO-BE
const onRemove = useCallback( (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
}, [todos])
❗과정
💡 todos 상태를 의존하는 의존성 useCallback 함수를 생성.
💡 todos 배열에 변경분이 생길때만 함수가 새롭게 생성(렌더링) 된다.❗장점
💡 변경분이 없어도 렌더링이 발생하면 계속 함수가 생성되는 문제를 해결했다.
💡 사용하는 컴포넌트가 많지 않다면 이렇게 사용해도 문제없이 성능이 좋다.❗문제점
💡 이 함수를 사용하는 여러개의 동일한 컴포넌트가 존재하는 상태에서 어떤 1개의 컴포넌트가 todos를 수정하면 전체 컴포넌트가 렌더링 되어버린다. (매우 비효율적 렌더링이 발생).
방법2. 빈 의존성 useClaback & 함수형 업데이트 최적화
이 최적화 방법에서 필요한 준비물은 [빈 의존성 useCallback 함수], [함수형 업데이트 기법] 이다.
STEP1. 빈 의존성 useCallback 훅 생성
// AS-IS
const onRemove = useCallback( (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
}, [todos])
// TO-BE
const onRemove = useCallback( (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
}, []); //우선 이렇게 빈 의존성으로 변경한다.
❗과정
💡 의존하지 않는 빈 의존성 useCallback 함수를 생성.
💡 렌더링 시점만 todos를 초기 값으로 가지는 새로운 함수가 생성 된다.
💡 렌더링 이후 todos가 변경되어도 최신 값으로 업데이트되지 않고 항상 최초의 메모리 (todos 배열)을 참조한다.❗정리
💡 따라서 해당 함수는 렌더링되고 나서 외부에서 todos 값이 변경되었더라도 변경된 값이 반영되지 않고 이전의 todos 값을 기준으로 동작한다.
STEP2. 함수형 업데이트 문법 적용
// AS-IS
const onRemove = useCallback( (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
}, []);
// TO-BE
const onRemove = useCallback( (id) => {
setTodos(todos => (todos.filter((todo) => todo.id !== id)));
}, []);
❗과정
💡 [함수형 업데이트]를 활용하여 이전 상태를 기반으로 새로운 상태를 업데이트
💡 useCallback 훅의 두 번째 인자로 빈 의존성 배열([])을 전달하더라도, 함수형 업데이트를 통해 항상 최신 todos 배열 값을 사용하게 된다.
💡 콜백함수기 때문에 더욱 안전함.❗정리
💡 렌더링되어도 함수는 재사용되는데 참조하는 상태는 최신을 바라보는 기법이다.
💡 최적화된 업데이트를 위해 useCallback을 함께 사용하여 불필요한 함수 재생성을 방지하는 것이 권장되는 패턴이다.장점
- 최신 데이터 사용
- 콜백 함수를 통해 항상 최신의 todos 배열을 활용하여 새로운 배열을 생성. 때문에 todos 배열이 업데이트되기 전의 예전 데이터로 업데이트하는 오류를 방지할 수 있다.
- 의존성 배열의 필요성 제거
- useCallback의 의존성 배열을 비워두었기 때문에, todos를 의존성 배열로 추가할 필요가 없다. 따라서, todos가 업데이트되더라도 onRemove 함수가 재생성되지 않아도 된다.
- 안전한 업데이트(비동기니까!)
- setTodos 콜백 함수를 통해 업데이트하므로, 최신 todos 배열을 안전하게 참조할 수 있고, 불필요한 리렌더링을 방지할 수 있다.
방법3. useReducer 최적화
STEP1. 예제1
// 1. 초기값 정의
const initialState = {
quantity: 0,
};
// 2. 액션 정의
const INCREASE = 'INCREASE';
// 3. Reducer 함수 생성
function reducer(state, action) {
switch (action.type) {
case INCREASE:
return { ...state, quantity: state.quantity + 1 };
default:
return state;
}
}
// 4. useReducer 훅 사용
function Product() {
const [state, dispatch] = useReducer(reducer, initialState);
const { quantity } = state;
const handleIncrease = () => {
dispatch({ type: INCREASE });
};
return (
<div>
<p>Quantity: {quantity}</p>
<button onClick={handleIncrease}>Increase</button>
</div>
);
}
STEP2. 예제2
import { useReducer } from 'react';
// 2. 액션 정의
// 3. Reducer 함수 생성
function todosReducer(state, action) {
switch (action.type) {
case 'INSERT':
// { type: 'INSERT', todo: { id: i, text: '입력값', checked: false } }
return todos.concat(action.todo);
case 'REMOVE':
// { type: 'REMOVE', id: 1 }
return state.filter((todo) => todo.id !== action.id);
case 'TOGGLE':
// { type: 'REMOVE', id: 1 }
return state.map((todo) =>
todo.id === action.id ? { ...todo, checked: !todo.checked } : todo
);
default:
return state;
}
}
function ScheduleBoard() {
// 1. 초기값 정의
// 4. useReducer 훅 사용
const [todos, dispatch] = useReducer(todoReducer, undefined , createBulkTodos);
const nextId = useRef(todos.length);
const onInsert = useCallback( (text) => {
const todo = {
id: nextId.current += 1
, text: text
, checked: false
};
dispatch({ type: 'INSERT', todo });
}, []);
const onRemove = useCallback((id) => {
dispatch({ type: 'REMOVE', id });
}, []);
const onToggle = useCallback((id) => {
dispatch({ type: 'TOGGLE', id });
}, []);
// 나머지 컴포넌트 로직과 JSX 반환
}
useReducer 패턴으로 작성했다.
❗초기 렌더링될 때만 함수를 호출
const [todos, dispatch] = useReducer(todoReducer, undefined, createBulkTodos);
💡 원래 2번째 인자에 초기값을 넣었는데 undefined 을 넣었다. 이는 only 초기렌더링을 의도한것이다.
💡 [함수형 업데이트를 활용한 상태 업데이트] 의 비의존성 useCallback 함수 기능을 구현한것이다.❗정리
💡 useReducer를 사용하여 상태 관리하면 복잡한 상태 로직을 더 직관적이고 효율적으로 작성할 수 있다.
💡 useState 대신 useReducer를 사용하는 것은 상태가 복잡해지거나 상태 변경 로직이 복잡해질 때 더 유용하다.
정리
스텝별로 최적화 과정
❗과정
💡STEP1. 반복적인 [컴포넌트 리랜더링]을 방지한다.
- 부모컴포넌트에서 호출하는 자식컴포넌트 반복 생성을 방지한다. (TodoList 컴포넌트 반복 생성을 방지)
- React.memo 함수로 방지
// 부모컴포넌트 (반복적인 자식컴포넌트 호출) return ( <TodoTemplate> <TodoList todos={todos} onRemove={onRemove} onToggle={onToggle} /> </TodoTemplate> )
// 자식컴포넌트 (React.memo 기능으로 불필요한 컴포넌트 랜더링을 방지) const TodoList = ({ todos, onRemove, onToggle }) => { retrun (/* 결과 리턴 */) } // export default React.memo(TodoList);
💡STEP2. [반복적인 컴포넌트에서 사용하는 props 리랜더링] 을 방지한다.
- 부모컴포넌트에서 자식컴포넌트를 호출할때 사용하는 props 를 모두 최적화 한다. (todos, onRemove, onToggle 프롭스의 불필요한 리랜더링을 방지)
- todos 배열이 업데이트되면 함수들도 새롭게 업데이트 되기 때문이다.
- 빈 의존성 useCallback 함수 & 함수형업데이트 문법으로 방지
// 부모컴포넌트 const onRemove = useCallback( (id) => { setTodos(todos => (todos.filter((todo) => todo.id !== id))); }, []); // onInsert, onToggle 모두 동일하게 처리하면 된다. // 불필요한 부모컴포넌트의 리랜더링이 발생하지 않아서 // 자식컴포넌트는 자신의 컴포넌트 최적화만 신경쓰면 된다.
최종 결과
💡v1 속도가 상대적으로 매우 느리다는것을 알 수 있다.
💡v2 와 v3 는 동일한 수준의 최적화를 보인다.
💡맨 마지막 최적화는 다음 포스팅에서 !
Leave a comment