문서에는 "queued"라고만 적혀 있습니다. 호출 단위인지 컴포넌트 단위인지 탭 전체인지 알 수 없어서, 30개 요청을 세 가지 방식으로 직접 재봤습니다.
Next.js 공식 문서에는 이런 문장이 있습니다.
Server Actions are queued. Using them for data fetching introduces sequential execution.
Server Action은 순차 실행된다는 말입니다. 그런데 이 문장만으로는 범위를 알 수 없었습니다.
Promise.all로 감싸면 병렬이 될까"순차"가 호출 단위인지, 컴포넌트 단위인지, 탭 전체인지에 따라 쓸 수 있는 곳이 완전히 달라집니다. 직접 측정해봤습니다.
/api/data?id={id}, 응답에 10ms 지연// Server Action
"use server";
export async function serverActionFetchData(id: number) {
const response = await fetch(`${EXPRESS_API_URL}/api/data?id=${id}`, {
cache: "no-store",
});
return response.json();
}
// Route Handler
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const response = await fetch(`${EXPRESS_API_URL}/api/data?id=${id}`, {
cache: "no-store",
});
return NextResponse.json(await response.json());
}
Route Handler를 같이 둔 건 대조군입니다. 순차 실행이 Server Action의 성질인지, 아니면 제 측정 환경 탓인지 구분해야 했습니다.
// Server Action
const promises = Array.from({ length: 30 }, (_, i) =>
serverActionFetchData(i + 1)
);
await Promise.all(promises);
// Route Handler
const promises = Array.from({ length: 30 }, (_, i) =>
fetch(`/api/data?id=${i + 1}`).then(res => res.json())
);
await Promise.all(promises);
앞쪽이 Server Action, 뒤쪽이 Route Handler입니다. Route Handler는 요청이 한꺼번에 나가고, Server Action은 순차적으로 나가는 것을 볼 수 있습니다.
const queries = useQueries({
queries: Array.from({ length: 30 }, (_, i) => ({
queryKey: ["server-action", i + 1],
queryFn: () => serverActionFetchData(i + 1),
})),
});
동일합니다. 라이브러리가 호출을 관리하든 말든 큐는 그대로입니다.
여기가 제일 궁금했던 부분입니다. 한 곳에서 30번 부르는 것과, 서로 모르는 컴포넌트 30개가 한 번씩 부르는 건 다른 상황입니다.
function DataItem({ id }: { id: number }) {
const { data } = useQuery({
queryKey: ["data", id],
queryFn: () => serverActionFetchData(id),
});
return <div>{data?.data}</div>;
}
// 30개의 독립 컴포넌트
{Array.from({ length: 30 }, (_, i) => <DataItem key={i} id={i + 1} />)}
여전히 순차입니다.
이게 이번 측정에서 가장 중요한 결과였습니다. 큐는 호출부나 컴포넌트 단위가 아니라 탭 전체에 하나입니다.
즉 서로 아무 관계 없는 컴포넌트끼리도 서로를 기다립니다. A 컴포넌트의 Server Action이 느리면 B 컴포넌트가 그만큼 늦게 시작합니다. 컴포넌트를 분리해도 이 결합은 사라지지 않습니다.
큐가 불편하게 느껴지는 건 Server Action을 읽기에 쓰려고 할 때입니다. 하지만 Server Action은 애초에 mutation을 위해 만들어졌고, 그 자리에서 보면 순차 실행은 제약이 아니라 필요한 성질입니다.
읽기와 쓰기는 동시 실행에서 위험이 다릅니다. 읽기 30개는 순서가 뒤섞여도 각자 제 결과를 받아 갑니다. 서로 간섭할 게 없습니다. 쓰기는 같은 데이터를 함께 건드리는 순간 결과가 순서에 따라 달라집니다.
"use server";
export async function updateUserProfile(formData: FormData) {
const user = await db.users.findOne({ id: formData.get("id") });
user.name = formData.get("name");
await db.users.save(user);
}
읽고 → 고치고 → 저장하는 세 단계입니다. 이 셋이 한 덩어리로 처리되지 않으면, 두 호출이 겹칠 때 이렇게 됩니다.
user를 읽는다 (name: "가")user를 읽는다 (name: "가" — A의 수정은 아직 저장 전)A의 변경이 사라집니다. B가 A의 결과를 못 보고 시작했기 때문입니다. 사용자가 폼을 빠르게 두 번 제출했을 때 앞의 입력이 조용히 없어지는 종류의 버그입니다.
큐는 A가 끝난 뒤에 B를 시작시켜서 B가 항상 최신 값을 읽게 합니다. 사용자가 누른 순서가 저장되는 순서가 됩니다. 읽기에 안 맞는 게 아니라, 쓰기에 맞춘 결과입니다.
물론 이건 트랜잭션이나 낙관적 잠금으로도 막을 수 있습니다. 다만 그건 액션마다 개발자가 챙겨야 하는 일이고, 큐는 아무것도 하지 않아도 기본으로 깔려 있는 안전장치입니다. 프레임워크가 기본값으로 고를 만한 쪽은 후자입니다.
Server Action 내부에서
Promise.all을 쓰는 건 병렬로 동작합니다. 큐에 걸리는 건 액션의 실행 단위이지 그 안의 fetch가 아닙니다. 여러 곳에서 데이터를 모아야 한다면 액션 하나 안에서 모으면 됩니다.
개발 서버(HTTP/1.1)에서 쟀습니다. 프로덕션에서 그대로 재현되는 수치는 아닙니다.
다만 방향은 유지될 것으로 봅니다. HTTP/2 멀티플렉싱이 붙으면 Route Handler는 더 빨라질 여지가 있지만, Server Action의 큐는 네트워크 계층이 아니라 그 위에 있어서 영향을 받지 않습니다. 격차가 줄기보다는 벌어지는 쪽입니다.
수치 자체보다 순차/병렬이라는 형태의 차이를 보려던 측정이었습니다.
Promise.all, React Query,
컴포넌트 분리 — 전부 이걸 우회하지 못했습니다.Promise.all 로 처리하면 됩니다.특정 요청이 아니라 아무 요청에서나 502가 났습니다. 커넥션을 재사용하는 쪽과 받는 쪽의 타임아웃이 서로를 모른 채 돌고 있었고, 그 틈에서 죽어가는 소켓이 재사용되고 있었습니다.
메타 태그를 다 넣었는데도 검색 결과는 파란 링크 한 줄뿐이었습니다. 검색엔진이 페이지를 '읽는' 것과 '이해하는' 것은 다른 문제였고, 그 사이를 메우는 게 구조화된 데이터였습니다.
참조형 데이터는 내용이 같아도 주소가 다르면 다른 값으로 취급됩니다. 이 특성이 useEffect 의존성 배열과 React.memo에서 왜 문제가 되는지, useCallback과 useMemo가 무엇을 해결하는지, 그리고 왜 모든 곳에 쓰면 안 되는지 정리합니다.
인앱 브라우저에서 기능이 깨지는 문제와 대용량 목록 렌더링 성능 문제, 두 가지를 실제로 부딪히고 풀어낸 기록입니다.
Promise 핸들러가 항상 비동기로 실행되는 이유인 마이크로태스크 큐를 짚고, 이를 편하게 다루는 async/await 문법과 실행 순서 종합 문제로 시리즈를 마무리합니다.
Promise.all/allSettled/race 같은 정적 메서드, AbortController로 요청을 취소하는 방법, 그리고 콜백을 Promise로 바꾸는 Promisification을 정리합니다.