React useEffect 완벽 가이드 - Todo 앱으로 배우는 생명주기와 CRUD

📅 2025년 11월 28일 | ⏱️ 15분 읽기

React 공부하다가 useEffect 때문에 머리 아프셨죠?

처음에 저도 그랬어요. “의존성 배열이 뭐야?”, “왜 내 코드는 무한 루프에 빠지는 거야?”라고 소리치면서 모니터를 노려봤던 기억이 나네요. 콘솔창에서 에러가 1초에 수백 개씩 찍힐 때의 그 당황스러움… 지금 생각해도 아찔합니다.

하지만 걱정 마세요. 오늘 이 글 하나면 useEffect를 완벽하게 정복할 수 있습니다. 이론만 주구장창 설명하는 게 아니라, 실제로 Todo 앱을 만들면서 CRUD(생성, 조회, 수정, 삭제)까지 구현해볼 거예요. 코드 한 줄 한 줄 왜 이렇게 써야 하는지, 제 실수담과 함께 친절하게 알려드릴게요!

🔄 왜 useEffect를 배워야 할까? - 컴포넌트의 생명주기

본격적으로 들어가기 전에, 왜 useEffect가 필요한지부터 이해해야 합니다. React 컴포넌트는 사람의 인생처럼 태어나고, 살다가, 사라지는 주기를 가지고 있어요.

graph LR
    A[Mount<br/>탄생] --> B[Update<br/>변화]
    B --> B
    B --> C[Unmount<br/>소멸]
    style A fill:#a8e6cf
    style B fill:#7ec8e3
    style C fill:#ff8b94

1. Mount (마운트) - 컴포넌트의 탄생 🐣

컴포넌트가 처음으로 화면에 나타나는 순간입니다. 마치 우리가 세상에 태어나는 것처럼요. 이때 필요한 데이터를 서버에서 가져오거나, 초기 설정을 해야 할 때가 있죠.

2. Update (업데이트) - 컴포넌트의 변화 🔄

State나 Props가 바뀌면 컴포넌트가 다시 렌더링됩니다. 예를 들어, 사용자가 버튼을 클릭해서 count 값이 증가하면 화면이 업데이트되는 거죠. 이 과정이 앱이 살아있는 동안 계속 반복됩니다.

3. Unmount (언마운트) - 컴포넌트의 소멸 💀

컴포넌트가 화면에서 사라지는 순간입니다. 다른 페이지로 이동하거나, 조건부 렌더링으로 컴포넌트가 제거될 때죠. 이때는 타이머를 정리하거나 구독을 해제하는 등의 “뒷정리”가 필요합니다.

💡 실생활 비유로 이해하기

Mount: 아침에 일어나서 하루를 시작 (초기 설정)
Update: 하루 종일 밥 먹고, 일하고, 쉬는 등 활동 (상태 변화)
Unmount: 밤에 자기 전 양치하고 정리 (클린업)

⚡ useEffect, 이렇게 쓰는 겁니다

자, 이제 본격적으로 useEffect 사용법을 알아볼까요? useEffect는 언제 실행될지를 제어할 수 있는 의존성 배열(Dependency Array)이라는 게 있어요. 이게 핵심입니다!

기본 문법은 이렇게 생겼어요:

useEffect(콜백함수, [의존성배열]);

두 번째 인자인 의존성 배열에 뭘 넣느냐에 따라 실행 시점이 완전히 달라집니다. 하나씩 살펴볼게요.

graph TD
    A[useEffect 실행 시점] --> B{의존성 배열?}
    B -->|없음| C[렌더링 될 때마다<br/>항상 실행]
    B -->|"빈 배열 []"| D[마운트 시<br/>단 1회만 실행]
    B -->|"[count]"| E[count 변경 시<br/>실행]
    B -->|return 함수| F[언마운트 시<br/>클린업 실행]

    style C fill:#ffe0b2
    style D fill:#c5e1a5
    style E fill:#90caf9
    style F fill:#ef9a9a

패턴 1️⃣ 의존성 배열 없음 - 매번 실행

useEffect(() => {
  console.log("렌더링될 때마다 실행됩니다!");
});
// 의존성 배열을 아예 안 쓰면 렌더링 될 때마다 실행

🚨 주의하세요!

이 패턴은 거의 쓰지 않습니다. 왜냐하면 너무 자주 실행되거든요. 특히 useEffect 안에서 setState를 하면 무한 루프에 빠져요! 제가 초보일 때 이거 모르고 브라우저 먹통 시킨 적 있습니다… ㅋㅋ

패턴 2️⃣ 빈 배열 [] - 마운트 시 1회만

useEffect(() => {
  console.log("컴포넌트가 처음 나타날 때 딱 한 번만 실행!");
  // 여기서 보통 API 호출해서 데이터 가져옵니다
  fetchUserData();
}, []); // 빈 배열 = 처음 한 번만!

이게 가장 많이 쓰는 패턴이에요. 컴포넌트가 처음 화면에 나타날 때 서버에서 데이터를 가져오는 용도로 딱입니다.

패턴 3️⃣ 특정 값 감시 [count] - 값 변경 시

const [count, setCount] = useState(0);

useEffect(() => {
  console.log("count가 변경되었습니다:", count);
  // count가 바뀔 때마다 이 코드가 실행됩니다
  document.title = `클릭 ${count}번`;
}, [count]); // count를 감시!

배열 안에 count를 넣으면, count 값이 바뀔 때마다 useEffect가 실행돼요. 마치 CCTV처럼 특정 값을 감시하는 거죠.

패턴 4️⃣ 클린업 함수 - 언마운트 시 정리

useEffect(() => {
  // 타이머 시작
  const timer = setInterval(() => {
    console.log("1초마다 실행!");
  }, 1000);

  // return으로 클린업 함수 정의
  return () => {
    console.log("컴포넌트가 사라질 때 타이머 정리!");
    clearInterval(timer); // 타이머 중지
  };
}, []);

useEffect 안에서 함수를 return하면, 그게 바로 클린업 함수입니다. 컴포넌트가 사라질 때 실행되면서 뒷정리를 해줘요. 타이머, 이벤트 리스너, 구독 등을 정리할 때 필수입니다!

💡 클린업 함수는 왜 필요한가요?

타이머나 이벤트 리스너를 정리하지 않으면 메모리 누수(Memory Leak)가 발생합니다. 컴포넌트는 사라졌는데 타이머는 계속 돌고 있으면 성능 문제가 생기죠. 마치 불 끄고 나가는 것처럼, 깔끔하게 정리하는 습관이 중요해요!

🚀 실전! Todo 앱으로 배우는 React CRUD

이제 이론은 충분히 배웠으니, 실전으로 가볼까요? 요즘 개발자 면접에서 자주 나오는 Todo 앱을 직접 만들어보겠습니다. CRUD(Create, Read, Update, Delete) 기능을 모두 구현하면서 React의 핵심을 익힐 수 있어요.

CRUD 작업을 표현하는 3D 플로팅 아이콘들. 플러스(생성), 체크리스트(조회), 연필(수정), 휴지통(삭제)이 원형으로 배치된 파스텔 톤의 미니멀 디자인

Create, Read, Update, Delete - Todo 앱의 핵심 4가지 기능 ✨

프로젝트 구조 잡기

먼저 어떤 컴포넌트가 필요한지 생각해봅시다:

  • Header: 오늘 날짜 표시
  • TodoEditor: 새로운 할 일 입력
  • TodoList: 할 일 목록 + 검색 기능
  • TodoItem: 개별 할 일 (체크박스, 삭제 버튼)

데이터 모델링 - 어떻게 저장할까?

할 일 하나하나를 객체로 만들고, 여러 개를 배열에 담아 관리할 거예요.

// 할 일 하나의 구조
{
  id: 1,                    // 고유 식별자 (삭제, 수정할 때 필요)
  isDone: false,            // 완료 여부
  content: "React 공부하기", // 할 일 내용
  createdDate: 1701234567   // 생성 시간 (타임스탬프)
}

왜 이렇게 구조를 잡았을까요? id는 나중에 “이 항목을 삭제해!”라고 지정할 때 필요하고, isDone은 체크박스 상태를 관리하기 위해서죠. 날짜는 나중에 정렬할 때 유용해요.

✨ Create - 할 일 추가하기

가장 먼저 구현할 기능은 새로운 할 일을 추가하는 거예요. 여기서 핵심은 불변성을 지키는 겁니다!

// App.js
import { useState, useRef } from 'react';

function App() {
  const [todo, setTodo] = useState([]); // 할 일 배열
  const idRef = useRef(1); // id 생성용 (렌더링과 무관)

  const onCreate = (content) => {
    // 새로운 할 일 객체 생성
    const newItem = {
      id: idRef.current,              // 현재 id 사용
      content,                         // ES6 단축 문법
      isDone: false,
      createdDate: new Date().getTime()
    };
    
    // ⭐ 중요: 불변성을 지키며 배열 업데이트
    setTodo([newItem, ...todo]); // 맨 앞에 추가
    idRef.current += 1;          // 다음 id를 위해 증가
  };

  return (
    <TodoEditor onCreate={onCreate} />
  );
}

🚨 왜 push() 대신 스프레드 연산자(…)를 쓰나요?

todo.push(newItem)처럼 직접 수정하면 안 됩니다! React는 원본 배열이 바뀌었는지 감지 못해요. 반드시 새로운 배열을 만들어서 setState 해야 리렌더링이 일어납니다. 이게 바로 불변성!

그리고 TodoEditor 컴포넌트에서는 입력값 검증도 해줘야겠죠?

// TodoEditor.js
function TodoEditor({ onCreate }) {
  const [content, setContent] = useState("");
  const inputRef = useRef(); // 포커스 제어용

  const onSubmit = () => {
    // 빈 값 체크
    if (!content.trim()) {
      inputRef.current.focus(); // 포커스 주기
      return;
    }
    
    onCreate(content);   // 부모 함수 호출
    setContent("");      // 입력창 비우기
  };

  // Enter 키 지원
  const onKeyDown = (e) => {
    if (e.keyCode === 13) onSubmit();
  };

  return (
    <div>
      <input 
        ref={inputRef}
        value={content}
        onChange={(e) => setContent(e.target.value)}
        onKeyDown={onKeyDown}
        placeholder="새로운 할 일..."
      />
      <button onClick={onSubmit}>추가</button>
    </div>
  );
}

📖 Read - 리스트 보여주기 & 검색

이제 추가한 할 일들을 화면에 보여줘야겠죠. map() 메서드를 사용해서 배열을 컴포넌트로 변환합니다.

// TodoList.js
function TodoList({ todo, onUpdate, onDelete }) {
  const [search, setSearch] = useState("");

  // 검색 필터링 함수
  const getSearchResult = () => {
    return search === ""
      ? todo // 검색어 없으면 전체
      : todo.filter((it) => 
          it.content.toLowerCase().includes(search.toLowerCase())
        );
  };

  return (
    <div>
      {/* 검색바 */}
      <input 
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="검색어를 입력하세요..."
      />

      {/* 리스트 렌더링 */}
      {getSearchResult().map((item) => (
        <TodoItem 
          key={item.id}  // ⭐ key는 필수!
          {...item}       // props 펼치기
          onUpdate={onUpdate}
          onDelete={onDelete}
        />
      ))}
    </div>
  );
}

💡 key props는 왜 필요한가요?

React가 리스트의 각 항목을 구별하기 위해서입니다. key가 없으면 경고가 뜨고, 성능도 나빠져요. 항상 고유한 값(보통 id)을 key로 써야 합니다. 절대 index를 key로 쓰면 안 돼요!

검색 기능을 보면 toLowerCase()를 양쪽에 다 써서 대소문자 구분 없이 검색되게 했어요. 사용자 경험을 위한 작은 배려죠!

🔧 Update - 완료 상태 변경

체크박스를 클릭하면 isDone 값을 뒤집어야 해요. 이때도 불변성을 지키면서 업데이트합니다.

// App.js
const onUpdate = (targetId) => {
  setTodo(
    todo.map((item) => 
      // id가 일치하면 isDone을 반대로 변경
      item.id === targetId 
        ? { ...item, isDone: !item.isDone }  // 객체 복사 후 수정
        : item                                // 일치 안 하면 그대로
    )
  );
};

여기서 map()을 쓴 이유는? 배열의 모든 항목을 순회하면서 조건에 맞는 것만 수정하기 위해서죠. 삼항 연산자로 깔끔하게 처리할 수 있어요.

🗑️ Delete - 삭제하기

마지막으로 삭제 기능입니다. filter() 메서드로 간단하게 구현할 수 있어요.

// App.js
const onDelete = (targetId) => {
  // targetId와 다른 것들만 남기기 = targetId 삭제
  setTodo(todo.filter((item) => item.id !== targetId));
};

filter()는 조건을 만족하는 항목만 모아서 새로운 배열을 만듭니다. 삭제하고 싶은 id가 아닌 것들만 걸러내면, 결과적으로 그 항목이 삭제되는 효과가 나는 거죠!

🔍 React Developer Tools로 디버깅하기

코드를 짜다 보면 “어? 왜 안 돼?”하는 순간이 많죠. 이럴 때 Chrome 확장 프로그램인 React Developer Tools가 엄청 유용해요.

돋보기로 React 코드를 검사하는 3D 아이소메트릭 일러스트. 개발자 도구 인터페이스 요소들과 함께 테크 블루 컬러로 표현된 디버깅 컨셉의 모던한 디자인

버그를 찾아내는 개발자의 필수 도구, React DevTools 🔍

설치 및 기본 사용법

  1. Chrome 웹 스토어에서 “React Developer Tools” 검색 후 설치
  2. React 앱을 실행하면 개발자 도구에 “Components” 탭이 생깁니다
  3. 컴포넌트 트리를 보며 Props와 State를 실시간으로 확인 가능!

Highlight Updates - 불필요한 렌더링 찾기

설정에서 “Highlight updates when components render”를 켜면, 리렌더링이 일어날 때마다 컴포넌트가 깜빡입니다. 이걸로 불필요한 렌더링을 찾아서 최적화할 수 있어요.

💡 실전 팁

TodoList에서 하나의 항목만 체크했는데 전체 리스트가 깜빡인다면? 최적화가 필요하다는 신호입니다. 이건 나중에 React.memouseMemo로 해결할 수 있어요!

😅 Props Drilling, 이게 문제입니다

여기까지 따라오시느라 수고 많으셨어요! 근데… 뭔가 좀 이상하지 않나요?

graph TD
    A[App<br/>onDelete 함수 정의] --> B[TodoList<br/>그냥 전달만]
    B --> C[TodoItem<br/>실제 사용]
    style A fill:#a8e6cf
    style B fill:#ffe0b2
    style C fill:#7ec8e3

onDelete 함수를 보면, App에서 만들었는데 실제로 사용하는 건 TodoItem이에요. 그런데 중간에 있는 TodoList는 이 함수를 전혀 안 쓰는데도 받아서 다시 전달만 해야 하죠.

// 이런 식으로 계속 전달...
<TodoList onDelete={onDelete} />
  └─ <TodoItem onDelete={onDelete} />

이게 바로 Props Drilling 문제입니다. 컴포넌트 구조가 깊어질수록 데이터 전달이 복잡해지고, 유지보수가 어려워져요. 마치 땅을 파고 내려가는 드릴처럼 props가 계층을 뚫고 내려간다고 해서 붙은 이름이에요.

🚨 Props Drilling의 문제점

  • 중간 컴포넌트가 불필요한 props를 받아야 함
  • 컴포넌트 구조 변경 시 여러 곳을 수정해야 함
  • 코드 가독성이 떨어짐

이 문제는 Context API상태 관리 라이브러리(Zustand, Recoil)로 해결할 수 있어요. 다음 단계에서 배우면 됩니다!

🎯 마무리하며

오늘 우리는 React의 핵심을 제대로 배웠습니다:

  • 컴포넌트 생명주기와 useEffect의 4가지 패턴
  • Todo 앱 CRUD 구현을 통한 실전 경험
  • 불변성의 중요성과 올바른 상태 업데이트 방법
  • React Developer Tools로 디버깅하는 방법
  • Props Drilling 문제와 한계점 인식

이제 여러분은 기본적인 React 앱을 만들 수 있는 능력을 갖추었습니다! 하지만 아직 최적화되지 않았고, Props Drilling 같은 문제도 있죠.

다음 단계에서는 useMemo, useCallback으로 성능을 최적화하고, Context API로 Props Drilling을 해결하는 방법을 배워볼 거예요. 더 나아가 TypeScript를 적용하면 타입 안정성까지 확보할 수 있습니다.

오늘 배운 내용으로 직접 Todo 앱을 만들어보세요. 에러가 나도 괜찮아요. 제가 처음 배울 때 얼마나 많은 에러를 만났는지 몰라요. 그 과정이 바로 성장입니다!

질문이나 막히는 부분이 있다면 댓글로 편하게 남겨주세요. 함께 해결해봅시다! 🚀

참고 자료: 이 글은 ‘한입 크기로 잘라 먹는 리액트’를 바탕으로 작성되었습니다.