life-cycle

React 생명주기의 진화: 클래스에서 훅까지

들어가며

React는 2013년 Facebook에서 공개된 이후 지속적으로 진화해왔습니다. 그 중에서도 가장 혁신적인 변화 중 하나는 React 16.8에서 도입된 훅(Hooks)입니다. 이 글에서는 React의 생명주기 관리가 어떻게 발전해왔는지, 그리고 현대적인 함수형 컴포넌트에서 생명주기를 어떻게 다루는지 깊이 있게 알아보겠습니다.

React 생명주기의 역사

초기 React: 클래스 컴포넌트의 시대 (2013-2018)

React는 처음부터 컴포넌트의 생명주기를 중심으로 설계되었습니다. 초기 React에서 상태를 가진 컴포넌트를 만들려면 클래스 컴포넌트를 사용해야 했고, 이는 명확한 생명주기 메서드를 제공했습니다.

전통적인 생명주기 단계:

  1. Mounting (마운팅): 컴포넌트가 DOM에 삽입되는 과정
  2. Updating (업데이팅): props나 state가 변경되어 컴포넌트가 다시 렌더링되는 과정
  3. Unmounting (언마운팅): 컴포넌트가 DOM에서 제거되는 과정

이 시기의 개발자들은 componentDidMount, componentDidUpdate, componentWillUnmount 등의 메서드를 통해 각 단계에서 필요한 로직을 구현했습니다.

훅의 등장: 패러다임의 전환 (2018~)

React 16.8에서 훅이 도입되면서 함수형 컴포넌트도 상태와 생명주기를 다룰 수 있게 되었습니다. 이는 단순한 문법적 변화가 아닌, React의 철학적 변화였습니다.

훅이 가져온 변화:

  • 로직 재사용의 용이성
  • 관련 로직의 집중화
  • 더 작고 테스트하기 쉬운 함수들
  • 클래스의 복잡성 제거

useEffect: 모든 생명주기를 아우르는 훅

useEffect는 함수형 컴포넌트에서 생명주기를 관리하는 핵심 훅입니다. 클래스 컴포넌트의 여러 생명주기 메서드를 하나로 통합한 강력한 도구입니다.

기본 사용법

javascript
import React, { useState, useEffect } from 'react';

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

  useEffect(() => {
    // 이 부분은 componentDidMount와 componentDidUpdate에 해당
    console.log('Effect 실행됨');
    
    return () => {
      // 이 부분은 componentWillUnmount에 해당
      console.log('Cleanup 실행됨');
    };
  });

  return loading ? <div>Loading...</div> : <div>{user?.name}</div>;
}

의존성 배열(Dependency Array)의 이해

의존성 배열은 useEffect의 실행 조건을 정의하는 핵심 개념입니다.

javascript
// 1. 의존성 배열 없음: 매번 렌더링 후 실행
useEffect(() => {
  console.log('매번 실행됩니다');
});

// 2. 빈 배열: 마운트시에만 실행 (componentDidMount와 동일)
useEffect(() => {
  console.log('마운트시에만 실행됩니다');
}, []);

// 3. 특정 값들을 의존성으로: 해당 값들이 변경될 때만 실행
useEffect(() => {
  console.log('userId가 변경될 때만 실행됩니다');
}, [userId]);

// 4. 여러 의존성
useEffect(() => {
  console.log('userId나 theme이 변경될 때 실행됩니다');
}, [userId, theme]);

실전 생명주기 관리 패턴

1. 데이터 페칭 (Data Fetching)

javascript
function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isCancelled = false; // cleanup을 위한 플래그

    async function fetchUsers() {
      try {
        setLoading(true);
        const response = await fetch('/api/users');
        const userData = await response.json();
        
        // 컴포넌트가 언마운트된 경우 상태 업데이트 방지
        if (!isCancelled) {
          setUsers(userData);
          setError(null);
        }
      } catch (err) {
        if (!isCancelled) {
          setError(err.message);
        }
      } finally {
        if (!isCancelled) {
          setLoading(false);
        }
      }
    }

    fetchUsers();

    // Cleanup function: 컴포넌트 언마운트 시 실행
    return () => {
      isCancelled = true;
    };
  }, []); // 마운트시에만 실행

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{/* 사용자 목록 렌더링 */}</div>;
}

2. 이벤트 리스너 관리

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

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    // 이벤트 리스너 등록
    window.addEventListener('resize', handleResize);

    // Cleanup: 이벤트 리스너 제거
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // 마운트/언마운트시에만

  return <div>화면 크기: {windowSize.width} x {windowSize.height}</div>;
}

3. 타이머 관리

javascript
function Timer() {
  const [seconds, setSeconds] = useState(0);
  const [isActive, setIsActive] = useState(false);

  useEffect(() => {
    let interval = null;

    if (isActive) {
      interval = setInterval(() => {
        setSeconds(seconds => seconds + 1);
      }, 1000);
    }

    // Cleanup function
    return () => {
      if (interval) {
        clearInterval(interval);
      }
    };
  }, [isActive]); // isActive가 변경될 때마다 실행

  return (
    <div>
      <div>시간: {seconds}초</div>
      <button onClick={() => setIsActive(!isActive)}>
        {isActive ? '정지' : '시작'}
      </button>
    </div>
  );
}

고급 생명주기 패턴

1. 커스텀 훅으로 로직 분리

생명주기 관련 로직을 커스텀 훅으로 분리하면 재사용성과 테스트 용이성이 향상됩니다.

javascript
// 커스텀 훅: 데이터 페칭 로직 분리
function useApi(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    async function fetchData() {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch(url);
        const result = await response.json();
        
        if (!isCancelled) {
          setData(result);
        }
      } catch (err) {
        if (!isCancelled) {
          setError(err);
        }
      } finally {
        if (!isCancelled) {
          setLoading(false);
        }
      }
    }

    fetchData();

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

  return { data, loading, error };
}

// 사용법
function UserProfile({ userId }) {
  const { data: user, loading, error } = useApi(`/api/users/${userId}`);

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

2. useLayoutEffect: 동기적 실행이 필요한 경우

javascript
function ScrollToTop() {
  useLayoutEffect(() => {
    // DOM 변경 후, 브라우저가 화면을 그리기 전에 동기적으로 실행
    window.scrollTo(0, 0);
  }, []); // 컴포넌트가 마운트될 때

  return <div>페이지 상단으로 스크롤됩니다</div>;
}

3. 조건부 Effect

javascript
function ConditionalEffect({ shouldTrack, userId }) {
  useEffect(() => {
    if (!shouldTrack) return;

    // 조건이 만족될 때만 실행되는 로직
    console.log('사용자 활동 추적 시작:', userId);

    return () => {
      console.log('사용자 활동 추적 종료:', userId);
    };
  }, [shouldTrack, userId]);

  return <div>조건부 추적 컴포넌트</div>;
}

성능 최적화와 생명주기

1. 불필요한 Effect 실행 방지

javascript
function OptimizedComponent({ user, theme }) {
  const [data, setData] = useState(null);

  // 잘못된 예: 매번 실행됨
  useEffect(() => {
    fetchUserData(user.id);
  }); // 의존성 배열 없음

  // 올바른 예: user.id가 변경될 때만 실행
  useEffect(() => {
    fetchUserData(user.id);
  }, [user.id]); // user 전체가 아닌 필요한 속성만

  // theme 변경시에만 실행되는 별도 Effect
  useEffect(() => {
    applyTheme(theme);
  }, [theme]);
}

2. 복잡한 계산 최적화

javascript
function ExpensiveComponent({ items, filter }) {
  // useMemo로 비싼 계산 최적화
  const filteredItems = useMemo(() => {
    return items.filter(item => item.category === filter);
  }, [items, filter]);

  // Effect에서 최적화된 값 사용
  useEffect(() => {
    // filteredItems가 변경될 때만 실행
    updateAnalytics(filteredItems.length);
  }, [filteredItems]);

  return <ItemList items={filteredItems} />;
}

클래스 컴포넌트 vs 함수형 컴포넌트: 생명주기 비교

마이그레이션 가이드

클래스 컴포넌트의 각 생명주기 메서드를 함수형 컴포넌트로 변환하는 방법:

javascript
// 클래스 컴포넌트
class ClassComponent extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }

  componentDidMount() {
    document.title = `카운트: ${this.state.count}`;
    this.timer = setInterval(() => {
      this.setState({ count: this.state.count + 1 });
    }, 1000);
  }

  componentDidUpdate() {
    document.title = `카운트: ${this.state.count}`;
  }

  componentWillUnmount() {
    clearInterval(this.timer);
  }

  render() {
    return <div>{this.state.count}</div>;
  }
}

// 함수형 컴포넌트로 변환
function FunctionComponent() {
  const [count, setCount] = useState(0);

  // componentDidMount + componentDidUpdate
  useEffect(() => {
    document.title = `카운트: ${count}`;
  }, [count]);

  // componentDidMount + componentWillUnmount
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prevCount => prevCount + 1);
    }, 1000);

    return () => {
      clearInterval(timer);
    };
  }, []);

  return <div>{count}</div>;
}

모던 React의 생명주기 패턴

1. Concurrent Features와의 호환성

React 18의 Concurrent Features를 고려한 생명주기 관리:

javascript
function ConcurrentSafeComponent() {
  const [data, setData] = useState(null);

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

    // startTransition으로 긴급하지 않은 업데이트 처리
    startTransition(() => {
      fetchData().then(result => {
        if (!cancelled) {
          setData(result);
        }
      });
    });

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

  return <div>{data ? '데이터 로드됨' : '로딩 중...'}</div>;
}

2. Suspense와 Error Boundary 활용

javascript
function SuspenseAwareComponent() {
  const [error, setError] = useState(null);

  useEffect(() => {
    // 에러 상태 초기화
    setError(null);
  }, []);

  if (error) {
    throw error; // Error Boundary가 처리
  }

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <AsyncComponent />
    </Suspense>
  );
}

실무에서의 베스트 프랙티스

1. Effect 분리 원칙

관련없는 로직은 별도의 Effect로 분리:

javascript
function UserDashboard({ userId }) {
  const [user, setUser] = useState(null);
  const [notifications, setNotifications] = useState([]);

  // 사용자 데이터 페칭
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  // 알림 데이터 페칭 (별도 Effect)
  useEffect(() => {
    fetchNotifications(userId).then(setNotifications);
  }, [userId]);

  // 페이지 제목 업데이트 (별도 Effect)
  useEffect(() => {
    if (user) {
      document.title = `${user.name}의 대시보드`;
    }
  }, [user]);

  return <div>대시보드 내용</div>;
}

2. 메모리 누수 방지

javascript
function SafeComponent() {
  useEffect(() => {
    const controller = new AbortController();

    fetch('/api/data', { signal: controller.signal })
      .then(response => response.json())
      .then(data => {
        // 데이터 처리
      })
      .catch(error => {
        if (error.name !== 'AbortError') {
          console.error('Fetch error:', error);
        }
      });

    return () => {
      controller.abort(); // 요청 취소
    };
  }, []);
}

3. 의존성 배열 최적화

javascript
function OptimizedDependencies({ config }) {
  const { apiUrl, retryCount } = config;

  useEffect(() => {
    fetchData(apiUrl, retryCount);
  }, [apiUrl, retryCount]); // config 전체가 아닌 필요한 속성만

  // 또는 useCallback으로 함수 최적화
  const fetchDataCallback = useCallback(() => {
    fetchData(apiUrl, retryCount);
  }, [apiUrl, retryCount]);

  useEffect(() => {
    fetchDataCallback();
  }, [fetchDataCallback]);
}

마무리

React의 생명주기 관리는 클래스 컴포넌트에서 함수형 컴포넌트로 진화하면서 더욱 유연하고 강력해졌습니다. useEffect를 중심으로 한 훅 기반 접근법은 다음과 같은 장점을 제공합니다:

  • 로직의 집중화: 관련된 로직을 한 곳에 모을 수 있습니다
  • 재사용성: 커스텀 훅으로 로직을 쉽게 재사용할 수 있습니다
  • 테스트 용이성: 작은 단위로 분리된 함수들을 개별적으로 테스트할 수 있습니다
  • 성능 최적화: 세밀한 의존성 관리로 불필요한 실행을 방지할 수 있습니다

현대적인 React 개발에서는 함수형 컴포넌트와 훅을 활용한 생명주기 관리가 표준이 되었습니다. 이러한 패턴을 잘 이해하고 활용한다면, 더욱 효율적이고 유지보수하기 쉬운 React 애플리케이션을 개발할 수 있을 것입니다.