6주간 프로젝트를 진행하며 시간과 노력이 많이 들었던 기능이 크게 두 가지가 있다.
1. 로그인 기능
2. 게시글 작성/수정 중 사진 넣기
그 중 로그인 기능은 처음부터 이해하기 어려웠고, 그래서 어려움을 예상했지만
게시글 작성은 쉽게 할 수 있을 것이라 생각했는데 자꾸 문제가 발생하여 애먹었다.
그래서 혹시 다른 사람들이 "input type="file"을 이용하여
사용자의 사진을 받고 싶다면 참고했으면 하는 바람에서 글을 쓴다.
게시글에 사진을 넣을 수 있다. 본문에 한 장, 각 선택지에 한 장.
선택지가 최대 5개이니 사진은 본문까지 합쳐 최대 6장이 들어갈 수 있다.
1. 사용자에게 사진을 받는다.
- < input file="file" /> 를 이용한다.
2. 서버로 글과 사진을 넘긴다. 서버에서는 해당 내용과 이미지를 저장한다.
- < form />를 이용한다.
3. 게시글을 보여줘야 할 때 서버로부터 "3번" 데이터를 받아 그린다.
간략하게 구조는 아래와 같다.
폼 컴포넌트가 있고 그 안에 input type="file" 태그가 있다.
다만 이것을 조금 변형하여 ContentImageSection
컴포넌트를 만들어서 훅으로 관리한다.
export function PostForm () {
...
return (
//제츌의 트리거인 버튼이 폼 외부에 있는 경우 form="form-id"를 달아서 제어할 수 있음
<button type="submit" form="form-post">
저장
</button>
//handlePostFormSubmit = 제출 이벤트
<form id="form-post" onSubmit={handlePostFormSubmit}>
//제목과 본문은 string이든 number이든 모두 들어갈 수 있어 type을 따로 지정하지 않음
<input />
<input />
<input />
...
//사진을 넣을 input은 type을 지정
<input type="file" />
<form/>
)
}
이렇게 하면 input type="file"의 기본 UI가 그려지는데
type="radio"와 마찬가지로 이 UI를 커스텀할 수 없다.
때문에 우리 UI에 맞게 수정하려면 다른 조치가 필요하다.
export default function ContentImageSection({ contentImageHook }) {
const { imageUrl, contentInputRef, removeImage, handleUploadImage } = contentImageHook;
const handleButtonClick = () => {
contentInputRef.current && contentInputRef.current.click();
};
return (
<>
//이미지 url이 있으면 image를 보여준다.
{imageUrl && (
<div>
<button onClick={removeImage} />
<img src={imageUrl} alt="본문에 포함된 사진" />
</div>
)}
//이미지 url이 없으면 input을 그린다.
{
<div>
<button
type="button"
$isVisible={!!imageUrl}
aria-label="본문 이미지 업로드"
onClick={handleButtonClick >
<label htmlFor="content-image-upload">본문에 사진 넣기</label>
</button>
<input
id="content-image-upload"
ref={contentInputRef}
type="file"
accept="image/*"
onChange={handleUploadImage}
tabIndex={-1}
/>
</ div>
}
</>
);
}
이 이미지 넣는 버튼은 <button />
과 <input />
을 넣는다.
훅을 사용하는 이유는 아래와 같다.
export const useContentImage = (imageUrl: string = '') => {
const [contentImage, setContentImage] = useState(imageUrl);
//이건 changeEvent때문에 달아준건데 아래에서 다시 이야기할 예정
const contentInputRef = useRef<HTMLInputElement | null>(null);
const removeImage = () => {
setContentImage('');
//업로드된 file input 초기화
if (contentInputRef.current) contentInputRef.current.value = '';
};
const handleUploadImage = (event: ChangeEvent<HTMLInputElement>) => {
const { files } = event.target;
if (!files) return;
const file = files[0];
event.target.setCustomValidity('');
//사진 용량 확인
if (file.size > MAX_FILE_SIZE) {
event.target.setCustomValidity('사진의 용량은 5MB 이하만 가능합니다.');
event.target.reportValidity();
return;
}
//파일의 url을 가지고 오는 방법
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onloadend = () => {
setContentImage(reader.result?.toString() ?? '');
};
};
return { contentImage, contentInputRef, removeImage, handleUploadImage };
};
위에서 chagneEvent에 대해 언급했다.
input type="file"을 제어하는 것은 change이벤트인데 이로인한 문제점이 있다.
최초의 코드에서는 아래와 같이 로직이 진행되었다.
1. 사진을 등록한다: input에 사진파일이 등록된다/imageUrl state(사진 미리보기)가 변화한다.
2. 사진을 삭제한다: imput의 사진파일은 변화가 없다./ imageUrl state(사진 미리보기)를 초기화한다.
3. 사진을 다시 등록한다
1. 1번과 다른 사진을 올린다: 다른 파일이므로 changeEvent가 발동하여 1번과 동일한 작용을 한다.
2. 1번과 같은 사진을 올린다: 같은 파일이므로 changeEvent가 발동하지 않아 변화가 없다. = 미리보기가 없다.
결국 사진을 삭제할 때 input을 직접 초기화해줘야 했다.
이는 ref를 이용하여 html tag를 가지고 올 수밖에 없었고, 한층 더 더럽고 복잡해져버렸다.🥲
더군다나 배포 막바지에 발견한 버그라서 해결할 시간이 부족해 확실한 방법을 채택했다.
만약 image를 넣는 칸이 많아진다면 그만큼 복잡해질 것 같아 관련 로직을 추상화하거나 다른 방법을 찾아야 겠다.
본래 input으로 문자열을 받는 경우에는 body에 json으로 넘긴다.
그래서 지금 상황에서 imageUrl을 넘겼는데 이는 유효하지 않은 처리였다.
백엔드와 이야기를 해서 처리 방법을 논의했는데,
form-data 형식으로 넘기면 처리가 가능하다!
다만 백엔드에서도 이렇게 받는 것이 처음이라 이 방법을 찾는데 더욱 오류가 많았다.🥲
const formData = new FormData();
contentImageFileList.map(file => formData.append('contentImages', file));
optionImageFileList.map(file => formData.append('optionImages', file));
formData.append('request', JSON.stringify(updatedPostTexts));
축약하면 위와같이 새로운 formData를 만들고 이에 데이터를 이름을 붙여 넣었다.
request
라는 이름의 json은 title, content와 같이 기존에 json으로 보내야 했던 문자열들이다.
결과적으로 request body 형식은 아래처럼 된다. 원래라면 저렇게 file을 따로 위치시키지 않고 비슷한 결의 데이터와 같은 선상에 놓겠지만 이런 방식으로 하게 되면서 그렇게 하지 못했다.
formData (
request: {
title: string;
content: string;
options: [...];
:
},
contentImages: file;
optionImages: file[];
)
여기서 contentImageFileList
은 submitEvent에서 만든 file | undefined []인데 만약 input type="file"에 아무 파일이 없다면 undefined이 된다.
new File(['없는사진'], '없는사진.jpg'
을 만들어 대체하기로 했다. 이는 두 개의 고려할 점이 있는데 이 가상의 파일을 서버에 저장하게 된다는 점과 정말 "없는사진.jpg"파일을 사용자가 넣는 경우를 대비해야 한다는 점이다. 우선은 사용자가 적고 undefined보단 나은 방법이라고 생각하여 해당 방식을 채택하였으나 보완이 필요하다.이렇게 폼데이터를 만들면 이제 서버로 보내야 한다.
request는 아래처럼 요청했다.
export const multiPostFetch = async (url: string, body: FormData) => {
try {
const response = await fetch(url, {
method: 'POST',
body,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const errorMessage = await response.text();
throw new Error(errorMessage);
}
} catch (e) {
const error = e as Error;
throw new Error(error.message);
}
};
처음에는 헤더에 'Content-Type': 'application/json'
처럼 콘텐츠 형식을 넣어줘야 한다고 생각을 해서 이를 폼데이터로 줬다. (넣는것이 허용되는 데이터 타입이다.) 그런데 오히려 넣었을 때 백엔드측에서 이 데이터를 받아들이지 못했다. 이를 지우니 오히려 통신이 원활했다. 왜인지는 알 수 없다.😅
이제 서버는 우리가 준 게시글 데이터를 저장한다.
게시글을 가지고 올 때 우리가 보낸 사진은 서버에 저장된 url로 변환되어 전달된다.
{
postId: number;
title: string;
content: string;
imageUrl: string; //< 이렇게 들어온다!
options: [...]
:
}
이 데이터를 받으면 그리면 되는데, 막상 넣고 나니 image url을 찾지 못했다. alt만 나왔는데,
알고보니 url앞에 도메인 주소를 붙여줘야 했다. 번거로워졌다.
이는 백엔드랑 협의하는 것에 따라 우리처럼 할 수도 있고 다르게 할 수도 있다.
왜냐하면 우리가 단순하게 보여주는 거면 response를 받아왔을 때 수정하면 끝이지만,
문제는 이걸 글을 수정할 때 사용하고 다시 보내야 하는 경우가 생기기 때문이다.
예로 현재 방법으로는
http---http----찐url
이런 형식이 되어버려 오류가 난다.막상 적어놓고 보니 크게 어려울 것 없는 코드이다. 다만 프론트도 처음, 백도 처음 사진을 다루어보다보니 온갖것을 다 부딪히며 겪어본 느낌이다. 아직 changeEvent에서 발생하는 버그나 imageUrl 처리에 대한 논의 등 완벽하다고 할 수 없는 코드이지만 잘 굴러가고 앞으론 더 성능을 향상 시키는 부분이라고 생각해 그래도 만족스럽다☺️