React의 async 컴포넌트는 무엇인가: 서버 컴포넌트와 React 18 이후의 비동기 모델
처음 App Router 코드를 봤을 때 가장 낯설었던 건 이 한 줄이었습니다.
export default async function Page() {
const users = await getUsers();
// ...
}컴포넌트 함수 앞에 async가 붙어 있고, 본문에서 await로 데이터를 바로 가져옵니다. useEffect도, useState로 만든 로딩 플래그도 없습니다. 그동안 React에서 데이터를 불러오려면 거쳐야 했던 절차가 통째로 사라진 모습이라, 한참을 다시 봤던 기억이 납니다.
이 글은 그 async 한 글자가 정확히 무엇을 뜻하는지 파고든 기록입니다. 왜 서버 컴포넌트에서는 컴포넌트를 async로 만들 수 있는데 클라이언트 컴포넌트에서는 안 되는지, 그 차이가 React 18의 concurrent 렌더링·Suspense 스트리밍과 어떻게 맞물리는지, 그리고 React 19의 use()가 그 빈틈을 어떻게 메우는지를 실무 관점에서 짚어 보겠습니다.
한눈에 보면
async컴포넌트는 서버 컴포넌트(RSC) 에서만 성립합니다. 함수 본문에서await로 데이터를 직접 읽고, 그 결과로 UI를 반환합니다.- 클라이언트 컴포넌트는
async가 될 수 없습니다. 클라이언트 렌더링은 컴포넌트 함수를 기다려 주지 않기 때문입니다. - 그래서 클라이언트에서 비동기를 다루는 정식 통로는 Suspense +
use()(React 19)입니다. - 이 모델을 떠받치는 토대는 React 18의 concurrent 렌더링과 Suspense 기반 스트리밍 SSR입니다.
- 서버 컴포넌트가 promise를 만들고, 클라이언트 컴포넌트가
use()로 푸는 조합이 React 18 이후 비동기 UI의 기본 문법으로 자리 잡았습니다. - 편해 보이지만 함정도 같이 들어옵니다. 요청 워터폴, 직렬화 경계,
'use client'에 대한 오해가 대표적입니다.
그동안 데이터를 가져오던 방식
서버 컴포넌트 이전에는 보통 이렇게 썼습니다.
'use client';
import { useEffect, useState } from 'react';
export function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
getUsers()
.then(setUsers)
.catch(setError)
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <Spinner />;
if (error) return <ErrorBox error={error} />;
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}데이터 하나 화면에 띄우는 데 상태가 셋이나 필요합니다. 컴포넌트는 일단 빈 화면으로 한 번 그려지고, 마운트된 다음에야 요청을 보내고, 응답이 오면 다시 그립니다. 화면 깜빡임, 마운트 이후의 워터폴, 매번 반복되는 로딩·에러 처리가 따라옵니다.
여기서 자연스럽게 떠오르는 질문이 있습니다. 어차피 데이터가 있어야 그릴 수 있다면, 컴포넌트가 그냥 데이터를 기다렸다가 그리면 안 될까요? async 서버 컴포넌트는 바로 그 질문에 대한 답입니다.
async 서버 컴포넌트가 하는 일
서버 컴포넌트는 이름 그대로 서버에서만 실행되는 컴포넌트입니다. App Router에서는 별다른 표시가 없으면 모든 컴포넌트가 서버 컴포넌트이고, 상호작용이 필요한 곳에만 'use client'를 붙여 클라이언트 컴포넌트로 빼냅니다.
서버에서만 돈다는 성질 덕분에 컴포넌트 함수를 async로 선언하고 본문에서 곧장 await를 쓸 수 있습니다.
// app/users/page.tsx — 서버 컴포넌트(기본값)
async function UserList() {
const res = await fetch('https://api.example.com/users', {
cache: 'no-store',
});
const users: User[] = await res.json();
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
export default function Page() {
return <UserList />;
}앞의 코드와 비교하면 로딩 상태도, 에러 플래그도, useEffect도 없습니다. 데이터가 준비된 다음에야 컴포넌트가 결과를 반환하므로, 화면이 빈 상태로 한 번 그려졌다가 다시 그려지는 단계 자체가 없습니다.
서버에서만 실행된다는 점은 부수적인 이점도 같이 가져옵니다.
- API 키나 DB 접속 정보를 컴포넌트 안에서 그대로 써도 클라이언트 번들로 새어 나가지 않습니다.
- 마크다운 파서나 무거운 날짜 라이브러리처럼 덩치 큰 의존성을 서버에만 두고, 결과 UI만 내려보낼 수 있습니다.
- 데이터 소스와 가까운 곳에서 요청하므로 클라이언트-서버 왕복이 줄어듭니다.
대신 서버 컴포넌트에는 분명한 제약이 따라옵니다. useState·useEffect 같은 훅을 쓸 수 없고, onClick 같은 이벤트 핸들러도 붙일 수 없으며, window나 localStorage 같은 브라우저 API에도 접근하지 못합니다. 한마디로 상태와 상호작용이 빠진, 데이터를 그려 내는 역할에 집중하는 컴포넌트입니다.
왜 클라이언트 컴포넌트는 async가 안 될까
여기서 막히는 지점이 있습니다. 그럼 클라이언트 컴포넌트도 그냥 async로 만들면 되지 않나요?
'use client';
// ❌ 의도대로 동작하지 않습니다
export default async function Profile() {
const user = await getUser();
return <p>{user.name}</p>;
}이 코드는 우리가 기대하는 방식으로 동작하지 않습니다. 이유는 렌더링 방식의 차이에 있습니다.
클라이언트에서 React는 컴포넌트 함수를 호출해 그 반환값(엘리먼트 트리)을 즉시 받아 재조정(reconciliation)에 들어갑니다. 그런데 async 함수는 무엇을 반환하든 결과를 Promise로 감싸서 돌려줍니다. 클라이언트 렌더러는 컴포넌트가 돌려준 Promise가 풀리기를 기다려 주도록 설계돼 있지 않습니다. 함수가 끝나는 순간의 값을 곧바로 트리로 해석하려 하기 때문에, Promise를 그릴 수는 없습니다.
반면 서버 컴포넌트는 한 번 실행해 직렬화된 결과를 만들어 내려보내는 구조라, 그 과정에서 await를 기다릴 여유가 있습니다. 렌더링 모델 자체가 다르기 때문에 한쪽은 되고 한쪽은 안 되는 셈입니다.
그렇다면 클라이언트에서 비동기 데이터가 필요할 때는 어떻게 해야 할까요? 여기서 React 18이 다져 놓은 토대가 등장합니다.
React 18이 깔아 둔 토대: concurrent와 Suspense 스트리밍
React 18은 렌더링을 중간에 멈췄다가 다시 이어 갈 수 있는 concurrent 렌더링을 도입했습니다. 덕분에 React는 "이 부분은 아직 준비가 안 됐으니 일단 다른 걸 먼저 그리자"라고 판단할 수 있게 됐습니다. 이 판단의 단위가 바로 Suspense입니다.
서버 렌더링 쪽에서는 이 변화가 스트리밍 SSR로 이어졌습니다. 예전 SSR은 HTML 전체가 완성돼야 한 번에 내려보낼 수 있었지만, React 18부터는 준비된 부분부터 조각조각 흘려보낼 수 있습니다. 아직 데이터를 기다리는 영역은 fallback으로 자리를 잡아 두고, 데이터가 도착하면 그 조각을 이어서 내려보내는 식입니다.
import { Suspense } from 'react';
export default function Page() {
return (
<main>
<Header />
<Suspense fallback={<UserListSkeleton />}>
{/* await가 끝날 때까지 fallback이 자리를 지킵니다 */}
<UserList />
</Suspense>
</main>
);
}UserList가 데이터를 기다리는 동안 사용자는 Header와 스켈레톤을 먼저 봅니다. await가 끝나면 그 자리만 실제 목록으로 채워집니다. async 서버 컴포넌트가 자연스럽게 느껴지는 이유가 여기 있습니다. 컴포넌트가 데이터를 기다리는 동안 화면 전체가 멈추지 않도록 받쳐 주는 장치가 이미 React 18에 들어와 있었던 겁니다.
sequenceDiagram
participant B as 브라우저
participant S as 서버(React)
B->>S: 페이지 요청
S-->>B: 셸 + Suspense fallback 즉시 전송
Note over S: async 컴포넌트의 await 진행
S-->>B: 데이터 준비된 조각부터 이어서 스트리밍
B->>B: fallback 영역을 실제 UI로 교체React 19의 use(): 클라이언트의 빈자리를 메우다
서버 컴포넌트가 await로 데이터를 읽는다면, 클라이언트 컴포넌트는 React 19에서 안정화된 use()로 promise를 풉니다.
핵심 패턴은 이렇습니다. 서버 컴포넌트가 promise를 만들되 직접 기다리지 않고 그대로 클라이언트 컴포넌트에 넘깁니다. 그러면 서버는 그 데이터를 기다리느라 멈추지 않고, 클라이언트가 use()로 promise를 받아 Suspense 경계 안에서 풀어 냅니다.
// 서버 컴포넌트: promise를 만들지만 await 하지 않습니다
import { Suspense } from 'react';
import { Comments } from './comments';
export default function Post({ id }: { id: string }) {
const commentsPromise = fetchComments(id); // 여기서 await 하지 않음
return (
<article>
<PostBody id={id} />
<Suspense fallback={<p>댓글을 불러오는 중…</p>}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
</article>
);
}'use client';
import { use } from 'react';
export function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
const comments = use(commentsPromise); // promise가 풀릴 때까지 Suspense에 위임
return (
<ul>
{comments.map((c) => (
<li key={c.id}>{c.text}</li>
))}
</ul>
);
}use()는 promise가 아직 풀리지 않았으면 가장 가까운 Suspense 경계에 처리를 넘기고, 풀리면 그 값을 반환합니다. 일반 훅과 달리 조건문이나 반복문 안에서도 부를 수 있고, promise뿐 아니라 context를 읽는 데도 쓸 수 있습니다.
참고로 promise를 서버에서 클라이언트로 그대로 넘기는 동작은 프레임워크의 직렬화 지원에 기댑니다. Next.js App Router는 이를 지원하지만, 모든 환경에서 당연히 되는 건 아니라는 점은 염두에 두는 게 좋습니다.
여기에 React 19의 Actions, useActionState, useOptimistic까지 더하면 폼 제출이나 mutation 같은 "쓰기" 쪽 비동기도 같은 결을 가집니다. 읽기는 서버 컴포넌트와 use()로, 쓰기는 Actions로 정리되는 그림입니다.
서버 컴포넌트 vs 클라이언트 컴포넌트
지금까지 내용을 경계 기준으로 정리하면 이렇습니다.
| 구분 | 서버 컴포넌트 | 클라이언트 컴포넌트 |
|---|---|---|
| 선언 | 기본값 | 파일 상단에 'use client' |
async 함수 |
가능 (await로 직접 fetch) |
불가능 |
| 비동기 데이터 | 본문에서 await |
use() + Suspense |
| 상태·이펙트 훅 | 사용 불가 | 사용 가능 |
| 이벤트 핸들러 | 불가 | 가능 |
| 브라우저 API | 불가 | 가능 |
| 번들 포함 | 클라이언트로 전송 안 됨 | 클라이언트 번들에 포함 |
기억해 둘 점은 둘이 대립 관계가 아니라는 겁니다. 서버 컴포넌트가 바깥 틀을 그리고, 상호작용이 필요한 잎사귀만 클라이언트 컴포넌트로 내려 보내는 조합이 기본 사고방식입니다.
실무에서 부딪히는 함정들
편의가 늘어난 만큼, 새로 신경 써야 할 지점도 같이 생깁니다. 직접 겪었거나 리뷰에서 자주 짚게 되는 것들을 추려 봤습니다.
1. 순차 await로 만드는 요청 워터폴
await가 편하다 보니 무심코 줄줄이 세우기 쉽습니다.
// ❌ user를 기다릴 필요가 없는데도 posts가 뒤에서 대기합니다
const user = await getUser(id);
const posts = await getPosts(id);두 요청 사이에 의존 관계가 없다면, 순차 await는 그대로 응답 시간을 더하는 워터폴이 됩니다. 서로 독립적인 요청은 병렬로 묶는 편이 맞습니다.
// ✅ 동시에 출발시키고 함께 기다립니다
const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);2. 직렬화 경계를 넘지 못하는 값
서버 컴포넌트에서 클라이언트 컴포넌트로 props를 넘길 때는 직렬화가 가능한 값이어야 합니다. 함수, 클래스 인스턴스, Date가 아닌 복잡한 객체 등은 경계를 넘기지 못합니다. "서버에서 만든 헬퍼 함수를 클라이언트에 prop으로 넘겼는데 안 된다"는 상황은 대부분 이 경계 때문입니다.
3. 'use client'는 "이 파일만"이 아니다
'use client'를 흔히 "이 컴포넌트는 클라이언트에서 돈다" 정도로 읽지만, 실제로는 경계 선언에 가깝습니다. 그 파일이 import하는 모듈들도 클라이언트 쪽으로 딸려 들어갑니다. 무심코 페이지 최상단에 'use client'를 붙이면 그 아래 트리 전체가 클라이언트 번들로 넘어가, 서버 컴포넌트의 이점을 통째로 잃기 쉽습니다. 경계는 가능한 한 잎사귀 쪽으로 미루는 게 좋습니다.
4. 서버 상태와 클라이언트 캐시의 역할 재정리
서버 컴포넌트가 데이터를 직접 읽게 되면서, "그럼 React Query 같은 도구는 이제 필요 없나?"라는 질문이 자주 나옵니다. 첫 렌더의 읽기는 서버 컴포넌트로 단순해지지만, 클라이언트에서 사용자 조작에 따라 다시 불러오고, 캐시를 무효화하고, 낙관적 업데이트를 하는 영역은 여전히 남습니다. 둘은 대체재라기보다 읽기의 출발점이 서버로 옮겨 간 것에 가깝습니다.
정리하면
async 컴포넌트는 단순한 문법 설탕이 아니라, 데이터를 기다렸다가 그리는 일을 서버 렌더링 모델 위에 올려놓은 결과입니다.
- 컴포넌트를
async로 만들 수 있는 건 서버 컴포넌트뿐이고, 클라이언트는 렌더링 방식이 달라use()+ Suspense로 비동기를 다룹니다. - 이 모든 게 자연스럽게 굴러가는 이유는 React 18의 concurrent 렌더링과 Suspense 스트리밍이 먼저 자리를 잡았기 때문입니다.
- React 19에서 RSC와
use()가 안정화되면서, 서버가 promise를 만들고 클라이언트가 그것을 푸는 조합이 비동기 UI의 기본 문법이 됐습니다. - 대신 워터폴, 직렬화 경계,
'use client'의 의미처럼 새로 챙겨야 할 함정도 함께 들어왔습니다.
편해진 만큼 어디서 무엇이 실행되는지를 더 또렷하게 의식해야 한다는 점이, 이 모델을 공부하며 가장 크게 남은 감각입니다.
버전에 따라 세부 API와 동작이 달라질 수 있으니, 실제 도입 전에는 사용하는 React·프레임워크 버전의 공식 문서로 한 번 더 확인하기를 권합니다. (이 글은 React 19.2 기준입니다.)
