1. "어? 왜 안 되지?" 흔한 실수와 당혹감
Next.js 13(App Router)과 React 18이 도입되면서 프론트엔드 생태계에는 거대한 지각 변동이 있었습니다. Server Component(RSC)의 등장이죠.
"서버 컴포넌트에서는 async/await를 마음껏 써서 DB에 직접 접근해도 된다!"라는 말에 신나서 코드를 짜다가, useState 같은 훅이 필요해서 파일 맨 위에 "use client"를 붙이는 순간, 갑자기 화면이 붉게 물듭니다.
// app/user-profile/page.jsx
"use client"; // 클라이언트 컴포넌트 선언
// ❌ 에러 발생! Client Component는 async 함수가 될 수 없습니다.
export default async function UserProfile() {
const data = await fetch("/api/user");
const user = await data.json();
return (
<div>
<h1>안녕하세요, {user.name}님!</h1>
<button onClick={() => alert("환영합니다!")}>인사하기</button>
</div>
);
}
브라우저 콘솔과 화면에는 다음과 같은 무시무시한 에러 메시지가 출력됩니다.
Error: async/await is not yet supported in Client Components, only Server Components. This error is often caused by accidentally adding 'use client' to a module that was originally written for the server.
"아니, 자바스크립트 함수에 async 좀 붙였다고 왜 에러가 나? 비동기 처리는 기본 아니야?"
이런 의문을 가지는 것은 매우 당연합니다. 하지만 여기에는 React의 렌더링 철학과 브라우저의 동작 원리에 깊은 기술적 이유가 숨어 있습니다.
결론부터 말하자면, React의 클라이언트 컴포넌트(Client Component)는 비동기 함수(Async Function)가 될 수 없습니다.
이것은 Next.js만의 제약이 아니라, React 라이브러리 자체가 브라우저에서 동작하는 방식 때문입니다.
2. 왜 안 되는 걸까? (심층 기술 분석)
이 현상을 제대로 이해하려면 "React가 브라우저를 지배하는 방법"인 재조정(Reconciliation) 과정과 Hooks의 동작 원리를 알아야 합니다.
2.1. 동기적 렌더링 (Synchronous Rendering)과 VDOM
React의 렌더링 사이클은 기본적으로 동기적(Synchronous)입니다.
React가 컴포넌트 트리(Component Tree)를 순회할 때, 각 컴포넌트 함수를 호출하고 그 결과물인 React Element(가상 DOM 객체)를 즉시 돌려받기를 기대합니다.
이 결과물을 이전 렌더링 결과와 비교(Diffing)하여 실제 DOM에 반영(Commit)해야 하기 때문입니다.
그런데 만약 컴포넌트 함수가 async라면?
자바스크립트에서 async 함수는 호출 즉시 Promise 객체를 반환합니다. JSX(React Element)가 아니라요!
React 입장에서는 당황스럽습니다.
"야, 화면에 그릴 <div> 태그를 달라고 했지, 누가 '나중에 줄게'라는 약속(Promise) 종이를 달라고 했냐?"
React는 Promise 객체를 받아서는 화면에 무엇을 그려야 할지 알 수 없습니다. 그래서 렌더링이 중단되고 에러가 발생하는 것입니다.
2.2. Hooks의 순서 보장 (The Rule of Hooks)
더 결정적인 이유는 React Hooks(useState, useEffect)에 있습니다.
React Hooks는 마법 같은 도구지만, 작동 원리는 매우 원시적입니다. 바로 배열(Array)와 인덱스(Index)에 의존한다는 점입니다.
React는 컴포넌트가 렌더링될 때 훅이 호출되는 순서(Order)를 엄격하게 기억합니다.
- 첫 번째
useState -> 0번 인덱스
- 두 번째
useEffect -> 1번 인덱스
- 세 번째
useMemo -> 2번 인덱스
다음 렌더링 때도 이 순서는 반드시 일치해야 합니다.
그런데 async/await는 함수의 실행을 일시 중지(Pause)시킵니다.
await를 만나는 순간 자바스크립트 엔진은 해당 함수의 실행을 멈추고 다른 작업(이벤트 루프)을 처리하러 떠납니다.
비동기 작업이 완료되어 다시 돌아왔을 때, 만약 다른 컴포넌트의 렌더링이 끼어들거나 상태 업데이트가 발생했다면? Hooks의 호출 순서가 뒤죽박죽이 될 위험이 큽니다.
이러한 동시성(Concurrency) 문제와 상태 관리의 불확실성 때문에, React 팀은 클라이언트 컴포넌트(State를 가진 컴포넌트)에서의 async 사용을 원천적으로 막아둔 것입니다.
2.3. Server Component는 왜 되나?
그렇다면 Server Component는 왜 async가 될 수 있을까요?
Server Component는 Hooks를 쓸 수 없기 때문입니다.
useState, useEffect를 전혀 사용할 수 없는, 오직 데이터를 받아서 UI를 그려내기만 하는 순수 함수(Pure Function)에 가깝습니다. (엄밀히 말하면 Side Effect가 없는 함수).
또한 서버 컴포넌트는 서버에서 딱 한 번 실행되어 JSON 형태(RSC Payload)로 직렬화되어 클라이언트로 전송됩니다. 브라우저에서 리렌더링되거나 상태가 변할 일이 없으므로, async/await로 인한 순서 꼬임 문제가 발생하지 않습니다.
3. 해결 방법 - 상황별 3가지 필승 패턴
이 문제를 해결하는 방법은 명확합니다. "비동기 작업의 위치를 옮기거나, 처리 방식을 바꾸는 것"입니다.
다음 3가지 패턴 중 상황에 맞는 것을 골라 쓰세요.
패턴 1 - 부모(Server Component)에서 Fetching 후 Props로 전달 (권장)
가장 우아하고 Next.js스러운 해결책입니다. Next.js App Router가 지향하는 아키텍처이기도 합니다.
데이터 페칭은 서버에서 하고, 클라이언트는 오직 UI 렌더링과 상호작용에만 집중하게 역할을 분리하는 것입니다. 옛날의 Container-Presenter 패턴과 유사합니다.
1. 부모 (Server Component): page.jsx
import UserProfileClient from "./UserProfileClient";
// Server Component이므로 async 가능!
export default async function ProfilePage() {
// DB 직접 조회 혹은 API 호출 (Server-to-Server 통신이라 빠름)
const res = await fetch("https://api.example.com/user", { cache: 'no-store' });
const user = await res.json();
// 데이터를 props로 자식(Client Component)에게 넘깁니다.
return <UserProfileClient user={user} />;
}
2. 자식 (Client Component): UserProfileClient.jsx
"use client";
import { useState } from "react";
// async 아님! 그냥 props로 데이터를 받음
export default function UserProfileClient({ user }) {
const [likes, setLikes] = useState(0); // Hooks 사용 가능
return (
<div className="card">
<h2>{user.name}</h2>
<p>{user.email}</p>
<button onClick={() => setLikes(likes + 1)}>
좋아요 {likes}
</button>
</div>
);
}
장점:
- 성능 최적화: 데이터 페칭이 서버에서 일어나므로 클라이언트의 FCP(First Contentful Paint)가 빨라집니다.
- 번들 사이즈 감소: 데이터 페칭 로직이 브라우저로 전송되지 않습니다.
- Waterfal 방지: 클라이언트에서 여러 번 요청할 것을 서버에서 병렬로 처리할 수 있습니다.
패턴 2 - useEffect 내부에서 비동기 처리 (전통적 방식)
사용자의 클릭이나 특정 이벤트 이후에 데이터를 가져와야 하거나, 부모 컴포넌트에서 데이터를 제어하기 힘든 경우(예: 전역 레이아웃의 일부)라면 useEffect를 사용해야 합니다. React 초창기부터 써오던 방식입니다.
"use client";
import { useState, useEffect } from "react";
export default function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// useEffect 자체에는 async를 붙일 수 없습니다.
// 내부에서 async 함수를 선언하고 호출해야 합니다.
const fetchData = async () => {
try {
setLoading(true);
const res = await fetch("/api/user");
if (!res.ok) throw new Error("데이터 로딩 실패");
const data = await res.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
// Cleanup function (Optional)
return () => { /* 정리 작업 */ };
}, []); // 의존성 배열 주의
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러 발생: {error}</div>;
return <div>{user.name}</div>;
}
주의사항:
useEffect(async () => { ... })는 금지입니다. useEffect는 cleanup 함수(또는 undefined)만 반환해야 하는데, async 함수는 Promise를 반환하기 때문입니다.
- 이 방식은 Client-side Waterfall을 유발하여 페이지 로딩이 느려질 수 있습니다.
패턴 3 - React Query (TanStack Query) / SWR 사용 (실제 표준)
실제로는 useEffect로 직접 데이터를 가져오는 것을 지양합니다. 캐싱, 중복 호출 제거, 로딩/에러 상태 관리 등 처리해야 할 게 너무 많기 때문입니다.
TanStack Query나 SWR 같은 라이브러리를 사용하면 코드가 훨씬 깔끔해지고 안정적입니다.
"use client";
import { useQuery } from "@tanstack/react-query";
export default function UserProfile() {
// 한 줄로 로딩, 에러, 데이터 상태 관리 끝
const { data: user, isLoading, isError } = useQuery({
queryKey: ["user"],
queryFn: async () => {
const res = await fetch("/api/user");
return res.json();
},
});
if (isLoading) return <div>로딩 중...</div>;
if (isError) return <div>에러가 발생했습니다.</div>;
return (
<div>
<h1>{user.name}</h1>
{/* 윈도우 포커스 시 자동 갱신 등 강력한 기능 제공 */}
</div>
);
}
장점:
- 강력한 상태 관리: 서버 상태(Server State)를 클라이언트 상태와 분리하여 관리할 수 있습니다.
- 사용자 경험 향상: 데이터가 캐시되므로 뒤로 가기 했다가 다시 와도 로딩 없이 즉시 화면이 뜹니다.
4. "직렬화(Serialization)"의 경계를 조심하라 제대로 파보기
패턴 1(Server -> Client Props)을 사용할 때 자주 겪는 또 다른 에러가 있습니다.
Error: Props must be serializable for components in the "use client" entry file.
서버 컴포넌트에서 클라이언트 컴포넌트로 데이터를 넘기는 것은, 마치 서버에서 브라우저로 네트워크 패킷을 보내는 것과 같습니다.
그래서 직렬화(Stringify)가 가능한 데이터만 넘길 수 있습니다.
❌ 보낼 수 없는 것 (Non-Serializable)
- 함수 (Function): 이벤트 핸들러(
onClick={handleClick}) 같은 건 못 넘깁니다. 서버엔 클릭할 마우스가 없으니까요.
- 클래스 인스턴스:
new Date(), Map, Set, 커스텀 Class 객체 등.
- DOM Element:
ref 같은 것들.
✅ 보낼 수 있는 것 (Serializable)
- 문자열, 숫자, Boolean
null, undefined
- 일반 객체(Plain Object), 배열(Array)
해결책 Example (Date 객체):
// ❌ Error
<ClientComponent date={new Date()} />
// ✅ Good (문자열로 변환해서 전달)
<ClientComponent date={new Date().toISOString()} />
5. 미래의 React - use Hook과 Suspense의 결합
React 팀도 개발자들이 겪는 이 불편함("useEffect 쓰기 귀찮아!")을 잘 알고 있습니다.
그래서 현재 실험적인 기능으로 use 라는 새로운 API가 도입되고 있습니다. (React 19, Next.js 최신 버전)
이것을 사용하면 클라이언트 컴포넌트에서도 마치 await를 쓰는 것처럼 비동기 데이터를 처리할 수 있습니다.
"use client";
import { use, Suspense } from "react";
// Promise를 prop으로 받습니다.
function UserProfile({ userPromise }) {
// use()가 Promise가 해결될 때까지 컴포넌트 렌더링을 '일시 정지(Suspend)' 시킵니다.
const user = use(userPromise);
return <div>{user.name}</div>;
}
export default function Page() {
// 데이터 요청은 시작하지만, 기다리지(await) 않고 Promise 자체를 넘깁니다.
const userPromise = fetchUser();
return (
<Suspense fallback={<div>로딩 중...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
이 방식이 정착되면, 클라이언트 컴포넌트에서의 비동기 처리가 훨씬 우아해질 것입니다. useEffect의 지옥에서 벗어날 수 있는 희망입니다.
6. 마무리 - "Next.js의 사고방식"을 장착하자
"Client Component에서 async를 못 써서 안티 패턴 아니냐?"라고 불평하기보다는, Next.js와 React가 의도한 아키텍처를 이해하는 것이 중요합니다.
- 데이터는 가능한 한 서버에서 가져온다. (Server Component 활용)
- 클라이언트는 인터랙션(클릭, 입력)과 UI 상태 관리에 집중한다. (Client Component 활용)
-
그 사이를 연결할 때는 직렬화 가능한 Props를 사용한다.
이 원칙만 지킨다면, async/await 에러는 더 이상 여러분을 괴롭히지 않을 것입니다. 오히려 더 빠르고, 검색 엔진에 친화적이며, 유지보수하기 쉬운 웹 애플리케이션을 만들 수 있게 될 것입니다.