
마지막으로 보던 행사로 진입시키기 위해 로컬 스토리지를 쓰는 기능을 제거하는 것.. 이것이 이 이야기의 시작이다.
화면과 개발자 도구를 같이 보던 중 아래 요청이 메뉴 이동마다 반복적으로 붙는 것을 발견했다.
eventsuser/api/v1/admin/events/[eventId]이 요청들은 페이지마다 공통으로 다시 붙거나, 서버가 이미 알고 있는 데이터를 클라이언트가 다시 읽는 흐름과 연결돼 있었는데, 특히 아래 구조가 겹쳐 있었다.
관리자 기본 화면의 진입 기준과 데이터 소유권이 로컬 스토리지로 인해 클라이언트 쪽에 있던 상태인 것이다.
일단 하던 제거 먼저 하자.
당시 바꾸려던 내용은 구체적으로 아래와 같았다.
그래서 위 내용을 걷어냈는데, 행사 ID만 내려보내는 게 아니라 행사 정보를 전역으로 갖고 있으면 페이지 이동 시 요청을 중복 호출하지 않아도 되지 않나?라는 질문을 받았다.
처음에는 "이 상태 전역을 어떻게 관리할까"라는 고민 아래 선택지가 2가지 있었다.
zustand에서 관리한다.TanStack Query에 staleTime을 추가하여 재요청의 횟수를 줄인다.결론적으로 zustand는 이 문제의 핵심과는 잘 맞지 않았다.
사실 zustand를 그냥 전역 상태 관리 라이브러리 정도로 알고 있었는데, 찾아보니 클라이언트 UI 상태 관리에 더 적합하다고 한다.
그러나 이번 문제는 서버 데이터의 최초 소유권과 불필요한 재요청 제거에 더 가까웠다.
행사 목록, 사용자 정보, 행사 상세 같은 데이터를 전역 클라이언트 상태로 더 끌어올릴수록 오히려 서버 기준과 동기화 포인트가 늘어나기도 하고..
그래서 zustand는 UI 상태에 집중시키고, 서버 데이터를 옮겨 담는 용도로는 쓰지 않기로 했다.
기존에 이미 TanStack Query를 쓰고 있었기 때문에, 가장 먼저 staleTime으로 당장 중복된 요청을 줄일 수 있지 않을까 싶었다.
이 방법은
하지만 클라이언트 query를 계속 소유하는 상태란 것은 변함이 없다.
그래서 아래 질문은 그대로 남는다.
그래서 고민의 초점이 staleTime에서 "서버 props와 query 중 무엇이 이 화면의 기본 구조에 더 어울리는가"로 옮겨갔다.
사실 처음부터 서버 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가 아직도 헷갈렸는데 이해에 너무 큰 도움이 되었습니당 감사감사🙇 무한 샤라웃 진행시켜)
직접 한번 어울리는지 두 방식을 먼저 비교해보자.
TanStack Query prefetch 🤜🤛 서버에서 fetch 후 props 전달
둘 다 서버에서 먼저 데이터를 준비한다는 점은 비슷하다.
그래서 "서버가 준비한 값을 어디까지 서버 책임으로 두고, 어디서부터 클라이언트 query가 소유하게 둘 것인가"가 쟁점이 되겠다.
prefetch + client query장점
고려할 점
staleTime, 재검증 시점, 캐시 책임 범위를 계속 같이 설계해야 한다.server fetch + props장점
고려할 점
router.refresh(), redirect, 별도 상태 갱신처럼 다른 방식으로 풀어야 한다.이렇게 정리해보니 prefetch는 결국 클라이언트 query를 다시 중심에 놓고, props는 서버가 기본 읽기 구조를 끝까지 책임진다는 차이가 있었다.
기존에는 마지막으로 본 행사 페이지로 보내기 위해 로컬 스토리지와 클라이언트 상태를 같이 쓰고 있었다.
이 흐름을 제거한 뒤에는:
/admin 진입 기준을 서버에서 정할 수 있고즉, 클라이언트가 먼저 상태를 들고 진입 방향을 정해야 할 이유가 약해진 것이다.
실제 레이아웃도 이전 코드에서는 데이터를 모른 채 렌더링했지만, 이후 코드에서는 서버에서 먼저 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>
);
대시보드를 제외하면, 관리자 기본 화면은 아래 비중이 더 크다.
이런 데이터는 초기 렌더 시점에 서버가 가져와 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로작업을 진행하던 중 한 가지 고민이 남았다.
초기(현재 브랜치 초기) 관리자 페이지 쪽에서 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 중심으로 정리하는 게 맞는지 궁금하다. (매우 긴 한 가지..)
매니저님께 따로 물어봤고, 받은 답변은
그래서 조금 더 고민해본 결과 관리자 공통 컨텍스트인 events, user를 서버 props로 옮겼고, 미션 관리에서는 행사 상태 판단에 필요한 eventStartDate, eventEndDate를 서버에서 내려주도록 바꿨다.
이후 행사 관리 detail도 prefetch + useAdminEventQuery 조합을 제거하고, 서버에서 initialEvent를 내려주는 방식으로 전환했다.
반대로 대시보드 query, 미션 목록 query, 그리고 수정/생성 같은 mutation 흐름은 그대로 유지했고, 수정 후 최신값 반영은 router.refresh()와 기존 이동 흐름으로 처리했다.
TanStack Query 공식 문서에선 Server Components 앱에서는 프레임워크 기본 데이터 패칭부터 시작하고, 실제로 필요할 때 React Query를 도입하라고 안내한다. 같은 문서에서 "right tool for the job"이라는 표현도 같이 나온다.
또 Next.js 공식 문서에서 말하는 Server Components를 사용할 상황은
이번 관리자 기본 화면은 이 조건과 잘 맞는다.
반대로 React Query가 특히 강한 지점은
그래서 대시보드처럼 지속적으로 다시 읽는 화면은 query를 유지하는 쪽이 자연스럽고, 행사 관리나 미션 관리처럼 기본 읽기 컨텍스트를 서버에서 안정적으로 줄 수 있는 화면은 서버 props로 단순화했다.
이제 실제 요청 기준으로 어떻게 변화됐는지 수치를 봐보자.
Vercel preview 기준으로 /admin/events/[eventId]/dashboard, /admin/events/[eventId], /admin/events/[eventId]/missions를 각각 3회씩 비교했다.
핵심만 정리하면 아래와 같다.
| 페이지 | before | after | 시간 변화 |
|---|---|---|---|
| 대시보드 | 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는 세 경로 모두에서 줄었고, 그 효과가 가장 선명하게 보인 화면은 미션 관리였다.
아 이번에 뭔가 작업하면서 분명 처음엔 로컬 스토리지 기능 제거하고, 중복 요청이 많으니 전역으로 관리해보면 어떨까에서 여기까지 왔다;
뭔가 작업이 진행되면서, 생각치 못한 조언을 들으면서 여기까지 오게 되었다.
코드 리뷰의 순기능, 동료의 중요성..
암튼 마지막으로 정리 한번 해보면
events, user, /api/v1/admin/events/[eventId] 같은 불필요한 재요청을 확인했다.zustand 확장이 아니라 서버 데이터 소유권 정리 문제라고 판단했다.staleTime 조절도 검토했지만, 이는 클라이언트 query 유지 전제의 완화책이라고 봤다.events, user, 일부 화면의 event detail 재요청 제거를 확인됐다.[세 줄 요약]
로컬 스토리지 제거를 계기로 관리자 기본 화면의 데이터 소유권을 다시 봤고,
단순 GET 읽기 중심 화면은 React Query 튜닝보다 서버 props 방식이 더 일관되고
실제 네트워크도 더 깔끔하게 줄었다.
정말 오랜만에 글 올린다. 매일 쓰기로 다짐했는데 쉽지 않네.. 글 안쓰는 동안 그래도 소잿거리 찾아다닌다고 아티클이나 모던자바스크립트 뒤지고 그랬는데도 별 소득이 없어서.. 라기 보단 걍 주말 2주 연속 시원하게 놀아서 그렇다. 당장에 오늘 zustand 왜 쓰면 안되는지나 tanstack query 쓰는 이유도 정확히 몰랐는데..
암튼 끊긴거 아쉽긴 하면서도 이거 밀린거 쓰겠다고 낑낑대다 난리난 적이 한두번이 아니어서 그냥 다시 오늘부터 열심히 쓰려고 한다.
벌써 3시 반 침대로 deep dive..