Skip to Content
WebReactReact 실무12. 표지 이미지 업로드와 미리보기

이번 편의 결과물: 책 등록 폼에서 표지 이미지를 선택하면 즉시 미리보기가 뜨고, 저장 후 목록·상세에 이미지가 보입니다. · 다루는 개념: input[type="file"]FileReader, base64 데이터 URL, PATCH로 필드 하나만 갱신

이 편에서 만드는 파일

bookshelf/src/ ├── components/ │ ├── BookForm.jsx ~ 수정 (파일 입력·미리보기 추가) │ └── BookCard.jsx ~ 수정 (coverImage 있으면 이미지, 없으면 coverColor) ├── pages/ │ ├── BookDetailPage.jsx ~ 수정 (coverImage 렌더링) │ └── NewBookPage.jsx ~ 수정 (등록 후 coverImage를 별도 PATCH로 저장) ├── api/ │ └── books.js ~ 수정 (updateBook 함수 추가) └── hooks/ └── useBookMutations.js ~ 수정 (useUpdateBook 추가)

개념 정리

coverImage는 파일이 아니라 문자열(base64로 인코딩한 데이터 URL)로 다룹니다. FormDatamultipart/form-data 없이, 지금까지 써온 JSON 요청 그대로 저장할 수 있습니다.

함수역할
FileReader.readAsDataURL(file)파일을 data:image/png;base64,... 형태의 문자열로 비동기 변환
reader.onload변환이 끝나면 reader.result에 데이터 URL이 담겨 호출됨

URL.createObjectURL과 달리 FileReader가 만든 데이터 URL은 별도로 해제(revoke)할 필요가 없는 순수 문자열입니다. 그 값을 <img src>에 그대로 쓰면 미리보기가 되고, 서버에 저장하는 값과 미리보기 값이 같아 변환을 두 번 할 필요도 없습니다.

책을 새로 등록할 때는 아직 id가 없어 표지를 한 번에 같이 저장할 수 없습니다. 그래서 순서를 나눕니다. (1) 텍스트 필드로 먼저 POST /books를 보내 책을 만들고, (2) 응답으로 받은 idPATCH /books/:idcoverImage 필드만 갱신합니다. PATCH는 본문에 담은 필드만 바꾸고 나머지는 그대로 둔다는 점에서, 문서 전체를 교체하는 PUT과 다릅니다.

실습

1. BookForm에 파일 입력과 미리보기 추가

// src/components/BookForm.jsx (파일 관련 부분만 발췌 — 나머지는 10편 코드 유지) import { useState } from 'react'; // ...기존 import 유지(useForm, zodResolver, bookSchema) function BookForm({ defaultValues, onSubmitBook, submitLabel = '저장' }) { // ...기존 useForm 설정 유지 const [coverImage, setCoverImage] = useState(defaultValues?.coverImage ?? null); function handleCoverChange(e) { const file = e.target.files?.[0] ?? null; if (!file) { setCoverImage(null); return; } const reader = new FileReader(); reader.onload = () => setCoverImage(reader.result); reader.readAsDataURL(file); } async function onValid(values) { // ...기존 coverColor 계산 유지 await onSubmitBook({ ...values, coverColor, coverImage }, { setError }); } return ( <form className="book-form" onSubmit={handleSubmit(onValid)} noValidate> {/* ...기존 title·author·pages·status·memo 필드 유지 */} <label htmlFor="book-cover">표지 이미지</label> <input id="book-cover" type="file" accept="image/*" onChange={handleCoverChange} /> {coverImage && ( <img className="cover-preview" src={coverImage} alt="표지 미리보기" width={120} /> )} <button type="submit" disabled={isSubmitting}> {isSubmitting ? '저장 중...' : submitLabel} </button> </form> ); } export default BookForm;

readAsDataURL은 비동기라 결과가 바로 나오지 않습니다. reader.onload가 호출되는 시점에야 reader.result가 채워지므로, 그 안에서 setCoverImage를 부릅니다. 파일을 선택하지 않으면 coverImagenull로 되돌려 기존 coverColor 블록이 다시 보이게 합니다.

2. api/books.js에 updateBook 추가

// src/api/books.js (updateBook만 발췌 — 나머지 함수는 05~10편 코드 유지) export async function updateBook(id, changes) { const response = await fetch(`${API_BASE}/books/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(changes), }); if (!response.ok) throw new Error('책 정보를 수정하지 못했습니다'); return response.json(); }

3. useBookMutations.js에 useUpdateBook 추가

// src/hooks/useBookMutations.js (useUpdateBook만 발췌 — useCreateBook·useDeleteBook, 기존 import는 05~06편 코드 유지) import { updateBook } from '../api/books.js'; // useMutation, useQueryClient는 05편에서 이미 import함 export function useUpdateBook() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, changes }) => updateBook(id, changes), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['books'] }); }, }); }

mutationFn이 인자 하나({ id, changes })만 받도록 감싸, 호출부에서 mutate({ id, changes }) 형태로 통일합니다.

4. NewBookPage에서 등록 후 표지를 PATCH로 저장

// src/pages/NewBookPage.jsx import { useNavigate } from 'react-router'; import BookForm from '../components/BookForm'; import { useCreateBook, useUpdateBook } from '../hooks/useBookMutations.js'; function NewBookPage() { const navigate = useNavigate(); const createBook = useCreateBook(); const updateBook = useUpdateBook(); async function handleSubmitBook({ coverImage, ...values }, { setError }) { try { const created = await createBook.mutateAsync(values); if (coverImage) { await updateBook.mutateAsync({ id: created.id, changes: { coverImage } }); } navigate('/'); } catch (error) { const fieldErrors = error.fieldErrors ?? {}; const fields = Object.keys(fieldErrors); if (fields.length === 0) { setError('root', { message: '저장에 실패했습니다. 잠시 후 다시 시도하세요.' }); return; } fields.forEach((field) => setError(field, { type: 'server', message: fieldErrors[field] })); } } return ( <section> <h2>책 등록</h2> <BookForm onSubmitBook={handleSubmitBook} submitLabel="등록" /> </section> ); } export default NewBookPage;

coverImage는 구조 분해로 따로 떼어내 createBook(텍스트 필드만)에는 넘기지 않습니다. 책이 만들어져 id가 생긴 뒤에야 updateBook으로 표지를 저장합니다.

5. BookCard·BookDetailPage에서 이미지 렌더링

// src/components/BookCard.jsx (표지 부분만 발췌 — 나머지는 기존 코드 유지) function BookCover({ book }) { if (book.coverImage) { return <img className="book-cover" src={book.coverImage} alt={`${book.title} 표지`} />; } return <div className="book-cover-placeholder" style={{ backgroundColor: book.coverColor }} />; }

BookDetailPage.jsx에도 같은 BookCover 컴포넌트를 가져다 써서 목록과 상세에서 동일한 규칙(이미지 있으면 이미지, 없으면 색상 블록)을 유지합니다.

6. 실행

npx json-server db.json --port 3001 npm run dev

7. 확인

  • 책 등록 폼에서 이미지 파일을 선택하면 서버 응답을 기다리지 않고 즉시 미리보기가 나타납니다.
  • 파일을 선택하지 않고 저장하면 기존처럼 coverColor 블록이 표시됩니다.
  • 이미지를 넣어 저장한 뒤 목록으로 돌아오면 해당 책 카드에 실제 이미지가 보입니다.
  • 상세 페이지에서도 같은 이미지가 보입니다.
  • 개발자 도구 네트워크 탭에서 POST /books 다음에 PATCH /books/:id 요청이 이어지는지 확인합니다.
  • bookshelf/db.json을 열어 해당 책의 coverImagedata:image/... 문자열로 저장됐는지 확인합니다.

직접 해보기

  1. 이미지 크기가 2MB를 넘으면 미리보기 전에 “파일이 너무 큽니다” 메시지를 띄우고 업로드를 막아 보세요(file.size 확인).
  2. 이미지가 아닌 파일(.pdf 등)을 선택했을 때 accept="image/*"만으로는 완전히 막히지 않는 브라우저도 있습니다. file.type.startsWith('image/')로 한 번 더 검사해 보세요.

정답 보기

// src/components/BookForm.jsx (handleCoverChange 안) function handleCoverChange(e) { const file = e.target.files?.[0] ?? null; if (!file) return setCoverImage(null); if (file.size > 2 * 1024 * 1024) { alert('파일이 너무 큽니다(2MB 이하)'); e.target.value = ''; return; } if (!file.type.startsWith('image/')) { alert('이미지 파일만 선택하세요'); e.target.value = ''; return; } const reader = new FileReader(); reader.onload = () => setCoverImage(reader.result); reader.readAsDataURL(file); }

e.target.value = ''로 입력을 비워야 같은 파일을 다시 선택했을 때도 onChange가 실행됩니다.

자주 하는 실수

증상원인고치는 법
저장 후에도 이미지가 안 보임createBook 성공 후 updateBook을 호출하지 않음handleSubmitBook에서 created.idupdateBook.mutateAsync 호출 확인
미리보기가 늦게 나타나거나 안 바뀜reader.onload 밖에서 setCoverImage 호출콜백 안에서만 reader.result를 읽을 수 있음을 확인
db.json에 coverImage가 매우 길게 저장됨base64는 원본보다 약 33% 커짐(정상 동작)실습 규모에서는 문제 없음. 실제 서비스는 별도 스토리지 URL 사용
목록에는 이미지가 안 보이고 색상 블록만 보임BookCard가 여전히 coverColor만 참조book.coverImage 우선 분기 추가 확인

확인 문제

문제 14지선다
이 편에서 표지 이미지를 서버에 저장하는 형식은?
문제 24지선다
새 책 등록 시 coverImage를 POST 본문에 바로 넣지 않고 별도 PATCH로 저장하는 이유는?
문제 34지선다
PATCH 요청이 PUT과 다른 점은?
문제 44지선다
FileReader.readAsDataURL을 URL.createObjectURL 대신 쓸 때의 이점은?

참고 자료

Last updated on