Prologue: 늘 귀찮았던 비동기 로딩 처리
사학과 시절 역사 논문을 쓸 때 사료를 모으고 정리하는 과정은 지루하지만 꼭 필요한 과정이었습니다. 개발을 배우고 내 서비스를 직접 만들면서도 비슷한 느낌을 주는 작업이 있었는데, 바로 **'비동기 폼(Form) 전송과 로딩 상태 처리'**였습니다.
처음 리액트로 간단한 이메일 구독 폼을 만들었을 때 작성했던 코드는 대략 이랬습니다.
function SubscriptionForm() {
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
await subscribeEmail(email);
alert('구독 성공!');
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={e => setEmail(e.target.value)} disabled={isLoading} />
<button type="submit" disabled={isLoading}>
{isLoading ? '구독 중...' : '구독하기'}
</button>
{error && <p>{error}</p>}
</form>
);
}
이 코드는 작동은 잘 합니다. 하지만 겨우 비동기 요청 하나 보내는데 useState가 3개나 들어가고, try-catch-finally 블록 안에서 상태를 껐다 켰다 하는 보일러플레이트 코드가 너무 많았습니다. 폼이 5개, 10개로 늘어나면 이 귀찮은 짓을 계속 반복해야 했습니다.
"더 나은 방법은 없을까?" 고민하던 찰나에 리액트 19의 Actions를 접하게 되었고, 이 지저분한 상태 관리의 흐름을 완전히 다르게 해석할 수 있었습니다.
Concept: '비동기 과정을 리액트가 직접 추적한다'
처음 리액트 19의 Action 개념을 봤을 땐 단순한 문법 설탕(Syntactic Sugar)인 줄 알았습니다. 하지만 자세히 들여다보니 리액트가 비동기 데이터의 생명주기를 바라보는 관점 자체가 바뀌었다는 것을 깨달았습니다.
리액트 19에서 Action은 **"비동기 함수를 실행할 때 리액트가 그 함수의 대기(Pending) 상태를 자동으로 감지하고 관리해 주는 기능"**입니다.
기존에는 비동기 요청의 시작과 끝을 개발자가 직접 상태 변화(setIsLoading(true), setIsLoading(false))를 통해 알려주어야 했습니다. 하지만 리액트 19에서는 함수가 Promise를 반환하면, 리액트는 그 Promise가 Resolve되거나 Reject될 때까지의 전 과정을 스스로 트래킹합니다.
이걸 깨닫고 나니 그동안 리액트 코드가 왜 그렇게 지저분했는지 와닿았습니다. 우리가 수동으로 하던 '로딩 상태 스위치 끄고 켜기'를 리액트 내부 엔진에 위임할 수 있게 된 것입니다.
Deep Dive: 리액트 19 Action API 삼총사
리액트 19에서는 Action을 실무에 쉽게 적용할 수 있도록 몇 가지 새로운 Hook과 기능을 제공합니다. 내 서비스에 직접 적용하면서 유용하게 썼던 세 가지 핵심 요소를 정리해 보았습니다.
1. HTML Form과의 강력한 결합: <form action={asyncFn}>
가장 먼저 와닿았던 변화는 <form> 태그의 action 속성에 비동기 함수를 직접 전달할 수 있게 된 점입니다. 기존 HTML의 action은 다른 페이지로 이동하는 정적 URL만 지정할 수 있었는데, 이제는 자바스크립트 비동기 함수를 바인딩할 수 있습니다.
// 리액트 19 방식
function SubscriptionForm() {
const handleSubscribe = async (formData) => {
const email = formData.get('email');
await subscribeEmail(email); // 비동기 작업
};
return (
<form action={handleSubscribe}>
<input name="email" type="email" />
<button type="submit">구독하기</button>
</form>
);
}
이 간단한 코드 안에서 다음과 같은 일들이 자동으로 일어납니다:
e.preventDefault()를 직접 호출하지 않아도 기본 제출 브라우저 동작이 방지됩니다.- 입력값들을 수동으로
useState로 바인딩할 필요 없이FormData객체를 통해 직접 추출할 수 있어 제어 컴포넌트(Controlled Component)의 피로감이 줄어듭니다. - 제출 버튼 클릭 시 비동기 함수가 끝날 때까지 폼 내의 로딩 상태가 내부적으로 유지됩니다.
2. 로딩과 에러를 한 번에 잡는 useActionState
기존의 useFormState가 리액트 19에서는 useActionState로 이름이 바뀌며 기능이 한층 더 유용해졌습니다. 비동기 Action의 실행 상태와 결과값을 한 번에 관리할 수 있게 해주는 마법 같은 Hook입니다.
import { useActionState } from 'react';
async function updateProfile(prevState, formData) {
try {
const name = formData.get('username');
await api.updateUsername(name);
return { success: true, message: '프로필 업데이트 완료!' };
} catch (err) {
return { success: false, message: err.message };
}
}
function ProfileForm() {
// state: Action의 반환값, formAction: form에 바인딩할 함수, isPending: 실행 대기 상태
const [state, formAction, isPending] = useActionState(updateProfile, null);
return (
<form action={formAction}>
<input name="username" type="text" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? '저장 중...' : '저장'}
</button>
{state && <p className={state.success ? 'text-green' : 'text-red'}>{state.message}</p>}
</form>
);
}
useActionState가 반환하는isPending덕분에 더 이상 수동으로 로딩용useState를 만들 필요가 없어졌습니다.- 비동기 연산의 결과(
state)도 상태로 관리되어 컴포넌트에 즉각 반영됩니다.
3. 하위 컴포넌트에서 폼 상태를 아는 useFormStatus
복잡한 폼을 만들다 보면 폼 내부의 깊숙한 자식 컴포넌트(예: 커스텀 제출 버튼)에서 현재 폼이 전송 중인지(pending) 알아야 할 때가 있습니다. 이전에는 Context API를 파거나 Props Drilling을 해야 했습니다.
리액트 19의 useFormStatus는 이 문제를 깔끔하게 해결해 줍니다.
import { useFormStatus } from 'react-dom';
function SubmitButton() {
// 부모 <form>의 상태를 자동으로 감지
const { pending, data, method, action } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '전송하는 중...' : '확인'}
</button>
);
}
function ParentForm() {
return (
<form action={submitAction}>
<input name="title" />
{/* Props로 pending을 넘겨주지 않아도 내부에서 감지 가능 */}
<SubmitButton />
</form>
);
}
단, 주의해야 할 점은 useFormStatus는 반드시 <form>의 하위(자식) 컴포넌트 내부에서 호출되어야 제대로 부모 폼의 상태를 감지할 수 있습니다. 폼과 같은 계층에서 호출하면 작동하지 않습니다.
Application: 내 프로젝트의 비동기 버튼 리팩토링
이 개념들을 내 서비스의 '포스트 좋아요(Like) 버튼'에 직접 적용해 보기로 했습니다. 좋아요 버튼은 비동기 네트워크 요청이 들어가기 때문에 클릭 시 로딩 처리가 필수적이었고, 특히 네트워크 지연 시 사용자 경험이 나빠지는 대표적인 컴포넌트입니다.
기존 코드는 대략 아래와 같았습니다.
// 기존 리팩토링 전 코드
function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes);
const [isPending, setIsPending] = useState(false);
const handleLike = async () => {
setIsPending(true);
try {
const updatedLikes = await api.toggleLike(postId);
setLikes(updatedLikes);
} catch (error) {
console.error(error);
} finally {
setIsPending(false);
}
};
return (
<button onClick={handleLike} disabled={isPending}>
{isPending ? '⏳' : '❤️'} {likes}
</button>
);
}
이걸 리액트 19의 useTransition을 활용하여 수동 useState 로딩 처리를 완전히 걷어냈습니다. 또한 반응성을 높이기 위해 새롭게 지원되는 낙관적 업데이트(Optimistic Update) Hook인 useOptimistic까지 조합했습니다.
import { useOptimistic, useTransition } from 'react';
function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes);
const [isPending, startTransition] = useTransition();
// 낙관적 상태 정의 (서버 응답 전에 미리 화면을 업데이트하기 위함)
const [optimisticLikes, setOptimisticLikes] = useOptimistic(
likes,
(state, newLikes) => newLikes
);
const handleLike = () => {
// startTransition 내부의 비동기 함수가 실행되는 동안 isPending이 자동으로 true가 됨
startTransition(async () => {
// 1. 화면에 먼저 +1을 반영 (낙관적 업데이트)
setOptimisticLikes(likes + 1);
try {
// 2. 실제 서버에 네트워크 요청
const updatedLikes = await api.toggleLike(postId);
setLikes(updatedLikes);
} catch (error) {
// 3. 에러 발생 시 원래 상태로 복구 (useOptimistic이 알아서 likes 원본으로 돌아감)
console.error('좋아요 실패:', error);
}
});
};
return (
<button onClick={handleLike} disabled={isPending}>
{isPending ? '⏳' : '❤️'} {optimisticLikes}
</button>
);
}
이 리팩토링 작업을 거치고 나니 두 가지 측면에서 깊은 카타르시스를 느꼈습니다:
- 코드의 간결함: 로딩 상태를 관리하기 위해 컴포넌트 이곳저곳에 흩어져 있던
setIsPending코드들이startTransition하나로 묶였습니다. - 사용자 경험 극대화:
useOptimistic덕분에 사용자가 버튼을 클릭하자마자 즉시 숫자가 올라가고, 백그라운드에서는 묵묵히 비동기 연산과 로딩 스피너(isPending상태일 때의 '⏳')가 작동합니다. 만약 실패하더라도 리액트가 원래의 상태로 부드럽게 되돌려 줍니다.
Summary: 결국 핵심은 흐름의 제어권을 넘기는 것
컴퓨터공학 비전공자로서 개발을 배울 때 가장 와닿았던 규칙 중 하나는 **"제어의 역전(IoC, Inversion of Control)"**이었습니다. 내가 일일이 제어하던 것을 프레임워크나 엔진에 넘겨서 실수를 줄이고 선언적으로 코딩하는 원리입니다.
리액트 19의 Actions는 결국 비동기 상태 흐름에 대한 제어의 역전이었습니다. 비동기 작업이 시작하고 끝나는 시점을 개발자가 직접 상태 코드로 작성하던 명령형(Imperative) 방식에서, 비동기 함수 그 자체를 리액트에 제공하고 상태 감지를 프레임워크가 알아서 수행하게 하는 선언형(Declarative) 방식으로의 도약인 것입니다.
처음엔 새로운 API들이 낯설었지만, 직접 폼 컴포넌트들을 리팩토링하면서 코드의 양이 획기적으로 줄고 비동기 예외 처리가 쉬워지는 것을 경험했습니다. 이제 더 이상 const [isLoading, setIsLoading] = useState(false)를 무작습적으로 치는 코딩 패턴과는 작별을 고할 때가 된 것 같습니다.