3 / 3

Step 9. BFS 탐색과 인터랙티브 UI

예상 시간: 9분

Step 9. BFS 탐색과 인터랙티브 UI

이 문서에서 다루는 내용: BFS(Breadth-First Search) 알고리즘으로 선택된 키워드의 N-depth 이웃을 탐색하고, 하이라이트/색상 분기/사이드바 상세 정보 등 대시보드 형태의 인터랙티브 UI를 완성합니다.


1. 요구사항

PM 김도연:

"이준혁 님, 지난 Step에서 Top-N 필터링이랑 forceRadial까지 적용해서 그래프가 훨씬 깔끔해졌습니다. 그런데 마케팅팀에서 추가 요청이 들어왔어요."

"지금은 그래프에서 노드가 수백 개인데, 특정 키워드를 클릭했을 때 그 키워드와 직접 연결된 키워드만 하이라이트되면 좋겠다고 합니다. 나머지 노드는 희미하게 처리해서 시각적으로 구분이 되도록요."

"그리고 하나 더 -- 1단계 이웃만 보는 게 아니라, 2단계, 3단계까지 탐색 깊이를 사용자가 조절할 수 있어야 한다고 하더라고요. 예를 들어 '감자빵'을 클릭하면 감자빵과 직접 연결된 키워드, 그리고 그 키워드들과 또 연결된 키워드까지 확인하고 싶은 거죠."

"마지막으로 사이드바에 선택된 키워드의 상세 정보 -- 검색량, 경쟁도, 시드 여부 같은 것들이랑, 연관 키워드 목록을 리스트로 보여주세요. 전체적으로 대시보드 느낌의 완성된 화면이 나오면 됩니다."


2. 시니어의 접근 방식

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

"좋아, 드디어 마지막 단계네. 여기서 할 일은 크게 세 가지야."

"첫째, 노드 탐색 알고리즘. 사용자가 노드를 클릭하면 '이 노드와 깊이 N까지 연결된 노드가 뭐지?'를 계산해야 해. 이건 전형적인 그래프 탐색 문제야. 여기서 질문 -- DFS(깊이 우선)와 BFS(너비 우선) 중 뭘 써야 할까?"

"답부터 말하면 BFS야. 왜? 우리가 원하는 건 '깊이 N까지의 이웃'이잖아. 돌맹이를 연못에 던졌다고 생각해봐. 물결이 동심원으로 퍼져나가지? 첫 번째 물결이 1단계 이웃, 두 번째 물결이 2단계 이웃... BFS가 정확히 이 방식으로 동작해. 가까운 노드부터 층(level)별로 방문하니까 깊이 제한이 자연스러워. DFS는 한 방향으로 쭉 파고들어가니까 '깊이 N까지'라는 제한을 걸기가 어색하고 비효율적이야."

"둘째, 하이라이트 시스템. BFS로 찾은 노드 집합을 Set에 담아두고, 그래프의 모든 노드와 엣지 색상을 결정할 때 '이 노드가 Set에 있나?'를 확인해서 색상을 분기하는 거야. 여기서 getNodeColor 함수가 3단계 분기 로직을 갖게 돼."

"셋째, UI 레이아웃. header에 필터 컨트롤, sidebar에 정보 패널, main에 그래프 캔버스를 배치하는 대시보드 구조야."

"그런데 여기서 중요한 질문 하나. findConnectedNodes, getNodeColor, handleNodeClick -- 이 함수들을 왜 그냥 function이나 const로 선언하지 않고 useCallback으로 감싸는 걸까?"

"React는 컴포넌트가 리렌더링될 때마다 내부 함수를 새로 생성해. 함수의 로직이 똑같더라도 메모리 주소가 달라지니까 React 입장에서는 '새 함수'야. 이 함수를 자식 컴포넌트의 prop이나 다른 Hook의 의존성으로 쓰면, 매번 '값이 바뀌었다'고 판단해서 불필요한 재실행이 일어나거든. useCallback은 의존성 배열이 변하지 않는 한 같은 함수 참조를 유지해줘서 이런 연쇄 리렌더링을 막는 거야."

"자, 코드로 들어가자."


3. 구현

3.1 BFS 탐색 -- findConnectedNodes

그래프에서 특정 노드로부터 깊이 N까지 연결된 모든 노드를 찾는 BFS 알고리즘입니다.

// src/app/page.tsx (findConnectedNodes)
 
// BFS로 depth N까지 연결된 노드 찾기
const findConnectedNodes = useCallback((nodeId: string, maxDepth: number): Set<string> => {
  if (!filteredGraphData) return new Set();
 
  const visited = new Set<string>([nodeId]);
  const queue: { id: string; level: number }[] = [{ id: nodeId, level: 0 }];
 
  while (queue.length > 0) {
    const { id, level } = queue.shift()!;
 
    if (level >= maxDepth) continue;
 
    const neighbors = adjacencyMap.get(id);
    if (neighbors) {
      neighbors.forEach(neighborId => {
        if (!visited.has(neighborId)) {
          visited.add(neighborId);
          queue.push({ id: neighborId, level: level + 1 });
        }
      });
    }
  }
 
  return visited;
}, [filteredGraphData, adjacencyMap]);

이 함수를 한 줄씩 분해해봅시다.

1단계: 초기화

const visited = new Set<string>([nodeId]);
const queue: { id: string; level: number }[] = [{ id: nodeId, level: 0 }];

이준혁: "BFS에는 두 가지 자료구조가 필수야. visited는 '이미 방문한 노드'를 기록하는 Set이고, queue는 '다음에 방문할 노드'를 순서대로 담는 배열이야. 시작 노드를 양쪽 모두에 넣고 출발해."

visited에 시작 노드를 넣는 이유는 자기 자신을 다시 방문하지 않기 위해서입니다. queue의 각 항목에는 노드 ID와 함께 **현재 깊이(level)**를 기록합니다. 이 level이 깊이 제한 탐색의 핵심입니다.

2단계: BFS 루프

while (queue.length > 0) {
  const { id, level } = queue.shift()!;
 
  if (level >= maxDepth) continue;
 
  const neighbors = adjacencyMap.get(id);
  if (neighbors) {
    neighbors.forEach(neighborId => {
      if (!visited.has(neighborId)) {
        visited.add(neighborId);
        queue.push({ id: neighborId, level: level + 1 });
      }
    });
  }
}

이준혁: "연못의 물결 비유를 여기에 대입해볼게."

물결 0단계 (level 0): 시작 노드 '감자빵'
   ↓ 감자빵의 이웃을 queue에 추가 (level 1)
물결 1단계 (level 1): '감자빵 맛집', '감자빵 택배', '강릉 감자빵'
   ↓ 각 이웃의 이웃을 queue에 추가 (level 2)
물결 2단계 (level 2): '강릉 맛집', '택배 배송', ...
   ↓ maxDepth에 도달하면 continue로 더 이상 확장하지 않음

queue.shift()는 배열 맨 앞에서 꺼내는 것이므로 선입선출(FIFO) 순서입니다. 이것이 BFS의 핵심입니다. 가까운 이웃(level 1)이 먼 이웃(level 2)보다 먼저 처리됩니다.

if (level >= maxDepth) continue는 현재 노드의 깊이가 제한에 도달하면 이웃 탐색을 건너뛰는 것입니다. 즉, 해당 노드 자체는 결과에 포함되지만, 그 노드의 이웃은 더 이상 추가하지 않습니다.

3단계: 결과 반환

return visited;

visited Set에는 시작 노드와 깊이 N까지의 모든 이웃이 담겨 있습니다. Set을 사용했기 때문에 has() 조회가 O(1)이라서, 이후 수백 개 노드의 색상을 결정할 때도 빠릅니다.

3.2 노드 색상 분기 -- getNodeColor

BFS 결과를 바탕으로 각 노드의 색상을 결정하는 함수입니다. 세 단계의 분기 로직이 있습니다.

// src/app/page.tsx (getNodeColor)
 
const getNodeColor = useCallback((node: any) => {
  const kNode = node as KeywordNode;
 
  // ── 분기 1: 하이라이트 모드일 때 ──
  if (highlightNodes.size > 0) {
    // 하이라이트 대상이 아닌 노드 → 회색 반투명
    if (!highlightNodes.has(kNode.id)) {
      return 'rgba(148,163,184,0.25)';
    }
    // 사용자가 직접 클릭한 노드 → 주황색
    if (selectedNode?.id === kNode.id) {
      return '#F97316';
    }
  }
 
  // ── 분기 2: 시드 키워드 → 그룹 컬러 ──
  if (kNode.isSeed) {
    return GROUP_COLORS[kNode.group % GROUP_COLORS.length];
  }
 
  // ── 분기 3: 일반 키워드 → 경쟁도 기반 ──
  switch (kNode.compIdx) {
    case '높음': return '#EF4444';   // 빨강
    case '중간': return '#EAB308';   // 노랑
    case '낮음': return '#22C55E';   // 초록
    default: return '#94A3B8';       // 회색
  }
}, [highlightNodes, selectedNode]);

이준혁: "이 함수의 분기 순서가 중요해. 먼저 '하이라이트 모드인지' 확인하고, 그다음 '시드 키워드인지', 마지막으로 '경쟁도'를 본다. 이 순서를 뒤집으면 하이라이트가 제대로 안 돼."

전체 로직을 도식으로 정리하면 다음과 같습니다:

getNodeColor(node)

  ├─ highlightNodes.size > 0?  (하이라이트 모드)
  │     │
  │     ├─ node가 highlightNodes에 없음 → rgba(148,163,184,0.25) (회색 반투명)
  │     └─ node가 selectedNode        → #F97316 (주황)
  │     └─ (그 외 하이라이트 노드)     → 아래 분기로 통과

  ├─ node.isSeed?
  │     └─ Yes → GROUP_COLORS[group % 10]  (시드 그룹 컬러)

  └─ node.compIdx?
        ├─ '높음' → #EF4444 (빨강)
        ├─ '중간' → #EAB308 (노랑)
        ├─ '낮음' → #22C55E (초록)
        └─ default → #94A3B8 (회색)

GROUP_COLORS 배열은 10개 색상이 정의되어 있고, group % 10으로 인덱싱하므로 시드 키워드가 몇 개이든 순환 배정됩니다:

// src/app/page.tsx (상수)
 
const GROUP_COLORS = [
  '#3B82F6', '#22C55E', '#F97316', '#A855F7', '#14B8A6',
  '#EAB308', '#0EA5E9', '#EF4444', '#8B5CF6', '#64748B',
];

3.3 클릭 핸들러와 선택 해제

사용자의 인터랙션을 처리하는 핸들러 세 개를 살펴봅니다.

// src/app/page.tsx (인터랙션 핸들러)
 
// 선택된 노드의 연관 노드 업데이트
const updateHighlight = useCallback((node: KeywordNode, currentDepth: number) => {
  const connected = findConnectedNodes(node.id, currentDepth);
  setHighlightNodes(connected);
 
  if (filteredGraphData) {
    const connectedNodes = filteredGraphData.nodes.filter(
      n => connected.has(n.id) && n.id !== node.id
    );
    setConnectedKeywords(connectedNodes);
  }
}, [findConnectedNodes, filteredGraphData]);
 
// 노드 클릭 핸들러
const handleNodeClick = useCallback((node: any) => {
  const kNode = node as KeywordNode;
  setSelectedNode(kNode);
  updateHighlight(kNode, depth);
}, [depth, updateHighlight]);
 
// 선택 해제
const clearSelection = useCallback(() => {
  setSelectedNode(null);
  setHighlightNodes(new Set());
  setConnectedKeywords([]);
}, []);

이준혁: "흐름을 따라가보면 이래. 사용자가 노드를 클릭 → handleNodeClicksetSelectedNode로 선택 노드 저장 → updateHighlightfindConnectedNodes(BFS 실행) → setHighlightNodes로 하이라이트 Set 업데이트 → React가 리렌더링 → getNodeColor가 새 highlightNodes를 기준으로 색상 재계산. 전부 연쇄적으로 일어나."

clearSelection은 배경을 클릭했을 때 호출되며, 세 가지 상태를 모두 초기화합니다.

깊이 변경 시 실시간 업데이트도 처리해야 합니다:

// src/app/page.tsx (depth 변경 감지)
 
// depth 변경 시 하이라이트 업데이트
useEffect(() => {
  if (selectedNode) {
    updateHighlight(selectedNode, depth);
  }
}, [depth, selectedNode, updateHighlight]);

사용자가 탐색 깊이 +/- 버튼을 눌러 depth가 변경되면, 선택된 노드가 있을 경우 BFS를 다시 실행하여 하이라이트를 업데이트합니다.

3.4 로딩 상태 처리

API에서 데이터를 불러오는 동안 보여주는 로딩 UI입니다.

// src/app/page.tsx (로딩 상태)
 
if (loading) {
  return (
    <div className="app-shell min-h-screen flex items-center justify-center px-4 py-10">
      <GlobalStyles />
      <div className="surface-card w-full max-w-md rounded-3xl px-8 py-10 text-center">
        <div className="mx-auto mb-5 h-12 w-12 animate-spin rounded-full
             border-2 border-slate-200 border-t-blue-500" />
        <p className="text-lg font-semibold text-slate-900">
          키워드 데이터 로딩 중...
        </p>
        <p className="mt-2 text-sm text-slate-500">
          서버에서 그래프 데이터를 불러오는 중입니다.
        </p>
      </div>
    </div>
  );
}

이준혁: "여기서 짚을 건 CSS로 만든 스피너야. animate-spin이 전체 원을 회전시키고, border-t-blue-500이 위쪽 테두리만 파란색으로 칠해서 회전하는 것처럼 보이는 거야. SVG 스피너를 별도로 만들 필요 없이 Tailwind 클래스 세 개로 해결됐어. 또 @media (prefers-reduced-motion: reduce) 쿼리에서 애니메이션을 끌 수 있게 GlobalStyles에 처리해놓은 것도 접근성 측면에서 중요해."

이 early return 패턴은 로딩이 완료되기 전에 그래프 렌더링 로직이 실행되는 것을 방지합니다. graphDatanull인 상태에서 그래프를 그리려고 하면 에러가 나기 때문입니다.

3.5 대시보드 레이아웃 -- header, sidebar, graph canvas

메인 렌더링 부분을 세 영역으로 나누어 분석합니다.

전체 레이아웃 구조:

┌──────────────────────────────────────────────────────────────────┐
│ header: 타이틀 + 필터 컨트롤 (최소 검색량, 상위 엣지, 탐색 깊이) │
├────────────────┬─────────────────────────────────────────────────┤
│ aside (320px)  │ section (나머지 너비)                            │
│                │                                                 │
│ 그래프 요약     │  ForceGraph2D                                   │
│ 탐색 가이드     │  (그래프 캔버스)                                  │
│ 선택된 키워드   │                                                 │
│  └ 연관 목록    │                                                 │
│                │                                                 │
├────────────────┴─────────────────────────────────────────────────┤

header -- 필터 컨트롤 바:

// src/app/page.tsx (header 영역)
 
<header className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
  <div>
    <p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-500">
      Marketing Intelligence
    </p>
    <h1 className="mt-2 text-3xl font-semibold text-slate-900">
      Keyword Graph Console
    </h1>
  </div>
  <div className="surface-card flex flex-wrap items-center gap-5 rounded-2xl px-4 py-3">
    {/* 최소 검색량 슬라이더 */}
    <div className="flex items-center gap-2">
      <span className="font-semibold text-slate-700">최소 검색량</span>
      <input type="range" min={0} max={10000} step={500}
             value={minVolume}
             onChange={(e) => setMinVolume(Number(e.target.value))} />
      <span>{minVolume.toLocaleString()}</span>
    </div>
 
    {/* 상위 엣지 슬라이더 */}
    <div className="flex items-center gap-2">
      <span className="font-semibold text-slate-700">상위 엣지</span>
      <input type="range" min={1} max={30} step={1}
             value={topN}
             onChange={(e) => setTopN(Number(e.target.value))} />
      <span>{topN}</span>
    </div>
 
    {/* 탐색 깊이 +/- 버튼 */}
    <span className="font-semibold text-slate-700">탐색 깊이</span>
    <div className="flex items-center gap-2 rounded-full bg-slate-100 px-2 py-1">
      <button onClick={() => setDepth(d => Math.max(1, d - 1))}
              disabled={depth <= 1}> - </button>
      <span>{depth}</span>
      <button onClick={() => setDepth(d => Math.min(10, d + 1))}
              disabled={depth >= 10}> + </button>
    </div>
  </div>
</header>

이준혁: "탐색 깊이를 슬라이더가 아니라 +/- 버튼으로 만든 이유가 있어. 깊이는 1~10 범위의 정수고, 한 단계 올리거나 내리는 게 대부분이거든. 슬라이더보다 버튼이 정밀 조작에 유리해. Math.max(1, d - 1)Math.min(10, d + 1)로 범위를 제한하고, disabled로 경계에서 비활성화까지 처리했어."

aside -- 사이드바 (선택된 키워드 상세):

// src/app/page.tsx (사이드바 - 선택된 키워드 영역)
 
<section className="surface-card rounded-2xl p-4">
  <div className="flex items-center justify-between">
    <h2 className="text-sm font-semibold text-slate-900">선택된 키워드</h2>
    {selectedNode && (
      <button onClick={clearSelection} className="text-xs font-semibold text-slate-500">
        선택 해제
      </button>
    )}
  </div>
 
  {selectedNode ? (
    <>
      {/* 키워드 상세 정보 */}
      <div className="mt-3 rounded-xl border border-slate-200 bg-white px-3 py-3">
        <p className="text-sm font-semibold text-slate-900">{selectedNode.keyword}</p>
        <div className="mt-2 grid grid-cols-2 gap-2 text-xs text-slate-600">
          <span>검색량(PC)</span>
          <span>{selectedNode.monthlyPcQcCnt.toLocaleString()}</span>
          <span>검색량(Mobile)</span>
          <span>{selectedNode.monthlyMobileQcCnt.toLocaleString()}</span>
          <span>경쟁도</span>
          <span className={
            selectedNode.compIdx === '높음' ? 'text-red-600' :
            selectedNode.compIdx === '중간' ? 'text-yellow-600' : 'text-green-600'
          }>{selectedNode.compIdx}</span>
          <span>시드 키워드</span>
          <span>{selectedNode.isSeed ? '예' : '아니오'}</span>
        </div>
      </div>
 
      {/* 연관 키워드 목록 */}
      <div className="mt-4">
        <h3 className="text-xs font-semibold text-slate-500">
          연관 키워드 (Depth {depth}, {connectedKeywords.length}개)
        </h3>
        <div className="mt-2 max-h-64 space-y-2 overflow-y-auto">
          {connectedKeywords.map(node => (
            <button key={node.id} onClick={() => handleNodeClick(node)}
                    className="flex w-full items-center justify-between rounded-lg ...">
              <span className="truncate">{node.keyword}</span>
              <span className="rounded-full px-2 py-0.5 text-[10px] font-semibold">
                {node.totalVolume.toLocaleString()}
              </span>
            </button>
          ))}
        </div>
      </div>
    </>
  ) : (
    <div className="mt-3 rounded-xl border border-dashed border-slate-200 ...">
      그래프에서 키워드를 선택해 연결 관계를 확인하세요.
    </div>
  )}
</section>

이준혁: "사이드바의 연관 키워드 목록에서 각 항목이 <button>이고 onClickhandleNodeClick이 연결된 거 보이지? 목록에서 키워드를 클릭하면 그래프에서 해당 노드를 선택한 것과 동일한 효과가 나. 사이드바와 그래프 캔버스가 양방향으로 연동되는 거야."

selectedNodenull일 때는 점선 테두리의 빈 상태(empty state) 안내 메시지를 보여줍니다. 이런 빈 상태 처리는 사용자 경험 측면에서 중요합니다.

3.6 ForceGraph2D 핵심 Props

그래프 캔버스의 핵심 설정들을 분석합니다.

// src/app/page.tsx (ForceGraph2D 렌더링)
 
<ForceGraph2D
  ref={fgRef}
  graphData={filteredGraphData}
  width={dimensions.width}
  height={dimensions.height}
 
  // ── 노드 설정 ──
  nodeColor={getNodeColor}
  nodeRelSize={4}
  nodeVal={(node: any) => {
    const kNode = node as KeywordNode;
    return 1 + (kNode.totalVolume / maxVolume) * 49;
  }}
  nodeLabel={getNodeLabel}
 
  // ── 엣지 설정 (하이라이트 반응) ──
  linkColor={(link: any) => {
    if (highlightNodes.size === 0) return 'rgba(148,163,184,0.12)';
    const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
    const targetId = typeof link.target === 'object' ? link.target.id : link.target;
    if (highlightNodes.has(sourceId) && highlightNodes.has(targetId)) {
      return 'rgba(249,115,22,0.7)';      // 하이라이트 엣지 → 주황 반투명
    }
    return 'rgba(148,163,184,0.04)';       // 비하이라이트 엣지 → 거의 투명
  }}
  linkWidth={(link: any) => {
    if (highlightNodes.size === 0) return 0.5;
    const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
    const targetId = typeof link.target === 'object' ? link.target.id : link.target;
    if (highlightNodes.has(sourceId) && highlightNodes.has(targetId)) {
      return 1.8;                           // 하이라이트 엣지 → 굵게
    }
    return 0.3;                             // 비하이라이트 엣지 → 가늘게
  }}
 
  // ── 시뮬레이션 설정 ──
  warmupTicks={100}
  cooldownTicks={0}
 
  // ── 인터랙션 ──
  onNodeClick={handleNodeClick}
  onBackgroundClick={clearSelection}
  enableNodeDrag={true}
  enableZoomInteraction={true}
  enablePanInteraction={true}
/>

각 prop의 역할을 표로 정리합니다:

Prop역할
nodeVal1 + (volume / max) * 49노드 크기를 검색량에 비례하여 1~50 범위로 정규화
nodeRelSize4nodeVal 1단위당 실제 픽셀 반지름 (기본 크기 배율)
linkColor함수양쪽 노드 모두 하이라이트 Set에 포함될 때만 주황색, 아니면 투명
linkWidth함수하이라이트 엣지는 1.8px, 나머지는 0.3px
warmupTicks100렌더링 전 물리 시뮬레이션을 100틱 미리 돌려서 초기 배치를 안정화
cooldownTicks0렌더링 후 추가 시뮬레이션 없이 바로 정지 (CPU 절약)

이준혁: "nodeVal 정규화 공식을 뜯어보자. 검색량이 0인 노드는 1 + 0 = 1, 검색량이 최대인 노드는 1 + 49 = 50이 돼. 왜 0이 아니라 1에서 시작하느냐고? 크기가 0이면 노드가 안 보이니까. 최소 크기 1을 보장하는 거야. 그리고 maxVolume으로 나누는 건 모든 노드를 0~1 사이로 정규화(normalize) 하는 거야. 데이터의 절대적인 검색량 범위가 바뀌어도 노드 크기는 항상 일관된 비율로 표시돼."

linkColorlinkWidth에서 typeof link.source === 'object' 체크가 필요한 이유는, react-force-graph가 시뮬레이션을 돌리면서 source/target을 문자열 ID에서 노드 객체 참조로 바꿔버리기 때문입니다. 두 가지 경우 모두 대응해야 합니다.


4. 핵심 포인트 정리

  • BFS는 깊이 제한 탐색에 자연스러운 알고리즘이다. queue(FIFO) + visited Set + level 추적의 세 요소로 구성된다. 물결이 동심원으로 퍼져나가듯 가까운 노드부터 층별로 방문하므로, maxDepth에서 탐색을 멈추는 것이 직관적이다. DFS는 한 방향으로 깊이 파고들기 때문에 깊이 제한 용도에 적합하지 않다.

  • useCallback은 함수 참조 안정성을 위한 메모이제이션이다. React 컴포넌트가 리렌더링될 때마다 내부 함수가 새로 생성되는데, 이 함수가 다른 Hook의 의존성이나 자식 컴포넌트의 prop으로 사용될 경우 불필요한 연쇄 재실행이 발생한다. useCallback은 의존성 배열이 변하지 않는 한 동일한 함수 참조를 유지한다.

  • getNodeColor의 3단계 분기 순서가 중요하다. 하이라이트 모드 확인(선택 안 된 노드는 회색, 선택된 노드는 주황) -> 시드 키워드 확인(그룹별 고유 색상) -> 경쟁도 기반 색상(빨강/노랑/초록). 이 순서가 바뀌면 하이라이트가 정상 동작하지 않는다.

  • nodeVal의 정규화 공식 1 + (volume / max) * 49는 최소값 1, 최대값 50 범위를 보장한다. 최소값을 0이 아닌 1로 설정하여 모든 노드가 최소한의 가시성을 갖도록 하고, maxVolume으로 나누어 데이터 범위에 독립적인 일관된 크기 비율을 만든다.

  • 엣지 하이라이트는 양쪽 노드가 모두 하이라이트 Set에 포함될 때만 적용된다. highlightNodes.has(sourceId) && highlightNodes.has(targetId) 조건으로 관련 없는 엣지를 거의 투명하게 처리하여 선택된 키워드 네트워크만 시각적으로 부각시킨다.

  • warmupTicks: 100cooldownTicks: 0의 조합은 초기 레이아웃 안정화와 CPU 절약을 동시에 달성한다. 렌더링 전에 시뮬레이션을 100틱 미리 돌려 노드 위치를 잡고, 렌더링 후에는 추가 시뮬레이션 없이 멈추므로 브라우저 리소스를 절약한다.


5. 전체 프로젝트 회고

이 Step을 마지막으로, "네이버 키워드 시각화 프로젝트"의 전체 구현이 완료되었습니다. Step 1부터 Step 9까지 걸어온 길을 되돌아봅니다.

전체 데이터 흐름 복습

[네이버 광고 API]

      ▼  (Step 2~3: Rate Limiter + API Client)
[Raw JSON 파일]  ←  data/raw/*.json

      ▼  (Step 4~5: 데이터 파싱 + 그래프 빌드)
[GraphData { nodes, links }]

      ▼  (Step 6: API Route)
[Next.js /api/graph 엔드포인트]

      ▼  (Step 7: fetch + useState)
[React 컴포넌트 상태]

      ▼  (Step 8: Top-N 필터링 + adjacencyMap + forceRadial)
[filteredGraphData]

      ▼  (Step 9: BFS + 하이라이트 + 대시보드 UI)
[완성된 인터랙티브 시각화]

Step별로 배운 것

Step핵심 주제배운 것
1프로젝트 초기화 + 타입 설계TypeScript strict 모드, 유니온 리터럴 타입, 데이터 흐름별 타입 분리
2토큰 버킷 Rate Limiter비동기 큐 패턴, Promise resolve 지연 호출, 게으른 보충 전략
3API 클라이언트HMAC-SHA256 서명, HTTP 헤더 인증, Rate Limiter 통합
4데이터 파싱JSON 파일 읽기, Raw -> Node 변환, 합산 필드 계산
5그래프 빌드노드 간 링크 생성, strength 계산, 시드 키워드 그룹핑
6API RouteNext.js App Router 서버 핸들러, 쿼리 파라미터 처리
7프론트엔드 기초dynamic import(SSR 우회), useEffect 데이터 로딩, ForceGraph2D 기본 설정
8Top-N 필터링 + 물리 엔진useMemo 최적화, adjacencyMap/degreeMap 구축, d3 forceRadial 커스텀
9BFS + 인터랙티브 UIBFS 깊이 제한 탐색, useCallback 메모이제이션, 3단계 색상 분기, 대시보드 레이아웃

기술 스택 최종 정리

  • 프레임워크: Next.js 16 (App Router) + React 19 + TypeScript 5.9 (strict mode)
  • 시각화: react-force-graph-2d + d3-force (forceRadial 커스텀)
  • 스타일링: Tailwind CSS 4 (유틸리티 퍼스트)
  • 데이터: 네이버 광고 API JSON + 서버사이드 그래프 빌드

확장 가능성

이 프로젝트를 더 발전시킨다면 다음과 같은 방향이 있습니다:

  • 실시간 데이터 업데이트: 크롤링 스케줄러를 붙여 키워드 데이터를 주기적으로 갱신하고, 시간에 따른 검색량 변화를 추적
  • 클러스터 분석 강화: 커뮤니티 디텍션 알고리즘(Louvain, Label Propagation)을 적용하여 자동으로 키워드 군집을 분류
  • 3D 시각화: react-force-graph-3d로 전환하여 대규모 그래프의 입체적 탐색 지원
  • 내보내기 기능: 선택된 키워드 네트워크를 CSV/이미지로 다운로드
  • 협업 기능: 여러 사용자가 동시에 같은 그래프를 탐색하고 어노테이션을 남기는 실시간 협업

이준혁: "처음에 타입 네 개 정의하는 것부터 시작해서, 마지막에 BFS 탐색이 되는 인터랙티브 대시보드까지 왔어. 돌이켜보면 결국 핵심은 하나야 -- 데이터가 어디서 시작해서 어떤 변환을 거쳐 화면에 도달하는지, 그 흐름을 명확하게 타입으로 잡아두는 것. 그게 탄탄하면 나머지 로직은 자연스럽게 따라와. 수고했어."