Next.js 이렇게 쓰면 느립니다 2 - Server Functions

post-thumbnail

저는 서버 액션을 좋아합니다

서버와 클라이언트가 대화하는 방법 중에 이것보다 단순한 게 없습니다. 서버에서 함수를 하나 쓰고, 클라이언트에서 그 함수를 import해서 부릅니다. 끝입니다. 인자 타입도, 반환 타입도 그대로 따라옵니다.

// app/actions.ts
'use server'

export async function getPost(id: string): Promise<Post> {
  return db.post.find(id)
}
// 클라이언트 컴포넌트
'use client'
import { getPost } from './actions'

const post = await getPost('1') // Post 타입 그대로

이런 걸 원격 함수(remote function)라고 부릅니다. HTTP 엔드포인트를 설계하고, 요청·응답 타입을 따로 맞추고, 클라이언트에 fetch 래퍼를 짜는 일이 전부 사라집니다. 코드가 서버로 가는 POST 요청으로 바뀌는 건 Next.js가 빌드 타임에 알아서 합니다.

그래서 서버 액션을 "폼 제출을 편하게 하는 것" 혹은 "백엔드가 만들어지기 전에 임시로 쓰는 것" 정도로만 생각하고 있다면, 관점을 조금 넓혀볼 만합니다. 서버와 클라이언트 사이의 타입 안전한 통로 자체가 서버 액션이거든요.

다만 이 통로의 동작을 잘못 이해하면 1편보다 더 치명적인 속도 문제를 겪습니다. 이번 글은 그 이야기입니다. 읽고 나면 "Promise.all로 묶었는데 왜 순서대로 실행되죠?"라는 질문에 답할 수 있게 됩니다.

서버 액션은 이력서에 "써봤다"고 적기 쉬운 기술입니다. 그래서 면접에서도 써봤다는 말 뒤에 이런 질문이 따라오기 쉽고요. 거기에 이 답까지 붙일 수 있으면 대화가 꽤 달라집니다.

React 문서의 Server Functions
출처: React: Server Functions. 2024년 9월부터 React는 이 기능을 Server Functions라고 부르고, 그중 action 경로로 호출되는 것만 Server Actions라고 구분합니다.

본론 1. 백엔드가 따로 있어도 Next.js 서버를 거치는 이유

서버 액션 이야기 전에 짚을 게 하나 있습니다. "우리는 백엔드가 따로 있는데 Next.js 서버에서 뭘 더 처리할 일이 있나요?" 이런 질문을 자주 받습니다.

저는 백엔드가 따로 있어도 브라우저가 백엔드와 직접 통신하지 말고 Next.js 서버를 거쳐서 통신하기를 추천합니다. Next.js 공식 문서에도 이 패턴이 있습니다. Backend for Frontend 가이드에서 Route Handler를 다른 백엔드로 가는 프록시로 쓰는 방법, 그리고 여러 소스의 데이터를 조합·필터·집계해서 내부 시스템을 노출하지 않는 방법을 설명합니다.

Next.js BFF 가이드
출처: Next.js: Backend for Frontend

better-auth 같은 라이브러리를 쓰면 서버사이드에서 세션을 읽어 로그인 여부와 권한을 먼저 확인할 수 있다는 이점도 있습니다. 그런데 제가 보기에 가장 큰 이유는 속도입니다.

프론트는 API를 조합한다

요구사항은 바뀝니다. 이 페이지에 뭘 보여줄지, 기획이 어떻게 달라질지 정해지면 가장 먼저 바뀌는 게 프론트입니다. 그때마다 백엔드에 "이 화면에 딱 맞는 엔드포인트 하나 파주세요" 하기는 어렵습니다. 백엔드는 비즈니스 로직 말고도 신경 쓸 게 많고, 그 서버는 웹 서비스만 보는 게 아니라 어드민도 보고 앱도 봅니다. 화면 하나 전용 엔드포인트를 계속 늘릴 수는 없죠.

그래서 프론트가 기존 API 몇 개를 조합해서 씁니다. 이걸 앱 조인이라고 부르기도 합니다. 병렬로 부를 수 있으면 상관없는데, 앞 요청의 결과가 있어야 다음 요청을 보낼 수 있는 경우가 꼭 생깁니다. 사용자 정보를 받아야 그 사용자의 팀을 알고, 팀을 알아야 팀의 프로젝트 목록을 받는 식으로요.

이때 브라우저가 백엔드와 직접 통신하면 네트워크 지연이 순차 호출 횟수에 비례해서 커집니다.

브라우저 ─── 백엔드 직접 호출 (왕복 100ms 가정)

  브라우저 ──▶ GET /me ──────────▶ 백엔드
           ◀────────────────────  100ms
  브라우저 ──▶ GET /teams/:id ───▶ 백엔드
           ◀────────────────────  200ms
  브라우저 ──▶ GET /projects ────▶ 백엔드
           ◀────────────────────  300ms   ← 총 300ms

이걸 Next.js 서버에서 하면 다릅니다. Next.js 서버와 백엔드를 물리적으로 같은 위치에 두면 서버 사이의 왕복은 몇 ms입니다.

브라우저 ─── Next.js 서버 경유

  브라우저 ──▶ 한 번 요청 ──▶ Next.js 서버 ─▶ 백엔드 (3ms)
                                       ◀──
                                          ─▶ 백엔드 (3ms)
                                       ◀──
                                          ─▶ 백엔드 (3ms)
           ◀────────────────────────────── 약 110ms   ← 왕복 한 번

Vercel도 블로그에서 같은 이야기를 합니다. 함수가 데이터베이스에서 멀면 요청마다 지연이 누적되니, 함수를 데이터 소스 근처에 두면 시드니에서 미국으로 세 번 가는 대신 한 번만 가면 된다고요. 프론트의 유연함은 그대로 챙기면서(백엔드 수정 없이 프론트에서 조합) 속도까지 챙길 수 있습니다.

그러면 이걸 클라이언트에 어떻게 노출하나

Next.js 서버에서 조합한 결과를 클라이언트에 주려면 Route Handler를 만들어야 합니다. 이때 타입까지 챙기는 가장 알려진 방법이 tRPC입니다.

tRPC의 원리는 이렇습니다. 서버 코드는 원칙적으로 클라이언트에 못 보내니까 타입만 보냅니다. import type으로 가져온 타입은 컴파일할 때 사라지기 때문에 서버 코드가 클라이언트 번들에 딸려 나오지 않습니다. 그리고 클라이언트 객체는 사실 JavaScript Proxy입니다. client.post.byId.query()처럼 점을 찍으며 내려가는 경로를 그대로 /api/trpc/post.byId 엔드포인트로 바꿔서 fetch합니다. 점 뒤에 뜨는 자동완성은 서버에서 정의한 라우터 타입에서 옵니다.

// 서버
export const appRouter = router({
  post: router({
    byId: publicProcedure.input(z.string()).query(({ input }) => db.post.find(input)),
  }),
})
export type AppRouter = typeof appRouter

// 클라이언트: 타입만 가져온다
import type { AppRouter } from '../server/router'
const client = createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: '/api/trpc' })] })

// Proxy가 경로를 엔드포인트로 바꾼다
const post = await client.post.byId.query('1')   // → GET /api/trpc/post.byId?input="1"

tRPC concepts
출처: tRPC: Concepts. Proxy 기반 클라이언트의 최소 구현은 tRPC 블로그에 있습니다.

좋은 방법이지만 그래도 번거롭습니다. 라우터를 등록하고, 프로시저를 정의하고, 프로바이더와 링크를 설정해야 하죠.

그런데 서버 액션은 이 모든 걸 함수 하나 export하는 걸로 끝냅니다. 서버 사이드에서 호출하면서 클라이언트에 타입까지 그대로 가져오니까요. 개꿀 아닌가요? 여기서 Next.js의 입장이 다릅니다.

본론 2. 서버 "액션"이라는 이름

Next.js는 서버 액션을 mutation용으로 만들었다

이름을 보면 알 수 있습니다. 원격 함수(remote function)가 아니라 서버 "액션"입니다. 조회를 상정하지 않았다는 뜻입니다. React 문서의 'use server' 페이지에 이렇게 적혀 있습니다.

Server Functions are designed for mutations that update server-side state; they are not recommended for data fetching. Accordingly, frameworks implementing Server Functions typically process one action at a time and do not have a way to cache the return value.

Next.js 문서는 더 직접적입니다. Server Actions and Mutations 가이드의 "Sequential dispatch on the client" 절입니다.

Next.js dispatches Server Actions one at a time per client. If a user triggers three actions in quick succession, the second waits for the first to finish, then the third waits for the second. (…) A consequence: do not rely on Promise.all to parallelize Server Actions from the client.

Next.js use server 문서
출처: Next.js: use server

즉 클라이언트에서 병렬로 호출해도 서버 액션은 큐에 들어가서 하나씩 실행됩니다.

'use client'
import { getUser, getTeam, getProjects } from './actions' // 각각 1초 걸린다고 가정

async function load() {
  console.time('all')
  await Promise.all([getUser(), getTeam(), getProjects()])
  console.timeEnd('all') // all: 3012ms  ← 1초가 아니라 3초
}
코드는 Promise.all                     실제 실행

getUser()    ─┐                        getUser()    ████████ 1s
getTeam()    ─┼─ 동시에 호출            getTeam()             ████████ 1s
getProjects()─┘                        getProjects()                  ████████ 1s
                                       ─────────────────────────────────────▶ 3s

코드상으로는 병렬인데 실행 시간은 각 함수 실행 시간의 합이 됩니다.

소스로 확인해보면

Next.js 16.3.1의 클라이언트 코드를 열어보면 이유가 보입니다. 서버 액션을 호출하면 callServer가 불리는데, 이 함수는 fetch를 바로 보내지 않고 App Router의 액션 큐에 ACTION_SERVER_ACTION을 넣습니다.

// next/dist/client/app-call-server.js (발췌)
async function callServer(actionId, actionArgs) {
  return new Promise((resolve, reject) => {
    startTransition(() => {
      dispatchAppRouterAction({
        type: ACTION_SERVER_ACTION,
        actionId, actionArgs, resolve, reject,
      })
    })
  })
}
// next/dist/client/components/app-router-instance.js (발췌)
function dispatchAction(actionQueue, payload, setState) {
  // ...
  if (actionQueue.pending === null) {
    // 큐가 비어 있으면 바로 실행
    runAction({ actionQueue, action: newAction, setState })
  } else if (payload.type === ACTION_NAVIGATE || payload.type === ACTION_RESTORE) {
    // 내비게이션은 대기 중인 액션보다 우선
    // ...
  } else {
    // 큐가 비어 있지 않으면 맨 뒤에 붙인다
    // 이전 액션이 끝나면 runRemainingActions가 다음을 시작한다
    actionQueue.last.next = newAction
    actionQueue.last = newAction
  }
}

큐가 비어 있지 않으면 뒤에 붙고, 앞의 액션이 끝나야 다음이 시작됩니다. 내비게이션과 같은 큐를 쓰는 것도 보이죠. 서버 액션의 응답에는 갱신된 RSC 트리가 함께 실려 올 수 있어서, 라우터 상태와 액션 결과의 순서를 맞춰야 합니다. Next.js 문서도 이 순차 실행의 이유를 "재렌더된 서버 트리를 그 결과를 만든 액션과 일관되게 유지하기 위해서"라고 설명합니다. mutation 여러 개가 동시에 들어와서 꼬이지 말라고 만든 설계입니다.

읽기도 좀 생각해달라는 이야기

이 제약에 대한 불만은 오래됐습니다. 2023년 6월에 "왜 서버 액션이 순차 실행되나요"라는 디스커션이 올라왔고, 2024년 8월에 열린 이슈 #69265는 지금도 열려 있습니다. 2025년 10월 디스커션 #84893에서 Vercel 팀원이 방향을 밝혔습니다. 서로 다른 액션이면 동시 실행을 허용하고, 순차 실행이 필요 없는 읽기 전용 액션 API를 따로 만들겠다고요. 다만 16.3.4 문서 기준으로 아직 한 번에 하나씩입니다.

SvelteKit은 처음부터 읽기와 쓰기를 나눴다

이 관점은 이해할 만합니다. 그래서 SvelteKit의 Remote Functions는 처음부터 읽기와 쓰기를 구분해서 나왔습니다. query, form, command, prerender 네 종류인데, query는 읽기, command와 form은 쓰기입니다.

// src/routes/blog/data.remote.ts
import { query, command } from '$app/server'
import * as db from '$lib/server/database'

// 읽기: 어디서든 호출, 서버에서 실행
export const getLikes = query(v.string(), async (id) => {
  const [row] = await db.sql`SELECT likes FROM item WHERE id = ${id}`
  return row.likes
})

// 쓰기: 렌더 중에는 호출 불가
export const addLike = command(v.string(), async (id) => {
  await db.sql`UPDATE item SET likes = likes + 1 WHERE id = ${id}`
})
<script>
  import { getLikes, addLike } from './likes.remote'
  let { item } = $props()
</script>

<button onclick={() => addLike(item.id)}>add like</button>
<p>likes: {await getLikes(item.id)}</p>

SvelteKit Remote functions 문서
출처: SvelteKit: Remote functions. 2.27부터 experimental이고, 2026년 8월에 나온 SvelteKit 3 RC에서도 아직 플래그 뒤에 있습니다.

query는 컴포넌트 렌더 중에 await으로 바로 읽을 수 있고, command는 렌더 중 호출을 막습니다. 읽기와 쓰기의 성격이 다르다는 걸 API 이름으로 드러낸 거죠. 아직 experimental이긴 하지만 방향은 분명합니다.

Next.js가 서버 액션을 "액션"으로만 한정한 게 아쉬울 따름이지만, Next.js를 쓰기로 했으면 따라야겠지요. 하지만 이걸 해결하려고 눈물겨운 사투를 벌이게 됩니다.

본론 3. 눈물겨운 사투

1단계: 반환값을 Promise로 감싼다

첫 번째 꼼수는 이렇습니다. 액션이 결과를 직접 반환하지 않고, 결과를 담은 Promise를 배열에 넣어서 반환합니다.

// lib/action.ts
function parallelAction<A extends unknown[], T>(fn: (...args: A) => Promise<T>) {
  return async (...args: A) => [fn(...args)] as const
  //                           ^ await 하지 않는다. Promise를 그대로 담아서 돌려준다
}

async function runParallelAction<T>(result: Promise<readonly [Promise<T>]>) {
  return (await result)[0]
}

export const createAction = flow(safeAction, parallelAction)
// api/user/actions/get-me.ts
'use server'
export const getMe = createAction(async () => db.user.findMe())

// 호출 측에서는 일반 함수처럼 쓴다
export const user = resolveActions({ getMe })  // 내부에서 runParallelAction으로 풀어준다

이게 왜 통하냐면, React Flight(RSC 직렬화 형식)가 Promise를 직렬화할 수 있기 때문입니다. React 문서의 'use server' 페이지에 서버 함수의 반환값으로 지원하는 타입 목록이 있는데, 거기에 Promise가 들어 있습니다. 액션 자체는 [Promise]를 반환하는 순간 끝난 것으로 처리되고, 큐는 다음 액션으로 넘어갑니다. 안에 든 Promise는 응답 스트림에서 나중에 resolve됩니다.

1단계 적용 전                          1단계 적용 후

getUser   ████████                     getUser   ▌(즉시 반환) ┄┄┄┄┄┄┄ resolve
getTeam           ████████             getTeam    ▌(즉시 반환) ┄┄┄┄┄┄┄ resolve
getProjects               ████████     getProjects ▌(즉시 반환) ┄┄┄┄┄┄┄ resolve
──────────────────────────▶ 3s         ─────────────▶ 약 1s + 큐 지연

액션 호출 자체는 여전히 순차지만, 액션 안의 Promise가 끝나야 액션이 끝난 것으로 보지 않으니 큐가 바로 빠집니다. 남는 건 클라이언트와 Next.js 서버 사이의 네트워크 지연뿐이라 사실상 병렬입니다. GitHub 이슈에도 같은 우회법이 올라와 있습니다.

복병: 렌더 중에는 못 부른다

그런데 한 가지 복병이 있습니다. 서버 액션에는 제약이 하나 더 있는데, 초기 렌더 트리를 구성하는 중에 호출하면 에러를 냅니다. React 소스에 에러 메시지가 그대로 있습니다.

Server Functions cannot be called during initial render. This would create a fetch waterfall. Try to use a Server Component to pass data to Client Components instead.

이 가드는 SSR의 초기 렌더에서 걸립니다. 그래서 TanStack Query의 useSuspenseQuery 같은 사용이 안 됩니다.

TanStack useSuspenseQuery 문서
출처: TanStack Query: useSuspenseQuery

useQuery와 useSuspenseQuery의 차이를 짚어둘게요. useQuery는 마운트 이후에 페칭을 시작합니다(정확히는 useSyncExternalStore 구독이 붙은 뒤). 그래서 서버 렌더 시점에는 pending 상태로 그려지고, 이 컴포넌트의 실제 콘텐츠는 서버 렌더 결과에 들어가지 않습니다. 반면 useSuspenseQuery는 렌더 중에 데이터를 요청하고, 아직 Promise면 그걸 throw합니다. 그러면 위에 있는 Suspense가 걸리죠.

이 차이가 SEO에서 드러납니다. 1편에서 말했듯 Suspense가 걸리면 스트리밍 SSR이 응답을 열어둔 채 데이터가 풀릴 때까지 기다립니다. 사용자는 로딩 화면을 보지만 봇은 완성된 HTML을 받아갑니다. useQuery로 하면 HTTP 응답은 로딩 상태인 채로 완료되고, 봇이 보는 화면도 로딩 상태입니다. 같은 화면처럼 보여도 봇에게는 다릅니다.

그런데 서버 액션은 렌더 중 호출이 막혀 있으니 useSuspenseQuery의 queryFn에 넣을 수 없습니다. TanStack 문서도 "Next.js Server Actions로 데이터를 페칭하는 것을 권장하지 않는다"고 못 박아뒀습니다. 관련 이슈도 upstream 문제로 닫혔고요.

2단계: 'use server'처럼 쓰고, 실제로는 다르게 컴파일한다

그래서 제가 만든 템플릿에서는 조금 더 나갔습니다. 소스 코드는 그냥 'use server'를 쓰는 것처럼 보이는데, 실제로는 서버 액션을 전혀 쓰지 않습니다.

작성 규약은 그대로입니다.

// src/services/app/api/user/actions/get-me.ts
'use server'

import { createAction } from '@/lib/utils'

async function _getMe(): Promise<User | null> {
  const session = await getServerSession()
  return session ? db.user.find(session.userId) : null
}

export const getMe = createAction(_getMe)
// src/services/app/api/user/index.ts
import { resolveActions } from '@/lib/utils'
import { getMe } from './actions'

export const user = resolveActions({ getMe })

빌드할 때 Turbopack 로더가 세 군데를 바꿉니다.

소스                                     빌드 결과

actions/*.ts                             'use server'  →  import 'server-only'
  'use server'                           (React 지시어가 아니라 poison import.
                                          클라이언트 번들로 새면 빌드 실패)

api/*/index.ts                           resolveActions({ getMe })
  resolveActions({ getMe })                →  resolveCompiledActions({
                                                 getMe: 'app/user/get-me#getMe'
                                               })
                                           브라우저: 함수 id로 POST 호출
                                           서버: manifest에서 모듈을 직접 import

lib/server-action/manifest.ts            id → () => import('@/services/app/api/user/actions/get-me')
  (빈 스텁)                               (서버 전용, 허용 목록)

핵심 코드만 보면 이렇습니다.

// build/shared.cjs (발췌)
function replaceUseServerWithServerOnly(source) {
  return source.replace(
    /^(\uFEFF?\s*)?(['"])use server\2;?\s*/,
    (_m, prefix = '') => `${prefix}import 'server-only'\n\n`,
  )
}
// next.config.ts
import { withServerFn } from './src/lib/server-action/next'
export default withServerFn(nextConfig)
// app/api/internal/server-fn/[...slug]/route.ts — 모든 액션이 지나가는 단일 Route Handler
import { handleServerFnPost } from '@/lib/server-action/handler'

export async function POST(request: Request, ctx: { params: Promise<{ slug: string[] }> }) {
  const { slug } = await ctx.params
  return handleServerFnPost(request, slug)   // slug → 함수 id → manifest에서 import → 실행
}

브라우저에서 user.getMe()를 부르면 /api/internal/server-fn/app/user/get-me/getMe로 POST가 나갑니다. 서버 컴포넌트나 SSR에서 같은 함수를 부르면 HTTP 없이 manifest에서 모듈을 import해서 원본 함수를 그냥 실행합니다. Next.js 문서가 경고하는 "Route Handler를 거치면 렌더 프로세스와 핸들러 사이에 HTTP 왕복이 하나 더 생긴다"는 문제를 피하는 겁니다.

브라우저에서 user.getMe()
  → fetch POST /api/internal/server-fn/app/user/get-me/getMe
  → Route Handler → manifest['app/user/get-me#getMe']() → 실행 → JSON

서버(RSC/SSR)에서 user.getMe()
  → manifest['app/user/get-me#getMe']() → 실행   (HTTP 없음)

이렇게 하면 얻는 게 세 가지입니다. 병렬 호출이 진짜 병렬이고, 렌더 중 호출이 가능해서 Suspense 기반 데이터 로딩이 되고, 서버가 자기 자신에게 HTTP를 보내지 않습니다. 잃는 것도 있습니다. React의 action 통합(useActionState, 폼의 점진적 향상)과 응답에 갱신된 RSC 트리를 실어 보내는 기능은 못 씁니다. 그래서 조회용으로만 씁니다. 쓰기는 원래 서버 액션 그대로 두고요.

결론

Next.js가 서버 액션을 mutation 전용으로 설계한 건 이해가 갑니다. 다만 그 사실을 모른 채 조회에 쓰면, 코드는 병렬인데 실행은 순차인 상황을 겪게 됩니다. 알고 쓰면 우회할 방법도 있고요.

이 글의 createAction과 서버 함수 컴파일러는 제가 운영하는 컴윗의 코드 템플릿에 들어 있습니다. 컴윗은 클로드코드나 코덱스만 있으면 호스팅, DB, 파일 저장, 로그인, 도메인까지 나머지 인프라를 갖춰둔 서비스입니다. 프로젝트를 만들면 기술 스택까지 정해서 주는데, 여기서 다룬 액션 규약을 린트 규칙과 함께 넣어둬서 에이전트가 이 규칙을 벗어난 코드를 쓰면 빌드가 실패합니다. 코드가 커져도 산출물이 일관되게 나오도록 하네스를 깔아둔 건데, 그게 실제 서비스에서 어떤 모습인지 궁금하시면 한 번 살펴봐 주세요.

참고 자료

profile
기부하면 코드 드려요

0개의 댓글