목록으로

custom-fetcher

약 5분 읽기Development#Development댓글로 이동

custom fetcher

main
date
Aug 25, 2025
slug
custom-fetcher
author
status
Public
tags
Development
summary
custom fetcher
type
Post
thumbnail
category
Development
updatedAt
Oct 12, 2025 01:10 PM
신고 기능을 개발하는 중에, 프론트 작업을 해주시는 팀원이 API에서 에러가 반환되어 신고 등록이 안된다고 하여 확인을 해보았다.
 
신고 관련 API는 내가 작업을 하였기에 어떤 에러인지 여쭤보았는데 validation 에러가 반환되고 있었다.
뭔가 값이 제대로 들어오지 않아서 발생하는 에러로 보고 프론트 코드를 살펴보았다. 파일 업로드도 함께 진행되기 때문에 form-data로 변환하여 보내달라고 하였고 프론트에서도 보내지 전에 body를 form-data로 변환해서 보내주고는 있었다.
 
일단 api로 정의되어있는 코드 내에서는 문제가 있어보이지 않았다.
 
export const submitSupport = async (params: SubmitSupportParams) => { const { reporterId, reasonCode, reasonDetail, files } = params; const formData = new FormData(); formData.append('platform', PLATFORM_CODE); formData.append('reason_code', reasonCode.toString()); // 생략 ... files?.forEach((file) => { formData.append('files', file); }); const response = await httpClient.post('/v2/report/ichart', formData); return response.data; // 생략 ...
 
근데 백엔드에서 로깅을 했을 빈 객체로 값이 넘어오고 있었다.
 
왜일까..???? httpClient는 커스텀 fetcher를 구현체를 확인해보았다.
 
class HttpClient implements IHttpClient { private baseUrl: string; constructor(url: string) { this.baseUrl = url; } public async post<T>(url: string, body?: any, config: RequestConfig = {}) { const res = await fetch(this.baseUrl.concat(url), { method: 'POST', headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(config.headers ?? {}), }, cache: config?.cache, signal: config.signal, body: body ? JSON.stringify(body) : undefined, }); await rejectIfNeeded(res); const data: T = await res.json(); const { ok, headers } = res; if (ok) { return { data, headers }; } return Promise.reject(data); } }
 
첫 번째는 엔드포인트, 두 번째에 body를 받고 세 번째에 config option을 받는다. 여기서 문제가 발생했다.
 
백엔드에서는 Content-Type을 multipart/form-data 로 정의하여 데이터를 처리하고 있다.
 
근데 위 코드를 보면 두 번째 인자에 값이 있을 경우 Content-Type을 application/json 정의하고 JSON.stringify(body) 로 JSON 직렬화를 하기 때문에 백엔드에서는 빈 객체 {} 로 넘어와 유효성 체크에서 에러가 발생한 것이다.
 
그래서 header의 Content-Type을 multipart/form-data로 정의해서 요청을 보내면 백엔드에서 응답조차 없었다…
 
어떻게 처리를 해야할까?
 
간단한 방법은 formData를 처리할 때에는 Content-Type을 지정하지 않고, JSON.stringify로 직렬화도 하지 않은 상태로 요청을 보내면된다.
 
formData로 요청을 보낼 때 Content-Type을 지정하지 않으면 브라우저에서 자동으로 Content-Type을 multipart/form-data; boundary=... 로 붙여서 요청을 보내게 된다.
 
public async post<T>(url: string, body?: any, config: RequestConfig = {}) { const isFormData = body instanceof FormData; const res = await fetch(this.baseUrl.concat(url), { method: 'POST', headers: { ...(isFormData ? {} : { 'Content-Type': 'application/json' }), // formData면 헤더 자동 처리 ...(config.headers ?? {}), }, cache: config?.cache, signal: config.signal, body: isFormData ? body : body ? JSON.stringify(body) : undefined, // formData 그대로 전달 }); await rejectIfNeeded(res); const data: T = await res.json(); const { ok, headers } = res; if (ok) { return { data, headers }; } return Promise.reject(data); }
 
브라우저는 그럼 formData인지 어떻게 알고 자동으로 Content-Type을 변환해서 요청을 보내게 되는것인가
 
우선 JSON과 달리 FormData의 경우 키:값의 객체가 아니다. FormData는 multipart/form-data 인코딩을 표현하기 위해 정의된 class이다.
그렇기 때문에 fetch에 body로 FormData를 넣으면 브라우저는 multipart 전송으로 요청을 보내게 되는데, 이때 boundary 값도 함께 생성하여 요청을 보낸다.
 
POST /upload HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAbCdEf123456 ------WebKitFormBoundaryAbCdEf123456 Content-Disposition: form-data; name="field1" value1 ------WebKitFormBoundaryAbCdEf123456 Content-Disposition: form-data; name="file"; filename="test.png" Content-Type: image/png (binary data) ------WebKitFormBoundaryAbCdEf123456--
 
boundary=----WebKitFormBoundaryAbCdEf123456 값으로 브라우저에서 생성하여 Content-Type에 함께 요청함
 
그렇다면 왜 브라우저에서는 boundary값을 생성하여 함께 요청을 보내는 것일까?? multipart/form-data는 여러 개의 값을 한 요청 본문에 묶어서 보낼 때 쓰는 포맷이다. (특히 파일<바이너리>을 그대로 실어 보낼 수 있도록)
 
이 포맷의 핵심은 각 파트(part) 사이를 구분할 “경계문자열(boundary)”을 두는 것이다.
 
  • JSON처럼 이스케이프를 하거나 base64로 감싸지 않고, 그냥 있는 그대로 텍스트/바이너리를 섞어 실으려면, 어디서부터 어디까지가 하나의 필드인지 구분하는 구분자가 필요
  • 이 구분자를 payload(본문) 안 어디에도 존재하지 않도록 하여 확실히 구분될 수 있도록 함
  • 클라이언트에서 매 요청에 랜덤 문자열(boundary)를 생성해서 사용
 
fetch가 Body Type으로 특수 타입 (BodyInit)이 들어올 때 직렬화 전략
  • string → 그대로 바이트화 (UTF-8), Content-Type 지정하지 않음 (개발자가 정의)
  • Blob/ArrayBuffer/Unit8Array → 바이너리 그대로 전송 (Blob의 경우 Content-Type을 정의하여 요청할 경우 해당 타입으로 설정되며, 그게 아닐 경우 application/octet-stream 사용됨)
  • FormData → 멀티파트 직렬화 알고리즘 실행 : 브라우저가 boundary를 만들고, 본문을 멀티 파트로 생성하여 요청하도록 설정됨