이번 편의 결과물: 책 등록 폼에서 표지 이미지를 선택하면 즉시 미리보기가 뜨고, 저장 후 목록·상세에 이미지가 보입니다. · 다루는 개념: 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)로 다룹니다. FormData나 multipart/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) 응답으로 받은 id에 PATCH /books/:id로 coverImage 필드만 갱신합니다. 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를 부릅니다. 파일을 선택하지 않으면 coverImage를 null로 되돌려 기존 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 dev7. 확인
- 책 등록 폼에서 이미지 파일을 선택하면 서버 응답을 기다리지 않고 즉시 미리보기가 나타납니다.
- 파일을 선택하지 않고 저장하면 기존처럼
coverColor블록이 표시됩니다. - 이미지를 넣어 저장한 뒤 목록으로 돌아오면 해당 책 카드에 실제 이미지가 보입니다.
- 상세 페이지에서도 같은 이미지가 보입니다.
- 개발자 도구 네트워크 탭에서
POST /books다음에PATCH /books/:id요청이 이어지는지 확인합니다. bookshelf/db.json을 열어 해당 책의coverImage가data:image/...문자열로 저장됐는지 확인합니다.
직접 해보기
- 이미지 크기가 2MB를 넘으면 미리보기 전에 “파일이 너무 큽니다” 메시지를 띄우고 업로드를 막아 보세요(
file.size확인). - 이미지가 아닌 파일(
.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.id로 updateBook.mutateAsync 호출 확인 |
| 미리보기가 늦게 나타나거나 안 바뀜 | reader.onload 밖에서 setCoverImage 호출 | 콜백 안에서만 reader.result를 읽을 수 있음을 확인 |
db.json에 coverImage가 매우 길게 저장됨 | base64는 원본보다 약 33% 커짐(정상 동작) | 실습 규모에서는 문제 없음. 실제 서비스는 별도 스토리지 URL 사용 |
| 목록에는 이미지가 안 보이고 색상 블록만 보임 | BookCard가 여전히 coverColor만 참조 | book.coverImage 우선 분기 추가 확인 |