1 / 3

Step 7. 프론트엔드 기초와 데이터 페칭

예상 시간: 12분

Step 7. 프론트엔드 기초와 데이터 페칭

이 문서에서 다루는 내용: Step 06에서 완성한 /api/graph API Route로부터 그래프 데이터를 가져와 화면에 표시하는 프론트엔드 페이지를 구축합니다. Next.js App Router의 Client Component 패턴, 10개 이상의 React 상태 설계, dynamic import를 활용한 Canvas 라이브러리 로딩, 그리고 데이터 페칭과 실시간 필터링의 기초를 다룹니다.


1. 요구사항

PM 김도연:

"이준혁 님, 드디어 프론트엔드 작업에 들어갑니다. Step 03에서 05까지 buildGraph를 완성하고, Step 06에서 /api/graph API Route까지 만들어뒀으니, 이제 사용자가 실제로 볼 수 있는 화면을 만들어야 해요."

"마케팅팀에서 가장 원하는 건 이런 거예요. 페이지에 접속하면 키워드 그래프가 바로 보이고, 사이드바에 노드 수, 링크 수 같은 요약 통계가 나와야 합니다. 그리고 최소 검색량 슬라이더가 있어서, 사용자가 이걸 조절하면 실시간으로 그래프가 갱신되어야 해요. 검색량이 낮은 키워드를 걸러내서, 중요한 키워드만 집중해서 보고 싶다는 요구사항이에요."

"이번 Step에서는 전체 페이지의 뼈대와 데이터 연결을 먼저 잡아주세요. 그래프 인터랙션이나 노드 클릭 같은 건 다음 Step에서 붙이면 됩니다. 이 페이지가 프로젝트의 '얼굴'이 되는 거니까, 스타일도 신경 써주시면 좋겠습니다."


2. 시니어의 접근 방식

시니어 이준혁 (8년차):

"좋아, 드디어 프론트엔드야. 지금까지 백엔드에서 데이터 파이프라인을 만들어왔으니, 이번에는 그 결과물을 사용자한테 보여주는 단계지."

"근데 코드 치기 전에 먼저 생각해야 할 게 있어. Next.js App Router에서는 컴포넌트가 기본적으로 Server Component야. 서버에서 렌더링되고, 브라우저에 HTML로 내려가는 거지. 그런데 우리가 쓰려는 react-force-graph-2d는 내부적으로 Canvas API를 쓰거든. Canvas는 브라우저에만 존재하는 API야. 서버에는 windowdocumentcanvas도 없어. 그러면 어떻게 해야 할까?"

"정답은 두 가지야. 첫째, 파일 맨 위에 'use client'를 선언해서 이 컴포넌트가 Client Component라는 걸 Next.js한테 알려줘야 해. 둘째, react-force-graph-2ddynamic(() => import(...), { ssr: false })로 불러와서 서버 사이드 렌더링을 명시적으로 꺼야 해."

"그 다음 질문. 이 컴포넌트에 useState가 10개 넘게 들어가거든. '상태가 너무 많은 거 아니야?' 하고 생각할 수 있어. 실제로 상태 관리를 대충 하면 스파게티가 되는데, 여기서는 각 상태가 명확한 역할을 갖고 있어. 한번 분류해보면 이해가 될 거야."

"또 하나 중요한 패턴이 있어. useSearchParams라는 Hook인데, 이게 Next.js App Router에서 반드시 Suspense 경계 안에서만 사용 가능해. 왜 그런지 생각해봐. URL 쿼리 파라미터는 클라이언트 사이드에서만 알 수 있는 정보잖아. 서버에서 렌더링할 때는 아직 URL이 확정되지 않았으니까, Suspense로 '이 부분은 클라이언트에서 결정된다'고 경계를 쳐줘야 하는 거야."

"자, 코드로 들어가보자. 크게 다섯 파트로 나눠서 볼 거야: 글로벌 스타일, 상수와 유틸리티, dynamic import, 컴포넌트 구조와 상태 선언, 그리고 데이터 페칭."


3. 구현

3.1 파일 선언과 import -- Client Component의 시작

// src/app/page.tsx (lines 1-6)
 
'use client';
 
import dynamic from 'next/dynamic';
import { Suspense, useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import type { KeywordNode, GraphData } from '@/types/graph';

파일의 첫 줄이 'use client'입니다. 이 한 줄이 이 파일과 그 하위 트리 전체를 Client Component로 선언합니다.

이준혁: "Next.js App Router에서는 모든 컴포넌트가 기본으로 Server Component야. 서버에서 렌더링되고, JavaScript 번들에 포함되지 않아서 초기 로딩이 빨라. 근데 우리는 useState, useEffect, useRef 같은 React Hook을 쓸 거잖아. Hook은 브라우저에서 동작하는 기능이야. 그래서 'use client'를 붙여서 '이 컴포넌트는 브라우저에서 실행해야 합니다'라고 선언하는 거지."

Server Component (기본)Client Component ('use client')
서버에서 렌더링브라우저에서 렌더링
useState, useEffect 사용 불가Hook 전부 사용 가능
JS 번들에 포함되지 않음JS 번들에 포함됨
async/await로 직접 데이터 페칭 가능fetch + useEffect 패턴 사용
window, document 접근 불가브라우저 API 전부 접근 가능

import 목록을 보면 React에서 가져오는 Hook이 6개나 됩니다: Suspense, useEffect, useState, useCallback, useRef, useMemo. 이 각각이 어디서 쓰이는지는 이후 섹션에서 하나씩 살펴봅니다.

import type { KeywordNode, GraphData } from '@/types/graph'는 Step 01에서 설계한 도메인 타입을 가져옵니다. import type을 사용하면 타입 정보만 가져오고 런타임 코드에는 포함되지 않아서 번들 크기에 영향을 주지 않습니다.

3.2 GlobalStyles -- CSS 변수 시스템

// src/app/page.tsx (lines 8-51)
 
const GlobalStyles = () => (
  <style jsx global>{`
    :root {
      --background: #f8fafc;
      --foreground: #0f172a;
      --surface: #ffffff;
      --muted: #475569;
      --border: #e2e8f0;
      --accent: #f97316;
      --accent-strong: #ea580c;
    }
 
    body {
      background: var(--background);
      color: var(--foreground);
      min-height: 100vh;
    }
 
    .app-shell {
      background:
        radial-gradient(1200px 600px at 10% -10%, #dbeafe 0%, transparent 55%),
        radial-gradient(900px 500px at 110% 10%, #fee2e2 0%, transparent 50%),
        linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
    }
 
    .surface-card {
      background-color: rgba(255, 255, 255, 0.86);
      border: 1px solid var(--border);
      box-shadow: 0 18px 45px rgba(15, 23, 42, 0.08);
      backdrop-filter: blur(10px);
    }
 
    .graph-canvas {
      background: radial-gradient(circle at top, rgba(15, 23, 42, 0.85), #0f172a 65%);
    }
 
    @media (prefers-reduced-motion: reduce) {
      .animate-spin {
        animation: none;
      }
    }
  `}</style>
);

이준혁: "스타일을 왜 별도 컴포넌트로 빼놨냐고? 두 가지 이유가 있어."

첫째, CSS 변수(Custom Properties) 시스템을 한 곳에서 관리하기 위해서야. :root에 선언된 --background, --foreground, --border 같은 변수들은 프로젝트 전체에서 참조돼. 나중에 다크 모드를 추가하려면 이 변수 값만 바꾸면 되거든. 색상을 하드코딩하면 나중에 수정할 때 파일 수십 개를 뒤져야 해.

둘째, <style jsx global>은 Next.js의 styled-jsx 문법이야. global 키워드가 붙어 있으니 이 스타일이 전체 페이지에 적용돼. 일반 jsx는 해당 컴포넌트 범위로 스코프가 한정되는데, global은 그 제약을 풀어주는 거지.

각 클래스의 역할을 정리하면:

클래스역할
.app-shell페이지 전체 배경. 세 겹의 그라디언트를 겹쳐서 은은한 색감 연출
.surface-card카드형 UI 요소. 반투명 배경 + 블러 효과로 글래스모피즘 구현
.graph-canvas그래프가 렌더링되는 영역. 어두운 배경으로 노드 색상이 돋보이게 함

@media (prefers-reduced-motion: reduce) 부분은 접근성(Accessibility) 대응입니다. 사용자가 운영체제에서 "움직임 줄이기"를 설정해뒀으면 로딩 스피너의 회전 애니메이션을 정지합니다.

3.3 상수와 유틸리티 함수

// src/app/page.tsx (lines 53-75)
 
const GROUP_COLORS = [
  '#3B82F6', '#22C55E', '#F97316', '#A855F7', '#14B8A6',
  '#EAB308', '#0EA5E9', '#EF4444', '#8B5CF6', '#64748B',
];
 
function getDataStats(data: GraphData) {
  const seedNodes = data.nodes.filter(n => n.isSeed).length;
  const highComp = data.nodes.filter(n => n.compIdx === '높음').length;
  const groups = new Set(data.nodes.map(n => n.group));
 
  return {
    totalNodes: data.nodes.length,
    totalLinks: data.links.length,
    seedNodes,
    highCompetition: highComp,
    groupCount: groups.size,
    avgLinksPerNode: data.nodes.length > 0
      ? (data.links.length / data.nodes.length).toFixed(2)
      : '0',
  };
}

이준혁: "GROUP_COLORS는 시드 키워드 그룹별로 다른 색상을 부여하기 위한 팔레트야. 10개면 충분하고, 인덱스가 10을 넘어가면 group % GROUP_COLORS.length로 순환시킬 거야."

getDataStatsGraphData를 받아서 대시보드에 표시할 요약 통계를 계산합니다. 이 함수를 컴포넌트 바깥에 선언한 이유가 있습니다.

이준혁: "컴포넌트 안에 선언하면 렌더링할 때마다 함수가 새로 생성되거든. 이 함수는 외부 상태(this나 Hook)에 의존하지 않으니까 컴포넌트 밖에 두는 게 맞아. 순수 함수는 가능한 한 컴포넌트 밖으로 빼는 습관을 들여야 해."

avgLinksPerNode 계산에서 data.nodes.length > 0 체크를 하는 건, 빈 데이터가 들어왔을 때 0 / 0 = NaN이 되는 걸 방지하기 위한 방어적 프로그래밍입니다.

3.4 Dynamic Import -- Canvas 라이브러리의 SSR 회피

// src/app/page.tsx (lines 77-85)
 
// react-force-graph는 Canvas/WebGL 사용 -> SSR 불가
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
  ssr: false,
  loading: () => (
    <div className="flex items-center justify-center h-full">
      <p className="text-gray-500">그래프 로딩 중...</p>
    </div>
  ),
});

이 부분이 이 파일에서 가장 중요한 패턴 중 하나입니다.

이준혁: "왜 일반 import가 아니라 dynamic()을 쓰는지 이해해야 해. react-force-graph-2d는 내부적으로 HTML5 Canvas API를 사용해. Canvas는 브라우저에만 존재하는 렌더링 엔진이야. Node.js(서버)에는 Canvas가 없어."

일반 import의 문제:
import ForceGraph2D from 'react-force-graph-2d';
  -> Next.js가 서버에서 이 모듈을 로드하려고 시도
  -> 모듈 내부에서 window.devicePixelRatio 같은 브라우저 API 접근
  -> ReferenceError: window is not defined
  -> 빌드 실패
dynamic import로 해결:
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false });
  -> ssr: false 옵션으로 서버에서는 이 모듈을 아예 로드하지 않음
  -> 브라우저에서만 모듈 다운로드 + 실행
  -> loading 옵션으로 모듈 로딩 중 대체 UI 표시

dynamic의 세 가지 핵심 옵션:

옵션역할
() => import(...)코드 분할(Code Splitting). 이 모듈을 별도 청크로 분리해서 필요할 때만 로드
ssr: false서버 사이드 렌더링 비활성화. 브라우저에서만 모듈 로드
loading모듈이 로드되기 전에 보여줄 폴백(fallback) UI

3.5 컴포넌트 구조 -- Suspense 경계 패턴

// src/app/page.tsx (lines 87-94)
 
export default function Home() {
  return (
    <Suspense fallback={<div className="flex items-center justify-center h-screen">Loading...</div>}>
      <HomeInner />
    </Suspense>
  );
}

Home 컴포넌트는 놀라울 정도로 단순합니다. Suspense로 감싼 HomeInner를 렌더링하는 것이 전부입니다.

이준혁: "왜 이렇게 두 겹으로 나눴을까? useSearchParams 때문이야."

Next.js App Router에서 useSearchParams()는 URL의 쿼리 파라미터(예: ?minVolume=2000)를 읽는 Hook입니다. 그런데 이 Hook에는 제약이 하나 있습니다: 반드시 Suspense 경계 안에서만 사용할 수 있다.

왜 Suspense가 필요한가?
 
1. 서버에서 페이지를 렌더링할 때, URL 쿼리 파라미터는 아직 알 수 없음
2. useSearchParams()는 클라이언트 사이드에서만 값을 확정할 수 있음
3. 서버 렌더링 시점에는 이 Hook이 "아직 준비 안 됨" 상태
4. Suspense가 이 "준비 안 됨" 상태를 fallback UI로 대체해줌
5. 클라이언트에서 하이드레이션이 완료되면 실제 컴포넌트가 렌더링됨

만약 Suspense 없이 useSearchParams()를 사용하면 Next.js가 빌드 타임에 경고를 발생시킵니다. 이 패턴은 App Router에서 URL 파라미터를 클라이언트 컴포넌트에서 다룰 때의 정석적인 구조입니다.

3.6 상태(State) 설계 -- 10개의 useState, 왜 이렇게 많은가

// src/app/page.tsx (lines 96-113)
 
function HomeInner() {
  const searchParams = useSearchParams();
  const [graphData, setGraphData] = useState<GraphData | null>(null);
  const [stats, setStats] = useState<ReturnType<typeof getDataStats> | null>(null);
  const [loading, setLoading] = useState(true);
  const [selectedNode, setSelectedNode] = useState<KeywordNode | null>(null);
  const [highlightNodes, setHighlightNodes] = useState<Set<string>>(new Set());
  const [connectedKeywords, setConnectedKeywords] = useState<KeywordNode[]>([]);
  const [depth, setDepth] = useState(1);
  const [minVolume, setMinVolume] = useState(() => {
    const v = Number(searchParams.get('minVolume'));
    return v > 0 ? Math.min(v, 50000) : 1000;
  });
  const [topN, setTopN] = useState(10);
  const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
  const containerRef = useRef<HTMLDivElement>(null);
  const fgRef = useRef<any>(null);

이준혁: "useState가 10개라고 겁먹을 필요 없어. 역할별로 분류하면 깔끔하게 정리돼."

[그룹 1] 서버 데이터 -- API에서 가져온 원본 데이터

상태타입역할
graphDataGraphData | nullAPI에서 받아온 그래프 원본 데이터
statsReturnType<typeof getDataStats> | null그래프 요약 통계 (노드 수, 링크 수 등)
loadingboolean데이터 로딩 중 여부

[그룹 2] 사용자 인터랙션 -- UI 조작에 의해 변하는 상태

상태타입역할
selectedNodeKeywordNode | null현재 클릭된 노드
highlightNodesSet<string>하이라이트 대상 노드 ID 집합
connectedKeywordsKeywordNode[]선택된 노드와 연결된 키워드 목록

[그룹 3] 필터 파라미터 -- 그래프 표시 조건

상태타입역할
minVolumenumber최소 검색량 필터 (슬라이더로 조절)
topNnumber노드별 상위 N개 엣지만 표시
depthnumberBFS 탐색 깊이

[그룹 4] 레이아웃 -- 화면 크기 관련

상태타입역할
dimensions{ width, height }그래프 캔버스 크기

이준혁: "각 그룹이 서로 독립적인 관심사를 담당하고 있지? 이걸 하나의 거대한 객체로 합치면 오히려 나빠. 예를 들어 selectedNode가 바뀔 때 minVolume까지 같이 들어있는 객체를 새로 만들면, React가 불필요한 리렌더링을 일으킬 수 있거든. 관심사가 다른 상태는 분리하는 게 원칙이야."

3.7 minVolume의 Lazy Initializer -- 함수형 초기값 패턴

// src/app/page.tsx (lines 105-108)
 
const [minVolume, setMinVolume] = useState(() => {
  const v = Number(searchParams.get('minVolume'));
  return v > 0 ? Math.min(v, 50000) : 1000;
});

이 패턴을 주의 깊게 봐야 합니다. useState에 값이 아니라 함수를 전달하고 있습니다.

이준혁: "이걸 Lazy Initializer라고 해. 두 가지 방식을 비교해볼게."

// 방식 A: 값을 직접 전달
const [minVolume, setMinVolume] = useState(
  Number(searchParams.get('minVolume')) > 0
    ? Math.min(Number(searchParams.get('minVolume')), 50000)
    : 1000
);
// -> 이 계산이 컴포넌트가 리렌더링될 때마다 실행됨
// -> 결과값은 첫 렌더링 때만 사용되고, 이후엔 무시됨
// -> 매번 searchParams.get()을 호출하는 건 낭비
 
// 방식 B: 함수를 전달 (Lazy Initializer)
const [minVolume, setMinVolume] = useState(() => {
  const v = Number(searchParams.get('minVolume'));
  return v > 0 ? Math.min(v, 50000) : 1000;
});
// -> 이 함수는 컴포넌트 최초 렌더링 때 딱 한 번만 실행됨
// -> 이후 리렌더링에서는 함수 자체가 호출되지 않음
// -> 초기값 계산 비용이 클 때 성능 이점

로직의 의미도 짚어봅시다:

  1. searchParams.get('minVolume') -- URL에서 ?minVolume=3000 같은 쿼리 파라미터를 읽음
  2. Number(...)로 변환 -- 문자열 "3000"을 숫자 3000으로
  3. v > 0 체크 -- 유효한 양수인지 검증 (파라미터가 없거나 NaN이면 false)
  4. Math.min(v, 50000) -- 상한선 50000 적용 (사용자가 ?minVolume=999999 같은 극단값을 넣는 것 방지)
  5. 기본값 1000 -- URL에 파라미터가 없으면 사용

이 패턴을 통해 사용자가 http://localhost:3000?minVolume=5000으로 접속하면 슬라이더가 5000에서 시작합니다. URL로 상태를 공유할 수 있는 간단한 딥링크(Deep Link) 기능이 됩니다.

3.8 useRef 두 개 -- DOM 참조와 라이브러리 인스턴스

// src/app/page.tsx (lines 111-113)
 
const containerRef = useRef<HTMLDivElement>(null);
const fgRef = useRef<any>(null);

이준혁: "useRef는 두 가지 용도로 쓰여. 하나는 DOM 요소에 직접 접근하기 위해서, 다른 하나는 렌더링 사이에 값을 유지하기 위해서."

ref용도설명
containerRefDOM 참조그래프 캔버스를 감싸는 <div>의 실제 크기를 측정하기 위해
fgRef라이브러리 인스턴스 참조ForceGraph2D의 인스턴스에 직접 접근해서 d3-force 시뮬레이션을 제어하기 위해

fgRef의 타입이 any인 이유는 react-force-graph-2d가 TypeScript 타입을 완전하게 제공하지 않기 때문입니다. 실무에서는 이렇게 서드파티 라이브러리의 타입이 불완전한 경우가 종종 있고, any로 우회하는 것이 현실적인 선택입니다.

3.9 컨테이너 크기 추적 -- ResizeObserver와 useEffect

// src/app/page.tsx (lines 115-126)
 
// 컨테이너 크기 추적 (ResizeObserver)
useEffect(() => {
  const el = containerRef.current;
  if (!el) return;
 
  const ro = new ResizeObserver((entries) => {
    const { width, height } = entries[0].contentRect;
    setDimensions({ width: Math.round(width), height: Math.round(height) });
  });
  ro.observe(el);
  return () => ro.disconnect();
}, []);

useEffect는 그래프 캔버스의 반응형 크기 대응을 담당합니다.

이준혁: "react-force-graph-2d는 Canvas를 사용하는데, Canvas는 CSS처럼 width: 100%가 안 먹어. 픽셀 단위로 정확한 너비와 높이를 prop으로 전달해야 해. 그래서 부모 컨테이너의 크기가 변할 때마다 그 크기를 측정해서 Canvas에 전달하는 거야."

ResizeObserver는 브라우저가 제공하는 API로, 특정 DOM 요소의 크기 변화를 감지합니다.

동작 흐름:
 
1. 컴포넌트 마운트 -> useEffect 실행
2. containerRef.current로 실제 DOM 요소 가져옴
3. ResizeObserver 생성, 해당 요소 관찰 시작
4. 브라우저 창 크기 변경 -> 컨테이너 크기 변경 -> 콜백 실행
5. 콜백에서 새 크기를 dimensions 상태에 저장
6. dimensions가 변하면 ForceGraph2D의 width/height prop이 업데이트
7. Canvas가 새 크기로 다시 그려짐
 
컴포넌트 언마운트 시:
8. return () => ro.disconnect() 실행 -> 관찰 중단 (메모리 누수 방지)

의존성 배열이 [](빈 배열)인 이유는, ResizeObserver 등록은 컴포넌트가 마운트될 때 한 번만 하면 되기 때문입니다. 이후에는 Observer가 알아서 크기 변화를 감지합니다.

return () => ro.disconnect()클린업 함수입니다. 컴포넌트가 화면에서 사라질 때 Observer를 해제합니다. 이걸 빠뜨리면 컴포넌트가 사라진 뒤에도 Observer가 계속 동작하면서 존재하지 않는 상태를 업데이트하려는 메모리 누수가 발생합니다.

3.10 데이터 페칭 -- minVolume 변경 시 API 재호출

// src/app/page.tsx (lines 128-142)
 
// API에서 그래프 데이터 로딩
useEffect(() => {
  setLoading(true);
  setSelectedNode(null);
  setHighlightNodes(new Set());
  setConnectedKeywords([]);
  fetch(`/api/graph?minVolume=${minVolume}`, { cache: 'no-store' })
    .then(res => res.json())
    .then((data: GraphData) => {
      setGraphData(data);
      setStats(getDataStats(data));
      setLoading(false);
    })
    .catch(() => setLoading(false));
}, [minVolume]);

useEffectStep 06에서 만든 API Route와 프론트엔드를 연결하는 핵심 코드입니다.

이준혁: "이 useEffect의 의존성 배열에 [minVolume]이 들어있어. 이게 무슨 뜻이냐면, minVolume 값이 바뀔 때마다 이 Effect가 다시 실행된다는 거야. 사용자가 슬라이더를 움직이면 -> setMinVolume 호출 -> minVolume 변경 -> 이 Effect 재실행 -> API 다시 호출 -> 새 데이터로 그래프 갱신. 이게 반응형 데이터 페칭이야."

Effect가 실행될 때의 동작을 단계별로 분석합니다:

1단계: 상태 초기화

setLoading(true);
setSelectedNode(null);
setHighlightNodes(new Set());
setConnectedKeywords([]);

새 데이터를 가져오기 전에 이전 상태를 깨끗하게 정리합니다. 왜 이게 필요할까요?

이준혁: "minVolume을 바꾸면 이전에 존재하던 노드가 새 데이터에는 없을 수 있어. 만약 '감자빵' 노드를 선택한 상태에서 minVolume을 올리면, 새 데이터에서 '감자빵'이 필터링돼서 사라질 수 있잖아. 존재하지 않는 노드가 selected 상태로 남아 있으면 에러가 나거든. 그래서 데이터 변경 시 인터랙션 상태를 초기화하는 거야."

2단계: API 호출

fetch(`/api/graph?minVolume=${minVolume}`, { cache: 'no-store' })

Step 06에서 만든 /api/graph 엔드포인트에 쿼리 파라미터를 붙여서 호출합니다. { cache: 'no-store' }는 브라우저 캐시를 사용하지 않겠다는 뜻입니다. 슬라이더 값이 같더라도 항상 서버에서 최신 데이터를 가져옵니다.

3단계: 응답 처리

.then(res => res.json())
.then((data: GraphData) => {
  setGraphData(data);
  setStats(getDataStats(data));
  setLoading(false);
})

JSON 응답을 파싱한 뒤, graphDatastats를 동시에 업데이트하고, 로딩을 종료합니다. getDataStats(data)를 호출해서 요약 통계도 함께 계산해둡니다.

4단계: 에러 처리

.catch(() => setLoading(false));

네트워크 에러가 발생하면 로딩 상태만 해제합니다. 로딩 스피너가 영원히 돌아가는 것을 방지하는 최소한의 에러 처리입니다.

3.11 maxVolume -- useMemo를 활용한 파생값 계산

// src/app/page.tsx (lines 144-148)
 
// 노드 크기 정규화용 최대 검색량
const maxVolume = useMemo(() => {
  if (!graphData) return 1;
  return Math.max(1, ...graphData.nodes.map(n => n.totalVolume));
}, [graphData]);

이준혁: "이건 useMemo야. useState와 뭐가 다르냐고? useState직접 설정하는 상태고, useMemo는 **다른 값에서 자동으로 계산되는 파생값(derived value)**이야."

maxVolume은 그래프 노드의 크기 정규화에 사용됩니다. 노드의 검색량을 시각적 크기로 변환할 때, 모든 노드를 가장 큰 검색량 기준으로 상대적 크기를 계산합니다:

노드 크기 = 1 + (해당 노드 검색량 / 전체 최대 검색량) * 49
 
예시 (maxVolume = 100,000):
- 검색량 100,000인 노드: 1 + (100000/100000) * 49 = 50 (가장 큼)
- 검색량  50,000인 노드: 1 + (50000/100000) * 49 = 25.5
- 검색량   1,000인 노드: 1 + (1000/100000) * 49 = 1.49 (가장 작음)

useMemo의 의존성 배열이 [graphData]이므로, graphData가 변할 때만 재계산됩니다. 슬라이더를 움직여서 새 데이터가 로드되면 maxVolume도 자동으로 업데이트되지만, 사용자가 노드를 클릭하는 등 graphData와 무관한 상태 변경 시에는 재계산을 건너뜁니다.

Math.max(1, ...)에서 최솟값을 1로 보장하는 이유는, 나중에 이 값으로 나눗셈을 할 때 0으로 나누는 오류를 방지하기 위해서입니다.


4. 핵심 포인트 정리

  • 'use client' 지시자는 Server Component와 Client Component의 경계를 정의한다. Next.js App Router에서 useState, useEffect 등 React Hook을 사용하려면 반드시 파일 최상단에 'use client'를 선언해야 한다. 이 선언은 해당 컴포넌트와 그 하위 트리 전체를 클라이언트 번들에 포함시킨다.

  • Canvas/WebGL 라이브러리는 dynamic(() => import(...), { ssr: false })로 불러온다. 브라우저 전용 API에 의존하는 라이브러리를 서버에서 렌더링하면 에러가 발생한다. ssr: false 옵션으로 서버 렌더링을 건너뛰고, loading 옵션으로 로딩 중 대체 UI를 제공한다.

  • useSearchParams()는 반드시 Suspense 경계 안에서 사용한다. URL 쿼리 파라미터는 클라이언트 사이드에서만 확정되므로, Next.js App Router는 Suspense 경계를 요구한다. Home -> Suspense -> HomeInner 패턴이 이 제약을 해결하는 정석적인 구조다.

  • 상태는 관심사별로 분리하되, 역할이 명확하면 개수가 많아도 괜찮다. 10개의 useState가 서버 데이터, 사용자 인터랙션, 필터 파라미터, 레이아웃의 4개 그룹으로 깔끔하게 분류된다. 무리하게 하나의 객체로 합치면 불필요한 리렌더링을 유발할 수 있다.

  • useState의 Lazy Initializer(함수형 초기값)는 비용이 큰 초기값 계산에 사용한다. useState(() => { ... }) 패턴은 함수를 최초 렌더링 때 한 번만 실행한다. URL 파라미터 파싱 같은 연산을 매 렌더링마다 반복하지 않도록 방지하며, 동시에 URL 딥링크 기능을 제공한다.

  • useEffect의 의존성 배열은 "언제 다시 실행할 것인가"를 결정한다. [minVolume]을 의존성으로 두면 슬라이더 조작 시 자동으로 API를 재호출한다. 데이터가 바뀔 때 이전 인터랙션 상태를 초기화하는 것은 존재하지 않는 노드를 참조하는 버그를 사전에 방지하는 방어적 프로그래밍이다.


5. 다음 Step 예고

Step 8: Top-N 필터링과 그래프 레이아웃

이번 Step에서 데이터를 가져오는 것까지 완성했으니, 다음에는 그래프를 보기 좋게 다듬는 단계입니다. 노드별 상위 N개 엣지만 남기는 Top-N 필터링, 연결 수가 높은 노드를 중심에 배치하는 Radial Force 레이아웃, 그리고 useMemo를 활용한 인접 리스트(adjacency map) 전처리를 다룹니다.