현재 개발 중인 대안신용평가 사이트에서 매장보고서 페이지를 만들면서 겪었던 고민들과 해결 과정을 정리해본다. 겉보기엔 거의 같은 UI지만 속은 완전히 다른 두 페이지를 어떻게 구성했는지 기록해보자!
매장보고서 페이지는 두 가지 형태로 존재한다:
/admin/:shop_no
): 달별로 변경하면서 조회하는 기능/report/:report_no
): 단순 보고서로만 존재(/admin/:shop_no과 같은 것은 실제 주소가 아닌 단순하게 각색한 형태이다)
도식화하면 큰 레이아웃 하나와 5개의 데이터 표시 영역을 가진다.
관리자가 조회할 때는:
보고서 페이지에서는:
여기서 크게 맞닥뜨린 과제가 두 개였다:
일단 API가 쪼개져있는 관리자용을 기준으로 컴포넌트를 구획별로 쪼개서 각각 SSR, CSR로 만들어보려고 했다.
그런데 문제가 발생했다.
변하지 않는 데이터가 변하는 데이터 중간에 끼워져 있어서 컴포넌트를 구획별로 예쁘게 딱 나눠떨어지지 않는 상황이 발생한 것이다.
그래서 두 번째 큰 컴포넌트도 더 쪼개서 최소 단위의 컴포넌트로 구성했다.
csr → ssr로의 흐름은 지원하지 않기 때문에 파란색에 해당하는 UI단은 모두 CSR로 작업하고, 상위에서 데이터 불러오는 부분만 CSR, SSR로 분리하기로 했다.
그런데 여기서 새로운 고민이 추가되었다.
월이 바뀌면 바뀌어야 하는 영역들이 다 바뀌어야 하는데, 형제 관계로 나란히 배치하면 이 달 정보를 어떻게 전달하지?
전역 관리 툴을 굳이 써야 하나?
예를 들어 CSR만 따로 컴포넌트로 묶는다고 했을 때:
// app/shops/[shop_no]/page.tsx (Server)
import ShopDetailServer from '@/components/server/ShopDetailServer';
import AnalyticsIsland from '@/components/client/AnalyticsIsland';
export default async function Page({ params, searchParams }: {
params: { shop_no: string },
searchParams: { month?: string }
}) {
const detail = await getShopDetail(params.shop_no); // SSR 1회
const initialMonth = searchParams.month; // URL과 초기값 정합성 보장
return (
<>
<ShopDetailServer data={detail} /> {/* SSR 형제: 달과 무관 */}
<AnalyticsIsland shopNo={params.shop_no} initialMonth={initialMonth} />
</>
);
}
이런 구조가 나올 텐데 SSR 컴포넌트가 하나 중간에 낀 형태였기 때문에 위 구조는 불가능했다. (CSR에서 SSR 컴포넌트를 렌더링 할 수는 없기 때문에)
굳이 이 컴포넌트 하나 때문에 전역 상태를 만들고 싶지는 않았기 때문에 그럼 주소에서 쿼리로 월 정보를 관리해야만 하겠다고 생각했다.
사실 너무 어렵게 가는 것은 아닐까 싶었다.
모든 UI 부분은 다 CSR로 만들어놓고, 정말 빠르게 보여주고 싶은 매장 정보 부분만 SSR 컴포넌트도 하나 추가해둔 상태로 블록을 만들어둬서:
관리자쪽에서도 SSR을 반드시 사용해야한다고 생각해서 복잡하게 구성해버리게 되는 게 아닌가?
결론부터 말하면, 관리자(토큰 필요) 쪽은 일단 전부 CSR로 통일하고, 리포트 페이지만 SSR(+필요 섹션 CSR)으로 가는 게 현실적인 선택이었다.
관리자에 SSR을 억지로 섞으면 복잡도가 빨리 올라가는데, 얻는 게 크게 없을 가능성이 컸다.
기준 | 관리자 /admin/:shop_no | 리포트 /report/:shop_no |
---|---|---|
SEO/공유 | 의미 없음(내부 인증) → SSR 이점 약함 | 보고서 성격상 초기 완성도 중요 |
데이터 특성 | 월별로 자주 바뀜(분석/차트) → CSR 적합 | 초기 스냅샷 1회 수집 → SSR 적합 |
성능 관점 | SSR은 복잡도↑, 차트 직렬화 비용↑ | 상단 핵심 정보는 SSR로 즉시 노출 |
복잡도 | CSR 일원화가 단순(패칭/상태/에러 관리 한쪽에만) | SSR 1회로 모두 수집, 하위는 props로 렌더 |
AnalysisLoader
, ChartLoader
등)// Loader 내부
useEffect(() => {
if (!enabled) return; // 게이팅: 호출만 스킵(UI는 렌더)
const ac = new AbortController();
fetch(url, { signal: ac.signal })
.then(setData)
.catch(/*…*/);
return () => ac.abort();
}, [enabled, url]);
최신 달 데이터를 먼저 받아 Loader에 initial
로 전달해서 초기 재요청을 방지했다.
라우트 전용(app) vs 도메인 공용(features) 분리:
src/
app/
admin/[shop_no]/page.tsx // Admin: CSR 오케스트레이터
report/[shop_no]/page.tsx // Report: SSR 페이지
features/
shop-detail/
views/ShopDetailView.tsx // View
loaders/client/ShopDetailClient.tsx // CSR Loader
loaders/server/ShopDetailServer.tsx // SSR Loader
analytics/
views/{AnalysisView,ChartView}.tsx // View
loaders/client/{Analysis,Chart}Loader.tsx // CSR Loader
결국은 중간 고민 과정이 무색하게 관리자용 페이지에선 모든 컴포넌트를 CSR로 바꾸면서 해결되었다. 인터랙션이 많이 발생할 수 있는 페이지라 CSR이 적합하고, 관리에도 용이해서였다.
또 중복 디자인 코드를 줄이기 위해 전체적으로 SSR 컴포넌트로 구성된 리포트 페이지도 최종 UI 컴포넌트는 모두 CSR로 구성되었다.
CSR과 SSR을 결정하는 요소가 단순히 SEO만 고려했었는데, SEO가 필요 없는 이 페이지에서도 어떤 식으로 구성할까 고민해본 게 좋은 경험이었다.
컴포넌트는 View/Loader 분리로 나중에 손쉽게 SSR/CSR 전환 가능하게 설계할 수 있다는 점도 배웠다.