- 발행일
jquery-formset을 넣고 두 달 뒤 인덱스를 직접 다시 매겼다 — 삭제 체크박스를 켠 날과 20가지 시나리오
jquery-formset을 넣고 두 달 뒤 인덱스를 직접 다시 매겼다
이 글은 2026년 9월에 당시 커밋 이력을 다시 보며 정리한 것입니다. 날짜는 작업한 날 기준입니다.
입사하고 한 달쯤 됐을 때 맡은 화면이 워크플로 관리였습니다. 프로세스 하나에 상태·전이·액션·활동이 줄줄이 매달리고, 각각이 Django 인라인 폼셋이에요. workflow/forms.py에 inlineformset_factory 호출이 등록용·수정용 쌍으로 12쌍 있었습니다.
전부 can_delete=False였습니다. 추가는 되는데 지울 수가 없었어요. 프런트만 하다 온 입장에서 "삭제 버튼 하나 붙이면 되겠지" 하고 들어갔다가, 두 달에 걸쳐 세 번 손을 댔습니다.
5월 9일 — 삭제 체크박스를 켜고 라이브러리를 넣었다
오전 10시 5분에 static/formset/jquery.formset.js 308줄을 통째로 저장소에 넣었습니다(d6b890a0). 한 시간 뒤 can_delete=False를 24곳에서 걷어냈어요(088cd296).
ProcessStateFormSet = inlineformset_factory(Process, State, \
- form=StateForm, extra=5, can_delete=False)
+ form=StateForm, extra=5)
can_delete를 켜면 Django가 폼마다 DELETE 체크박스를 만듭니다. 그걸 화면에 그대로 보이게 두고 싶지는 않아서, 라이브러리가 만드는 추가·삭제 링크를 숨기고 우리 버튼이 대신 눌러 주는 구조로 했어요.
$forms.formset({
prefix: prefix,
addText: "",
deleteText: "",
addCssClass: "js-formset-add",
deleteCssClass: "js-formset-delete",
});
$wrap.find(".add-formset-button").on("click", function() {
$wrap.find(".js-formset-add").click();
});
커밋 제목이 "Keep both custom and default data formset handling to avoid JS delete/add issues"입니다. 라이브러리 기본 동작과 우리 버튼을 둘 다 살려 둔다는 뜻인데, 지금 읽으면 어느 쪽이 인덱스를 책임지는지 정하지 않은 채 시작한 것이었어요. 그때는 그게 문제가 될 줄 몰랐습니다.
7월 9일 저녁 — 인덱스를 직접 다시 매겼다
두 달 뒤 18시 45분 커밋(5f4a8933)입니다. 제목은 "reindex inline formsets on add and delete actions", 본문 셋째 줄이 "Ensure compliance with Django's formset structure"예요. 항목을 지우고 다시 추가한 뒤 저장하면 어긋났습니다. 무엇이 어떻게 어긋났는지는 커밋에 남아 있지 않고, 코드 주석에 한 줄 있습니다.
// 폼 인덱스 자동 재정렬 -> db 인덱스 이슈 해결 방법
function reindexFormset(prefix) {
const $formsetContainer = $(`.accordion-body[data-prefix='${prefix}']`);
const $totalForms = $(`input[name="${prefix}-TOTAL_FORMS"]`);
const $visibleForms = $formsetContainer.find(".form-row:visible");
$visibleForms.each(function(index) {
const $form = $(this);
$form.find("input, select, textarea").each(function() {
const $field = $(this);
const name = $field.attr("name");
if (name && name.includes(prefix + "-")) {
const newName = name.replace(new RegExp(prefix + "-(\\d+)-"), prefix + "-" + index + "-");
$field.attr("name", newName);
}
// id, label[for] 도 같은 방식
});
});
$totalForms.val($visibleForms.length);
}
Django 폼셋은 prefix-0-name, prefix-1-name처럼 번호가 이어져야 하고 TOTAL_FORMS가 그 개수와 맞아야 합니다. 라이브러리가 붙이는 번호와 우리가 DOM에서 지우는 행이 서로를 모르니 번호에 구멍이 생겼고, 그걸 저장 직전에 0부터 다시 매기는 게 이 함수예요.
삭제도 이때 두 갈래로 갈랐습니다. 라이브러리의 삭제 링크를 누르던 한 줄을 빼고, 기존 행과 새 행을 다르게 다뤄요.
const deleteField = $formRow.find("input[id$='-DELETE']");
if (deleteField.length) {
deleteField.val("on");
$formRow.hide();
} else {
$formRow.remove();
reindexFormset(prefix);
}
DB에 있던 행은 DELETE를 켜고 숨깁니다. Django가 지워야 하니까요. 방금 추가한 행은 서버가 모르는 행이니 DOM에서 떼고 번호를 다시 매깁니다. 이 구분을 5월에는 하지 않았습니다.
같은 날 밤 — 숨긴 행을 세지 않고 있었다
21시 1분에 한 번 더 커밋했습니다(2c5d3312). 제목은 "테스트 진행하기"인데 diff의 핵심은 세 줄이에요.
- const $visibleForms = $formsetContainer.find(".form-row:visible");
+ const $allForms = $formsetContainer.find(".form-row");
...
- $totalForms.val($visibleForms.length);
+ $totalForms.val($allForms.length);
저녁 버전은 보이는 행만 셌습니다. 왜 바꿨는지 커밋에는 없는데, diff로는 이렇게 읽힙니다. 기존 행을 지우면 숨기기만 하잖아요. 숨긴 행이 TOTAL_FORMS에서 빠지면 Django는 그 번호까지만 읽고, DELETE가 켜진 행은 아예 안 봅니다. 지웠다고 생각한 게 그대로 남는 거예요.
이 커밋의 메시지 본문에 시나리오 20개를 적어 뒀습니다. 그때 제가 손으로 돌린 목록이에요.
1. 기본 작성 후 저장 O
2. 전체 삭제 후 저장 O
5. 항목 추가 후 개별 삭제 후 저장 O
9. 항목 추가 후 중간에 있는 텍스트 삭제시 정상 저장 O
13. 단일 항목에서 개별 삭제: 항목이 1개만 있을 때 개별 삭제 O
18. 기존 데이터 개별 삭제: DB에 저장된 기존 항목을 개별 삭제 (DELETE 필드 처리) O
19. 신규 추가 항목 개별 삭제: 방금 추가한 항목을 바로 개별 삭제 (DOM 제거) O
20. 혼합 상태에서 전체 삭제: 기존 데이터 + 신규 추가 항목이 섞인 상태에서 전체 삭제 O
15번 "빈 상태에서 전체 삭제"만 O 표시가 없습니다. 그 케이스 때문에 같은 커밋에서 "삭제할 항목이 없습니다" 안내를 넣었어요. 자동 테스트로 만들 생각은 그때 못 했습니다.
지금도 그대로 돌고 있다
일주일 뒤 이 스크립트는 템플릿에서 static/js/formsetManager.js로 분리됐고(3d9ed29c), 2026년 9월 현재도 reindexFormset이 같은 파일 10행에 그대로 있습니다. workflow/forms.py의 inlineformset_factory 25개 중 can_delete=False는 0개, jquery.formset.js는 5월 9일에 넣은 파일 그대로예요.
검증
- 7월 9일 밤 시나리오 20개를 손으로 돌렸습니다. 결과는 커밋 메시지에 O로 남겼고, 15번 하나는 표시가 없습니다.
- 재인덱스 뒤
name·id·label[for]셋이 같은 번호로 바뀌는 건 개발자 도구로 봤을 겁니다. 기록은 시나리오 20개뿐입니다. - 자동 테스트는 없습니다. 지금 저장소에도 이 함수를 검사하는 스펙은 없어요.
남은 것 · 한계
- 추가 뒤
setTimeout(…, 100)으로 재인덱스합니다. 라이브러리가 행을 붙이는 타이밍을 몰라서 100ms를 기다리는 건데, 왜 100인지 근거가 없습니다. 오늘도 그대로예요. - 라이브러리와 직접 조작이 공존합니다. 추가는 라이브러리, 삭제는 우리 코드. 5월 커밋 제목의 "both"가 지금까지 이어진 셈이에요. 라이브러리를 걷어내고
empty_form으로 직접 붙이는 쪽이 단순했을 텐데, 그때는 라이브러리를 이해하는 것보다 그 위에 덧대는 게 빨라 보였습니다. - 20개 시나리오가 코드로 남지 않았습니다. 커밋 메시지에만 있어요. 이 함수를 건드리는 사람은 그 목록을 찾아 다시 손으로 돌려야 합니다.
- 1년 뒤 신청서 화면은 폼셋을 안 씁니다. 작성분을 초안 JSON으로 두고 승인 때 서버가 만드는 쪽으로 갔어요. 폼셋 번호를 화면에서 맞추는 일 자체를 없앤 건데, 그 선택은 승인될 때까지 아무것도 만들지 않기에 있습니다.
관련 글: Django Formset과 Inline Formset 이해하기 · 승인될 때까지 아무것도 만들지 않기 · 파일 33개를 한 커밋에 넣었다 · 마이페이지를 뷰부터 URL까지 처음 짰다