- 직장에서 코드리뷰(라고 불리기에는 한 달에 한 번)시간에 공유한 내용을 다시 정리한 글입니다(23년 12월).
- 통신에는 Axios V.1.xxxx를 사용하고 있습니다.
리뉴얼한 백오피스의 전화문의 기록 작성 시, 첨부파일이 옵션값인데 필수로 인식되어 무조건 파일을 넣어야 통신이 가능한 상태이다.
이를 원래 계획대로 첨부파일이 옵션값으로 인식되게 고쳐야 한다.
As-Is 개발서버에서 network탭을 먼저 확인하여 다음과 같이 API 통신이 이루어지고 있는 것을 볼 수 있었다.
formData O | formData X | |
---|---|---|
Content-Type | multipart/form-data | application/json |
query string | content data | content data |
(옵션)payload | formData | X |
즉, formData가 있을 시에는 multipart/form-data
으로 가야 하고, 없을 시에는 application/json
으로 가야 한다.
그러나, 리뉴얼에서는 무조건 multipart/form-data
으로 세팅이 되어 있기에 서버와 통신이 잘못 되고 있었다.
쉽다고 생각을 했지만 소스코드를 확인해니 오히려 의문점이 생겼다.
// As-Is
headers: {
"Content-Type": "application/json"
},
// Renewal
headers: {
"Content-Type": "multipart/form-data"
},
왜 As-Is는 "Content-Type": "application/json"
인데 어떻게 네트워크 탭을 보면 상황에 따라서 "Content-Type"이 수정되는 것일까?
프로젝트에 내가 모르는 다른 코드가 있을 거라고 생각을 하여 똑같이 맞춰주면 해결될 거라고 단순하게 생각했다.
따라서, As-Is와 동일하게 맞춰주었다.
return post(MUTATION_KEY.customerTelephoneInquiry, formData, true, {
headers: {
"Content-Type": "application/json", //"multipart/form-data",
"Cache-Control": "no-cache",
},
params: query
});
위와 같이 수정해서 보내면 "Content-Type": "application/json"
으로 보내지지만 formData로 지정이 되지 않아서 파일이 보내지지 않느다.
소스를 한번 더 깊게 파고 들어가보면 axios의 default instance를 설정할 때 이미 “application/json”으로 설정을 하고 있어서 위 선언은 중복이다. 그러니 적어줄 필요는 없다.
그러면 어떤 이유 때문에 As-Is는 application/json
으로 보내도 알아서 multipart/form-data
로 보내진는 걸까?
Axios를 키워드로 현재 상황을 검색하다가 아래 글을 확인했다.
Looks like when axios detects form data in the request body, it auto-sets a multipart/form-data header, overriding the header value. - Can not change Contant-Type when data is FormData
해당 이슈 댓글을 보면 axios가 request body에 form data가 있으면 Content-Type을 자동적으로 multipart/form-data
으로 변경한다.
내부 source code를 보니 특정 코드와 주석을 확인할 수 있었다.
export default isXHRAdapterSupported && function (config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
let requestData = config.data;
const requestHeaders = AxiosHeaders.from(config.headers).normalize();
let {responseType, withXSRFToken} = config;
let onCanceled;
function done() {
if (config.cancelToken) {
config.cancelToken.unsubscribe(onCanceled);
}
if (config.signal) {
config.signal.removeEventListener('abort', onCanceled);
}
}
let contentType;
if (utils.isFormData(requestData)) {
if (platform.hasStandardBrowserEnv || platform.hasStandardBrowserWebWorkerEnv) {
// 실제 아래 라인에 다믕과 같이 주석이 작성
requestHeaders.setContentType(false); // Let the browser set it
} else if ((contentType = requestHeaders.getContentType()) !== false) {
// fix semicolon duplication issue for ReactNative FormData implementation
const [type, ...tokens] = contentType ? contentType.split(';').map(token => token.trim()).filter(Boolean) : [];
requestHeaders.setContentType([type || 'multipart/form-data', ...tokens].join('; '));
}
}
위 requestHeaders.setContentType(false); // Let the browser set it
보면 알 수 있다.
FormData가 있으면 Content-Type 설정을 하지 않고 브라우저에 “위임”한다
다른 말로하면. 브라우저는 상황을 인지하고 내부적으로 처리를 하는 방식이 있다.
브라우저는 내부적으로 상당히 많은 일을 하고 있다.
그렇기에 "브라우저"라는 키워드를 연관지어 검색을 하고 GPT에게도 한번 물어봤고 접합점을 찾으면서 살펴본 결과 MIME Sniffing이라는 작업을 알게 되었다.
해당 기능을 간단히 요약하면:
이런 기능이 있기에 Axios 개발자는 requestHeaders.setContentType(false); // Let the browser set it
와 같이 작성했다고 판단이 된다.
조금 더 원론적으로 이 기능이 왜 생겼는지 탐색해봤고 MIME Sniffing 규격 문서 바로 첫 문단에서 확인을 할 수 있었다.
The HTTP Content-Type header field is intended to indicate the MIME type of an HTTP response. However, many HTTP servers supply a Content-Type header field value that does not match the actual contents of the response. Historically, web browsers have tolerated these servers by examining the content of HTTP responses in addition to the Content-Type header field in order to determine the effective MIME type of the response.
Without a clear specification for how to "sniff" the MIME type, each user agent has been forced to reverse-engineer the algorithms of other user agents in order to maintain interoperability. Inevitably, these efforts have not been entirely successful, resulting in divergent behaviors among user agents. In some cases, these divergent behaviors have had security implications, as a user agent could interpret an HTTP response as a different MIME type than the server intended.. - MIME SniffingLiving Standard — Last Updated 27 September 2023
위 문장을 해석하자면,
Content-Type
간의 불일치 하는 경우가 많음. 왜 발생하는 지, 그리고 왜 만들었졌는 지를 파악을 한 후 다음 2가지 방법을 생각했다.
Content-Type: undefined
를 통하여 브라우저 위임현재 프로젝트에서는 axios instance 생성 시, 다음과 같이 Content-Type을 선언하고 있다.
export const createInstance = (token: string | null) => {
if (!!token && !instanceObject.default) {
instanceObject.default = axios.create({
...defaultOptions,
headers: {
Accept: "*/*",
"Content-Type": "application/json", // 선언
Authorization: `Bearer ${token}`,
},
});
setInterceptors(instanceObject.default);
}
if (!instanceObject.notToken) {
instanceObject.notToken = axios.create({
...defaultOptions,
headers: {
Accept: "*/*",
"Content-Type": "application/json", // 선언
},
});
setInterceptors(instanceObject.notToken);
}
};
위 선언은 개별 API 통신 시, 개발자에 의해서 설정 변경이 가능하게 작업이 되어있다.
현재 이 상황에서만 해당 API 호출의 개별 API의 header에서 "Content-Type": undefined
을 설정하여 Axios 내부에 아래 코드로 브라우저에 위임하게 만들 수 있다.
if (utils.isFormData(requestData)) {
if (platform.hasStandardBrowserEnv || platform.hasStandardBrowserWebWorkerEnv) {
// 실제 아래 라인에 다믕과 같이 주석이 작성
requestHeaders.setContentType(false); // Let the browser set it
} else if ((contentType = requestHeaders.getContentType()) !== false) {
// fix semicolon duplication issue for ReactNative FormData implementation
const [type, ...tokens] = contentType ? contentType.split(';').map(token => token.trim()).filter(Boolean) : [];
requestHeaders.setContentType([type || 'multipart/form-data', ...tokens].join('; '));
}
}
Interceptor를 활용하여 개발자가 책임을 가져갈 수 도 있다.
instance.interceptors.request.use(
async (config) => {
// 개발자가 명시한 Content-Type이 있으면 쓰지만, 만약 명시하지 않았다면 data를 보고 자동 입력
// TODO: api 선언하는 곳에 헤더가 있으면 그대로 사용?
console.log(config);
if (config.headers["Content-Type"]) {
return config;
}
config.headers = config.headers ?? {};
if (config.data instanceof FormData) {
config.headers["Content-Type"] = "multipart/form-data";
} else {
config.headers["Content-Type"] = "application/json";
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
요청을 보내 기 전에 Content-Type을 먼저 확인하고, 그 다음 data의 타입을 확인하여 Content-Type을 설정한다.
이렇게 공통 로직을 작성하여 중복 코드를 줄이고, 필요에 따라서 개발자가 설정하여, form data
일 때는 "multipart/form-data"
를 설정해준다.
서비스에 실제로 적용한 방법은 2번(“Axios instance를 interceptor를 통하여 개발자가 직접 코드로 명시”)이다.
글로벌 처리를 하여 나는 다음을 생각했다.
단점도 생각을 해봤다
이후 다른 작업도 하다가 발견을 한 것인데, Axios의 request header부분에 "Content-Type": multipart/form-data
라고 명시하면 전달하는 data를 form data로 wrapping을 해주며, return type도 명시를 하면 응답의 타입을 return type에 명시한 형태로 전환을 시켜주기도 한다.
상당히 편리한 기능이지만 떄로는 굳이라는 생각을 들기도 한다.
[Spec]
HTML Standard: 4.10.21.8 Multipart form data
RFC 7578: Returning Values from Forms: multipart/form-data
RFC1341(MIME) : 7 The Multipart content type
[Axios Github]
Don't delete Content-Type when formData
Can not change Contant-Type when data is FormData
[Etc.,]