핵심 요약
개발 환경에서 useEffect가 두 번 실행되는 것처럼 보인다면 먼저 루트의 <StrictMode>와 setup → cleanup → setup 순서를 확인합니다. 이 검사는 cleanup 누락을 찾기 위한 개발 전용 동작입니다. 다만 의존성 값이 실제로 바뀌거나, 매 렌더마다 새 객체·함수를 만들거나, 같은 컴포넌트를 두 번 렌더링한 경우에는 StrictMode와 무관하게 Effect가 반복될 수 있습니다. 조회 요청은 cleanup과 오래된 응답 방어를 갖추고, 저장·주문·결제처럼 사용자 행동으로 시작되는 요청은 이벤트 핸들러와 서버 idempotency로 보호해야 합니다.
- 먼저 개발 검사와 실제 중복을 구분하기
- StrictMode가 setup → cleanup → setup을 실행하는 이유
- 의존성 배열과 새 객체·함수 점검
- 구독·이벤트에 cleanup 붙이기
- 조회 API와 AbortController의 정확한 역할
- POST·주문·결제를 이벤트와 idempotency로 보호하기
- Effect가 필요 없는 코드 걷어내기
- 최종 점검표와 FAQ
먼저 개발 검사와 실제 중복을 구분하기
console.log가 두 번 보인다는 사실만으로 운영 환경에서 API가 두 번 저장됐다고 결론 내리면 원인을 잘못 잡기 쉽습니다. 브라우저 개발자 도구의 Network 탭에서 요청 횟수와 메서드, 응답 상태를 확인하고, 서버 로그나 데이터베이스에서 실제 부작용이 몇 번 발생했는지 함께 봐야 합니다.

| 관찰한 현상 | 먼저 확인할 원인 | 검증 방법 |
|---|---|---|
| 새로고침 직후 개발 환경에서만 두 번 보임 | 루트 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가 무조건 두 번 실행된다는 뜻이 아닙니다.
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>;
}
useMemo나 useCallback을 무조건 붙이기 전에, 객체·함수를 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로 보호하기

사용자가 저장 버튼을 눌렀기 때문에 실행해야 하는 POST를 state 변화 감시 Effect에 넣으면, remount·상태 복원·StrictMode 검사와 사용자 의도가 섞입니다. React 공식 가이드의 기준처럼 특정 상호작용 때문에 실행되는 로직은 해당 이벤트 핸들러에 둡니다.
import { useState } from 'react';
type FormData = { name: string; price: number };
function SaveButton({ formData }: { formData: FormData }) {
const [isSaving, setIsSaving] = useState(false);
async function handleSave() {
if (isSaving) return;
setIsSaving(true);
const idempotencyKey = crypto.randomUUID();
try {
const response = await fetch('/api/products', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(formData),
});
if (!response.ok) {
throw new Error(`저장 실패: ${response.status}`);
}
} finally {
setIsSaving(false);
}
}
return (
<button type='button' disabled={isSaving} onClick={handleSave}>
{isSaving ? '저장 중' : '저장'}
</button>
);
}
버튼 비활성화는 빠른 연속 클릭을 줄이지만 네트워크 재시도, 프록시 재전송, 여러 탭, 모바일 연결 복구까지 막지는 못합니다. 주문·결제 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 바깥 시스템과 동기화하는 탈출구이지, 모든 후처리를 넣는 기본 장소가 아닙니다.
수정 후 확인할 체크리스트
- 루트 또는 해당 하위 트리가
<StrictMode>로 감싸졌는지 확인합니다. - setup에서 등록·연결·시작한 모든 작업을 cleanup에서 정확히 해제하는지 봅니다.
- Effect가 읽는 모든 reactive prop·state가 의존성에 포함됐는지 linter로 검사합니다.
- 의존성에 매 렌더마다 새로 만들어지는 객체·배열·함수가 있는지 확인합니다.
- 상위의 조건부 렌더링, 변경되는
key, route 전환으로 remount되는지 확인합니다. - GET은 오래된 응답이 최신 화면을 덮지 않도록 cleanup과 경쟁 상태를 처리합니다.
- POST·주문·결제는 이벤트 핸들러와 서버 idempotency로 보호합니다.
- 개발과 프로덕션 빌드에서 Network·서버 로그·최종 데이터가 각각 몇 번 발생했는지 비교합니다.
자주 묻는 질문
StrictMode를 끄면 해결되나요?
개발 중 추가 검사는 사라지지만 cleanup 누락이나 재실행에 취약한 Effect는 그대로 남습니다. 먼저 Effect를 대칭적이고 반복 안전하게 고치는 편이 장기적으로 안전합니다.
빈 의존성 배열이면 절대 한 번만 실행되나요?
아닙니다. 컴포넌트 인스턴스가 remount되면 다시 실행되고, 개발 StrictMode 검사도 있습니다. 앱 전체 생명주기에서 정확히 한 번이어야 하는 작업은 컴포넌트 Effect와 다른 소유 위치가 필요할 수 있습니다.
useRef로 이미 실행했는지 막아도 되나요?
로그처럼 중요하지 않은 표시를 제한하는 좁은 경우는 있을 수 있지만, 연결·구독의 cleanup 문제를 숨기는 일반 해법으로 사용하면 안 됩니다. effect가 다시 실행돼도 올바른 결과를 내도록 설계하는 것이 우선입니다.
AbortController면 POST 중복도 막을 수 있나요?
아닙니다. 서버가 이미 요청을 받아 처리했다면 브라우저의 abort로 그 변경이 취소되지 않습니다. 서버 idempotency와 트랜잭션이 필요합니다.
공식 문서와 함께 확인하기
- React StrictMode: 개발 전용 추가 렌더링과 Effect 재실행의 목적
- React useEffect: setup·cleanup·의존성의 정확한 동작
- Synchronizing with Effects: 외부 시스템과 동기화하는 기준
- You Might Not Need an Effect: 계산과 사용자 이벤트를 Effect에서 분리하는 기준
결론은 실행 횟수를 억지로 하나로 만드는 것이 아니라, Effect의 소유 이유를 명확히 하고 setup과 cleanup을 대칭으로 만드는 것입니다. 조회는 오래된 응답에 안전하게, 저장은 사용자 이벤트와 서버 중복 방어로 처리하면 개발 검사와 실제 장애를 구분하기 쉬워집니다.
“React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결”에 대한 2개의 생각