- Published on
노드 두 개가 땅콩처럼 붙었다 — 연쇄 탐색 관계도와 forceCenter
- Authors

- Name
- Hyo814
노드 두 개가 땅콩처럼 붙었다 — 연쇄 탐색 관계도와 forceCenter
메타클래스 관계도 화면이 있습니다. 클래스 하나를 고르면 그 클래스가 어떤 속성을 갖고, 그 속성의 값이 어떤 클래스인지 그래프로 보여주는 화면이에요.
문제는 클래스가 3개인 화면이 노드 18개로 그려진다는 것이었습니다.
서버가 rdfs:range를 따라 깊이 제한 없이 BFS로 펼쳐서 내려주고 있었거든요. Catalog를 보려 했는데 Dataset이 나오고, 거기서 Message가 나오고, Message의 속성값 클래스가 또 나옵니다. 화면에는 선이 엉킨 덩어리 하나만 남았어요.
1. 어디를 고칠 것인가
| 방법 | 얻는 것 | 포기하는 것 | 판단 |
|---|---|---|---|
① 서버 BFS에 depth 파라미터 추가 | 원인 지점을 직접 고침 | 이 API를 관계도 화면 6종이 공유. 깊이 의미가 화면마다 달라 파라미터 하나로 안 맞음. 회귀 범위가 큼 | 기각 |
| ② 서버가 중심 클래스 기준 부분 그래프만 반환 | 전송량 감소 | 중심을 옮길 때마다 왕복. 클릭 한 번에 네트워크가 붙으면 탐색이 뚝뚝 끊김 | 기각 |
| ③ 응답은 그대로 받고 프런트에서 부분 그래프를 잘라 그림 | 백엔드 무변경, 중심 이동이 즉시 | 전체 그래프를 계속 메모리에 들고 있음 | 채택 |
③의 근거는 데이터 크기였습니다. 전체 클래스 그래프라 해봐야 노드 수백 개예요. 한 번 받아서 들고 있으면 중심 이동이 네트워크 없이 끝납니다.
그래서 화면 모델을 이렇게 다시 잡았습니다.
중심 클래스 + 그 클래스가
domain인 속성 + 각 속성의 값, 딱 여기까지. 클릭하면 중심이 옮겨간다.
한 화면에 보이는 건 항상 두 고리입니다. 깊이는 사용자가 클릭으로 만들어요.
2. 클래스 관계는 순환한다
Catalog → Dataset → Message → Catalog. 표준 모델에는 순환이 있습니다. 클릭할 때마다 경로에 쌓기만 하면 브레드크럼이 무한히 길어져요.
// 클래스 관계는 순환한다(Catalog → Dataset → Message → Catalog). 이미 지나온
// 클래스로 돌아가면 경로에 또 쌓지 않고 그 지점으로 되감는다.
const visited = trail.indexOf(nextId);
if (visited >= 0) trail = trail.slice(0, visited + 1);
else trail.push(nextId);
이미 지나온 지점으로 되감습니다. 이렇게 하면 경로 길이가 자연히 "서로 다른 클래스 수" 이하로 묶여요. 방문 집합을 따로 들지 않아도 됩니다 — trail 자체가 방문 기록이니까요.
indexOf가 선형 탐색이라 걸리적거릴 수 있는데, trail 길이는 사람이 클릭한 횟수고 실제로는 한 자리 수입니다. Set을 병행할 이유가 없었어요.
3. xsd:string을 노드로 그리면 안 되는 이유
속성의 값이 데이터 유형일 때가 많습니다. dct:title의 값은 xsd:string이에요.
처음엔 이것도 노드로 그렸습니다. 그랬더니 문자열 속성 전부가 한 점으로 모였어요. xsd:string은 IRI가 하나뿐이라 노드 ID가 같고, 그래프에서는 같은 노드니까요. 속성 8개가 전부 그 한 점으로 선을 뻗어서 화면 가운데가 선 뭉치가 됩니다.
// 값이 데이터 유형이면 노드 대신 속성 라벨 아래 텍스트로 붙인다.
데이터 유형은 노드가 아니라 속성의 부가 표기로 내렸습니다. 그래프에서 노드로 그릴 것과 라벨로 붙일 것을 가르는 기준이 여기서 생겼어요.
여러 주체가 같은 값을 가리키는데 그 값 자체를 탐색할 일이 없으면, 노드가 아니라 라벨이다. 노드로 만들면 공유 노드가 되어 그래프를 오히려 못 읽게 만듭니다.
4. 배치를 힘 시뮬레이션에서 뺐다
d3.forceSimulation에 맡겨봤는데, 속성 수가 적을 때 노드가 한쪽으로 뭉쳤습니다. 속성이 2개면 두 노드가 어디로 갈지는 초기 좌표 난수에 달려 있어요. 새로고침할 때마다 배치가 달라지고, 운 나쁘면 선 두 개가 겹칩니다.
그래서 12시부터 시계 방향으로 균등 방사하는 고정 좌표로 바꿨습니다.
const outerX = 450;
const outerY = 200;
const step = (2 * Math.PI) / propIds.length;
const outerShift = Math.min(step / 2, Math.PI / 6);
const sparse = propIds.length <= 2;
const startAngle = sparse ? 0 : -Math.PI / 2;
const INNER_RATIO = sparse ? 0.5 : 0.64;
propIds.forEach((propId, index) => {
const angle = startAngle + index * step;
const place = (id, ratio, at) => {
const node = byNodeId.get(id);
if (!node || node.id === centerId) return;
const dx = Math.cos(at) * outerX * ratio;
const dy = Math.sin(at) * outerY * ratio;
node.fx = width / 2 + dx;
node.fy = height / 2 + dy;
// 타원이라 배치각과 실제 방향이 다르다 — 라벨은 눈에 보이는 방향으로 뻗어야 한다
node.labelAngle = Math.atan2(dy, dx);
};
place(propId, INNER_RATIO, angle);
place(valueIdOfProp.get(propId), 1, angle + outerShift);
});
여기 들어간 결정이 넷인데, 전부 화면을 보고 되돌아온 것들입니다.
① 반지름을 화면 크기에서 뽑지 않는다. 처음엔 width * 0.35 식으로 잡았어요. 그러면 1280×800에서 노드가 붙고, 2560×1440에서는 라벨이 겹칩니다. 한 창 크기에만 맞는 튜닝이 돼요. 고정 좌표로 한 번만 잡고 축소는 화면 맞춤(fitAfterRender)에 맡기면, 창이 커지든 작아지든 같은 그림이 배율만 달라집니다.
② 원이 아니라 타원(450×200). 라벨이 가로로 뻗기 때문입니다. 정원으로 돌리면 좌우 여백이 통째로 놉니다.
③ 바깥 고리를 반 칸 어긋나게. 값 노드를 속성과 같은 각도에 두면 속성 라벨이 뻗을 길목을 값 노드가 막습니다. 고리 사이 간격은 40px인데 라벨은 190px이에요. 반 칸(step/2) 틀어서 길을 비우되, 속성이 1~2개면 반 칸이 90°·180°라 뒤로 돌아버려서 30°를 상한으로 걸었습니다.
④ 속성이 1~2개면 12시가 아니라 3시부터. 12시에서 시작하면 노드가 세로로만 쌓여서 넓은 가로가 논다. 이 경우에만 시작각을 0으로 돌립니다.
5. 그리고 노드 두 개가 땅콩이 됐다
이웃이 1~2개인 인스턴스 관계도에서, 두 원이 서로 파고들어 땅콩처럼 붙어 보였습니다. 링크 거리를 150으로 줬고 충돌 반경이 80인데도요.
고정 좌표를 넣은 쪽을 한참 봤는데 거기가 아니었습니다. 범인은 forceCenter 였어요.
const simulation = d3.forceSimulation(graph.nodes)
.force("link", d3.forceLink(graph.links).id(d => d.id).distance(150))
.force("charge", d3.forceManyBody().strength(-1000))
.force("center", d3.forceCenter(width / 2, height / 2)) // ← 이것
.force("collision", d3.forceCollide().radius(80))
.force("x", d3.forceX(width / 2).strength(0.1))
.force("y", d3.forceY(height / 2).strength(0.1));
forceCenter는 다른 힘들과 성격이 다릅니다. 속도를 더하는 게 아니라, 매 틱마다 전체 노드의 무게중심을 계산해서 목표 지점으로 오도록 모든 노드를 평행이동시켜요.
여기에 고정 노드가 끼면 이렇게 됩니다.
- 중심 노드는
fx/fy로 화면 중앙에 못 박혀 있다. forceCenter가 무게중심을 계산한다 — 중심 노드와 이웃들의 평균이라 중앙에서 살짝 벗어나 있다.- 그 차이만큼 모든 노드를 평행이동한다. 이웃도, 중심 노드도.
- 틱이 끝나면서 고정 노드는
fx/fy로 되돌아간다. - 결과적으로 이웃만 중앙 쪽으로 끌려온다. 매 틱마다.
링크 거리 150은 "이 정도 떨어지려는 힘"일 뿐이고, forceCenter의 평행이동은 힘이 아니라 좌표 강제입니다. 그래서 밀어내는 힘과 당기는 평행이동이 균형을 이루는 지점 — 150보다 훨씬 가까운 자리 — 에 멈춰요. 그게 땅콩입니다.
이웃이 많으면 무게중심이 자연히 중심 노드 근처라 티가 안 납니다. 이웃이 1~2개일 때만 무게중심이 크게 치우쳐서 눈에 보였어요.
고친 건 두 줄입니다.
// 중심 노드가 화면 중앙에 고정된 탐색 그래프에서 forceCenter 를 켜면 안 된다 —
// forceCenter 는 무게중심을 중앙으로 평행이동시키는데 고정 노드는 안 움직이므로
// 이웃만 매 틱 중앙으로 끌려와, 링크 거리(150)를 못 지키고 노드가 겹친다.
// 중앙 정렬은 forceX/forceY 가 이미 담당한다.
const hasPinnedFocus = graph.nodes.some(n => isFocusNode(n));
// ...
.force("center", hasPinnedFocus ? null : d3.forceCenter(width / 2, height / 2))
중앙 정렬은 forceX/forceY가 이미 하고 있었습니다. forceCenter는 중복이었고, 고정 노드와는 상극이었어요. 지우는 게 답이었습니다.
시뮬레이션에서 좌표를 직접 쓰는 것(
fx/fy,forceCenter)과 힘을 더하는 것을 섞으면, 힘 쪽 파라미터(거리·반경)가 지켜지지 않습니다. 겹침을 보고 반경부터 키우려던 손을 멈춘 게 이번엔 맞았어요.
6. 클릭했는데 그래프가 휘돈다
배치를 잡고 나니 조작에서 걸렸습니다. 노드를 클릭만 해도 그래프가 한 바퀴 휘돌았어요.
d3.drag의 start/end는 mousedown/mouseup만으로도 발화합니다. 즉 클릭도 드래그로 칩니다. 기존 코드가 start에서 시뮬레이션을 재가동(alphaTarget(0.3).restart())하고 end에서 고정을 풀고 있었으니, 클릭 한 번에 배치가 통째로 흐트러진 거예요.
const CLICK_SLOP = 4;
let moved = false;
return d3.drag()
.clickDistance(CLICK_SLOP)
.on("start", event => {
moved = false;
pinX = event.subject.fx; pinY = event.subject.fy; // 원래 고정 좌표 기억
startX = event.x; startY = event.y;
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
})
.on("drag", event => {
if (!moved) {
if (Math.hypot(event.x - startX, event.y - startY) < CLICK_SLOP) return;
moved = true;
// drag 이벤트 시점엔 event.active 가 1이라 !active 조건을 걸면 영영
// 재가동이 안 된다(멈춘 시뮬레이션은 tick 이 없어 화면이 커서를 안 따라옴)
simulation.alphaTarget(0.3).restart();
}
event.subject.fx = event.x;
event.subject.fy = event.y;
})
4px 미만은 클릭으로 봅니다. clickDistance(4)도 같이 걸어서 손떨림이 클릭 이벤트를 삼키지 않게 했어요.
주석에 적어둔 함정 하나가 실제로 시간을 잡아먹었습니다. 시뮬레이션 재가동을 drag 핸들러로 옮기면서 관례대로 if (!event.active)를 그대로 들고 갔는데, drag 시점에는 event.active가 이미 1입니다. 조건이 영영 거짓이라 시뮬레이션이 안 깨어나고, 멈춘 시뮬레이션은 틱이 없어서 노드가 커서를 안 따라와요. 같은 관용구가 start에서는 맞고 drag에서는 틀립니다.
7. 놓으면 제자리로 — 164px 표류
드래그를 놓을 때도 문제가 있었습니다. 표준 d3 관례는 end에서 fx = null로 풀어주는 건데, 방사 고정 노드를 풀면 평형점이 제자리가 아닙니다. 힘이 다시 계산되면서 수백 px 떠내려가요. 실측으로 164px이었습니다. 사용자 입장에선 잠깐 옆으로 치워 봤을 뿐인데 그래프가 무한정 늘어지는 것처럼 보입니다.
// 방사 고정 노드 — 원래 좌표로 300ms 복원. 시뮬레이션은 복원이 끝날 때까지
// 살려 둬야 tick 이 돌아 화면이 따라온다.
d3.transition()
.duration(300)
.ease(d3.easeCubicOut)
.tween("returnPin", () => t => {
s.fx = fromX + (toX - fromX) * t;
s.fy = fromY + (toY - fromY) * t;
})
.on("end interrupt", () => {
s.fx = toX; s.fy = toY;
simulation.alphaTarget(0);
});
여기서 조작 규칙을 셋으로 정리해서 문서에 박았습니다.
| 조작 | 의미 | 결과 |
|---|---|---|
| 클릭 | 탐색 | 중심 이동 (속성·중심 노드는 상세로) |
| 드래그 | 일시 관찰 | 놓으면 제자리 복원 |
| 줌·팬 | 시야 | 배치 불변 |
"드래그로 배치를 바꿀 수 있다"를 포기한 결정입니다. 배치가 의미를 갖는 그래프(방사 각도 = 속성 순서)에서 사용자가 배치를 흐트러뜨리면 그 의미가 사라져요. 자유 배치 노드는 지금도 표준 d3 관례대로 풀어줍니다.
8. 검증
배치 문제는 "잘 나온다"로 끝낼 수가 없어서 Playwright로 경계상자를 뽑아 비교했습니다.
| 항목 | 결과 |
|---|---|
| 속성 보유 공개 클래스 | 9종 전수 |
| 창 크기 | 2종 |
| 조합 | 18조합에서 노드·글자 겹침 0건 (경계상자 전수 비교) |
| 공개 그래프 3종 | 회귀 없음 |
| 페이지 에러 | 0 |
| ESLint | 0건 |
경계상자를 비교한 이유는 스크린샷 눈검사로는 "겹쳤다"를 못 세기 때문입니다. 노드 원과 라벨 텍스트의 getBoundingClientRect()를 전부 뽑아 쌍마다 교차를 확인하면 숫자가 나와요. 창 크기를 2종 돌린 건 4절 ①에서 데인 것 때문이고요.
9. 남은 것
- 속성이 많은 클래스는 여전히 빡빡합니다. 타원 둘레가 고정(450×200)이라 속성 수가 늘면
step이 줄어들 뿐이에요. 검증한 9종은 통과했지만 이건 "지금 데이터에서 통과"지 구조적 해결이 아닙니다. 속성이 일정 수를 넘으면 고리를 하나 더 두거나 페이지를 나눠야 해요. - 주석 하나가 코드보다 오래됐습니다.
placeRadially위에 "드래그하면 놓은 자리로 고정이 옮겨진다" 고 적혀 있는데, 7절에서 놓으면 제자리로 복원하도록 바꿨으니 지금은 틀린 설명입니다. 나중에 읽는 사람은 코드보다 주석을 먼저 믿어요. - 데이터 유형을 라벨로 내린 건 화면 가독성 결정이라, 데이터 유형을 클릭해서 그 유형을 쓰는 속성들을 보는 탐색은 지금 못 합니다. 필요해지면 노드가 아니라 별도 화면으로 여는 게 맞다고 봅니다.
정리
forceCenter는 힘이 아니라 좌표 평행이동이다. 고정 노드와 같이 쓰면 링크 거리·충돌 반경이 지켜지지 않는다.- 반지름을 창 크기에서 뽑지 않는다. 고정 좌표 + 화면 맞춤이 창마다 다른 겹침을 없앤다.
- 여러 주체가 가리키는데 탐색할 일 없는 값은 노드가 아니라 라벨이다.
d3.drag의start/end는 클릭에도 발화한다. 슬롭을 두지 않으면 클릭이 곧 배치 파괴다.- 배치 검증은 스크린샷이 아니라 경계상자로. 겹침은 세어야 한다.