React useEffect 두 번 실행되는 이유 StrictMode API 중복 해결

··설명 약 7분·작성: 해비·블로그 소개

React StrictMode에서 useEffect가 두 번 실행될 때 무엇을 점검해야 하나요?

개발 환경의 StrictMode는 Effect의 setup과 cleanup을 추가로 확인해 구독 정리 문제를 드러낼 수 있습니다. 로그가 더 나온다는 이유만으로 StrictMode를 끄지 말고, 연결과 해제가 반복되어도 실제 이벤트가 중복 처리되지 않는지 봅니다.

적용 조건·범위: 개발 중 console.log나 API 요청이 두 번 실행되는 것처럼 보여 “useEffect가 두 번 실행된다”는 문제를 자주 만납니다. React StrictMode는 개발 환경에서 컴포넌트와 Effect의 문제를 더 빨리 발견하기 위해 추가 검사를 수행합니다.

본문 근거: Synchronizing with Effects

독립 실습: setup 횟수와 실제 이벤트 처리 횟수 분리하기

개발 StrictMode의 setup·cleanup 실제 출력을 펼쳐 확인하세요.

예제 크게 보기 (새 탭)

JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.

src/App.tsx 전체 코드

import { useEffect, useState } from 'react';
export default function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    console.log('setup');
    const handlePing = () => setCount(value => value + 1);
    window.addEventListener('practice-ping', handlePing);
    return () => {
      console.log('cleanup');
      window.removeEventListener('practice-ping', handlePing);
    };
  }, []);
  return (
    <main>
      <button onClick={() => window.dispatchEvent(new Event('practice-ping'))}>이벤트 보내기</button>
      <p>처리 횟수: {count}</p>
    </main>
  );
}

직접 확인: main.tsx 루트 StrictMode를 유지한 개발 환경에서 첫 setup → cleanup → setup 로그를 관찰합니다. 버튼 한 번에는 처리 횟수가 1만 늘어야 합니다. 외부 API는 호출하지 않습니다.

변형 과제: cleanup의 removeEventListener를 잠시 빼고 새로고침해 중복을 비교한 뒤 반드시 복구합니다.

Effect는 연결과 정리를 함께 작성합니다

이벤트를 등록하는 Effect를 생각해 보겠습니다.

useEffect(() => {
  window.addEventListener('online', handleOnline);

  return () => {
    window.removeEventListener('online', handleOnline);
  };
}, []);

컴포넌트가 사라지거나 Effect가 다시 setup되기 전에 cleanup에서 기존 연결을 해제합니다.

이 구조가 제대로 되어 있다면 개발 환경에서 setup → cleanup → setup이 실행되어도 이벤트 리스너가 계속 누적되지 않습니다.

StrictMode를 제거하는 게 해결책일까

StrictMode를 제거하면 개발 중 추가 검사가 사라져 증상이 안 보일 수 있습니다. 하지만 cleanup이 빠진 구독이나 연결 문제 자체가 해결된 것은 아닙니다.

먼저 Effect가 반복 실행되어도 동일한 결과가 되는지 확인하는 편이 좋습니다.

API 요청이 두 번 보일 때

단순히 “두 번 실행됐으니 버그”라고 결론내리기보다 다음을 확인합니다.

  • 개발 StrictMode 때문인지
  • 요청을 Effect에서 해야 하는 상황인지
  • cleanup 또는 요청 취소가 필요한지
  • 서버에 중복 mutation을 보내는 구조인지
  • 데이터 패칭 라이브러리가 해결해야 하는 문제인지

특히 POST 같은 변경 요청을 컴포넌트 마운트 Effect에 무조건 넣는 것은 신중해야 합니다.

자주 발생하는 오류와 해결

이벤트가 클릭할 때마다 두 번씩 실행됩니다

Effect가 재실행될 때 이전 listener를 제거하지 않았는지 확인합니다.

cleanup 함수가 없습니다

타이머, 이벤트 listener, 외부 연결처럼 해제할 대상이 있다면 cleanup을 작성합니다.

StrictMode를 바로 제거했습니다

먼저 개발 검사에서 드러난 비대칭 setup/cleanup 문제인지 확인합니다.

학습 완료 체크리스트

  • StrictMode가 개발 환경에서 추가 검사를 한다는 점을 이해합니다.
  • Effect setup과 cleanup을 대칭으로 작성할 수 있습니다.
  • 이벤트 listener 중복 등록을 막을 수 있습니다.
  • API 중복 문제를 Effect 실행 횟수만으로 판단하지 않습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.

브라우저의 online/offline 이벤트를 구독해 네트워크 상태를 표시합니다. 이 Effect는 cleanup이 필요한 실제 예제가 됩니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TaskFlow 14단계 실행

시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.

예제 크게 보기 (새 탭)

시작 체크포인트: 이전: React useEffect 사용법 외부 시스템과 상태 동기화하기의 완료 상태에서 이어갑니다.

이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.

이전 완료본과 달라진 파일: package.json, src/App.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

변경 순서: src/App.tsx의 네트워크 Effect에서 online/offline 리스너 등록과 동일 함수 해제를 한 쌍으로 작성합니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.

독자 질문과 직접 확인

질문: 개발 중 Effect가 한 번 더 실행되어 보일 때 중복 구독을 어떻게 판별하나요?

컴포넌트 실습: step-14-effect-strict-mode에서 본문의 setup·cleanup 로그 예시를 따라 순서를 기록합니다. 동일 이벤트를 한 번 일으켰을 때 실제 처리 결과가 중복되는지도 별도로 확인합니다.

예상 화면·상태: 개발 모드에서 setup → cleanup → setup 로그가 보여도 정리가 맞으면 활성 구독은 하나여야 합니다. 로그 횟수와 실제 중복 동작은 따로 판단합니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.

오류 점검: 이벤트가 중복 처리되면 cleanup에서 같은 listener·연결을 해제하는지 확인하고 StrictMode 제거만으로 문제를 덮지 않습니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.

다음 학습: 15회에서 Effect의 동기화 단위와 최신 값 읽기를 다룹니다.

완료 코드 ZIP 다운로드 — step-14-effect-strict-mode

이전 체크포인트

step-13-effect

이번 목표

브라우저의 online/offline 이벤트를 구독해 네트워크 상태를 표시합니다. 이 Effect는 cleanup이 필요한 실제 예제가 됩니다.

확인 방법

브라우저 DevTools의 Network 설정에서 Offline을 선택하거나 네트워크 상태를 변경해 표시가 바뀌는지 확인합니다.

개발 중 Effect가 다시 setup되더라도 listener가 중복되지 않아야 합니다.

완료 체크포인트:

step-14-effect-strict-mode

전체 코드 뷰어의 모든 파일은 다운로드 ZIP과 같습니다. ZIP은 독립 실행할 수 있는 완료 시점의 코드입니다. Node.js 22.12 이상에서 npm install 후 npm run dev로 실행하고, npm run build로 검사합니다.

전체 파일·코드 보기

실습 프로젝트 전체 코드

index.html

<!doctype html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TaskFlow</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

package.json

{
  "name": "taskflow-step-14-effect-strict-mode",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 127.0.0.1",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview --host 127.0.0.1"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "typescript": "7.0.2",
    "vite": "8.3.0",
    "@vitejs/plugin-react": "6.1.1",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@types/node": "26.6.2"
  }
}

src/App.tsx

import StateResetDemo from './components/StateResetDemo';
import {
  useEffect,
  useState,
} from 'react';
import Header from './components/Header';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import type { Task } from './types/task';

const initialTasks: Task[] = [
  {
    id: 'task-1',
    title: 'StrictMode와 cleanup 확인하기',
    completed: false,
  },
];

function App() {
  const [tasks, setTasks] = useState<Task[]>(initialTasks);
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  const remainingCount = tasks.filter(
    (task) => !task.completed,
  ).length;

  useEffect(() => {
    document.title = `TaskFlow (${remainingCount})`;
  }, [remainingCount]);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  const handleAdd = (title: string) => {
    setTasks((previousTasks) => [
      ...previousTasks,
      {
        id: crypto.randomUUID(),
        title,
        completed: false,
      },
    ]);
  };

  const handleToggle = (id: string) => {
    setTasks((previousTasks) =>
      previousTasks.map((task) =>
        task.id === id
          ? { ...task, completed: !task.completed }
          : task,
      ),
    );
  };

  const handleDelete = (id: string) => {
    setTasks((previousTasks) =>
      previousTasks.filter((task) => task.id !== id),
    );
  };

  return (
    <main className="app">
      <Header />
      <p>
        네트워크: {isOnline ? '온라인' : '오프라인'}
      </p>
      <p>남은 작업: {remainingCount}</p>

      <TaskForm onAdd={handleAdd} />
      <TaskList
        tasks={tasks}
        onToggle={handleToggle}
        onDelete={handleDelete}
      />
    <StateResetDemo />
    </main>
  );
}

export default App;

src/components/Header.tsx

export default function Header() {
  return (
    <header>
      <p className="eyebrow">React Learning Project</p>
      <h1>TaskFlow</h1>
      <p>오늘 할 일을 하나씩 정리해 보세요.</p>
    </header>
  );
}

src/components/StateResetDemo.tsx

import { useState } from 'react';
import TaskEditor from './TaskEditor';
import type { Task } from '../types/task';
const examples: Task[] = [
 { id: 'example-1', title: '첫 번째 작업', completed: false },
 { id: 'example-2', title: '두 번째 작업', completed: false },
];
export default function StateResetDemo() {
 const [selectedId, setSelectedId] = useState(examples[0].id);
 const selectedTask = examples.find(task => task.id === selectedId)!;
 return <details><summary>state 초기화 실습</summary>
 <p>입력값은 저장되지 않습니다. 작업을 바꾸면 key가 달라져 입력값이 초기화됩니다.</p>
 {examples.map(task => <button type="button" key={task.id} onClick={() => setSelectedId(task.id)}>{task.title} 선택</button>)}
 <TaskEditor key={selectedTask.id} task={selectedTask} />
 </details>;
}

src/components/TaskCard.tsx

import type { ReactNode } from 'react';

type TaskCardProps = {
  children: ReactNode;
  actions?: ReactNode;
};

export default function TaskCard({
  children,
  actions,
}: TaskCardProps) {
  return (
    <li className="task-card">
      <div className="task-card__content">
        {children}
      </div>
      {actions ? (
        <div className="task-card__actions">
          {actions}
        </div>
      ) : null}
    </li>
  );
}

src/components/TaskEditor.tsx

import { useState } from 'react';
import type { Task } from '../types/task';

type TaskEditorProps = {
  task: Task;
};

export default function TaskEditor({
  task,
}: TaskEditorProps) {
  const [title, setTitle] = useState(task.title);

  return (
    <section>
      <h2>작업 편집</h2>
      <label htmlFor="editor-title">편집 입력 연습</label>
      <input id="editor-title"
        value={title}
        onChange={(event) => setTitle(event.target.value)}
      />
      <p>선택한 작업 ID: {task.id}</p>
    </section>
  );
}

src/components/TaskForm.tsx

import {
  useRef,
  useState,
  type FormEvent,
} from 'react';

type TaskFormProps = {
  onAdd: (title: string) => void;
};

export default function TaskForm({
  onAdd,
}: TaskFormProps) {
  const [title, setTitle] = useState('');
  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (
    event: FormEvent<HTMLFormElement>,
  ) => {
    event.preventDefault();

    const trimmedTitle = title.trim();

    if (!trimmedTitle) {
      inputRef.current?.focus();
      return;
    }

    onAdd(trimmedTitle);
    setTitle('');
    inputRef.current?.focus();
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="task-title">
        새 작업
      </label>

      <input
        ref={inputRef}
        id="task-title"
        value={title}
        onChange={(event) =>
          setTitle(event.target.value)
        }
        placeholder="할 일을 입력하세요"
      />

      <button type="submit">추가</button>
    </form>
  );
}

src/components/TaskItem.tsx

import type { Task } from '../types/task';
import TaskCard from './TaskCard';

type TaskItemProps = {
  task: Task;
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
};

export default function TaskItem({
  task,
  onToggle,
  onDelete,
}: TaskItemProps) {
  return (
    <TaskCard
      actions={
        <>
          <button
            type="button"
            onClick={() => onToggle(task.id)}
          >
            {task.completed ? '완료 취소' : '완료'}
          </button>
          <button
            type="button"
            onClick={() => onDelete(task.id)}
          >
            삭제
          </button>
        </>
      }
    >
      <span>{task.title}</span>
    </TaskCard>
  );
}

src/components/TaskList.tsx

import TaskItem from './TaskItem';
import type { Task } from '../types/task';

type TaskListProps = {
  tasks: Task[];
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
};

export default function TaskList({
  tasks,
  onToggle,
  onDelete,
}: TaskListProps) {
  if (tasks.length === 0) {
    return <p>등록된 작업이 없습니다.</p>;
  }

  return (
    <ul>
      {tasks.map((task) => (
        <TaskItem
          key={task.id}
          task={task}
          onToggle={onToggle}
          onDelete={onDelete}
        />
      ))}
    </ul>
  );
}

src/index.css

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, sans-serif;
  color: #111;
  background: #f5f5f5;
}

.app {
  width: min(720px, calc(100% - 32px));
  margin: 48px auto;
}

.eyebrow {
  color: #666;
  font-size: 14px;
}

ul {
  padding-left: 20px;
}

button, input, select { min-height: 44px; font: inherit; }
button, input, select { border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
ul { list-style: none; padding: 0; display: grid; gap: 12px; }
.task-card { border: 1px solid #ccc; padding: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.task-card__content, .task-card__actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
form, section { margin-block: 20px; }
input:not([type="checkbox"]), select { max-width: 100%; }
details { margin-block: 24px; } summary { cursor: pointer; min-height: 44px; }
body { overflow-wrap: anywhere; }
[role="dialog"], [data-radix-popper-content-wrapper] { --radius: 0; }

src/main.tsx

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

src/types/task.ts

export type Task = {
  id: string;
  title: string;
  completed: boolean;
};

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": [
      "ES2022",
      "DOM",
      "DOM.Iterable"
    ],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "types": [
      "vite/client"
    ],
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  },
  "include": [
    "src",
    "vite.config.ts"
  ]
}

vite.config.ts

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()] });

글 소개·학습 준비·전체 목차

React useEffect 중복 실행 원인을 개발 모드와 실제 API 호출로 나누어 확인하는 흐름

React · 14/32 · TaskFlow 누적 실습

React useEffect 두 번 실행되는 이유 StrictMode API 중복 해결

React · 14 / 32 · 필수 선수 · StrictMode·Effect 오류

개발 중 console.log나 API 요청이 두 번 실행되는 것처럼 보여 “useEffect가 두 번 실행된다”는 문제를 자주 만납니다. React StrictMode는 개발 환경에서 컴포넌트와 Effect의 문제를 더 빨리 발견하기 위해 추가 검사를 수행합니다.

중요한 해결책은 StrictMode를 무조건 제거하는 것이 아니라 Effect가 setup과 cleanup을 반복해도 안전한 구조인지 확인하는 것입니다.

이번 글에서 배우는 내용

  • StrictMode의 개발 전용 검사
  • Effect setup과 cleanup
  • 왜 개발 환경에서 Effect가 다시 실행될 수 있는지
  • 이벤트 구독의 중복을 막는 방법
  • API 요청 중복 문제를 볼 때 확인할 기준

목차

다음 학습

다음 글에서는 Effect를 컴포넌트 생명주기 암기가 아니라 외부 시스템과의 동기화 주기로 이해하고, 최신 값을 읽되 Effect를 다시 연결할 필요가 없을 때 useEffectEvent를 사용하는 기준을 배웁니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 33개 · 필수 27개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기