React

React useEffect 두 번 실행 문제 해결: Strict Mode에서 중복 호출 확인하기

2025.12.04·수정 2026.05.12·약 11분

두 번 실행, 먼저 버그로 단정하지 않기

React 개발 환경에서 useEffect가 두 번 실행되면 API 요청도 두 번 나가고 콘솔 로그도 두 번 찍혀서 당황스럽습니다. 다만 이 현상은 React가 고장났다는 뜻이 아닐 때가 많습니다. 특히 StrictMode가 켜져 있다면 effect 코드가 cleanup까지 제대로 갖춰져 있는지 확인하기 위해 개발 중에 한 번 더 실행될 수 있습니다.

가 두 번 실행될 때 먼저 확인할 것

React 개발 환경에서 useEffect 실행 흐름과 StrictMode 점검 과정을 비교한 다이어그램

처음에는 보통 코드가 잘못된 줄 압니다. useEffect(() => { console.log('mounted'); }, [])처럼 빈 의존성 배열을 넣었는데도 콘솔이 두 번 찍히기 때문입니다. 빈 배열이면 컴포넌트가 처음 화면에 붙은 뒤 한 번만 실행된다고 배웠는데, 실제 개발 화면에서는 두 번 보이니 설명과 결과가 맞지 않는 것처럼 느껴집니다.

이때 가장 먼저 볼 것은 배포 환경이 아니라 개발 환경입니다. React 앱의 루트가 <StrictMode>로 감싸져 있다면 개발 중에 effect가 한 번 더 실행될 수 있습니다. 이 동작은 production build, 즉 실제 배포용 빌드에서 같은 방식으로 반복되는 동작이 아닙니다. React가 개발자에게 “이 effect는 한 번 더 실행되어도 안전한가?”를 미리 물어보는 점검에 가깝습니다.

그렇다고 모든 중복 실행을 StrictMode 탓으로 넘기면 안 됩니다. 의존성 배열에 매 렌더링마다 새로 만들어지는 함수나 객체가 들어갔거나, effect 안에서 상태를 바꾸고 그 상태가 다시 effect의 의존성이 되는 구조라면 실제 버그일 수 있습니다. 그래서 확인 순서는 “StrictMode 개발 점검인지”와 “내 코드가 계속 다시 실행될 구조인지”를 나눠 보는 쪽이 좋습니다.

StrictMode가 effect를 다시 실행하는 이유

StrictMode는 React가 개발 중에 흔한 실수를 일찍 찾도록 돕는 장치입니다. 공식 문서 기준으로 StrictMode는 개발 환경에서 컴포넌트를 한 번 더 렌더링하거나 effect를 한 번 더 실행할 수 있습니다. 특히 effect는 첫 실제 setup 전에 setup → cleanup → setup 흐름을 한 번 더 거치며, cleanup이 setup에서 만든 작업을 제대로 되돌리는지 확인합니다.

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

이 설정이 있으면 개발 중에는 effect가 한 번 더 호출되는 것처럼 보일 수 있습니다. 여기서 중요한 점은 React가 “같은 작업을 두 번 하라”고 요구하는 것이 아니라, effect가 외부 시스템과 연결했다면 끊는 코드도 갖추었는지 검사한다는 점입니다. 예를 들어 이벤트 리스너를 등록했다면 제거해야 하고, 웹소켓을 연결했다면 끊어야 하며, 타이머를 만들었다면 정리해야 합니다.

그래서 StrictMode를 끄는 것은 원인을 숨기는 임시 처방이 될 수 있습니다. 물론 특정 레거시 라이브러리 때문에 범위를 조정해야 하는 상황은 있을 수 있지만, 일반적인 해결 방향은 StrictMode를 없애는 것이 아니라 effect 코드를 다시 실행되어도 안전하게 고치는 것입니다. 개발 중 두 번 보이는 현상은 불편하지만, 배포 후 사용자에게 드러날 수 있는 누수나 중복 등록 문제를 미리 발견하게 해줍니다.

cleanup이 빠졌을 때 실제로 생기는 문제

effect 안에서 단순히 로그만 찍는다면 두 번 실행되어도 큰 문제가 없어 보입니다. 하지만 이벤트 리스너, 구독, 타이머처럼 외부에 무언가를 등록하는 코드는 다릅니다. 등록만 하고 제거하지 않으면 컴포넌트가 다시 나타날 때 이전 작업이 남아 있고, 그 결과 한 번의 클릭이나 한 번의 resize에 여러 개의 핸들러가 반응할 수 있습니다.

import { useEffect, useState } from 'react'; function WindowWidth() { const [width, setWidth] = useState(window.innerWidth); useEffect(() => { const handleResize = () => { setWidth(window.innerWidth); }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, []); return <p>현재 너비: {width}px</p>;
}

위 코드에서 cleanup은 선택 장식이 아닙니다. addEventListener로 등록한 일을 removeEventListener로 되돌립니다. StrictMode가 개발 중에 effect를 다시 실행해도 cleanup이 먼저 실행되기 때문에 같은 리스너가 계속 쌓이지 않습니다. setup에서 시작한 일을 cleanup에서 되돌린다는 짝을 맞추는 것이 핵심입니다.

구독형 코드도 같은 방식으로 봅니다. 채팅방 연결, Firebase 구독, 브라우저 이벤트, 타이머는 컴포넌트가 사라질 때 멈춰야 합니다. cleanup이 없다면 화면을 이동했다가 돌아왔을 때 이전 작업이 살아남을 수 있고, StrictMode는 이런 문제를 개발 단계에서 더 잘 보이게 만듭니다.

의존성 배열을 점검하는 기준

의존성 배열은 effect를 “몇 번 실행할지 직접 정하는 스위치”라기보다, effect 안에서 참조하는 반응형 값이 바뀌었을 때 다시 동기화하겠다는 선언입니다. props, state, 컴포넌트 안에서 선언한 변수와 함수가 effect 안에서 쓰이면 의존성 후보가 됩니다. 빈 배열은 “아무 값도 바뀌는 기준으로 삼지 않겠다”는 뜻이지, 모든 상황에서 절대 한 번만 실행된다는 보장은 아닙니다.

자주 생기는 문제는 의존성을 빼서 경고를 없애거나, 반대로 매번 새로 만들어지는 값을 의존성에 넣어 effect가 계속 도는 경우입니다. 예를 들어 컴포넌트 안에서 만든 객체를 그대로 의존성에 넣으면 렌더링마다 새 객체가 만들어져 React는 값이 바뀐 것으로 볼 수 있습니다. 이때는 객체 생성을 effect 안으로 옮기거나, 필요한 원시값만 의존성으로 남기는 식으로 구조를 조정합니다.

function UserList({ teamId }) { const [users, setUsers] = useState([]); useEffect(() => { const controller = new AbortController(); async function loadUsers() { const response = await fetch(`/api/teams/${teamId}/users`, { signal: controller.signal}); const data = await response.json(); setUsers(data); } loadUsers().catch((error) => { if (error.name !== 'AbortError') { console.error(error); } }); return () => { controller.abort(); }; }, [teamId]); return <ul>{users.map((user) => <li key={user.id}>{user.name}</li>)}</ul>;
}

여기서는 teamId가 바뀌면 사용자 목록도 다시 가져와야 하므로 의존성 배열에 들어갑니다. 반대로 users를 의존성에 넣으면 데이터를 받아 setUsers를 호출한 뒤 다시 effect가 실행되는 흐름이 생길 수 있습니다. effect가 읽는 값과 effect가 갱신하는 값을 구분해서 봐야 반복 루프를 줄일 수 있습니다.

API 호출이 두 번 일어날 때의 대응

API 요청이 두 번 보일 때는 불안해지기 쉽습니다. 특히 개발자 도구 Network 탭에 같은 요청이 두 줄로 찍히면 서버 비용이나 데이터 중복 생성이 걱정됩니다. 조회 요청이라면 StrictMode 개발 점검으로 한 번 더 보이는 경우가 많지만, 결제, 주문 생성, 글 작성처럼 서버 상태를 바꾸는 요청이라면 effect에서 자동 실행하는 구조 자체를 다시 봐야 합니다.

조회 요청은 중단 가능한 구조로 만들면 정리가 좋아집니다. 위 예시처럼 AbortController를 사용하면 cleanup 시점에 이전 요청을 취소할 수 있습니다. StrictMode의 첫 번째 점검 실행에서 시작한 요청이 cleanup으로 취소되고, 실제 setup에서 다시 요청하는 흐름이 됩니다. 서버 응답이 빠른 환경에서는 두 요청이 모두 도착한 것처럼 보일 수 있으므로, 화면 반영 로직도 최신 요청만 반영하도록 설계하면 더 안정적입니다.

서버에 데이터를 쓰는 요청은 버튼 클릭, 폼 제출 같은 사용자 액션에 연결하는 것이 보통 더 낫습니다. 컴포넌트가 화면에 나타났다는 이유만으로 주문 생성이나 포인트 적립 요청을 보내면 재마운트, 새로고침, StrictMode 점검 같은 상황에서 같은 작업이 반복될 수 있습니다. effect는 외부 시스템과 화면 상태를 동기화하는 용도로 두고, “한 번만 일어나야 하는 사용자 결정”은 이벤트 핸들러나 서버의 중복 방지 키와 함께 처리하는 방식이 안전합니다.

두 번 실행을 없애기보다 안전한 effect 만들기

useEffect cleanup 의존성 배열 API 요청 점검 체크리스트를 카드 형태로 정리한 인포그래픽

가 두 번 실행될 때 바로 StrictMode를 지우면 화면상 증상은 사라질 수 있습니다. 하지만 cleanup이 빠진 이벤트 리스너, 잘못 잡힌 의존성 배열, effect 안에서 반복되는 상태 업데이트는 그대로 남습니다. 개발 환경에서 드러난 신호를 없애기보다, effect가 다시 실행되어도 같은 결과를 유지하도록 만드는 쪽이 장기적으로 덜 흔들립니다.

점검 순서는 단순하게 잡을 수 있습니다. 첫째, 현재 현상이 개발 환경의 StrictMode에서만 보이는지 확인합니다. 둘째, effect 안에서 외부에 등록하거나 연결한 일이 있다면 cleanup이 있는지 봅니다. 셋째, 의존성 배열에 effect가 실제로 읽는 반응형 값이 들어 있는지 확인합니다. 넷째, API 요청이 조회인지 쓰기인지 나누고, 쓰기 요청이라면 effect가 아니라 사용자 액션이나 서버 측 중복 방지로 옮길 수 있는지 검토합니다.

빈 배열은 임시로 조용하게 만드는 도구가 아닙니다. cleanup도 “경고가 나니까 붙이는 코드”가 아닙니다. useEffect는 컴포넌트를 외부 시스템과 맞추는 자리이고, StrictMode는 그 맞춤이 한 번 더 반복되어도 깨지지 않는지 확인하는 장치입니다. 이 관점으로 보면 두 번 실행되는 현상은 귀찮은 버그라기보다 effect 코드를 더 단단하게 만들라는 신호에 가깝습니다.

같이 읽으면 좋은 글

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

“React useEffect 두 번 실행 문제 해결: Strict Mode에서 중복 호출 확인하기”에 대한 3개의 생각

댓글 남기기