NextJS 14부터는 server action을 지원한다. 이번 포스팅에서는 server action의 타입 안전성을 강화하고, 실행 흐름을 보다 직관적으로 파악할 수 있게 돕는 next-safe-action에 대해 알아보고, logger를 만들어 어떻게 개발자 경험을 향상시킬 수 있는지에 대해 이야기할 예정이다.
언제 server action을 쓸 수 있는지부터 짚고 넘어가겠다. server action은 서버, 클라이언트 컴포넌트 모두에서 호출될 수 있으며 form 제출, data mutation을 처리할 때 쓰는 비동기 함수이다. 구체적인 예시는 공식 문서를 참고하고, 이번 포스팅에서 주로 쓰일 상황만 이야기 해보겠다. 이를 위해 간단한 todo 애플리케이션을 만들어 보았다.
server action은 form 요소와 주로 쓰이는 것이 일반적이지만 event handler와도 함께 쓰일 수 있다.
create-todo-button.tsx
"use client";
import { createTodoAction } from "@/actions/create-todo";
import { Button } from "./ui/button";
import { Todo, TodoCreate } from "../../utils/supabase";
interface CreateTodoButtonProps {
newTodo: TodoCreate;
}
export default function CreateTodoButton({ newTodo }: CreateTodoButtonProps) {
const onClickCreateButton = async () => {
const result = await createTodoAction(newTodo);
// result를 바탕으로 한 적절한 error handling 또는 route 이동
console.log(result);
};
return <Button onClick={onClickCreateButton}>생성하기</Button>;
}
create-todo-action.ts
"use server";
import { TodoCreate } from "../../utils/supabase";
import { createClient } from "../../utils/supabase/server";
export const createTodoAction = async (todo: TodoCreate) => {
const supabase = await createClient();
return await supabase.from("todo").insert(todo);
};
onClick뿐만 아니라 onChange 등 아무 이벤트 핸들러에 붙여서 쓰일 수 있다. 그런데 data mutation의 역할을 하는 함수가 바인딩된다고 하면, 제출 완료 버튼의 onClick, 자자동 저장 또는 임시 저장을 구현하기 위해 textArea의 onChange, onBlur 정도가 적당한 이벤트가 되지 않을까 싶다.
위의 시연에서 console에 액션이 정확히 들어가고, supabase 데이터베이스에서도 삽입된 것을 알 수 있지만 실제 화면에 그려지는 데이터는 변하지 않는다. 이 이유는 NextJS에서 지원하는 caching에 있다. 애플리케이션 퍼포먼스 향상과 렌더링 비용을 감소시키기 위해 NextJS에서 지원해주는 기능이다. 현 상황에서는 server component인 page.tsx
에서 요청한 data에 대해 cache가 남아있다고 볼 수 있다.
그런데 이런 경우 새로고침을 수동으로 해야하는 번거로움이 있으니 mutation이 들어가서 사용자에게 새로운 데이터를 바로 보여줘야 할 경우 해당 페이지의 cache를 날려버리는 기능이 있는데, 그것이 바로 revaldatePath이다.
지금은 최상위 라우터인 /
로 캐시를 제거하도록 되어 있지만, 어떤 페이지 라우트냐에 따라서 전략을 달리 하면 된다. 예를 들어 todo를 보여주는 곳의 라우트가 /todos
가 된다면 revalidatePath("/todos")
로 작성하면 되고, /todo/1
이 된다면 revalidatePath("/todo/[slug]")
가 된다.
create-todo.ts
"use server";
import { revalidatePath } from "next/cache";
import { TodoCreate } from "../../utils/supabase";
import { createClient } from "../../utils/supabase/server";
export const createTodoAction = async (todo: TodoCreate) => {
const supabase = await createClient();
const result = await supabase.from("todo").insert(todo);
revalidatePath("/");
return result;
};
이러한 server action에 next-safe-action이 결합되면 좋은 점은 아래와 같다.
라이브러리 자체가 server action을 의존하므로 다음과 같은 패키지 요구사항이 있다.
- Next.js >= 14 (>= 15 for useStateAction hook)
- React >= 18.2.0
- TypeScript >= 5
- A supported validation library: Zod, Valibot, Yup, TypeBox
npm i next-safe-action zod
하다가 발견한 next15와의 호환성 오류
NextJS 15 자체는 괜찮지만, 내부로 가지고 있는 react 19가 아직 정식 출시 버전이 아니라서(release candidate) next-safe-action과의 호환성 오류가 발생한다. 혹시나 next15 + next-safe-action을 고려하고 있다면 우선 next14로 초기화하는 것이 좋을 것 같다.
과연 react 19을 품고 NextJS 15를 출시한 게 현명한 판단이었을까...🤨
utils/next-safe-action/client.ts
import { createSafeActionClient } from "next-safe-action";
import { z } from "zod";
export class ActionError extends Error {}
export const action = createSafeActionClient({
handleServerError(e) {
console.error("Action error:", e.message);
throw e;
},
defineMetadataSchema() {
return z.object({
actionName: z.string(),
});
},
});
create-todo.ts
"use server";
import { revalidatePath } from "next/cache";
import { TodoCreate } from "../../utils/supabase";
import { createClient } from "../../utils/supabase/server";
import { action } from "../../utils/next-safe-action/client";
import { z } from "zod";
export const createTodoAction = action
.metadata({
actionName: "createTodoAction",
})
.schema(z.custom<TodoCreate>())
.action(async ({ parsedInput: todo }) => {
const supabase = await createClient();
const result = await supabase.from("todo").insert(todo);
revalidatePath("/");
return result;
});
action에 chaining된 메서드를 하나씩 살펴보자.
metadata
는 client 생성 시 defineMetadataSchema
에서 정의했던 형식대로 값을 받을 수 있다. 말 그대로 action에 대한 metadata를 넣어 보내줄 수 있다. 오버 엔지니어링이라고 생각할 수 있지만 이 부분이 middleware로 넘어가 error logging을 거친다면, 어떤 action에서 에러가 발생했는지 바로 알 수 있어서 유용하다고 볼 수 있다.
지금은 단순한 actionName을 넣었지만, 필요하다면 실행 시점에 대한 시각 데이터도 함께 넣어줄 수 있을 것이다.
schema
는 Input에 대한 타입을 지정해줄 수 있는 역할을 한다. 만약 이 부분에서 유효성 에러가 발생한다면 middleware 쪽에서 validationError로 걸러준다. schema 내부에서 사용자 지정 error를 던져줄 수도 있다. 그리고 여기서 지정한 schema대로 다음 action 메서드의 인자로 parsedInput의 타입이 지정된다.
action
은 기존 server action이 품고 있는 로직을 담는 곳이다. 앞서 말한 schema를 인자로 받아 쓸 수 있다. action이 실행되면 모든 chaining되어 있던 middleware 함수가 런타임에 실행된다.
create-todo-button.tsx
"use client";
import { createTodoAction } from "@/actions/create-todo";
import { Button } from "./ui/button";
import { Todo, TodoCreate } from "../../utils/supabase";
import { useAction } from "next-safe-action/hooks";
interface CreateTodoButtonProps {
newTodo: TodoCreate;
}
export default function CreateTodoButton({ newTodo }: CreateTodoButtonProps) {
const createTodo = useAction(createTodoAction, {
onSuccess: ({ data }) => {
console.log(data);
},
});
const onClickCreateButton = async () => {
createTodo.execute(newTodo);
};
return <Button onClick={onClickCreateButton}>생성하기</Button>;
}
useAction
은 server action이 완전히 실행될 때까지 기다린 다음 콜백을 통해 그 액션에 대한 반환값을 가져와 추후 로직을 구성할 수 있는 hook이다. 데모 코드에서는 단순히 console.log를 두었는데, toast, route 이동, setState 등 자유롭게 작성할 수 있다. 물론 onError
라는 콜백도 있어 에러 핸들링도 가능하다.
이제 본격적으로 디버깅하기 쉬운 환경을 만들어보겠다. API 연결 작업 시 의도한대로 되지 않는 경우 흔히 드는 생각은,
등이 있을 것이다. 이런 경우 API 함수 안, 함수의 반환값, useEffect 내부, 컴포넌트 어디든 이곳저곳에 console.log
를 남발한 경우가 한 번쯤은 있었을 것이다. 적어도 필자는 console.log
가 이곳저곳 박혀 있는 컴포넌트, 함수가 많아 eslint(no-console)을 애용해왔다.
그래서 server action에 한하여 전용 logger를 만들어 보겠다.
utils/next-safe-action/util.ts
const IS_DEVELOPMENT = process.env.NODE_ENV === "development";
const COLORS = {
blue: "\x1b[34m",
yellow: "\x1b[33m",
green: "\x1b[32m",
red: "\x1b[31m",
reset: "\x1b[0m",
bold: "\x1b[1m",
};
interface LogArgs {
json: unknown;
actionName?: string;
}
const formatJSON = (json: unknown) =>
COLORS.yellow + JSON.stringify(json, null, 2) + COLORS.reset;
const log = (
message: string,
json: unknown,
color: string,
isError: boolean = false
) => {
if (IS_DEVELOPMENT) {
const formattedMessage = `${color}${message}${COLORS.reset}`;
const formattedJSON = formatJSON(json);
console[isError ? "error" : "log"](formattedMessage);
console[isError ? "error" : "log"](formattedJSON);
}
};
const logInfo = ({ json, actionName }: LogArgs) => {
const message = `⛳️ [LOG] Method ${COLORS.bold}${actionName}${COLORS.reset} is being called with arguments:`;
log(message, json, COLORS.blue);
};
const logSuccess = ({ json, actionName }: LogArgs) => {
const message = `✅ [LOG] Method ${COLORS.bold}${actionName}${COLORS.reset} succeeded with result:`;
log(message, json, COLORS.green);
};
const logError = ({ json, actionName }: LogArgs) => {
const message = `❌ [ERROR] Method ${COLORS.bold}${actionName}${COLORS.reset} failed with error:`;
log(message, json, COLORS.red, true);
};
export { logInfo, logSuccess, logError };
utils/next-safe-action/client.ts
import {
createSafeActionClient,
DEFAULT_SERVER_ERROR_MESSAGE,
} from "next-safe-action";
import { z } from "zod";
import { logError, logInfo, logSuccess } from "./util";
export class ActionError extends Error {}
export const action = createSafeActionClient({
handleServerError(e) {
console.error("Action error:", e.message);
if (e instanceof ActionError) {
return e.message;
}
return DEFAULT_SERVER_ERROR_MESSAGE;
},
defineMetadataSchema() {
return z.object({
actionName: z.string(),
});
},
}).use(async ({ next, clientInput, metadata }) => {
logInfo({
json: clientInput,
actionName: metadata.actionName,
});
const result = await next();
if (result.success && !result.data.error) {
logSuccess({
json: result.data,
actionName: metadata.actionName,
});
} else {
logError({
json: result.data.error,
actionName: metadata.actionName,
});
if (result.serverError) {
logError({
json: result.serverError,
actionName: metadata.actionName,
});
}
if (result.validationErrors) {
logError({
json: result.validationErrors,
actionName: metadata.actionName,
});
}
throw result.serverError;
}
return result;
});
우선 logger는 개발 단계에서 디버깅을 용이하게 하고, 로직을 추적하기 위해 만든 것이므로 프로덕션 단계에서는 나타나지 않도록 조건을 걸었다.
const IS_DEVELOPMENT = process.env.NODE_ENV === "development";
client.ts
의 코드가 길어보이고 반복이 많아보이지만 기존에는 아래처럼 불필요하게 ANSI 이스케이프 코드*까지 포함하고 있어서 더 더려웠다. log util의 재사용성과 가독성을 높이기 위함이자, result를 커스텀할 수 있으므로 유지보수성을 높이기 위한 결정이었다. 여기서 조금 더 개선할 수 있는 여지를 찾았다면 댓글을 부탁한다!
🎨 ANSI 이스케이프 코드:
터미널 상에서 텍스트이 색상, 스타일 및 포맷을 지정하는 데 사용된다. 각 색상 코드를 통해 특정 시퀀스를 사용하여 텍스트의 색상이나 강조를 설정할 수 있다.
일반적으로\x1b
로 시작하고 다음의[
뒤 숫자 및 세미콜론으로 구분된 명령이 온다. 자세한 컬러 코드는 ANSI Escape Sequences를 참고바란다.
여기까지 작업했다면 action 함수에 별도의 console.log를 찍지 않아도 터미널에 다음과 같이 로그가 찍힌다.
very mindful~, very demure~
이젠 더 이상 API 근처 console.log를 남발하는 일은 없을 것이다.
next-safe-action의 이점은 이외에도 useAction을 통해 mutation 이후 연쇄 로직을 직관적으로 구성할 수 있다는 점이 있는데, 주제에서 벗어난다고 생각하여 이점은 생략했다.
그리고 추후 NextJS 15가 완전한 react 19를 지원한다면 봐 볼만한 useStateAction
이 있는데, 이 부분은 추후 react 19 출시 이후 다시 보면 좋을 것 같다.
혹시나 이것만 보고 next-safe-action을 써야 겠다고 맘 먹은 분들은 아래 단점을 보고 한 번 더 생각을 해보길 바란다.
유사한 기능을 하는 라이브러리 중에 zsa가 있다.
자세한 코드는 여기에 있다!