[Next.js] 매장보고서 페이지 개발기: 같은 UI, 다른 렌더링 전략

.DS_Store·2025년 9월 11일
2

개발기

목록 보기
1/5
post-thumbnail

현재 개발 중인 대안신용평가 사이트에서 매장보고서 페이지를 만들면서 겪었던 고민들과 해결 과정을 정리해본다. 겉보기엔 거의 같은 UI지만 속은 완전히 다른 두 페이지를 어떻게 구성했는지 기록해보자!

요구사항 분석

매장보고서 페이지는 두 가지 형태로 존재한다:

  1. 관리자 페이지 (/admin/:shop_no): 달별로 변경하면서 조회하는 기능
  2. 보고서 페이지 (/report/:report_no): 단순 보고서로만 존재

(/admin/:shop_no과 같은 것은 실제 주소가 아닌 단순하게 각색한 형태이다)

도식화하면 큰 레이아웃 하나와 5개의 데이터 표시 영역을 가진다.

데이터 구조의 차이점

관리자가 조회할 때는:

  • 변하지 않는 매장 정보
  • 조회하는 달을 기준으로 매달 변하는 차트 및 분석 데이터

보고서 페이지에서는:

  • 모든 데이터가 고정된 시점의 스냅샷

첫 번째 고민: API 형태의 차이

여기서 크게 맞닥뜨린 과제가 두 개였다:

  1. API 구조가 다름
    • 관리자: 매장 정보, 일반 분석 데이터, 차트 데이터 3개의 API로 분리
    • 보고서: 전체적으로 하나의 API로 구성
  2. CSR과 SSR의 적절한 활용을 어떻게 할 것인가
    • 보고서 페이지는 한 번에 불러와지고, 빈번한 리렌더링도 필요하지 않고, 보안 관련 문제를 위해서라도 SSR(서버에서 호출하므로 비밀키 안전 보관 가능)이 적합해 보임
    • 그런데 관리자 페이지에서 보이는 부분은 달별 조회로 리렌더링이 빈번하게 발생할 수 있어 해당 부분들에는 CSR이 적합하다고 판단함

초기 접근: 컴포넌트 세분화

일단 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 컴포넌트도 하나 추가해둔 상태로 블록을 만들어둬서:

  • 관리자에선 → 최상단부터 아예 Client Component로 렌더링하고
  • 리포트에선 → 데이터 페칭하는 최상단만 SSR, 나머지 UI는 CSR 구조로 가면 되는 거 아닌가?

관리자쪽에서도 SSR을 반드시 사용해야한다고 생각해서 복잡하게 구성해버리게 되는 게 아닌가?

최종 결정

결론부터 말하면, 관리자(토큰 필요) 쪽은 일단 전부 CSR로 통일하고, 리포트 페이지만 SSR(+필요 섹션 CSR)으로 가는 게 현실적인 선택이었다.

관리자에 SSR을 억지로 섞으면 복잡도가 빨리 올라가는데, 얻는 게 크게 없을 가능성이 컸다.

왜 이렇게 결정했나

기준관리자 /admin/:shop_no리포트 /report/:shop_no
SEO/공유의미 없음(내부 인증) → SSR 이점 약함보고서 성격상 초기 완성도 중요
데이터 특성월별로 자주 바뀜(분석/차트) → CSR 적합초기 스냅샷 1회 수집 → SSR 적합
성능 관점SSR은 복잡도↑, 차트 직렬화 비용↑상단 핵심 정보는 SSR로 즉시 노출
복잡도CSR 일원화가 단순(패칭/상태/에러 관리 한쪽에만)SSR 1회로 모두 수집, 하위는 props로 렌더
  • 관리자 UI는 인터랙션/필터가 많고 큰 시계열이 오가므로 CSR이 자연스럽다.
  • 리포트는 초기 화면을 꽉 채워 보여주는 체감 속도가 핵심이므로 상단 핵심 카드/요약은 SSR이 이득이고, 차트는 CSR로 분리한다.

구현 전략: View/Loader 분리 패턴

View ↔ Loader 분리

  • View: 순수 UI, 상태 없음
  • Loader: fetch만 담당(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]);

초기 병렬 + initial 주입

최신 달 데이터를 먼저 받아 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 전환 가능하게 설계할 수 있다는 점도 배웠다.

What I learned

  • 게이팅 패턴: 조건이 갖춰지기 전에는 요청 자체를 하지 않는 패턴으로 중복 요청과 레이스 컨디션을 방지할 수 있다.
  • View/Loader 분리: 데이터 패칭 로직과 UI 로직을 분리하면 SSR/CSR 전환이 용이하고 테스트하기도 좋다.
  • 렌더링 전략은 페이지 특성을 고려해야: SEO뿐만 아니라 인터랙션 패턴, 데이터 갱신 빈도, 초기 로딩 체감 속도 등을 종합적으로 고려해야 한다.
  • 복잡함보다는 단순함: 억지로 최적화를 시도하다가 복잡도만 올라가는 경우가 있다. 때로는 단순한 해결책이 더 나을 수 있다.

0개의 댓글