안녕하세요. 웹 파트 YB 한수정입니다.
벌써 마지막 7주차라니... 솝트하면서 시간이 정말 빨리 지나간 것 같아요.
어젠 2차 행사 끝나고 집에 늦게 들어갔더니.... 아티클 시작하겠습니다 !!
오늘은 Effect로 동기화하기, Effect가 필요하지 않는 경우, 커스텀 Hook으로 로직 재사용하기에 대한 아티클입니다. 내용이 많다보니, Effect가 필요하지 않는 경우에 대해 자세히 알아보도록 하겠습니다.
Effect로 동기화하기
Effect
컴포넌트 내부 로직의 두 가지 유형
- 렌더링 코드
- 컴포넌트의 최상단에 위치하며, props와 state를 기반으로 결과적으로 JSX를 반환합니다.
- 순수 로직으로 작성해야 하며, 수학 공식처럼 결과를 계산하는 역할만 수행해야 합니다.
- 이벤트 핸들러
- 컴포넌트 내부에 중첩 함수로 작성되며, 사용자의 특정 작업(버튼 클릭, 입력 등)에 따라 동작합니다.
- 입력 필드 업데이트, HTTP 요청 전송, 화면 전환 등 부수 효과를 포함합니다.
- 사용자 동작에 따라 직접적으로 실행됩니다.
Effect와 이벤트의 차이점
구분 | Effect | 이벤트 핸들러 |
발생시점 | 렌더링에 의해 발생 | 사용자 동작에 의해 발생 |
역할 | 외부 시스템과 동기화 (서버 연결, 브라우저 타이틀 변경 등) |
사용자 작업에 따른 로직 수행 (상태 업데이트, 네트워크 요청, 페이지 이동 등) |
실행시점 | 렌더링이 완료된 후(커밋 후) 실행 | 사용자 작업 시 즉시 실행 |
Effect 사용 시나리오
- 네트워크 요청: 데이터를 가져오기(fetch) 또는 서버에 전송
- 브라우저 API 사용: 문서 타이틀 변경, 이벤트 리스너 등록
- 써드파티 라이브러리 초기화: 외부 위젯이나 플러그인 동작 설정
- 서버 연결: 컴포넌트 표시 시 채팅 서버 연결
Effect의 동작 방식
- 렌더링 후 커밋 완료 시점에 실행합니다.
- 클린업(cleanup) 함수 제공: 컴포넌트가 언마운트되거나 업데이트될 때 리소스 정리(이벤트 리스너 제거, 서버 연결 해제 등)를 수행합니다.
useEffect(() => {
// Effect 실행 예시: 채팅 서버 연결
const connection = connectToChatServer();
// 클린업: 연결 해제
return () => {
connection.disconnect();
};
}, []); // 빈 배열: 마운트/언마운트 시만 실행
Effect가 필요하지 않은 경우
"컴포넌트에 Effect를 무작정 추가하지 마세요. "
Effect는 주로 React 외부 시스템과 동기화할 때 필요합니다. 만약 특정 상태가 다른 상태에 기반하여 조정되는 경우에는 Effect 없이도 상태 관리만으로 처리할 수 있습니다.
Effect가 필요하지 않는 경우
불필요한 Effect를 제거하는 방법
React에서 Effect는 외부 시스템과의 동기화를 주로 다룹니다. 그러나 잘못된 상황에서 사용하면 비효율적일 수 있습니다. 다음은 불필요한 Effect를 제거하고 효율적으로 코드를 작성하는 방법입니다.
Effect가 필요하지 않은 두 가지 경우
1. 렌더링을 위해 데이터를 변환하는 경우
-
- 데이터를 필터링하거나 변환하는 작업은 Effect가 필요하지 않습니다.
- 데이터를 컴포넌트의 최상위 레벨에서 계산하면, props나 state가 변경될 때 자동으로 재계산됩니다.
🛑 잘못된 방식: Effect를 사용하여 state 업데이트
function TodoList({ todos, filter }) {
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
}
✅ 올바른 방식: 렌더링 중 계산
function TodoList({ todos, filter }) {
const visibleTodos = getFilteredTodos(todos, filter);
}
2. 사용자 이벤트를 처리하는 경우
- Effect는 사용자 이벤트를 처리하기에 적합하지 않습니다.
- 이벤트 핸들러를 사용하면 사용자가 어떤 작업(버튼 클릭 등)을 했는지 바로 알 수 있어 효율적입니다.
🛑 잘못된 방식: Effect에서 이벤트 처리
function BuyButton() {
const [isPurchased, setIsPurchased] = useState(false);
useEffect(() => {
if (isPurchased) {
fetch('/api/buy', { method: 'POST' });
}
}, [isPurchased]);
}
✅ 올바른 방식: 이벤트 핸들러 사용
function BuyButton() {
const handleBuy = () => {
fetch('/api/buy', { method: 'POST' });
};
}
Effect가 반드시 필요한 경우
- 외부 시스템과 동기화
- 예: 브라우저 API 사용, 서드파티 위젯과 동기화, 네트워크 요청 처리.
- 이 경우 Effect를 적절히 사용하세요.
- 데이터 가져오기
- 예: 검색 결과를 쿼리에 따라 가져오는 작업.
- 하지만 현대 프레임워크에서는 내장된 데이터 가져오기 메커니즘이 더 효율적입니다.
구체적인 사례와 최적화 방법
1. props나 state에 따라 state를 업데이트하려는 경우
- 기존 state에서 계산할 수 있다면, state를 중복 정의하지 마세요.
- 렌더링 중 계산하면 불필요한 렌더링을 피할 수 있습니다.
🛑 잘못된 방식: 중복된 state와 Effect 사용
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
}
✅ 올바른 방식: 렌더링 중 계산
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const fullName = `${firstName} ${lastName}`;
}
2. 비용이 많이 드는 계산 캐싱하기
- 복잡한 계산(getFilteredTodos() 등)이 포함된 경우, **useMemo**를 사용하여 캐싱하세요.
- useMemo는 의존성(todos, filter)이 변경될 때만 내부 함수를 다시 실행합니다.
🛑 잘못된 방식: 매 렌더링마다 계산
function TodoList({ todos, filter }) {
const visibleTodos = getFilteredTodos(todos, filter);
}
✅ 올바른 방식: useMemo로 최적화
import { useMemo } from 'react';
function TodoList({ todos, filter }) {
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
}
비용이 많이 드는 계산? 계산이 비싸다?
DEEP DIVE
계산이 비싼지 어떻게 알 수 있나요?
성능 측정
- console.time과 console.timeEnd를 사용해 코드 실행 시간을 측정합니다.
console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');
- 측정값이 1ms 이상이면 메모이제이션(useMemo)을 고려합니다.
useMemo로 최적화 테스트
- 기존 계산을 useMemo로 감싸고, 로깅 시간 감소 여부를 확인합니다.
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
CPU 스로틀링
- 개발자 도구에서 CPU 스로틀링을 활성화하여 저성능 환경 테스트.
프로덕션 테스트
- 정확한 결과를 얻으려면 프로덕션 빌드로 실제 사용자 환경에서 테스트하세요.
React에서 state 관리 및 업데이트 최적화
Prop 변경 시 모든 state 초기화
🛑문제
ProfilePage 컴포넌트에서 userId가 변경될 때, comment state가 이전 값으로 유지되어 잘못된 사용자의 프로필에 댓글을 남길 위험이 있습니다.
잘못된 접근 방식
useEffect를 사용해 userId 변경 시 comment state를 초기화하는 방식은 비효율적입니다.
useEffect(() => {
setComment('');
}, [userId]);
- 포넌트가 오래된 값으로 한 번 렌더링된 뒤 재렌더링됩니다.
- 중첩된 컴포넌트의 state 초기화도 필요하면 복잡도가 증가합니다.
✅ 해결책: Key 활용
key를 사용해 React에 userId가 변경되면 새 컴포넌트로 취급하도록 지시합니다.
export default function ProfilePage({ userId }) {
return <Profile userId={userId} key={userId} />;
}
function Profile({ userId }) {
const [comment, setComment] = useState('');
// userId 변경 시 comment state가 자동으로 초기화됩니다.
}
- React는 key 변경 시 컴포넌트를 새로 생성하며 모든 state를 초기화합니다.
- 외부에서 ProfilePage만 노출하므로 구현 세부 사항을 숨길 수 있습니다.
Prop 변경 시 일부 state만 초기화
🛑문제
List 컴포넌트에서 items prop이 변경될 때, selection state를 초기화하려고 할 때 발생할 수 있는 비효율.
useEffect(() => {
setSelection(null);
}, [items]);
- 기존 값으로 한 번 렌더링한 뒤 업데이트되어, 불필요한 DOM 업데이트가 발생합니다.
더 나은 접근 방식: 렌더링 중 state 조정
렌더링 도중 이전 items와 비교하여 state를 조정합니다.
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
}
- React는 state 업데이트 후 바로 컴포넌트를 다시 렌더링합니다.
- DOM 업데이트 이전에 값을 조정하므로 효율적입니다.
✅ 해결책: 렌더링 중 모든 값을 계산
선택된 항목을 직접 저장하는 대신, ID를 저장하고 렌더링 중 계산합니다.
function List({ items }) {
const [selectedId, setSelectedId] = useState(null);
const selection = items.find(item => item.id === selectedId) ?? null;
}
- 불필요한 state 조정 없이 항상 정확한 값을 계산합니다.
이벤트 핸들러 간 로직 공유
🛑문제
Effect에서 이벤트별 로직을 처리하면 잘못된 동작이 발생할 수 있습니다.
function ProductPage({ product, addToCart }) {
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the cart!`);
}
}, [product]);
}
- 페이지를 새로 고칠 때도 알림이 반복적으로 표시될 수 있습니다.
✅ 해결책: 이벤트 핸들러에서 로직 공유
공통 로직을 별도 함수로 분리하고 각 이벤트 핸들러에서 호출합니다.
function ProductPage({ product, addToCart }) {
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
}
- 이벤트에 따라 필요한 로직만 실행되며, 불필요한 Effect가 제거됩니다.
- 유지 보수와 디버깅이 쉬워집니다.
POST 요청의 최적화
Form 컴포넌트는 두 가지 종류의 POST 요청을 보냅니다
- 컴포넌트가 마운트될 때: analytics 이벤트를 보내는 요청
- 사용자가 폼을 제출할 때: /api/register 엔드포인트로 데이터를 보내는 요청
🛑문제: Effect 내부의 이벤트별 로직
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
✅ 해결책: 이벤트 핸들러에서 POST 요청
analytics 이벤트는 컴포넌트가 표시되었을 때 발생해야 하므로 Effect에 두는 것이 적절합니다. 반면, /api/register 요청은 사용자가 버튼을 클릭하는 특정 시점에 발생해야 하므로, 이를 이벤트 핸들러로 이동해야 합니다.
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ 컴포넌트가 표시되었을 때 이벤트 전송
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
function handleSubmit(e) {
e.preventDefault();
// ✅ 폼 제출 시에만 POST 요청
post('/api/register', { firstName, lastName });
}
}
연쇄 계산 문제 해결
상태에 따라 다른 상태를 업데이트하는 Effect 체인을 사용할 때, 두 가지 문제가 발생할 수 있습니다.
- 비효율적인 렌더링: 상태 업데이트가 연쇄적으로 일어나면, 불필요한 렌더링이 여러 번 발생합니다. 예를 들어, 상태가 업데이트될 때마다 컴포넌트가 다시 렌더링됩니다.
- 융통성 부족: 상태 업데이트가 서로 연결되면, 코드가 커질수록 수정하기 어려워지고, 불필요한 Effect 체인이 트리거될 수 있습니다.
✅ 해결책
- 렌더링 중에 계산: 상태 업데이트 없이 렌더링 중에 값을 계산합니다. 예를 들어, 게임 종료 여부는 round > 5로 계산할 수 있습니다.
- 이벤트 핸들러에서 상태 업데이트: 상태를 한 번에 계산하고 업데이트합니다. 여러 상태를 하나의 이벤트 핸들러에서 처리하여 Effect 체인을 피할 수 있습니다.
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) throw Error('Game already ended.');
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) alert('Good game!');
}
}
}
}
장점
- 효율성: 한 번에 상태를 업데이트하므로 불필요한 렌더링이 없습니다.
- 유연성: 코드가 더 간단하고, 수정이 용이합니다.
주의
- 상태는 스냅샷처럼 동작하므로 최신 값을 사용하려면 계산을 미리 해야 합니다.
애플리케이션 초기화 최적화
🛑문제: useEffect가 개발 환경에서 두 번 실행될 수 있음
useEffect는 기본적으로 컴포넌트가 렌더링될 때마다 실행됩니다. 개발 환경에서는 "Strict Mode"가 활성화되어 있어, 컴포넌트가 두 번 마운트되거나 두 번 실행되는 상황이 발생할 수 있습니다. 이로 인해 인증 토큰이 무효화되거나 다른 의도치 않은 문제가 생길 수 있습니다.
✅해결책: useEffect 내부에서 한 번만 실행되도록 추적
최상위 변수(didInit)를 사용하여 한 번만 실행되도록 만들 수 있습니다.
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ 앱 로드당 한 번만 실행
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
다른 방법: 최상위 레벨에서 실행
모듈 초기화가 필요할 때는 컴포넌트가 렌더링되기 전에 최상위 레벨에서 실행할 수도 있습니다. 이 방법은 브라우저에서만 실행되도록 조건을 추가하여 사용합니다.
if (typeof window !== 'undefined') { // 브라우저에서 실행 중인지 확인
// ✅ 앱 로드당 한 번만 실행
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
최상위 컴포넌트를 임포트할 때, 최상위 레벨 코드가 한 번만 실행되도록 할 수 있지만, 이 방법을 과도하게 사용하면 앱 성능에 영향을 미칠 수 있습니다. 전체 초기화 로직은 주로 App.js와 같은 루트 컴포넌트 모듈이나 엔트리 포인트에 두는 것이 좋습니다.
상태를 부모 컴포넌트에게 알리기
Toggle 컴포넌트와 같은 경우, 부모와 자식 간의 상태 변경을 관리하는 방법이 중요합니다. onChange와 같은 콜백을 사용하여 부모 컴포넌트에 상태 변경을 알리고, 이를 통해 불필요한 렌더링을 최소화할 수 있습니다. 이를 통해 상태 변경을 부모가 제어하도록 할 수 있으며, "상태 끌어올리기" 기법을 사용하면 상태 동기화 및 로직 복잡도를 줄이는 데 도움이 됩니다.
function ParentComponent() {
const [toggleState, setToggleState] = useState(false);
const handleToggleChange = (newState) => {
setToggleState(newState);
};
return (
<Toggle checked={toggleState} onChange={handleToggleChange} />
);
}
function Toggle({ checked, onChange }) {
return (
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
);
}
외부 저장소 구독하기
React 컴포넌트가 외부 데이터 저장소를 구독해야 할 때가 있습니다. 이 데이터는 React의 상태와 별개로 변경될 수 있으므로, React가 직접 알지 못하는 외부 데이터의 변화를 추적하기 위해 Effect를 사용하여 수동으로 구독할 수 있습니다.
🛑 문제: Effect에서 저장소를 수동으로 구독
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}
✅ 해결책: useSyncExternalStore로 외부 저장소 구독
React에는 외부 저장소를 구독하기 위해 특별히 설계된 useSyncExternalStore Hook이 있습니다. 이를 사용하면 보다 안전하고 효율적으로 외부 데이터를 구독할 수 있습니다.
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribe, // 구독 함수
() => navigator.onLine, // 현재 값 가져오기
() => true // 서버에서의 기본 값
);
}
이 방식은 useSyncExternalStore를 사용하여 외부 저장소의 상태를 React와 안전하게 동기화합니다. 이벤트 리스너 추가 및 제거를 관리할 필요 없이, React가 상태의 변화를 감지하고 관리합니다.
React Effect의 생명주기 - 챌린징 3. 오래된 값 버그 조사하기
import { useState, useEffect } from 'react';
export default function App() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [canMove, setCanMove] = useState(true);
useEffect(() => {
function handleMove(e) {
if (canMove) {
setPosition({ x: e.clientX, y: e.clientY });
}
}
window.addEventListener('pointermove', handleMove);
return () => window.removeEventListener('pointermove', handleMove);
}, [canMove]);
return (
<>
<label>
<input type="checkbox"
checked={canMove}
onChange={e => setCanMove(e.target.checked)}
/>
The dot is allowed to move
</label>
<hr />
<div style={{
position: 'absolute',
backgroundColor: 'pink',
borderRadius: '50%',
opacity: 0.6,
transform: `translate(${position.x}px, ${position.y}px)`,
pointerEvents: 'none',
left: -20,
top: -20,
width: 40,
height: 40,
}} />
</>
);
}
- position: 마우스 포인터의 x, y 좌표를 저장하는 상태입니다. 초기값은 { x: 0, y: 0 }입니다.
- canMove: 체크박스 상태를 관리하는 상태로, true이면 마우스 포인터가 움직일 수 있고, false이면 움직이지 않습니다. 초기값은 true입니다.
- useEffect 훅을 사용하여 마우스의 움직임을 감지합니다.
- handleMove 함수는 pointermove 이벤트가 발생할 때마다 실행됩니다. canMove가 true일 때만 포인터의 위치를 position 상태에 업데이트합니다.
- useEffect의 두 번째 인자로 [canMove]를 전달하였기 때문에, canMove 상태가 변경될 때마다 pointermove 이벤트를 새로 구독하고 기존 구독을 해제합니다.
- window.addEventListener('pointermove', handleMove)는 마우스 포인터가 움직일 때마다 handleMove를 호출합니다.
- return () => window.removeEventListener('pointermove', handleMove)는 컴포넌트가 언마운트되거나 canMove 상태가 변경될 때 이벤트 리스너를 제거합니다.
- 체크박스를 클릭하여 canMove 상태를 변경할 수 있습니다.
- onChange 이벤트에서 setCanMove(e.target.checked)를 사용하여 체크박스의 상태를 canMove에 반영합니다.
끝...... 간단하게 정리한다 !!!! 하고 시작했는데 무진장 길어졌네요...
제가 정리를 잘 하는 편이 아니라 effect를 간단하게 정리하고 필요하지 않은 경우에 대해 길게 쓰고 이렇게 끝내긴 아쉬워서 챌린징을 하나 더 넣어봤습니다.........
궁금한 건, 공식문서를 읽으며 effect에 정말정말 많은 내용이 있었구나 싶고 정말 많은 예제 코드를 보며 하나하나 알아가고 있는데, 다들 어디서 배워서 이렇게 잘하시는지 궁금합니다.... 프로젝트를 하며 배우시나요?!
또, effect는 무한 루프를 만들어 낼 수 있다고 하는데 혹시 그래보셨던 경험이 있는지 또는 코드를 작성할 때 무한루프를 방지 하기 위해 어떻게 노력하시는 지 궁금합니다..!
끝~🩷
'나야, 리액트 스터디' 카테고리의 다른 글
[week 7] Effect ✨ (3) | 2024.12.08 |
---|---|
[week 7] - Effect가 필요하지 않은 경우 알아보기 (2) | 2024.12.08 |
[Week7] Effect로 동기화하기 (2) | 2024.12.08 |
[week7] Effect& custom Hook (4) | 2024.12.08 |
[week7] useEffect, customHook (2) | 2024.12.08 |