발행일

분류체계 화면에서 목록 단계를 없앴다 — 자식을 반대 방향으로 찾아야 했던 이유

분류체계 화면에서 목록 단계를 없앴다

분류체계(skos:ConceptScheme)는 개념(skos:Concept)을 계층으로 묶는 어휘입니다. 공개 화면이 두 단계였어요.

  1. /scheme/ — 분류체계 목록
  2. /scheme/instance/<pk> — 고른 체계의 상세

목록 화면이 하는 일은 사실상 "체계 하나 고르기" 뿐이었습니다. 운영 중인 공개 분류체계는 손에 꼽는 수준이라, 목록 한 페이지에 카드 몇 개가 놓여 있고 그중 하나를 누르는 게 전부였어요.

같은 시기에 카탈로그 화면은 이미 좌측 트리 + 우측 패널 구조로 정리돼 있었습니다. 분류체계도 여기에 맞췄습니다. 공개 분류체계 전부를 트리 뿌리로 세우고, 목록 단계를 없앴어요.

방법장점포기하는 것판단
① 목록 유지 + 상세만 트리로변경 최소. 체계가 늘어도 목록이 감당클릭 한 번이 계속 남음. 체계를 바꾸려면 목록으로 되돌아가야 함기각
② 목록을 셀렉트로 축소, 상세는 트리왕복 없음셀렉트에 없는 체계는 존재를 모름. 트리 뿌리와 셀렉트가 같은 정보를 두 번 보여줌기각
트리 하나로 통합 — 체계 전부가 뿌리한 화면에서 체계 간 이동. 카탈로그 화면과 같은 구조체계가 수십 개가 되면 뿌리가 길어짐. 지금 규모에서만 유효한 선택채택

③이 지금 데이터 규모에서만 맞는 선택이라는 걸 알고 골랐습니다. 체계가 수십 개로 늘면 뿌리 노드가 스크롤을 요구하게 되고, 그때는 ②로 돌아가야 합니다. 그 조건을 문서에 적어뒀어요.

쓰이지 않게 된 get_scheme_list_querysetscheme_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() 입니다. 지금 규모에서는 측정할 만한 값이 안 나오는데, 개념이 수천이 되면 다시 볼 자리예요. 인덱스도 안 봤습니다.
  • 선버스트 라벨 폭 계산은 확대 시 최종 각도 기준으로 재계산합니다. 애니메이션 중간 프레임에서는 잠깐 어긋나 보일 수 있어요. 중간 프레임마다 재계산하면 매끄럽겠지만 비용이 커서 안 했습니다.

관련 글: 관계도 연쇄 탐색과 땅콩 모양 노드 겹침 · 속성 range — 클래스 참조와 데이터 유형 분리