[번역] useEffect의 종말: React 19가 바꾸는 모든 것
Soshy·

React 커뮤니티에는 이런 농담이 있습니다.
useEffect를 충분히 오래 바라보면,useEffect도 당신을 바라본다.
React를 진지하게 사용해본 개발자라면 누구나 한 번쯤은 이런 경험을 있으실 겁니다. 새벽 2시에 의존성 배열 때문에 무한 루프에 빠지거나, 클린업 함수가 조용히 실패하거나, 데이터 페칭(fetching) 중 발생한 레이스 컨디션을 며칠에 걸쳐 디버깅했던 경험 말입니다. 우리는 오랫동안 이것을 어쩔 수 없는 것으로 받아들여 왔습니다. useEffect는 React의 탈출구였고, 탈출구란 원래 불편한 법이니까요.
React 19는 그 전제를 바꿉니다. use(), useActionState, useOptimistic, 그리고 서버 컴포넌트에 대한 일급(first-class) 지원이라는 새로운 API들과 함께, React 팀은 비판론자들이 수년간 주장해온 것을 사실상 인정했습니다. useEffect는 처음부터 우리가 써왔던 대부분의 용도에 맞는 도구가 아니었다는 것을요.
이 글은 useEffect에 대한 조사가 아닙니다. useEffect는 사라지지 않으며, 여전히 정당한 역할을 갖고 있습니다. 하지만 그 역할은 이제 더 좁고, 더 정밀하며, 더 솔직해졌습니다. 이 변화가 왜 일어났는지, 그리고 무엇이 이를 대체하는지 이해하는 것이 2026년 React 개발자에게 가장 중요한 과제입니다.

useEffect는 처음부터 무엇이 문제였나?
React 19가 우리를 어디로 데려가는지 이해하려면, useEffect가 우리를 어디에 남겨두었는지 솔직하게 바라볼 필요가 있습니다. 이 훅은 React 16.8에서 컴포넌트를 외부 시스템(브라우저 API, 서드파티 라이브러리, WebSocket 등)과 동기화하는 수단으로 도입되었습니다. 그게 전부였습니다. 바로 그것이 useEffect의 본래 역할이었습니다.
그런데 훅이 주류가 된 지 불과 몇 달 만에 useEffect는 React 개발의 만능 도구가 되어버렸습니다.
- 마운트 시 데이터 페칭?
useEffect - props에서 state 파생?
useEffect - 분석 이벤트 전송?
useEffect - 문서 제목 설정?
useEffect
이 중 일부는 납득할 수 있습니다. 하지만 나머지는 커뮤니티가 조용히 정상화시킨 코드 악취(code smell)였습니다.
"Effect가 필요 없을 수도 있습니다. Effect는 React 패러다임으로부터의 탈출구입니다. React 밖으로 나가서 컴포넌트를 외부 시스템과 동기화할 수 있게 해줍니다."
— React 공식 문서, 2023
문서는 수년간 이 점을 경고해왔습니다. 커뮤니티가 충분히 귀 기울이지 않은 데는 두 가지 이유가 있습니다. 라이브러리 자체에 더 나은 대안이 없었고, 그 경고가 실제 문제로 돌아오기 전까지는 너무 추상적으로 느껴졌기 때문입니다. 실제 문제란 이런 것들이었습니다. 낡은 클로저, Strict Mode에서의 이중 실행, 열 개짜리 의존성 배열, 조용히 실패하는 클린업 함수, 그리고 데이터를 페칭하고 리렌더링하고 다시 페칭하다가 레이스 컨디션으로 UI 상태가 오염되는 컴포넌트들.
React 19의 새로운 기본기: use() 훅
React 19에서 가장 중요한 추가 사항이자, useEffect의 데이터 페칭 남용에 대한 가장 직접적인 해답은 바로 use() 훅입니다. React의 다른 모든 훅과 달리, use()는 조건부로 호출할 수 있으며, 루프 안에서도 사용할 수 있습니다. 훅의 규칙을 어기는데, 이는 의도된 것입니다.
use()는 Promise 또는 Context 객체를 인자로 받습니다. Promise를 전달하면, React는 Promise가 resolve될 때까지 컴포넌트를 일시 중단(suspend)한 뒤, resolve된 값으로 렌더링합니다. 이것이 Suspense 네이티브 데이터 페칭입니다. 로딩 상태 변수도, 의존성 목록의 빈 배열도, "마운트 시 페칭" 안티패턴도 이제 필요 없습니다.
React 18 이전 방식
// 과거 방식: 데이터 페칭을 위한 useEffect
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUser(userId)
.then(data => {
if (!cancelled) setUser(data);
})
.catch(err => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorBoundary />;
return <UserCard user={user} />;
}
React 19 방식
// 새로운 방식: Suspense와 함께하는 use()
import { use } from 'react';
function UserProfile({ userPromise }) {
// React가 여기서 일시 중단 — 로딩 상태 불필요
const user = use(userPromise);
return <UserCard user={user} />;
}
// 부모 컴포넌트에서 Suspense + ErrorBoundary로 감싸기:
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={fetchUser(userId)} />
</Suspense>
</ErrorBoundary>
차이가 극명합니다. 컴포넌트는 주어진 데이터로 무엇을 렌더링할지에 대한 순수한 서술이 되었습니다. 에러 처리, 로딩 상태, 취소 로직이 모두 컴포넌트 밖으로 빠져나와 React 런타임으로 이동했습니다. 이것은 단순히 코드가 줄어든 것이 아닙니다. 근본적으로 더 나은 멘탈 모델입니다.

Form Actions와 useActionState: Form Effect의 종말
데이터 페칭이 useEffect의 가장 흔한 오용이었다면, 폼 처리는 가장 고통스러운 오용이었습니다. form 상태를 감시하는 useEffect, 수동으로 관리하는 로딩 및 에러 상태, 제출 한 번에 다섯 개의 state 변수를 업데이트하는 핸들러. 이 패턴이 너무 장황한 나머지 폼 라이브러리 생태계 전체가 여기서 탄생했을 정도입니다.
React 19는 Actions를 도입했습니다. form 엘리먼트의 action prop에 직접 전달할 수 있는 비동기 함수입니다. useActionState와 결합하면, 대기(pending) 및 에러 상태 관리가 내장된 비동기 폼 제출 패턴을 간결하게 구현할 수 있습니다.
// Form Actions + useActionState
import { useActionState } from 'react';
async function submitComment(prevState, formData) {
const text = formData.get('comment');
try {
await postComment(text);
return { success: true, error: null };
} catch (e) {
return { success: false, error: e.message };
}
}
function CommentForm() {
const [state, action, isPending] = useActionState(
submitComment,
{ success: false, error: null }
);
return (
<form action={action}>
<textarea name="comment" />
<button disabled={isPending}>
{isPending ? "제출 중..." : "댓글 달기"}
</button>
{state.error && <p className="error">{state.error}</p>}
</form>
);
}
무엇이 사라졌는지 보십시오. useEffect도, 로딩용 useState도, 수동 pending 플래그도, 클린업 함수도 없습니다. 폼 제출의 전체 상태 생명주기(idle → pending → success/error)가 단 하나의 훅 호출로 표현됩니다.
핵심 인사이트
useActionState는 폼 제출에서useState + useEffect + 수동 pending 플래그패턴을 대체합니다. action 함수는 이전 상태와FormData를 인자로 받기 때문에, 결과를 누적하거나 에러 시 롤백하는 것이 매우 간단해집니다.
useOptimistic으로 낙관적 업데이트 구현하기
낙관적 UI 업데이트는 현대 React에서 구현하기 가장 까다로운 패턴 중 하나였습니다. useEffect, useReducer, 그리고 신중하게 작성한 롤백 로직을 조율해야 했기 때문입니다. React 19의 useOptimistic은 이 복잡함을 단순하고 선언적인 기본 요소 하나로 압축합니다.
import { useOptimistic, useActionState } from 'react';
function LikeButton({ post }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
post.likes,
(current, increment) => current + increment
);
async function handleLike() {
addOptimisticLike(1); // UI를 즉시 업데이트
await likePost(post.id); // 서버에서 확인
// await가 reject되면, React가 자동으로 롤백
}
return (
<button onClick={handleLike}>
♥ {optimisticLikes}
</button>
);
}
핵심은 마지막 주석에 있습니다. 서버 요청이 실패하면 React가 낙관적 업데이트를 자동으로 이전 상태로 되돌립니다. 과거에는 useEffect 클린업 안에 롤백 로직을 직접 작성해야 했고, 자주 잘못 구현되었던 기능입니다.
그렇다면 useEffect를 언제 써야 할까?
여기서 뉘앙스가 중요해집니다. React 19는 useEffect를 deprecated하지 않습니다. 그 역할을 더 명확하게 정의할 뿐입니다. 이제 올바른 질문은 "이것에 useEffect를 써도 될까?"가 아니라 "이 문제가 정말로 외부 시스템과의 동기화에 관한 것인가?"입니다.
2026년 기준, useEffect의 정당한 사용 사례는 다음과 같습니다.
// 1. 서드파티 DOM 라이브러리와 동기화
useEffect(() => {
const chart = new ChartLib(ref.current, data);
return () => chart.destroy();
}, [data]);
// 2. WebSocket 또는 EventSource 관리
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (e) => dispatch({ type: 'message', data: e.data });
return () => ws.close();
}, [url]);
// 3. 엘리먼트에 포커스를 명령형으로 설정
useEffect(() => {
if (isOpen) inputRef.current?.focus();
}, [isOpen]);
세 예시 모두 공통점이 있습니다. React의 선언적 세계와 외부 명령형 시스템 사이의 경계를 다루고 있다는 점입니다. WebSocket은 React의 렌더 사이클을 알지 못합니다. 서드파티 차트 라이브러리는 JSX를 이해하지 못합니다. 이런 지점이 바로 탈출구가 필요한 진짜 순간입니다.
React 작성 방식에 대한 의미
React 19의 새로운 API들이 가져오는 실질적인 변화는 방향의 전환입니다. 컴포넌트는 더 가볍고, 더 순수하고, 더 선언적으로 변합니다. 로딩 플래그, 에러 변수, 클린업 함수 같은 명령형 배관 작업들이 컴포넌트 밖으로 빠져나와 React 런타임과 action, ErrorBoundary의 몫이 됩니다.
이것은 좋은 변화이지만, 사고방식의 전환이 필요합니다. 훅 시대(2018–2024)에 React를 배운 개발자들은 useEffect에 대한 반사적인 습관이 깊이 배어 있을 겁니다. 무언가 "일어나야 할" 때 반사적으로 useEffect에 손을 뻗는 습관 말입니다. React 19는 그 반사를 한 번 멈추고 돌아보라고 합니다. 이게 외부 시스템 동기화 문제인가, 아니면 비동기 데이터, 폼 상태, 낙관적 UI 문제인가? 후자라면, 이제 더 나은 도구가 생겼습니다.
"최고의 코드는 모든 엣지 케이스를 처리하는 코드가 아닙니다. 그 엣지 케이스들에 대한 책임을 한 번에, 올바르게, 모두를 위해 처리하는 레이어로 옮기는 코드입니다."
— Faisal Haque, JavaScript in Plain English
마이그레이션 부담은 크지 않습니다. React 19는 완전히 하위 호환됩니다. 오늘 당장 모든 useEffect를 걷어낼 필요는 없습니다. 다만 새 컴포넌트를 작성하거나 기존 컴포넌트를 리팩터링할 때, 스스로에게 한 번 물어보시기 바랍니다. 이 문제를 위해 설계된 React 19 기본 요소가 따로 있지 않을까?
대부분의 경우, useEffect보다 더 나은 답을 찾게 될 것입니다.

결론: 더 좁아지고, 더 솔직해진 useEffect
우리가 알던 useEffect의 죽음은 비극이 아닙니다. 오랫동안 미뤄온 정리입니다. 이 훅은 외부 시스템과의 진정한 동기화를 위해 아껴서 사용할 때 가장 빛났습니다. React 19는 useEffect를 죽이지 않습니다. 처음부터 useEffect의 역할이 아니었던 것들을 제자리로 돌려보낼 뿐입니다.
use(), useActionState, useOptimistic은 나중에 덧붙인 기능들이 아닙니다. 이것들은 React 컴포넌트가 어떤 모습이어야 하는지에 대한 일관된 비전을 나타냅니다. 비동기 배관 작업, 레이스 컨디션 방어, 로딩 상태 관리. 이런 것들은 컴포넌트가 아니라 프레임워크 수준에서 처리되어야 하며, 컴포넌트는 UI를 선언적으로 서술하는 데만 집중해야 한다는 비전입니다.
2026년에 관용적인 React를 작성하고자 한다면, 이 기본 요소들을 익히는 것은 선택이 아닙니다. 다행히 이것들은 대체하는 패턴들보다 더 단순하고, 더 읽기 쉽고, 오류도 덜 납니다. React는 무언가를 제거했기 때문에 더 어려워진 게 아니라, 오히려 더 쉬워졌습니다.