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

2026.05.14·수정 2026.09.12·약 47분·작성: 해비·블로그 소개

핵심 요약

개발 환경에서 useEffect가 두 번 실행되는 것처럼 보인다면 먼저 루트의 <StrictMode>setup → cleanup → setup 순서를 확인합니다. 이 검사는 cleanup 누락을 찾기 위한 개발 전용 동작입니다. 다만 의존성 값이 실제로 바뀌거나, 매 렌더마다 새 객체·함수를 만들거나, 같은 컴포넌트를 두 번 렌더링한 경우에는 StrictMode와 무관하게 Effect가 반복될 수 있습니다. 조회 요청은 cleanup과 오래된 응답 방어를 갖추고, 저장·주문·결제처럼 사용자 행동으로 시작되는 요청은 이벤트 핸들러와 서버 idempotency로 보호해야 합니다.

오류를 보기 전: Effect 생명주기 정상 기준선

아래 예제는 버튼으로 의존성 count를 바꾸고, 체크박스로 컴포넌트를 마운트·언마운트합니다. Effect는 렌더 자체가 아니라 렌더 결과를 외부 시스템과 동기화하는 setup입니다. 의존성이 바뀌면 React는 이전 값의 cleanup을 먼저 실행한 뒤 새 값의 setup을 실행하고, 컴포넌트가 사라질 때 마지막 cleanup을 실행합니다.

import { useEffect, useState } from "react";

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

  useEffect(() => {
    console.log("setup", count);
    const previousTitle = document.title;
    document.title = `count: ${count}`;
    return () => {
      document.title = previousTitle;
      console.log("cleanup", count);
    };
  }, [count]);

  return <button onClick={() => setCount((n) => n + 1)}>count: {count}</button>;
}

export default function App() {
  const [shown, setShown] = useState(true);
  return (
    <main>
      <label><input type="checkbox" checked={shown} onChange={(e) => setShown(e.target.checked)} />counter 표시</label>
      {shown && <TitleCounter />}
    </main>
  );
}

개발자 도구 콘솔을 연 뒤 count 버튼을 한 번 누르면 이전 count의 cleanup 뒤 새 count의 setup을 확인할 수 있습니다. 체크를 끄면 마지막 cleanup이 실행됩니다. 개발 StrictMode에서는 최초 마운트에 setup→cleanup→setup 검사가 한 번 더 보일 수 있으므로, 우선 의존성 변경과 언마운트에서 setup과 cleanup이 짝을 이루는지를 정상 완료 기준으로 삼습니다.

먼저 개발 검사와 실제 중복을 구분하기

console.log가 두 번 보인다는 사실만으로 운영 환경에서 API가 두 번 저장됐다고 결론 내리면 원인을 잘못 잡기 쉽습니다. 브라우저 개발자 도구의 Network 탭에서 요청 횟수와 메서드, 응답 상태를 확인하고, 서버 로그나 데이터베이스에서 실제 부작용이 몇 번 발생했는지 함께 봐야 합니다.

React useEffect 중복 실행 원인을 개발 모드와 실제 API 호출로 나누어 확인하는 흐름
관찰한 현상 먼저 확인할 원인 검증 방법
새로고침 직후 개발 환경에서만 두 번 보임 루트 StrictMode의 개발 전용 검사 setup·cleanup 로그 순서와 프로덕션 빌드를 비교
특정 prop이나 state가 바뀔 때마다 반복 정상적인 의존성 변경 또는 불안정한 객체·함수 React DevTools와 의존성 값을 비교
클릭 한 번에 POST가 두 번 저장됨 중복 이벤트, 빠른 연속 클릭, 재시도, 서버 중복 방어 누락 Network 요청 ID와 서버 저장 레코드를 확인
컴포넌트를 떠난 뒤 경고·오래된 데이터가 나타남 cleanup 누락 또는 응답 순서 경쟁 느린 네트워크에서 route·id를 빠르게 전환
운영 환경에서도 계속 반복 상위 컴포넌트의 remount, 변경되는 key, 실제 의존성 변경 컴포넌트 mount/unmount와 key를 기록

특히 개발 서버의 Hot Reload, 브라우저 확장, 라우트 전환, 상위 조건부 렌더링이 컴포넌트를 다시 마운트할 수도 있습니다. 따라서 StrictMode라는 이름만 보고 끝내지 말고 언제, 어떤 트리에서, 어떤 외부 부작용이 반복됐는지를 기록하는 것이 출발점입니다.

StrictMode가 setup → cleanup → setup을 실행하는 이유

React 공식 문서에 따르면 루트의 <StrictMode>는 개발 중 컴포넌트를 추가로 렌더링하고 Effect와 ref callback을 한 번 더 실행해, 순수하지 않은 렌더링과 cleanup 누락을 일찍 드러냅니다. Effect에는 첫 실제 setup 전에 개발 전용 setup → cleanup → setup 스트레스 테스트가 추가됩니다. 이는 운영 환경의 모든 Effect가 무조건 두 번 실행된다는 뜻이 아닙니다. 루트 전체가 아니라 일부 하위 트리만 StrictMode로 감쌌다면 최초 마운트의 Effect 추가 재실행은 적용되지 않습니다. 실제 총횟수는 remount·의존성 변경·개발 도구 동작에도 영향을 받으므로 정확히 두 번이라는 수치를 정상 기준으로 고정하지 않습니다.

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

const rootElement = document.getElementById('root');

if (!rootElement) {
  throw new Error('root 요소를 찾을 수 없습니다.');
}

createRoot(rootElement).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

좋은 Effect는 setup을 한 번 실행한 경우와 setup → cleanup → setup을 거친 경우가 사용자에게 같은 결과를 보여야 합니다. 중복이 보인다는 이유로 StrictMode를 끄거나 useRef 플래그로 두 번째 실행을 숨기면, 연결·구독·타이머가 실제로 해제되지 않는 문제를 남길 수 있습니다.

import { useEffect } from 'react';

function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();

    return () => {
      connection.disconnect();
    };
  }, [roomId]);

  return <p>현재 채팅방: {roomId}</p>;
}

이 예제에서 roomId가 바뀌면 React는 이전 연결을 cleanup한 뒤 새 연결을 setup합니다. 컴포넌트가 사라질 때도 마지막 cleanup을 실행합니다. 개발 전용 재실행은 이 대칭 구조가 제대로 만들어졌는지 미리 검사합니다.

의존성 배열과 새 객체·함수 점검

Effect의 반복은 의존성 배열에 따라 달라집니다. 배열을 생략하면 모든 commit 뒤에 실행되고, 빈 배열 []은 해당 컴포넌트 인스턴스의 mount와 unmount 흐름에 맞춰 실행됩니다. [roomId]처럼 값을 넣으면 이전 값과 새 값을 Object.is로 비교해 달라졌을 때 cleanup과 setup을 실행합니다.

작성 형태 동작 주의점
useEffect(setup) 모든 commit 뒤 실행 의도 없이 쓰면 state 갱신과 무한 반복이 생기기 쉽습니다.
useEffect(setup, []) mount 흐름에 맞춰 실행 사용한 reactive 값을 억지로 숨기는 용도가 아닙니다.
useEffect(setup, [id]) id 변경 때 cleanup 후 재실행 코드에서 읽는 모든 reactive 값을 포함해야 합니다.

매 렌더마다 만든 객체와 함수는 내용이 같아 보여도 참조가 다릅니다. 아래의 options는 렌더링할 때마다 새 객체가 되어 Effect를 반복시킵니다.

function ChatRoom({ roomId }: { roomId: string }) {
  const options = { serverUrl: 'https://chat.example.com', roomId };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // 매 렌더마다 새 객체

  return <p>{roomId}</p>;
}

객체를 Effect 안에서 만들면 실제 reactive 값인 roomId만 의존성으로 남길 수 있습니다.

function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    const options = {
      serverUrl: 'https://chat.example.com',
      roomId,
    };
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return <p>{roomId}</p>;
}

useMemouseCallback을 무조건 붙이기 전에, 객체·함수를 Effect 내부로 옮기거나 컴포넌트 밖의 상수로 만들 수 있는지 먼저 봅니다. 의존성을 빼서 linter를 속이는 방식은 오래된 prop과 state를 읽는 버그를 만들 수 있습니다.

구독·이벤트에 cleanup 붙이기

외부 시스템과 동기화하는 Effect는 setup과 cleanup이 한 쌍이어야 합니다. 이벤트 리스너, WebSocket, 타이머, 외부 위젯 구독이 대표적입니다. cleanup만 있고 대응하는 setup이 없거나, setup한 대상과 다른 함수를 해제하면 대칭이 깨집니다.

import { useEffect, useState } from 'react';

function WindowWidth() {
  const [width, setWidth] = useState<number | null>(null);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }

    handleResize();
    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return <p>현재 너비: {width ?? '확인 중'}</p>;
}

초기 state에서 바로 window.innerWidth를 읽으면 서버 렌더링 환경에 window가 없어 실패할 수 있습니다. 위 예제는 브라우저에서 Effect가 실행된 뒤 값을 채워 이 문제를 피합니다. 프레임워크가 제공하는 반응형 CSS나 전용 훅으로 해결할 수 있다면 그 방법이 더 단순할 수도 있습니다.

조회 API와 AbortController의 정확한 역할

목록 조회처럼 화면이 보이는 동안 필요한 GET 요청은 Effect에 둘 수 있습니다. 이때 id가 빠르게 바뀌면 늦게 도착한 이전 응답이 새 화면을 덮는 경쟁 상태가 생길 수 있으므로 요청 취소와 stale 응답 방어가 필요합니다.

import { useEffect, useState } from 'react';

type Product = { id: string; name: string };

function ProductList({ categoryId }: { categoryId: string }) {
  const [products, setProducts] = useState<Product[]>([]);
  const [message, setMessage] = useState('');

  useEffect(() => {
    const controller = new AbortController();
    let ignore = false;

    async function loadProducts() {
      try {
        setMessage('');
        const response = await fetch(
          `/api/categories/${categoryId}/products`,
          { signal: controller.signal },
        );

        if (!response.ok) {
          throw new Error(`상품 조회 실패: ${response.status}`);
        }

        const data: Product[] = await response.json();
        if (!ignore) setProducts(data);
      } catch (error) {
        if (!ignore && !(error instanceof DOMException && error.name === 'AbortError')) {
          setMessage('상품을 불러오지 못했습니다.');
        }
      }
    }

    void loadProducts();

    return () => {
      ignore = true;
      controller.abort();
    };
  }, [categoryId]);

  if (message) return <p role='alert'>{message}</p>;

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

AbortController는 브라우저 쪽 fetch 소비를 취소하고 불필요한 응답 처리를 줄이는 도구입니다. 이미 서버가 받은 요청이나 완료한 데이터베이스 변경을 되돌리는 트랜잭션이 아닙니다. 따라서 저장·주문·결제 중복 방어를 abort()에 맡기면 안 됩니다. 또한 Next.js 같은 프레임워크에서는 Server Component, route loader, 데이터 fetching 라이브러리가 캐시·중복 제거·오류 처리를 더 잘 제공할 수 있으므로 프로젝트의 데이터 계층을 먼저 확인합니다.

POST·주문·결제를 이벤트와 idempotency로 보호하기

useEffect API 중복 호출을 cleanup, 요청 취소, 사용자 액션 기준으로 점검하는 체크리스트

사용자가 저장 버튼을 눌렀기 때문에 실행해야 하는 POST를 state 변화 감시 Effect에 넣으면, remount·상태 복원·StrictMode 검사와 사용자 의도가 섞입니다. React 공식 가이드의 기준처럼 특정 상호작용 때문에 실행되는 로직은 해당 이벤트 핸들러에 둡니다.

import { useRef, useState } from 'react';

type FormData = { name: string; price: number };
type Attempt = { key: string; payload: FormData };
type SaveState = 'idle' | 'saving' | 'failed';

function SaveButton({ formData }: { formData: FormData }) {
  const [saveState, setSaveState] = useState<SaveState>('idle');
  const [error, setError] = useState('');
  const attemptRef = useRef<Attempt | null>(null);

  async function sendCurrentAttempt() {
    if (saveState === 'saving') return;
    const attempt = attemptRef.current ?? {
      key: crypto.randomUUID(),
      payload: { ...formData },
    };
    attemptRef.current = attempt;
    setSaveState('saving');
    setError('');
    try {
      const response = await fetch('/api/products', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Idempotency-Key': attempt.key },
        body: JSON.stringify(attempt.payload),
      });
      if (!response.ok) throw new Error(`저장 실패: ${response.status}`);
      attemptRef.current = null;
      setSaveState('idle');
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : '저장에 실패했습니다.');
      setSaveState('failed');
    }
  }

  function startNewOperation() {
    attemptRef.current = null;
    setError('');
    setSaveState('idle');
  }

  return <div><button type="button" disabled={saveState === 'saving'} onClick={() => void sendCurrentAttempt()}>{saveState === 'failed' ? '같은 작업 재시도' : saveState === 'saving' ? '저장 중' : '저장'}</button>{saveState === 'failed' && <button type="button" onClick={startNewOperation}>새 작업으로 바꾸기</button>}{error && <p role="alert">{error}</p>}</div>;
}

버튼 비활성화는 빠른 연속 클릭을 줄이지만 네트워크 재시도, 프록시 재전송, 여러 탭, 모바일 연결 복구까지 막지는 못합니다. 주문·결제 API는 서버가 Idempotency-Key와 요청 결과를 원자적으로 저장하고 같은 키의 재요청에 기존 결과를 반환해야 합니다. 재시도할 때는 같은 작업에 같은 키를 재사용해야 하며, 데이터베이스의 unique constraint나 트랜잭션도 함께 설계합니다.

  • 조회 GET은 여러 번 실행돼도 서버 상태를 바꾸지 않는 설계를 기본으로 합니다.
  • 저장 POST는 클릭 이벤트에 두고 진행 중 UI를 보여줍니다.
  • 주문·결제는 서버 idempotency와 데이터베이스 제약으로 최종 방어합니다.
  • 실패 후 재시도 정책과 키의 만료 시간, 사용자에게 보여줄 결과를 문서화합니다.

Effect가 필요 없는 코드 걷어내기

외부 시스템과 동기화하지 않는 계산이라면 Effect가 필요하지 않을 가능성이 큽니다. props로 받은 목록을 필터링해 다시 state에 저장하거나, 두 state를 합쳐 세 번째 state를 갱신하는 코드는 렌더링 중 계산할 수 있습니다.

function ProductList({
  products,
  query,
}: {
  products: Product[];
  query: string;
}) {
  const normalizedQuery = query.trim().toLowerCase();
  const filteredProducts = products.filter((product) =>
    product.name.toLowerCase().includes(normalizedQuery),
  );

  return (
    <ul>
      {filteredProducts.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

계산 비용이 실제로 크고 측정 결과 캐시가 필요할 때만 useMemo를 검토합니다. Effect는 React 바깥 시스템과 동기화하는 탈출구이지, 모든 후처리를 넣는 기본 장소가 아닙니다.

수정 후 확인할 체크리스트

  1. 루트 또는 해당 하위 트리가 <StrictMode>로 감싸졌는지 확인합니다.
  2. setup에서 등록·연결·시작한 모든 작업을 cleanup에서 정확히 해제하는지 봅니다.
  3. Effect가 읽는 모든 reactive prop·state가 의존성에 포함됐는지 linter로 검사합니다.
  4. 의존성에 매 렌더마다 새로 만들어지는 객체·배열·함수가 있는지 확인합니다.
  5. 상위의 조건부 렌더링, 변경되는 key, route 전환으로 remount되는지 확인합니다.
  6. GET은 오래된 응답이 최신 화면을 덮지 않도록 cleanup과 경쟁 상태를 처리합니다.
  7. POST·주문·결제는 이벤트 핸들러와 서버 idempotency로 보호합니다.
  8. 개발과 프로덕션 빌드에서 Network·서버 로그·최종 데이터가 각각 몇 번 발생했는지 비교합니다.

자주 묻는 질문

StrictMode를 끄면 해결되나요?

개발 중 추가 검사는 사라지지만 cleanup 누락이나 재실행에 취약한 Effect는 그대로 남습니다. 먼저 Effect를 대칭적이고 반복 안전하게 고치는 편이 장기적으로 안전합니다.

빈 의존성 배열이면 절대 한 번만 실행되나요?

아닙니다. 컴포넌트 인스턴스가 remount되면 다시 실행되고, 개발 StrictMode 검사도 있습니다. 앱 전체 생명주기에서 정확히 한 번이어야 하는 작업은 컴포넌트 Effect와 다른 소유 위치가 필요할 수 있습니다.

useRef로 이미 실행했는지 막아도 되나요?

로그처럼 중요하지 않은 표시를 제한하는 좁은 경우는 있을 수 있지만, 연결·구독의 cleanup 문제를 숨기는 일반 해법으로 사용하면 안 됩니다. effect가 다시 실행돼도 올바른 결과를 내도록 설계하는 것이 우선입니다.

AbortController면 POST 중복도 막을 수 있나요?

아닙니다. 서버가 이미 요청을 받아 처리했다면 브라우저의 abort로 그 변경이 취소되지 않습니다. 서버 idempotency와 트랜잭션이 필요합니다.

공식 문서와 함께 확인하기

결론은 실행 횟수를 억지로 하나로 만드는 것이 아니라, Effect의 소유 이유를 명확히 하고 setup과 cleanup을 대칭으로 만드는 것입니다. 조회는 오래된 응답에 안전하게, 저장은 사용자 이벤트와 서버 중복 방어로 처리하면 개발 검사와 실제 장애를 구분하기 쉬워집니다.

직접 실행하는 최소 재현과 수정 실습

실습 ZIP 다운로드 · 3047번 글의 완성 프로젝트입니다. Node.js 22.12 이상에서 압축을 풀고 아래 명령을 실행합니다. HTML, CSS, JSX가 별도 파일이며 외부 API는 사용하지 않습니다.

npm install
npm run dev

# 결과 검증
npm test
npm run build
npm run preview

1. 원인을 확인하는 최소 오류 코드

useEffect(() => {
  function receive() { setReceived(previous => previous + 1); }
  window.addEventListener('practice-message', receive);
}, []);

cleanup 없는 오류 코드는 루트 StrictMode의 개발 검사에서 같은 컴포넌트에 리스너를 중복 등록할 수 있습니다. 수정본은 setup에서 만든 함수와 같은 참조를 removeEventListener에 전달합니다. []는 앱 전체에서 단 한 번이라는 약속이 아닙니다. 테스트는 컴포넌트 호출 횟수를 세지 않고 메시지 한 번이 화면 수를 정확히 1 증가시키는지, 숨겼다 다시 표시해도 같은 결과인지 확인합니다. 이 실습의 이벤트는 브라우저 안에서만 발생하며 실제 API 요청은 보내지 않습니다.

2. App.jsx 수정본

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

이 실습은 오류를 재현하는 시작본과 설명, 검증된 완성본을 차례로 비교합니다. 메시지를 보낸 뒤 수신기를 숨겼다가 다시 표시하고 다시 보내세요. 완성본은 재마운트 후 0에서 시작해 한 번에 1만 증가합니다.

수정 전

App.jsx

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

function Receiver() {
  const [received, setReceived] = useState(0);
  useEffect(() => {
    function receive() {
      setReceived((previous) => previous + 1);
    }
    window.addEventListener('practice-message', receive);
  }, []);
  return <p role="status">받은 메시지: {received}</p>;
}

export default function App() {
  const [shown, setShown] = useState(true);
  return (
    <main>
      <h1>StrictMode에서 안전한 구독</h1>
      <label>
        <input
          type="checkbox"
          checked={shown}
          onChange={(event) => setShown(event.target.checked)}
        />
        수신기 표시
      </label>
      <button
        type="button"
        onClick={() => window.dispatchEvent(new Event('practice-message'))}
      >
        메시지 보내기
      </button>
      {shown && <Receiver />}
      <p>수신기를 다시 표시하면 수는 0으로 시작하고 메시지 한 번에 1씩 증가합니다.</p>
    </main>
  );
}

App.test.jsx

import React, { StrictMode } from 'react';
import { it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App.jsx';
afterEach(cleanup);
it('receives one message per external event after StrictMode and remount', async () => {
  const user = userEvent.setup();
  render(
    <StrictMode>
      <App />
    </StrictMode>,
  );
  await user.click(screen.getByRole('button', { name: '메시지 보내기' }));
  expect(screen.getByRole('status').textContent).toBe('받은 메시지: 1');
  await user.click(screen.getByLabelText('수신기 표시'));
  await user.click(screen.getByRole('button', { name: '메시지 보내기' }));
  expect(screen.queryByRole('status')).toBeNull();
  await user.click(screen.getByLabelText('수신기 표시'));
  await user.click(screen.getByRole('button', { name: '메시지 보내기' }));
  expect(screen.getByRole('status').textContent).toBe('받은 메시지: 1');
});

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React 실습 3047</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/main.jsx"></script>
  </body>
</html>

main.jsx

import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';

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

package.json

{
  "name": "react-practice-3047",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "build": "vite build",
    "preview": "vite preview --host 0.0.0.0",
    "test": "vitest run"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7"
  }
}

styles.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #222;
  background: #fff;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
}
main {
  max-width: 760px;
  margin: 40px auto;
  padding: 24px;
}
h1 {
  font-size: 1.7rem;
}
label {
  display: block;
  margin: 12px 0 6px;
}
input,
select,
button {
  font: inherit;
  color: #222;
  background: #fff;
  border: 1px solid #777;
  padding: 8px 12px;
}
input:not([type='checkbox']),
select {
  max-width: 100%;
}
button {
  margin: 8px 8px 8px 0;
  cursor: pointer;
}
button:disabled {
  color: #777;
  cursor: default;
}
[aria-pressed='true'] {
  background: #222;
  color: #fff;
}
:focus-visible {
  outline: 2px solid #222;
  outline-offset: 3px;
}
li {
  margin: 12px 0;
}
[role='alert'] {
  font-weight: 700;
}

vite.config.js

import { defineConfig } from 'vite';
export default defineConfig({ base: './', test: { environment: 'jsdom' } });

수정 방법

App.jsx

수정 위치: Receiver의 useEffect가 이벤트를 등록하지만 cleanup 함수를 반환하지 않는 부분을 확인하세요.

이유: StrictMode의 개발 검증과 재마운트 때 예전 listener가 남아 한 이벤트가 여러 state 업데이트를 만들 수 있습니다. 같은 함수로 removeEventListener를 호출하는 cleanup을 반환해야 구독 수명과 컴포넌트 수명이 일치합니다.

재현과 기대 결과: 메시지를 보낸 뒤 수신기를 숨겼다가 다시 표시하고 다시 보내세요. 완성본은 재마운트 후 0에서 시작해 한 번에 1만 증가합니다.

App.jsx에서 아래 차이가 있는 위치를 수정하세요. -는 삭제할 줄, +는 추가할 줄이며 표시는 실제 코드에 넣지 않습니다.

본문의 확인 절차로 변경 결과를 확인합니다.

--- 수정 전/App.jsx
+++ 수정 후/App.jsx
@@ -7,6 +7,7 @@
       setReceived((previous) => previous + 1);
     }
     window.addEventListener('practice-message', receive);
+    return () => window.removeEventListener('practice-message', receive);
   }, []);
   return <p role="status">받은 메시지: {received}</p>;
 }

수정 후 탭의 전체 파일과 비교한 뒤 본문의 확인 절차를 실행하세요.

App.test.jsx

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

index.html

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

main.jsx

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

package.json

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

styles.css

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

vite.config.js

이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.

수정 후

App.jsx

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

function Receiver() {
  const [received, setReceived] = useState(0);
  useEffect(() => {
    function receive() {
      setReceived((previous) => previous + 1);
    }
    window.addEventListener('practice-message', receive);
    return () => window.removeEventListener('practice-message', receive);
  }, []);
  return <p role="status">받은 메시지: {received}</p>;
}

export default function App() {
  const [shown, setShown] = useState(true);
  return (
    <main>
      <h1>StrictMode에서 안전한 구독</h1>
      <label>
        <input
          type="checkbox"
          checked={shown}
          onChange={(event) => setShown(event.target.checked)}
        />
        수신기 표시
      </label>
      <button
        type="button"
        onClick={() => window.dispatchEvent(new Event('practice-message'))}
      >
        메시지 보내기
      </button>
      {shown && <Receiver />}
      <p>수신기를 다시 표시하면 수는 0으로 시작하고 메시지 한 번에 1씩 증가합니다.</p>
    </main>
  );
}

App.test.jsx

import React, { StrictMode } from 'react';
import { it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App.jsx';
afterEach(cleanup);
it('receives one message per external event after StrictMode and remount', async () => {
  const user = userEvent.setup();
  render(
    <StrictMode>
      <App />
    </StrictMode>,
  );
  await user.click(screen.getByRole('button', { name: '메시지 보내기' }));
  expect(screen.getByRole('status').textContent).toBe('받은 메시지: 1');
  await user.click(screen.getByLabelText('수신기 표시'));
  await user.click(screen.getByRole('button', { name: '메시지 보내기' }));
  expect(screen.queryByRole('status')).toBeNull();
  await user.click(screen.getByLabelText('수신기 표시'));
  await user.click(screen.getByRole('button', { name: '메시지 보내기' }));
  expect(screen.getByRole('status').textContent).toBe('받은 메시지: 1');
});

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React 실습 3047</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/main.jsx"></script>
  </body>
</html>

main.jsx

import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';

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

package.json

{
  "name": "react-practice-3047",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 0.0.0.0",
    "build": "vite build",
    "preview": "vite preview --host 0.0.0.0",
    "test": "vitest run"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.7"
  }
}

styles.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #222;
  background: #fff;
  font-family: system-ui, sans-serif;
  line-height: 1.7;
}
main {
  max-width: 760px;
  margin: 40px auto;
  padding: 24px;
}
h1 {
  font-size: 1.7rem;
}
label {
  display: block;
  margin: 12px 0 6px;
}
input,
select,
button {
  font: inherit;
  color: #222;
  background: #fff;
  border: 1px solid #777;
  padding: 8px 12px;
}
input:not([type='checkbox']),
select {
  max-width: 100%;
}
button {
  margin: 8px 8px 8px 0;
  cursor: pointer;
}
button:disabled {
  color: #777;
  cursor: default;
}
[aria-pressed='true'] {
  background: #222;
  color: #fff;
}
:focus-visible {
  outline: 2px solid #222;
  outline-offset: 3px;
}
li {
  margin: 12px 0;
}
[role='alert'] {
  font-weight: 700;
}

vite.config.js

import { defineConfig } from 'vite';
export default defineConfig({ base: './', test: { environment: 'jsdom' } });

App.jsx 전체 코드 보기

3. 따라 해볼 순서와 완료 기준

메시지를 보냅니다. 수신기를 숨겼다가 다시 표시하고 다시 보냅니다. 한 메시지당 1 증가하며 다시 마운트된 수신기는 0에서 시작합니다.

ZIP의 main.jsx에서 React 루트와 StrictMode를 구성하고 styles.css에서 흑백 UI를 적용합니다. App.test.jsx는 실제 입력·클릭 뒤 화면 결과를 확인합니다. 오류 코드는 broken-example.md에 설명용으로 보관되어 자동 실행되지 않습니다. 테스트는 jsdom에서 검증하며 브라우저의 시각적 배치까지 검증한 것은 아닙니다.

공식 참고 자료

이 글이 도움이 되었나요?

조회 중

React 학습 순서

필수 19개 · 전체 23개

읽음 기록 관리

전체 과정 목차 (23개)
  1. 필수 길잡이 · React 학습 순서: 컴포넌트·props·state부터 상태관리까지
  2. 필수 학습 · React Vite 사용법: Vite 8 프로젝트 생성·실행·빌드
  3. 필수 학습 · React 컴포넌트 개념 정리: UI 재사용 구조 잡기
  4. 필수 학습 · React JSX 문법 사용법: 조건부 렌더링과 리스트 처리
  5. 필수 학습 · React props 사용법: 부모에서 자식으로 데이터 전달하는 구조
  6. 필수 학습 · React useState 사용법: state와 객체 배열 업데이트 기준
  7. 선택 참고 · React state 업데이트 안됨 문제 해결
  8. 필수 선수 · React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유
  9. 필수 학습 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드
  10. 필수 학습 · React useRef 실습: 입력 포커스와 state의 역할 나누기
  11. 필수 선수 · React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결 현재 글
  12. 선택 참고 · React Maximum update depth exceeded 오류 해결: 무한 렌더링 원인 찾기
  13. 선택 참고 · React Cannot update 오류 해결: 렌더링 중 setState 원인
  14. 필수 학습 · React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기
  15. 필수 학습 · React 컴포넌트 props 타입 지정하기: 부모와 자식 사이의 값 구조 잡기
  16. 선택 참고 · React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트
  17. 필수 길잡이 · React 라이브러리 조합 가이드: Zustand·TanStack Query·shadcn/ui 선택 기준
  18. 필수 학습 · Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기
  19. 필수 학습 · React Compiler 기준: useMemo useCallback 언제 줄일까
  20. 필수 학습 · React·TypeScript 검색 필터 만들기: 상태와 결과 목록 연결
  21. 필수 학습 · React Router v7 실습: BrowserRouter부터 Layout·Outlet·상세 경로까지
  22. 필수 학습 · shadcn/ui 시작 실습: Vite 설정·components.json·입력 폼·Sonner
  23. 필수 학습 · shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기