어제의 나보다 성장한 오늘의 나

[Next.js] 데이터 요청 구조를 Server Action으로 통합해보기 본문

Next.js

[Next.js] 데이터 요청 구조를 Server Action으로 통합해보기

today_me 2025. 7. 21. 21:39

 

Next.js를 활용 하면서 많이 하게 되는 고민은,  이걸 대체 어떻게 써야 우리 팀에 적합하게 활용할 수 있을까? 입니다.  
서버 컴포넌트(Server Components), 서버 액션(Server Actions) 등 Next.js의 다양한 기능들을 접해보면 분명히 강력하고 놀라운 부분도 많지만, 때로는 다양한 옵션들과 제약들로 인해 개발이 더 복잡해져서 오히려 개발 생산성이 저하 된다고 느껴질 때도 있습니다. 결국 좋은 기술이 있더라도 팀에 맞게 쓰지 않으면 오히려 독이 될 수 있다는 것이죠.


이번 글에서는 실제 프로젝트에서 경험한 기존 데이터 요청 방식에서의 개발 생산성 이슈와, 이를 Server Action 중심으로 통합함으로써 개선해 본 경험에 대해 공유드리고자 합니다.


다양한 방식으로 이뤄졌던 기존의 데이터 요청 구조


Next.js를 사용하면서 우리는 아래와 같이 상황에 따라 데이터 요청 방식을 다르게 가져가고 있었습니다.


1. 서버 컴포넌트 내 fetch
서비스 단 (services/) 들을 컴포넌트 내에서 직접 호출

 

// app/page.tsx (서버 컴포넌트)
import { getUserByIdFromDB } from '@/services/user';

export default async function Page() {
  const user = await getUserByIdFromDB('123');
  return <div>{user.name}</div>;
}



2. 클라이언트 컴포넌트 내 fetch

api route handler를 만들고 이를 fetch를 통해 호출.

실제로는 tanstack-Query 사용하여 브라우저 캐시를 적극 활용합니다.

 

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

export function UserInfo() {
  const [name, setName] = useState('');

  const fetchUser = async () => {
    const res = await fetch('/api/user?id=123');
    const data = await res.json();
    setName(data.name);
  };

  return <button onClick={fetchUser}>{name || 'Load User'}</button>;
};

 

호출하는 라우트 핸들러

// app/api/user/route.ts
import { NextResponse } from 'next/server';

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const id = searchParams.get('id');
  const user = await getUserByIdFromDB(id!);

  return NextResponse.json(user);
}



3. Mutation

server action을 활용하여 호출

 

// app/components/EditUser.tsx
'use client';

import { updateUserName } from '@/actions/user';

const EditUser = () => {
  const handleSubmit = async () => {
    const res = await updateUserName('123', 'John');
    if (res.success) alert('Updated!');
  };

  return <button onClick={handleSubmit}>Update Name</button>;
};

 

액션 정의

// app/actions/user.ts
'use server';

export async function updateUserName(id: string, name: string) {
  return { success: true, ... };
}



이렇게 데이터 요청 방식이 세 가지로 나누어져 있다 보니 다음과 같은 문제들이 발생하기 시작했습니다.

구조적 불일치가 만든 DX 저하


1. 라우팅 방식의 불일치
- 서버 컴포넌트는 라우트 단 없이 바로 서비스 단 호출
- 클라이언트 컴포넌트는 app/api 라우트를 통해 요청
- Mutation은 server action을 통해 이루어짐 (route 역할을 대신 수행)

2. 응답 형태(response type)의 불일치
- 서버 컴포넌트는 직접 타입이 적용된 결과값 반환
- API Route는 Response.json() 형태로 wrapping
- Server Action은 { success: boolean, message?: string, data?: T } 식으로 custom 구조 사용

3. 호출 로직 분산으로 인한 폴더 구조 혼란
- services/, api/, actions/ 등 호출 위치가 제각각이라 개발자가 로직 위치를 예측하기 어려움

 

구조가 불일치 하다보니 자연스레 헷갈려하는 팀원들이 나타나기 시작했죠 ㅎㅎ..
특히 이번에 새로 합류한 팀원이 프로젝트 구조를 이해하는 과정에서, 각기 다른 데이터 요청 방식으로 어디서 어떤 데이터를 요청하고 응답 받는지 예측 하기 어렵다는 피드백을 했습니다. 그래서 데이터 요청 방식을 통합 시켜서 개발 생산성을 높여 보고자 하였습니다.

 


Server Action를 활용한 데이터 처리 구조 통합


1. 모든 데이터 요청은 Server Action을 통해 처리한다
- 클라이언트와 서버 컴포넌트 모두 actions/에 정의된 함수만 사용
- fetch나 API Route는 사용하지 않고, Server Action → Service 함수 형태로 흐름을 통일


2. 비즈니스 로직은 services/에서 관리하고, Server Action에서만 호출한다
- 클라이언트가 직접 service를 건드리는 일은 없도록 분리
- 서비스 로직은 항상 action 내부에서 호출되도록 규칙화하여 역할과 책임을 명확히 분리

액션 정의

// app/actions/user.ts
'use server'

import { getUserByIdFromDB } from '@/services/user';

export async function getUserById(id: string) {
  const user = await getUserByIdFromDB(id);
  return { success: true, data: user };
}

 

서버 컴포넌트 fetch

import { getUserById } from '@/actions/user';

const Page = async () => {
  const res = await getUserById('123');
  if (!res.success) return <div>Error</div>;
  return <div>{res.data.name}</div>;
};

 

 

클라이언트 컴포넌트 fetch

'use client';

import { getUserById } from '@/actions/user';

const ClientComp = () => {
  const handleClick = async () => {
    const res = await getUserById('123');
    console.log(res.data);
  };

  return <button onClick={handleClick}>불러오기</button>;
};

 

 

이러면 뭐가 좋지??


1. 요청 방식 일관성
서버/클라이언트 구분 없이 동일한 방식으로 호출 가능하기 때문에 재사용이 가능 합니다.
전에는 같은 기능이어도 클라이언트에서 쓰면 라우트 핸들러를 만들어야 했는데...하하..

2. 디렉토리 일관성
actions/ 디렉토리 중심으로 통일되므로 유지보수성이 올라갑니다.

3.타입 안정성
라우트 핸들러와 달리 타입이 자동 추론 된다.
다른 프로젝트에서 Trpc를 사용하는 입장에서 이건 아주 행복합니다.


근데 이렇게 해도 괜찮을까??

 

 

1. Server Actions는 원래 mutation용으로 설계됨

next.js의 공식 문서의 server action를 읽어보면 form submission 또는 mutation 용이라고 되어 있습니다. 예제를 모두 보아도 fetch 용도로 사용하지 않습니다. 이것을 보면 제가 시도한 방식이 공식적으로 권장하는 방식은 아닌 것 같습니다.
https://nextjs.org/docs/app/guides/forms#programmatic-form-submission

2. Server Actions perform HTTP POST requests

모두 내부에서 사용하는 것들 대상이기 때문에 문제가 되지 않습니다.


 

쨋든 현재 상황에서는 문제가 되지 않아 보입니다.


결론

솔직히 이게 맞는 방법인지는 잘 모르겠습니다.
다만 우리 팀에게는 DX를 높여준 좋은 방식 입니다.

전에는 무조건 공식 문서대로만 해야 된다고 생각했었는데 꼭 그렇지만은 않은 것 같습니다.
어떤 기술을 사용하더라도 현재 상황에 적합한 방법으로 사용하는 것이 중요하다는 것을 배웠습니다!


참고자료
https://nextjs.org/docs/app/guides/forms#programmatic-form-submission
https://www.robinwieruch.de/next-server-actions-fetch-data/

 

Data Fetching with Server Actions in Next.js

Can I fetch data with Server Actions in Next.js? There are different ways to fetch data. Normally Server Actions are used to mutate data, but ...

www.robinwieruch.de




Comments