프론트엔드에서 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로 변경하니 제대로 작동했는데, 왜 그런지 궁금했습니다.
FormData는 키-값 쌍만 전송할 수 있습니다. 배열을 표현하는 표준 방법이 없습니다.
// 실제 HTTP 전송 형태
Content-Disposition: form-data; name="ids"
value="1"
같은 키에 값이 몇 개 append 되었는지에 따라 결과가 달라집니다:
| 전송 방식 | 값 개수 | 백엔드 파싱 결과 |
|---|---|---|
append('ids', '1') | 1개 | "1" (문자열) ❌ |
append('ids', '1') × 2 | 2개 | ["1", "2"] (배열) ✅ |
append('ids[]', '1') | 1개 | ["1"] (배열) ✅ |
append('ids[]', '1') × 2 | 2개 | ["1", "2"] (배열) ✅ |
[] 표기법의 역할formData.append('deletedFileIds[]', '1');
[]는 단순한 명시가 아니라, 실제로 키 이름의 일부입니다!
HTTP 전송: name="deletedFileIds[]"
NestJS/Multer 파싱: "[] 패턴 발견! 배열로 변환해야겠다"
→ deletedFileIds = ["1"]
// 프론트엔드
formData.append('deleteTimelines', JSON.stringify([1, 2, 3]));
// 백엔드 DTO
@Transform(({ value }) => (typeof value === 'string' ? JSON.parse(value) : value))
deleteTimelines?: number[];
// 결과: ✅ 항상 배열로 파싱
장점:
단점:
// 프론트엔드
dto.deletedResourceIds.forEach(id => {
formData.append('deletedResourceIds', id.toString());
});
// 백엔드: 자동 배열 변환 (여러 값일 때만)
장점:
단점:
// 프론트엔드
dto.deletedFileIds.forEach(id => {
formData.append('deletedFileIds[]', id.toString());
});
// 백엔드: [] 패턴 인식 → 무조건 배열로 변환
장점:
단점:
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를 사용?
// 파일 업로드 ([] 없음)
dto.files.forEach(file => {
formData.append('files', file); // Multer가 자동 처리
});
// 숫자 배열 ([] 있음)
dto.deletedFileIds.forEach(id => {
formData.append('deletedFileIds[]', id.toString());
});
왜 deletedFileIds[]에만 [] 사용?
@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 ❌ (배열 아님!)
✅ 객체 배열을 전송할 때
formData.append('items', JSON.stringify([
{ id: 1, name: 'A' },
{ id: 2, name: 'B' }
]));
✅ API 내 다른 배열들도 JSON.stringify를 쓸 때 (일관성)
✅ 복잡한 중첩 구조를 전송할 때
✅ 단순 값(숫자/문자열) 배열만 전송할 때
✅ 백엔드가 [] 패턴을 명시적으로 요구할 때
✅ 값이 1개일 수도 있는 경우 (엣지 케이스 방지)
⚠️ File 객체 (Multer가 자동 처리)
⚠️ 항상 2개 이상의 값이 보장될 때
⚠️ 백엔드가 관대하게 파싱할 때 (권장하지 않음)
// Swagger에서는 똑같이 보임
{
"deleteTimelines": {
"type": "array",
"items": { "type": "number" }
}
}
하지만 실제 FormData 전송 방식은 다릅니다:
deleteTimelines: "[1,2,3]" (JSON 문자열)deletedFileIds[]: 1, deletedFileIds[]: 2 ([] 패턴)같은 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)); // 혼자 다름
프론트엔드만 바꾸면 작동하지 않습니다. 백엔드 DTO 설계를 먼저 확인하세요:
// 백엔드 Request DTO를 확인!
@Transform(({ value }) => ...)
deleteIds?: number[];
"값이 1개만 삭제할 때" 같은 시나리오를 테스트하세요. 많은 버그가 여기서 발생합니다.
// 배열 형태로 전송하기 위해 [] 표기법 사용
formData.append('deletedFileIds[]', id.toString());| 항목 | JSON.stringify | forEach + [] | forEach ([] 없음) |
|---|---|---|---|
| 사용 케이스 | 객체 배열 | 단순 배열 | 파일/특수 케이스 |
| 값 1개 안전성 | ✅ 안전 | ✅ 안전 | ❌ 위험 |
| 복잡한 구조 | ✅ 가능 | ❌ 불가 | ❌ 불가 |
| 코드 간결성 | ✅ 간결 | ⚠️ 반복문 | ⚠️ 반복문 |
| 백엔드 파싱 | JSON.parse | 자동 | 자동 |
결론: API의 특성에 맞게 선택하되, 일관성을 유지하자!