FormData 배열 전송: `[]` 표기법과 JSON.stringify의 모든 것

최종욱·2025년 10월 10일

📌 문제의 시작

프론트엔드에서 FormData로 배열을 전송할 때, 같은 프로젝트 내에서도 다양한 방식이 혼재되어 있었습니다:

// 방식 1: JSON.stringify
formData.append('deleteTimelines', JSON.stringify([1, 2, 3]));

// 방식 2: forEach ([] 없음)
dto.deletedResourceIds.forEach(id => {
  formData.append('deletedResourceIds', id.toString());
});

// 방식 3: forEach + []
dto.deletedFileIds.forEach(id => {
  formData.append('deletedFileIds[]', id.toString());
});

특히 타임라인 API에서 deleteTimelines[]deleteTimelines로 변경하니 제대로 작동했는데, 왜 그런지 궁금했습니다.


🔍 핵심 원리

1. FormData의 본질적 한계

FormData는 키-값 쌍만 전송할 수 있습니다. 배열을 표현하는 표준 방법이 없습니다.

// 실제 HTTP 전송 형태
Content-Disposition: form-data; name="ids"
value="1"

2. 백엔드 파싱 동작 방식

같은 키에 값이 몇 개 append 되었는지에 따라 결과가 달라집니다:

전송 방식값 개수백엔드 파싱 결과
append('ids', '1')1개"1" (문자열) ❌
append('ids', '1') × 22개["1", "2"] (배열) ✅
append('ids[]', '1')1개["1"] (배열) ✅
append('ids[]', '1') × 22개["1", "2"] (배열) ✅

3. [] 표기법의 역할

formData.append('deletedFileIds[]', '1');

[]는 단순한 명시가 아니라, 실제로 키 이름의 일부입니다!

HTTP 전송: name="deletedFileIds[]"
           
NestJS/Multer 파싱: "[] 패턴 발견! 배열로 변환해야겠다"
                   → deletedFileIds = ["1"]

💡 세 가지 전송 방식 비교

방식 1: JSON.stringify

// 프론트엔드
formData.append('deleteTimelines', JSON.stringify([1, 2, 3]));

// 백엔드 DTO
@Transform(({ value }) => (typeof value === 'string' ? JSON.parse(value) : value))
deleteTimelines?: number[];

// 결과: ✅ 항상 배열로 파싱

장점:

  • ✅ 값이 1개든 여러 개든 항상 안전
  • ✅ 복잡한 객체 배열도 전송 가능
  • ✅ 코드가 간결

단점:

  • ⚠️ 백엔드에서 JSON 파싱 필요

방식 2: forEach ([] 없음)

// 프론트엔드
dto.deletedResourceIds.forEach(id => {
  formData.append('deletedResourceIds', id.toString());
});

// 백엔드: 자동 배열 변환 (여러 값일 때만)

장점:

  • ✅ 백엔드에서 자동 파싱 (여러 값일 때)

단점:

  • ❌ 값이 1개일 때 문자열로 인식되어 에러!
  • ❌ 단순 값(숫자/문자열)만 가능

방식 3: forEach + []

// 프론트엔드
dto.deletedFileIds.forEach(id => {
  formData.append('deletedFileIds[]', id.toString());
});

// 백엔드: [] 패턴 인식 → 무조건 배열로 변환

장점:

  • ✅ 값이 1개든 여러 개든 항상 배열로 보장
  • ✅ 백엔드에서 자동 파싱

단점:

  • ❌ 단순 값만 가능 (객체 배열 불가)
  • ⚠️ 프로젝트마다 지원 여부 다름

🎯 실제 사례 분석

타임라인 API (JSON.stringify 통일)

formData.append('timelines', JSON.stringify(dto.timelines));           // 객체 배열
formData.append('addTimelines', JSON.stringify(dto.addTimelines));     // 객체 배열
formData.append('updateTimelines', JSON.stringify(dto.updateTimelines)); // 객체 배열
formData.append('deleteTimelines', JSON.stringify(dto.deleteTimelines)); // 숫자 배열

왜 모두 JSON.stringify를 사용?

  • ✅ API 내부 일관성 (모든 배열을 같은 방식으로)
  • ✅ 객체 배열 전송 필수 (timelines, addTimelines, updateTimelines)
  • ✅ 백엔드 파싱 로직 통일

공지사항 API (forEach + [] 사용)

// 파일 업로드 ([] 없음)
dto.files.forEach(file => {
  formData.append('files', file);  // Multer가 자동 처리
});

// 숫자 배열 ([] 있음)
dto.deletedFileIds.forEach(id => {
  formData.append('deletedFileIds[]', id.toString());
});

deletedFileIds[]에만 [] 사용?

  • ✅ 파일은 Multer가 자동으로 배열 처리
  • ✅ 숫자 ID는 명시적으로 배열임을 표시해야 함
  • ✅ 값이 1개일 때도 배열로 보장 (엣지 케이스 방지)

🔧 백엔드 코드 분석

NestJS의 @Transform 데코레이터

@Transform(({ value }) => (typeof value === 'string' ? JSON.parse(value) : value))
deletedFileIds?: number[];

이 코드의 의미:
1. 문자열이 오면 → JSON.parse() 실행 (JSON.stringify로 보낸 경우)
2. 배열이 오면 → 그대로 사용 (forEach + []로 보낸 경우)

시나리오별 동작:

// Case 1: JSON.stringify
formData.append('ids', JSON.stringify([1,2,3]));
// value = "[1,2,3]" (문자열)
// JSON.parse("[1,2,3]") → [1,2,3] ✅

// Case 2: forEach + []
formData.append('ids[]', '1');
// value = ["1"] (배열)
// 그대로 통과 → ["1"] ✅

// Case 3: forEach (값 1개, [] 없음)
formData.append('ids', '1');
// value = "1" (문자열)
// JSON.parse("1") → 1 ❌ (배열 아님!)

📋 선택 가이드

JSON.stringify를 사용해야 할 때:

객체 배열을 전송할 때

formData.append('items', JSON.stringify([
  { id: 1, name: 'A' },
  { id: 2, name: 'B' }
]));

API 내 다른 배열들도 JSON.stringify를 쓸 때 (일관성)

복잡한 중첩 구조를 전송할 때

forEach + []를 사용해야 할 때:

단순 값(숫자/문자열) 배열만 전송할 때

백엔드가 [] 패턴을 명시적으로 요구할 때

값이 1개일 수도 있는 경우 (엣지 케이스 방지)

forEach ([] 없음)를 사용할 때:

⚠️ File 객체 (Multer가 자동 처리)

⚠️ 항상 2개 이상의 값이 보장될 때

⚠️ 백엔드가 관대하게 파싱할 때 (권장하지 않음)


🎓 핵심 교훈

1. Swagger만으로는 부족하다

// Swagger에서는 똑같이 보임
{
  "deleteTimelines": {
    "type": "array",
    "items": { "type": "number" }
  }
}

하지만 실제 FormData 전송 방식은 다릅니다:

  • deleteTimelines: "[1,2,3]" (JSON 문자열)
  • deletedFileIds[]: 1, deletedFileIds[]: 2 ([] 패턴)

2. API 내부 일관성이 중요하다

같은 API 내에서는 통일된 방식을 사용하는 게 좋습니다:

// ✅ 좋은 예: 모두 JSON.stringify
formData.append('add', JSON.stringify([...]));
formData.append('update', JSON.stringify([...]));
formData.append('delete', JSON.stringify([...]));

// ❌ 나쁜 예: 혼재
formData.append('add', JSON.stringify([...]));
formData.append('update', JSON.stringify([...]));
arr.forEach(id => formData.append('delete[]', id)); // 혼자 다름

3. 백엔드와의 협의가 필수

프론트엔드만 바꾸면 작동하지 않습니다. 백엔드 DTO 설계를 먼저 확인하세요:

// 백엔드 Request DTO를 확인!
@Transform(({ value }) => ...)
deleteIds?: number[];

4. 엣지 케이스를 고려하라

"값이 1개만 삭제할 때" 같은 시나리오를 테스트하세요. 많은 버그가 여기서 발생합니다.


🚀 실전 권장사항

새 API 개발 시:

  1. 백엔드와 먼저 협의: JSON vs forEach 방식 결정
  2. 일관성 유지: 같은 API는 같은 방식으로
  3. 문서화: 주석으로 이유 명시
    // 배열 형태로 전송하기 위해 [] 표기법 사용
    formData.append('deletedFileIds[]', id.toString());

기존 코드 유지보수 시:

  1. 현재 방식 확인: 백엔드 DTO와 프론트엔드 코드 모두
  2. 테스트: 값이 1개일 때, 여러 개일 때 모두 테스트
  3. 함부로 바꾸지 않기: 이미 작동하면 그대로 두기

통일을 원한다면:

  1. 팀 컨벤션 문서 작성
  2. 백엔드 팀과 협의 (가장 중요!)
  3. 새 API부터 적용
  4. 점진적 마이그레이션 (여유 있을 때)

📚 요약

항목JSON.stringifyforEach + []forEach ([] 없음)
사용 케이스객체 배열단순 배열파일/특수 케이스
값 1개 안전성✅ 안전✅ 안전❌ 위험
복잡한 구조✅ 가능❌ 불가❌ 불가
코드 간결성✅ 간결⚠️ 반복문⚠️ 반복문
백엔드 파싱JSON.parse자동자동

결론: API의 특성에 맞게 선택하되, 일관성을 유지하자!


profile
항상 “Why?”로 시작하는 프론트엔드 개발자

0개의 댓글