- 발행일
분류체계 화면에서 목록 단계를 없앴다 — 자식을 반대 방향으로 찾아야 했던 이유
분류체계 화면에서 목록 단계를 없앴다
분류체계(skos:ConceptScheme)는 개념(skos:Concept)을 계층으로 묶는 어휘입니다. 공개 화면이 두 단계였어요.
/scheme/— 분류체계 목록/scheme/instance/<pk>— 고른 체계의 상세
목록 화면이 하는 일은 사실상 "체계 하나 고르기" 뿐이었습니다. 운영 중인 공개 분류체계는 손에 꼽는 수준이라, 목록 한 페이지에 카드 몇 개가 놓여 있고 그중 하나를 누르는 게 전부였어요.
같은 시기에 카탈로그 화면은 이미 좌측 트리 + 우측 패널 구조로 정리돼 있었습니다. 분류체계도 여기에 맞췄습니다. 공개 분류체계 전부를 트리 뿌리로 세우고, 목록 단계를 없앴어요.
| 방법 | 장점 | 포기하는 것 | 판단 |
|---|---|---|---|
| ① 목록 유지 + 상세만 트리로 | 변경 최소. 체계가 늘어도 목록이 감당 | 클릭 한 번이 계속 남음. 체계를 바꾸려면 목록으로 되돌아가야 함 | 기각 |
| ② 목록을 셀렉트로 축소, 상세는 트리 | 왕복 없음 | 셀렉트에 없는 체계는 존재를 모름. 트리 뿌리와 셀렉트가 같은 정보를 두 번 보여줌 | 기각 |
| ③ 트리 하나로 통합 — 체계 전부가 뿌리 | 한 화면에서 체계 간 이동. 카탈로그 화면과 같은 구조 | 체계가 수십 개가 되면 뿌리가 길어짐. 지금 규모에서만 유효한 선택 | 채택 |
③이 지금 데이터 규모에서만 맞는 선택이라는 걸 알고 골랐습니다. 체계가 수십 개로 늘면 뿌리 노드가 스크롤을 요구하게 되고, 그때는 ②로 돌아가야 합니다. 그 조건을 문서에 적어뒀어요.
쓰이지 않게 된 get_scheme_list_queryset과 scheme_instance_list 템플릿(115줄)은 지웠습니다. 화면을 합치면서 옛 경로를 남겨두면 나중에 어느 쪽이 정본인지 모릅니다.
자식을 정방향으로 못 찾는다
트리로 합치고 나서 진짜 문제가 나왔습니다.
카탈로그 화면에는 이미 catalog_child_list()가 있습니다. 카탈로그 노드를 고르면 우측에 거느린 데이터셋 목록을 냅니다. 개념 노드도 똑같이 하면 될 줄 알았어요. 하위 개념을 거느린 개념이면 구성 속성 표 대신 자식 목록을 내는 겁니다.
기존 get_child_instances(정방향 객체 링크를 따라가는 함수)를 그대로 썼더니, 좌측 트리에 보이는 자식과 우측 목록의 자식이 달랐습니다.
원인은 SKOS의 저장 방식이었습니다.
- 저장된 트리플은 자식이 선언한
skos:broader(자식 → 부모) 하나뿐입니다 skos:narrower(부모 → 자식)는 저장되지 않고 서버가 역방향으로 보강해 내보냅니다- 게다가 개념의 정방향 링크에는
broader(상위)와inScheme(소속)이 섞여 있습니다
즉 "이 개념에서 나가는 링크"를 따라가면 부모와 소속 체계가 나오지, 자식은 안 나옵니다. 자식은 "나를 부모로 가리키는 개념들" 이에요. 방향이 반대입니다.
좌측 트리(build_concept_tree)는 애초에 역방향으로 조립하고 있었습니다. 우측 패널만 정방향 함수를 재사용하다 갈린 거였어요.
def concept_child_list(concept) -> dict:
"""개념이 거느린 하위 개념 목록 — 좌측 트리 한 단계 아래와 같은 집합.
카탈로그(`catalog_child_list`)와 같은 자리·같은 payload 지만 자식을 찾는 방향이 반대다.
분류체계 트리의 부모→자식 가지는 **자식이 선언한 `skos:broader`** 라(`build_concept_tree`
와 같은 근거), 정방향 링크를 타는 `get_child_instances` 로는 못 찾는다 — 정방향에는
소속(inScheme)·상위(broader)가 섞여 들어와 트리와 다른 집합이 된다.
⚠️ 한 개념이 여러 분류체계에 걸치면 다른 체계의 자식까지 섞인다(트리는 체계로 한정).
현재 데이터에 `skos:inScheme` 다중 링크가 없어 그대로 둔다.
"""
children = list(
MetaClassInstance.objects.filter(
object_links__metaclass_metadata__metaclassproperty__namespace__prefix="skos",
object_links__metaclass_metadata__metaclassproperty__name="broader",
object_links__target_instance=concept,
metaclass__public=True,
)
.select_related("metaclass__namespace")
.order_by("name", "id")
.distinct()
)
"같은 자리, 같은 payload인데 찾는 방향이 반대" 라는 게 이 작업의 핵심이었습니다. 카탈로그와 개념이 화면에서 똑같이 생겼다는 이유로 같은 함수를 쓰려다 틀렸어요. 겉모습이 같다고 데이터 관계가 같은 게 아니었습니다.
주석의 ⚠️는 지금 안 고친 한계입니다. 한 개념이 두 체계에 걸치면 다른 체계의 자식까지 섞여요. 지금 데이터에 다중 inScheme이 없어서 안 고쳤는데, "지금 안 터진다"와 "안 터지게 해뒀다"는 다른 말이라 주석으로 남겼습니다.
열 하나를 더 낼 수 있었던 이유
카탈로그 목록은 이름 한 열입니다. 개념 목록은 이름 + 정의 두 열로 냈어요. 이유가 데이터 쪽에 있습니다.
카탈로그의 자식은 여러 클래스가 섞입니다(데이터셋·다른 카탈로그·...). 클래스마다 필수 속성이 달라서 공통으로 낼 두 번째 열이 없어요. 개념의 자식은 전부 skos:Concept 하나라 skos:definition이 항상 있습니다.
열 머리글은 문자열로 안 박고 용어 사전에서 가져옵니다.
def _slot_column_label(curie: str) -> str:
"""열 머리글 — 용어 사전 라벨을 그대로 쓴다(§64 병기 규칙).
JS 에 문구를 박아 두면 사전이 바뀌어도 표만 옛 이름으로 남는다.
"""
prefix, _sep, name = curie.partition(":")
prop = MetaClassProperty.objects.filter(
namespace__prefix=prefix, name=name
).first()
return _property_label(prop) if prop else curie
용어를 엑셀에서 재적재하는 시스템이라, 화면에 박힌 문구는 반드시 사전과 어긋납니다. 시점 문제일 뿐이에요. 한 번 겪고 나서 이 프로젝트에서는 열 머리글을 사전에서 뽑는 게 규칙이 됐습니다.
아이콘이 잎에서만 이름표가 됐다
트리를 붙이고 보니 잎 노드만 아이콘이 달랐습니다. 상자(폴더)여야 하는데 이름표(fa-tag)로 그려졌어요.
assignLeafType이라는 클라이언트 로직이 있었습니다. 자식이 없는 노드를 "잎"으로 보고 아이콘을 바꾸는 건데, 분류체계에서는 자식 없는 개념도 여전히 개념입니다. 클라이언트가 구조(자식 유무)를 보고 종류(개념/속성)를 추측하고 있었던 거죠.
서버가 type을 명시하게 고치고, 규칙을 문서로 올렸습니다.
트리 노드의 종류 아이콘은 서버가
type으로 정한다. 클라이언트는 자식 유무로 종류를 추측하지 않는다.
같은 역관계가 관계도에서도 겹쳤다
broader/narrower 역관계는 데이터 맵(관계도)에서도 문제를 냈습니다. 서버가 역방향을 보강해 내보내니, 같은 두 노드 사이에 링크가 두 개 옵니다. 선은 겹쳐 앉고 술어 라벨이 두 번 그려져요.
두 가지를 같이 했습니다.
하나 — 짝이 다 있으면 하위 개념 쪽만 남깁니다.
// skos:broader 와 skos:narrower 는 같은 관계를 양방향으로 실어 보낸 것이다
// (저장 트리플은 자식→broader 하나뿐이고 서버가 역방향을 보강한다).
// 두 링크는 같은 선 위에 겹쳐 앉아 술어 라벨이 두 번 나오므로,
// 짝이 다 있을 때는 하위 개념(narrower) 쪽만 남긴다.
const localName = l => (l.label || l.predicate || "").split(/[#:/]/).pop();
const narrowerPairs = new Set(
graph.links.filter(l => localName(l) === "narrower").map(pairKey)
);
graph.links = graph.links.filter(
l => localName(l) !== "broader" || !narrowerPairs.has(pairKey(l))
);
localName으로 지역명만 떼는 건, 링크가 CURIE(skos:broader)로 올 수도 URI로 올 수도 있어서입니다.
둘 — 그래도 남는 다중 링크는 라벨만 비켜 앉힙니다.
역관계 짝이 아닌 다중 링크(같은 두 노드를 잇는 다른 술어)는 여전히 라벨이 포개집니다. 선 중점이 완전히 같으니까요. 쌍 안에서 순번을 매겨 선의 수직 방향으로 한 칸씩 밀었습니다.
const pairStep = (linkLabelFontSize || 15) * 2.6; // 두 줄(술어 + 국문명)이 안 겹칠 만큼
const perp = d => {
const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y;
const len = Math.hypot(dx, dy) || 1;
const dist = offset + (d.pairIndex || 0) * pairStep;
return { x: (-dy / len) * dist, y: (dx / len) * dist };
};
선은 안 건드리고 라벨만 옮깁니다. 선을 곡선으로 갈라 그리는 방법도 있는데, 이 관계도는 이미 노드 겹침 문제로 한 번 크게 손본 적이 있어서 레이아웃을 다시 흔들고 싶지 않았습니다. 겹치는 건 글자지 선이 아니었어요.
선버스트 라벨을 12자로 자르고 있었다
같은 데이터 맵의 분류체계 탭은 선버스트(sunburst)입니다. 조각 라벨을 이렇게 자르고 있었어요.
.text((d) => d.data.name.length > 12 ? `${d.data.name.slice(0, 11)}…` : d.data.name)
12자는 아무 근거가 없는 숫자입니다. 안쪽 링의 넓은 조각은 20자도 들어가는데 12자에서 잘리고, 바깥 링의 좁은 조각은 12자여도 삐져나갑니다. 링 폭을 실측해서 자르도록 바꿨습니다.
이어서 라벨 회전도 걷어냈습니다. 선버스트 라벨은 보통 반지름 방향으로 회전시키는데, 그러면 12시·6시 근처 조각이 세로쓰기로 읽힙니다. 한글은 세로로 세우면 읽는 속도가 확 떨어져요. 회전을 없애고 가로로 두되, 조각 위치별로 들어갈 폭을 계산했습니다. 두 글자도 못 들어가면 라벨을 비우고 툴팁이 전체 경로를 줍니다.
셀렉트에서 '전체' 옵션도 뺐습니다. 체계 전부를 한 판에 그리면 링 3개를 나눠 써야 해서 어느 계층도 제대로 안 보였어요. 첫 체계를 기본 선택하고, 고른 체계를 뿌리로 세워 링 3개를 그 체계의 계층에 전부 쓰게 했습니다.
색 배정에서도 하나 걸렸습니다. 처음엔 최상위 층에서 색을 갈랐는데, 최상위가 하나뿐인 체계에서는 화면 전체가 한 색이 됐어요. 색 배정 층을 "처음 갈라지는 층"으로 바꿨습니다.
검증
test_scheme_instance_screens.py갱신 — 목록 뷰 제거에 따른 라우팅, 개념 자식 목록이 좌측 트리 한 단계 아래와 같은 집합인지 대조.- 통합검색의 분류체계 꼬리 링크를 트리로 교체. 받을 목록이 없어져서 검색어 파라미터도 같이 제거했습니다 — 안 지웠으면 트리 화면이 안 쓰는 파라미터를 계속 받았을 거예요.
- 데이터 맵:
broader/narrower중복 링크 제거 후 라벨 중복 0, 선버스트 조각 라벨이 링 폭 안에 들어가는 것 확인. - 문서 §108·§109 추가, 전제가 뒤집힌 §105·§109에 🔄 표시. 이 프로젝트는 뒤집힌 결정에 🔄를 답니다 — 지우면 왜 바뀌었는지가 사라집니다.
남은 것 · 한계
- 개념이 여러 분류체계에 걸치면 자식 목록이 섞입니다. 좌측 트리는 체계로 한정하는데 우측 목록은 안 해요. 지금 데이터에 다중
skos:inScheme이 없어 안 고쳤습니다. 데이터가 들어오는 순간 터지는 자리고, 주석에만 적혀 있습니다. - 트리 뿌리가 체계 수만큼 늘어납니다. 지금은 몇 개라 괜찮지만 수십 개가 되면 이 구조가 무너져요. 그때 셀렉트로 되돌리는 게 준비돼 있지 않습니다.
concept_child_list의 쿼리가 객체 링크 4단 조인 +distinct()입니다. 지금 규모에서는 측정할 만한 값이 안 나오는데, 개념이 수천이 되면 다시 볼 자리예요. 인덱스도 안 봤습니다.- 선버스트 라벨 폭 계산은 확대 시 최종 각도 기준으로 재계산합니다. 애니메이션 중간 프레임에서는 잠깐 어긋나 보일 수 있어요. 중간 프레임마다 재계산하면 매끄럽겠지만 비용이 커서 안 했습니다.