발행일

신청서 항목 모달에서 후보 조회 20회를 2회로 줄였다 — 체크박스 2,178개와 숨은 탭의 required

신청서 항목 모달에서 후보 조회 20회를 2회로 줄였다

표준데이터 등록 신청서에는 메시지·데이터 프레임·데이터 엘리먼트를 항목으로 추가하는 모달이 있습니다. [추가]를 누르면 그 클래스의 슬롯이 전부 폼으로 서요.

이 모달이 화면 4.1개 길이였습니다. 스크롤을 네 번 넘게 내려야 [저장]이 나와요. 재 봤더니 표 2,364px 중 1,593px, 67%가 max-h-[10rem]짜리 체크박스 상자 9개였습니다. 그 안에 든 후보가 이만큼이었어요.

슬롯후보
데이터 표현 (kadif:hasRepresentation)1,542
값 집합 (kadif:hasValueSet)291
범주 (dcat:theme)96
생성자 (dct:creator)56
정의·활용·시나리오·구성 표준각 45

모달 한 번에 체크박스 2,178개를 그리고, 1,542건을 10rem 상자 안에서 검색 없이 스크롤로 찾게 되어 있었습니다.

어디까지 검색칸으로 바꿀 것인가

전부 검색칸으로 바꾸면 되는 게 아니었습니다. 상태는 후보가 3건이에요. 3건을 타이핑해서 찾게 만들면 후퇴입니다.

처음 정한 임계값은 20이었습니다. "한 화면에 들어오는 줄 수"라고 근거를 적었는데, 같은 날 그 근거가 틀렸다는 걸 확인했어요.

🔄 **2026-09-02 후속 2 — 검색 임계값을 상자 높이에 맞췄다.** `SLOT_SEARCH_MIN_CANDIDATES`
20**7**. 20 의 근거로 "한 화면에 들어오는 줄 수" 라고 적었는데 정작 체크박스 상자는
`max-h-[10rem]` = 6.5줄이라, 카탈로그 10건이 6줄만 보이고 그 안에서 또 스크롤됐다.
7 은 그 상자가 스크롤 없이 보여주는 줄 수다 — 스크롤이 생기는 순간 "눈으로 훑는 게 빠르다"
는 전제가 깨진다.

체크박스가 검색보다 빠른 건 눈으로 한 번에 훑을 수 있을 때뿐입니다. 상자 안에서 스크롤이 생기면 그 전제가 깨져요. 그래서 상자가 스크롤 없이 보여주는 7줄이 경계가 됐습니다. 실제 후보 분포가 3·10·35 이상으로 극단적이라 체크박스로 남은 건 상태 하나뿐이었어요.

행 순서는 한 칸도 안 바꿨습니다. 같은 날 의무 수준 정렬은 하지 않기로 했거든요. 바꾼 건 행 안쪽뿐입니다. 결과는 모달 3,290 → 2,077px, 체크박스 2,178 → 13개.

선택기를 관리자 것으로 통일했다 — 갈라져 있던 쪽은 신청서였다

신청서 전용 instance_picker(칩 + 검색칸 세로 적층)를 버리고 관리자 인스턴스 화면이 쓰는 createAsyncRelationPicker로 통일했습니다.

이때 정정한 게 하나 있어요. 처음에는 "관리자 화면도 체크박스 전량 렌더니 나중에 옮겨야 한다"고 적었는데, 확인해 보니 관리자는 이미 검색형이었습니다. 체크박스 전량은 신청서에만 남아 있던 옛 방식이었고, 이 변경이 그걸 관리자에 맞춘 거예요. 옮길 일은 남아 있지 않았습니다.

통일하면서 공용 선택기로 옮긴 것 넷은 관리자 인스턴스·구성 속성·용어 트리 세 화면도 같이 좋아집니다.

    // 한글은 조합 중에도 input 이 자모마다 뜬다 — "속도" 를 치면 ㅅ·소·속·속ㄷ·속도로
    // 다섯 번이고, 앞 네 번은 사람이 찾으려던 말이 아니다. 조합이 끝날 때까지 미룬다.
    let composing = false;
    searchInput.addEventListener("compositionstart", function () {
      composing = true;
    });
    searchInput.addEventListener("compositionend", function () {
      composing = false;
      debouncedSearch();
    });
    searchInput.addEventListener("input", function () {
      if (!composing) debouncedSearch();
    });

"속도"를 치면 요청 5회 → 1회. 디바운스만으로는 안 됐던 게, 자모 사이 간격이 디바운스보다 길면 중간 조합이 그대로 나가거든요. 조합 이벤트로 막아야 합니다.

나머지 셋은 돋보기를 장식에서 검색 실행 버튼으로(예전엔 pointer-events-none이라 클릭이 입력칸으로 통과할 뿐이었어요), 결과 줄 꼬리표 badge(신청서의 신청 칩), 그리고 lockDuplicateFieldNumber — 같은 부모 안에서 필드 번호는 유일해야 하는데 서버가 저장에서 거부하니 고르기 전에 (번호 중복)으로 잠급니다.

통일 뒤에도 남는 차이는 둘이고 둘 다 의도한 것입니다.

관리자신청서
빈 검색어결과 0건 (minSearchLength=2)첫 50건 (0)
후보 범위전체 인스턴스배포분 + 이 신청 작성분

신청자가 남의 심사 중 자료를 고르면 그게 반려될 때 이 신청이 깨집니다. 그리고 관리자는 무엇을 찾을지 알고 오지만, 신청자는 후보가 뭐가 있는지 모르고 와요.

20회 → 2회

선택기를 붙이고 나니 모달 한 번 여는 데 후보 조회가 20번 나갔습니다. 객체 슬롯 9개가 저마다 첫 화면을 미리 조회했고, 대부분은 열어 보지도 않는 슬롯이에요.

문제는 공용 선택기가 스스로 첫 조회를 건다는 거였습니다. minSearchLength === 0이면 만들어지는 순간 runSearch(). 호출부가 미룰 방법이 없었어요.

    // `deferInitialSearch` 는 접힌 자리에 미리 붙이는 호출부용이다 — 신청서 모달은 객체
    // 슬롯이 9개라, 다 스스로 조회하면 모달 한 번 여는 데 후보 조회가 20번 나간다.
    // 켜 두면 첫 조회를 호출부가 `search()` 로 직접 건다.
    if (minSearchLength === 0 && !config.deferInitialSearch) runSearch();

호출부에서는 <details>가 접혀 있으면 미룹니다.

        deferInitialSearch: root.closest("details")?.open === false,

지금은 모달 열기 2회, 슬롯을 펼칠 때 1회, 다시 접었다 펴면 추가 조회 없음.

순서 함정이 하나 있었습니다.

      // ⚠️ 첫 조회는 `setValues` **가 끝난 뒤에** 건다. 선택기는 요청에 일련번호를 매겨
      // 마지막 것만 반영하므로, 나란히 쏘면 뒤늦게 온 선택값 응답이 버려져 칩이
      // "불러오는 중…" 에서 멈춘다.

선택기는 늦게 온 응답을 버립니다. 그건 맞는 설계인데, 기존 선택값을 채우는 setValues와 첫 조회를 나란히 쏘면 선택값 응답이 "늦은 것"으로 분류돼 버려져요. 칩이 영영 "불러오는 중…"입니다.

서버 쪽에서도 하나 막았습니다. 짧은 검색어는 설명을 훑지 않아요 — 필드 번호가 값으로 잡혀 "1"이 사실상 전체 목록과 같아지거든요. 두 글자부터 설명을 열어서 한글 검색(이름은 전부 영문)을 살렸습니다. 총건수는 limit + 1로 갈음합니다. 후보가 2,730건인 슬롯에서 타건마다 COUNT를 돌리지 않아요.

숨은 탭의 required가 submit 자체를 막았다

속성 20칸(1,390px)을 지나야 필드 구성이 나오던 걸 기본 속성 / 데이터 프레임 필드 탭 둘로 갈랐습니다. 새 필드 카드는 <details>로 접고요. 새 필드 3장 기준 5,797px(6.8화면) → 2,138px(2.5화면).

그러자 [추가]를 눌러도 아무 일이 안 일어나는 화면이 됐습니다.

{# ⚠️ `novalidate` 는 탭 때문이다. 숨긴 탭 안의 빈 `required` 는 브라우저가 포커스를
   못 줘서 submit 이벤트 자체를 막고 콘솔에 "not focusable" 만 남긴다.
   검증은 서버가 하고, 돌아온 오류 수를 탭 배지에 적은 뒤 오류가 있는 탭으로
   `requestItemEditor.js` 가 옮겨 준다. #}
<form class="flex flex-col" method="post" data-item-form novalidate

브라우저의 폼 검증은 빈 required 칸에 포커스를 주면서 말풍선을 띄웁니다. 그런데 그 칸이 display:none인 탭 안에 있으면 포커스를 못 줘요. 그러면 말풍선도 못 띄우고 submit 이벤트도 발생시키지 않은 채 콘솔에 "An invalid form control is not focusable"만 남깁니다. 사람 눈에는 버튼이 죽은 거예요.

novalidate로 브라우저 검증을 끄고, 서버가 돌려준 오류 수를 탭 배지에 적고, 오류가 있는 탭으로 옮깁니다(markTabErrors). 탭을 늘리거나 required를 손댈 때 이 셋은 한 묶음이에요.

같은 날 작은 것 둘. [수정]을 눌렀는데 "아무것도 안 불러온 화면"으로 읽혔습니다. 슬롯 순서상 첫 화면 7칸 중 값이 있는 게 요약 정보 하나뿐이고 표제·상태는 스크롤 아래였거든요. 순서를 안 바꾸기로 한 제약 아래 고른 방법이 sticky 제목 줄에 항목 이름을 넣는 것이었습니다. 그리고 HiddenInput 필드가 라벨 줄까지 그려서 화면 아래에 "Users submitted"가 덩그러니 남던 것 — 숨은 위젯은 행을 안 만들게 했습니다.

검증

  • 모달 높이 실측: 기본 2,175px → 속성 탭 1,618 / 필드 탭 812. 새 필드 3장 5,797 → 2,138. 카드 한 장 추가 +1,232 → +76.
  • 후보 조회 횟수: 모달 열기 20 → 2, 슬롯 펼침 +1, 재펼침 0.
  • "속도" 타이핑 시 요청 5 → 1.
  • 서버 테스트: 후보 7건 초과 슬롯만 검색칸이 되고 짧은 것은 체크박스로 남는 것, 짧은 검색어가 설명을 안 훑고 이름 앞부분 일치가 앞에 오는 것, 작성 단계 밖 클래스의 슬롯 조회가 404인 것(그 밖 250개 슬롯).
  • 모달 제목에 항목 이름이 뜨는 회귀 테스트 추가.

남은 것 · 한계

  • 근본 원인인 슬롯 순서는 그대로입니다. "[수정]을 눌렀는데 빈 화면"은 제목 줄로 가렸지, 값 있는 칸 강조·빈 선택 칸 접기는 안 했어요.
  • 검색기 두 벌(asyncRelationPicker / instance_picker)이 여전히 따로 있습니다. 객체 슬롯은 공용 선택기로 옮겼지만 같은 모달의 필드 선택 자리는 아직 instance_picker 매크로예요. 합칠지는 미정입니다 — 후보 범위가 호출부마다 다르다는 게 전제라서요.
  • novalidate는 탭 하나 때문에 폼 전체의 브라우저 검증을 끈 것입니다. 서버 왕복 없이 잡히던 빈 칸이 이제 한 번 다녀와야 해요.
  • 폴더 탭의 활성 클래스 묶음이 두 곳에 박혀 있습니다. 템플릿과 JS TAB_ACTIVE가 같은 문자열을 들고 있어 한쪽만 고치면 갈라집니다.
  • 값 전달 규약이 hidden input이 아닙니다. 공용 선택기는 다중 선택에서 폼 이름이 붙은 input을 안 만들어서 제출 직전에 appendPickerValues()로 싣습니다. 폼을 다른 경로로 제출하는 코드가 생기면 선택값이 조용히 빠져요.

관련 글: 라벨을 기다리다 값이 사라진다 · 옵션이 많아질수록 필요한 검색형 select · 승인될 때까지 아무것도 만들지 않기 · 신청서 단계 구획이 로딩 중에 전부 보였다