Step 4. 필터링과 그룹 할당
Step 4. 필터링과 그룹 할당
이 문서에서 다루는 내용: Phase 1에서 만들어둔 nodeMap의 키워드를 검색량 기준으로 필터링하고, 필터를 통과한 키워드에 시드별 그룹 번호를 할당하여 시각적 색상 구분의 기반을 만듭니다.
1. 요구사항
PM 김도연:
"이준혁 님, Phase 1에서 노드 데이터 구조를 잘 만들어주셨는데요. 한 가지 문제가 있습니다. 시드 키워드가 51개다 보니까 연관 키워드가 수천 개는 될 텐데, 이걸 전부 그래프에 그리면 어떻게 되겠어요?"
"마케팅팀에서 테스트해봤는데, 검색량이 월 100도 안 되는 롱테일 키워드까지 전부 노드로 표시되니까 그래프가 너무 복잡해서 의미 있는 패턴을 읽어낼 수가 없다고 하더라고요. 검색량이 일정 기준 이하인 키워드는 걸러내야 할 것 같습니다."
"다만 조건이 하나 있어요. 시드 키워드는 검색량이 낮더라도 절대 빠지면 안 됩니다. 시드 키워드가 그래프에서 사라지면 분석의 출발점 자체가 없어지니까요. '감자빵'을 시드로 넣었는데 그래프에 '감자빵' 노드가 없으면 말이 안 되잖아요."
"그리고 필터링이 끝난 키워드들은 시드별로 색상 그룹을 나눠서 시각적으로 구분해주세요. '감자빵' 계열은 파란색, '강황' 계열은 빨간색 이런 식으로요. 어떤 키워드가 어느 시드에서 파생됐는지 한눈에 보이면 좋겠습니다."
2. 시니어의 접근 방식
시니어 이준혁 (8년차):
"Phase 1에서 nodeMap, seedKeywords, keywordToSeeds, keywordFirstSeed 이렇게 자료구조를 만들어뒀잖아. 이제 이 데이터를 두 단계로 가공할 거야."
"첫 번째는 필터링. 전체 키워드 중에서 그래프에 실제로 표시할 것만 걸러내는 거지. 두 번째는 그룹 할당. 필터를 통과한 키워드에 번호를 매겨서, 나중에 시각화할 때 색상을 다르게 칠할 수 있게 하는 거야."
"먼저 필터링부터 생각해보자. nodeMap에는 키워드가 수천 개 들어 있어. 이 중에서 totalVolume >= minVolume인 것만 남기면 되는데, 여기서 질문 하나. 필터링 결과를 배열로 저장할까, Set으로 저장할까?"
"답부터 말하면, Set<string>으로 간다. 이유는 두 가지야. 첫째, 중복 방지. 시드 키워드가 검색량 조건도 통과하면 일반 루프에서 한 번, 시드 보존 루프에서 한 번, 총 두 번 추가될 수 있거든. Set은 알아서 중복을 무시하니까 신경 쓸 필요 없어. 둘째, 나중에 Phase 4(링크 생성)에서 filteredNodeIds.has(rel)로 특정 키워드가 필터를 통과했는지 확인하는데, Set의 has()는 O(1)이야. 배열의 includes()는 O(n)이고. 키워드가 수천 개면 이 차이가 누적돼."
"그리고 필터링에서 가장 중요한 건 시드 키워드 보존이야. 시드 키워드는 검색량이 0이어도 그래프에서 빠지면 안 돼. 왜? 시드는 사용자가 분석하겠다고 직접 입력한 키워드야. 그래프의 중심축이지. '감자빵'을 시드로 넣었는데 검색량이 minVolume 미만이라고 필터링해버리면, 연관 키워드들은 있는데 중심 노드가 없는 그래프가 돼. 말 그대로 뿌리 없는 나무야."
"필터링 다음은 그룹 할당이야. 여기서 핵심은 시드 등장 순서를 그룹 번호로 쓴다는 거야. seedKeywords 배열에서 0번째 시드는 그룹 0, 1번째 시드는 그룹 1, 이런 식으로. 그러면 일반 키워드는 어떤 그룹에 넣을까? 그 키워드가 처음 등장한 시드 파일의 그룹 번호를 받아. Step 3에서 만들어둔 keywordFirstSeed가 여기서 빛을 발하는 거지."
"왜 마지막 등장 시드가 아니라 '첫 등장 시드'를 기준으로 했는지 궁금하지 않아? 파일 처리 순서가 감자빵.json -> 강황.json -> 고구마.json이라고 치자. '감자빵요리법'이라는 키워드가 감자빵 파일에서도 나오고 강황 파일에서도 나왔어. 이 키워드의 그룹을 '강황'(마지막)으로 할당하면, 사용자가 보기에 직관에 어긋나거든. 감자빵 연관 키워드인데 강황 색상으로 표시되니까. 첫 등장 기준이면 가장 직관적인 소속을 반영할 가능성이 높아."
"자, 코드로 들어가자."
3. 구현
3.1 Phase 2 & Phase 3 전체 코드
먼저 전체 코드를 보고, 이후 각 부분을 상세히 분석합니다.
// src/lib/buildGraph.ts (70-97행)
// ── Phase 2: 검색량 필터링 ──
const filteredNodeIds = new Set<string>();
for (const [id, node] of nodeMap) {
if (node.totalVolume >= minVolume) {
filteredNodeIds.add(id);
}
}
// seed 키워드는 항상 포함
for (const seed of seedKeywords) {
if (nodeMap.has(seed)) {
filteredNodeIds.add(seed);
}
}
// ── Phase 3: 그룹 할당 ──
const seedIndexMap = new Map<string, number>();
seedKeywords.forEach((s, i) => seedIndexMap.set(s, i));
for (const id of filteredNodeIds) {
const node = nodeMap.get(id)!;
if (node.isSeed) {
node.group = seedIndexMap.get(id) ?? 0;
} else {
const firstSeed = keywordFirstSeed.get(id) ?? seedKeywords[0];
node.group = seedIndexMap.get(firstSeed) ?? 0;
}
}이 코드가 하는 일을 한 문장으로 요약하면: nodeMap에서 검색량 기준으로 키워드를 걸러내고, 남은 키워드에 시드별 그룹 번호를 매기는 것입니다. Phase 1의 결과물을 Phase 4(링크 생성)에서 쓸 수 있는 상태로 가공하는 중간 단계입니다.
3.2 Phase 2 -- 검색량 필터링
filteredNodeIds: Set을 선택한 이유
// src/lib/buildGraph.ts (71행)
const filteredNodeIds = new Set<string>();이준혁: "여기서
new Array<string>()가 아니라new Set<string>()을 쓴 거 보이지? 왜 그런지 비교해볼게."
| 연산 | Array | Set |
|---|---|---|
| 중복 추가 방지 | 직접 체크 필요 (includes()) | 자동 무시 |
특정 값 존재 확인 (has) | O(n) -- 전체 순회 | O(1) -- 해시 기반 |
| 순서 보장 | O (인덱스 기반) | O (삽입 순서) |
필터링 결과를 Set으로 만들어두면, 나중에 Phase 4의 링크 생성에서 filteredNodeIds.has(rel)을 수천 번 호출할 때 성능 이점이 생깁니다. 키워드가 2,000개라고 가정하면, 배열 기반 includes()는 최악의 경우 매번 2,000번 비교하지만, Set의 has()는 상수 시간에 끝납니다.
검색량 기준 필터링
// src/lib/buildGraph.ts (73-77행)
for (const [id, node] of nodeMap) {
if (node.totalVolume >= minVolume) {
filteredNodeIds.add(id);
}
}nodeMap은 Map<string, KeywordNode>이므로, for...of로 순회하면 [key, value] 쌍을 구조 분해할 수 있습니다. totalVolume(PC + 모바일 검색량 합산)이 minVolume 이상인 키워드만 filteredNodeIds에 추가합니다.
구체적인 예시로 추적해봅시다. minVolume이 1000이라고 가정합니다:
nodeMap 순회:
"감자빵" totalVolume: 21820 → 21820 >= 1000 ✓ filteredNodeIds에 추가
"감자빵만들기" totalVolume: 8500 → 8500 >= 1000 ✓ filteredNodeIds에 추가
"감자빵레시피" totalVolume: 350 → 350 >= 1000 ✗ 탈락
"강황" totalVolume: 15200 → 15200 >= 1000 ✓ filteredNodeIds에 추가
"강황가루" totalVolume: 600 → 600 >= 1000 ✗ 탈락
..."감자빵레시피"와 "강황가루"는 검색량이 1,000 미만이라 탈락합니다. 하지만 여기서 끝이 아닙니다.
시드 키워드 보존
// src/lib/buildGraph.ts (78-83행)
// seed 키워드는 항상 포함
for (const seed of seedKeywords) {
if (nodeMap.has(seed)) {
filteredNodeIds.add(seed);
}
}이준혁: "이 루프가 왜 검색량 필터링 뒤에 오는지 눈여겨봐. 순서가 중요해."
검색량 필터링만으로는 시드 키워드가 빠질 수 있습니다. 극단적인 예로, "아몬드가루"라는 시드 키워드의 totalVolume이 800이고 minVolume이 1000이면, 위의 필터링 루프에서 탈락해요. 하지만 이 보존 루프가 뒤따라오면서 다시 추가합니다.
Set의 특성 덕분에 이 구현이 깔끔해집니다. "감자빵"처럼 검색량도 높고 시드이기도 한 키워드는 첫 번째 루프에서 이미 추가되었지만, 두 번째 루프에서 다시 add()해도 중복이 발생하지 않습니다. Set이 알아서 무시하니까 조건 분기 없이 그냥 넣으면 됩니다.
nodeMap.has(seed) 체크는 방어적 코딩입니다. 시드 키워드 이름으로 파일은 있지만, 그 파일의 JSON 데이터 안에 시드 키워드 자체가 relKeyword로 등장하지 않는 경우를 대비한 것입니다.
필터링 완료 후 filteredNodeIds:
{ "감자빵", "감자빵만들기", "강황", "고구마", "아몬드가루", ... }
↑
검색량 800이지만 시드이므로 보존됨3.3 Phase 3 -- 그룹 할당
seedIndexMap: 시드 등장 순서를 번호로 매핑
// src/lib/buildGraph.ts (86-87행)
const seedIndexMap = new Map<string, number>();
seedKeywords.forEach((s, i) => seedIndexMap.set(s, i));seedKeywords 배열의 인덱스를 그대로 그룹 번호로 사용합니다. forEach의 두 번째 인자 i가 인덱스입니다.
실제 데이터 파일 기준으로 추적하면:
seedKeywords = ["가라아게", "감자빵", "강황", "계란", "고구마", ...]
↓ ↓ ↓ ↓ ↓
seedIndexMap: "가라아게"→0 "감자빵"→1 "강황"→2 "계란"→3 "고구마"→4 ...이준혁: "왜 바로
seedKeywords.indexOf(seed)를 쓰지 않고 Map을 따로 만들었냐고?indexOf()는 호출할 때마다 배열을 처음부터 탐색하거든. 시드가 51개인데 필터링된 키워드가 500개면,indexOf()를 500번 호출하는 거야. 매번 최악의 경우 51개를 탐색하니까 총 25,500번 비교. Map의get()은 O(1)이니까 500번이면 끝. 한 번의 전처리로 반복 연산을 줄이는 전형적인 룩업 테이블 패턴이야."
그룹 할당 분기: 시드 노드 vs 일반 노드
// src/lib/buildGraph.ts (89-97행)
for (const id of filteredNodeIds) {
const node = nodeMap.get(id)!;
if (node.isSeed) {
node.group = seedIndexMap.get(id) ?? 0;
} else {
const firstSeed = keywordFirstSeed.get(id) ?? seedKeywords[0];
node.group = seedIndexMap.get(firstSeed) ?? 0;
}
}이 루프는 필터링을 통과한 키워드에만 그룹 번호를 매깁니다. 두 가지 경우로 나뉩니다.
경우 1: 시드 키워드 (node.isSeed === true)
node.group = seedIndexMap.get(id) ?? 0;시드 키워드는 자기 자신의 인덱스를 그룹 번호로 받습니다.
"감자빵" (isSeed: true) → seedIndexMap.get("감자빵") → 1 → group = 1
"강황" (isSeed: true) → seedIndexMap.get("강황") → 2 → group = 2경우 2: 일반 키워드 (node.isSeed === false)
const firstSeed = keywordFirstSeed.get(id) ?? seedKeywords[0];
node.group = seedIndexMap.get(firstSeed) ?? 0;일반 키워드는 처음 등장한 시드 파일을 기준으로 그룹이 결정됩니다. Step 3에서 만든 keywordFirstSeed가 여기서 사용됩니다.
파일 처리 순서: 가라아게.json → 감자빵.json → 강황.json → ...
"감자빵만들기" → 감자빵.json에서 첫 등장
→ keywordFirstSeed.get("감자빵만들기") → "감자빵"
→ seedIndexMap.get("감자빵") → 1
→ group = 1 (감자빵과 같은 그룹, 같은 색상)
"강황가루" → 강황.json에서 첫 등장
→ keywordFirstSeed.get("강황가루") → "강황"
→ seedIndexMap.get("강황") → 2
→ group = 2 (강황과 같은 그룹, 같은 색상)
"고구마요리" → 감자빵.json에서 첫 등장, 고구마.json에서도 등장
→ keywordFirstSeed.get("고구마요리") → "감자빵" (첫 등장 기준!)
→ seedIndexMap.get("감자빵") → 1
→ group = 1 (감자빵 그룹)이준혁: "마지막 예시 봐. '고구마요리'가 감자빵 파일에서 먼저 나왔으면, 고구마 파일에서도 나오더라도 감자빵 그룹이야. 이게 '첫 등장 시드 기준'의 의미야. 완벽하지는 않지만, 파일 처리 순서상 첫 번째로 만난 시드가 그 키워드와 가장 관련 깊을 확률이 높아. 만약 '마지막 등장 시드'를 기준으로 하면, 파일 처리 순서에 따라 결과가 뒤집혀서 직관에 어긋나는 경우가 더 많아지거든."
nullish coalescing (??) -- 안전한 폴백
코드에서 ?? 연산자가 세 곳에 등장합니다. 각각의 폴백이 왜 필요한지 짚어봅시다.
// (1) 시드 키워드의 그룹
node.group = seedIndexMap.get(id) ?? 0;seedIndexMap에 해당 시드가 없는 경우를 대비합니다. 정상적인 흐름에서는 발생하지 않지만, 타입 안전성을 위해 Map.get()이 undefined를 반환할 수 있음을 처리합니다. 폴백 값 0은 첫 번째 그룹입니다.
// (2) 일반 키워드의 첫 등장 시드
const firstSeed = keywordFirstSeed.get(id) ?? seedKeywords[0];keywordFirstSeed에 해당 키워드가 없는 경우, 첫 번째 시드 키워드를 폴백으로 사용합니다. "어디 소속인지 모르겠으면 일단 첫 번째 그룹에 넣어라"라는 전략입니다.
// (3) 첫 등장 시드의 그룹 번호
node.group = seedIndexMap.get(firstSeed) ?? 0;firstSeed가 seedIndexMap에 없는 경우의 최종 안전망입니다. ??가 없으면 node.group에 undefined가 들어가서 시각화 레이어에서 런타임 오류가 발생할 수 있습니다.
이준혁: "
??를 '혹시 모르니까'라고 대충 넣은 게 아니야. TypeScript의strict모드에서Map.get()은 반환 타입이V | undefined거든.??없이node.group = seedIndexMap.get(id)라고 쓰면 컴파일 에러가 나. 타입 시스템이 강제하는 안전장치이고, 동시에 '예외 상황에서 어떻게 행동할지'를 명시하는 문서 역할도 해."
4. 핵심 포인트 정리
-
필터링 결과를
Set<string>으로 저장하면 중복 방지와 O(1) 조회를 동시에 얻는다. 배열의includes()는 O(n)이지만, Set의has()는 O(1)이다. 이후 Phase 4에서filteredNodeIds.has()를 수천 번 호출하므로 성능 차이가 누적된다. -
시드 키워드 보존은 필터링의 불변 조건이다. 시드는 분석의 출발점이므로 검색량이 0이더라도 그래프에서 빠지면 안 된다. Set의 중복 무시 특성 덕분에 보존 로직이 조건 분기 없이 깔끔하게 구현된다.
-
seedIndexMap은 룩업 테이블 패턴의 전형이다. 시드 배열의 인덱스를 미리 Map에 저장해두면, 반복 루프 안에서indexOf()대신 O(1)get()으로 그룹 번호를 조회할 수 있다. -
그룹 할당은 "첫 등장 시드" 기준이다. 하나의 키워드가 여러 시드 파일에 등장할 수 있지만, 처음 만난 시드의 그룹을 따른다. 이는 파일 처리 순서상 가장 직관적인 소속을 반영하기 위한 설계 선택이다.
-
??(nullish coalescing)는 TypeScript strict 모드에서Map.get()의undefined가능성을 처리하는 필수 패턴이다. 단순한 방어 코딩이 아니라, 예외 상황에서의 폴백 전략을 코드에 명시하는 역할을 한다. -
Phase 2와 Phase 3는 nodeMap을 직접 수정(mutate)한다. 새로운 배열을 만드는 대신, 기존 노드 객체의
group필드를 덮어쓴다. 이는 의도적인 설계로, 불필요한 객체 복사를 피하고 Phase 4에서 nodeMap을 그대로 재사용할 수 있게 한다.
5. 다음 Step 예고
Step 5: 링크 생성 알고리즘
필터링과 그룹 할당이 끝났으니, 이제 키워드 간의 연결선(링크)을 만들 차례입니다. 시드와 연관 키워드 사이의 직접 엣지, 그리고 여러 시드 파일에 동시 출현한 키워드 쌍의 동시출현 엣지를 생성하고, 연결 강도를 계산하는 Phase 4를 다룹니다.