- 발행일
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으로 두고 승인 때 서버가 만드는 쪽으로 갔어요. 폼셋 번호를 화면에서 맞추는 일 자체를 없앤 건데, 그 선택은 승인될 때까지 아무것도 만들지 않기2026-08-25승인될 때까지 아무것도 만들지 않기 — 신청서 작성분을 초안으로 두고 승인 때 재생하기신청서에서 엘리먼트·프레임·메시지를 직접 쓸 수 있게 했는데, 신청 시점에 만들어 버리면 반려된 신청의 찌꺼기가 목록에 남습니다. 작성분을 JSON 초안 한 칸에 두고 승인 시점에 자식부터 순서대로 재생하게 했어요. 그러다 초안 JSON이 PostgreSQL btree 색인 상한 2704바이트에 걸려 INSERT가 통째로 실패했습니다.에 있습니다.
관련 글: Django Formset과 Inline Formset 이해하기2025-08-21Django Formset과 Inline Formset 이해하기Django에서 여러 폼을 한 페이지에서 처리하는 Formset과 부모-자식 관계를 함께 처리하는 Inline Formset의 차이와 사용법을 정리합니다. · 승인될 때까지 아무것도 만들지 않기2026-08-25승인될 때까지 아무것도 만들지 않기 — 신청서 작성분을 초안으로 두고 승인 때 재생하기신청서에서 엘리먼트·프레임·메시지를 직접 쓸 수 있게 했는데, 신청 시점에 만들어 버리면 반려된 신청의 찌꺼기가 목록에 남습니다. 작성분을 JSON 초안 한 칸에 두고 승인 시점에 자식부터 순서대로 재생하게 했어요. 그러다 초안 JSON이 PostgreSQL btree 색인 상한 2704바이트에 걸려 INSERT가 통째로 실패했습니다. · 파일 33개를 한 커밋에 넣었다2025-07-17파일 33개를 한 커밋에 넣었다 — 제목은 한 가지 일이라고 말하고, 안에는 다섯 가지가 있었다입사 석 달째, 파일 33개에 1,228줄을 더하고 823줄을 지운 커밋을 올렸습니다. 제목은 스크립트 하나를 바꿨다는 한 줄이었어요. 지금 세어 보니 그 안에 이름 바꾸기, 새 모듈 다섯, 인라인 스크립트 분리, 설정 변경, 뷰 정리가 같이 들어 있었습니다. 그중 하나는 두 달 뒤 팀원이 지웠고, 하나는 1년 뒤 버그로 돌아왔습니다. · 마이페이지를 뷰부터 URL까지 처음 짰다2025-10-01마이페이지를 뷰부터 URL까지 처음 짰다 — 사흘 동안 TemplateView에서 UpdateView까지입사 여섯 달 만에 템플릿이 아니라 뷰와 URL부터 짠 첫 화면이 마이페이지였습니다. 첫날은 GET만 받는 TemplateView에 값을 박아 넣었고, 이튿날 입력칸을 열었는데 뷰는 여전히 GET만 받았어요. 사흘째에 UpdateView와 PasswordChangeView로 갈아탔고, 손으로 넣은 비밀번호 눈 아이콘이 스크립트가 붙이는 것과 겹쳐 지웠습니다. 그 과정을 커밋 일곱 개로 돌아봅니다.