Back

useEffect가 두 번 도는데요? React 19 Strict Mode 제대로 이해하기

React 프로젝트 새로 만들었어요. 간단하게 useEffect에서 API 호출 하나 했는데... 어? 요청이 두 번 가네요. 콘솔 로그도 두 번 찍히고요.

"이거 버그 아냐? 내가 뭘 잘못했나?"

아뇨, 잘못한 거 없어요. 원래 그래요. 정확히는 React가 일부러 그렇게 만들었어요. 그리고 왜 이렇게 동작하는지 이해하면, 진짜 좋은 React 개발자가 될 수 있어요. 프로덕션에서 터질 뻔한 버그를 개발 중에 미리 잡을 수 있거든요.

이 글에서 정확히 무슨 일이 벌어지는지, React 팀이 왜 이런 결정을 했는지, 그리고 어떤 상황에서도 문제없는 Effect를 작성하는 방법까지 다 다룰게요.


뭔 일이 일어난 거야?

이런 코드 작성했다고 해볼게요:

import { useEffect, useState } from 'react'; function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { console.log('데이터 가져오는 중...', userId); fetch(`/api/users/${userId}`) .then(res => res.json()) .then(data => setUser(data)); }, [userId]); return <div>{user?.name}</div>; }

개발 모드에서 콘솔 열어보면:

데이터 가져오는 중... 123
데이터 가져오는 중... 123

네트워크 탭 가보면 똑같은 API 호출이 두 개. 뭔가 잘못된 것 같죠?

React 오래 쓰신 분이라면 이상하게 느껴질 수 있어요. React 18 이전에는 개발에서도 Effect가 한 번만 실행됐거든요. 뭐가 바뀐 걸까요?


범인: React.StrictMode

이유는 StrictMode 때문이에요. 보통 main.jsxindex.js에 이렇게 돼 있을 거예요:

import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; createRoot(document.getElementById('root')).render( <StrictMode> <App /> </StrictMode> );

React 18부터 StrictMode는 일부러 이렇게 해요:

  1. 컴포넌트 마운트
  2. 바로 언마운트
  3. 다시 마운트

개발 환경에서만 그래요. 프로덕션 빌드에선 한 번만 실행됩니다.

핵심은: 이 이중 실행은 여러분 코드의 버그를 찾아내려고 일부러 하는 거예요.


근데 왜 일부러 그러는 거야? 이유를 알아보자

React 팀이 괴롭히려고 이런 게 아니에요. Effect에 대한 진짜 중요한 사실 때문입니다:

"두 번 실행했을 때 문제 생기면, 어차피 프로덕션에서도 문제 생겼을 거야. 개발 중에 못 잡았을 뿐."

실제로 Effect가 여러 번 실행되는 상황들을 생각해봐요:

상황 1: 개발 중 Fast Refresh

코딩하다 파일 저장하면 Fast Refresh가 컴포넌트를 다시 렌더링해요. Effect가 구독을 설정하고 정리 안 하면? 구독이 중복으로 쌓여요.

상황 2: Suspense랑 Transition

React 18+ 기능인 startTransition이나 Suspense 쓰면, React가 렌더를 "중단"했다가 다시 시도할 수 있어요. 컴포넌트가 마운트 → 언마운트 → 다시 마운트되는 게 정상 동작이에요.

상황 3: 실제 네비게이션에서의 리마운트

사용자가 다른 페이지 갔다가 뒤로가기 누르면 컴포넌트가 리마운트돼요. Effect가 WebSocket 연결 만들고 정리 안 하면? 고아 연결이 남아요.

상황 4: 앞으로 나올 기능들

React 팀은 "Offscreen" API를 준비 중이에요 (화면에 보이기 전에 백그라운드에서 렌더링하는 거). 이 기능 쓰려면 컴포넌트가 정상적으로 여러 번 마운트/언마운트 될 수 있어야 해요.

Strict Mode의 이중 실행은 이런 실제 상황을 시뮬레이션하는 거예요. 여기서 문제없으면 위에 말한 시나리오에서도 안전하다는 뜻.


진짜 문제: cleanup 없는 Effect

대부분 개발자들이 어디서 실수하는지 볼게요:

useEffect(() => { const socket = new WebSocket('wss://api.example.com/realtime'); socket.onmessage = (event) => { setMessages(prev => [...prev, event.data]); }; }, []);

뭐가 문제일까요? cleanup 함수가 없어요. Strict Mode가 언마운트하고 다시 마운트하면, 두 번째 WebSocket 연결이 생겨요. 첫 번째 연결은 고아가 돼요—여전히 메시지 받고, 메모리 쓰고 있는데, 닫을 참조가 없어요.

올바른 패턴:

useEffect(() => { const socket = new WebSocket('wss://api.example.com/realtime'); socket.onmessage = (event) => { setMessages(prev => [...prev, event.data]); }; // 👇 이게 핵심. 언마운트 시 호출됨 return () => { socket.close(); }; }, []);

이제 흐름이:

  1. 마운트 → WebSocket #1 생성
  2. Strict Mode 언마운트 → WebSocket #1 닫기
  3. 리마운트 → WebSocket #2 생성

한 번에 하나의 연결만 존재해요. Effect가 튼튼해졌어요.


흔한 Effect 패턴 고치기

가장 흔한 Effect 패턴들과 Strict Mode에서도 문제없게 만드는 방법을 알아볼게요.

패턴 1: AbortController로 데이터 가져오기

문제있는 방식:

// ❌ 문제: 이중 fetch, 경쟁 조건 가능성 useEffect(() => { fetch(`/api/users/${userId}`) .then(res => res.json()) .then(setUser); }, [userId]);

튼튼한 방식:

// ✅ 정답: cleanup에서 진행 중인 요청 취소 useEffect(() => { const controller = new AbortController(); fetch(`/api/users/${userId}`, { signal: controller.signal }) .then(res => res.json()) .then(setUser) .catch(err => { if (err.name !== 'AbortError') { console.error('Fetch 실패:', err); } }); return () => controller.abort(); }, [userId]);

이게 해결하는 것:

  • Strict Mode가 언마운트할 때 첫 번째 요청이 취소됨
  • 두 번째 요청은 정상 진행
  • 빠른 네비게이션에서 이전 요청이 새 데이터를 덮어쓰지 않음 (경쟁 조건 문제 해결)

패턴 2: window/document 이벤트 리스너

// ❌ 문제: 리스너가 쌓임 useEffect(() => { window.addEventListener('resize', handleResize); }, []);
// ✅ 정답: 깔끔하게 제거 useEffect(() => { window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []);

패턴 3: 타이머와 인터벌

// ❌ 문제: 여러 인터벌이 돌아감 useEffect(() => { setInterval(() => { setCount(c => c + 1); }, 1000); }, []);
// ✅ 정답: 인터벌 정리 useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []);

패턴 4: 서드파티 라이브러리 초기화

차트 라이브러리나 애니메이션 프레임워크 같은 건 초기화와 파괴가 필요해요:

// ❌ 문제: 차트가 두 번 초기화됨 useEffect(() => { const chart = new ChartLibrary(containerRef.current, { data: chartData, }); }, [chartData]);
// ✅ 정답: cleanup에서 파괴 useEffect(() => { const chart = new ChartLibrary(containerRef.current, { data: chartData, }); return () => chart.destroy(); }, [chartData]);

패턴 5: 애널리틱스와 트래킹

이건 좀 미묘해요. 애널리틱스는 중복 제거 안 하는 게 맞을 수도 있거든요:

// Strict Mode에서 두 번 발생하는데, 문제일까? useEffect(() => { analytics.track('page_viewed', { page: pathname }); }, [pathname]);

정답은: 애널리틱스 서비스에 따라 다르다. 대부분의 현대 애널리틱스 (GA4, Amplitude, Mixpanel 등)는 중복 이벤트를 서버에서 걸러내거나 디바운스해요. 개발 중에 두 번 발사되는 건 프로덕션 데이터엔 영향 없어요.

그래도 중요한 경우라면, ref로 이미 트래킹했는지 체크할 수 있어요:

const hasTracked = useRef(false); useEffect(() => { if (!hasTracked.current) { analytics.track('page_viewed', { page: pathname }); hasTracked.current = true; } }, [pathname]);

근데 조심: 이 패턴은 진짜 버그를 숨길 수 있어요. 꼭 필요할 때만 쓰세요.


안티패턴: "한 번만 실행" 플래그

Strict Mode 처음 만난 개발자들이 이런 "해결책"을 떠올려요:

// ⚠️ 안티패턴: 이러면 안 됨 useEffect(() => { let ignore = false; const run = async () => { const data = await fetchData(); if (!ignore) { setData(data); } }; run(); return () => { ignore = true }; }, []);

잠깐—이 패턴은 async 데이터 가져오기에선 맞는 거예요! ignore 플래그가 언마운트 후 상태 설정을 막아주거든요. 근데 이걸 잘못 적용하는 경우가 있어요:

// ❌ 잘못됨: Strict Mode 목적을 무력화 const hasRun = useRef(false); useEffect(() => { if (hasRun.current) return; hasRun.current = true; // Effect 로직... }, []);

이 패턴은 "Strict Mode에서도 절대 한 번만 실행해"라고 하는 거예요. 왜 문제냐면:

  1. 버그를 숨겨요. cleanup 필요한데 없으면, 이 패턴이 개발 중에 문제를 가려요.
  2. 프로덕션에서 깨져요. 컴포넌트가 진짜 리마운트되면? Effect가 안 돌아요.
  3. React 철학에 어긋나요. Effect는 여러 번 실행돼도 괜찮게 만들어야 해요.

결론: "한 번만 실행" 패턴이 필요하다면, 스스로 물어보세요: Effect가 두 번 실행될 때 깨지지? 답은 대부분 잊어버린 cleanup 함수예요.


React 19의 새 패턴: Server Actions와 use

React 19는 Effect 함정을 아예 피해가는 새 패턴들을 소개해요. 알아두면 데이터 가져오기 방식을 현대화할 수 있어요.

데이터 가져오기용 use

React 19는 Promise랑 Context를 소비하는 use 훅을 도입했어요:

import { use, Suspense } from 'react'; function UserProfile({ userPromise }) { // `use`가 promise를 언래핑 const user = use(userPromise); return <div>{user.name}</div>; } // 부모 컴포넌트 function App({ userId }) { const [userPromise] = useState(() => fetchUser(userId)); return ( <Suspense fallback={<div>로딩 중...</div>}> <UserProfile userPromise={userPromise} /> </Suspense> ); }

왜 중요하냐면: Promise가 부모에서 한 번 생성되고 전달돼요. 자식 컴포넌트는 fetch 라이프사이클을 관리 안 해요—그냥 결과만 읽어요. Effect 자체가 없으니 "이중 fetch" 문제가 완전히 사라져요.

뮤테이션용 Server Actions

데이터 변경에는 React 19의 Server Actions가 다른 모델을 제공해요:

// actions.js - 서버에서 실행 'use server'; export async function createUser(formData) { const user = await db.users.create({ name: formData.get('name'), email: formData.get('email'), }); return user; }
// 컴포넌트 - useEffect 없이 제출 import { createUser } from './actions'; function CreateUserForm() { return ( <form action={createUser}> <input name="name" /> <input name="email" /> <button type="submit">생성</button> </form> ); }

Server Actions는 폼 action으로 호출되니까 제출 핸들러랑 Effect가 필요 없어요. 액션은 서버에서 실행되고, React가 UI 업데이트를 알아서 해요.

그래도 useEffect가 필요할 때

새 패턴들이 강력하지만, useEffect가 사라지는 건 아니에요. 여전히 필요한 경우:

  • DOM 측정 (요소 크기 읽기)
  • 브라우저 API 구독 (IntersectionObserver, ResizeObserver)
  • 서드파티 라이브러리 연동
  • 상태 변화에 따른 애니메이션
  • WebSocket 연결
  • 타이머/인터벌 관리

이런 경우엔 cleanup 함수 패턴이 여전히 핵심이에요.


디버깅 전략: Strict Mode 때문인가 진짜 버그인가?

이중 실행 보면 이렇게 체계적으로 디버깅하세요:

1단계: Strict Mode 활성화 확인

엔트리 포인트 (index.js, main.jsx, main.tsx) 확인:

<StrictMode> <App /> </StrictMode>

StrictMode가 없는데 이중 실행되면, 진짜 버그예요—아마 라우팅이나 부모 컴포넌트 로직 문제.

2단계: 로깅으로 흐름 이해

useEffect(() => { console.log('Effect SETUP:', userId); return () => { console.log('Effect CLEANUP:', userId); }; }, [userId]);

Strict Mode에서 보게 될 거:

Effect SETUP: 123
Effect CLEANUP: 123
Effect SETUP: 123

마운트 → 언마운트 → 리마운트 사이클 확인.

3단계: 프로덕션 모드로 테스트

프로덕션 빌드를 로컬에서 실행:

npm run build npm run preview

프로덕션에서는 Strict Mode가 비활성화돼요. 여기서도 이중 실행되면 진짜 버그입니다.

4단계: 의존성 배열 확인

예상치 못한 재실행의 흔한 원인은 불안정한 의존성:

// ❌ 매 렌더마다 새 객체 생성 → 매번 Effect 실행 useEffect(() => { // ... }, [{ some: 'object' }]); // ✅ 안정적인 원시값 useEffect(() => { // ... }, [userId]);

렌더 중에 인라인으로 정의된 객체, 배열, 함수는 매번 새 거예요. Effect가 계속 다시 실행됩니다.


성능 영향: 걱정해야 할까?

자연스러운 걱정: "이중 실행이 앱 느리게 하는 거 아냐?"

개발 환경에서: 네, 약간. 근데 개발 속도는 원래 프로덕션이랑 다르잖아요. 안전하게 버그 잡는 이점이 수 밀리초보다 낫죠.

프로덕션에서: Strict Mode는 완전히 제거돼요. 성능 영향 제로. Effect가 마운트당/의존성 변경당 정확히 한 번만 실행됩니다.

React 팀이 항상 말해요: 개발 성능 최적화하지 마세요. 개발 빌드에는 프로덕션에 없는 체크, 경고, 의도적인 슬로우다운이 많거든요.


경쟁 조건: 중급 시나리오

사용자가 프로필을 빠르게 전환한다고 생각해봐요:

// 사용자가 빠르게 클릭: 프로필 A → 프로필 B → 프로필 C

제대로 cleanup 안 하면 이럴 수 있어요:

  1. 요청 A 시작
  2. 요청 B 시작
  3. 요청 C 시작
  4. 요청 A 완료 → 상태가 User A로
  5. 요청 C 완료 → 상태가 User C로
  6. 요청 B 완료 → 상태가 User B로 ← 틀림!

사용자는 User C를 기대하는데, User B가 마지막으로 완료됐어요.

AbortController가 이걸 고쳐요: 사용자가 프로필 B 클릭하면 요청 A가 취소돼요. 프로필 C 클릭하면 요청 B가 취소돼요. 요청 C만 완료됩니다.


마운트에서만 실행해야 하는 Effect

가끔 진짜로 "마운트에서만" 실행해야 하는 로직이 있어요. ref를 조심스럽게 사용:

const initialized = useRef(false); useEffect(() => { // 한 번만 해야 하는 셋업 실행 initializeComplexLibrary(); if (!initialized.current) { initialized.current = true; // 백엔드에 앱 등록 같은 일회성 셋업 registerAppInstance(); } return () => { // 근데 cleanup은 항상 해야 함 cleanupComplexLibrary(); }; }, []);

참고: cleanup은 매번 실행돼요. ref는 "일회성" 등록 로직만 가드해요.


외부 시스템과 동기화

Effect가 React 밖의 뭔가와 상태를 동기화할 때 (DOM, 외부 API, 브라우저 스토리지):

useEffect(() => { // 마운트할 때 외부 상태 읽기 const savedTheme = localStorage.getItem('theme'); if (savedTheme) setTheme(savedTheme); // 읽기만 하면 cleanup 필요 없음 // 근데 리스너 설정하면 정리해야 함 const handler = (e) => { if (e.key === 'theme') setTheme(e.newValue); }; window.addEventListener('storage', handler); return () => window.removeEventListener('storage', handler); }, []);

튼튼한 Effect를 위한 베스트 프랙티스

지금까지 배운 걸 정리해볼게요:

1. 항상 Cleanup 함수 반환하기

습관으로 만드세요. cleanup 필요 없다고 생각해도:

useEffect(() => { // Setup 로직 return () => { // Cleanup 로직 (빈 주석이라도) // 뭘 정리해야 하는지 생각하게 만들어줌 }; }, [deps]);

2. 모든 Fetch에 AbortController 사용

이 패턴은 자연스러워야 해요:

useEffect(() => { const controller = new AbortController(); fetch(url, { signal: controller.signal }) .then(/* ... */) .catch(err => { if (err.name !== 'AbortError') throw err; }); return () => controller.abort(); }, [url]);

3. 변경 가능한 참조는 Ref에 저장

cleanup 사이클을 거쳐도 유지되어야 하는 변경 가능한 상태가 필요하면:

const socketRef = useRef(null); useEffect(() => { socketRef.current = new WebSocket(url); return () => socketRef.current?.close(); }, [url]);

4. 재사용 가능한 패턴은 커스텀 훅으로 추출

올바른 동작을 중앙화:

function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); setLoading(true); fetch(url, { signal: controller.signal }) .then(res => { if (!res.ok) throw new Error(res.statusText); return res.json(); }) .then(setData) .catch(err => { if (err.name !== 'AbortError') setError(err); }) .finally(() => setLoading(false)); return () => controller.abort(); }, [url]); return { data, loading, error }; }

5. React Query나 SWR 고려하기

복잡한 데이터 가져오기에는 이 라이브러리들이 캐싱, 중복 제거, cleanup을 자동으로 처리해요:

// React Query 사용 import { useQuery } from '@tanstack/react-query'; function UserProfile({ userId }) { const { data: user, isLoading, error } = useQuery({ queryKey: ['user', userId], queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()), }); if (isLoading) return <div>로딩 중...</div>; if (error) return <div>에러 발생</div>; return <div>{user.name}</div>; }

React Query는 자동으로 요청 중복 제거해요—Strict Mode 이중 마운트에도 네트워크 요청은 하나만.


Strict Mode 끄기: 해야 할까?

StrictMode 제거할 있어요:

// 전 <StrictMode> <App /> </StrictMode> // 후 <App />

해야 할까요? 거의 절대 안 돼요. 이유:

  1. 버그를 숨기는 거지 고치는 게 아니에요. 그 버그들은 프로덕션에서 터질 거예요.
  2. 미래 대비를 잃어요. 다음 React 기능들이 튼튼한 Effect에 의존할 수 있어요.
  3. 코드 냄새예요. Strict Mode를 꺼야 한다면, Effect에 구조적 문제가 있을 가능성 높아요.

Strict Mode를 잠깐 끄는 게 정당화되는 유일한 경우는 디버깅할 때—문제가 Strict Mode 때문인지 별도 버그인지 격리하려고 할 때예요.


결론: 이중 실행을 받아들이세요

React 19의 Strict Mode는 여러분 적이 아니에요—더 좋은 코드 쓰게 훈련시켜주는 거예요. 이중 실행 만날 때마다 스스로 물어보세요:

  1. Effect에 cleanup 함수가 있나?
  2. Cleanup 함수가 실제로 Effect가 설정한 걸 정리하나?
  3. Effect가 3번, 10번, 100번 실행돼도 제대로 작동할까?

세 가지 다 "예"면, Effect가 프로덕션 준비된 거예요.

이 글의 패턴들—fetch에 AbortController, 구독에 cleanup 함수, 변경 가능한 상태에 ref—이건 Strict Mode 우회법이 아니에요. Effect 작성하는 올바른 방법이에요. Strict Mode는 그 중요성을 무시 못하게 만들어주는 것뿐.

다음에 Effect가 두 번 실행되면, 우회법 찾지 마세요. 사용자가 발견하기 전에 버그 잡아준 Strict Mode에 감사하고, cleanup 함수를 작성하세요. 새벽 2시에 프로덕션 이슈 디버깅하는 미래의 나 자신이 고마워할 거예요.


마지막 업데이트: 2025년 12월

reactreact-19useeffectstrict-modedebuggingweb-development