Step 8. Top-N 필터링과 그래프 레이아웃
Step 8. Top-N 필터링과 그래프 레이아웃
이 문서에서 다루는 내용: 백엔드에서 받아온 수천 개의 엣지를 노드별 검색량 기준 Top-N 필터링으로 정리하고, d3-force의 forceRadial을 활용해 연결이 많은 허브 노드를 그래프 중심에 자동 배치하는 레이아웃 전략을 구현합니다.
1. 요구사항
PM 김도연:
"이준혁 님, 전 단계에서 프론트엔드 기초 세팅이 잘 끝났는데요. 실제로 그래프를 띄워보니 문제가 하나 있습니다."
"백엔드에서 오는 엣지가 수천 개다 보니까 그래프가 실뭉치(hairball)처럼 보여요. 선이 너무 많아서 어떤 키워드가 어떤 키워드와 연결돼 있는지 전혀 파악이 안 됩니다. 마케팅팀에서 '이걸로 뭘 분석하라는 거냐'고 피드백이 왔어요."
"해결 방법을 고민해봤는데, 각 키워드마다 검색량이 높은 상위 N개의 연결만 보여주면 핵심 관계만 드러나지 않을까요? N 값은 사용자가 슬라이더로 1부터 30까지 조절할 수 있으면 좋겠습니다."
"그리고 하나 더요. '강황', '감자빵' 같은 시드 키워드처럼 많은 키워드와 연결된 허브 노드가 그래프 한쪽 구석에 있으면 전체 구조를 파악하기 어렵더라고요. 이런 허브 노드가 자연스럽게 중앙에 위치하도록 레이아웃을 조정해주실 수 있나요?"
2. 시니어의 접근 방식
시니어 이준혁 (8년차):
"좋아, 지금 두 가지 문제를 동시에 풀어야 해. 엣지 과다(hairball) 문제하고 허브 노드 중심 배치 문제."
"첫 번째 문제부터 가자. 엣지가 3,000개 있으면 그래프가 뭉개지는 건 당연해. 그런데 전부 날려버리면 정보 손실이 생기잖아. 그래서 각 노드마다 가장 중요한 이웃 N개만 남기는 전략을 쓸 거야. 중요도의 기준은? 검색량(totalVolume)이지. 검색량 높은 키워드와의 연결이 비즈니스 관점에서 더 의미 있으니까."
"여기서 질문 하나. 노드 A가 노드 B를 top-5에 포함시켰는데, B 입장에서는 A가 top-5에 안 들면 이 엣지를 살릴까 죽일까? 양쪽 다 포함(AND)해야 살리는 게 맞을까, 한쪽만 포함(OR)해도 살리는 게 맞을까?"
"답은 OR 조건이야. AND로 하면 너무 많은 엣지가 잘려나가. 검색량이 높은 허브 노드 입장에서는 이웃이 수십 개인데 top-5에 못 들어간 노드의 연결이 전부 끊겨버리거든. OR로 해야 작은 노드에서 허브 노드로의 연결이 유지돼서, 그래프의 전체 구조가 보존돼."
"두 번째 문제, 허브 노드 중심 배치. 이건 d3-force의 forceRadial을 쓸 거야. 이게 뭐냐면, 각 노드에게 '너는 중심에서 이 정도 거리에 있어'라고 지정해주는 힘이야. 연결이 많은 노드(degree가 높은 노드)일수록 반지름을 작게 주면 중심에 모이게 되는 거지."
"그러면 이 두 가지를 구현하려면 데이터 가공이 여러 단계를 거쳐야 해. 전부 useMemo로 묶을 건데, 여기서 중요한 건 의존성 체인이야. 폭포수를 생각해봐. 위에서 물이 떨어지면 아래 단 전체가 다시 적셔지잖아. useMemo도 마찬가지야:"
graphData (원본)
↓ topN 변경 시 재계산
filteredGraphData (필터된 엣지)
↓ 필터 결과가 바뀌면 재계산
adjacencyMap (인접 리스트)
↓ 인접 리스트가 바뀌면 재계산
degreeMap (각 노드 연결 수)
↓ degree가 바뀌면 force 재설정
forceRadial (레이아웃 적용)"graphData가 바뀌면 아래 전부가 연쇄적으로 재계산돼. 이게 React에서 useMemo 의존성 체인을 설계할 때 핵심이야. 불필요한 재계산은 막되, 필요한 재계산은 확실하게 전파되게 해야 하거든."
"한 가지 더 알아야 할 게 있어. link.source가 string일 수도 있고 object일 수도 있다는 거 알아? react-force-graph가 내부적으로 시뮬레이션을 돌리면서 source와 target을 문자열 ID에서 노드 객체 참조로 바꿔버려. 그래서 코드 곳곳에 typeof link.source === 'object' 체크가 들어가는 거야. 이걸 모르면 런타임에 .id가 없다고 터지거든."
"자, 코드로 들어가자. 네 개의 useMemo와 하나의 useEffect로 전부 해결할 수 있어."
3. 구현
3.1 Top-N 엣지 필터링 (filteredGraphData)
백엔드에서 받아온 전체 그래프 데이터에서, 각 노드별로 검색량 상위 N개의 이웃과 연결된 엣지만 남기는 필터링 로직입니다.
먼저 전체 코드를 보고, 이후 세 단계로 분해해서 분석합니다.
// src/app/page.tsx — filteredGraphData (lines 151-184)
const filteredGraphData = useMemo(() => {
if (!graphData) return null;
const nodeMap = new Map(graphData.nodes.map(n => [n.id, n]));
// [Phase 1] 각 노드별 이웃 수집
const neighbors = new Map<string, string[]>();
for (const link of graphData.links) {
const src = typeof link.source === 'object' ? (link.source as KeywordNode).id : link.source;
const tgt = typeof link.target === 'object' ? (link.target as KeywordNode).id : link.target;
if (!neighbors.has(src)) neighbors.set(src, []);
if (!neighbors.has(tgt)) neighbors.set(tgt, []);
neighbors.get(src)!.push(tgt);
neighbors.get(tgt)!.push(src);
}
// [Phase 2] 각 노드별 top N 이웃 선정 (검색량 기준 내림차순)
const topNeighbors = new Map<string, Set<string>>();
for (const [nodeId, neighList] of neighbors) {
const sorted = [...new Set(neighList)]
.sort((a, b) => (nodeMap.get(b)?.totalVolume ?? 0) - (nodeMap.get(a)?.totalVolume ?? 0))
.slice(0, topN);
topNeighbors.set(nodeId, new Set(sorted));
}
// [Phase 3] 양쪽 중 하나라도 상대를 top N에 포함하면 유지
const filteredLinks = graphData.links.filter(link => {
const src = typeof link.source === 'object' ? (link.source as KeywordNode).id : link.source;
const tgt = typeof link.target === 'object' ? (link.target as KeywordNode).id : link.target;
return topNeighbors.get(src)?.has(tgt) || topNeighbors.get(tgt)?.has(src);
});
return { nodes: graphData.nodes, links: filteredLinks };
}, [graphData, topN]);Phase 1: 이웃 수집
const neighbors = new Map<string, string[]>();
for (const link of graphData.links) {
const src = typeof link.source === 'object' ? (link.source as KeywordNode).id : link.source;
const tgt = typeof link.target === 'object' ? (link.target as KeywordNode).id : link.target;
if (!neighbors.has(src)) neighbors.set(src, []);
if (!neighbors.has(tgt)) neighbors.set(tgt, []);
neighbors.get(src)!.push(tgt);
neighbors.get(tgt)!.push(src);
}이준혁: "전체 링크를 한 번 순회하면서 각 노드가 누구와 연결되어 있는지 수집하는 거야. 무방향 그래프니까 양쪽 다 넣어줘.
A — B링크가 있으면 A의 이웃에 B를 넣고, B의 이웃에도 A를 넣는 거지."
여기서 typeof link.source === 'object' 체크가 등장합니다. react-force-graph는 시뮬레이션을 돌리면서 link.source를 문자열 ID("감자빵")에서 노드 객체 참조({ id: "감자빵", totalVolume: 21820, ... })로 변환합니다. 최초 렌더링에서는 string이지만, 시뮬레이션 이후에는 object가 되므로 두 경우를 모두 처리해야 합니다.
Phase 2: 검색량 기준 Top-N 선정
const topNeighbors = new Map<string, Set<string>>();
for (const [nodeId, neighList] of neighbors) {
const sorted = [...new Set(neighList)] // 중복 제거
.sort((a, b) => // 검색량 내림차순 정렬
(nodeMap.get(b)?.totalVolume ?? 0) - (nodeMap.get(a)?.totalVolume ?? 0)
)
.slice(0, topN); // 상위 N개만 선택
topNeighbors.set(nodeId, new Set(sorted));
}이준혁: "이 부분이 필터링의 핵심이야. 흐름을 따라가보면:"
new Set(neighList)-- 같은 이웃이 여러 번 등장할 수 있으니 중복부터 제거sort()--nodeMap에서 각 이웃의totalVolume을 꺼내서 내림차순 정렬. 검색량 높은 이웃이 앞에 옴.slice(0, topN)-- 상위 N개만 잘라냄new Set(sorted)-- 나중에has()검색을 빠르게 하려고Set으로 변환
"예를 들어 topN = 5이고, '감자빵' 노드의 이웃이 30개라면, 그중 검색량이 가장 높은 5개만 남기는 거야. 나머지 25개의 연결은 '감자빵' 입장에서는 버린다는 뜻이지."
Phase 3: OR 조건 필터링
const filteredLinks = graphData.links.filter(link => {
const src = typeof link.source === 'object' ? (link.source as KeywordNode).id : link.source;
const tgt = typeof link.target === 'object' ? (link.target as KeywordNode).id : link.target;
return topNeighbors.get(src)?.has(tgt) || topNeighbors.get(tgt)?.has(src);
});이준혁: "최종 필터링 단계야.
A — B링크가 있을 때, A가 B를 top-N에 넣었거나(||) B가 A를 top-N에 넣었으면 이 엣지는 살아남아."
OR 조건을 사용하는 이유를 구체적으로 보겠습니다:
| 시나리오 | AND 조건 | OR 조건 |
|---|---|---|
| '감자빵'(검색량 21,820)이 '감자전'(검색량 500)을 top-5에 포함 | '감자전'도 '감자빵'을 top-5에 넣어야만 유지 | '감자빵' 쪽만 포함해도 유지 |
| 허브 노드(이웃 50개)와 소형 노드(이웃 3개)의 연결 | 허브 노드의 top-5에 못 들면 항상 삭제 | 소형 노드에서 허브를 top-3에 넣으면 유지 |
이준혁: "AND로 하면 소형 노드들이 허브와의 연결을 대부분 잃어버려. OR로 해야 '작은 노드가 큰 노드를 바라보는 관계'가 유지돼서 그래프의 방사형 구조가 살아있게 돼."
최종 반환값에서 노드는 원본 그대로 유지하고 링크만 필터링한다는 점도 주목하세요. 연결이 끊긴 노드는 그래프에 남아 있되 시각적으로 고립된 상태로 보여집니다.
3.2 인접 리스트 구성 (adjacencyMap)
필터링된 엣지를 기반으로 각 노드의 이웃 목록을 Map<string, Set<string>>으로 구성합니다. 이 자료구조는 뒤이어 나올 degreeMap 계산과 다음 Step의 BFS 탐색에서 사용됩니다.
// src/app/page.tsx — adjacencyMap (lines 187-201)
const adjacencyMap = useMemo(() => {
if (!filteredGraphData) return new Map<string, Set<string>>();
const map = new Map<string, Set<string>>();
filteredGraphData.links.forEach(link => {
const sourceId = typeof link.source === 'object' ? (link.source as KeywordNode).id : link.source;
const targetId = typeof link.target === 'object' ? (link.target as KeywordNode).id : link.target;
if (!map.has(sourceId)) map.set(sourceId, new Set());
if (!map.has(targetId)) map.set(targetId, new Set());
map.get(sourceId)!.add(targetId);
map.get(targetId)!.add(sourceId);
});
return map;
}, [filteredGraphData]);이준혁: "'아까 filteredGraphData 안에서도 이웃 수집을 했잖아. 왜 또 하냐?'고 생각할 수 있어. 두 가지 차이가 있어:"
- 범위가 다르다 -- 앞에서는 원본(
graphData.links) 전체를 순회했고, 여기서는 필터링된 엣지(filteredGraphData.links)만 순회해. 결과가 완전히 달라.- 자료구조가 다르다 -- 앞에서는
Map<string, string[]>(배열)이었고, 여기서는Map<string, Set<string>>(셋)이야. BFS 탐색에서has()조회가 필요하니까Set이 더 적합해.
"의존성이 [filteredGraphData]인 점 주목해. filteredGraphData가 바뀔 때만 재계산되고, graphData가 같은데 topN만 바뀌면 filteredGraphData -> adjacencyMap 순서로 폭포수처럼 흘러내려."
3.3 연결 수 계산 (degreeMap)
각 노드가 필터링 후 몇 개의 엣지와 연결되어 있는지를 나타내는 degree를 계산합니다.
// src/app/page.tsx — degreeMap (lines 204-210)
const degreeMap = useMemo(() => {
const map = new Map<string, number>();
for (const [id, neighbors] of adjacencyMap) {
map.set(id, neighbors.size);
}
return map;
}, [adjacencyMap]);이준혁: "이건 간단해. adjacencyMap에서 각 노드의 이웃
Set의 크기를 꺼내서 숫자로 저장하는 거야. '감자빵'이 필터링 후 12개의 노드와 연결돼 있으면degreeMap.get('감자빵') = 12가 되는 거지. 이 값이 다음 단계에서 forceRadial의 입력으로 들어가."
"의존성 체인을 다시 보면: graphData -> filteredGraphData -> adjacencyMap -> degreeMap. 네 단계의 폭포수야. 각 단계가 바로 윗 단계의 결과에만 의존하고, 불필요한 재계산은 useMemo가 알아서 막아줘."
3.4 Radial Force 레이아웃 적용 (forceRadial)
degree가 높은 허브 노드를 그래프 중심에 배치하기 위해, d3-force의 forceRadial을 시뮬레이션에 주입합니다.
// src/app/page.tsx — forceRadial 설정 (lines 213-230)
useEffect(() => {
if (!fgRef.current || !filteredGraphData || degreeMap.size === 0) return;
import('d3-force').then(({ forceRadial }) => {
const maxDegree = Math.max(...degreeMap.values(), 1);
fgRef.current.d3Force('radial',
forceRadial(
(node: any) => {
const degree = degreeMap.get(node.id) ?? 0;
return 300 * (1 - degree / maxDegree);
},
0, 0
).strength(0.3)
);
fgRef.current.d3ReheatSimulation();
});
}, [filteredGraphData, degreeMap]);이 useEffect는 네 개의 핵심 요소로 구성됩니다. 하나씩 분해합니다.
요소 1: Guard 절
if (!fgRef.current || !filteredGraphData || degreeMap.size === 0) return;이준혁: "세 가지를 체크해. ForceGraph2D 인스턴스가 마운트됐는지, 필터된 데이터가 존재하는지, degree 계산이 끝났는지. 하나라도 안 되면 아직 force를 설정할 준비가 안 된 거니까 그냥 빠져나와."
요소 2: 동적 import
import('d3-force').then(({ forceRadial }) => { ... });이준혁: "d3-force를 왜
useEffect안에서 동적으로 import하냐고? 두 가지 이유가 있어. 첫째, 이 모듈은 서버 사이드에서 쓸 일이 없으니까 클라이언트 번들에만 포함시키는 게 맞아. 둘째, react-force-graph가 자체적으로 d3-force를 번들링하고 있어서, 최상위 import로 가져오면 번들이 커질 수 있거든."
요소 3: 반지름 함수
const maxDegree = Math.max(...degreeMap.values(), 1);
// ...
(node: any) => {
const degree = degreeMap.get(node.id) ?? 0;
return 300 * (1 - degree / maxDegree);
}이 함수가 각 노드의 목표 반지름(중심으로부터의 거리)을 결정합니다. 계산 과정을 구체적인 숫자로 추적해봅시다:
전체 노드 중 최대 degree = 25 (maxDegree = 25)
'감자빵' (degree = 25, 허브 노드):
→ 300 * (1 - 25/25) = 300 * 0 = 0px → 정중앙
'강황라떼' (degree = 12, 중간 노드):
→ 300 * (1 - 12/25) = 300 * 0.52 = 156px → 중간 거리
'감자빵맛집추천' (degree = 2, 말단 노드):
→ 300 * (1 - 2/25) = 300 * 0.92 = 276px → 외곽이준혁: "패턴이 보이지? degree가 높을수록 반지름이 0에 가까워지고(중심), degree가 낮을수록 300에 가까워져(외곽). 연결이 많은 허브 노드가 자연스럽게 중심에 모이는 레이아웃이 만들어지는 거야."
forceRadial의 두 번째, 세 번째 인자 0, 0은 방사형 중심의 좌표(x, y)입니다. 그래프 원점을 중심으로 방사형이 형성됩니다.
요소 4: strength와 시뮬레이션 재가열
.strength(0.3)
// ...
fgRef.current.d3ReheatSimulation();이준혁: "
strength(0.3)은 이 force가 얼마나 강하게 작용하는지를 정해. 0이면 무시, 1이면 강제로 해당 반지름에 붙여버려."
| strength 값 | 효과 |
|---|---|
0.1 | 힌트 수준. 노드가 대략적인 위치에 머물되 다른 force에 의해 많이 움직임 |
0.3 | 적당한 균형. 방사형 구조를 유지하면서도 노드 간 반발력이 자연스럽게 작용 |
0.8 | 노드가 지정된 반지름 원 위에 거의 고정. 원형 배치에 가까워짐 |
이준혁: "
d3ReheatSimulation()은 force 설정을 변경한 뒤 시뮬레이션을 재시작하는 메서드야. 이걸 안 하면 새로운 force가 적용되지 않아. d3-force 시뮬레이션은 에너지가 점점 감소하면서 멈추거든. 새 force를 넣으면 다시 에너지를 주입해서(reheat) 노드들이 새 위치를 찾아가게 해야 해."
3.5 useMemo 의존성 체인 전체 흐름
네 개의 useMemo와 하나의 useEffect가 구성하는 전체 데이터 흐름을 시각적으로 정리하면 다음과 같습니다:
사용자가 topN 슬라이더 조절 (예: 10 → 5)
│
v
┌─────────────────────────────────────────────────┐
│ filteredGraphData = useMemo( │
│ graphData.links에서 노드별 top-5 엣지만 필터 │
│ deps: [graphData, topN] ← topN이 바뀌었으니 │
│ 재계산! │
│ ) │
└──────────────────────┬──────────────────────────┘
│ filteredGraphData 변경
v
┌─────────────────────────────────────────────────┐
│ adjacencyMap = useMemo( │
│ 필터된 엣지 기반 인접 리스트 재구성 │
│ deps: [filteredGraphData] ← 바뀌었으니 재계산 │
│ ) │
└──────────────────────┬──────────────────────────┘
│ adjacencyMap 변경
v
┌─────────────────────────────────────────────────┐
│ degreeMap = useMemo( │
│ 각 노드의 연결 수 재계산 │
│ deps: [adjacencyMap] ← 바뀌었으니 재계산 │
│ ) │
└──────────────────────┬──────────────────────────┘
│ degreeMap 변경
v
┌─────────────────────────────────────────────────┐
│ useEffect( │
│ forceRadial 재설정 + d3ReheatSimulation() │
│ deps: [filteredGraphData, degreeMap] │
│ ) │
└─────────────────────────────────────────────────┘이준혁: "이게 폭포수 패턴이야. 맨 위에서 물(topN 변경)이 떨어지면 아래 단이 전부 순서대로 적셔져(재계산). 중간 단계를 건너뛸 수는 없어. 그런데 만약
selectedNode만 바뀌면? 이 폭포수는 작동하지 않아.graphData와topN이 안 바뀌었으니까. 이게useMemo의 의존성 배열이 주는 효율성이야."
4. 핵심 포인트 정리
-
useMemo 의존성 체인은 폭포수처럼 동작한다.
graphData->filteredGraphData->adjacencyMap->degreeMap->forceRadial순서로 연쇄 재계산이 일어나며, 의존성에 포함되지 않은 상태 변경은 이 체인을 트리거하지 않는다. -
Top-N 필터링은 "이웃 수집 -> 검색량 정렬 -> 상위 N개 선정 -> OR 조건 필터"의 네 단계로 구성된다. OR 조건을 사용하는 이유는, AND 조건에서는 소형 노드가 허브 노드와의 연결을 대부분 잃어버려 그래프의 방사형 구조가 파괴되기 때문이다.
-
typeof link.source === 'object'체크는 react-force-graph의 내부 동작 때문에 필수다. react-force-graph는 시뮬레이션을 돌리면서source/target을 문자열 ID에서 노드 객체 참조로 변환하므로, 두 가지 타입을 모두 처리해야 한다. -
adjacencyMap은 필터된 엣지 기반의 인접 리스트다.
Set<string>으로 구성하여has()조회를 O(1)로 보장하며, 이후 BFS 탐색의 자료구조로 직접 사용된다. -
forceRadial은
300 * (1 - degree/maxDegree)공식으로 허브 노드를 중심에 배치한다. degree가 높을수록 목표 반지름이 0에 가까워져 중심으로 이동하고, degree가 낮은 말단 노드는 300px 외곽에 위치한다. -
strength(0.3)은 방사형 구조와 자연스러운 물리 시뮬레이션 사이의 균형점이다. 너무 낮으면 레이아웃 효과가 약하고, 너무 높으면 노드가 동심원 위에 고정되어 인위적으로 보인다.d3ReheatSimulation()으로 force 변경 후 시뮬레이션을 반드시 재시작해야 한다.
5. 다음 Step 예고
Step 9: BFS 탐색과 인터랙티브 UI
이번 Step에서 만든 adjacencyMap을 활용하여, 사용자가 노드를 클릭했을 때 BFS(너비 우선 탐색)로 depth N까지의 연관 키워드를 찾아 하이라이트하는 인터랙션을 구현합니다. 노드 클릭 핸들러, 색상 분기 로직, 그리고 연관 키워드 사이드 패널까지 대시보드의 탐색 경험을 완성합니다.