
원 저자 Dan Neciu의 허가 하에 <Start naming your useEffect functions, you will thank me later> 아티클을 번역한 글입니다. 약간의 의역 및 오역이 포함되어 있을 수 있습니다.
"저는 한 1년 전부터
useEffect함수에 이름을 붙이기 시작했습니다. 그 이후로 컴포넌트를 읽고 디버깅하는 방식, 그리고 컴포넌트 구조를 잡는 방식까지 달라졌습니다."
지난달에 동료가 올린 PR을 읽었습니다.
PR 코드엔 처음 보는 컴포넌트가 있었고 창고 API와 재고를 동기화하는 로직이 구현되었습니다. 약 200줄 분량에 4개의 useEffect를 호출하고 있었습니다. 하나씩 읽으면서 의존성 배열을 따라가고, 어떤 상태가 어떤 이펙트에 속하는지, 무엇이 무엇을 트리거하는지 파악하는 데 꼬박 1분이 걸렸습니다.
이런 경험을 수도 없이 해봤고, 여러분도 아마 마찬가지일 겁니다.
코드 자체에 문제가 있는 게 아니었습니다. 잘 작성된 코드였고, 이펙트도 관심사별로 올바르게 분리되어 있었습니다.
그럼에도 각 이펙트가 무슨 일을 하는지 파악하려면 모든 줄을 다 읽어야 했습니다. useEffect(() => {는 의도에 대해 아무것도 알려주지 않기 때문입니다. 코드가 언제 실행되는지는 알 수 있지만, 왜 실행되는지는 알 수 없습니다.
이건 클래스 컴포넌트 시대로부터 물려받은 유산이기도 합니다. componentDidMount와 componentDidUpdate만 있던 시절에는 생명주기 이벤트마다 사이드 이펙트 코드를 넣을 수 있는 곳이 딱 하나뿐이었습니다.
그 제약 덕분에 코드의 위치만 봐도 언제 실행되는지 알 수 있었고, "왜"는 주석이나 코드를 직접 읽어야만 알 수 있었습니다.
훅은 생명주기 제약에서 우리를 해방시켰지만, 그 자리를 익명 화살표 함수가 어두컴컴하게 메꿨습니다.
거대한 생명주기 메서드 하나 대신, 이제는 익명 클로저가 줄줄이 늘어서 있고, 무슨 일을 하는지 알려면 구현 코드를 직접 읽어야 합니다.
저는 1년 전부터 이펙트 함수에 이름을 붙이기 시작했습니다. 리액트를 작성하는 방식에서 가장 작은 변화였지만, 읽는 방식에 가장 큰 영향을 미쳤습니다.
아까 언급한 재고 컴포넌트를 단순화하면 이렇습니다.
function InventorySync({ warehouseId, locationId, onStockChange }) {
const [stock, setStock] = useState<StockLevel[]>([]);
const [connected, setConnected] = useState(false);
const prevLocationId = useRef(locationId);
useEffect(() => {
const ws = new WebSocket(`wss://inventory.api/ws/${warehouseId}`);
ws.onopen = () => setConnected(true);
ws.onclose = () => setConnected(false);
ws.onmessage = (event) => {
const update = JSON.parse(event.data);
setStock(prev => prev.map(s =>
s.sku === update.sku ? { ...s, quantity: update.quantity } : s
));
};
return () => ws.close();
}, [warehouseId]);
useEffect(() => {
if (!connected) return;
fetch(`/api/warehouses/${warehouseId}/stock?location=${locationId}`)
.then(res => res.json())
.then(setStock);
}, [warehouseId, locationId, connected]);
useEffect(() => {
if (prevLocationId.current !== locationId) {
setStock([]);
prevLocationId.current = locationId;
}
}, [locationId]);
useEffect(() => {
if (stock.length > 0) {
onStockChange(stock);
}
}, [stock, onStockChange]);
// ... render
}
이펙트가 네 개입니다. 각각 무슨 일을 할까요? 첫 번째 이펙트는... 웹소켓을 연결하는 것 같습니다. 두 번째는 connected가 바뀔 때 무언가를 가져오는 것 같습니다. 세 번째는 위치가 바뀔 때 재고를 초기화합니다. 네 번째는... 재고가 업데이트될 때마다 props로 받은 콜백을 호출합니다.
방금 머릿속 컴파일러가 네 번 돌아갔습니다.
마우스를 댄다고 해서 타입 정보를 볼 수 없고, 제한된 컨텍스트로 변경사항을 훑어볼 수밖에 없는 GitHub 코드 리뷰에서는 이 지점에서 이해 속도가 뚝 떨어집니다.
PR에 들어 있는 컴포넌트마다 이 과정을 반복해야 합니다.
이제 동일한 컴포넌트를 다듬은 버전으로 읽어보세요.
function InventorySync({ warehouseId, locationId, onStockChange }) {
const [stock, setStock] = useState<StockLevel[]>([]);
const [connected, setConnected] = useState(false);
const prevLocationId = useRef(locationId);
useEffect(function connectToInventoryWebSocket() {
const ws = new WebSocket(`wss://inventory.api/ws/${warehouseId}`);
ws.onopen = () => setConnected(true);
ws.onclose = () => setConnected(false);
ws.onmessage = (event) => {
const update = JSON.parse(event.data);
setStock(prev => prev.map(s =>
s.sku === update.sku ? { ...s, quantity: update.quantity } : s
));
};
return () => ws.close();
}, [warehouseId]);
useEffect(function fetchInitialStock() {
if (!connected) return;
fetch(`/api/warehouses/${warehouseId}/stock?location=${locationId}`)
.then(res => res.json())
.then(setStock);
}, [warehouseId, locationId, connected]);
useEffect(function resetStockOnLocationChange() {
if (prevLocationId.current !== locationId) {
setStock([]);
prevLocationId.current = locationId;
}
}, [locationId]);
useEffect(function notifyParentOfStockUpdate() {
if (stock.length > 0) {
onStockChange(stock);
}
}, [stock, onStockChange]);
// ... render
}
이제 함수 이름 네 개만 훑으면 전체 데이터 흐름을 파악할 수 있습니다. 웹소켓 연결, 초기 재고 조회, 위치 변경 시 초기화, 부모에게 알림.
특정 버그를 디버깅할 게 아니라면 코드 한 줄도 읽을 필요가 없습니다.
변경 사항은 문법뿐입니다. useEffect에 익명 화살표 함수 대신 기명 함수 표현식(named function expression)을 전달합니다.
// 익명 화살표 함수 (모두가 쓰는 방식)
useEffect(() => {
document.title = `${count} items`;
}, [count]);
// 기명 함수 표현식 (제가 권장하는 방식)
useEffect(
function updateDocumentTitle() {
document.title = `${count} items`;
},
[count],
);
함수를 따로 선언해서 이름으로 전달하는 방법(useEffect(updateDocumentTitle, [count]))도 있지만, 저는 인라인 방식을 선호합니다. 이름이 호출 지점 바로 옆에 있어서 함수 선언을 찾아 위로 스크롤할 필요가 없기 때문입니다.
디버깅 측면에서도 이점이 있습니다.
익명 화살표 함수에서 에러가 발생하면 at (anonymous) @ InventorySync.tsx:14처럼 표시됩니다.
한 파일에 이펙트가 네 개 있다면 이 정보는 아무 쓸모가 없습니다.
기명 함수를 쓰면 at connectToInventoryWebSocket @ InventorySync.tsx:14처럼 표시되어, 파일을 열지 않고도 어떤 이펙트가 문제인지 바로 알 수 있습니다.
Sentry 같은 모니터링 도구로 에러 리포트를 분류할 때 특히 유용합니다. 리액트 DevTools 프로파일링에서도 마찬가지입니다. 기명 함수는 이름으로 표시되지만, 익명 함수는 말 그대로 익명으로 표시됩니다.
가독성 측면의 이점만으로도 충분하지만, 이펙트에 이름을 붙이기 시작하면서 다른 일도 일어났습니다. 이펙트를 작성하는 방식 자체에 변화가 생겼습니다.
이 이펙트에 이름을 붙여보세요.
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
if (user?.preferences?.theme) {
document.body.className = user.preferences.theme;
}
return () => window.removeEventListener("resize", handleResize);
}, [user?.preferences?.theme]);
뭐라고 부를까요? syncWidthAndApplyTheme? "and"가 들어간다는 건 경고 신호와 같습니다. 이펙트가 서로 관련 없는 두 가지 일을 하고 있다는 뜻이기 때문입니다.
"and"나 "also"를 쓰지 않고는 이름을 붙이기 힘들다면, 이펙트가 분리되어야 한다는 신호입니다.
useEffect(function trackWindowWidth() {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
useEffect(
function applyUserTheme() {
if (user?.preferences?.theme) {
document.body.className = user.preferences.theme;
}
},
[user?.preferences?.theme],
);
명확하게 이름을 붙일 수 없다면, 하는 일이 너무 많다는 뜻입니다. 리액트도 생명주기 타이밍이 아니라 관심사별로 이펙트를 분리하도록 권장합니다.
이름은 주석으로는 할 수 없는 방식으로 그 원칙을 드러냅니다. 주석은 금세 뒤처지지만 이름은 항상 읽힙니다.
이 효과는 useEffect에만 국한되지 않습니다. useCallback, useMemo, 리듀서 함수 등 훅에 익명 함수를 전달하는 모든 곳에서 이름은 코드를 읽는 다음 사람에게 도움이 됩니다. 하지만 useEffect에서 효과가 가장 큽니다. 이펙트는 한눈에 이해하기 가장 어려운 훅이기 때문입니다. 실행되는 타이밍이 직관적이지 않고, 보이지 않는 클린업 규칙이 있으며, 의존성을 직접 역추적해야 합니다.
클린업 함수에도 이름을 붙일 수 있습니다. 익명 화살표 함수를 반환하는 대신 기명 함수를 반환하면 됩니다.
useEffect(
function pollServerForUpdates() {
const intervalId = setInterval(() => {
fetch(`/api/status/${serverId}`)
.then((res) => res.json())
.then(setServerStatus);
}, 5000);
return function stopPollingServer() {
clearInterval(intervalId);
};
},
[serverId],
);
클린업은 컨텍스트에서 목적이 명확할 때가 많아서 항상 이름을 붙이지는 않습니다. 하지만 클린업 로직이 복잡할 때는 pollServerForUpdates와 stopPollingServer처럼 대칭되는 이름이 설정과 해제의 역할을 한눈에 드러냅니다.
어떤 이펙트는 이름을 붙이기가 애매한데, 그 애매함 자체가 신호입니다.
updateStateBasedOnOtherState나 syncDerivedValue 같은 이름이 떠오른다면, 멈추세요.
그런 모호함은 대개 그 코드가 이펙트에 속하지 않는다는 뜻입니다. 이름 붙이기가 어려운 건 이펙트로 처리해선 안 될 일을 하고 있기 때문입니다.
// 아마 이건 필요 없습니다
useEffect(
function syncFullName() {
setFullName(`${firstName} ${lastName}`);
},
[firstName, lastName],
);
// 그냥 파생하면 됩니다
const fullName = `${firstName} ${lastName}`;
왜 이펙트 버전이 더 나쁠까요? 렌더 사이클이 하나 더 발생하기 때문입니다.
리액트가 컴포넌트를 렌더링하고, 이펙트를 실행하면 setFullName이 호출되고, 업데이트된 값으로 렌더링이 다시 일어납니다.
화면이 두 번 업데이트되는 데다, fullName이 이전 값으로 잠깐 렌더링되는 순간도 생깁니다.
파생 버전은 렌더 중에 값을 계산하므로 항상 정확하고 항상 동기화 상태이며, 리액트에 추가 부담을 주지 않습니다.
// 이것도 아마 필요 없습니다
useEffect(
function resetFormOnSubmit() {
if (submitted) {
setName("");
setEmail("");
setSubmitted(false);
}
},
[submitted],
);
// 이벤트 핸들러에 넣으세요
function handleSubmit() {
submitForm({ name, email });
setName("");
setEmail("");
}
폼 초기화는 이벤트 핸들러가 처리해야 할 일입니다. 사용자가 제출 버튼을 클릭하는 건 사용자 인터랙션이므로 인터랙션이 발생하는 곳에서 처리해야 합니다. 이펙트 버전은 submitted 플래그 변경에 반응하고, 이 추가 단계가 흐름을 파악하기 어렵게 만듭니다.
이펙트가 8~9개 달린 컴포넌트에서, 그 절반은 애초에 이펙트가 필요 없는 상태 간 동기화였던 경우를 여러 번 봤습니다.
AI 코드 생성 도구는 이 문제를 더 악화시킵니다. 이펙트를 잘못 사용한 수백만 개의 예시로 학습했기 때문에, 같은 안티패턴을 자신 있게 재현합니다. 잘못된 코드가 다시 학습 데이터가 되는 악순환입니다.
InventorySync 예시로 돌아가보면, 네 번째 이펙트인 notifyParentOfStockUpdate가 바로 이런 의심을 품어볼 만한 후보입니다.
상태 변화에 반응하는 이펙트 안에서 부모 콜백을 호출하는 패턴은 리액트 공식 문서의 "You Might Not Need an Effect"에서 구체적으로 언급한 패턴 중 하나입니다.
부모가 데이터를 직접 가져오거나, 재고 업데이트 시 소스(웹소켓 핸들러, fetch의 .then 콜백)에서 콜백을 호출하는 방법도 있습니다.
실제 코드베이스에서 매우 흔한 패턴이라 예시에 남겨뒀지만, 이름을 붙이면서 문제가 눈에 띄었습니다. notifyParentOfStockUpdate는 이름만으로 하는 일을 정확히 드러냅니다. 그리고 바로 그 명확함이 이 이펙트가 정말 필요한지 되묻게 만들었습니다.
이 검토를 통과하는 이름에는 패턴이 있습니다. 외부 시스템과 진정으로 동기화하는 이펙트는 대체로 명확하고 구체적인 이름을 가집니다. connectToWebSocket, initializeMapInstance, subscribeToGeolocation처럼 동사에서 이펙트의 종류를 알 수 있습니다. subscribe와 listen은 이벤트 기반, synchronize와 apply는 외부 시스템을 동기 상태로 유지하는 것, initialize는 일회성 설정을 의미합니다.
열심히 고른 이름에서 내부 상태를 이리저리 옮기는 느낌이 든다면, 그 코드는 다른 곳에 있어야 합니다.
리액트 19는 이 흐름을 더욱 밀어붙입니다. Actions는 뮤테이션을 처리하고, use()는 데이터 패칭을 처리하며, 서버 컴포넌트는 데이터 로딩을 위한 클라이언트 사이드 이펙트를 완전히 없앱니다.
현대 리액트 앱에 남는 이펙트는 외부 시스템과 진짜로 연동되는 코드이고, 바로 그 이펙트에 이름을 잘 붙일 가치가 있습니다.
Kyle Shevlin이 "useEncapsulation"이라는 훌륭한 글을 썼습니다. 모든 useEffect는 커스텀 훅 안에 있어야 한다는 주장이었습니다.
그의 논거는 실제 문제에서 출발합니다. 컴포넌트에 훅이 늘어날수록, 한 관심사에 속하는 구현 세부 사항들이 관련 없는 훅 선언들 사이에 흩어지게 됩니다.
커스텀 훅은 한 관심사의 상태, 이펙트, 핸들러를 한 곳에 모아 이 문제를 해결합니다.
function useWindowWidth() {
const [width, setWidth] = useState(
typeof window !== "undefined" ? window.innerWidth : 0,
);
useEffect(function trackWindowWidth() {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return width;
}
(typeof window !== 'undefined' 체크는 Next.js처럼 서버 사이드 렌더링을 지원하는 프레임워크에서 필요합니다. 서버에서 컴포넌트가 처음 렌더링될 때는 window가 존재하지 않기 때문입니다. 순수 클라이언트 사이드 앱을 만든다면 window.innerWidth를 바로 써도 됩니다.)
그런데 useWindowWidth 안에서도 여전히 useEffect에 이름을 붙였다는 점을 주목하세요.
커스텀 훅 안에도 이펙트가 여러 개 있을 수 있고, 그 안에서 디버깅할 때 스택 트레이스에 기명 함수가 있으면 여전히 도움이 됩니다.
모든 것을 커스텀 훅으로 만들 필요는 없습니다. 특정 컴포넌트의 동작에만 쓰이고 재사용될 일이 없는 일회성 이펙트도 있습니다.
그런 이펙트를 useCloseOnEscapeKeyForThisSpecificModal로 빼내는 건 아무 이득 없이 간접 계층만 늘리는 일입니다. 리액트 공식 문서도 조기 추상화를 경계합니다. 함수 컴포넌트가 하는 일이 많아질수록 길어지는 건 자연스러운 일이며, 로직이 생겼다고 해서 즉시 별도 파일로 분리할 필요는 없습니다.
저는 보통 이 기준을 따릅니다. 이펙트가 자체 상태를 관리하고 재사용될 가능성이 있으면 커스텀 훅으로 만들고, 연관 상태 없이 한 곳에서만 쓰이는 이펙트라면 이름을 붙이고 인라인으로 놔둡니다.
어떤 경우든 함수에는 이름을 붙입니다. 렌더링 없이 단위 테스트하고 싶은 경우, 특히 서드파티 SDK나 복잡한 외부 시스템과 상호작용하는 이펙트라면 핵심 로직을 별도 모듈로 추출하는 방법도 있습니다.
약 1년 전, Next.js 프로젝트에서 Mapbox 인스턴스를 애플리케이션 상태와 동기화하는 컴포넌트를 작업했습니다. 처음엔 지도 인스턴스 초기화, 줌 레벨 동기화, 지도 중심 좌표 동기화, 마커 클릭 이벤트 처리, 선택된 마커가 변경될 때 이벤트 리스너 정리까지 이펙트가 5개 있었습니다.
그 파일을 열 때마다 어떤 익명 이펙트가 무슨 일을 하는지 다시 파악하느라 30초씩 허비했습니다.
그래서 이름을 붙였습니다. initializeMapSDK, synchronizeZoomLevel, synchronizeCenterPosition, handleMarkerInteractions, cleanupStaleMarkerListeners. 즉시 어디서 무엇을 디버깅해야 하는지 보였습니다.
그런데 이름을 붙이니 또 다른 게 보였습니다.
다섯 가지 이름을 나란히 보고 나니, cleanupStaleMarkerListeners가 handleMarkerInteractions와 별개의 관심사가 아니라는 게 보였습니다.
handleMarkerInteractions가 리스너를 추가한다면, cleanupStaleMarkerListeners는 오래된 리스너를 제거했으므로 사실상 같은 동기화 작업의 클린업 쌍이었습니다.
두 이펙트를 제대로 된 클린업 반환이 있는 하나의 이펙트로 합쳤고 컴포넌트가 단순해졌습니다. 그다음엔 synchronizeZoomLevel과 synchronizeCenterPosition이 지도 인스턴스 준비 여부에 의존성을 동일하게 가졌고, 항상 함께 실행된다는 걸 알아챘습니다. 그래서 둘을 synchronizeMapViewport로 합쳤습니다.
그렇게 이펙트 다섯 개가 세 개가 됐고, 세 개의 경계가 원래 다섯 개보다 훨씬 명확해졌습니다.
Sergio Xalambrí가 2020년에 useEffect 함수에 이름 붙이기에 대해 글을 썼습니다. Cory House도 같은 말을 했습니다. 새로운 이야기가 아닙니다. 그럼에도 거의 아무도 하지 않는 이유는, 커뮤니티가 집단적으로 useEffect(() => {를 이펙트를 쓰는 유일한 방법으로 내재화했기 때문입니다.
공식 문서든, 튜토리얼이든, AI 생성 코드든 그대로 가져다 씁니다. 익명 화살표 함수가 기본값이 됐고, 한번 굳어진 기본값은 좀처럼 바뀌지 않습니다.
전환 비용은 거의 없습니다. 새 라이브러리도, 빌드 플러그인도 필요 없습니다. 함수에 이름 하나를 추가하면 되고, 오래된 파일을 열어 모든 이펙트를 다시 읽지 않아도 무슨 일을 하는지 바로 알게 되는 순간 그 차이를 느낄 겁니다.
오늘부터 이펙트에 이름을 붙여 보세요!