발행일

Django Formset과 Inline Formset 이해하기

Django Formset과 Inline Formset 이해하기

Django Formset은 동일한 폼을 한 페이지에서 여러 개 편집할 수 있게 해주는 기능입니다. 예를 들어 게시글 하나에 여러 개의 태그를 동시에 입력하거나, 주문서에 여러 상품 항목을 한 번에 추가하는 경우에 사용합니다.


Formset vs Inline Formset

구분FormsetInline Formset
관계독립적인 여러 객체부모-자식 FK 관계
팩토리modelformset_factoryinlineformset_factory
FK 연결수동으로 설정부모 instance 전달 시 자동
사용 예태그 일괄 등록주문-상품 항목, 게시글-첨부파일

Formset — 독립적인 여러 객체

from django.forms import modelformset_factory
from .models import Tag

# 폼셋 팩토리 생성
TagFormSet = modelformset_factory(Tag, fields=('name',), extra=3)

# 뷰에서 사용
def tag_create(request):
    if request.method == 'POST':
        formset = TagFormSet(request.POST)
        if formset.is_valid():
            formset.save()
    else:
        formset = TagFormSet(queryset=Tag.objects.filter(active=True))
    return render(request, 'tags.html', {'formset': formset})
  • extra=3: 빈 폼을 3개 추가로 렌더링
  • queryset: 초기값으로 보여줄 데이터

Inline Formset — 부모-자식 관계

from django.forms import inlineformset_factory
from .models import Dataset, DatasetDetail

# 부모(Dataset) → 자식(DatasetDetail) 관계
DatasetDetailFormSet = inlineformset_factory(
    Dataset,        # 부모 모델
    DatasetDetail,  # 자식 모델
    fields=('name', 'value'),
    extra=3,
    can_delete=True  # 삭제 체크박스 포함
)

뷰에서 사용

def dataset_edit(request, pk):
    dataset = get_object_or_404(Dataset, pk=pk)

    if request.method == 'POST':
        formset = DatasetDetailFormSet(request.POST, instance=dataset)
        if formset.is_valid():
            instances = formset.save(commit=False)
            for obj in instances:
                obj.dataset = dataset
                obj.save()
            formset.save_m2m()
            return redirect('dataset_detail', pk=pk)
    else:
        formset = DatasetDetailFormSet(instance=dataset)

    return render(request, 'dataset_edit.html', {
        'dataset': dataset,
        'formset': formset
    })

핵심은 instance=dataset을 전달하면 FK가 자동으로 연결된다는 점입니다.


템플릿

<form method="post">
    {% csrf_token %}
    {{ formset.management_form }}  {# 반드시 포함 #}

    {% for form in formset %}
        <div class="form-row">
            {{ form.as_p }}
        </div>
    {% endfor %}

    <button type="submit">저장</button>
</form>

management_form은 formset의 총 개수, 초기 개수 등 메타 정보를 담고 있습니다. 누락 시 ManagementForm data is missing 오류가 발생합니다.


주요 옵션 정리

옵션설명기본값
extra추가 빈 폼 개수1
max_num최대 폼 개수제한 없음
min_num최소 폼 개수0
can_delete삭제 체크박스 포함False
can_order순서 변경 필드 포함False
fields포함할 필드 목록모든 필드
exclude제외할 필드 목록

동적으로 폼 추가하기 (JavaScript)

기본 Formset은 서버에서 렌더링된 폼 수가 고정됩니다. 사용자가 버튼으로 폼을 동적으로 추가하려면 TOTAL_FORM_COUNT를 JavaScript로 업데이트해야 합니다.

let formCount = parseInt(document.getElementById('id_form-TOTAL_FORMS').value);

document.getElementById('add-form').addEventListener('click', function () {
    const newForm = document.querySelector('.form-row').cloneNode(true);
    // 인덱스 교체
    newForm.innerHTML = newForm.innerHTML.replace(/form-\d+/g, `form-${formCount}`);
    document.getElementById('formset-container').appendChild(newForm);
    formCount++;
    document.getElementById('id_form-TOTAL_FORMS').value = formCount;
});

라이브러리를 쓰고 싶다면 django-fancy-formset이나 django-crispy-forms를 활용할 수 있습니다.


실무에 붙이면서 고친 것들

위 예제를 실제 화면(표준데이터 부가정보 입력)에 쓰면서 세 군데를 고쳤습니다.

commit=False를 쓰면 삭제가 동작하지 않는다

가장 오래 못 찾은 버그였습니다. can_delete=True로 삭제 체크박스를 넣었는데 체크하고 저장해도 항목이 안 지워졌어요.

원인은 저장 코드였습니다.

instances = formset.save(commit=False)
for obj in instances:
    obj.dataset = dataset
    obj.save()
formset.save_m2m()

save(commit=False)새로 만들거나 수정된 객체만 돌려줍니다. 삭제 대상은 formset.deleted_objects에 따로 담기고, 직접 지워줘야 해요. save_m2m()은 M2M만 처리하지 삭제와는 무관하고요.

instances = formset.save(commit=False)
for obj in instances:
    obj.save()
for obj in formset.deleted_objects:   # ← 이게 빠져 있었다
    obj.delete()
formset.save_m2m()

그리고 하나 더 — obj.dataset = dataset은 애초에 필요 없습니다. 바로 위에서 "instance=dataset을 전달하면 FK가 자동으로 연결된다" 고 써놓고 코드에서는 수동으로 다시 넣고 있었어요. 글 안에서 설명과 코드가 어긋난 셈입니다.

commit=False가 필요한 이유(저장 전에 뭔가 손대야 함)가 없다면 그냥 formset.save() 한 줄이 맞습니다. 그러면 삭제도 알아서 처리돼요.

② 동적 폼 추가는 empty_form으로

예제의 JS는 첫 번째 행을 복제합니다.

const newForm = document.querySelector('.form-row').cloneNode(true);
newForm.innerHTML = newForm.innerHTML.replace(/form-\d+/g, `form-${formCount}`);

두 가지가 문제입니다. 첫 행에 이미 입력된 값이 같이 복제되고(빈 폼이 아니라 복사본), innerHTML을 문자열로 갈아끼우니 그 안의 이벤트 리스너가 전부 날아갑니다.

Django가 이걸 위한 빈 폼을 제공합니다. formset.empty_form은 인덱스 자리에 __prefix__가 들어간 템플릿이에요.

<div id="empty-form" class="d-none">
    {{ formset.empty_form.as_p }}   {# form-__prefix__-name ... #}
</div>
const total = document.getElementById('id_form-TOTAL_FORMS');
document.getElementById('add-form').addEventListener('click', function () {
    const index = parseInt(total.value, 10);
    const html = document.getElementById('empty-form').innerHTML
                   .replace(/__prefix__/g, index);
    document.getElementById('formset-container')
            .insertAdjacentHTML('beforeend', html);
    total.value = index + 1;
});

빈 폼의 정의를 서버가 갖고 있게 두는 게 핵심입니다. 필드가 추가돼도 JS를 안 고쳐도 되고요.

extra는 작을수록 좋았다

처음에 extra=3으로 뒀습니다. 예제들이 대체로 그렇게 쓰기도 하고, 넉넉하면 편할 거라 생각했어요.

실제로는 반대였습니다. 대부분의 사용자가 한 건만 입력하는데 빈 행 세 개가 먼저 보이니 "세 개를 다 채워야 하나" 로 읽혔습니다. 화면도 그만큼 길어지고요.

extra=1로 줄이고 "행 추가" 버튼을 두는 쪽으로 바꿨습니다. 빈 칸을 미리 깔아두는 것과 필요할 때 늘리는 것은 사용자에게 다른 메시지를 줍니다. 전자는 요구고 후자는 선택이에요.


언제 무엇을 쓸까?

  • Formset: 독립적인 여러 객체를 한 번에 처리할 때 (태그 일괄 등록, 설문 답변 일괄 저장)
  • Inline Formset: 부모와 자식 관계가 명확할 때 (주문-상품, 게시글-첨부파일, 데이터셋-세부항목)

Inline Formset은 instance만 넘기면 FK 연결을 알아서 처리해주기 때문에, 부모-자식 관계라면 Inline Formset이 훨씬 간편합니다.

정리하면 formset.save()로 끝낼 수 있으면 그렇게 하고, commit=False를 쓴다면 삭제 처리까지 직접 챙겨야 합니다. 그 한 줄이 빠지면 "삭제가 안 되는" 조용한 버그가 됩니다.