본문 바로가기

컴포넌트 생명주기와 useEffect

3. 컴포넌트 생명주기와 useEffect

React 컴포넌트는 생성되고, 업데이트되고, 제거되는 생명주기를 가집니다. useEffect Hook을 사용하면 이러한 생명주기에 맞춰 코드를 실행할 수 있습니다. 데이터 가져오기, 타이머 설정, 정리 작업 등을 학습해봅시다.

useEffect 기본 사용법

처음 한 번만 실행하기

import { useState, useEffect } from 'react';

function WelcomeMessage() {
  const [user, setUser] = useState('');

  // 컴포넌트가 처음 렌더링될 때 한 번만 실행
  useEffect(() => {
    console.log('컴포넌트가 마운트되었습니다');
    setUser('김개발');
  }, []); // 빈 배열 = 한 번만 실행

  return (
    <div>
      <h3>환영 메시지</h3>
      <p>안녕하세요, {user}님!</p>
    </div>
  );
}

상태가 변할 때마다 실행하기

import { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  const [message, setMessage] = useState('');

  // count가 변할 때마다 실행
  useEffect(() => {
    console.log(`카운트가 ${count}로 변경됨`);
    
    if (count === 0) {
      setMessage('시작!');
    } else if (count > 0 && count < 5) {
      setMessage('계속 클릭해보세요');
    } else if (count >= 5) {
      setMessage('많이 클릭했네요!');
    }
  }, [count]); // count가 변할 때마다 실행

  return (
    <div>
      <h3>카운터</h3>
      <p>카운트: {count}</p>
      <p>메시지: {message}</p>
      <button onClick={() => setCount(count + 1)}>증가</button>
      <button onClick={() => setCount(count - 1)}>감소</button>
      <button onClick={() => setCount(0)}>초기화</button>
    </div>
  );
}

매 렌더링마다 실행하기

import { useState, useEffect } from 'react';

function RenderTracker() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  // 매 렌더링마다 실행 (의존성 배열 없음)
  useEffect(() => {
    console.log('컴포넌트가 렌더링되었습니다');
    document.title = `카운트: ${count}, 이름: ${name}`;
  });

  return (
    <div>
      <h3>렌더링 추적기</h3>
      <p>카운트: {count}</p>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="이름을 입력하세요"
      />
      <button onClick={() => setCount(count + 1)}>카운트 증가</button>
      <p>브라우저 탭 제목을 확인해보세요!</p>
    </div>
  );
}

정리 함수 (Cleanup Function)

타이머 정리하기

import { useState, useEffect } from 'react';

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

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

    if (isRunning) {
      interval = setInterval(() => {
        setSeconds(prevSeconds => prevSeconds + 1);
      }, 1000);
    }

    // 정리 함수: 컴포넌트가 언마운트되거나 의존성이 변경될 때 실행
    return () => {
      if (interval) {
        clearInterval(interval);
        console.log('타이머가 정리되었습니다');
      }
    };
  }, [isRunning]);

  const startTimer = () => setIsRunning(true);
  const stopTimer = () => setIsRunning(false);
  const resetTimer = () => {
    setSeconds(0);
    setIsRunning(false);
  };

  return (
    <div>
      <h3>타이머</h3>
      <p>경과 시간: {seconds}초</p>
      <button onClick={startTimer} disabled={isRunning}>시작</button>
      <button onClick={stopTimer} disabled={!isRunning}>정지</button>
      <button onClick={resetTimer}>초기화</button>
    </div>
  );
}

이벤트 리스너 정리하기

import { useState, useEffect } from 'react';

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

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

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

    // 정리 함수: 이벤트 리스너 제거
    return () => {
      window.removeEventListener('resize', handleResize);
      console.log('이벤트 리스너가 제거되었습니다');
    };
  }, []);

  return (
    <div>
      <h3>창 크기 추적기</h3>
      <p>창 너비: {windowSize.width}px</p>
      <p>창 높이: {windowSize.height}px</p>
      <p>창 크기를 변경해보세요!</p>
    </div>
  );
}

데이터 가져오기

API 호출 시뮬레이션

import { useState, useEffect } from 'react';

// 가짜 API 함수
function fetchUserData(userId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      const users = {
        1: { id: 1, name: '김개발', email: 'kim@example.com' },
        2: { id: 2, name: '이디자인', email: 'lee@example.com' },
        3: { id: 3, name: '박데이터', email: 'park@example.com' }
      };
      resolve(users[userId] || null);
    }, 1000); // 1초 지연
  });
}

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

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetchUserData(userId)
      .then(userData => {
        if (userData) {
          setUser(userData);
        } else {
          setError('사용자를 찾을 수 없습니다');
        }
      })
      .catch(err => {
        setError('데이터를 불러오는 중 오류가 발생했습니다');
      })
      .finally(() => {
        setLoading(false);
      });
  }, [userId]);

  return (
    <div>
      <h3>사용자 프로필</h3>
      
      <div>
        <label>사용자 ID: </label>
        <select value={userId} onChange={(e) => setUserId(Number(e.target.value))}>
          <option value={1}>1</option>
          <option value={2}>2</option>
          <option value={3}>3</option>
          <option value={4}>4 (존재하지 않음)</option>
        </select>
      </div>

      {loading && <p>로딩 중...</p>}
      {error && <p>오류: {error}</p>}
      {user && (
        <div>
          <p>이름: {user.name}</p>
          <p>이메일: {user.email}</p>
        </div>
      )}
    </div>
  );
}

실시간 데이터 업데이트

import { useState, useEffect } from 'react';

function LiveClock() {
  const [currentTime, setCurrentTime] = useState(new Date());

  useEffect(() => {
    const timer = setInterval(() => {
      setCurrentTime(new Date());
    }, 1000);

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

  return (
    <div>
      <h3>실시간 시계</h3>
      <p>현재 시간: {currentTime.toLocaleTimeString()}</p>
    </div>
  );
}

function RandomNumberGenerator() {
  const [number, setNumber] = useState(0);
  const [isGenerating, setIsGenerating] = useState(false);

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

    if (isGenerating) {
      interval = setInterval(() => {
        setNumber(Math.floor(Math.random() * 100) + 1);
      }, 500);
    }

    return () => {
      if (interval) clearInterval(interval);
    };
  }, [isGenerating]);

  return (
    <div>
      <h3>랜덤 숫자 생성기</h3>
      <p>현재 숫자: {number}</p>
      <button onClick={() => setIsGenerating(!isGenerating)}>
        {isGenerating ? '정지' : '시작'}
      </button>
    </div>
  );
}

조건부 useEffect

특정 조건에서만 실행하기

import { useState, useEffect } from 'react';

function ConditionalEffect() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');
  const [saveMessage, setSaveMessage] = useState('');

  // count가 5의 배수일 때만 실행
  useEffect(() => {
    if (count > 0 && count % 5 === 0) {
      setSaveMessage(`카운트 ${count}에서 자동 저장됨`);
    }
  }, [count]);

  // name이 3글자 이상일 때만 실행
  useEffect(() => {
    if (name.length >= 3) {
      console.log(`유효한 이름: ${name}`);
      setSaveMessage(`이름 '${name}'이 유효합니다`);
    } else if (name.length > 0) {
      setSaveMessage('이름은 3글자 이상이어야 합니다');
    } else {
      setSaveMessage('');
    }
  }, [name]);

  return (
    <div>
      <h3>조건부 useEffect</h3>
      
      <div>
        <p>카운트: {count}</p>
        <button onClick={() => setCount(count + 1)}>증가</button>
        <button onClick={() => setCount(0)}>초기화</button>
        <p>카운트가 5의 배수가 되면 자동 저장됩니다</p>
      </div>

      <div>
        <input
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="이름을 입력하세요 (3글자 이상)"
        />
      </div>

      {saveMessage && <p>메시지: {saveMessage}</p>}
    </div>
  );
}

실습: 할 일 목록 with 로컬스토리지

import { useState, useEffect } from 'react';

function TodoAppWithStorage() {
  const [todos, setTodos] = useState([]);
  const [inputText, setInputText] = useState('');

  // 컴포넌트 마운트 시 로컬스토리지에서 데이터 불러오기
  useEffect(() => {
    const savedTodos = localStorage.getItem('todos');
    if (savedTodos) {
      try {
        setTodos(JSON.parse(savedTodos));
      } catch (error) {
        console.error('저장된 할 일 목록을 불러오는 중 오류 발생:', error);
      }
    }
  }, []);

  // todos가 변경될 때마다 로컬스토리지에 저장
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos]);

  const addTodo = () => {
    if (inputText.trim()) {
      const newTodo = {
        id: Date.now(),
        text: inputText.trim(),
        completed: false
      };
      setTodos([...todos, newTodo]);
      setInputText('');
    }
  };

  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed));
  };

  return (
    <div>
      <h3>할 일 목록 (자동 저장)</h3>
      
      <div>
        <input
          type="text"
          value={inputText}
          onChange={(e) => setInputText(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && addTodo()}
          placeholder="할 일을 입력하세요"
        />
        <button onClick={addTodo}>추가</button>
      </div>

      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <label>
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id)}
              />
              <span>
                {todo.text}
              </span>
            </label>
            <button onClick={() => deleteTodo(todo.id)}>삭제</button>
          </li>
        ))}
      </ul>

      {todos.length > 0 && (
        <div>
          <p>총 {todos.length}개, 완료 {todos.filter(t => t.completed).length}개</p>
          <button onClick={clearCompleted}>완료된 항목 삭제</button>
        </div>
      )}
    </div>
  );
}

useEffect 사용 시 주의사항

무한 루프 방지

import { useState, useEffect } from 'react';

function InfiniteLoopExample() {
  const [count, setCount] = useState(0);
  const [data, setData] = useState([]);

  // ❌ 잘못된 예: 무한 루프 발생
  // useEffect(() => {
  //   setCount(count + 1); // count 변경
  // }, [count]); // count가 변경될 때마다 실행 → 무한 루프

  // ✅ 올바른 예 1: 특정 조건에서만 업데이트
  useEffect(() => {
    if (count < 5) {
      setCount(count + 1);
    }
  }, [count]);

  // ❌ 잘못된 예: 객체를 의존성에 직접 포함
  // useEffect(() => {
  //   // 일부 로직
  // }, [data]); // 배열이나 객체는 참조가 매번 달라질 수 있음

  // ✅ 올바른 예 2: 객체의 특정 속성만 의존성에 포함
  useEffect(() => {
    console.log('데이터 길이가 변경됨:', data.length);
  }, [data.length]);

  return (
    <div>
      <h3>useEffect 주의사항</h3>
      <p>카운트: {count} (5에서 멈춤)</p>
      <p>데이터 개수: {data.length}</p>
      <button onClick={() => setData([...data, Date.now()])}>
        데이터 추가
      </button>
    </div>
  );
}

정리

useEffect와 컴포넌트 생명주기의 핵심 개념을 학습했습니다:

  1. 기본 useEffect: 마운트, 업데이트, 언마운트 시점에 코드 실행
  2. 의존성 배열: 빈 배열, 특정 값, 배열 없음의 차이점
  3. 정리 함수: 타이머, 이벤트 리스너 등의 정리 작업
  4. 데이터 가져오기: API 호출과 로딩 상태 관리
  5. 조건부 실행: 특정 조건에서만 useEffect 실행
  6. 실습: 로컬스토리지를 활용한 데이터 영속성
  7. 주의사항: 무한 루프 방지와 올바른 의존성 배열 사용

useEffect는 React에서 부수 효과(side effects)를 처리하는 핵심 Hook입니다. 적절한 의존성 배열과 정리 함수를 사용하면 메모리 누수 없이 효율적인 React 애플리케이션을 만들 수 있습니다.

다음 장에서는 사용자 정의 Hook에 대해 알아보겠습니다.

컴포넌트 생명주기와 useEffect - 리엑트 베이스캠프 | 위니버시티