Next.js Server Actions "Functions cannot be passed directly to Client Components" 에러 해결법

Next.js Server Actions Functions cannot be passed to Client Components 에러 해결 가이드
3초 요약: 이렇게 고치세요
  • 원인: 서버 컴포넌트의 일반 함수는 직렬화가 안 돼서, Client Component에 props로 그대로 넘기면 React가 전달 자체를 막음
  • 해결: 넘길 함수 내부 최상단에 'use server'를 선언해 진짜 Server Action으로 만들거나, 서버 로직이 필요 없다면 아예 클라이언트 컴포넌트 안으로 로직을 옮기기
  • 주의: 인자를 미리 고정해서 넘기고 싶으면 .bind(null, id)로 감싸야지, 클로저로 감싼 일반 함수를 새로 만들면 다시 같은 에러가 남
async function deletePost(id: string) {
  'use server';
  await db.post.delete({ where: { id } });
}
↓ 왜 이런 에러가 나는지 원리 보기

서버 컴포넌트에서 만든 핸들러 함수를 버튼 같은 클라이언트 컴포넌트에 그대로 props로 넘기면 "Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with \"use server\"" 에러가 뜨는 경우가 있습니다. 왜 함수는 그냥 넘길 수 없는지와, Server Action으로 바꿔서 해결하는 방법을 정리합니다.


왜 서버 컴포넌트의 함수를 클라이언트 컴포넌트에 그대로 못 넘길까

서버 컴포넌트는 브라우저로 그대로 전송되지 않습니다. 서버에서 렌더링된 결과가 RSC(React Server Components) 페이로드라는 직렬화된 데이터 형태로 변환되어 클라이언트로 넘어가고, 클라이언트는 이 데이터를 읽어서 화면을 조립합니다. 이때 문자열, 숫자, 객체, 배열처럼 JSON으로 표현 가능한 값은 문제없이 직렬화되지만, 자바스크립트 함수는 코드 자체가 서버의 메모리 안에만 존재하기 때문에 이 변환 과정에서 표현할 방법이 없습니다.

그래서 서버 컴포넌트 안에서 선언한 평범한 함수를 클라이언트 컴포넌트의 props로 그대로 넘기면, 그 서버 컴포넌트가 실제로 렌더링되어 RSC 페이로드로 직렬화되는 시점에 "이 함수를 어떻게 클라이언트로 보내라는 거냐"며 에러가 납니다. 정적 라우트라면 next build 시점에(프로덕션 빌드 기준이며, next dev에서는 정적 라우트도 요청마다 다시 렌더링되므로 그 요청 시점에 나타납니다), 동적 라우트라면 그 페이지에 실제 요청이 들어오는 시점에 나타나는 런타임 에러입니다. 유일한 예외가 'use server' 지시어가 붙은 함수인 Server Action입니다. Server Action은 실제 함수 코드를 그대로 보내는 게 아니라, "이 액션을 호출하면 서버에서 이 코드를 실행해 달라"는 참조 아이디(reference ID)로 직렬화되기 때문에 클라이언트로 넘길 수 있는 것입니다.

에러가 나는 코드

// app/posts/page.tsx (서버 컴포넌트)
import DeleteButton from './DeleteButton';

// 'use server'가 없는 평범한 서버 사이드 함수
async function deletePost(id: string) {
  await db.post.delete({ where: { id } });
}

export default async function PostsPage() {
  const posts = await getPosts();

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          {post.title}
          {/* 에러: 일반 함수를 클라이언트 컴포넌트 props로 그대로 전달 */}
          <DeleteButton onDelete={deletePost} />
        </li>
      ))}
    </ul>
  );
}

실무에서 자주 나타나는 패턴

예전에 외주로 맡았던 관리자 페이지 프로젝트를 Pages Router에서 App Router로 옮기던 중에 이 에러를 처음 만났습니다. 예전 습관대로 목록을 그리는 서버 컴포넌트 위쪽에 삭제·수정 같은 핸들러 함수를 몰아서 선언하고 각 행마다 자식 컴포넌트에 props로 뿌려주는 구조를 그대로 가져왔는데, 타입스크립트도 조용했고 에디터에도 빨간 줄이 없어서 별문제 없어 보였습니다.

문제는 그 버튼을 별도 파일로 뽑아 클라이언트 컴포넌트로 분리하고 나서야 터졌습니다. 처음엔 에러 메시지를 그대로 검색해서 'use server'를 함수 위에 붙이면 되는 줄 알고 아무 함수에나 붙였다가, 서버 자원이 필요 없는 단순 토글 함수까지 전부 네트워크 요청을 타는 이상한 구조가 됐습니다. 나중에 서버 전용 로직과 순수 UI 로직을 구분해서, 전자만 Server Action으로 남기고 후자는 클라이언트 컴포넌트 안 useState로 되돌리고 나서야 원래 의도한 구조로 정리됐습니다.


에러를 해결하는 2가지 방법

방법 A: 함수 내부에 'use server'를 선언해 진짜 Server Action으로 만들기

DB 접근이나 인증 체크처럼 서버에서만 실행되어야 하는 로직이라면, 함수 본문 최상단에 'use server'를 선언합니다. 이렇게 하면 Next.js가 이 함수를 클라이언트에서도 호출 가능한 참조로 컴파일해 주기 때문에 props로 넘겨도 에러가 나지 않습니다.

// app/posts/page.tsx (서버 컴포넌트)
import DeleteButton from './DeleteButton';

async function deletePost(id: string) {
  'use server'; // 함수 최상단에 지시어 추가
  await db.post.delete({ where: { id } });
}

export default async function PostsPage() {
  const posts = await getPosts();

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          {post.title}
          {/* 각 항목의 id를 미리 고정해서 넘김 */}
          <DeleteButton onDelete={deletePost.bind(null, post.id)} />
        </li>
      ))}
    </ul>
  );
}

// app/posts/DeleteButton.tsx (클라이언트 컴포넌트)
'use client';

export default function DeleteButton({ onDelete }: { onDelete: () => Promise<void> }) {
  return (
    <button onClick={() => onDelete()}>
      삭제
    </button>
  );
}

deletePost(post.id)처럼 바로 호출한 결과를 넘기면 렌더링 시점에 함수가 즉시 실행돼 버리므로, 인자를 미리 고정만 해두고 실제 호출은 클릭 시점으로 미루는 .bind(null, post.id)를 씁니다. bind로 인자가 고정된 Server Action도 여전히 Server Action이라 클라이언트로 넘기는 데 문제가 없습니다.

방법 B: 서버 로직이 필요 없다면 클라이언트 컴포넌트 안으로 옮기기

단순히 아코디언을 펼치거나 탭을 전환하는 것처럼 서버 자원에 접근할 필요가 없는 로직이라면, 애초에 서버 컴포넌트에 함수를 두지 말고 클라이언트 컴포넌트 내부에서 직접 상태로 처리하는 편이 더 단순합니다.

// app/posts/ExpandableRow.tsx (클라이언트 컴포넌트)
'use client';

import { useState } from 'react';

export default function ExpandableRow({ title }: { title: string }) {
  const [expanded, setExpanded] = useState(false);

  return (
    <li onClick={() => setExpanded((prev) => !prev)}>
      {title}
      {expanded && <p>상세 내용...</p>}
    </li>
  );
}

이 경우 서버 컴포넌트는 데이터만 내려주고, 상태를 토글하는 로직 자체는 클라이언트 컴포넌트 밖으로 나갈 일이 없으므로 애초에 직렬화 문제가 발생하지 않습니다.


요약: 어떤 경우에 어떤 방법을 쓸까

상황 원인 해결
DB 접근·인증 등 서버 전용 로직 일반 함수는 직렬화가 안 되어 클라이언트로 전달 불가 'use server'로 Server Action화
항목별로 인자를 미리 고정해야 할 때 즉시 호출하면 렌더링 시점에 실행돼 버림 .bind(null, id)로 인자 고정
서버 자원이 필요 없는 단순 UI 로직 애초에 서버 컴포넌트에 로직을 둘 이유가 없음 클라이언트 컴포넌트 안으로 이동
공식 참고 레퍼런스

Server Actions의 상세 동작 방식과 클라이언트 컴포넌트로의 전달 규칙은 공식 문서에서 확인할 수 있습니다.

호마다의 웹개발 팁

서버/클라이언트 컴포넌트 경계와 직렬화 규칙은 결국 "브라우저로 무엇이 실제로 전송되는가"를 이해해야 자연스러워집니다. 관련 기초는 [호마다의 IT 개발 입문 블로그] 에서 확인하실 수 있습니다.

댓글

이 블로그의 인기 게시물

TypeScript: Unknown vs Any 타입 차이점과 안전한 타입 가드(Type Guard)

Next.js 하이드레이션 오류 원인과 해결법 완벽 정리

Next.js App Router에서 getServerSideProps 대체 및 데이터 페칭법