Step 3. 데이터 수집과 중복 제거
Step 3. 데이터 수집과 중복 제거
이 문서에서 다루는 내용: 51개의 시드 키워드 JSON 파일에서 약 13,000개의 키워드를 읽어들이면서 중복을 제거하고, 각 키워드가 어떤 시드와 연관되는지를 추적하는 5개의 자료구조(Map/Set)를 설계합니다.
1. 요구사항
PM 김도연:
"이준혁 님, Step 2에서 Rate Limiter까지 만들었으니 이제 데이터를 본격적으로 가공할 차례입니다."
"현재 data/raw/ 폴더에 시드 키워드별 JSON 파일이 51개 들어 있어요. 감자빵.json, 고구마.json, 강황.json 이런 식으로요. 각 파일에는 해당 시드 키워드의 연관 키워드 목록이 수백 개씩 담겨 있습니다."
"그런데 문제가 하나 있어요. 같은 키워드가 여러 파일에 중복으로 등장합니다. 예를 들어 '계란'이라는 키워드가 감자빵.json에도, 강황.json에도, 고구마.json에도 나와요. 총 25개 파일에서 등장하더라고요. 이런 중복을 제거하면서도 '이 키워드가 몇 개의 시드와 연관되는지' 같은 추적 정보는 꼭 유지해야 합니다."
"Step 1에서 설계한 RawKeywordEntry 타입을 KeywordNode 타입으로 변환하는 첫 단계라고 보시면 됩니다. 나중에 그래프 시각화에서 노드 크기나 색상을 결정할 때 이 정보가 쓰일 거예요."
2. 시니어의 접근 방식
시니어 이준혁 (8년차):
"좋아, 이번 단계는 그래프 빌드 함수의 첫 번째 Phase야. 전체 구조부터 먼저 보여줄게."
"buildGraph() 함수는 크게 **4단계(Phase)**로 구성돼:"
| Phase | 역할 | 이 문서에서 다루는 범위 |
|---|---|---|
| Phase 1 | 읽기 + 중복 제거 | 이번 Step에서 상세히 |
| Phase 2 | 검색량 필터링 | Step 4에서 다룸 |
| Phase 3 | 그룹 할당 | Step 4에서 다룸 |
| Phase 4 | 링크 생성 | Step 5에서 다룸 |
"이번 문서에서는 Phase 1만 집중적으로 파고들 거야. 근데 이 Phase 1이 나머지 Phase 전부의 기반이 되거든. 여기서 자료구조를 잘못 설계하면 뒤에서 전부 무너져."
"자, 질문 하나. 51개 파일에서 키워드를 모아야 하는데, 중복 검사를 어떻게 할 거야? 배열에 넣고 매번 includes()로 찾을 거야, 아니면 다른 방법이 있을까?"
"정답은 Map이야. 왜냐면 Array.includes()는 처음부터 끝까지 훑어야 하니까 O(n)이야. 키워드가 13,000개 넘으면 매번 13,000개를 훑어야 한다는 뜻이지. 반면 Map.has()는 해시 기반이라 O(1)이야. 키워드가 13만 개여도 한 번에 찾아."
"구체적인 숫자로 비교해볼까. 실제 데이터에서 고유 키워드가 13,875개고, 중복 포함 총 엔트리가 수만 개야. 매 엔트리마다 중복 검사를 하니까:"
Array 방식: ~수만 회 x 평균 ~7,000 비교 = 약 수억 회 연산
Map 방식: ~수만 회 x 1 비교 = 약 수만 회 연산"차이가 만 배 이상이야. 데이터가 작을 때는 체감이 안 되지만, 키워드 수가 늘어나면 바로 병목이 돼."
"그리고 하나 더. Phase 1에서는 Map과 Set을 총 5개 만드는데, 각각 역할이 달라. 왜 5개나 필요한지 궁금하지? 코드를 보면서 하나씩 설명할게."
3. 구현
3.1 Phase 1 전체 코드
먼저 전체 코드를 보고, 이후 각 부분을 상세히 분석합니다.
// src/lib/buildGraph.ts
import fs from 'fs';
import path from 'path';
import type { RawKeywordEntry, KeywordNode, KeywordLink, GraphData } from '@/types/graph';
const RAW_DIR = path.join(process.cwd(), 'data', 'raw');
export function buildGraph(minVolume: number = 1000): GraphData {
const files = fs.readdirSync(RAW_DIR).filter(f => f.endsWith('.json'));
// ── Phase 1: 읽기 + 중복 제거 ──
const nodeMap = new Map<string, KeywordNode>();
const seedToRelated = new Map<string, string[]>();
const seedKeywords: string[] = [];
// 각 키워드가 어느 seed 파일에 속하는지 추적 (동시출현 계산용)
const keywordToSeeds = new Map<string, Set<string>>();
// 키워드가 처음 등장한 seed 파일 (그룹 할당용)
const keywordFirstSeed = new Map<string, string>();
for (const file of files) {
const seedKeyword = path.basename(file, '.json').normalize('NFC');
seedKeywords.push(seedKeyword);
const raw: RawKeywordEntry[] = JSON.parse(
fs.readFileSync(path.join(RAW_DIR, file), 'utf-8')
);
const related: string[] = [];
for (const entry of raw) {
const kw = entry.relKeyword;
related.push(kw);
// 노드 생성 (첫 등장 시)
if (!nodeMap.has(kw)) {
nodeMap.set(kw, {
id: kw,
keyword: kw,
totalVolume: entry.monthlyPcQcCnt + entry.monthlyMobileQcCnt,
monthlyPcQcCnt: entry.monthlyPcQcCnt,
monthlyMobileQcCnt: entry.monthlyMobileQcCnt,
compIdx: entry.compIdx,
monthlyAvePcCtr: entry.monthlyAvePcCtr,
monthlyAveMobileCtr: entry.monthlyAveMobileCtr,
isSeed: false,
seedCount: 0,
group: 0,
});
}
const node = nodeMap.get(kw)!;
node.seedCount++;
if (kw === seedKeyword) {
node.isSeed = true;
}
// seed 파일 소속 추적
if (!keywordToSeeds.has(kw)) keywordToSeeds.set(kw, new Set());
keywordToSeeds.get(kw)!.add(seedKeyword);
// 첫 등장 seed 기록
if (!keywordFirstSeed.has(kw)) {
keywordFirstSeed.set(kw, seedKeyword);
}
}
seedToRelated.set(seedKeyword, related);
}
// ... Phase 2, 3, 4는 다음 Step에서 다룸3.2 임포트와 상수 선언
// src/lib/buildGraph.ts (임포트 부분)
import fs from 'fs';
import path from 'path';
import type { RawKeywordEntry, KeywordNode, KeywordLink, GraphData } from '@/types/graph';
const RAW_DIR = path.join(process.cwd(), 'data', 'raw');이준혁: "임포트에서
import type을 쓴 거 보여? 일반import랑 뭐가 다르냐면,import type은 컴파일 후 완전히 사라져. JavaScript 번들에 한 글자도 안 남는 거야. 타입은 런타임에 필요 없으니까, 번들 크기를 줄이는 좋은 습관이지."
"RAW_DIR은 process.cwd() 기준으로 data/raw 경로를 조합해. process.cwd()는 Node.js 프로세스가 실행된 디렉토리, 즉 프로젝트 루트를 가리키거든. path.join()으로 합치면 OS에 관계없이 올바른 경로가 만들어져."
3.3 함수 시그니처와 파일 탐색
// src/lib/buildGraph.ts (함수 시그니처 부분)
export function buildGraph(minVolume: number = 1000): GraphData {
const files = fs.readdirSync(RAW_DIR).filter(f => f.endsWith('.json'));이준혁: "
minVolume = 1000은 기본 매개변수야. 호출할 때 값을 안 넘기면 1000이 들어가. 이건 Phase 2(검색량 필터링)에서 쓰이는데, 월간 검색량이 1,000 미만인 키워드를 걸러내는 기준치야."
"fs.readdirSync()로 디렉토리를 읽고, .filter(f => f.endsWith('.json'))으로 JSON 파일만 골라내. 왜 필터링하냐고? data/raw/ 폴더에 .DS_Store 같은 숨김 파일이 섞여 있을 수 있거든. 이런 방어 코드가 없으면 JSON.parse()에서 터져."
실제로 data/raw/ 폴더에는 51개의 JSON 파일이 들어 있습니다:
data/raw/
├── 가라아게.json (시드: "가라아게")
├── 감자빵.json (시드: "감자빵", 273개 연관 키워드)
├── 강황.json (시드: "강황")
├── 계란.json (시드: "계란")
├── 고구마.json (시드: "고구마", 969개 연관 키워드)
├── 고구마말랭이.json
├── ...
└── (총 51개 파일)3.4 5개 자료구조의 역할
Phase 1의 핵심은 5개의 Map/Set 자료구조입니다. 각각이 어떤 질문에 답하는지 정리합니다.
// src/lib/buildGraph.ts (자료구조 선언 부분)
const nodeMap = new Map<string, KeywordNode>();
const seedToRelated = new Map<string, string[]>();
const seedKeywords: string[] = [];
const keywordToSeeds = new Map<string, Set<string>>();
const keywordFirstSeed = new Map<string, string>();이준혁: "5개나 되니까 복잡해 보이지? 각각이 어떤 질문에 답하는 자료구조인지 생각하면 쉬워."
| 자료구조 | 타입 | 답하는 질문 | 사용처 |
|---|---|---|---|
nodeMap | Map<string, KeywordNode> | "이 키워드의 노드 정보가 뭐야?" | 중복 제거 + 노드 조회 (Phase 1~4 전체) |
seedToRelated | Map<string, string[]> | "이 시드의 연관 키워드 목록이 뭐야?" | 링크 생성 (Phase 4) |
seedKeywords | string[] | "시드 키워드 순서가 어떻게 돼?" | 그룹 번호 할당 (Phase 3) |
keywordToSeeds | Map<string, Set<string>> | "이 키워드가 어느 시드들에 속해?" | 동시출현 엣지 계산 (Phase 4) |
keywordFirstSeed | Map<string, string> | "이 키워드가 처음 등장한 시드가 뭐야?" | 그룹 할당 (Phase 3) |
이준혁: "
keywordToSeeds의 값이Set<string>인 게 중요해. 같은 시드가 두 번 추가되는 걸 자동으로 막아주거든. 예를 들어감자빵.json파일을 처리할 때 어떤 키워드가 두 번 나와도, Set이니까'감자빵'은 한 번만 저장돼."
"그리고 seedKeywords만 유일하게 배열이야. 왜냐면 순서가 중요하기 때문이지. 나중에 Phase 3에서 '감자빵'은 그룹 0, '강황'은 그룹 1, '계란'은 그룹 2... 이런 식으로 인덱스 순서대로 그룹 번호를 부여하거든. Map이나 Set은 삽입 순서를 보장하긴 하지만, 인덱스로 접근하려면 배열이 더 자연스럽지."
3.5 한글 NFC 정규화
// src/lib/buildGraph.ts (시드 키워드 추출 부분)
for (const file of files) {
const seedKeyword = path.basename(file, '.json').normalize('NFC');
seedKeywords.push(seedKeyword);path.basename('감자빵.json', '.json')은 확장자를 제거하고 '감자빵'을 반환합니다. 그런데 뒤에 .normalize('NFC')가 붙어 있습니다.
이준혁: "이건 macOS에서 작업하면 반드시 필요한 부분이야. 한글의 유니코드 표현 방식이 두 가지가 있거든."
NFC (조합형): '감' = U+AC10 (한 글자로 저장)
NFD (분해형): '감' = ㄱ + ㅏ + ㅁ = U+1100 + U+1161 + U+11B7 (자모로 분해)"macOS의 파일 시스템(APFS/HFS+)은 한글 파일명을 NFD로 저장해. 그런데 JSON 파일 안의 relKeyword 값은 NFC로 되어 있어. 같은 '감자빵'인데 바이트 레벨에서 달라지는 거야."
파일명에서 추출한 '감자빵' (NFD): ㄱㅏㅁㅈㅏㅃㅏㅇ
JSON 안의 '감자빵' (NFC): 감자빵"이 둘을 ===로 비교하면 false가 나와. 그러면 시드 키워드인 '감자빵'이 자기 자신의 연관 키워드 목록에 있는데도 isSeed = true가 안 되는 버그가 생겨. .normalize('NFC')를 호출하면 둘 다 NFC로 통일되니까 이런 문제가 사라지는 거야."
이준혁: "이런 류의 버그가 제일 잡기 어려워. 눈으로 보면 똑같은 글자인데 비교하면 다르다고 나오거든. 한글 데이터를 다루는 프로젝트에서는 입구에서 한 번 정규화하는 게 국룰이야."
3.6 JSON 파싱과 노드 생성
// src/lib/buildGraph.ts (파일 읽기 + 노드 생성 부분)
const raw: RawKeywordEntry[] = JSON.parse(
fs.readFileSync(path.join(RAW_DIR, file), 'utf-8')
);
const related: string[] = [];
for (const entry of raw) {
const kw = entry.relKeyword;
related.push(kw);
// 노드 생성 (첫 등장 시)
if (!nodeMap.has(kw)) {
nodeMap.set(kw, {
id: kw,
keyword: kw,
totalVolume: entry.monthlyPcQcCnt + entry.monthlyMobileQcCnt,
monthlyPcQcCnt: entry.monthlyPcQcCnt,
monthlyMobileQcCnt: entry.monthlyMobileQcCnt,
compIdx: entry.compIdx,
monthlyAvePcCtr: entry.monthlyAvePcCtr,
monthlyAveMobileCtr: entry.monthlyAveMobileCtr,
isSeed: false,
seedCount: 0,
group: 0,
});
}이준혁: "여기가 RawKeywordEntry에서 KeywordNode로의 변환이 일어나는 지점이야. Step 1에서 설계한 두 타입이 드디어 만나는 거지."
핵심은 if (!nodeMap.has(kw)) 조건입니다. 구체적인 숫자로 동작을 추적해봅시다.
[1단계] 감자빵.json 처리 중 -- entry: { relKeyword: '계란', monthlyPcQcCnt: 770, monthlyMobileQcCnt: 3760 }
nodeMap.has('계란') -> false (처음 등장)
nodeMap.set('계란', {
id: '계란',
keyword: '계란',
totalVolume: 770 + 3760 = 4530, // <-- 감자빵.json에서의 검색량
seedCount: 0,
isSeed: false,
...
})
[2단계] 고구마.json 처리 중 -- entry: { relKeyword: '계란', monthlyPcQcCnt: 770, monthlyMobileQcCnt: 3760 }
nodeMap.has('계란') -> true (이미 존재!)
-> 노드를 새로 만들지 않음
-> 기존 노드의 값(totalVolume 등)을 덮어쓰지 않음
-> 첫 번째 파일에서 만든 노드를 그대로 유지
[3단계] 계란.json 처리 중 -- entry: { relKeyword: '계란', monthlyPcQcCnt: 28200, monthlyMobileQcCnt: 129100 }
nodeMap.has('계란') -> true (이미 존재!)
-> 여전히 노드를 새로 만들지 않음
-> totalVolume은 처음 값인 4530을 유지 (계란.json의 157,300이 아님!)이준혁: "여기서 한 가지 의문이 들 수 있어. '계란' 키워드가
계란.json에서는 검색량이 157,300인데,감자빵.json에서 먼저 만들어졌으니까 4,530으로 고정돼 있잖아. 이게 괜찮은 건가?"
"실제로는 같은 키워드의 검색량은 어느 파일에서 가져와도 동일한 경우가 대부분이야. 네이버 API가 동일 키워드에 대해 같은 값을 내려주거든. 만약 시점 차이로 값이 달라지더라도, 이 프로젝트에서는 '첫 등장 값을 기준으로 삼는다'는 설계 결정을 한 거야. 완벽하지는 않지만, 시각화 용도로는 충분해."
3.7 seedCount 증가와 isSeed 플래그
// src/lib/buildGraph.ts (seedCount 및 isSeed 부분)
const node = nodeMap.get(kw)!;
node.seedCount++;
if (kw === seedKeyword) {
node.isSeed = true;
}seedCount는 노드 생성과 무관하게, 키워드가 등장할 때마다 증가합니다. 구체적으로 추적해봅시다:
'계란' 키워드의 seedCount 변화:
감자빵.json 처리 -> seedCount: 0 → 1 (노드 생성 직후 첫 증가)
강황.json 처리 -> seedCount: 1 → 2
계란.json 처리 -> seedCount: 2 → 3, isSeed = true! ← 자기 파일에서 등장
고구마.json 처리 -> seedCount: 3 → 4
...
(총 25개 파일에서 등장하므로 최종 seedCount = 25)이준혁: "
seedCount가 높을수록 **여러 시드와 교차 연관되는 '허브 키워드'**라는 뜻이야. 실제 데이터를 보면 '냉동고구마'가 28개 파일에서 등장하고, '쌀20KG'가 27개 파일에서 등장해. 이런 키워드는 나중에 그래프에서 여러 클러스터를 연결하는 다리 역할을 하게 되지."
"isSeed 플래그는 더 간단해. 계란.json 파일을 처리할 때, 연관 키워드 목록에 '계란' 자기 자신이 포함되어 있으면 true로 마킹하는 거야. 시드 키워드는 그래프에서 각 클러스터의 중심 노드가 되거든. 시각적으로 다르게 표현할 때 이 플래그를 쓰게 돼."
3.8 시드 소속 추적
// src/lib/buildGraph.ts (시드 소속 추적 부분)
// seed 파일 소속 추적
if (!keywordToSeeds.has(kw)) keywordToSeeds.set(kw, new Set());
keywordToSeeds.get(kw)!.add(seedKeyword);
// 첫 등장 seed 기록
if (!keywordFirstSeed.has(kw)) {
keywordFirstSeed.set(kw, seedKeyword);
}
}
seedToRelated.set(seedKeyword, related);
}마지막 부분은 두 가지 추적 정보를 기록합니다.
keywordToSeeds -- "이 키워드가 어느 시드들에 속하는가"
'계란'의 keywordToSeeds 변화:
감자빵.json 처리 -> Set { '감자빵' }
강황.json 처리 -> Set { '감자빵', '강황' }
계란.json 처리 -> Set { '감자빵', '강황', '계란' }
고구마.json 처리 -> Set { '감자빵', '강황', '계란', '고구마' }
...
(최종: 25개 시드를 담은 Set)이준혁: "이 Set이 왜 필요하냐고? Phase 4에서 동시출현 엣지를 만들 때 써. 두 키워드가 같은 시드 파일에 2번 이상 함께 등장하면 연결선을 긋는 건데, 그때 두 키워드의
keywordToSeedsSet의 교집합 크기를 구해야 하거든. Set이 없으면 이 계산이 엄청 복잡해져."
keywordFirstSeed -- "이 키워드가 처음 등장한 시드가 무엇인가"
'계란'의 keywordFirstSeed: '감자빵' (감자빵.json에서 처음 등장했으므로)
'냉동고구마'의 keywordFirstSeed: '강황' (강황.json에서 처음 등장했으므로)이준혁: "이건 Phase 3의 그룹 할당에서 쓰여. 시드가 아닌 일반 키워드는 처음 등장한 시드의 그룹에 배정돼. 예를 들어 '계란'이 '감자빵' 파일에서 처음 나왔으면, '감자빵' 그룹의 색상을 받게 되는 거야. 단순한 규칙이지만, 시각적으로 의미 있는 클러스터링이 만들어져."
seedToRelated -- 파일별 연관 키워드 목록 저장
seedToRelated.set(seedKeyword, related);이준혁: "루프 마지막에
seedToRelated에 해당 시드의 연관 키워드 목록을 통째로 저장해. 이때related배열에는 중복 제거가 안 된 원본 그대로가 들어가 있어. 중복 제거는nodeMap이 담당하고,seedToRelated는 '이 시드 파일에 어떤 키워드들이 있었는지' 원본 관계를 보존하는 역할이야. Phase 4에서 seed와 연관 키워드 사이에 링크를 그을 때 이 정보가 필요하거든."
3.9 Phase 1 완료 후 자료구조 상태
51개 파일을 모두 처리한 후, 5개 자료구조의 상태를 정리하면 다음과 같습니다:
nodeMap: 13,875개 엔트리 (고유 키워드 수)
seedToRelated: 51개 엔트리 (시드 파일 수)
seedKeywords: 51개 요소 (시드 키워드 배열)
keywordToSeeds: 13,875개 엔트리 (각 키워드별 소속 시드 Set)
keywordFirstSeed: 13,875개 엔트리 (각 키워드별 첫 등장 시드) 51개 JSON 파일
│
v
┌────── Phase 1 ──────┐
│ │
│ for (file of files)│
│ for (entry) │
│ │ │
│ v │
│ nodeMap에 노드 생성│ ← 중복이면 skip, seedCount만 증가
│ keywordToSeeds 갱신│ ← Set으로 소속 시드 추적
│ keywordFirstSeed │ ← 첫 등장 시드만 기록
│ │ │
│ v │
│ seedToRelated 저장│ ← 파일 처리 완료 시
│ │
└─────────────────────┘
│
v
Phase 2, 3, 4로 전달
(다음 Step에서 계속)4. 핵심 포인트 정리
-
Map은 O(1) 조회, Array는 O(n) 조회이다. 13,875개의 키워드를 수만 번 중복 검사할 때,
Map.has()와Array.includes()의 성능 차이는 만 배 이상이다. 대규모 데이터 처리에서 자료구조 선택은 곧 성능이다. -
한글 파일명은 반드시
.normalize('NFC')로 정규화해야 한다. macOS 파일 시스템은 한글을 NFD(분해형)로 저장하지만, JSON 데이터 안의 한글은 NFC(조합형)인 경우가 많다. 정규화 없이 비교하면 같은 글자가 다르다고 판정되는 버그가 발생한다. -
"첫 등장 시 생성, 이후 등장 시 카운트만 증가"가 중복 제거의 핵심 패턴이다.
nodeMap.has(kw)로 존재 여부를 O(1)로 확인하고, 없으면 생성, 있으면seedCount++만 수행한다. 노드의 원본 데이터(검색량 등)는 첫 등장 값이 유지된다. -
5개 자료구조는 각각 서로 다른 질문에 답한다.
nodeMap은 "이 키워드 노드 정보가 뭐야?",keywordToSeeds는 "이 키워드가 어느 시드들에 속해?",keywordFirstSeed는 "이 키워드의 첫 등장 시드는?" -- 각각의 역할이 명확히 분리되어 있어야 Phase 2~4에서 효율적으로 참조할 수 있다. -
seedCount는 키워드의 교차 연관도를 나타내는 핵심 지표이다. 여러 시드 파일에 동시에 등장하는 키워드(예: '냉동고구마' -- 28개 파일)는 그래프에서 클러스터 간 허브 역할을 하며, 이 값으로 링크 강도를 계산한다. -
import type은 런타임 번들에서 완전히 제거된다. 타입만 가져올 때import type을 사용하면 JavaScript 빌드 결과물에 불필요한 코드가 포함되지 않는다. TypeScript 프로젝트에서 타입과 값의 임포트를 구분하는 좋은 습관이다.
5. 다음 Step 예고
Step 4: 필터링과 그룹 할당
Phase 1에서 수집한 13,875개의 노드 중 검색량이 기준치 이하인 키워드를 걸러내고(Phase 2), 남은 노드에 시드별 그룹 번호를 부여하여(Phase 3) 시각화에서 색상 클러스터링의 기반을 마련합니다.