- 발행일
범위 필터의 두 축을 배타에서 교집합으로 바꿨다 — 셀렉트 두 칸이 나란히 서자 옛 규칙이 설명할 수 없는 동작이 됐다
범위 필터의 두 축을 배타에서 교집합으로 바꿨다
표준데이터 검색에는 결과를 좁히는 범위 필터가 두 축 있습니다. 카탈로그 기준(?catalog_id=)과 개념체계 기준(?scheme_ids=·?concept_ids=)이에요. 9월 3일 통합 검색 화면을 목업 구성으로 옮기면서(f8ce7598) 두 축을 함께 걸었을 때의 규칙을 배타에서 교집합으로 바꿨습니다.
이 커밋은 히어로 카드·탭·결과 카드까지 한꺼번에 바꾼 큰 변경인데, 이 글은 그중 두 축의 조합 규칙 하나만 다룹니다. 화면 모양이 아니라 주소를 읽는 서버 계약과 테스트가 같이 움직인 자리라서요.
옛 규칙: 둘 중 하나만
범위 필터는 8월 10일 팀원이 레거시 화면의 필터 두 종을 인스턴스 기준으로 되살리며 들어왔습니다(ba4015ab). 레거시는 카탈로그 기반·분류 기반 아코디언이 한 번에 하나만 열리는 구조(toggleFilterAccordion)였고, 되살린 규칙도 그대로 배타였어요.
- 한쪽 패널을 열면 반대 패널의 선택이 비워지고 접힌다
- 필터를 적용할 때 반대 축 파라미터를 주소에서 지운다
- 두 축이 함께 실린 주소가 오면 서버가 카탈로그만 남긴다
# 두 검색 방식은 **배타**다(기획 결정) — 하나를 고르면 다른 조건은 해제된다.
# 화면 JS 도 적용할 때 반대 축 파라미터를 지우지만, 손으로 고친 주소·옛 북마크가
# 두 축을 함께 실어 올 수 있어 서버도 같은 규칙을 판정한다. 카탈로그가 이긴다 —
# 레거시 `filter_mode` 의 우선순위(카탈로그 → 분류)를 그대로 지킨다.
if buckets["catalog"]:
buckets["scheme"] = []
buckets["concept"] = []
설계 문서는 서버 판정을 둔 이유를 이렇게 적고 있었습니다 — "손으로 고친 주소가 화면이 표현할 수 없는 상태(교집합)로 빠지지 않게 서버도 배타를 판정한다." 아코디언이 하나만 열리는 화면에는 두 조건이 동시에 걸린 모습을 그릴 자리가 없으니, 서버가 그 상태를 막아 화면과 계약을 맞춘 거예요. 틀린 규칙이 아니라 그 UI에 정확히 맞춘 규칙이었습니다.
8월 21일 필터를 좌측 30% 컬럼으로 옮길 때(e88d1b0b)도 아코디언 두 개 구조는 그대로라, 배타 규칙도 그대로 남았습니다.
셀렉트 두 칸이 나란히 서자
9월 1주차 목업은 필터를 라벨 위·셀렉트 아래로 한 줄에 늘어놓고, 고르는 즉시 조회합니다. 적용 버튼이 없어요. 이 셀렉트 줄 구성은 기획 결정이었습니다(템플릿 머리말 "기획 결정 2026-09-03"). 통합 검색에서는 카탈로그·개념체계·관련 표준·상태 칸이 한 줄에 섭니다.
기록에 남긴 전환 이유는 "패싯 검색의 보통 규칙" 한 줄뿐입니다. 그 규칙이 왜 이 화면에서 맞는지를 풀면 이렇습니다. 옛 규칙을 셀렉트 줄에 그대로 얹으면, 카탈로그 칸에서 하나를 고른 상태로 옆 개념체계 칸을 고를 때 배타 규칙상 주소에서 catalog_id가 지워지고 카탈로그 칸이 스스로 비워집니다. 두 칸이 같은 줄에 나란히 보이는데, 한쪽을 만지면 다른 쪽이 말없이 풀리는 화면이 돼요.
아코디언 시절에는 "하나만 열린다"는 모양 자체가 배타를 설명했습니다. 셀렉트 줄에는 그런 단서가 없습니다. 옛 규칙에서 '화면이 표현할 수 없는 상태'였던 교집합이 새 화면에서는 가장 자연스럽게 읽히는 상태가 됐고, 거꾸로 배타가 설명할 수 없는 동작이 됐습니다.
그래서 규칙을 뒤집었습니다(옛 규칙은 기획 결정이었는데, 기획과 다시 맞춘 기록은 남기지 않았습니다 — 아래 한계). 축 안은 합집합, 축끼리는 교집합. 한 축 안에서 여러 항목을 고르면 넓어지고, 두 축을 함께 걸면 둘 다 만족하는 것만 남습니다. 패싯 검색에서 흔히 쓰는 조합이에요. 통합 검색에서는 각 칸의 전체 … 선택지가 그 축을 푸는 자리가 됩니다(카탈로그·개념체계 탭의 전체가 다른 뜻을 갖게 된 경위는 이튿날 따로 정했습니다 — 탭의 '전체'는 조건 없음이 아니라 합집합이었다).
이 조합 규칙은 목업이 정해 준 것이 아닙니다. 목업 대조표를 보면 목업에는 카탈로그 계층 칸(1~4차)이 없습니다(카탈로그 1~4차 행, "목업엔 없다"). 목업의 카탈로그 탭은 평평한 속성 필터예요. 계층 칸과 통합 검색의 "첫 칸만" 배치는 기획 결정(9월 2일)이었지만, 계층 축 둘을 한 줄에 세웠을 때의 조합 규칙은 대조표에 목업 쪽 근거가 없습니다. 그 규칙을 정한 게 이 커밋이고, 코드 주석에는 "2026-09-03 결정"으로 적었습니다.
주소를 다시 쓰는 방식
조합 규칙에 해당하는 화면 쪽 변화는 작습니다(파일은 셀렉트 줄로 재작성됐지만요). 칸을 고르면 자기 축 파라미터만 다시 쓰고 반대 축은 건드리지 않습니다.
/** 고른 축만 주소에 다시 쓴다 — 반대 축은 건드리지 않아야 둘이 곱해진다. */
function applyAxis(axisName) {
var picked = deepestValue(axisName);
navigate(function (params) {
if (axisName === "catalog") {
clearCatalogParams(params);
if (picked) params.set("catalog_id", picked.value);
return;
}
clearConceptParams(params);
// …분류를 안 골랐으면 체계를, 골랐으면 분류를 싣는다
});
}
옛 코드는 카탈로그를 적용할 때 clearConceptParams를, 개념체계를 적용할 때 clearCatalogParams를 함께 불러 반대 축을 지웠습니다. 적용 시점의 배타는 그 호출 두 개였고(패널을 열 때 반대 선택을 비우던 아코디언 규칙은 셀렉트 줄로 바뀌며 함께 사라졌습니다), 새 코드에서는 그 호출이 빠졌습니다.
서버 계약: 판정과 폼
주소 파라미터 이름(catalog_id·scheme_ids·concept_ids)은 레거시 그대로 뒀습니다. 바뀐 건 같은 주소를 어떻게 읽느냐예요. 화면 JS만 바꾸면 손으로 고친 주소나 옛 링크는 여전히 서버의 배타 판정을 탑니다. 옛 규칙이 서버에도 판정을 둔 이유가 새 규칙에도 그대로 적용되니, 서버를 같이 바꿔야 했습니다.
# 범위 필터의 축 — `scheme`(개념체계)과 `concept`(그 아래 분류)는 **한 축**이다.
# 축 안은 합집합, 축끼리는 교집합(2026-09-03 결정).
SCOPE_AXIS_GROUPS = {"catalog": "catalog", "scheme": "concept", "concept": "concept"}
scheme과 concept은 파라미터는 둘이지만 한 축입니다. 매핑 한 줄로 "파라미터"와 "축"을 갈라 두고, 같은 축의 조건끼리는 합치고 축끼리는 곱합니다.
axis_scopes = {}
for item in items:
group = SCOPE_AXIS_GROUPS[item["axis"]]
axis_scopes[group] = axis_scopes.get(group, set()) | item["ids"]
panel_scope_ids = (
set.intersection(*axis_scopes.values()) if axis_scopes else None
)
조건이 없을 때는 None, 조건은 있는데 걸리는 게 없을 때는 빈 집합입니다. 이 구분은 원래 있던 규칙(test_empty_scope_is_zero_not_everything)이라, 교집합이 비었을 때 "조건 없음"으로 되살아나 전체가 나오지 않게 그대로 지켰습니다.
당시에는 건수도 같은 교집합에서 셌습니다. 고른 항목 가운데 하나만 골라 보는 2차 드롭다운(필터 선택)에 항목별 건수가 붙어 있었는데, 목록은 교집합으로 좁혀졌는데 건수를 한 축 안에서만 세면 드롭다운이 목록에 없는 건수를 말하게 되니 반대 축 조건까지 곱해 셌어요. 다만 이 드롭다운은 이튿날 통째로 걷었습니다(c835e226). 담긴 항목이 늘 하나뿐이라 고를 게 없었거든요. 그래서 이 글의 규칙 가운데 지금 코드로 남아 잠겨 있는 건 결과 교집합과 아래 hidden_fields 두 가지입니다.
검색 폼에 되살리는 조건도 방향이 반대가 됐습니다. 검색어를 제출할 때 필터 파라미터는 hidden_fields로 따라갑니다. 옛 규칙에서 반대 축 조건은 폼에 되살아나면 안 되는 값이었고(다음 제출에서 돌아오면 배타가 깨지니까요), 새 규칙에서는 빠지면 안 되는 값입니다. 한쪽만 되살아나면 검색어를 친 순간 조건 하나가 조용히 풀립니다. 폼을 만드는 코드는 손대지 않았어요. 서버가 반대 축을 비우던 줄이 빠지자 폼에 실리는 조건도 따라 뒤집혔고, 그 방향을 아래 테스트가 잠급니다.
테스트를 뒤집었다
서버 테스트는 이름부터 바뀌었습니다. test_axes_are_exclusive_catalog_wins가 test_axes_intersect가 됐어요.
both_axes = f"?catalog_id={self.root_catalog.pk}&concept_ids={self.branch1.pk}"
catalog_only = self._names(f"?catalog_id={self.root_catalog.pk}")
concept_only = self._names(f"?concept_ids={self.branch1.pk}")
self.assertEqual(self._names(both_axes), catalog_only & concept_only)
# 한쪽에만 있는 것은 빠진다 — 교집합이 합집합·한쪽 결과와 같아 보이면 안 된다
self.assertEqual(self._names(both_axes), {"잎1a 데이터"})
context = self._context(both_axes)
self.assertEqual(
[item["axis"] for item in context["scope_items"]], ["catalog", "concept"]
)
# 항목별 건수도 교집합에서 센다 — 드롭다운이 목록에 없는 건수를 말하면 안 된다
self.assertEqual([item["count"] for item in context["scope_items"]], [1, 1])
# 두 조건 모두 검색 폼에 되살아난다(다음 제출에서 한쪽이 조용히 풀리면 안 된다)
names = [field["name"] for field in context["search"]["hidden_fields"]]
self.assertIn("catalog_id", names)
self.assertIn("concept_ids", names)
위는 9월 3일 커밋 당시의 테스트이고, 항목별 축 목록과 건수 [1, 1] 단언은 이튿날 드롭다운과 함께 지웠습니다. 지금은 결과 교집합 두 단언과 hidden_fields 단언만 남아 있어요.
두 번째 단언을 따로 둔 이유가 있습니다. 첫 단언은 "교집합과 같다"만 봅니다. 픽스처가 나중에 바뀌어 교집합이 카탈로그 쪽 결과나 합집합과 우연히 같아지면, 배타나 합집합 구현에서도 첫 단언은 통과해요. 결과를 한 건으로 못박아 두면 그런 픽스처 변경이 먼저 걸립니다.
옛 테스트가 ["catalog"]로 세던 항목 축은 ["catalog", "concept"]로, assertNotIn("concept_ids", names)로 지키던 자리는 assertIn으로 뒤집혔습니다. 같은 필드를 같은 이유("조용히 풀리거나 되살아나면 안 된다")로 지키는데 방향만 반대예요.
e2e dataset-list-scope-filter도 배타 시나리오("분류를 적용하면 카탈로그 조건이 주소에서 사라진다")를 지우고 교집합 시나리오로 다시 썼습니다. 두 칸을 차례로 고른 뒤 주소에 두 파라미터가 다 살아 있는지, 두 칸이 고른 값을 지키는지, 한쪽을 전체로 되돌리면 반대 축만 남는지를 봅니다.
검증
- 서버:
test_axes_intersect가 교집합 결과와hidden_fields의 두 파라미터를 단언합니다(커밋 당시에는 항목별 건수[1, 1]도 있었지만 이튿날 드롭다운과 함께 삭제). 같은 파일의test_union_within_axis(축 안은 합집합)는 그대로 둬서, 축 안과 축끼리의 규칙이 한 파일에서 짝으로 잠깁니다. - e2e와 dev 데이터: 커밋 시점 dev는 개념 축이 전부 0건이었습니다. 어느 개념체계를 골라도 0건이라 교집합이 좁혀지는 모습을 화면에서 볼 수 없었어요. 그 사실을 같은 날 qa 기록에 남기고(
de1492d3), dev에 두 가지를 넣어 원격과 같은 건수가 나오게 했습니다 — 데이터셋 → 메시지 참조 링크 26건, 그리고 구성 조각 두 클래스(kadif:MessageField·kadif:DataFrameField)의 분류 지정. 그 뒤 건수가 원격과 같은 83·217로 섰습니다. - 전체 스위트: 같은 날 시나리오 스위트 274건이 통과 266·실패 0·skip 8이었고, 남은 skip 사유 목록에 이 스펙은 없습니다(
ae4242e0).
남은 것 · 한계
- 옛 규칙에 붙어 있던 "(기획 결정)"을 뒤집었는데, 기획과 다시 맞췄다는 기록이 없습니다. 셀렉트 줄 구성은 "기획 결정 2026-09-03"으로 적었지만 조합 규칙은 "2026-09-03 결정"으로만 남겼어요. 바꾼 이유는 문서에 있어도, 누가 동의했는지는 없습니다.
- 교집합에 맞춰 고친 드롭다운 건수가 하루 만에 사라졌습니다. 반대 축까지 곱해 세게 고친
필터 선택드롭다운을 이튿날 걷었어요. 조합 규칙을 옮기면서 그 드롭다운이 여전히 필요한지는 묻지 않았던 셈입니다. - 두 축을 함께 실은 옛 주소는 뜻이 바뀌었습니다. 파라미터 이름이 그대로라 옛 링크는 열리지만, 두 축이 같이 실린 주소의 결과는 "카탈로그만"에서 "둘 다 만족"으로 좁아지거나 같아집니다. 그런 주소가 실제로 얼마나 있는지는 보지 않았습니다.
- e2e는 좁혀지는지가 아니라 넓어지지 않는지만 봅니다.
toBeLessThanOrEqual이라, 반대 축이 0건인 환경에서는 0 ≤ N으로도 통과해요. 교집합이 실제로 좁히는지는 서버의 픽스처 테스트가 잠그고, 화면 쪽 확인은 dev 데이터 상태에 기대고 있습니다. - dev의 교집합 확인 데이터 일부가 시드 밖입니다. 데이터셋 → 메시지 참조 링크 26건은 원격에서 긁어 넣은 것이라 dev 시드를 다시 적재하면 사라지고, 그러면 개념 축은 다시 0건이 됩니다(
de1492d3qa 기록, 같은 날ae4242e0에서 정리). - 축 밖 조건(관련 표준·상태)도 같은 방식으로 곱했지만, 그 조건들이 무엇을 기준으로 거르는지는 이후 따로 바뀌어서 이 글에서는 다루지 않습니다.
관련 글: 탭의 '전체'는 조건 없음이 아니라 합집합이었다 · 필터 선택지 옆 건수를 어느 모집단으로 셀 것인가 · 검색 조건을 바꿀 때 새로고침 대신 조각 9개를 갈아끼웠다