전편에 이어서 API 연결과 리액트쿼리를 구현해 요청을 기어코 보내보겠다!
제일 처음에 보여줬던 회원가입 API 명세서를 봐보자.
Request Body
🚨 multipart/form-data로 주셔야 합니다!
항목
idCardImage
string, 학생증 이미지
password
string, 비밀번호
name
string, 이름
isNicknameChecked
boolean, 닉네임 중복 검사를 통과했는지
nickname
string, 닉네임
gender
boolean, 성별, 여자가 true
nation
string, 국적
termsOfUse
boolean, 이용약관 동의 여부
termsOfPersonalInformation
boolean, 개인정보 수집 및 이용동의 여부
termsOfPromotion
boolean, 이벤트 프로모션 알림 동의 여부
예시{ "idCardImage": "image", "email": "wingle@gmail.com", "password": "1234!", "name": "김윙글", "isNicknameChecked" : true, "nickname": "윙그리", "gender": true, "nation": "kr", "termsOfUse": true, "termsOfPersonalInformation": true, "termsOfPromotion": false }
저 데이터에 맞게 지금까지 컴포넌트들을 만들어왔고, 모델로서 가입 데이터를 관리한 것이다.
가장 중요한 포인트가 multipart/form-data
로 줘야한단 말이다. 즉, 줘야되는 데이터의 형태도 form-data
로 줘야한다.
그런 점을 유의하며 기능 구현을 해보자.
보내는 기능을 만드는데 중요한 재료들을 만들어가보자.
/api/signUpApi.ts
- postSignUp
만들기multipart/form-data
로 데이터 만들기미리 쓰기 전에 생각으로 트러블 슈팅이 은근 많다.. 작성해보자잇!
/api/signUpApi.ts
여기는 되게 쉽다. 만들어둔 instance
에 넣어주자.
export const postSignUp = async (signUpData: SignUpFormData) => {
const response = await instance.post("/auth/signup", (Form화된 signUpData), {
headers: { "Content-Type": "multipart/form-data" },
});
return response.data;
};
짠! 중요하게 볼 것은 headers
에 Content Type이 multipart/form-data
이고, instance.post
에서 두번째 인자로 Form화된 signUpData
를 받고 있다. 이제 Form화된 signUpData
를 만들어보자.
Form화된 signUpData
만들기 대작전formData
에 모두 담겨야하니 반복문을 통해 signUpData
의 Key, Value를 formData
의 Key와 Value로 담아야 한다.
어떻게 해야할까?
formData
새로 만들기! : new FormData()
로 만들기!entries(data)
를 사용해 2차원 배열 만들기forEach
를 사용해 2차원 배열을 돌며 각 요소인 배열 [key, value]
를 추출하기formData
에 append
를 사용해 넣기이정도로 하면 된다! 그런 줄만 알고 신나게 따라했다.
// src/types/auth/signupFormDataType.ts
export interface SignUpData {
idCardImage: string;
email: string;
password: string;
name: string;
isNicknameChecked: boolean;
nickname: string;
gender: boolean;
nation: string;
termsOfUse: boolean;
termsOfPersonalInformation: boolean;
termsOfPromotion: boolean;
}
// src/api/auth/signUpApi.ts
const withFormData = (data: SignUpData) => {
// multipart/form-data 형식으로 데이터를 보내기 위해 FormData 객체를 생성
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
export const postSignUp = async (signUpData: SignUpData) => {
const response = await instance.post("/auth/signup", withFormData(signUpData), {
headers: { "Content-Type": "multipart/form-data" },
});
return response.data;
};
그리고 보내봤더니 문제가 생겼다.
🚨 이후 글은 트러블 슈팅 위주 글입니다. 보셔도 되지만 저의 피땀눈물이 담겨있습니다!!@ 🚨
이 걸 보고 든 생각은
웬 CORS냐!! 다른 이메일이나 닉네임 검증 API를 보낼 땐 아무 문제 없었고, CORS 허락 헤더에 port도 3000~3010, localhost, ip 등등 다 열어주라고 백엔드와 협의 마쳤는데 ㅠㅠ
이렇게 생각이 들었다. 그래서 백엔드와 긴밀한 협의와 같이 자바 코드를 봐주고 30분 간 대화했다.
그런 결과 서버와 통신 중 Content-Type
도 CORS 허락이 없다면 반려한다는 사실을 알게 됐다. 왜 이걸 처음부터 몰랐느냐! 전 백엔드 팀원분이 다 해주셨어서 몰랐다.. 지금 팀원분과 나도 처음이라 이런 문제가 발생했다.
그래도 간단한 문제이기에 서로 껄껄대며 헤더에 추가하고 EC2에 올리는 걸로 합의 봤다.
그런데 문제가 또 생겼다.
Error: Failed to convert value of type 'java.lang.String' to required type 'org.springframework.web.multipart.MultipartFile'; nested exception is java.lang.IllegalStateException: Cannot convert value of type 'java.lang.String' to required type 'org.springframework.web.multipart.MultipartFile': no matching editors or conversion strategy found
이.. 이게 뭐야.. 나는 처음 보는 에러였다. 왜이러지 하고 검색을 해보니
Spring 어플리케이션에서 String 객체를 MultipartFile 객체로 변환하려고 할 때, Spring이 이를 수행하는 데 필요한 변환 전략을 찾을 수 없어서 발생하는 오류
라고 한다. 즉, 내가 FormData
로 넣었던 String
들 중 서버에서의 맞는 데이터로 변환하지 못해 생긴 에러다. 뭐지 싶어서 바로 백엔드와 의논하러 들어갔다.
@프론트엔드_윤뿔소
님 위 이슈는 idCardImage를 보내실 때 string 타입으로 보내셔서 발생한 문제입니다~
html에서는 input type을 file로 해서 보내주시면 된다고 하는데 MultipartFile 타입에만 맞게 보내주시면 좋을 것 같습니다!
바로 idCardImage
에서 Base64가 아니라 File
자체로 집어 넣어야한다고 말이다. 분명 명세서에 String
이었고, 이 부분에 대해서 말씀 드렸다.
결론은 명세서 File
데이터로 고치고, 나도 고쳐야한다.. 이럴 수가 ㅠ 하지만 이런 문제도 있기에 다음부턴 File
을 주고 받을 때 그 데이터 형식을 확인 받는 식으로 해야겠다고 생각했다. 나중에 블로그 글을 보고 복습하자!
바로 변경이 들어갔다.
SignUpFormData
수정 : string
=> FormData
타입 설정// src/types/auth/signupFormDataType.ts
export interface SignUpFormData {
idCardImage: FormData | null;
email: string;
...
}
// src/api/auth/signUpApi.ts
export const signUpFormDataAtom = atom<SignUpFormData>({
key: "signUpFormDataAtom",
default: {
idCardImage: null,
email: "",
...
base64
가 아닌 formData
로 담기게 수정formData
를 선언해 넣어주기만 하면 된다.const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const imageFile = event.target.files?.[0];
if (imageFile) {
const fileSizeInMB = imageFile.size / (1024 * 1024);
if (fileSizeInMB > 20) {
// 20MB 이하인 경우에만 처리
setError(true);
setSignUpFormData((prev: SignUpFormData) => ({
...prev,
idCardImage: null, // null 로 초기화
}));
return;
}
setError(false);
const formData = new FormData();
formData.append("idCardImage", imageFile);
// File 데이터를 formData 에 append
setSignUpFormData((prev: SignUpFormData) => ({
...prev,
idCardImage: formData,
}));
setUploadedFileName(imageFile.name);
}
};
formData
로 담아준 후 아톰에 넣을까?File 객체는 크기가 큰 바이너리 데이터를 나타내기 때문에, 리코일과 같은 상태 관리 라이브러리에 직접 담는 것은 권장되지 않는다.
왜냐하면 리코일과 같은 상태 관리 라이브러리는 일반적으로 변화가 일어날 때마다 전체 상태를 복사하고 업데이트해야 하는 구조를 가지고 있는데, 큰 크기의 바이너리 데이터를 포함하는 경우 상태 업데이트 시간과 메모리 사용량이 많아져 성능에 영향을 미치기 때문이다.
그래서 FormData
를 사용하여 큰 용량의 데이터를 다루는 역할을 맡긴 것이다. 왜냐하면 FormData
에 데이터를 추가할 때는 해당 데이터가 포함된 Blob
객체를 전달해야 하는데, 이 때 Blob
객체는 큰 용량의 데이터를 다룰 수 있도록 최적화돼있기 때문이다!
또, 왜 Blob
객체는 최적화 돼있냐면... (중략) 참고 사이트
즉, 일반 수레로는 내용이 커 옮기기 힘드니 옮겨다닐 힘을 줄일 수 있게 FormData
라는 모터를 달아줘 슈퍼 수레로 옮겨다니는 것이다.
이러면 됐겠지? 또 제출해보자!
또 똑같은 에러가 떴다. 왜이러지?! 이 문제를 찾느라 거의 1시간을 썼던 거 같다. 내가 결코 이 문제를 풀리라 다짐했다.
다양하게 써봤다. 뭐 불린데이터 문자열로 넣거나 log를 찍어보며 데이터를 확인했다. 아톰 하나하나 봤었는데 문제가 안보였다!!
그러다가 withFormData
를 직접 만져보며 여러가지 테스트 해본 결과 idCardImage: FormData
이 FormData
가 아니라 String
으로 들어가는 것이다! 왜지?
바로 FormData
는 string | Blop
만을 받는 객체이기에 다른 타입들도 전!부! string
으로 변환되는 로직을 갖고 있다.
그래서 아래와 같이 하고 로그를 찍어보면
const formData2 = new FormData();
formData2.append("idCardImage", formData);
console.log(formData2.getAll("idCardImage"));
문자열로 찍히는 걸 볼 수 있다. 이걸 내가 하고 있었던 거고 놓친 것이다.
이렇게 설명하니까 당연해보이고 이런 실수를 한다고? 하는데 FormData
를 넣는 구조가 한 문단 안에 없었기도 했고 FormData
배경지식을 알고 있는 상태가 아니었기에 그랬었다.. 이 글을 보는 분들은 이런 실수때메 1시간이나 낭비하지 않았으면 좋겠다. 물론 나는 이제 까먹지 않을 거 같다..
쨌든 이제 알았으니 FormData
에 File
을 직접 넣어주자!
const withFormData = (data: SignUpFormData) => {
// multipart/form-data 형식으로 데이터를 보내기 위해 FormData 객체를 생성
// formData를 그대로 넣어버리면 string으로 받아 오류, 그래서 idCardImage를 조건으로 넣고 그 값을 그대로 넣어줘야함.
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
if (typeof value === "boolean") {
formData.append(key, value ? "true" : "false");
} else if (key === "idCardImage") {
formData.append(key, value.get("idCardImage"));
} else {
formData.append(key, value);
}
});
return formData;
};
export const postSignUp = async (signUpData: SignUpFormData) => {
const response = await instance.post(
"/auth/signup",
withFormData(signUpData),
{
headers: { "Content-Type": "multipart/form-data" },
}
);
return response.data;
};
조건을 보면 key === "idCardImage"
일 때, value(아톰의 formData)에서 idCardImage
를 get해와 api의 formData
에 append
해주는 모습이다.
추가로 불린데이터도 문자열로 반환 후 넣어주는 걸 명시적으로 해주면 더 안전하다고 해서 그 조건 로직도 추가했다.
이제 잘 됐는지 확인해보자!
참고로 내 이메일은 이미 가입돼있기에 disabled
이 풀리는 모습과 통신이 되는 모습에 집중했음 좋겠다!
에러를 보자면
{
...
data:
data: null
message: "이미 가입된 유저입니다."
status: 400
...
}
이렇게 떴다. 즉, 갔다가 서버 통신 후 가입됐다는게 됐으니까 저렇게 온거다. 다른 이메일이나 다른 걸로 하면 잘된다! 유후! 대장정이 끝이 났다!!!!
좋네요 혹시 multipart/form-data 사용하시는게 파일도 같이 보내서 쓰시는건가용?