클라이언트에서 서버에 데이터를 보낼 때 보통 FormData 객체를 사용합니다.
nextjs에서 서버 액션으로 전달할 때, 폼에 이미지도 포함되어있어서, formData로 전달하는 방법을 택했습니다.
하지만 formData는 중첩 구조를 지원하지 않기 때문에, (html form 기반이라 key=value flat 구조만 지원)
formData.append()를 통해 직접 하나씩 넣어줘야하는 불편함이 있었습니다.
그래서 중첩된 객체를 재귀적으로 FormData로 변환하고, 다시 파싱하는 로직을 구현하였습니다.
언제 FormData를 사용하고, 언제 JSON을 쓰는가?
- 파일이 없고 일반 데이터만 있다면 → JSON.stringify(data)가 기본 선택입니다.
- 파일이 포함되어 있거나, 서버에서 multipart/form-data를 요구하면 → FormData를 사용하세요.
- 파일 따로 업로드 → URL만 저장 방식도 많이 쓰이며, 특히 클라우드 스토리지(S3, Supabase 등)에서 선호됩니다.
FormData를 사용하는 경우
상황 | 이유 |
📁 이미지, 동영상, 문서 등 파일을 함께 보낼 때 | FormData는 바이너리 데이터를 멀티파트 형식으로 전송 가능 |
🧾 전통적인 HTML <form> 전송 방식과 유사하게 처리할 때 | 브라우저 기본 form 전송 방식과 호환됨 |
🎯 서버가 multipart/form-data 요청을 받도록 설계된 경우 | 파일 + 기타 필드를 함께 받는 API에 적합 |
JSON(일반 객체 전송)을 사용하는 경우
상황 | 이유 |
✍️ 텍스트 기반 데이터만 보낼 때 (문자열, 숫자, 배열 등) | JSON이 간결하고 직관적인 표현 가능 |
🧩 REST API, GraphQL 등 JSON 기반 API를 사용할 때 | 대부분의 현대 API가 application/json을 기본으로 사용 |
🖼️ 파일은 별도 업로드 → URL만 JSON에 포함할 때 | 파일 업로드와 데이터 전송을 분리하여 관리하기 쉬움 |
1. 중첩 객체를 FormData로 바꾸기 - createFormData
- 재귀적으로 중첩된 객체를 탐색하면서, key1[key2][key3] 같은 키 이름을 만들어 FormData에 넣어줍니다.
- 파일(Blob)은 바로 append 처리해주고, 기본 타입은 그냥 값으로 추가해줍니다.
export function createFormData(
object: Record<string, any>, // 중첩 객체 형태의 데이터를 받음
form?: FormData, // 기존 FormData 객체 (없으면 새로 생성)
namespace?: string // 현재 중첩 깊이를 표현하는 키 네임스페이스
): FormData {
// form 인자가 없으면 새 FormData 객체 생성
const formData = form || new FormData();
// 객체의 모든 키 순회
for (const property in object) {
// 객체 자신의 프로퍼티인지 확인하고, 값이 falsy면 건너뜀 (null, undefined, 빈 문자열 등)
if (!object.hasOwnProperty(property) || !object[property]) continue;
const value = object[property];
// 네임스페이스가 있으면 '[property]' 형식으로 키 생성, 없으면 그냥 property 키 사용
const formKey = namespace ? `${namespace}[${property}]` : property;
// 값이 Blob(파일, 이미지 등)이면 FormData에 바로 추가
if (value instanceof Blob) formData.append(formKey, value);
// 값이 객체면 재귀 호출로 더 깊은 중첩 처리
else if (typeof value === 'object') createFormData(value, formData, formKey);
// 그 외 기본 타입(문자열, 숫자 등)은 그대로 FormData에 추가
else formData.append(formKey, object[property]);
}
return formData;
}
예를 들어, 서비스에서 작성한 로그를 수정할 때,
아래와 같은 방식으로 제출합니다
const onSubmit = (values: LogEditFormValues) => {
// formState.dirtyFields를 참고해 실제 변경된 값들만 추출하는 함수 호출
// 모든 값 중 수정된 필드만 골라내서 dirtyValues에 담음
const dirtyValues = extractDirtyValues<LogEditFormValues>(form.formState.dirtyFields, values);
// places가 변경된 경우, places 배열 내 각 장소에 대해 id와 order 값을 덮어씀
// id는 원래 폼에 있던 값 유지, order는 배열 인덱스 기준으로 1부터 부여
const patchedDirtyValues = {
...dirtyValues,
...(dirtyValues.places && {
places: dirtyValues.places.map((place, idx) => ({
...place,
id: form.getValues('places')[idx]?.id, // 원래 폼에 있는 id 유지
order: idx + 1, // 순서 재부여 (1부터 시작)
})),
}),
};
console.log('서버로 보낼 데이터', patchedDirtyValues);
// 중첩 객체 형태인 patchedDirtyValues를 FormData 타입으로 변환
const formData = createFormData(patchedDirtyValues);
mutate({ formData, logId: logData.log_id });
};
서버로 보낼 데이터를 콘솔로 찍으면
변경된 데이터와 이미지 등이 출력됩니다.
{
"logTitle": "부산 여행 제목 수정",
"logDescription": "설명 수정",
"places": [
{
"placeName": "카페1",
"location": "부산 동래구 수정",
"description": "수정",
"placeImages": [{}, {}, {}, {}, {}, {}],
"id": "장소아이디_abc123",
"order": 1
},
{
"id": "장소아이디_def456",
"placeName": "음식점 수정",
"category": "음식점",
"location": "부산 사상구 ",
"placeImages": [
{
"place_image_id": "이미지아이디_001",
"image_path": "places/임의경로/def456/0.webp",
"place_id": "장소아이디_def456"
},
{
"place_image_id": "이미지아이디_002",
"image_path": "places/임의경로/def456/1.webp",
"place_id": "장소아이디_def456"
},
{
"place_image_id": "이미지아이디_003",
"image_path": "places/임의경로/def456/2.webp",
"place_id": "장소아이디_def456"
}
],
"order": 2
},
{
"id": "장소아이디_ghi789",
"placeName": "막창 수정",
"category": "음식점 수정",
"location": "부산 해운대구 수정",
"placeImages": [
{
"place_image_id": "이미지아이디_101",
"image_path": "places/임의경로/ghi789/2.webp",
"order": 3,
"place_id": "장소아이디_ghi789"
},
{
"place_image_id": "이미지아이디_102",
"image_path": "places/임의경로/ghi789/3.webp",
"order": 4,
"place_id": "장소아이디_ghi789"
}
],
"order": 3
}
],
"tags": {
"mood": [],
"activity": [
"모임 · 동호회",
"가성비 굿",
"맛집투어",
"페스티벌"
]
},
"deletedPlace": [
"삭제된장소아이디_xyz000"
],
"deletedPlaceImages": [
"삭제된이미지아이디_201",
"삭제된이미지아이디_202"
]
}
그리고 createFormData으로 생성된 formData를 순회해서 출력해보면
아래처럼 중첩 구조로 보이게 됩니다
2. FormData를 중첩 객체로 복원하기 - parseFormData
export function parseFormData<T = Record<string, any>>(formData: FormData): T {
const result: Record<string, any> = {}; // 최종 결과를 저장할 빈 객체
// formData의 모든 엔트리(key, value) 순회
for (const [fullKey, value] of formData.entries()) {
// fullKey 예: places[0][placeName] 같은 키를
// ']' 문자 제거 후, '[' 문자 기준으로 분할해서 배열로 만듦
// 예: ['places', '0', 'placeName']
const keys = fullKey
.replace(/\]/g, '') // 모든 ']' 문자 제거
.split('['); // '[' 문자 기준으로 분리
// 현재 작업 위치를 result 객체로 초기화
let current = result;
// 분할된 키 배열 순회
keys.forEach((key, idx) => {
// 현재 키가 마지막 키인지 여부
const isLast = idx === keys.length - 1;
// 다음 키를 미리 확인 (undefined일 수도 있음)
const nextKey = keys[idx + 1];
// 다음 키가 숫자로만 이루어져 있으면 배열로 판단
const isNextArray = /^\d+$/.test(nextKey);
if (isLast) {
// 마지막 키라면 현재 위치 객체에 값을 대입
current[key] = value;
return;
}
// 아직 해당 키가 없으면 초기화
// 다음 키가 배열 인덱스이면 빈 배열로, 아니면 빈 객체로 초기화
if (!(key in current)) {
current[key] = isNextArray ? [] : {};
}
// 현재 위치를 다음 깊이로 이동
current = current[key];
});
}
return result as T;
}
로그 수정에서 변경한 폼데이터를 파싱하면
{
"logTitle": "부산 여행 제목 수정",
"logDescription": "설명 수정",
"places": [
{
"placeName": "카페1",
"location": "부산 동래구 수정",
"description": "수정",
"id": "장소아이디_가나다123",
"order": "1"
},
{
"id": "장소아이디_라마바456",
"placeName": "음식점 수정",
"category": "음식점",
"location": "부산 사상구 ",
"placeImages": [
{
"place_image_id": "이미지아이디_101",
"image_path": "places/임의경로/라마바456/0.webp",
"place_id": "장소아이디_라마바456"
},
{
"place_image_id": "이미지아이디_102",
"image_path": "places/임의경로/라마바456/1.webp",
"place_id": "장소아이디_라마바456"
},
{
"place_image_id": "이미지아이디_103",
"image_path": "places/임의경로/라마바456/2.webp",
"place_id": "장소아이디_라마바456"
}
],
"order": "2"
},
{
"id": "장소아이디_사아자789",
"placeName": "막창 수정",
"category": "음식점 수정",
"location": "부산 해운대구 수정",
"placeImages": [
{
"place_image_id": "이미지아이디_201",
"image_path": "places/임의경로/사아자789/2.webp",
"order": "3",
"place_id": "장소아이디_사아자789"
},
{
"place_image_id": "이미지아이디_202",
"image_path": "places/임의경로/사아자789/3.webp",
"order": "4",
"place_id": "장소아이디_사아자789"
}
],
"order": "3"
}
],
"tags": {
"activity": [
"모임 · 동호회",
"가성비 굿",
"맛집투어",
"페스티벌"
]
},
"deletedPlace": [
"삭제된장소아이디_abc123"
],
"deletedPlaceImages": [
"삭제된이미지아이디_111",
"삭제된이미지아이디_222"
]
}
후기
createFormData, parseFormData 유틸함수는 현재 로그 수정에서만 사용되고 있습니다...ㅎㅎ
처음에는 로그 등록과 로그 수정을 모두 같은 방식(FormData)으로 처리하고 있었습니다.
이미지와 텍스트 데이터를 모두 FormData로 묶어 서버 액션에 전달했었습니다.
하지만 이후 로그 등록 흐름이 변경되면서,
- 이미지 파일은 Supabase Storage에 별도로 업로드하고,
- 텍스트 데이터만 JSON 형태로 서버에 전달하는 구조로 바뀌었습니다.
반면, 로그 수정은 기존 FormData 방식 그대로 남게 되었고,
- 현재는 이미지 없이 텍스트 데이터만 전달되기 때문에, 사실상 FormData가 굳이 필요 없는 상황입니다...
- 단순히 텍스트만 전달하기 때문에, JSON.stringify() 방식으로 처리해도 충분합니다.
'🐟 Project' 카테고리의 다른 글
Supabase Storage 단일/중첩 폴더 이미지 삭제 로직 및 구현 방법 (0) | 2025.06.15 |
---|---|
supabase storage이미지 업로드 트러블슈팅 : 서버 액션에서 signed URL로 전환하며 겪은 문제와 해결 (1) | 2025.06.14 |
Next.js 마이그레이션을 진행하며 react-hook-form 개선기 (0) | 2025.06.04 |
이미지 업로드 최적화 트러블슈팅 (react-image-file-resizer -> browser-image-compression) (0) | 2025.04.30 |