들어가며
메이저 버전 업그레이드 공지가 뜨면 늘 하는 고민이 있다. "지금 당장 옮겨야 하나, 아니면 좀 더 지켜볼까." React 19도 마찬가지였다. 릴리즈 노트를 처음 훑어봤을 때는 use 훅이나 Actions 같은 개념들이 매력적으로 보였지만, 실제로 우리 프로젝트에 들어왔을 때 어떤 코드가 사라지고 어떤 코드가 새로 생기는지는 직접 옮겨보기 전까진 감이 안 왔다.
이 글은 사이드 프로젝트 하나와 팀 프로젝트의 일부 기능을 React 19로 옮기면서 실제로 써본 기능들, 기대와 달랐던 부분, 그리고 마이그레이션 과정에서 부딪힌 문제들을 정리한 기록이다.
1. Actions — "로딩 상태 관리 코드"가 사라졌다
1-1. 이전에는 이렇게 짰다
React 18까지 폼을 제출하는 로직을 짜면, 로딩/에러/성공 상태를 관리하는 보일러플레이트가 항상 따라다녔다.
// React 18 방식
function UpdateNameForm() {
const [name, setName] = useState('');
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setIsPending(true);
setError(null);
try {
const result = await updateName(name);
if (result.error) {
setError(result.error);
return;
}
redirectTo('/profile');
} catch (err) {
setError(err.message);
} finally {
setIsPending(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button disabled={isPending}>{isPending ? '저장 중...' : '저장'}</button>
{error && <p>{error}</p>}
</form>
);
}
isPending, error 상태를 매 폼마다 거의 똑같은 패턴으로 반복해서 짰다. 팀 내에 폼이 십수 개 있었는데, 이 패턴이 컴포넌트마다 조금씩 다르게 구현되어 있어서(어떤 곳은 loading, 어떤 곳은 isSubmitting) 일관성도 없었다.
1-2. useActionState로 바꾼 뒤
React 19의 useActionState를 쓰면 이 패턴이 상당히 압축된다.
// React 19 방식
function UpdateNameForm() {
const [error, submitAction, isPending] = useActionState(
async (previousState, formData) => {
const name = formData.get('name');
const result = await updateName(name);
if (result.error) return result.error;
redirectTo('/profile');
return null;
},
null // 초기 에러 상태
);
return (
<form action={submitAction}>
<input name="name" />
<button disabled={isPending}>{isPending ? '저장 중...' : '저장'}</button>
{error && <p>{error}</p>}
</form>
);
}
isPending을 직접 useState로 관리하지 않아도 React가 액션 함수의 실행 상태를 추적해서 자동으로 제공해준다. try/catch/finally로 감싸던 코드도, action 함수가 던지는 에러나 반환값을 React가 대신 다뤄준다.
처음 적용했을 때 가장 크게 느낀 변화는 코드 줄 수가 아니라 "어디서 로딩 상태를 켜고 끄는 걸 깜빡했는지" 찾아다니는 일이 사라졌다는 점이었다. finally에서 setIsPending(false)를 빼먹어서 버튼이 계속 비활성화 상태로 남아있던 버그를 팀에서 두어 번 겪은 적이 있는데, 이 패턴 자체가 구조적으로 사라졌다.
1-3. 다만 기대만큼은 아니었던 지점
formData.get('name')처럼 폼 데이터를 다루는 방식이 익숙해지기 전까지는 오히려 헷갈렸다. useState로 각 필드를 직접 관리하던 팀원들 입장에서는 "왜 갑자기 네이티브 FormData API를 다뤄야 하지?"라는 반응이 나왔다. 특히 체크박스나 다중 선택 같은 필드는 formData.getAll()을 써야 하는데, 이런 세부 API를 팀원들에게 다시 설명하는 과정이 필요했다.
또한 복잡한 클라이언트 사이드 유효성 검사(입력 중 실시간으로 검증하는 케이스)가 필요한 폼에서는, 오히려 useActionState보다 기존 react-hook-form 조합이 더 편한 경우도 있었다. 모든 폼을 Actions로 바꾸는 게 항상 정답은 아니라는 걸 실제로 옮겨보고서야 체감했다.
2. useOptimistic — 낙관적 업데이트 로직의 단순화
댓글 좋아요 기능처럼, 서버 응답을 기다리지 않고 UI를 먼저 업데이트해주는 패턴(낙관적 업데이트)은 사용자 경험상 거의 필수에 가깝다. 기존에는 이걸 직접 구현하려면 상태를 두 벌(실제 상태, 화면에 보여줄 임시 상태) 관리해야 했다.
// React 19의 useOptimistic
function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes);
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(currentLikes, increment) => currentLikes + increment
);
async function handleLike() {
addOptimisticLike(1); // 화면엔 즉시 반영
const updated = await likePost(postId); // 실제 요청은 백그라운드에서
setLikes(updated.likes); // 요청 완료 후 실제 값으로 동기화
}
return <button onClick={handleLike}>❤️ {optimisticLikes}</button>;
}
실제로 적용해보니 가장 좋았던 지점은, 요청이 실패했을 때 자동으로 이전 상태로 롤백된다는 점이었다. 기존 방식에서는 요청 실패 시 수동으로 임시 상태를 되돌리는 로직을 따로 짜야 했는데, useOptimistic은 실제 상태(likes)가 갱신되지 않으면 다음 렌더링에서 자연스럽게 낙관적 값이 실제 값으로 수렴한다.
다만 주의할 점도 있었다. 네트워크가 느린 환경에서 사용자가 좋아요 버튼을 연타하면, 낙관적 업데이트가 여러 번 겹치면서 최종적으로 서버 값과 화면 값이 잠깐 어긋나 보이는 경우가 있었다. 디바운스나 버튼 비활성화 처리를 별도로 신경 써야 했는데, 이 부분은 useOptimistic이 알아서 해결해주는 문제는 아니었다.
3. use — 조건부로 Promise/Context를 읽을 수 있게 된 훅
use는 기존 훅들과 성격이 다르다. 다른 훅들은 "훅의 규칙"(조건문 안에서 호출 금지)을 반드시 지켜야 했는데, use는 조건문이나 반복문 안에서도 호출할 수 있다.
// 조건부로 Context를 읽는 예시 — 기존엔 불가능했던 패턴
function Message({ show, contextValue }) {
if (show) {
const theme = use(ThemeContext); // 조건문 안에서 Context 사용
return <p style={{ color: theme.color }}>메시지</p>;
}
return null;
}
우리 프로젝트에서는 주로 Suspense와 조합해서 데이터 페칭에 사용했다.
function Comments({ commentsPromise }) {
// Promise가 resolve 될 때까지 가장 가까운 Suspense가 fallback을 보여줌
const comments = use(commentsPromise);
return (
<ul>
{comments.map((c) => (
<li key={c.id}>{c.text}</li>
))}
</ul>
);
}
function Post({ id }) {
const commentsPromise = fetchComments(id); // 여기서 await 하지 않고 Promise를 그대로 전달
return (
<Suspense fallback={<Spinner />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
);
}
기존에 useEffect + useState 조합으로 짜던 데이터 페칭 코드(로딩 상태 별도 관리, 언마운트 시 클린업 처리 등)가 Suspense 경계로 위임되면서 컴포넌트 코드 자체는 확실히 간결해졌다.
다만 실제로 적용하면서 가장 헷갈렸던 지점은, Promise를 컴포넌트 렌더링 도중에 새로 생성하면 안 된다는 규칙이었다. 위 예시에서 fetchComments(id)를 Post 컴포넌트가 리렌더링될 때마다 새로 호출하면, 매번 새 Promise가 생성되어 Comments가 계속 Suspense fallback을 다시 보여주는 문제가 생긴다. 이걸 방지하려면 데이터 페칭을 상위(주로 서버 컴포넌트나 별도 캐싱 레이어)에서 한 번만 하고 Promise를 그대로 내려주는 구조로 짜야 했는데, 이 설계 원칙을 팀 내에 공유하는 데 생각보다 시간이 걸렸다.
4. ref를 prop처럼 — forwardRef가 사라졌다
체감상 가장 자잘하지만 반가웠던 변화다. 지금까지 재사용 가능한 컴포넌트를 만들 때마다 forwardRef로 감싸는 게 거의 관례처럼 붙어다녔다.
// React 18까지
const Input = forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});
// React 19
function Input({ ref, ...props }) {
return <input ref={ref} {...props} />;
}
ref를 그냥 일반 prop처럼 받을 수 있게 되면서, forwardRef로 컴포넌트를 감싸는 래핑 코드가 통째로 사라졌다. 사소해 보이지만, 디자인 시스템 컴포넌트를 수십 개 관리하던 입장에서는 매 컴포넌트마다 반복되던 forwardRef 보일러플레이트가 없어진 게 꽤 크게 느껴졌다. 특히 TypeScript와 함께 쓸 때 forwardRef의 제네릭 타입을 매번 지정해주는 게 은근히 번거로웠는데, 그 부분도 자연스럽게 해결됐다.
5. 마이그레이션 과정에서 실제로 부딪힌 문제들
5-1. 서드파티 라이브러리 호환성
React 19로 올리자마자 몇몇 라이브러리에서 peer dependency 경고가 쏟아졌다. 특히 상태관리나 애니메이션 관련 라이브러리 일부가 아직 React 19를 공식 지원하지 않고 있었다. npm install --legacy-peer-deps로 일단 넘어가긴 했지만, 프로덕션에 실제로 반영하기 전에 해당 라이브러리들이 React 19 환경에서 실제로 문제없이 동작하는지 QA를 별도로 진행해야 했다.
5-2. Strict Mode에서 useEffect 이중 실행과의 충돌
React 19에서 새로 생긴 문제는 아니지만, Actions로 마이그레이션하며 기존 useEffect 기반 데이터 페칭 코드와 새 use 기반 코드가 한 화면에 공존하는 과도기 동안, Strict Mode의 개발 환경 이중 렌더링 때문에 혼란스러운 순간들이 있었다. 어떤 게 새 코드의 정상 동작이고 어떤 게 기존 코드의 버그인지 구분하는 데 처음엔 시간이 걸렸다.
5-3. 팀 내 학습 곡선
가장 과소평가했던 부분이다. 기능 자체는 릴리즈 노트만 봐도 이해가 됐지만, "언제 Actions를 쓰고 언제 안 쓰는지", "use를 Suspense와 어떻게 조합해야 하는지" 같은 설계 판단은 문서만 봐서는 감이 잘 안 잡혔다. 팀 내에서 작은 세션을 두 번 정도 열어서 실제 코드를 같이 보며 "이럴 땐 이렇게, 저럴 땐 저렇게" 기준을 맞추는 과정이 필요했다.
6. 결론 — 지금 바로 전면 도입할 만한가
실제로 옮겨보고 나서 내린 판단은 다음과 같다.
- 새로 시작하는 프로젝트라면: 적극 추천. 특히 Actions와 useActionState는 폼이 많은 서비스에서 코드량과 버그 발생 지점을 확실히 줄여준다.
- 이미 운영 중인 대규모 프로젝트라면: 전면 마이그레이션보다는, 새로 만드는 기능 단위부터 점진적으로 적용하는 편을 추천한다. 서드파티 라이브러리 호환성 이슈가 생각보다 발목을 잡는 경우가 있어서, 전면 업그레이드 전에 사용 중인 주요 의존성들의 React 19 지원 현황을 먼저 확인하는 게 안전하다.
- 팀 학습 비용은 과소평가하지 말 것: 기능이 간결해 보이는 것과, 팀 전체가 새로운 설계 패턴에 익숙해지는 건 별개의 문제였다. 마이그레이션 일정에 학습/합의 시간을 별도로 반영하는 걸 추천한다.
마치며
메이저 버전 업그레이드는 늘 "지금 옮길 가치가 있나"라는 질문에서 시작한다. 이번에 React 19를 실제로 적용해보면서 느낀 건, 겉으로 보이는 코드량 감소보다 "상태를 어디서 어떻게 관리할 것인가"에 대한 팀의 암묵적 규칙들이 다시 정리되는 계기가 됐다는 점이 더 컸다. 새 API를 배우는 것보다, 그 API가 전제하는 설계 철학을 팀이 같이 이해하는 과정이 실제 마이그레이션 시간의 더 큰 부분을 차지했다.
참고하면 좋을 자료
- React 공식 블로그의 React v19 릴리즈 노트 — 이 글에서 다룬 기능들의 공식 설명과 예시
- React 공식 문서의 use API 레퍼런스
- React 공식 문서의 useActionState, useOptimistic API 레퍼런스
'Frontend' 카테고리의 다른 글
| [Frontend] Typescript는 빌드할 때, 무슨 일을 하는가? (0) | 2026.07.09 |
|---|---|
| [Frontend] Promis.race와 Promise.allSettled 구현하기 (0) | 2026.07.09 |
| [Frontend]브라우저는 화면을 어떻게 그리는가 (0) | 2026.07.08 |
| [Frontend] Webpack, Vite, esbuild 뜯어보기 (0) | 2026.07.08 |
| [Frontend] 레거시 코드 리팩토링 (0) | 2026.07.08 |