불러오는 중입니다
문서는 Server Action이 큐에 쌓인다고만 말합니다. 그 큐가 어디까지 하나로 묶이는지는 직접 재 보기 전에는 알 수 없었습니다.
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 로 처리하면 됩니다.배포가 끝나도 열려 있던 탭은 옛 코드로 계속 돌아갑니다. 그 탭에 새 빌드를 알리되, 알림이 틀려도 사용자에게 해가 없게 만든 과정을 정리했습니다.
인증 분기를 서버로 옮겨도 본문은 여전히 비어 있었습니다. 화면 크기를 모르는 서버는 어느 레이아웃을 그려야 할까요?
인증 분기를 서버로 옮기자 모바일 e2e만 깨졌습니다. 클릭은 성공으로 기록됐는데, 화면에서는 아무 일도 일어나지 않았습니다.
데스크톱에서는 멀쩡한 인증 팝업이 iOS에서만 열리지 않았습니다. 원인은 금방 찾았지만, 고치는 일은 한 줄로 끝나지 않았습니다.
브라우저에서는 멀쩡한 페이지가 크롤러에게는 스켈레톤뿐인 빈 문서였습니다. 서버 컴포넌트로 만든 페이지는 왜 서버에서 그려지지 않았을까요?
특정 요청도, 붐비는 시간대도 아닌데 502가 간헐적으로 떴고 다시 보내면 멀쩡했습니다. 무작위처럼 보이는 이 오류는 어디서 생긴 걸까요?
검색엔진은 페이지를 읽을 수는 있어도 그 안의 숫자가 무엇을 뜻하는지는 모릅니다. 메타 태그를 다 넣어도 검색 결과가 파란 링크 한 줄에 머무는 이유를 정리했습니다.
전역 Suspense로 감싸면 경고는 사라지지만 페이지 본문도 HTML에서 함께 사라집니다. 동기 훅이 왜 Suspense 경계를 요구하는지 소스코드로 따라갔습니다.
내용이 같은 함수도 렌더링마다 새 주소를 받습니다. 두 훅을 성능 도구보다 참조를 고정하는 도구로 봐야 하는 이유와, 아무 데나 쓰면 안 되는 이유를 정리했습니다.