발행일

분류 칩 하나를 네 번 갈아엎었다 — URL 값 0이 falsy라서 생긴 일

분류 칩 하나를 네 번 갈아엎었다

표준데이터 검색 목록은 가로 테이블이었습니다. 데이터가 늘면서 한 행에 열 개가 넘는 열이 들어가 읽을 수가 없었어요. 외교부 LOD 같은 공개 데이터 포털을 참고해 세로 카드 리스트 + 상단 분류 칩 형태로 바꿨습니다.

칩 자체는 간단한 UI입니다. 그런데 칩이 무엇을 나누는가가 나흘간 네 번 바뀌었어요. 순서대로 적어봅니다.

1차 — 3분류, 백엔드 미연결

첫 판은 전체 · 표준데이터 · 부가정보 · 기타였습니다. URL 계약은 ?filter=std|add|etc.

건수와 실제 필터링은 더미로 뒀습니다. 백엔드가 안 붙은 상태로 화면부터 낸 거예요. 이유는 분류 체계 자체가 기획 확인 대상이어서, 무엇으로 나눌지 정하기 전에 어떻게 보일지부터 합의하는 편이 빨랐기 때문입니다. TODO는 docs/dataset-list-redesign.md에 모아뒀습니다.

이 판단은 맞았습니다. 화면을 보고 나서 분류가 바로 뒤집혔거든요.

2차 — 4분류로 교체

표준데이터 / 부가정보 / 기타는 사실 아무것도 안 나누는 이름이었습니다. "기타"에 절반이 들어가요.

RFP와 연차보고서의 용어를 따라 4분류로 바꿨습니다.

코드명칭 (2차)명칭 (4차에서 다시 바뀜)
FR기초 정보기초 자원
DIR정보 교환 데이터데이터 자원
SAS시스템 및 서비스시스템 / 서비스
CR부가 정보부가 정보

분류 이름만 봐서는 뭐가 들어가는지 알 수 없어서, 칩마다 ? 도움말 배지를 붙였습니다. 호버하면 영문 명칭과 대표 클래스를 보여줘요. 마크업은 용어 화면의 termFieldMeta.js같은 형태(말풍선 + 왼쪽 꼬리) 를 썼습니다 — 같은 성격의 도움말이 화면마다 다른 모양이면 사용자가 다른 기능으로 읽습니다.

3차 — 문자 코드를 숫자로

?filter=FR은 URL만 봐도 뜻을 알 수 있어서 좋아 보입니다. 그런데 DB의 class_category는 이미 정수예요. 문자 코드를 쓰면 화면 ↔ URL ↔ DB 사이에 변환 계층이 생깁니다.

방법장점포기하는 것판단
?filter=FR 유지, 뷰에서 코드↔숫자 매핑URL이 읽힘. 북마크가 자기설명적매핑 테이블이 뷰·템플릿·JS 세 곳에 생김. 분류를 추가할 때 세 곳을 고쳐야 함기각
② DB를 문자 코드로 변경변환 계층 없음 + URL 읽힘마이그레이션 + 적재 스크립트 + 기존 데이터 변환. 분류 코드는 기획이 또 바꿀 수 있는 값이라 스키마에 박기 부담기각
?filter=0~4, 화면·URL·DB가 같은 번호변환 계층 0. 분류 추가가 상수 한 줄URL만 보고 뜻을 모름채택

③으로 갔습니다. 대신 번호는 칩 표시 순서와 독립이라고 못박았어요. 칩 순서를 바꿔도 저장값과 북마크 URL의 의미는 그대로여야 합니다.

# 클래스 분류 — 공개 목록(/std-data/dataset/)의 분류 칩 값이자 URL ?filter= 값이다.
# 화면·URL·DB 가 같은 번호를 쓴다. 번호는 고정이며 칩 표시 순서와
# 독립이다 — 칩 순서를 바꿔도 저장값·북마크 URL 의 의미는 그대로다.
CLASS_CATEGORY_NONE = 0
CLASS_CATEGORY_FOUNDATIONAL = 1
...

옛 북마크(?filter=FR)와 범위 밖 값(?filter=99)은 전체로 폴백시켰습니다. 이걸 안 하면 아무 칩도 선택 안 된 화면이 나와서, 사용자는 자기가 무슨 목록을 보고 있는지 모르게 돼요.

4차 — "미분류" 칩과 0의 함정

여기서 제일 걸린 문제가 나왔습니다.

분류가 아직 안 붙은 클래스가 있습니다. 저장 코드는 0(미분류) 이에요. 이 데이터를 볼 방법이 없어서 "미분류" 칩을 추가하려 했는데, "전체"가 이미 0을 쓰고 있었습니다.

원래 코드는 이랬어요.

# 0(미분류)은 '전체' 와 같아 조건이 되지 않는다 — 칩 폴백 규칙과 같은 판정.
if class_category:
    queryset = queryset.filter(metaclass__class_category=class_category)

if class_category: — 0은 falsy라 조건에서 빠집니다. "미분류를 골랐다"와 "아무것도 안 골랐다"가 파이썬 문법 수준에서 같은 것이 됩니다. 미분류 칩을 눌러도 전체가 나와요.

이건 우연이 아니라, 3차까지는 의도한 동작이었습니다. 미분류를 볼 방법이 없었으니 0을 "전체"로 취급하는 게 편했거든요. 그 편의가 4차에서 벽이 됐습니다.

두 가지를 갈랐습니다.

첫째, "전체"는 None으로만 표현합니다.

# 0(미분류)도 조건이다 — '전체' 는 None 으로만 표현된다(칩 URL 값은 5).
if class_category is not None:
    queryset = queryset.filter(metaclass__class_category=class_category)

둘째, 미분류 칩의 URL 값만 저장 코드와 다르게 뒀습니다.

# 칩으로 고를 수 있는 값 — 저장 코드와 같은 번호를 쓴다.
CLASS_CATEGORY_FILTERABLE = tuple(
    code for code, _label in TYPE_CLASS_CATEGORY if code != CLASS_CATEGORY_NONE
)
# '미분류' 칩만 저장 코드(0)와 URL 값이 다르다 — 0 은 '전체'(파라미터 없음)와 겹쳐
# 칩 값으로 쓸 수 없어 5 를 쓴다. 뷰가 이 값을 CLASS_CATEGORY_NONE 으로 되돌린다.
CLASS_CATEGORY_UNCLASSIFIED_FILTER = 5

3차에서 "변환 계층을 없애려고" 숫자로 갔는데, 4차에서 예외를 하나 만든 겁니다. 다만 환원은 뷰의 selected_class_category 한 곳에서만 합니다. 매니저는 저장 코드만 받아요. 예외를 만들되 그 예외가 사는 자리를 한 곳으로 좁힌 셈입니다.

템플릿의 빈 상태 분기도 같은 판정으로 맞춰야 했습니다. Jinja에서도 {% if selected_class_category %}는 0에서 거짓이라, is not none으로 바꿨어요. 같은 falsy 함정이 파이썬·Jinja 두 곳에 있었습니다.

모델 choices는 안 건드려서 마이그레이션은 없습니다. 대신 용어 화면의 분류 셀렉트는 옛 명칭 그대로예요(기초 정보/정보 교환 데이터/...). 목록 칩과 용어 화면이 같은 값을 다른 이름으로 부르는 상태라, 문서에 명시해뒀습니다. 정직하게 말하면 이건 부채입니다.

0건 칩을 죽이는 방법

건수가 0인 분류는 눌러도 빈 화면입니다. 회색으로 죽여뒀는데, 여기서도 두 번 골랐습니다.

  • opacity로 흐리기 — 버튼 안의 ? 배지와 말풍선까지 같이 흐려집니다. 도움말은 0건이어도 읽을 수 있어야 해요. → 색 교체(chip_muted)로 대체
  • disabled 적용 — 안 했습니다. 탭 순서에서 빠지고, 무엇보다 빈 상태 문구가 이미 사유를 설명합니다. 눌러서 "이 분류에는 아직 데이터가 없습니다"를 읽는 게 못 누르는 것보다 낫다고 봤어요. (disabled를 잘못 쓰면 어떻게 되는지는 잠긴 버튼 글에 따로 적었습니다.)

칩 5개는 category_chip 매크로로 합쳤습니다. 0건 규칙이 칩마다 복사돼 있으면 여섯 번째 칩에서 어긋나요.

목록 <ul>에는 min-h-[18rem]을 줬습니다. 결과가 1~2건일 때 목록이 눌려서 "뭔가 잘못됐나"처럼 보이던 것을 막으려고요.

그리고 착지점 하나

칩 작업 끝에, 데이터 통계 화면에서 누른 항목이 쓰인 표준데이터로 착지하는 화면을 새로 만들었습니다(/std-data/dataset/stats-search/). 워드클라우드 세 탭과 홈 검색창이 여기로 옵니다.

여기서 칩을 일부러 뺐습니다. 이유가 도메인 쪽이에요. skos:Concept이 분류 1(기초)이라, 목록처럼 2·3·4로 한정하면 상위 값·용어가 전량 0건이 됩니다. 같은 컴포넌트를 재사용하는 게 항상 맞지는 않았습니다.

이 화면을 만들면서 기존 통합검색의 결함도 하나 드러났습니다. 통합검색은 필수 지정 슬롯만 훑고 있어서, 통계가 "ICT 융합 신기술 8회"라고 말하는데 검색하면 1건이 나왔어요. 통계와 검색이 다른 모집단을 보고 있었습니다. (같은 성격의 문제를 홈 타일에서도 만났습니다.)

검증

  • e2e 시나리오 6건(dataset-list-chips.spec.js) — 칩 5개 렌더, ?filter= 왕복, ?filter=0·FR·99전체 활성, ? 호버 말풍선, 0건 칩 회색·비disabled, 목록 min-h.
  • 폴백을 제거하고 실패하는 것을 확인한 뒤 되돌렸습니다. 폴백 테스트는 폴백이 없어도 우연히 통과할 수 있어서, 레드 확인 없이는 못 믿습니다.
  • Django 테스트: 매니저에 0을 주면 전체가 아니라 미분류만 나오는 것을 단언으로 뒤집었습니다.
def test_only_none_means_all_and_zero_keeps_unclassified(self):
    everything = self._names()

    self.assertIn(self.unclassified_instance.name, everything)
    self.assertNotIn(self.private_instance.name, everything)
    # 매니저에 0 을 주면 '전체' 가 아니라 미분류만 — 전체는 None 뿐이다
    self.assertEqual(
        self._names(class_category=CLASS_CATEGORY_NONE),
        {self.unclassified_instance.name},
    )
  • ?filter=5 → 저장 코드 0으로 환원되는 뷰 테스트 추가.
  • 분류 체계·툴팁 문구·filter↔칩 매핑표를 dataset-list-redesign.md정본으로 명시. 네 번 바뀌는 동안 문서가 매번 따라가지 못해 코드와 어긋난 적이 있었어요.

돌아보면

칩 하나에 나흘, 커밋 여섯 개입니다. 많아 보이지만 각 판이 뒤집힌 이유는 달랐습니다.

  1. 1차 → 2차: 분류가 아무것도 안 나눔 (도메인)
  2. 2차 → 3차: 변환 계층이 세 곳에 생김 (구조)
  3. 3차 → 4차: 0이 falsy라 "미분류"를 표현 못 함 (언어)
  4. 4차 내부: 명칭이 RFP 용어와 안 맞음 (도메인)

그중 제일 오래 잡은 건 3번인데, 코드로는 두 줄입니다. if x:if x is not None:의 차이를 "스타일"로 알고 있었지, "0을 의미 있는 값으로 쓰는 순간 갈라진다"로는 안 알고 있었어요. 실무에서 걸리고 나서야 도메인 값에 0을 배정하는 것 자체가 결정이라는 게 보였습니다.

남은 것 · 한계

  • 용어 화면의 분류 셀렉트는 여전히 옛 명칭입니다. 목록 칩과 같은 값을 다른 이름으로 부릅니다. choices를 바꾸면 마이그레이션이 딸려 와서 미뤘는데, 명칭은 CLASS_CATEGORY_LABELS 한 곳이므로 마이그레이션 없이도 바꿀 수 있는지 다시 봐야 합니다.
  • 미분류 칩의 URL 값 5는 "빈 자리"라서 고른 값입니다. 분류가 5개로 늘면 충돌해요. 상수와 주석으로만 지키고 있어서, 다음 분류를 추가하는 사람이 5를 쓸 위험이 남았습니다.
  • 통계 착지 화면의 축은 셋(?value= 정확 일치 / ?property= 그 속성 사용 / ?search_keyword= 부분 일치)인데 동시에 하나만 됩니다. 재검색이 좁히지 않고 교체돼요. AND 조합이 필요하다는 요구가 이미 나왔지만, 주소가 하나로 수렴하는 지금 구조를 깨야 해서 보류했습니다.
  • 분류가 안 붙은 클래스를 적재 시점에 막는 장치가 없습니다. 미분류 칩은 증상을 보이게 했을 뿐, 원인은 그대로예요.

관련 글: 표준데이터 통계 대시보드 — 개수에서 인사이트로 · 워드클라우드를 검색어 빈도로 개편