발행일

필수 여부 체크박스를 3단계 의무 수준으로 — 서버가 아직 불리언인 채로

필수 여부 체크박스를 3단계 의무 수준으로

메타클래스에는 구성 속성이 달립니다. 각 속성이 이 클래스에서 필수인지 아닌지를 관리자가 지정해요. 화면은 이걸 Set 하나로 들고 있었습니다.

const requiredIds = new Set();

들어 있으면 필수, 없으면 아님. 실무에서 나온 요구는 그 사이였습니다. "안 채워도 되지만 채우는 게 좋은 속성" 이 상당히 많았어요. 메타데이터 표준을 따르는 시스템에서는 흔한 구분입니다(DCMI 계열 문서도 mandatory / recommended / optional로 나눕니다).

서버가 아직 불리언인데 화면부터 바꿨다

여기서 순서 문제가 있었습니다. 서버 필드는 is_required 불리언입니다. 3값으로 바꾸려면 모델 변경 + 마이그레이션 + 시리얼라이저 + 적재 스크립트가 딸려 오고, 그건 기획 확인이 필요한 일이었어요.

방법장점포기하는 것판단
① 서버 3값 전환까지 기다렸다 한 번에반쪽 상태가 안 생김기획 확인이 며칠~몇 주. 그동안 화면은 체크박스 그대로고, 나중에 UI를 처음부터 다시 논의하게 됨기각
② 화면 3단계 + 서버는 is_required로 눌러 저장(권장 → 필수)저장은 됨거짓말이 저장됨. 권장으로 둔 걸 다음에 열면 필수로 보임. 데이터가 오염되고 되돌릴 근거가 없음기각
③ 화면 3단계 + 권장은 화면 상태로만, 저장은 필수/아님만컨트롤·문구·색을 실물로 확인받을 수 있음. 서버가 3값이 되면 입출력만 넓히면 끝새로고침하면 권장이 선택으로 돌아감. 사용자에게 "아직 저장 안 됨"을 설명해야 함채택
④ 로컬스토리지에 권장만 별도 보관새로고침에도 남음브라우저·사람마다 다른 값이 보임. 서버 데이터와 어긋나는 그림자 상태가 생김 — ②보다 나쁨기각

③으로 갔습니다. "저장되지 않는다"를 감추지 않는 것이 조건이었어요. 코드에 그대로 적었습니다.

// 의무 수준(obligation) — id → 0 선택 / 1 권장 / 2 필수.
// ponytail: 서버 필드는 아직 is_required boolean 이라 저장되는 값은 2(필수)뿐이고
// 1(권장)은 화면 상태로만 남는다. 3값 필드가 들어오면 setFlags/getRequiredValues만 넓히면 된다.
const obligations = new Map();

function obligationOf(id) {
  return obligations.get(id) || 0;
}

function isMandatory(id) {
  return obligationOf(id) === 2;
}

SetMap으로 바꾸면서 판정을 isMandatory() 한 곳으로 모은 게 이 작업에서 제일 값어치 있었습니다. 전에는 requiredIds.has(id)가 여기저기 흩어져 있었는데, 그게 그대로 남아 있었으면 "필수인가"를 묻는 자리마다 === 2인지 >= 1인지를 따로 판단하게 됐을 겁니다. 키 지정 규칙(필수만 키로 지정 가능)도 isMandatory()를 그대로 씁니다.

밑줄이 미끄러지지 않던 이유

컨트롤은 세그먼트 형태로 만들었습니다. 배경·테두리 없이 밑줄만 좌우로 미끄러지는 모양이에요.

// 의무 수준 세그먼트 — 밑줄만 좌우로 미끄러지는 형태. 칩이 이미 테두리를 두른 카드라
// 컨트롤까지 박스를 두르면 액자 속 액자가 된다 → 배경·테두리 없이 글자와 밑줄만 둔다.
// 칸 폭(w-9)과 밑줄 폭(w-1/3)이 맞물려야 밑줄이 한 칸을 정확히 덮는다. 칸 수를 바꾸면
// 두 값을 함께 고쳐야 한다.
const OBLIGATION_LABELS = ["선택", "권장", "필수"];
const OBLIGATION_BAR = [
  "translate-x-0 bg-gray-400",
  "translate-x-full bg-sky-500",
  "translate-x-[200%] bg-amber-500",
];

처음엔 클릭할 때마다 칩을 통째로 다시 그렸습니다. 그랬더니 밑줄이 안 미끄러지고 순간이동을 했어요. innerHTML로 새로 만든 요소는 처음부터 최종 위치에 그려지니까, transition-transform이 걸릴 "이전 상태"가 없습니다.

제자리에서 클래스만 갈아 끼우게 바꿨습니다.

// 클릭 시엔 칩 전체를 다시 그리지 않고 이 자리에서 색·위치만 바꾼다. innerHTML로 새로
// 만든 밑줄은 처음부터 최종 위치라 transition이 걸리지 않아 '미끄러짐'이 사라진다.
function paintObligation(group, level) {
  const bar = group.querySelector("[data-obligation-bar]");
  if (bar) bar.className = OBLIGATION_BAR_BASE + OBLIGATION_BAR[level];
  group.querySelectorAll("[data-obligation]").forEach(function (btn) {
    btn.className = obligationSegClass(level, Number(btn.dataset.obligation));
  });
}

"다시 그리기"가 애니메이션과 상극이라는 건 알고는 있었는데, 상태가 하나뿐인 작은 컨트롤에서는 매번 다시 그리는 게 편해서 습관처럼 그렇게 했다가 걸렸습니다.

표시 순서를 사람이 정하게

3단계가 붙자 다음 요구가 따라왔습니다. 필수 속성이 스무 개면 그 안에서의 순서도 의미가 있어요. 목록 화면 표의 열 순서가 이걸 따라가거든요.

손잡이 드래그와 Alt+↑/↓로 재배열하게 만들었습니다. 규칙은 묶음(필수·권장·선택) 안에서만 이동입니다. 선택 항목이 필수 항목 위로 올라가면 표 열 순서가 무의미해지니까요.

묶음을 넘는 드롭을 어떻게 처리할지가 갈림길이었습니다.

  • 거부(원위치로 튕김) — 사용자가 "왜 안 되지"만 알고 무엇이 규칙인지는 모름
  • 클램프(자기 묶음의 첫째/꼴찌로 붙임) — 놓은 방향은 존중하면서 규칙도 보임

클램프를 골랐습니다. 필수 묶음 위로 끌어 놓으면 선택 묶음의 맨 앞에 붙어요. "위로 가고 싶다는 건 알겠는데 여기까지"가 눈에 보입니다. 등급을 바꾼 칩도 같은 규칙으로 새 묶음 끝에 붙습니다.

재배열 로직은 chipDragSort.js로 분리했습니다. 이 모듈은 의무 수준을 모릅니다.

// 컨테이너 안의 항목을 손잡이로 끌어(또는 Alt+화살표로) 재배열하는 DOM 유틸.
//
// bandOf 를 주면 '묶음'을 넘는 이동을 막는 대신 자기 묶음의 첫째/꼴찌로 붙인다
// (구성 속성에서 선택 항목이 필수 항목 위로 올라가지 못하게 하는 규칙).
// 항목의 의미(의무 수준·id 등)는 전혀 모르고, 콜백이 돌려주는 숫자만 비교한다.

여기서 실수를 하나 했다가 고쳤습니다. 처음엔 칩 전체에 draggable을 걸었는데, 칩 안에 세그먼트 버튼이 세 개나 있어서 "권장"을 누르려다 칩이 끌려갔습니다. 손잡이를 누른 동안에만 draggable을 켜게 바꿨어요.

// 손잡이를 누른 동안에만 draggable 을 켠다 — 항목 전체를 draggable 로 두면
// 안에 있는 버튼을 누르다 끌려간다.
container.addEventListener("mousedown", function (event) { ... });

손잡이는 <div>가 아니라 <button> + aria-label + 포커스 링입니다. Alt+↑/↓를 넣은 것도 같은 이유예요 — 드래그만 있으면 마우스 없이는 순서를 못 바꿉니다.

이틀 전에 넣은 버튼을 지웠다

작업 순서가 이랬습니다.

  1. 7/30 — 편집 화면에 "등급순 정렬" 버튼 추가. 필수 → 권장 → 선택으로 묶음만 정돈(같은 등급 안 순서는 안정 정렬로 유지)
  2. 7/30 — 드래그 재배열 추가
  3. 7/31①에서 넣은 버튼을 제거하고, 조회 표 쪽에 "기본순 | 필수순" 표시 토글을 신설

②를 만들고 나서야 ①이 ②와 싸운다는 게 보였습니다. 편집 화면에서 손으로 순서를 잡아놨는데 정렬 버튼을 누르면 그게 흐트러집니다. 두 기능이 같은 자리에서 반대 방향으로 당기고 있었어요.

정리하면서 정한 역할 분담:

자리하는 일저장되는가
편집 화면 (드래그)순서를 정한다예정 — 서버 order 필드 대기
조회 표 토글 (기본순/필수순)순서를 잠깐 다르게 본다아니오 (표시 전용, seq 불변)

"필수 먼저 보고 싶다"는 요구는 사실 보기의 문제였는데, 편집 화면에 버튼으로 넣었던 겁니다. 조회 쪽으로 옮기니 편집 화면은 "순서를 정한다"에만 집중하게 됐어요.

라벨도 바꿨습니다. 등급순필수순. 열 머리글이 "필수 여부"인데 정렬 라벨만 "등급"이면, 화면에 없는 용어를 사용자가 추측해야 합니다.

검증

  • 회귀 잠금(Playwright): 토글 왕복 케이스 추가 — 정렬 비교자를 일부러 제거해 레드가 뜨는 것을 확인하고 되돌렸습니다. 정렬 테스트는 데이터가 이미 그 순서면 통과해버려서, 레드 확인 없이는 믿을 수 없어요.
  • 삭제한 등급순 버튼의 케이스도 같이 삭제. 죽은 기능의 초록 스펙을 남기면 다음 사람이 "있는 기능"으로 읽습니다.
  • 안정 정렬 확인: 같은 등급 안에서는 seq 순서가 그대로 유지.
  • itemFlags를 안 넘기는 기존 호출부(다른 화면의 관계 선택기)는 영향 없음 — opt-in 구조 유지.

남은 것 · 한계 — 이 글에서 제일 중요한 절

  • 표시 순서가 저장되지 않습니다. 서버에 order 필드가 없어서, 끌어서 정리한 순서는 새로고침하면 사라집니다. 화면 한정 준비 단계예요. 이걸 모르고 쓰면 "정리했는데 다음날 원상복구"를 겪습니다. 화면에 안내를 못 붙인 게 지금 상태의 제일 큰 구멍입니다.
  • 권장(1)도 저장되지 않습니다. 저장되는 건 필수(2)뿐이고 권장은 새로고침하면 선택으로 돌아갑니다. 위에서 골라놓고도 이 부분은 여전히 사용자에게 불친절합니다.
  • 이 둘 때문에 지금 이 화면은 "실물 확인용"에 가깝습니다. 서버 3값 필드 + order 필드가 들어와야 완결돼요. 그때 손댈 자리는 setFlags/getRequiredValues 둘로 좁혀뒀습니다.
  • 칸 수와 밑줄 폭(w-9 / w-1/3)이 손으로 맞물려 있습니다. 4단계가 되면 두 값을 같이 고쳐야 하는데, 주석으로만 묶어둔 상태라 한쪽만 고칠 위험이 남아요.
  • 서버 페이로드 변경(is_object 전달, 3값 필드)은 전부 프런트 판단이라 기획 확인 대기입니다. 인수인계 문서에 미해결로 올려뒀습니다.

관련 글: 플래그를 잘못된 테이블에 달아뒀다 · disabled를 쓰면 왜 안 되는지를 말할 수 없다