Engineering · Knowledge Graph

10만 노드 지식 그래프 뷰어: 렌더링 최적화에서 '무엇을 그릴 것인가'로 옮겨 간 문제

온톨로지 그래프를 브라우저에서 탐색하려고 Three.js InstancedMesh 렌더러와 Web Worker 물리 연산을 만들었지만, 컬렉션이 커지자 병목은 무엇을 그릴지로 옮겨 갔습니다. CDP 프로파일링으로 리프 노드가 비용의 대부분임을 확인해 구조 우선 샘플링으로 CPU 점유를 25%에서 7%로 낮추고, 렌더 상한 밖의 노드는 서버 검색으로 찾아 불러오게 했습니다.

기간
2026.04 – 2026.10
소속
플래티어 · XGEN
역할
설계·구현(프론트엔드·백엔드)

Key Results

7,469 → 2,652
렌더링 노드 수2만 2,001노드 시험 컬렉션, 비율(1/3) 정책 → 구조 우선 정책
25% → 7%
브라우저 CPU 점유같은 컬렉션, CDP 프로파일러 약 22초 창(유휴 74.9% → 93.1%)
4~125ms
컬렉션 전체 노드 검색 응답로컬 PostgreSQL 실제 함수 호출, label ILIKE 검색, 첫 조회 553ms 제외

온톨로지 그래프 뷰어는 두 가지 일을 합니다. 빌드 결과를 눈으로 검증하게 하고, GraphRAG 검색이 어떤 노드를 근거로 답했는지 그래프 위에 보여 줍니다. 초기에는 D3.js SVG 기반 2D 뷰였는데, 컬렉션이 커지면서 노드 수가 수만을 넘었고 가장 큰 규정 문서 컬렉션은 10만 8,703개였습니다.

이 글은 2026년 4월부터 10월까지 뷰어를 다룬 기록입니다. 처음에는 렌더링 속도의 문제로 보고 엔진과 물리 연산을 직접 만들었습니다. 그다음에는 보이는 것이 정확한지(클릭과 근거 하이라이트)를 고쳤고, 9월에 측정해 보니 문제는 이미 "어떻게 빨리 그리나"가 아니라 "무엇을 그리나"로 옮겨 가 있었습니다. 마지막으로 그리지 않기로 한 노드도 찾을 수 있게 했습니다.

뷰어의 구조와 규모

문서 서비스 API
  GET /graph/{id}?limit&focus    ─▶ PostgreSQL 노드·간선 테이블에서 뷰 선택
  GET /graph/{id}/search-nodes   ─▶ 컬렉션 전체 label ILIKE
        │
프론트엔드 그래프 섹션
  ├─ 3D 엔진: Three.js InstancedMesh + 커스텀 GLSL 셰이더
  │     └─ Web Worker: d3-force-3d 물리 연산, 좌표를 Float32Array 로 전송
  ├─ 2D 엔진: Louvain 군집 · 커뮤니티 접기
  └─ 노드 카드 · 구조 패널 · 근거 하이라이트

규모는 컬렉션마다 크게 달랐습니다. 공공기관 문서로 만든 시험 컬렉션이 2만 2,001개(재빌드 후 2만 8,687개), 같은 기관의 운영 컬렉션이 5만 2,720개, 규정 문서 컬렉션이 10만 8,703개 노드였습니다. 브라우저 탭 하나가 이 범위를 감당해야 했습니다.

라이브러리로 시작해 하루 만에 엔진을 직접 만들었습니다

4월 2일 D3 SVG 그래프를 react-force-graph(3D는 Three.js/WebGL, 2D는 Canvas)로 바꾸고 2D/3D 토글을 추가했습니다. 다음 날 이 라이브러리를 걷어내고 Three.js 위에 커스텀 GPU 렌더러와 플로팅 노드 카드를 만들었습니다. 교체 사유는 기록에 직접 남아 있지 않습니다. 이후 작업을 보면 노드 카드, 블룸, 셰이더 수준의 연출과 상호작용을 직접 제어해야 했던 것으로 추정합니다.

엔진은 노드를 InstancedMesh로 그립니다. 같은 지오메트리의 노드 수천 개를 인스턴스로 묶어 한 번에 그리는 방식입니다. 노드 모양은 커스텀 GLSL 셰이더로 그렸고, 인스턴스 ID를 위상 시드로 써서 노드마다 어긋난 맥동을 주는 연출도 셰이더에서 처리했습니다. 6월 디자인 리뉴얼에서는 채운 구체를 빌보드 평면 링(삼각형 2개)과 additive 블렌딩으로 바꿔 노드당 지오메트리 비용을 줄이고, 별 필드와 성운 배경을 지워 더 가볍게 했습니다.

더 큰 문제는 물리 연산이었습니다. 대형 그래프에서 force simulation이 메인 스레드를 점유해 UI가 끊겼습니다. 4월 5일 시뮬레이션을 Web Worker로 옮기고 좌표를 Float32Array transferable로 넘겨 복사 없이 전달했습니다. 워커는 노드 수에 따라 동작을 바꿉니다.

노드 수 좌표 전송 주기
1,000 초과 2틱마다 1회
5,000 초과 3틱마다 1회
10,000 초과 5틱마다 1회

물리 계수도 노드 수 구간별로 조정합니다(charge −40 ~ −100, distanceMax 1,000 ~ 2,000, linkDistance 80 ~ 200, alphaDecay 0.05 ~ 0.08). 초기 배치는 평면 무작위가 아니라 반지름 100~300의 구면 셸에 둬서 3D 배치와 수렴 속도를 함께 잡았습니다.

보이는 것이 정확해야 했습니다

6월에는 성능보다 정확성 문제가 먼저 나왔습니다. 작은 노드나 가려진 노드가 클릭되지 않았습니다. 레이캐스트 방식이라 히트 구체가 작고 가림에 약했기 때문입니다. 픽킹을 스크린 공간(screen-space) 최근접 방식으로 바꿨습니다. 각 노드의 투영 반경에 최소 10px를 더한 범위를 클릭 반경으로 잡고, 커서 반경 안의 노드 중 카메라에 가장 가까운 것을 고릅니다. deck.gl이나 sigma.js가 쓰는 color picking을 CPU에서 하는 셈입니다. 노드를 클릭하면 카메라가 링 노드의 구멍 안으로 들어가 뷰 전체가 검게 보이던 문제는 선택 노드와 1홉 이웃의 경계 상자로 프레이밍하고 최소 거리를 둬서 고쳤습니다. 클릭 직후 탐색 이펙트가 1홉 강조를 빈 집합으로 덮어쓰던 회귀는 콘솔 계측(이웃 56 → 0)으로 찾았습니다.

근거 하이라이트는 더 근본적인 문제였습니다. 검색 결과를 보여 줄 때 붉게 칠해진 노드가 너무 많다는 피드백이 있었습니다. 백엔드가 근거를 주지 않으면 프론트엔드가 질문 키워드로 모든 노드를 부분일치시켜 칠하고 있었고, 그 대부분은 근거가 아니었습니다. 백엔드가 답에 실제로 등장한 노드만 evidence_nodes로 반환하고 프론트엔드는 그것만 칠하게 바꿨습니다. 44개를 나열하는 질문에서 정확히 그 44개가 칠해지는지로 확인했습니다.

같은 시기 2D 뷰는 카드 그리드를 차수 크기의 원 노드로 바꾸고, Louvain 군집을 직접 구현해 커뮤니티를 기본적으로 접어 보여 주게 했습니다. 작은 커뮤니티(6개 미만)와 검색 근거가 든 커뮤니티만 자동으로 펼칩니다. 2,000개가 넘는 노드가 점처럼 흩어지던 뷰가 읽을 수 있는 덩어리로 바뀌었습니다.

측정해 보니 비용의 대부분은 리프였습니다

9월 빌드 리팩토링 뒤 실제 데이터로 뷰어를 검수하자 두 가지가 드러났습니다.

첫째, 데이터 선택이 임의였습니다. 그래프 API가 ORDER BY id LIMIT 500으로 적재 순서 앞 500개를 내려줬고, 5만 2,720개 노드 컬렉션에서는 0.95%였습니다. 검색 근거 노드는 거의 항상 이 밖에 있어 하이라이트가 통째로 보이지 않았습니다. 근거 노드는 limit과 무관하게 항상 포함하는 focus 파라미터를 만들고, 절단 기준을 연결이 많은 노드 순으로 바꿨습니다. 이때 subject = uri OR object = uri 조인은 OR 때문에 인덱스를 타지 못해 5만 노드에서 순차 스캔이 됐으므로, 주어와 목적어를 따로 GROUP BY한 뒤 UNION했습니다.

둘째, 큰 컬렉션에서 렉이 있었습니다. 이번에는 고치기 전에 측정부터 했습니다. 2만 2,001개 노드 컬렉션에서 당시의 비율 정책(전체의 1/3)은 7,469개(클래스 382 + 리프 7,087)를 그렸고, CDP 프로파일러 21초 창에서 CPU를 25% 점유했습니다. 최상위 비용은 quadtree visit였습니다. 같은 페이로드와 같은 힘 설정으로 로컬 d3-force 하네스를 만들어 재현하니 Louvain은 38ms였지만 힘 정착에 46틱 8.4초(틱당 183ms)가 걸렸습니다.

데이터를 열어 보니 이유가 보였습니다. 큰 컬렉션 8개 모두에서 인스턴스의 90~100%가 instanceOf 하나만 가진 리프였습니다. 리프는 아무리 그려도 구조를 보여 주지 못하고 힘 계산 비용만 늘립니다. 렌더러를 더 빠르게 만드는 대신 리프를 덜 그리기로 했습니다.

선택지 무엇을 그리나 문제 판단
적재 순서 LIMIT 500 처음 적재된 500개 중요도와 무관한 조각, 근거가 밖에 있음 폐기
비율 정책(1/3) 전체의 1/3 대부분 리프, 7,469개에 CPU 25% 폐기
구조 우선 계층 예산 안에서 뼈대부터 개별 리프는 클릭·드릴다운으로 불러와야 함 채택
노드 예산 3,000 안이면 전부 그리고, 넘으면 계층으로 자른다
tier 0  클래스 · 속성 노드          (스키마 골격은 항상)
tier 1  실제 관계나 속성값이 있는 인스턴스
tier 2  클래스당 리프 표본 2개      (나머지는 클래스 노드의 instance_count 로 표시)

하네스로 구조 우선을 모사하면 1,807개 노드에서 1.4초(틱당 30ms)였습니다. 그리지 않는 리프의 수는 클래스 라벨 옆에 인스턴스 수로 표시하고, "노드 N개 중 M개만 표시" 배너는 지웠습니다. 렌더 상한은 렌더 부하를 위한 것이지 탐색 범위를 줄이려는 것이 아니므로, 검색은 상한과 무관하게 그래프 전체를 보게 했습니다.

전송량도 따로 봤습니다. 표 사실을 그래프에 넣은 뒤 재빌드하자 뷰 페이로드가 23MB까지 커졌습니다. 열 이름 속성을 클래스마다 선언해 속성 상속 전개와 곱해지면서 스키마 간선이 폭증한 것이 원인이었습니다(허브 클래스 하나 아래 자식이 301개였습니다). 뷰어는 스키마 간선을 기본으로 숨기므로 시뮬레이션에는 영향이 적었지만, 매번 그만큼을 받아야 했습니다. 속성 선언을 술어당 하나로 바꾸고 기본 빌드의 불필요한 선언을 걷어낸 뒤 다시 빌드하자 스키마 간선이 0, 페이로드가 2.1MB가 됐습니다.

뷰어는 빌드 품질을 비추는 거울이기도 했습니다. 군집 이름을 차수가 가장 높은 노드로 정하자, 옛 빌드가 남긴 잡음 클래스(인스턴스 687개를 거느린 한 글자 클래스, 숫자, 연도 표기 같은 이름)가 군집 이름으로 올라왔습니다. Louvain 상위 12개 군집이 노드의 36%(다른 컬렉션은 9%)만 덮어 나머지는 거대한 "기타"가 됐습니다. 원인을 둘로 나눴습니다. 라벨 자체의 잡음은 빌드의 적재 게이트에서 막고(기존 그래프는 재빌드 필요), 뷰어는 이름다운 노드를 군집 이름으로 고르게 했습니다. 재배포와 재빌드 뒤에는 이름 있는 군집이 49~59개가 됐습니다.

그리지 않은 노드도 찾을 수 있어야 했습니다

렌더 상한을 둔 뒤 새 문제가 생겼습니다. 노드 검색창과 관계 대상 검색이 이미 받은 노드 안에서만 찾았습니다. 10만 8,703개 노드 컬렉션에서 뷰에 실린 것은 2,973개였으니, 있는 노드도 검색되지 않았습니다. 상한은 유지하고 검색만 전체를 보게 하는 것이 원칙이었으므로 서버 검색 API를 만들었습니다.

검색은 컬렉션 전체에 label ILIKE를 걸되 LIKE 특수문자를 이스케이프해 "100%" 같은 검색어도 글자 그대로 찾습니다. 순서는 결정적으로 정했습니다(이름이 같은 것, 앞부분이 같은 것, 클래스·속성·개체 순, 짧은 이름 순). 결과는 20건(최대 50건)과 더 있는지 여부를 반환합니다. 문서 검색과 같은 읽기 권한 관문을 거치고, 실패하면 500이나 404로 답해 프론트엔드가 "0건"으로 오해하지 않게 했습니다. 이 경우 프론트엔드는 뷰 안의 노드에서 찾고 그 사실을 안내합니다.

뷰 밖의 노드를 고르면 그 노드와 이웃을 focus로 받아 그래프에 합치고 카드를 엽니다. 여기서 두 가지 함정이 있었습니다. 첫째, 뷰는 종류와 이름이 같은 노드를 하나로 합쳐 그립니다. 규정 문서 컬렉션에서는 뷰 밖 노드 225개가 띄어쓰기만 다른 이름 등으로 뷰의 대표 노드에 합쳐졌고, 그러면 요청한 노드를 그래프에서 찾을 수 없었습니다. 그래프 응답에 요청 노드를 실제로 그린 노드로 대응시키는 focus_resolved를 실었습니다. 둘째, 새 노드는 군집 힘으로 자리를 잡는 동안 움직입니다. 400ms를 고정으로 기다리고 카메라를 옮기면 노드가 가장자리에 걸렸습니다. 좌표가 멈출 때까지 200ms 간격으로 최대 3초 지켜본 뒤 이동하게 했습니다. 불러온 노드는 그래프를 다시 읽어도 유지(최대 30개)하고 컬렉션을 옮기면 비웁니다.

검색으로 고른 노드가 그래프의 어디쯤인지 보여 주려고, 9월 말에는 그래프 왼쪽에 구조 패널도 추가했습니다. 첫 판은 계층 전체를 트리로 펼쳤는데, 피드백을 받아 고른 항목 기준으로 바꿨습니다. 노드를 클릭하거나 검색해 고르면 뿌리부터 그 항목까지의 경로만 펼치고, 고른 항목의 하위 클래스, 개체, 속성 수를 보여 줍니다. 구조 뷰에 실리지 않은 개체는 펼칠 때 1홉을 받아 합칩니다. 요청 수도 쟀습니다. 개체를 클릭하면 패널이 속성값을 한 번 더 요청해 2회였던 것을, 카드가 이미 받은 값을 넘겨받아 1회로 줄였습니다. 이 패널은 빌드 결함도 바로 드러냈습니다. 규정 문서 컬렉션의 첫 뿌리가 개체 643개를 거느린 "일부"라는 클래스였는데, 법령 이름들이 흔한 머리명사 클래스에 매달린 결과라 빌드 규칙 수정으로 넘겼습니다. 같은 시기 관계 편집 버튼이 한 번도 보이지 않던 원인도 찾았습니다. 카드는 편집 가능한 간선에만 버튼을 그리는데, PostgreSQL 시각화 조회가 모든 간선을 편집 불가로 내려보내고 있었습니다.

2D 뷰에서는 다른 종류의 "보이지 않음"도 고쳤습니다. CSV 10개로 만든 컬렉션에서 표 이름이 3개만 보이고 7개는 "기타" 안에 묻혔습니다. 2D 엔진이 6개 미만 군집을 "기타"로 접는데, 컬럼 정의 간선은 범례에서 기본으로 숨겨져 군집 계산에 들어가지 않아 행이 적은 표가 작은 군집이 됐기 때문입니다. 간선을 숨기기 전에 표 클래스를 판정해 노드에 표시하고, 표가 든 군집은 작아도 접지 않게 했습니다.

결과와 남은 과제

지표 이전 이후 측정 조건
그리는 노드 7,469 2,652 2만 2,001노드 시험 컬렉션, 비율 정책 → 구조 우선
브라우저 CPU 점유 25% (유휴 74.9%) 7% (유휴 93.1%) 같은 컬렉션, CDP 프로파일러 약 22초 창
힘 정착 비용 틱당 183ms (7,469노드) 틱당 30ms (1,807노드 모사) 로컬 d3-force 하네스, 같은 페이로드·힘 설정
뷰 페이로드 23MB 2.1MB 재빌드 후 2만 8,083노드 컬렉션, 스키마 간선 0
노드 검색 범위 뷰에 실린 2,973개 컬렉션 전체 10만 8,703개, 응답 4~125ms 로컬 PostgreSQL 실제 함수 호출, 첫 조회 553ms 제외
검색 결과 중 뷰 밖 노드 찾을 수 없음 50건 중 48건 규정 문서 컬렉션, 검색어 1개

그래프가 더 커진 재빌드 뒤(2만 8,687노드) 다시 쟀을 때는 그리는 노드 2,899개, CPU 10%(유휴 90.2%)였습니다. UI 동작은 빌드 산출물을 띄워 Playwright로 실제 클릭하며 확인했습니다.

남은 과제도 있습니다. 뷰 밖 노드를 불러오는 focus 재조회가 큰 그래프에서 4~6초(실측 3.8~5.9초) 걸립니다. 노드 클릭으로 이웃을 합치는 기존 요청과 같은 구조 우선 뷰 쿼리 비용이라, 지금은 "불러오는 중" 표시와 마지막 선택만 반영하는 처리로 감췄고 쿼리 속도는 별도 과제로 남겼습니다. 멤버가 300개를 넘는 클래스에서 드릴다운 이웃이 비어 오던 문제는 조인을 UNION 두 개로 다시 썼지만 원인을 확정하지 못했습니다. 구조 뷰의 평평한 클래스 층은 Louvain으로 묶이지 않고, 9월 작업에서 3D 뷰는 손대지 않았습니다.

돌아보면 뷰어의 병목은 시기마다 다른 층에 있었습니다. 4월에는 메인 스레드, 6월에는 클릭과 하이라이트의 정확성, 9월에는 무엇을 그릴지의 선택, 10월에는 그리지 않은 것을 찾는 경로였습니다. 렌더링 최적화는 어느 시점부터 같은 비용으로 더 많이 그리는 일이 아니라, 무엇을 그리지 않을지 정하는 일이 됐습니다.