- 발행일
필수 여부 체크박스를 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;
}
Set → Map으로 바꾸면서 판정을 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+↑/↓를 넣은 것도 같은 이유예요 — 드래그만 있으면 마우스 없이는 순서를 못 바꿉니다.
이틀 전에 넣은 버튼을 지웠다
작업 순서가 이랬습니다.
- 7/30 — 편집 화면에 "등급순 정렬" 버튼 추가. 필수 → 권장 → 선택으로 묶음만 정돈(같은 등급 안 순서는 안정 정렬로 유지)
- 7/30 — 드래그 재배열 추가
- 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값 필드)은 전부 프런트 판단이라 기획 확인 대기입니다. 인수인계 문서에 미해결로 올려뒀습니다.