| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- databricks
- 스파크
- materialized view
- Data Engineering
- Ontology
- metric view
- unity ai gateway
- alert
- 딥러닝
- networkx
- auto tagging
- 하이브
- ETL
- Spark
- Model serving
- AWS
- 데이터엔지니어링
- MV
- hive
- semantic view
- Data pipeline
- 지식그래프
- AI
- silver layer
- sencor
- 데이터브릭스
- view
- DeepLearning
- optimization
- 관리 테이블
- Today
- Total
남는건 기록뿐
[Databricks] NetworkX로 지식그래프 만들기 본문
사내 데이터 검색기를 개발하면서 NetworkX로 메타데이터 지식그래프를 구축했습니다. 노드 4만 개, 관계 5.2만 개 규모입니다.
그 과정에서 처음 세운 계획이 측정 결과에 따라 두 차례 번복되었습니다. 타당해 보였던 기능을 접었고, 고칠 필요가 없는 부분을 고칠 뻔했습니다. 그 과정을 정리합니다.
목차
1. 왜 그래프였나
사내에 데이터 카탈로그는 이미 있었습니다. 테이블 목록도 있고, 컬럼 타입도 있고, 검색도 됩니다. 그럼에도 현업에서 데이터를 찾을 때는 결국 담당자에게 문의합니다.
이유는 단순합니다. 카탈로그가 제공하는 정보와 사용자가 필요로 하는 정보가 다릅니다.
| 카탈로그가 답하는 것 | 사용자가 알고자 하는 것 |
|---|---|
| 이 테이블에 어떤 컬럼이 있는가 | "주문 실적"을 확인하려면 어느 테이블을 봐야 하는가 |
| 타입이 무엇인가 | 그 테이블을 무엇과 조인해야 의미가 생기는가 |
| 소유자가 누구인가 | 이 데이터는 어디에서 파생된 것인가 |
| 다른 사용자는 이 데이터를 어떻게 활용하고 있는가 |
왼쪽은 테이블 하나만 확인하면 답이 나옵니다. 오른쪽은 테이블 사이의 관계를 알아야 답이 나옵니다.
관계를 다루는 데 적합한 자료구조는 그래프입니다. 그래서 그래프를 선택했습니다.
2. 왜 Neo4j가 아니라 NetworkX였나
그래프라고 하면 일반적으로 Neo4j를 먼저 떠올립니다. 저희도 검토했으나 NetworkX로 시작했습니다. 성능을 측정한 뒤 내린 결정이었습니다.
| 항목 | 측정값 |
|---|---|
| 그래프 로드 (4만 노드 / 5.2만 관계) | 74 ms |
| 메모리 사용 | 73 MB |
| 관계 조회 API 응답 | 6~43 ms |
이 규모에서 Neo4j를 도입하면 얻는 것은 없고 운영해야 할 서버만 하나 늘어납니다. 그래프 DB의 강점은 수백만 노드 규모에서 다중 홉 탐색을 수행할 때 발휘되는데, 저희 조회는 대부분 1~2홉입니다.
판단 기준
성능이 병목이 아니라면 인프라를 늘리지 않는다. 대신 이후에 교체할 수 있는 구조로 만들어 둔다.
이 "교체 가능한 구조"는 이후 실제 작업으로 이어졌습니다. 5장에서 다시 다룹니다.
3. 그래프 스키마 설계
노드 타입
import networkx as nx
G = nx.DiGraph()
G.add_node(
"table:mart_sales.core.fct_order_daily",
type="Table",
comment="일별 주문 실적",
)
G.add_node(
"col:mart_sales.core.fct_order_daily.ORDER_QTY",
type="Column",
data_type="decimal",
)
G.add_node("concept:주문실적", type="Concept", label="주문실적")
G.add_node("domain:영업", type="Domain", label="영업")
최종적으로 다음과 같이 구성되었습니다.
| 노드 타입 | 개수 | 역할 |
|---|---|---|
| Column | 38,649 | 컬럼 |
| Table | 1,134 | 테이블 |
| Concept | 226 | 업무 개념 (사용자가 쓰는 용어) |
| SavedQuery | 40 | 실제 활용 SQL |
| Domain | 8 | 업무 영역 |
| CalcPattern | 5 | 계산 패턴 |
핵심은 Concept입니다. 사용자는 "주문실적"이라고 표현하는데 테이블에는 fct_order_daily라고 정의되어 있습니다. 이 둘을 연결하는 노드가 없으면 그래프가 아무리 촘촘해도 검색이 되지 않습니다.
관계 타입
G.add_edge(
"concept:주문실적",
"table:mart_sales.core.fct_order_daily",
relation="IDENTIFIES",
)
G.add_edge(
"concept:주문실적",
"col:mart_sales.core.fct_order_daily.ORDER_QTY",
relation="MAPS_TO",
)
G.add_edge(
"table:mart_sales.core.fct_order_daily",
"table:mart_sales.core.dim_store",
relation="JOINS_ON",
join_cols="STORE_ID=STORE_ID",
)
| 관계 | 개수 | 의미 |
|---|---|---|
| HAS_COLUMN | 38,649 | 테이블 → 컬럼 |
| FILTER_BY | 5,356 | 개념 → 필터 조건 |
| JOINS_ON | 3,291 | 테이블 ↔ 테이블 |
| MAPS_TO | 3,071 | 개념 → 컬럼 |
| SYNONYM | 503 | 개념 ↔ 개념 |
| BELONGS_TO | 485 | 개념 → 도메인 |
| IDENTIFIES | 479 | 개념 → 테이블 |
| ANSWERS | 57 | 활용 SQL → 개념 |
이 구조에서는 "주문실적"이라는 용어 하나에서 테이블 → 컬럼 → 필터 조건 → 활용 SQL까지 연속적으로 탐색할 수 있습니다.
def tables_for_concept(G, concept):
return [
t
for _, t, d in G.out_edges(f"concept:{concept}", data=True)
if d["relation"] == "IDENTIFIES"
]
4. 부딪힌 문제 다섯 가지
여기서부터가 실제로 시간을 투입한 부분입니다.
문제 1. DiGraph인데 방향이 없는 관계가 있다
nx.DiGraph()는 방향 그래프입니다. 그런데 조인에는 방향이 없습니다. A JOIN B와 B JOIN A는 동일한 관계입니다.
문제는 원본 데이터가 일관되지 않았다는 점이었습니다.
joins = [
(a, b) for a, b, d in G.edges(data=True) if d["relation"] == "JOINS_ON"
]
print(len(joins)) # 3291
print(len({tuple(sorted(p)) for p in joins})) # 2572
3291 ÷ 2 = 1645.5가 나와야 하는데 2,572가 나왔습니다. 내용을 확인해 보니 다음과 같았습니다.
directed = set(joins)
undirected = {tuple(sorted(p)) for p in joins}
both = sum(
1 for a, b in undirected if (a, b) in directed and (b, a) in directed
)
one = len(undirected) - both
print(f"양방향 {both}쌍 / 단방향 {one}쌍") # 양방향 719쌍 / 단방향 1853쌍
print(f"검산 {both * 2 + one}") # 검산 3291
| 쌍 | 엣지 | |
|---|---|---|
| 양방향 모두 기록된 쌍 | 719 | 1,438 |
| 한 방향만 기록된 쌍 | 1,853 | 1,853 |
| 합계 | 2,572 | 3,291 |
1,853쌍은 한 방향으로만 저장되어 있었습니다. 이 상태로 조회하면 A에서는 B가 보이지만 B에서는 A가 보이지 않습니다.
해결: 조회 계층에서 무방향으로 정규화
def joins_of(G, table):
"""조인은 무방향이므로 들어오는 엣지와 나가는 엣지를 모두 확인한다."""
out = {
t
for _, t, d in G.out_edges(table, data=True)
if d["relation"] == "JOINS_ON"
}
inc = {
s
for s, _, d in G.in_edges(table, data=True)
if d["relation"] == "JOINS_ON"
}
return out | inc
관계 수를 집계할 때는 2,572를 사용합니다. 3,291은 파일상의 물리적 엣지 수이지, 업무적으로 구분되는 관계 수가 아닙니다.
화면에서도 동일한 원칙을 적용했습니다. 초기에는 모든 엣지에 화살표를 표시했는데, 이는 존재하지 않는 방향을 있는 것처럼 해석하게 만듭니다. 현재는 리니지(원천→파생)와 개념 매핑에만 화살표를 표시하고, 조인과 동의어는 화살표 없는 선으로 처리합니다.
문제 2. 속성으로 정의한 것은 역방향으로 조회할 수 없다
PK를 초기에는 노드 속성으로 정의했습니다.
G.add_node(
"table:mart_sales.core.fct_order_daily",
type="Table",
keys=["ORDER_ID", "STORE_ID", "ORDER_DT"], # 속성
)
동작에는 문제가 없습니다. "이 테이블의 키가 무엇인가"는 답할 수 있습니다.
G.nodes["table:mart_sales.core.fct_order_daily"]["keys"]
그러나 "STORE_ID를 사용하는 테이블은 어디인가"는 답할 수 없습니다. 전체 노드를 순회하며 속성을 탐색하는 방법밖에 없습니다. 그래프를 구축했음에도 그래프의 이점을 활용하지 못하는 상태입니다.
# 이 작업을 매번 수행해야 한다 — 노드 4만 개 전수 순회
[
n
for n, d in G.nodes(data=True)
if d.get("type") == "Table" and "STORE_ID" in d.get("keys", [])
]
해결: 로드 시점에 양방향 인덱스 구축
from collections import defaultdict
def build_key_index(G):
tables_by_key = defaultdict(list)
keys_by_table = {}
for n, d in G.nodes(data=True):
if d.get("type") != "Table" or not d.get("keys"):
continue
keys = sorted({k.upper() for k in d["keys"]})
keys_by_table[n] = keys
for k in keys:
tables_by_key[k].append(n)
return dict(tables_by_key), keys_by_table
551개 테이블에 375종의 키가 존재하며, 이제 양방향 조회가 가능합니다.
교훈
"이 방향으로만 조회할 것 같다"는 판단이 들더라도 관계로 정의하는 편이 낫습니다. 속성은 한 방향으로만 열리는 문입니다.
문제 3. 측정 결과가 계획을 뒤집었다 — 키 공유는 조인이 아니다
키를 관계로 전환하면서 다음과 같은 기대를 했습니다.
동일한 키를 사용하는 테이블끼리는 조인이 가능할 것이다. 조인 후보를 자동으로 추천할 수 있겠다.
타당해 보였습니다. 그래서 구현 전에 측정했습니다.
import itertools
key_pairs = set()
for key, tables in tables_by_key.items():
for a, b in itertools.combinations(tables, 2):
key_pairs.add(tuple(sorted([a, b])))
verified = key_pairs & undirected # 실제 검증된 조인과의 교집합
print(len(key_pairs), len(verified)) # 71712 706
print(f"정밀도 {len(verified) / len(key_pairs):.1%}") # 정밀도 1.0%
71,712쌍 중 실제 조인은 706쌍. 정밀도 1%.
원인은 명확했습니다.
from collections import Counter
Counter({k: len(v) for k, v in tables_by_key.items()}).most_common(3)
# [('FACTORY_CD', 362), ('LINE_CD', 177), ('SPEC_CD', 165)]
FACTORY_CD 같은 공통 코드는 362개 테이블이 모두 보유하고 있습니다. 이를 조인 후보로 추천하면 7만 건의 노이즈가 발생합니다.
결정: 조인 추천 기능은 구현하지 않았다
대신 "이 키를 사용하는 테이블 목록" 조회만 남겼습니다. 그리고 이후에 동일한 아이디어가 다시 제기될 것을 고려해 측정 결과를 코드에 명시해 두었습니다.
def tables_with_key(key):
"""이 키를 사용하는 테이블 목록.
주의: 키 이름을 공유한다고 조인이 가능한 것은 아니다. 측정 결과 키를
공유하는 71,712쌍 중 검증된 조인은 706쌍(1%)에 불과하다. 여기서 반환되는
것은 '같은 코드 체계를 사용하는 테이블'이지 조인 후보가 아니다.
"""
return tables_by_key.get(key.upper(), [])
그래프를 구축할 때 가장 위험한 것은 그럴듯한 관계입니다. 연결할 수 있다고 해서 연결해서는 안 됩니다. 정밀도를 측정한 뒤에 추가해야 합니다.
문제 4. 리니지를 정규식으로 추출하면 날짜가 테이블이 된다
리니지는 초기에 뷰 정의문에서 정규식으로 테이블 참조를 추출했습니다.
refs = re.findall(r"(\w+\.\w+\.\w+)", view_definition)
이 방식에서는 다음과 같은 항목이 테이블로 인식됩니다.
| 추출된 항목 | 실제 정체 |
|---|---|
2014.2.19 |
주석에 기재된 날짜 |
analytics.information_schema.columns |
시스템 뷰 |
current_catalog |
SQL 함수 |
카탈로그 목록에 2025, 2014 같은 항목이 표시되는 것을 보고 확인했습니다.
해결: 컬럼 단위 리니지 테이블을 1차 소스로 전환
사내에 컬럼 단위 리니지 테이블이 이미 존재한다는 사실을 확인하여, 이를 1차 소스로 변경했습니다.
def looks_like_table(ref: str) -> bool:
"""정규식으로 추출한 참조에는 테이블이 아닌 항목이 포함된다."""
parts = ref.split(".")
if len(parts) != 3:
return False
if any(not p or p.isdigit() for p in parts): # 2014.2.19 같은 날짜
return False
return parts[1] != "information_schema"
def build_lineage(G, column_lineage_rows, view_refs, known_tables):
# 1차: 컬럼 단위로 기록된 정확한 관계
for src, src_col, tgt, tgt_col in column_lineage_rows:
if src in known_tables and tgt in known_tables:
G.add_edge(
src,
tgt,
relation="DERIVED_FROM",
source_column=src_col,
target_column=tgt_col,
)
# 2차: 뷰 정의에서 추출한 참조 — 1차가 포괄하지 못하는 부분을 보완
for table, refs in view_refs.items():
for ref in refs:
if looks_like_table(ref) and ref in known_tables:
G.add_edge(ref, table, relation="DERIVED_FROM")
| 이전 | 이후 | |
|---|---|---|
| 테이블 쌍 | 329 | 364 |
| 컬럼 단위 관계 | 0 | 18,427 |
여기서 주목할 점은 허위 참조 필터의 실제 효과가 0이었다는 사실입니다. 날짜 리터럴은 "인덱스에 실재하는 테이블만"이라는 조건에서 이미 제외되고 있었습니다.
실제 문제는 허위 참조가 아니라 커버리지 부족이었습니다. 필터는 방어 목적으로 유지했으나, 측정하지 않았다면 잘못된 지점을 개선하고 만족할 뻔했습니다.
문제 5. 사전을 전량 반영하면 노이즈가 된다
사내에 업무 용어사전이 있었습니다. 5,949건. 한글명·영문명·약어·설명이 86% 채워져 있었습니다.
검색 문제의 해법으로 보였습니다. "금형"으로 검색하면 0건이 나오는데, 사전에는 금형 = Mold가 정의되어 있고 컬럼에는 MOLD_CD가 존재하기 때문입니다.
적용한 결과 두 종류의 노이즈가 발생했습니다.
(1) 빈칸 표기가 검색어로 인식되었다
사전의 빈칸이 없음으로만 표기된 줄 알고 그것만 제외했는데, -도 혼재해 있었습니다. 그 결과 -가 검색 토큰이 되어 모든 설명에 매칭되었습니다.
몰드 마스터 --- MR_MOLD_INFO
↑ 여기에 매칭
한 단어를 검색했는데 무관한 50건이 반환되었습니다.
BLANK = re.compile(r"^(없음|해당\s*없음|n/a|na|null|[-–—.\s]+)$", re.I)
def is_blank(v: str) -> bool:
v = (v or "").strip()
return len(v) < 2 or bool(BLANK.match(v))
(2) 복합 용어에서 흔한 단어 하나만 일치해도 매칭되었다
"지구온난화지수"를 검색하면 무관한 테이블이 반환되었습니다. 영문명이 Global Warming Potential인데 global 하나만 일치해도 통과했기 때문입니다.
MAX_DOC_FREQ = 0.05 # 5%를 초과하는 테이블에 등장하는 토큰은 변별력이 없다
def usable(token, doc_freq, total_tables):
df = doc_freq.get(token, 0)
return 0 < df <= total_tables * MAX_DOC_FREQ
def english_tokens(entry, doc_freq, total_tables):
toks = [t for t in re.split(r"[^a-z0-9]+", entry.en.lower()) if len(t) >= 3]
# 복합 용어는 *모든* 단어가 인덱스에 존재해야 사용한다.
# 하나만 일치해도 통과시키면 "지구온난화지수"(Global Warming Potential)가
# global 하나로 무관한 테이블을 반환한다.
if toks and all(usable(t, doc_freq, total_tables) for t in toks):
return toks
return []
3,810개에서 1,489개로 감소했지만 정확해졌습니다.
| 검색어 | 필터 전 | 필터 후 |
|---|---|---|
| 지구온난화지수 | 무관한 결과 10건 | 0건 |
| 총계정원장 | 무관한 결과 2건 | 0건 |
| 금형 | 몰드 마스터 · 공용 몰드 (정확) | 유지 |
0건이 정답인 경우가 있습니다. 결과가 반환된다고 좋은 것이 아닙니다.
중간에 "메타데이터에 없는 용어 200개 중 88%가 결과를 반환한다"는 측정치를 긍정적으로 해석할 뻔했습니다. 그러나 88%는 오히려 이상한 수치입니다. 개별 항목을 확인해 보니 대부분 노이즈였습니다. 높은 재현율은 정밀도를 확인하기 전까지 아무런 의미가 없습니다.
5. NetworkX의 한계와 전환 검토
한계 1. 쿼리 언어가 없다
Neo4j에서는 다음과 같이 작성합니다.
MATCH (c:Concept {label:"주문실적"})-[:IDENTIFIES]->(t:Table)-[:JOINS_ON]-(o:Table)
RETURN o
NetworkX에서는 순회 로직을 직접 구현해야 합니다.
def joinable_tables_for_concept(G, concept):
result = set()
for t in tables_for_concept(G, concept):
result |= joins_of(G, t)
return result
한 줄짜리 Cypher가 두 개의 함수가 됩니다. 관계가 늘어날수록 이러한 코드가 누적됩니다.
한계 2. 전체가 메모리에 적재된다
현재는 73MB라 문제가 없으나, 컬럼 노드가 38,649개인 점을 고려하면 규모가 커질 때 가장 먼저 한계에 도달할 지점입니다.
대응: 경계를 차단했다
전환 가능성을 확보하려면 저장 구조가 외부로 노출되어서는 안 됩니다. 초기에는 API 계층에서 내부 자료구조를 직접 참조하고 있었습니다. 이 상태에서는 저장소를 변경하는 순간 어느 지점이 영향을 받는지 파악할 수 없습니다.
그래서 내부 구조를 모두 은닉하고 조회 메서드만 공개했습니다.
class KnowledgeGraph:
"""자료구조는 전부 비공개. 외부에서는 아래 조회 메서드만 사용한다 —
직접 참조하면 저장소를 교체할 때 영향 범위를 파악할 수 없다."""
def __init__(self, graph_path):
self._G = nx.node_link_graph(json.load(open(graph_path)))
self._tables_by_key, self._keys_by_table = build_key_index(self._G)
# ── 아래가 공개 계약 ──
def joins_of(self, table): ...
def keys_of(self, table): ...
def tables_with_key(self, key): ...
def columns_of(self, table): ...
def concepts_of(self, table): ...
13개 지점을 수정했습니다. 현재는 Neo4j로 전환하더라도 이 클래스 내부만 변경하면 되고, 외부에서는 joins_of()가 동일하게 동작합니다.
이것이 "확장을 위한 설계"의 실체라고 생각합니다. 미리 Neo4j를 도입하는 것이 아니라, 도입 시 변경해야 할 지점이 한곳에 모여 있도록 하는 것입니다.
6. 정리
최종 구성입니다.
| 계층 | 내용 |
|---|---|
| 업무 용어 | 업무 개념 226 · 동의어 503 · 용어사전 5,949건 |
| 구조 | 테이블 1,134 · 컬럼 38,649 · PK 375종 |
| 관계 | Join 2,572쌍 · 리니지 컬럼 단위 18,427건 |
| 의미 | 개념→컬럼 3,071 · 필터 조건 5,356 |
| 활용 | 활용 SQL 40건 · 계산 패턴 |
전체 4만 노드 · 5.2만 관계.
배운 것 세 가지
1. 그래프에서 어려운 것은 연결이 아니라 연결하지 않는 것
키 공유를 근거로 7만 쌍을 연결할 수 있었으나 정밀도가 1%였기에 연결하지 않았습니다. 사전 5,949건을 모두 반영할 수 있었으나 1,489건만 사용했습니다. 추가할 수 있는 관계와 추가해야 할 관계는 다릅니다.
2. 측정하기 전에는 무엇이 문제인지 알 수 없다
리니지에서 날짜 리터럴을 제외하면 개선될 것으로 예상했으나 효과가 0이었습니다. 실제 문제는 커버리지였습니다. 측정하지 않았다면 잘못된 지점을 개선하고 만족했을 것입니다.
3. 방향이 있는 관계와 없는 관계를 구분해야 한다
DiGraph를 사용한다고 해서 모든 관계에 방향이 있는 것은 아닙니다. 조인과 동의어는 방향이 없고, 리니지와 개념 매핑은 방향이 있습니다. 조회 계층과 화면 양쪽에서 이를 구분해야 사용자가 존재하지 않는 방향을 읽지 않습니다.
다음 과제
- 메타데이터 인덱스 커버리지 — 인덱스가 실제 카탈로그보다 좁다는 점을 확인했습니다.
- 검색 로그 수집 — 현재는 어떤 검색어가 0건을 반환하는지 파악할 방법이 없습니다. 사전이 임시로 공백을 메우고 있으나, 로그가 축적되면 그래프에 무엇을 추가해야 하는지 데이터가 알려줄 것입니다.