Server Actions: 자동화된 RPC
RSC가 서버에서 데이터를 “읽는” 방법이라면, Server Actions는 서버에서 데이터를 “쓰는” 방법입니다. 하지만 Server Actions를 이해하려면 먼저 오래된 개념, RPC를 알아야 합니다.
RPC란 무엇인가
RPC(Remote Procedure Call)는 1980년대에 등장한 개념입니다. 핵심 아이디어는 단순합니다:
원격 컴퓨터의 함수를 로컬 함수처럼 호출할 수 있게 하자.
[로컬] [원격 서버]
add(2, 3) ──── 네트워크 ────> 실제 add 함수 실행
↑ ↓
5 <──── 네트워크 ──── 결과 반환
Plain Text
복사
호출하는 쪽에서는 네트워크를 의식하지 않습니다. 그냥 함수를 호출하면 됩니다. 나머지는 RPC 시스템이 알아서 처리합니다:
- 인자를 직렬화해서 네트워크로 전송
- 서버에서 실제 함수 실행
- 결과를 직렬화해서 반환
웹에서의 RPC 역사
웹이 등장하면서 RPC는 여러 형태로 진화했습니다:
시대 | 기술 | 특징 |
1990s | SOAP, XML-RPC | XML 기반, 복잡한 명세 |
2000s | JSON-RPC | JSON으로 단순화 |
2010s | gRPC | Google, Protocol Buffers |
2020s | tRPC | TypeScript 타입 안전성 |
tRPC는 특히 TypeScript와 궁합이 좋습니다:
// tRPC 서버
const appRouter = router({
addTodo: procedure
.input(z.object({ text: z.string() }))
.mutation(async ({ input }) => {
return await db.todos.create({ text: input.text });
}),
});
// tRPC 클라이언트 - 타입이 자동 추론됨
const result = await trpc.addTodo.mutate({ text: "새 할 일" });
TypeScript
복사
하지만 여전히 별도의 라우터 설정, 클라이언트 설정이 필요합니다.
웹의 기본: HTTP는 RPC가 아니다
여기서 중요한 점이 있습니다. HTTP는 RPC가 아닙니다.
HTTP는 “요청-응답” 메시지 프로토콜입니다:
GET /users/123 HTTP/1.1 → "123번 사용자 데이터를 주세요"
POST /users HTTP/1.1 → "새 사용자를 만들어주세요"
Plain Text
복사
함수 호출이 아니라 리소스에 대한 동작을 표현합니다. REST API가 이 철학을 따르죠.
그래서 웹에서 “함수처럼” 서버를 호출하려면 추상화 계층이 필요합니다:
// 수동 RPC: 직접 fetch로 구현
async function addTodo(text) {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
return response.json();
}
JavaScript
복사
이게 바로 우리가 수년간 해온 방식입니다. 하지만 몇 가지 문제가 있습니다:
1.
보일러플레이트: 매번 fetch, method, headers, body 작성
2.
타입 안전성 없음: 서버 응답 타입을 수동으로 관리
3.
엔드포인트 관리: /api/todos, /api/users/[id] 등 URL 라우팅 필요
4.
직렬화 수동 처리: JSON.stringify, JSON.parse
Server Actions: React의 자동 RPC
Server Actions는 이 모든 과정을 자동화합니다:
// actions.ts
'use server';
export async function addTodo(formData: FormData) {
const text = formData.get('text') as string;
await db.todos.create({ text });
revalidatePath('/todos');
}
TypeScript
복사
// TodoForm.tsx
import { addTodo } from './actions';
export function TodoForm() {
return (
<form action={addTodo}>
<input name="text" />
<button type="submit">추가</button>
</form>
);
}
TypeScript
복사
이게 전부입니다. API 라우트도, fetch도, 직렬화 코드도 없습니다.
’use server’가 하는 일
'use server'는 컴파일러에게 이렇게 말합니다:
“이 함수를 RPC 엔드포인트로 변환해라”
빌드 타임에 일어나는 일:
[빌드 전] [빌드 후]
'use server'; // 서버: 실제 함수 유지
export async function addTodo() { export async function addTodo() {
await db.todos.create(...); await db.todos.create(...);
} }
// 클라이언트: 스텁으로 교체
export async function addTodo() {
return fetch('/_actions/abc123', {
method: 'POST',
body: ...
});
}
Plain Text
복사
클라이언트 번들에는 실제 함수 코드가 포함되지 않습니다. 대신 서버로 요청을 보내는 “스텁” 함수로 교체됩니다.
실제 네트워크 요청
개발자 도구의 Network 탭을 열어보면 이런 요청이 보입니다:
POST /_next/...actions/abc123xyz
Content-Type: multipart/form-data
------boundary
Content-Disposition: form-data; name="text"
새 할 일
------boundary--
Plain Text
복사
'use server'가 자동으로:
- 고유 엔드포인트 생성 (abc123xyz 같은 해시)
- FormData 직렬화
- 응답 처리
- 에러 핸들링
개발자가 신경 쓸 건 함수 로직뿐입니다.
‘use server’ vs ‘use client’ 대칭성
두 지시어는 대칭적인 역할을 합니다:
지시어 | 의미 | 변환 |
'use client' | 이 모듈은 클라이언트에서 실행됨 | 서버에서는 참조로 변환 |
'use server' | 이 함수는 서버에서만 실행됨 | 클라이언트에서는 RPC 스텁으로 변환 |
[서버 → 클라이언트 방향]
'use client' 컴포넌트
→ 서버에서 렌더링될 때 "참조"로 표현
→ 클라이언트에서 실제 컴포넌트로 복원
[클라이언트 → 서버 방향]
'use server' 함수
→ 클라이언트에서 "스텁"으로 표현
→ 호출하면 서버의 실제 함수 실행
Plain Text
복사
RSC + Server Actions가 완전한 그림을 만듭니다:
┌─────────────────────────────────────────────────┐
│ 서버 │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ Server │ │ Server Actions │ │
│ │ Components │ │ (데이터 쓰기) │ │
│ │ (데이터 읽기)│ │ │ │
│ └──────┬──────┘ └────────▲────────┘ │
│ │ │ │
├─────────┼────────────────────────┼──────────────┤
│ │ RSC Payload │ RPC 호출 │
│ ▼ │ │
│ ┌─────────────────────────────────────────┐ │
│ │ 클라이언트 │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ Client Components │ │ │
│ │ │ - UI 렌더링 │ │ │
│ │ │ - 이벤트 핸들링 │ │ │
│ │ │ - Server Actions 호출 │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
Plain Text
복사
Form Actions과 Progressive Enhancement
Server Actions의 또 다른 강점은 Progressive Enhancement입니다.
HTML form의 action 속성을 활용합니다:
<form action={addTodo}>
<input name="text" />
<button type="submit">추가</button>
</form>
TypeScript
복사
이 코드가 흥미로운 이유:
1.
JavaScript 없이도 동작: form의 기본 동작으로 서버에 요청
2.
Hydration 후 향상: JavaScript가 로드되면 fetch로 인터셉트
3.
페이지 새로고침 없음: JavaScript가 있으면 SPA처럼 동작
[JavaScript 비활성화 시]
form submit → 브라우저 기본 동작 → 페이지 새로고침
[JavaScript 활성화 시]
form submit → React 인터셉트 → fetch 요청 → UI 업데이트 (새로고침 없음)
Plain Text
복사
이건 접근성과 점진적 향상의 관점에서 중요합니다. 오래된 브라우저, JavaScript 로딩 실패, 네트워크 문제 상황에서도 기본 기능이 동작합니다.
에러 처리와 상태 관리
Server Actions와 함께 사용하는 React 훅들이 있습니다.
useActionState
액션의 상태를 관리합니다:
'use client';
import { useActionState } from 'react';
import { addTodo } from './actions';
function TodoForm() {
const [state, formAction, isPending] = useActionState(addTodo, null);
return (
<form action={formAction}>
<input name="text" disabled={isPending} />
<button disabled={isPending}>
{isPending ? '추가 중...' : '추가'}
</button>
{state?.error && <p className="error">{state.error}</p>}
</form>
);
}
TypeScript
복사
// actions.ts
'use server';
export async function addTodo(prevState: any, formData: FormData) {
const text = formData.get('text') as string;
if (!text.trim()) {
return { error: '내용을 입력해주세요' };
}
try {
await db.todos.create({ text });
revalidatePath('/todos');
return { success: true };
} catch (e) {
return { error: '저장에 실패했습니다' };
}
}
TypeScript
복사
useOptimistic
낙관적 업데이트를 구현합니다:
'use client';
import { useOptimistic } from 'react';
function TodoList({ todos, addTodo }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo) => [...state, { ...newTodo, pending: true }]
);
async function handleSubmit(formData) {
const text = formData.get('text');
addOptimisticTodo({ id: Date.now(), text }); // 즉시 UI 업데이트
await addTodo(formData); // 실제 서버 요청
}
return (
<>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.text}
</li>
))}
</ul>
<form action={handleSubmit}>
<input name="text" />
<button>추가</button>
</form>
</>
);
}
TypeScript
복사
RPC의 트레이드오프
Server Actions가 만능은 아닙니다:
REST API | tRPC | Server Actions | |
타입 안전성 | 수동 관리 | 자동 | 자동 |
추가 설정 | 라우팅 필요 | 라우터 설정 | 없음 |
프레임워크 종속 | 없음 | 없음 | Next.js 등 필요 |
Progressive Enhancement | 수동 구현 | 불가 | 자동 |
외부 클라이언트 지원 | 가능 | 가능 | 불가 |
디버깅 | 명확한 URL | 명확한 프로시저 | 해시 기반 URL |
Server Actions가 적합한 경우
•
폼 제출: 회원가입, 로그인, 댓글 작성
•
간단한 mutation: 좋아요, 북마크, 삭제
•
React 앱 내부 통신: 외부 클라이언트 필요 없음
REST API가 여전히 필요한 경우
•
외부 클라이언트: 모바일 앱, 서드파티 연동
•
복잡한 API: 여러 리소스를 조합하는 쿼리
•
프레임워크 독립성: Next.js 외 환경에서도 사용
•
API 문서화: OpenAPI/Swagger 명세 필요
보안 고려사항
Server Actions는 서버에서 실행되지만, 클라이언트가 호출한다는 점을 잊지 마세요.
인증/인가 필수
'use server';
export async function deletePost(postId: string) {
// ❌ 잘못된 예: 인증 없이 바로 삭제
await db.posts.delete(postId);
}
export async function deletePost(postId: string) {
// ✅ 올바른 예: 인증 확인
const session = await getSession();
if (!session) {
throw new Error('로그인이 필요합니다');
}
const post = await db.posts.find(postId);
if (post.authorId !== session.userId) {
throw new Error('권한이 없습니다');
}
await db.posts.delete(postId);
}
TypeScript
복사
입력 검증
'use server';
import { z } from 'zod';
const TodoSchema = z.object({
text: z.string().min(1).max(500),
});
export async function addTodo(formData: FormData) {
const result = TodoSchema.safeParse({
text: formData.get('text'),
});
if (!result.success) {
return { error: result.error.flatten() };
}
await db.todos.create({ text: result.data.text });
}
TypeScript
복사
Server Actions도 결국 API 엔드포인트입니다. 외부에서 직접 호출될 수 있다고 가정하고 방어적으로 코딩해야 합니다.
정리: Server Actions의 본질
Server Actions는 새로운 개념이 아닙니다. 40년 된 RPC 아이디어를 React와 웹 표준에 맞게 구현한 것입니다.
1984년 RPC
↓
"원격 함수를 로컬처럼 호출"
↓
2024년 Server Actions
"서버 함수를 클라이언트에서 호출"
Plain Text
복사
핵심 가치:
- 자동 직렬화: FormData, 응답 처리 자동화
- 자동 엔드포인트: API 라우팅 불필요
- 타입 안전성: TypeScript 타입 자동 공유
- Progressive Enhancement: JavaScript 없이도 동작
RSC와 Server Actions가 완성하는 그림:
방향 | 기술 | 용도 |
서버 → 클라이언트 | RSC | 데이터 읽기, UI 렌더링 |
클라이언트 → 서버 | Server Actions | 데이터 쓰기, mutation |
둘을 조합하면 전통적인 “API 레이어” 없이도 풀스택 앱을 만들 수 있습니다.
// 하나의 파일에서 읽기/쓰기 모두 처리
import { getTodos, addTodo } from './actions';
export default async function TodoPage() {
const todos = await getTodos(); // Server Component: 데이터 읽기
return (
<div>
<ul>
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
<form action={addTodo}> {/* Server Action: 데이터 쓰기 */}
<input name="text" />
<button>추가</button>
</form>
</div>
);
}
TypeScript
복사
부록: Server Actions 용어 정리
용어 | 의미 |
Server Action | ’use server’로 표시된 서버 전용 함수 |
Form Action | form의 action에 전달된 Server Action |
RPC | Remote Procedure Call, 원격 함수 호출 패턴 |
스텁(Stub) | 클라이언트에서 실제 함수 대신 사용되는 프록시 |
Progressive Enhancement | JavaScript 없이도 기본 기능 동작 |
useActionState | 액션 상태 관리 훅 (이전: useFormState) |
useOptimistic | 낙관적 업데이트 훅 |
revalidatePath | 서버 캐시 무효화 함수 |
