왜 파일·이미지 검증이 생각보다 자주 실패할까
쇼핑몰 상품 등록, 프로필 변경, 지원서 업로드처럼 파일 입력은 흔하지만, 타입·용량·해상도 검증을 놓치면 런타임 오류나 보안 이슈로 이어지기 쉽습니다. 특히 App Router 환경에서는 클라이언트 폼 검증과 서버 액션/라우트 핸들러 검증을 함께 설계해야 안전합니다.
React Hook Form은 성능 좋은 폼 상태 관리를 제공하고, Zod는 스키마 기반으로 입력을 엄격히 검사합니다. 두 도구를 조합하면 “미리보기는 되는데 서버에서 거절되는” 불일치를 줄이고, 사용자에게 즉각적인 피드백을 줄 수 있습니다.
이 글은 파일·이미지 업로드에서 자주 막히는 포인트를 패턴으로 정리합니다. 예를 들어 허용 확장자와 MIME 동시 체크, 최대 용량/개수 제한, 이미지 픽셀 크기·가로세로비 검증, 서버 액션에서의 2차 검증까지 단계별로 다룹니다.
실무에서 바로 적용할 수 있도록 클라이언트(React Hook Form + Zod)와 서버(App Router의 Route Handler/Server Action) 연동을 함께 보여줍니다. 적용 전 체크리스트와 최소 예제 위주로 설명해 시행착오를 줄이도록 구성했습니다.
클라이언트-서버 이중 검증이 기본선입니다
폼에서의 파일 검증은 “입력값”이 아니라 “Blob(파일 객체)”를 다룬다는 점이 다릅니다. 따라서 확장자 문자열만 확인하면 부족하고, 실제 MIME, 바이트 크기, 이미지라면 픽셀 정보를 함께 점검해야 합니다.
React Hook Form에서는 input[type=file]을 컨트롤러로 등록하고, Zod는 커스텀 refine으로 File 리스트를 검증합니다. 이때 클라이언트는 즉시 피드백을 제공하고, 서버 액션/라우트에서는 FormData를 다시 파싱해 같은 규칙으로 재검증하여 불일치를 줄입니다.
실무 사례로는 다음 흐름이 안전합니다.
- 업로드 전: 확장자+MIME 동시 체크, 개수/용량 제한
- 이미지일 때: 가로·세로 최소값, 비율(예: 1:1) 확인
- 서버 액션: 동일 스키마로 재검증 후 저장 경로 결정 또는 리젝
App Router에서는 사용자가 새로고침 없이 서버 액션을 호출할 수 있어 클라이언트 검증을 건너뛰는 경우가 생깁니다. 같은 Zod 규칙을 서버에 복제하거나, 공유 가능한 스키마 모듈을 만들어 “한 소스”에서 관리하는 구성이 유지보수에 유리합니다.
App Router에서 RHF+Zod로 파일 검증 빠르게 맞추기
클라이언트·서버가 같은 규칙을 쓰도록 스키마를 공용 모듈에 둡니다. 확장자·MIME, 개수, 용량은 Zod로 1차 검증하고, 해상도·비율은 클라이언트 선검증 후 서버에서 재검증합니다.
체크리스트
- input[type=file]를 RHF Controller로 등록
- Zod에서 FileList 커스텀 처리
- 확장자+MIME, 개수, 용량 제한
- 이미지 픽셀 크기·가로세로비 확인
- 서버 액션에서 동일 규칙 재검증 후 저장
아래 예시는 공용 스키마로 즉시 검증과 서버 재검증을 구성한 형태입니다. 코드 구조만 참고하세요.
// shared/schema.ts
import { z } from "zod";
const ACCEPT = ["image/png","image/jpeg"];
export const fileSchema = z.object({
files: z.custom<FileList>()
.refine(f => f && f.length > 0, "파일을 선택하세요.")
.refine(f => f.length <= 3, "최대 3개까지 업로드 가능합니다.")
.refine(f => [...f].every(v => ACCEPT.includes(v.type)), "PNG/JPEG만 허용됩니다.")
.refine(f => [...f].every(v => v.size <= 2 * 1024 * 1024), "각 파일은 2MB 이하여야 합니다."),
});
// app/(routes)/upload/page.tsx
"use client";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { fileSchema } from "@/shared/schema";
import { uploadAction } from "./actions";
export default function UploadPage() {
const { control, handleSubmit, formState:{ errors } } = useForm({
resolver: zodResolver(fileSchema),
defaultValues: { files: undefined as unknown as FileList },
});
const onSubmit = async (data:any) => {
const ok = await validateImages([...data.files], { minW:400, minH:400, ratio:1 });
if (!ok) return alert("이미지 크기 또는 비율이 맞지 않습니다.");
const fd = new FormData();
[...data.files].forEach(f => fd.append("files", f));
const res = await uploadAction(fd);
if (res.error) alert(res.error);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="files"
control={control}
render={({ field }) => (
<input
type="file"
multiple
accept="image/png,image/jpeg"
onChange={e => field.onChange(e.target.files)}
/>
)}
/>
{errors.files && <p>{String(errors.files.message)}</p>}
<button type="submit">업로드</button>
</form>
);
}
async function validateImages(files: File[], r:{minW:number;minH:number;ratio:number}) {
const checks = await Promise.all(files.map(f => new Promise<boolean>(resolve => {
const img = new Image();
img.onload = () => {
const okSize = img.width >= r.minW && img.height >= r.minH;
const okRatio = Math.abs(img.width / img.height - r.ratio) < 0.01;
resolve(okSize && okRatio);
};
img.onerror = () => resolve(false);
img.src = URL.createObjectURL(f);
})));
return checks.every(Boolean);
}
// app/(routes)/upload/actions.ts
"use server";
import { fileSchema } from "@/shared/schema";
export async function uploadAction(fd: FormData) {
const files = fd.getAll("files") as File[];
const parsed = fileSchema.safeParse({ files: toFileList(files) });
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "유효성 오류" };
// 필요 시 이미지 크기 재검증(Sharp 등) 후 저장
return { ok: true };
}
function toFileList(files: File[]) {
return { length: files.length, item: (i:number)=>files[i], ...files } as unknown as FileList;
}
핵심은 규칙을 한 곳에 모아 클라이언트와 서버가 동일한 기준으로 판정하는 것입니다. 해상도처럼 계산이 필요한 항목은 선판정+재검증으로 사용자 경험과 안전성을 함께 확보합니다.
흔한 실수와 안전한 선택 기준
파일 검증에서 가장 빈번한 실수는 확장자만 보고 통과시키는 것입니다. 반드시 실제 MIME과 바이트 크기를 함께 검증하고, 이미지일 경우 픽셀 크기·가로세로비까지 별도로 점검하는 편이 안전합니다.
App Router에서는 클라이언트 검증을 건너뛰고 서버 액션을 직접 호출하는 경로가 생길 수 있습니다. 서버에서도 동일 스키마로 재검증하고, 오류 메시지는 사용자가 이해할 단위(타입, 용량, 해상도)로 분리해 반환하세요.
Blob과 FileList를 Zod로 다룰 때는 null/undefined와 빈 목록을 구분하지 않아 생기는 예외가 잦습니다. 파일 필드가 선택 해제되는 경우를 대비해 필수 여부, 최소·최대 개수, 드래그앤드롭 중복 업로드 처리까지 포함해 방어적으로 설계합니다.
선택 기준은 목적에 맞춘 규칙의 우선순위로 정리하면 깔끔합니다.
- 보안 우선: 서버 2차 검증, 화이트리스트 MIME, 개수·용량 하드 리밋
- 품질 우선: 최소 해상도·비율, 색 공간/EXIF 제거 여부
- UX 우선: 즉시 미리보기, 항목별 명확한 에러 문구, 재시도 동선
검증 책임 분리도 중요합니다. 클라이언트는 빠른 피드백과 미리보기용 선검증, 서버는 최종 승인과 저장 전 정규화(리사이즈, 포맷 변환)를 담당하게 나누면 충돌을 줄일 수 있습니다.
지금 적용할 실행 순서와 마무리 체크
이번 글의 핵심은 클라이언트와 서버에서 같은 규칙으로 파일을 검증해 불일치를 줄이는 것입니다. 확장자·MIME·용량·개수는 스키마로, 해상도·비율은 이미지 로딩으로 분리해 점검하세요.
바로 적용할 단계
- 공용 폴더에 Zod 스키마 생성: FileList 필수 여부, 개수, 용량, 허용 MIME 정의
- RHF Controller로 input[file] 연결: onChange에서 Zod 1차 검증
- 이미지일 때만 캔버스/URL.createObjectURL로 해상도·비율 선검증
- App Router 서버 액션/라우트에서 FormData 재파싱 후 동일 스키마로 2차 검증
- 에러 메시지는 항목별(타입/용량/해상도)로 분리해 사용자에게 표시
필수 확인 사항
- 드래그앤드롭 중복 업로드, 선택 해제, 빈 FileList를 명확히 처리합니다.
- 서버에서 타입 변조 가능성을 전제로 화이트리스트 방식과 하드 리밋을 유지합니다.
다음 행동으로, 공용 스키마와 서버 액션 템플릿을 저장소에 추가하고 작은 폼부터 교체해 보세요. 팀 내 코드리뷰 체크리스트에 파일 검증 항목을 포함하면 재발을 줄일 수 있습니다.
'프로그래밍' 카테고리의 다른 글
| React 19 Actions와 useOptimistic으로 폼 제출·낙관적 UI 마이그레이션 체크리스트 (0) | 2026.06.25 |
|---|---|
| NextAuth.js v5(App Router)로 구글·카카오 OAuth 소셜 로그인 구현 가이드 (0) | 2026.06.25 |
| Tailwind CSS v4 마이그레이션 체크리스트: v3→v4 변경점과 @import 전환 가이드 (0) | 2026.06.08 |
| 2026 벡터DB 선택 가이드: Milvus·Qdrant·pgvector 실무 비교와 도입 기준 (0) | 2026.05.30 |
| GitHub Actions에 Claude Code 연결해 PR 자동 코드 리뷰 구축하기 가이드 및 설정 예시 (0) | 2026.05.29 |