굴러온 Server Component로 박힌 prefetch 빼기

이현성·2026년 6월 17일
post-thumbnail

마지막으로 보던 행사로 진입시키기 위해 로컬 스토리지를 쓰는 기능을 제거하는 것.. 이것이 이 이야기의 시작이다.

화면과 개발자 도구를 같이 보던 중 아래 요청이 메뉴 이동마다 반복적으로 붙는 것을 발견했다.

  • events
  • user
  • /api/v1/admin/events/[eventId]

이 요청들은 페이지마다 공통으로 다시 붙거나, 서버가 이미 알고 있는 데이터를 클라이언트가 다시 읽는 흐름과 연결돼 있었는데, 특히 아래 구조가 겹쳐 있었다.

  • 마지막으로 본 행사로 이동시키기 위한 로컬 스토리지 사용
  • 그 값을 감싸는 클라이언트 상태
  • 관리자 기본 화면 공통 데이터의 클라이언트 query 구독
  • 서버에서 이미 준비한 detail을 클라이언트가 다시 읽음

관리자 기본 화면의 진입 기준과 데이터 소유권이 로컬 스토리지로 인해 클라이언트 쪽에 있던 상태인 것이다.


사전 작업

일단 하던 제거 먼저 하자.

당시 바꾸려던 내용은 구체적으로 아래와 같았다.

  • 로컬 스토리지/Zustand 스토어에 저장해두고 이전 행사로 진입하던 로직 제거
  • 현재 정의된 우선순위 규칙을 기본 진입 기준으로 사용

그래서 위 내용을 걷어냈는데, 행사 ID만 내려보내는 게 아니라 행사 정보를 전역으로 갖고 있으면 페이지 이동 시 요청을 중복 호출하지 않아도 되지 않나?라는 질문을 받았다.


처음 고민했던 선택지

처음에는 "이 상태 전역을 어떻게 관리할까"라는 고민 아래 선택지가 2가지 있었다.

  1. zustand에서 관리한다.
  2. 기존 TanStack QuerystaleTime을 추가하여 재요청의 횟수를 줄인다.

zustand는.. (절레절레🙂‍↔️)

결론적으로 zustand는 이 문제의 핵심과는 잘 맞지 않았다.

사실 zustand를 그냥 전역 상태 관리 라이브러리 정도로 알고 있었는데, 찾아보니 클라이언트 UI 상태 관리에 더 적합하다고 한다.
그러나 이번 문제는 서버 데이터의 최초 소유권불필요한 재요청 제거에 더 가까웠다.
행사 목록, 사용자 정보, 행사 상세 같은 데이터를 전역 클라이언트 상태로 더 끌어올릴수록 오히려 서버 기준과 동기화 포인트가 늘어나기도 하고..

그래서 zustand는 UI 상태에 집중시키고, 서버 데이터를 옮겨 담는 용도로는 쓰지 않기로 했다.

그 다음 떠오른 staleTime

기존에 이미 TanStack Query를 쓰고 있었기 때문에, 가장 먼저 staleTime으로 당장 중복된 요청을 줄일 수 있지 않을까 싶었다.

이 방법은

  • 같은 query를 짧은 시간 안에 다시 읽을 때 재요청을 줄일 수 있다.
  • 기존 구조를 크게 바꾸지 않고도 요청 수를 줄일 수 있다.

하지만 클라이언트 query를 계속 소유하는 상태란 것은 변함이 없다.

그래서 아래 질문은 그대로 남는다.

  • 이 데이터를 애초에 클라이언트 query가 가져야 하나?
  • 서버가 이미 알고 있는 기본 데이터를 클라이언트가 다시 소유해야 하나?
  • 의도치 않게 로컬 스토리지 의존을 제거한 뒤에도 같은 구조를 유지할 이유가 있나?

그래서 고민의 초점이 staleTime에서 "서버 props와 query 중 무엇이 이 화면의 기본 구조에 더 어울리는가"로 옮겨갔다.


DevONew님 샤라웃 📢

사실 처음부터 서버 props 방식을 생각할 수 있던 건 아니고, 출발점은 DevONew님이 주신 아래 조언에 가까웠다. (쏘 스윗..🍭)

단순 GET 읽기에 React Query는 적합하지 않습니다. 쓰지 말라는 근거는 TanStack Query 공식 문서에 직접 나와 있어요:
"If you are just starting out with a new Server Components app, we suggest you start out with any tools for data fetching your framework provides you with and avoid bringing in React Query until you actually need it."
"use the right tool for the job!"
React Query가 필요한 경우는 딱 세 가지예요:
invalidateQueries — mutation 후 갱신할 때
캐시 공유 — 여러 컴포넌트가 같은 데이터 필요할 때( 이벤트 데이터 전반의 공유가 아니라 미션 페이지같은 소규모 데이터입니다)
refetch — 실시간 갱신 필요할 때
단순 읽기만 하는 페이지는 셋 다 해당 안 되니까, Next.js 공식 권장대로 서버 컴포넌트에서 fetch해서 props로 넘기는 게 맞는 패턴이 맞다고 생각합니다.
Next.js 공식도 이렇게 나와 있고요:
"Use Server Components when you need: Fetch data from databases or APIs close to the source, Reduce the amount of JavaScript sent to the browser, Improve the First Contentful Paint (FCP)"
출처:
TanStack Query - Advanced Server Rendering
Next.js Server and Client Components

(tanstack query가 아직도 헷갈렸는데 이해에 너무 큰 도움이 되었습니당 감사감사🙇 무한 샤라웃 진행시켜)


prefetch VS props

직접 한번 어울리는지 두 방식을 먼저 비교해보자.

TanStack Query prefetch 🤜🤛 서버에서 fetch 후 props 전달

둘 다 서버에서 먼저 데이터를 준비한다는 점은 비슷하다.
그래서 "서버가 준비한 값을 어디까지 서버 책임으로 두고, 어디서부터 클라이언트 query가 소유하게 둘 것인가"가 쟁점이 되겠다.

비교 지점

prefetch + client query

장점

  • 서버에서 먼저 데이터를 채워둘 수 있다.
  • 기존 TanStack Query 구조를 크게 흔들지 않고 이어갈 수 있다.
  • 이후 mutation, invalidation, refetch 같은 흐름과 자연스럽게 이어지기 쉽다.

고려할 점

  • 클라이언트가 결국 같은 query를 다시 구독하게 된다.
  • staleTime, 재검증 시점, 캐시 책임 범위를 계속 같이 설계해야 한다.
  • 기본 읽기 구조를 서버와 클라이언트가 함께 나눠 가지는 형태가 되기 쉽다.

server fetch + props

장점

  • 서버가 준비한 값을 그대로 클라이언트에 전달한다.
  • 기본 읽기 기준과 데이터 소유권을 서버 쪽에 더 명확하게 둘 수 있다.
  • 기본 화면에서는 query 구독 자체를 없앨 수 있다.
  • 초기 렌더 구조와 데이터 흐름이 단순해지기 쉽다.

고려할 점

  • 이후 갱신은 router.refresh(), redirect, 별도 상태 갱신처럼 다른 방식으로 풀어야 한다.

이렇게 정리해보니 prefetch는 결국 클라이언트 query를 다시 중심에 놓고, props는 서버가 기본 읽기 구조를 끝까지 책임진다는 차이가 있었다.


우리 서비스에선

1. 로컬 스토리지 제거 이후 서버가 진입 기준을 책임질 수 있게 됐다

기존에는 마지막으로 본 행사 페이지로 보내기 위해 로컬 스토리지와 클라이언트 상태를 같이 쓰고 있었다.

이 흐름을 제거한 뒤에는:

  • /admin 진입 기준을 서버에서 정할 수 있고
  • 우선순위 행사 결정도 RPC로 처리할 수 있고
  • 기본 화면에 필요한 데이터도 서버에서 함께 내려줄 수 있게 됐다

즉, 클라이언트가 먼저 상태를 들고 진입 방향을 정해야 할 이유가 약해진 것이다.

실제 레이아웃도 이전 코드에서는 데이터를 모른 채 렌더링했지만, 이후 코드에서는 서버에서 먼저 events, user를 준비하도록 바뀌었다.

이전 코드

return (
  <div className="flex h-screen overflow-hidden bg-gomin-neutral-100">
    <Sidebar />
    <div className="min-h-0 min-w-0 flex-1 overflow-y-auto">
      <Header />
      <main>{children}</main>
      <Footer />
    </div>
  </div>
);

이후 코드

const normalizedEvents = toCamelKeys(events ?? []) as EventModel[];
const adminUser: AdminUserModel = {
  id: user.id,
  name:
    typeof user.user_metadata?.name === "string"
      ? user.user_metadata.name
      : (user.email ?? "관리자"),
};

return (
  <div className="flex h-screen overflow-hidden bg-gomin-neutral-100">
    <Sidebar events={normalizedEvents} user={adminUser} />
    <div className="min-h-0 min-w-0 flex-1 overflow-y-auto">
      <Header events={normalizedEvents} />
      <main>{children}</main>
      <Footer />
    </div>
  </div>
);

2. 관리자 기본 화면은 단순 GET 읽기 비중이 높다

대시보드를 제외하면, 관리자 기본 화면은 아래 비중이 더 크다.

  • 현재 행사 컨텍스트 표시
  • 사용자 정보 표시
  • 행사 정보 읽기
  • 미션 목록 읽기

이런 데이터는 초기 렌더 시점에 서버가 가져와 props로 내리는 구조가 더 단순하다.

예를 들어 user도 이전 코드에서는 사이드바 안에서 query로 다시 읽고 있었다.

이전 코드

const { data: user, isLoading, isError } = useAdminUserQuery();

이후 코드

type Props = {
  user: AdminUserModel;
};

export default function AdminUserInfo({ user }: Props) {
  return <p className="text-sm text-gomin-black">{user.name}</p>;
}

미션 관리는 이미 이 방향으로 일부 정리돼 있다.

이전 코드

const { data: event } = useAdminEventQuery(eventId);
const isAfter =
  !!event && getEventOperationStatus(event.startDate, event.endDate).isAfter;

이후 코드

type Props = {
  eventId: number;
  eventStartDate: string | null;
  eventEndDate: string | null;
};

const isAfter =
  typeof eventStartDate === "string" &&
  typeof eventEndDate === "string" &&
  getEventOperationStatus(eventStartDate, eventEndDate).isAfter;

그리고 서버 페이지에서 필요한 행사 메타만 따로 내려준다.

const { data: event } = await sessionSupabase
  .from("events")
  .select("start_date,end_date")
  .eq("id", eventId)
  .maybeSingle();

<MissionClient
  eventId={eventId}
  eventStartDate={event?.start_date ?? null}
  eventEndDate={event?.end_date ?? null}
/>;

이렇게 이번 관리자 기본 화면에서는 props 쪽이 더 맞다고 봤다.

그 후 현재 구조를 다시 뜯어보면서 다음과 같이 최종 정리했다.

  • user처럼 사이드바에 박힌 명확한 공통 컨텍스트는 props로
  • 대시보드는 계속 데이터를 갱신하니 query 유지
  • 행사 관리와 미션 관리는 서버 props 전환 우선

작업 중 곡고민

작업을 진행하던 중 한 가지 고민이 남았다.

초기(현재 브랜치 초기) 관리자 페이지 쪽에서 React Query prefetch + hydration 구조를 쓰고 있었는데, 지금은 서버에서 props를 내려주는 방식으로 조금씩 교체하고 있다.
예를 들어 user 정보는 sidebar에 있는데, 페이지 렌더링 시 sidebar도 같이 렌더되다 보니 페이지마다 user 요청이 계속 발생해서 이건 서버 props로 바꿨다.
여기까지는 서버 props로 바꾸는 방향이 맞는 것 같고, 대시보드처럼 계속 데이터를 불러오거나 이후 invalidate/refetch가 자연스러운 화면은 TanStack Query를 유지하려고 한다.
근데 행사 관리랑 미션 관리는 GET으로 조회도 하지만 수정도 발생하는 화면이라, 이걸 서버 props로 넘기고 수정 후에는 router.refresh()로 맞출지, 아니면 지금처럼 query를 남겨둘지 고민이다.
일단 내 생각에는 행사 관리/미션 관리는 만들고 난 이후에는 수정이 그렇게 잦지 않을 것 같아서, 기본적으로는 서버 props로 전환하고 수정 후에만 router.refresh()로 동기화하는 쪽도 괜찮지 않을까 싶다.
이런 경우에도 query를 유지하는 게 더 나은지, 아니면 지금처럼 읽기 위주의 관리자 기본 화면은 props 중심으로 정리하는 게 맞는지 궁금하다. (매우 긴 한 가지..)

매니저님께 따로 물어봤고, 받은 답변은

  • 서버 props 방식으로 전체 전환 중이라면, 일부만 prefetch/query로 남기기보다 같이 전환하는 쪽이 일관적이다.
  • mutation이 있다는 이유만으로 기본 읽기 구조까지 query로 유지할 필요는 없다.

그래서 조금 더 고민해본 결과 관리자 공통 컨텍스트인 events, user를 서버 props로 옮겼고, 미션 관리에서는 행사 상태 판단에 필요한 eventStartDate, eventEndDate를 서버에서 내려주도록 바꿨다.
이후 행사 관리 detail도 prefetch + useAdminEventQuery 조합을 제거하고, 서버에서 initialEvent를 내려주는 방식으로 전환했다.
반대로 대시보드 query, 미션 목록 query, 그리고 수정/생성 같은 mutation 흐름은 그대로 유지했고, 수정 후 최신값 반영은 router.refresh()와 기존 이동 흐름으로 처리했다.


React Query는 그럼 어느 세월에 쓰나

TanStack Query 공식 문서에선 Server Components 앱에서는 프레임워크 기본 데이터 패칭부터 시작하고, 실제로 필요할 때 React Query를 도입하라고 안내한다. 같은 문서에서 "right tool for the job"이라는 표현도 같이 나온다.

또 Next.js 공식 문서에서 말하는 Server Components를 사용할 상황은

  • 데이터베이스나 API 가까이에서 데이터 가져오기
  • 브라우저로 보내는 JavaScript 줄이기
  • 초기 표시 성능 개선

이번 관리자 기본 화면은 이 조건과 잘 맞는다.

반대로 React Query가 특히 강한 지점은

  • mutation 후 invalidation
  • 여러 클라이언트 컴포넌트 간 캐시 공유
  • refetch, polling, 실시간성

그래서 대시보드처럼 지속적으로 다시 읽는 화면은 query를 유지하는 쪽이 자연스럽고, 행사 관리나 미션 관리처럼 기본 읽기 컨텍스트를 서버에서 안정적으로 줄 수 있는 화면은 서버 props로 단순화했다.


실제 요청 변화

이제 실제 요청 기준으로 어떻게 변화됐는지 수치를 봐보자.

Vercel preview 기준으로 /admin/events/[eventId]/dashboard, /admin/events/[eventId], /admin/events/[eventId]/missions를 각각 3회씩 비교했다.

핵심만 정리하면 아래와 같다.

페이지beforeafter시간 변화
대시보드events, user 재요청 존재events, user 재요청 제거Finish 기준 평균 약 111ms 개선
행사 관리events, user, /api/v1/admin/events/[eventId] 요청 존재events, user 재요청 제거, /api/v1/admin/events/[eventId] 1회 유지평균 기준 큰 차이 없음
미션 관리events, user, /api/v1/admin/events/[eventId] 재요청 존재세 요청 모두 제거Finish 기준 평균 약 473ms 개선

즉 공통 컨텍스트인 events, user는 세 경로 모두에서 줄었고, 그 효과가 가장 선명하게 보인 화면은 미션 관리였다.


최종 정리

아 이번에 뭔가 작업하면서 분명 처음엔 로컬 스토리지 기능 제거하고, 중복 요청이 많으니 전역으로 관리해보면 어떨까에서 여기까지 왔다;
뭔가 작업이 진행되면서, 생각치 못한 조언을 들으면서 여기까지 오게 되었다.
코드 리뷰의 순기능, 동료의 중요성..

암튼 마지막으로 정리 한번 해보면

  1. Network에서 events, user, /api/v1/admin/events/[eventId] 같은 불필요한 재요청을 확인했다.
  2. 로컬 스토리지 기반 마지막 조회 행사 복원 흐름을 제거했다.
  3. 상태 전역 관리는 zustand 확장이 아니라 서버 데이터 소유권 정리 문제라고 판단했다.
  4. staleTime 조절도 검토했지만, 이는 클라이언트 query 유지 전제의 완화책이라고 봤다.
  5. TanStack Query와 서버 props 방식을 다시 비교한 뒤, 관리자 기본 화면은 서버 props 쪽이 더 맞다고 정리했다.
  6. 대시보드는 query 유지, 행사 관리와 미션 관리는 서버 props 전환 쪽으로 먼저 정리하고 작업했다.
  7. 작업 후 남아 있던 의문은 멘토 답변으로 다시 확인했고, 이후 코드에서는 행사 관리 detail도 실제로 서버 props로 옮겼다.
  8. 실제 측정에서도 events, user, 일부 화면의 event detail 재요청 제거를 확인됐다.

[세 줄 요약]

로컬 스토리지 제거를 계기로 관리자 기본 화면의 데이터 소유권을 다시 봤고,
단순 GET 읽기 중심 화면은 React Query 튜닝보다 서버 props 방식이 더 일관되고
실제 네트워크도 더 깔끔하게 줄었다.


정말 오랜만에 글 올린다. 매일 쓰기로 다짐했는데 쉽지 않네.. 글 안쓰는 동안 그래도 소잿거리 찾아다닌다고 아티클이나 모던자바스크립트 뒤지고 그랬는데도 별 소득이 없어서.. 라기 보단 걍 주말 2주 연속 시원하게 놀아서 그렇다. 당장에 오늘 zustand 왜 쓰면 안되는지나 tanstack query 쓰는 이유도 정확히 몰랐는데..
암튼 끊긴거 아쉽긴 하면서도 이거 밀린거 쓰겠다고 낑낑대다 난리난 적이 한두번이 아니어서 그냥 다시 오늘부터 열심히 쓰려고 한다.
벌써 3시 반 침대로 deep dive..

0개의 댓글