엔드포인트 목록을 열어놓고 하나씩 세어본 적이 있습니다. 화면 하나를 만들려고 라우트 파일을 만들고, 요청 타입을 정의하고, 클라이언트에서 부를 함수를 또 만들고, 응답 타입을 다시 선언하는 일이 반복되고 있었습니다. 실제 로직은 열 줄인데 그걸 감싸는 껍데기가 세 배쯤 됐습니다.
Server Actions로 몇 개를 옮겨보기 시작한 건 그 목록을 본 다음이었습니다. 결론부터 말하면 절반쯤 옮겼고 나머지는 그대로 두었습니다. 이 글은 무엇을 옮겼고 무엇을 왜 남겼는지에 대한 기록입니다.
옮기면서 실제로 줄어든 것
가장 크게 체감한 것은 같은 타입을 두 번 쓰지 않아도 된다는 점이었습니다. 서버 함수를 직접 호출하는 형태가 되니 인자와 반환 타입이 그대로 이어집니다. 필드 이름을 바꿨을 때 컴파일 단계에서 잡히는 것과, 배포 후 화면에서 undefined로 발견하는 것의 차이는 큽니다.
• 보일러플레이트 감소: 라우트 파일, 요청 파싱, 응답 직렬화, 클라이언트 호출 래퍼가 사라집니다. 폼 제출 계열 기능은 파일 하나로 정리됩니다.
• 타입 안전: 서버와 클라이언트가 같은 타입 정의를 공유합니다. 별도 스키마 동기화 작업이 필요 없습니다.
• 변경 후 데이터 갱신이 단순해집니다: 저장 뒤 어떤 데이터를 다시 읽을지 서버 쪽에서 표시하면 되므로, 클라이언트에서 캐시 무효화 코드를 따로 관리하는 부담이 줄었습니다.
메리톡톡에서는 설정 저장, 프로필 수정, 메모 작성처럼 폼 하나에서 시작해 결과가 화면에 바로 반영되는 기능부터 옮겼습니다. 이 부류는 옮기고 나서 코드가 눈에 띄게 짧아졌고 후회한 적이 없습니다.
'use server';
export async function updateProfile(formData: FormData) {
const session = await getSession();
if (!session) throw new Error('Unauthorized');
const nickname = String(formData.get('nickname') ?? '').trim();
if (nickname.length < 2 || nickname.length > 20) {
return { ok: false, message: '닉네임은 2~20자여야 합니다.' };
}
// 소유자 확인을 여기서 다시 합니다
await profiles.update(session.userId, { nickname });
revalidatePath('/settings/profile');
return { ok: true };
}
인증과 권한은 반드시 안에서 다시 확인합니다
이 부분을 따로 떼어 강조하는 이유가 있습니다. Server Action은 함수처럼 보이지만 실제로는 네트워크로 노출되는 엔드포인트입니다. 화면에서 버튼을 숨겼다고 해서 호출이 막히는 것이 아닙니다. 즉 REST 엔드포인트에 걸어두던 검사를 그대로 다시 해야 합니다.
• 세션 확인: 로그인 여부를 액션 안에서 직접 확인합니다. 페이지 컴포넌트에서 확인했다는 것은 근거가 되지 않습니다.
• 소유권 확인: 넘어온 식별자가 이 사용자의 것인지 봅니다. 클라이언트가 보낸 ID를 그대로 믿으면 남의 데이터를 수정할 수 있게 됩니다.
• 입력 검증: 폼 데이터는 언제든 조작될 수 있으므로 길이, 형식, 허용값을 서버에서 다시 봅니다.
• 호출 빈도 제한: 공개 노출되는 지점이므로 반복 호출에 대한 방어가 필요합니다.
저는 이 네 가지를 액션 파일 상단에 같은 순서로 적어두는 습관을 들였습니다. 순서가 고정되어 있으면 리뷰할 때 빠진 것이 눈에 띕니다.
그럼에도 REST를 남겨야 하는 자리
옮기다 보면 어느 지점부터 이득이 사라집니다. 제가 남기기로 한 기준은 다음과 같습니다.
상황남기는 이유
모바일 앱이나 외부 클라이언트가 붙음웹 프레임워크에 묶이지 않은 계약이 필요합니다
웹훅 수신외부 시스템이 정해진 형식으로 호출합니다
대용량 파일 업로드스트리밍과 진행률 처리에 전용 경로가 편합니다
세밀한 캐시 제어가 필요한 조회HTTP 캐시 헤더를 직접 다루는 편이 명확합니다
백엔드가 다른 언어로 되어 있음이미 있는 API를 다시 만들 이유가 없습니다
특히 웹훅은 선택의 여지가 없습니다. 결제나 외부 서비스가 호출하는 주소는 그쪽 규격을 따라야 하므로 일반적인 API 라우트로 두는 것이 맞습니다. 서명 검증과 재시도 처리까지 감안하면 오히려 전용 경로에 두는 편이 읽기도 쉽습니다.
파일 업로드도 비슷합니다. 작은 이미지 한 장이면 액션으로 처리해도 무리가 없지만, 크기가 커지고 진행률을 보여줘야 하는 순간부터는 전용 경로나 저장소 직접 업로드 방식이 낫습니다. 저는 "이 요청이 몇 초 이상 걸릴 수 있는가"를 기준으로 갈랐습니다.
닷넷 백엔드를 따로 두는 구조에서는
제 경우가 여기 해당합니다. 도메인 로직과 데이터는 ASP.NET Core 쪽에 있고 화면은 Next.js입니다. 이 구조에서 Server Actions를 어떻게 쓸지는 한동안 고민이었습니다. 지금 정리한 기준은 이렇습니다.
• Server Action은 얇게 씁니다: 액션 안에서 비즈니스 로직을 구현하지 않고, 백엔드 API를 호출하는 어댑터 역할까지만 맡깁니다.
• 토큰이 브라우저로 나가지 않게 합니다: 서버 쪽에서 인증 토큰을 붙여 백엔드를 호출하면 자격 증명이 클라이언트에 노출되지 않습니다. 이 점 하나만으로도 액션을 거칠 이유가 됩니다.
• 화면 전용 가공은 액션 쪽에 둡니다: 여러 API를 합치거나 화면에 맞게 모양을 바꾸는 일은 프런트 쪽 관심사입니다.
• 도메인 규칙은 백엔드에 둡니다: 같은 규칙이 두 곳에 생기면 언젠가 어긋납니다.
정리하면 Server Action은 새 백엔드가 아니라 백엔드 앞에 놓인 얇은 층으로 쓰는 셈입니다. 이렇게 두니 나중에 모바일 앱이 붙어도 도메인 API가 그대로 남아 있어 다시 만들 일이 없습니다.
마치며
Server Actions로 옮겨서 확실히 이득인 것은 폼 중심의 변경 작업입니다. 타입이 이어지고 껍데기가 사라집니다. 반대로 외부 클라이언트, 웹훅, 파일 업로드, 캐시 제어, 별도 언어 백엔드가 얽힌 자리는 REST로 두는 편이 낫습니다. 그리고 어느 쪽이든 인증과 권한 확인은 실행되는 그 함수 안에서 다시 해야 합니다.
오늘 할 수 있는 한 가지는 지금 있는 엔드포인트를 폼 제출용과 그 외로 나눠보는 일입니다. 폼 제출용 목록이 옮길 후보이고, 나머지는 굳이 건드리지 않아도 됩니다.
'개발 노트 > 프론트엔드' 카테고리의 다른 글
| React Compiler 도입 후 useMemo·useCallback을 걷어내는 기준 (0) | 2026.09.06 |
|---|---|
| BFF 구조에서 리액트는 화면에 집중하고 인증은 서버가 맡는 방식 (0) | 2026.07.09 |
| React에서 서버 상태를 다루기 편하게 해주는 TanStack Query (0) | 2026.07.05 |
| React와 Vue를 다시 비교해보며 정리한 기준 (0) | 2026.07.05 |
| 아토믹 디자인이 더 이상 유행하지 않는 이유를 생각해보았습니다 (0) | 2026.07.05 |