useEffect는 어떻게 동작할까

DongHyun Park·2024년 10월 12일
1

React

목록 보기
5/6
post-thumbnail

목차

  1. useEffect의 내부 구현 살펴보기
  2. useEffect의 실행 순서와 타이밍
  3. 의존성 배열의 최적화
  4. 클린업 함수의 실행 메커니즘
  5. 고급 최적화 기법
  6. useEffect의 고급 활용 시나리오
  7. React 18과 useEffect
  8. 최신 트렌드와 best practices
  9. 결론

useEffect의 내부 구현 분석

useEffect의 실제 동작 방식을 React 소스 코드를 통해 살펴보겠습니다. 이를 통해 useEffect가 어떻게 작동하는지, 그리고 React의 내부 메커니즘과 어떻게 연결되는지 이해할 수 있습니다.

1. useEffect의 진입점

먼저 packages/react/src/ReactHooks.js 파일에서 useEffect를 볼 수 있습니다:

export function useEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  const dispatcher = resolveDispatcher();
  return dispatcher.useEffect(create, deps);
}

이 코드는 useEffect를 정의합니다. 여기서 주목할 점은:

  1. create 함수: 이펙트를 실행할 함수입니다. 이 함수는 선택적으로 클린업 함수를 반환할 수 있습니다.
  2. deps 배열: 의존성 배열로, 이 값들이 변경될 때만 이펙트가 재실행됩니다.
  3. resolveDispatcher(): 현재 React의 렌더링 단계에 따라 적절한 디스패처를 반환합니다.

dispatcher.useEffect는 실제 구현을 가리키는데, 이는 렌더링 단계(마운트 또는 업데이트)에 따라 다른 함수를 호출합니다.

2. mountEffect와 updateEffect

packages/react-reconciler/src/ReactFiberHooks.js 파일에서 useEffect의 실제 구현을 볼 수 있습니다:

function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return mountEffectImpl(
    UpdateEffect | PassiveEffect,
    HookPassive,
    create,
    deps
  );
}

function updateEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return updateEffectImpl(
    UpdateEffect | PassiveEffect,
    HookPassive,
    create,
    deps
  );
}

여기서 주목할 점:

  1. mountEffect: 컴포넌트가 처음 마운트될 때 호출됩니다.
  2. updateEffect: 컴포넌트가 업데이트될 때 호출됩니다.
  3. UpdateEffect | PassiveEffect: 이펙트의 타입을 지정합니다. PassiveEffect는 useEffect가 비동기적으로 실행됨을 나타냅니다.
  4. HookPassive: 훅의 타입을 나타냅니다.

이 함수들은 mountEffectImplupdateEffectImpl을 호출하여 실제 이펙트 로직을 처리합니다.

3. 의존성 배열 비교

의존성 배열의 변화를 감지하는 areHookInputsEqual 함수를 살펴보겠습니다:

function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
) {
  if (prevDeps === null) {
    return false;
  }
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

이 함수는:

  1. 이전 의존성 배열이 null이면 (첫 렌더링) false를 반환하여 이펙트를 실행합니다.
  2. 각 의존성 값을 Object.is 알고리즘으로 비교합니다.
  3. 모든 값이 같으면 true를 반환하여 이펙트 실행을 건너뜁니다.

이 과정을 통해 불필요한 이펙트 실행을 방지합니다.

4. 이펙트 실행과 클린업

이펙트의 실행과 클린업은 commitHookEffectListMountcommitHookEffectListUnmount 함수에서 처리됩니다:

function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) {
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & flags) === flags) {
        // Mount
        const create = effect.create;
        effect.destroy = create();
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

function commitHookEffectListUnmount(
  flags: HookFlags,
  finishedWork: Fiber,
  nearestMountedAncestor: Fiber | null,
) {
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & flags) === flags) {
        // Unmount
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {
          safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

이 함수들은:

  1. 컴포넌트의 이펙트 목록을 순회합니다.
  2. commitHookEffectListMount는 이펙트 함수를 실행하고, 반환된 클린업 함수를 저장합니다.
  3. commitHookEffectListUnmount는 저장된 클린업 함수를 실행합니다.

이러한 메커니즘을 통해 React는 useEffect의 생명주기를 관리하고, 컴포넌트의 마운트, 업데이트, 언마운트 시점에 적절한 동작을 수행합니다.

고급 최적화 기법

useEffect를 효과적으로 사용하기 위해서는 다음과 같은 고급 최적화 기법을 고려해야 합니다. 각 기법에 대해 상세히 살펴보겠습니다.

1. 불변성 유지

객체나 배열을 의존성 배열에 포함시킬 때는 불변성을 유지하여 불필요한 재렌더링을 방지해야 합니다.

잘못된 예:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(userData => {
      setUser(userData);
    });
  }, [userId]);

  useEffect(() => {
    // 이 효과는 user 객체가 변경될 때마다 실행됩니다
    console.log('User updated:', user);
  }, [user]); // user는 객체이므로 매번 새로운 참조가 생성됩니다

  // ...
}

개선된 예:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(userData => {
      setUser(userData);
    });
  }, [userId]);

  useEffect(() => {
    // 이 효과는 user의 특정 속성이 변경될 때만 실행됩니다
    console.log('User name updated:', user?.name);
  }, [user?.name]); // user.name이 변경될 때만 효과가 실행됩니다

  // ...
}

이 방식은 user 객체 전체가 아닌 필요한 속성만 의존성 배열에 포함시켜 불필요한 효과 실행을 방지합니다.

2. 함수의 메모이제이션

useCallback을 사용하여 함수를 메모이제이션하면 불필요한 효과 재실행을 줄일 수 있습니다.

function SearchComponent({ onSearch }) {
  const [query, setQuery] = useState('');

  // onSearch가 변경될 때마다 이 효과가 재실행됩니다
  useEffect(() => {
    const delayedSearch = setTimeout(() => {
      onSearch(query);
    }, 500);

    return () => clearTimeout(delayedSearch);
  }, [query, onSearch]); // onSearch가 부모 컴포넌트에서 매번 새로 생성된다면 문제가 됩니다

  // ...
}

// 부모 컴포넌트
function ParentComponent() {
  // 이 함수는 매 렌더링마다 새로 생성됩니다
  const handleSearch = (query) => {
    // 검색 로직
  };

  return <SearchComponent onSearch={handleSearch} />;
}

개선된 버전:

function SearchComponent({ onSearch }) {
  const [query, setQuery] = useState('');

  const debouncedSearch = useCallback(
    (q) => {
      const delayedSearch = setTimeout(() => {
        onSearch(q);
      }, 500);
      return () => clearTimeout(delayedSearch);
    },
    [onSearch]
  );

  useEffect(() => {
    return debouncedSearch(query);
  }, [query, debouncedSearch]);

  // ...
}

// 부모 컴포넌트
function ParentComponent() {
  const handleSearch = useCallback((query) => {
    // 검색 로직
  }, []); // 의존성이 없으므로 한 번만 생성됩니다

  return <SearchComponent onSearch={handleSearch} />;
}

이 방식은 handleSearch 함수를 메모이제이션하여 SearchComponent의 불필요한 리렌더링과 효과 재실행을 방지합니다.

3. 비동기 작업 최적화

비동기 작업을 다룰 때는 레이스 컨디션을 방지하고, 필요하다면 작업을 취소할 수 있어야 합니다.

function UserData({ userId }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    let isCancelled = false;

    async function fetchData() {
      try {
        const response = await fetch(`https://api.example.com/user/${userId}`);
        const result = await response.json();
        if (!isCancelled) {
          setData(result);
        }
      } catch (error) {
        if (!isCancelled) {
          console.error('Failed to fetch user data:', error);
        }
      }
    }

    fetchData();

    return () => {
      isCancelled = true;
    };
  }, [userId]);

  // ...
}

이 패턴은 컴포넌트가 언마운트되거나 userId가 변경되어 새로운 요청이 시작될 때 이전 요청의 결과를 무시합니다. 이를 통해 레이스 컨디션을 방지하고 메모리 누수를 막을 수 있습니다.

4. 디바운싱과 스로틀링

빈번한 상태 업데이트나 API 호출을 최적화하기 위해 디바운싱이나 스로틀링 기법을 활용할 수 있습니다.

디바운싱 예시:

import { debounce } from 'lodash';

function SearchInput() {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState([]);

  const debouncedSearch = useCallback(
    debounce(async (term) => {
      const response = await fetch(`https://api.example.com/search?q=${term}`);
      const data = await response.json();
      setResults(data);
    }, 300),
    []
  );

  useEffect(() => {
    debouncedSearch(searchTerm);
    return () => debouncedSearch.cancel();
  }, [searchTerm, debouncedSearch]);

  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />
      {/* 결과 표시 */}
    </div>
  );
}

이 예시에서는 lodashdebounce 함수를 사용하여 검색 요청을 최적화합니다. 사용자가 입력을 멈춘 후 300ms가 지나면 검색이 실행됩니다.

스로틀링 예시:

import { throttle } from 'lodash';

function ScrollTracker() {
  const [scrollPosition, setScrollPosition] = useState(0);

  const handleScroll = useCallback(
    throttle(() => {
      const position = window.pageYOffset;
      setScrollPosition(position);
    }, 100),
    []
  );

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => {
      window.removeEventListener('scroll', handleScroll);
      handleScroll.cancel();
    };
  }, [handleScroll]);

  return <div>Scroll position: {scrollPosition}</div>;
}

이 예시에서는 스크롤 이벤트 처리를 100ms마다 한 번씩만 실행하도록 스로틀링합니다. 이를 통해 스크롤 위치 업데이트의 빈도를 제한하여 성능을 개선할 수 있습니다.

이러한 최적화 기법들을 적절히 활용하면 useEffect의 성능을 크게 향상시킬 수 있습니다. 하지만 각 기법의 장단점을 이해하고, 실제 애플리케이션의 요구사항에 맞게 적용하는 것이 중요합니다. 또한, 과도한 최적화는 코드의 복잡성을 증가시킬 수 있으므로, 실제 성능 문제가 있는 경우에만 이러한 기법을 적용하는 것이 좋습니다.

useEffect의 고급 활용 시나리오

useEffect는 다양한 복잡한 시나리오에서 활용될 수 있습니다. 여기서는 세 가지 주요 시나리오를 자세히 살펴보겠습니다.

1. 복잡한 상태 동기화

여러 상태를 동기화해야 하는 경우, useEffect를 활용하여 상태 간의 일관성을 유지할 수 있습니다. 이는 특히 하나의 상태 변경이 다른 상태에 영향을 미치는 경우에 유용합니다.

예시: 장바구니 시스템

장바구니의 아이템 목록과 총 가격을 동기화하는 시나리오를 생각해봅시다.

function ShoppingCart() {
  const [items, setItems] = useState([]);
  const [totalPrice, setTotalPrice] = useState(0);
  const [discount, setDiscount] = useState(0);

  // 아이템 목록이 변경될 때마다 총 가격 업데이트
  useEffect(() => {
    const newTotalPrice = items.reduce((sum, item) => sum + item.price, 0);
    setTotalPrice(newTotalPrice);
  }, [items]);

  // 총 가격이 변경될 때마다 할인 적용
  useEffect(() => {
    if (totalPrice > 100) {
      setDiscount(totalPrice * 0.1); // 10% 할인
    } else {
      setDiscount(0);
    }
  }, [totalPrice]);

  const addItem = (newItem) => {
    setItems(prevItems => [...prevItems, newItem]);
  };

  return (
    <div>
      {/* 장바구니 UI 렌더링 */}
      <p>Total Price: ${totalPrice}</p>
      <p>Discount: ${discount}</p>
    </div>
  );
}

이 예시에서는 두 개의 useEffect를 사용하여 상태 간의 복잡한 관계를 관리합니다:
1. 첫 번째 useEffect는 아이템 목록이 변경될 때마다 총 가격을 재계산합니다.
2. 두 번째 useEffect는 총 가격이 변경될 때마다 할인을 적용합니다.

이러한 접근 방식의 장점은 각 상태 변경의 로직을 분리하여 관리할 수 있다는 것입니다. 하지만 주의할 점은 순환 종속성을 만들지 않도록 해야 한다는 것입니다.

2. 네트워크 요청 취소

React 18에서는 새로운 동시성 기능과 함께 네트워크 요청을 다루는 새로운 패턴이 도입되었습니다. 이 패턴은 레이스 컨디션을 방지하고 불필요한 상태 업데이트를 막아줍니다.

예시: 사용자 프로필 데이터 가져오기

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;

    async function fetchUserData() {
      setLoading(true);
      setError(null);
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        const userData = await response.json();
        if (!ignore) {
          setUser(userData);
        }
      } catch (err) {
        if (!ignore) {
          setError(err);
        }
      } finally {
        if (!ignore) {
          setLoading(false);
        }
      }
    }

    fetchUserData();

    return () => {
      ignore = true;
    };
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return null;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

이 패턴의 주요 특징:
1. ignore 플래그를 사용하여 컴포넌트가 언마운트되거나 userId가 변경된 후의 응답을 무시합니다.
2. 클린업 함수에서 ignore를 true로 설정하여 불필요한 상태 업데이트를 방지합니다.
3. 로딩 상태와 에러 처리를 포함하여 사용자 경험을 향상시킵니다.

이 방식은 특히 빠르게 변경되는 데이터나 사용자 입력에 따라 데이터를 가져오는 경우에 유용합니다.

3. 이벤트 리스너 최적화

전역 이벤트 리스너를 다룰 때, 성능 최적화를 위해 디바운싱이나 스로틀링을 사용할 수 있습니다. 이는 이벤트 핸들러가 너무 자주 호출되는 것을 방지합니다.

예시: 윈도우 리사이즈 이벤트 처리

import { throttle } from 'lodash';

function WindowSizeTracker() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = throttle(() => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }, 200);  // 200ms마다 최대 한 번씩 실행

    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
      handleResize.cancel();  // throttle 함수의 취소
    };
  }, []);  // 빈 의존성 배열

  return (
    <div>
      <p>Window width: {windowSize.width}px</p>
      <p>Window height: {windowSize.height}px</p>
    </div>
  );
}

이 예시의 주요 특징:
1. lodashthrottle 함수를 사용하여 리사이즈 이벤트 처리의 빈도를 제한합니다.
2. 클린업 함수에서 이벤트 리스너를 제거하고 throttle 함수를 취소합니다.
3. 빈 의존성 배열을 사용하여 이펙트가 한 번만 설정되도록 합니다.

이 방식은 빈번한 이벤트(스크롤, 마우스 이동 등)를 처리할 때 특히 유용하며, 애플리케이션의 전반적인 성능을 향상시킬 수 있습니다.

각 시나리오에서 useEffect를 사용할 때는 항상 부작용을 최소화하고, 필요한 경우에만 상태를 업데이트하며, 적절한 클린업을 수행하는 것이 중요합니다. 이러한 패턴들을 잘 활용하면 복잡한 상황에서도 React 애플리케이션의 동작을 효과적으로 제어할 수 있습니다.

React 18과 useEffect

React 18은 몇 가지 중요한 변화를 도입했으며, 이는 useEffect의 동작에 상당한 영향을 미칩니다. 주요 변경사항으로는 자동 배칭(Automatic Batching)과 Strict Mode에서의 이중 호출이 있습니다.

자동 배칭(Automatic Batching)

React 18의 자동 배칭은 여러 상태 업데이트를 하나의 리렌더링으로 묶어줍니다. 이는 성능을 향상시키지만, useEffect의 실행 타이밍에 영향을 줄 수 있습니다.

예시: 자동 배칭의 영향

function Counter() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  useEffect(() => {
    console.log(`Effect ran. Count: ${count}, Flag: ${flag}`);
  }, [count, flag]);

  function handleClick() {
    setCount(c => c + 1);
    setFlag(f => !f);
    // React 18 이전: 이 함수가 호출될 때 useEffect가 두 번 실행됨
    // React 18: 이 함수가 호출될 때 useEffect는 한 번만 실행됨
  }

  return (
    <div>
      <p>Count: {count}</p>
      <p>Flag: {String(flag)}</p>
      <button onClick={handleClick}>Update</button>
    </div>
  );
}

이 예시에서:

  • React 18 이전에는 handleClick 함수가 호출될 때 setCountsetFlag가 각각 리렌더링을 트리거하여 useEffect가 두 번 실행되었습니다.
  • React 18에서는 두 상태 업데이트가 하나의 리렌더링으로 배치되어 useEffect는 한 번만 실행됩니다.

자동 배칭의 이점과 주의점

  • 이점: 불필요한 리렌더링과 효과 실행을 줄여 성능을 향상시킵니다.
  • 주의점: 상태 업데이트 사이에 부수 효과가 필요한 경우, flushSync를 사용하여 배칭을 강제로 해제할 수 있습니다.
import { flushSync } from 'react-dom';

function handleClick() {
  flushSync(() => {
    setCount(c => c + 1);
  });
  // 여기서 DOM이 업데이트됨
  flushSync(() => {
    setFlag(f => !f);
  });
  // 여기서 다시 DOM이 업데이트됨
}

하지만 flushSync의 사용은 성능에 부정적인 영향을 줄 수 있으므로 꼭 필요한 경우에만 사용해야 합니다.

Strict Mode와 이중 호출

React 18의 Strict Mode에서는 개발 중 useEffect가 두 번 호출됩니다. 이는 부작용을 찾고 컴포넌트의 회복력을 테스트하기 위한 것으로, 프로덕션 빌드에서는 발생하지 않습니다.

예시: Strict Mode에서의 useEffect 동작

function DataFetcher({ url }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    console.log('Effect ran');
    let ignore = false;

    async function fetchData() {
      const response = await fetch(url);
      const result = await response.json();
      if (!ignore) {
        setData(result);
      }
    }

    fetchData();

    return () => {
      console.log('Cleanup ran');
      ignore = true;
    };
  }, [url]);

  return data ? <div>{JSON.stringify(data)}</div> : <div>Loading...</div>;
}

Strict Mode에서 이 컴포넌트를 마운트하면 콘솔에 다음과 같이 출력됩니다:
1. 'Effect ran'
2. 'Cleanup ran'
3. 'Effect ran'

Strict Mode 이중 호출의 목적과 대응 방법

  1. 목적:

    • 불완전한 정리(cleanup)를 찾아내기 위함
    • 경쟁 상태(race conditions)를 발견하기 위함
    • 부수 효과의 멱등성을 보장하기 위함
  2. 대응 방법:

    • 정리(cleanup) 함수를 항상 구현하여 리소스 누수를 방지
    • 비동기 작업에 대한 취소 로직 구현 (위 예시의 ignore 플래그 사용)
    • 부수 효과가 여러 번 실행되어도 안전하도록 설계
  3. 주의사항:

    • 개발 모드에서만 발생하므로 프로덕션 환경과의 동작 차이 인지 필요
    • 데이터 페칭, 구독 설정 등의 작업에서 특히 주의 필요

예시: useEffect 내에서 로컬 스토리지 사용

function LocalStorageManager({ key, initialValue }) {
  const [value, setValue] = useState(() => {
    return JSON.parse(localStorage.getItem(key)) || initialValue;
  });

  useEffect(() => {
    console.log(`Saving ${key} to localStorage`);
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

이 예시에서 Strict Mode는 로컬 스토리지에 두 번 저장하는 동작을 유발할 수 있습니다. 이는 실제 문제를 일으키지는 않지만, 개발자가 부수 효과의 중복 실행에 대비해야 함을 보여줍니다.

React 18의 이러한 변화들은 애플리케이션의 안정성과 성능을 향상시키는 데 도움을 주지만, 개발자들이 useEffect를 더욱 신중하게 사용해야 함을 의미합니다. 부수 효과를 최소화하고, 필요한 경우에만 사용하며, 항상 적절한 정리(cleanup) 로직을 구현하는 것이 중요합니다.

최신 트렌드와 Best Practices

useEffect를 효과적으로 사용하기 위한 최신 트렜드와 best practices를 살펴보겠습니다. 이러한 접근 방식들은 코드의 재사용성, 유지보수성, 그리고 테스트 용이성을 크게 향상시킬 수 있습니다.

1. 커스텀 훅 추상화

복잡한 useEffect 로직은 커스텀 훅으로 추상화하는 것이 좋습니다. 이는 로직의 재사용성을 높이고, 컴포넌트의 가독성을 개선합니다.

확장된 예시: 페이지네이션이 포함된 데이터 fetching 훅

function usePaginatedData(baseUrl, pageSize = 10) {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);

  useEffect(() => {
    let ignore = false;

    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(`${baseUrl}?page=${page}&limit=${pageSize}`);
        const newData = await response.json();
        
        if (!ignore) {
          setData(prevData => [...prevData, ...newData]);
          setHasMore(newData.length === pageSize);
          setLoading(false);
        }
      } catch (error) {
        if (!ignore) {
          setError(error);
          setLoading(false);
        }
      }
    }

    fetchData();

    return () => {
      ignore = true;
    };
  }, [baseUrl, page, pageSize]);

  const loadMore = useCallback(() => {
    if (!loading && hasMore) {
      setPage(prevPage => prevPage + 1);
    }
  }, [loading, hasMore]);

  return { data, loading, error, hasMore, loadMore };
}

// 사용 예시
function PostList() {
  const { data: posts, loading, error, hasMore, loadMore } = usePaginatedData('https://api.example.com/posts', 20);

  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {posts.map(post => <PostItem key={post.id} post={post} />)}
      {loading && <div>Loading...</div>}
      {hasMore && <button onClick={loadMore} disabled={loading}>Load More</button>}
    </div>
  );
}

장점:

  • 로직 재사용: 여러 컴포넌트에서 동일한 데이터 fetching 패턴을 쉽게 재사용할 수 있습니다.
  • 관심사 분리: 데이터 fetching 로직을 UI 로직과 분리하여 코드의 구조를 개선합니다.
  • 테스트 용이성: 커스텀 훅은 독립적으로 테스트하기 쉽습니다.

주의사항:

  • 과도한 추상화를 피해야 합니다. 너무 일반적인 훅은 오히려 사용하기 어려울 수 있습니다.
  • 훅의 의존성을 신중히 관리해야 합니다. 불필요한 리렌더링을 방지하기 위해 useCallbackuseMemo를 적절히 사용하세요.

2. 상태 업데이트 로직 분리

useEffect 내에서 복잡한 상태 업데이트 로직은 useReducer로 분리하는 것이 좋습니다. 이 접근 방식은 상태 업데이트 로직을 중앙화하고 테스트하기 쉽게 만듭니다.

확장된 예시: 복잡한 폼 상태 관리

// 리듀서 정의
function formReducer(state, action) {
  switch (action.type) {
    case 'FIELD_CHANGE':
      return { ...state, [action.field]: action.value, touched: { ...state.touched, [action.field]: true } };
    case 'SET_ERRORS':
      return { ...state, errors: action.errors };
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true };
    case 'SUBMIT_SUCCESS':
      return { ...state, isSubmitting: false, submitSuccess: true };
    case 'SUBMIT_FAILURE':
      return { ...state, isSubmitting: false, submitError: action.error };
    default:
      return state;
  }
}

// 커스텀 훅
function useComplexForm(initialState, validateForm, submitForm) {
  const [state, dispatch] = useReducer(formReducer, {
    ...initialState,
    touched: {},
    errors: {},
    isSubmitting: false,
    submitSuccess: false,
    submitError: null
  });

  useEffect(() => {
    const errors = validateForm(state);
    dispatch({ type: 'SET_ERRORS', errors });
  }, [state.name, state.email, state.message]);

  const handleSubmit = async (e) => {
    e.preventDefault();
    dispatch({ type: 'SUBMIT_START' });
    try {
      await submitForm(state);
      dispatch({ type: 'SUBMIT_SUCCESS' });
    } catch (error) {
      dispatch({ type: 'SUBMIT_FAILURE', error });
    }
  };

  const handleFieldChange = (field, value) => {
    dispatch({ type: 'FIELD_CHANGE', field, value });
  };

  return { state, handleFieldChange, handleSubmit };
}

// 사용 예시
function ContactForm() {
  const { state, handleFieldChange, handleSubmit } = useComplexForm(
    { name: '', email: '', message: '' },
    validateForm,
    submitForm
  );

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={state.name}
        onChange={(e) => handleFieldChange('name', e.target.value)}
      />
      {state.touched.name && state.errors.name && <span>{state.errors.name}</span>}
      {/* 다른 필드들... */}
      <button type="submit" disabled={state.isSubmitting}>Submit</button>
      {state.submitSuccess && <p>Form submitted successfully!</p>}
      {state.submitError && <p>Error: {state.submitError.message}</p>}
    </form>
  );
}

장점:

  • 상태 로직 중앙화: 복잡한 상태 업데이트 로직을 한 곳에서 관리할 수 있습니다.
  • 가독성 향상: useEffect는 부수 효과 처리에만 집중하고, 상태 업데이트는 리듀서가 담당합니다.
  • 디버깅 용이성: 상태 변화를 추적하기 쉬워집니다.

주의사항:

  • 간단한 상태 관리에는 과도할 수 있습니다. 상태 로직이 복잡할 때만 사용하세요.
  • 리듀서 함수가 너무 커지지 않도록 주의해야 합니다. 필요하다면 여러 개의 작은 리듀서로 분리하세요.

3. 레이스 컨디션 방지

비동기 작업을 다룰 때 레이스 컨디션을 방지하는 것은 매우 중요합니다. 특히 데이터 fetching 시나리오에서 이 문제가 자주 발생합니다.

예시: 사용자 검색 기능

function useUserSearch() {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isCurrent = true;
    const controller = new AbortController();

    async function fetchUsers() {
      if (searchTerm.length < 3) {
        setResults([]);
        return;
      }

      setLoading(true);
      setError(null);

      try {
        const response = await fetch(`https://api.example.com/users?search=${searchTerm}`, {
          signal: controller.signal
        });
        const data = await response.json();
        
        if (isCurrent) {
          setResults(data);
          setLoading(false);
        }
      } catch (error) {
        if (error.name === 'AbortError') {
          console.log('Request was aborted');
        } else if (isCurrent) {
          setError(error);
          setLoading(false);
        }
      }
    }

    fetchUsers();

    return () => {
      isCurrent = false;
      controller.abort();
    };
  }, [searchTerm]);

  return { searchTerm, setSearchTerm, results, loading, error };
}

// 사용 예시
function UserSearch() {
  const { searchTerm, setSearchTerm, results, loading, error } = useUserSearch();

  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search users..."
      />
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error.message}</p>}
      <ul>
        {results.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

장점:

  • 레이스 컨디션 방지: 이전 요청의 결과가 이후 요청의 결과를 덮어쓰는 문제를 방지합니다.
  • 리소스 관리: 불필요한 네트워크 요청을 취소하여 리소스를 절약합니다.
  • 사용자 경험 개선: 항상 최신의 검색 결과를 보여줍니다.

주의사항:

  • AbortController는 모든 브라우저에서 지원되지 않을 수 있으므로, 필요에 따라 폴리필을 사용해야 합니다.
  • 너무 빈번한 API 호출을 방지하기 위해 디바운싱이나 스로틀링을 고려해야 할 수 있습니다.

이러한 best practices를 적용하면 useEffect를 사용하는 코드의 품질과 유지보수성을 크게 향상시킬 수 있습니다. 하지만 각 접근 방식의 장단점을 이해하고, 프로젝트의 요구사항에 맞게 적절히 사용하는 것이 중요합니다.

결론

useEffect는 React 애플리케이션에서 부수 효과를 관리하는 강력한 도구입니다. 이렇게 React의 내부 구현을 살펴봄으로써, useEffect가 어떻게 작동하는지 더 깊이 이해할 수 있습니다. React는 복잡한 내부 로직을 통해 개발자에게 간단한 API를 제공하면서도, 효율적인 상태 관리와 부수 효과 처리를 가능하게 합니다. 이러한 이해를 바탕으로 우리는 useEffect를 더 효과적으로 사용하고 최적화할 수 있습니다.

또한, React의 생태계는 계속해서 진화하고 있으며, 이에 따라 useEffect의 사용 방식도 변화할 것입니다. 따라서 최신 트렌드와 패턴을 지속적으로 학습하고, 실제 프로젝트에 적용해보는 것이 중요합니다.

0개의 댓글