Use Cases (업데이트: 2026. 6. 1.)

Claude Code로 안전한 파일 업로드 구현하기: FormData, 검증, 미리보기, S3

SaaS 파일 업로드를 Claude Code로 안전하게 만드는 실무 가이드. File API, FormData, fetch, 검증, 진행률, S3까지 다룹니다.

Claude Code로 안전한 파일 업로드 구현하기: FormData, 검증, 미리보기, S3

파일 업로드는 SaaS에서 아주 빨리 등장하는 기능입니다. 프로필 이미지, 청구서 PDF, CSV 가져오기, 계약서, 채팅 첨부파일처럼 겉으로는 모두 “파일 선택” 버튼 하나로 보입니다. 하지만 실제로는 브라우저 API, 전송 형식, 서버 검증, 저장소 권한, 공개 범위, 미리보기, 진행률, 비용, 감사 로그까지 함께 설계해야 합니다.

Claude Code는 이 작업에 잘 맞습니다. React 컴포넌트, API 라우트, 검증 함수, 저장소 어댑터, README를 한 번에 만들 수 있습니다. 다만 프롬프트가 모호하면 데모는 동작하지만 원본 파일명을 그대로 저장하거나, 브라우저가 보내는 MIME 타입만 믿거나, S3 버킷을 공개 전제로 만드는 코드가 나올 수 있습니다.

이 글은 안전하게 리뷰할 수 있는 순서로 진행합니다. 먼저 브라우저의 File API, FormData, Fetch API를 나누어 이해하고, 최소 업로드, React 미리보기와 실제 진행률, 서버 검증, S3나 Cloud Storage로 옮기는 기준까지 정리합니다.

스토리지 설계는 Claude Code와 AWS S3와 함께 보면 좋고, 권한 리뷰는 Claude Code 보안 실천의 관점이 도움이 됩니다.

브라우저 쪽 역할을 먼저 나누기

첫 번째는 File API입니다. 사용자가<input type="file">로 파일을 선택하거나 드래그 앤 드롭하면 브라우저는File객체를 제공합니다. 여기서file.name, file.size, file.type, file.lastModified를 읽을 수 있습니다. 이미지라면URL.createObjectURL(file)로 임시 미리보기 URL도 만들 수 있습니다.

두 번째는 FormData입니다. 파일과 일반 필드를multipart/form-data로 보내는 컨테이너입니다. 보통formData.append("file", file)로 넣고 fetch의 body로 전달합니다. 중요한 점은 FormData를 사용할 때Content-Type헤더를 직접 고정하지 않는 것입니다. multipart boundary는 브라우저가 붙여야 하므로 수동 설정이 오히려 요청을 깨뜨릴 수 있습니다.

세 번째는 fetch입니다. 작은 파일을 단순히 보내는 경우에는 fetch가 충분합니다.

const formData = new FormData();
formData.append("file", file);

await fetch("/api/upload", {
  method: "POST",
  body: formData
});

하지만 실제 업로드 진행률이 필요하면 이야기가 달라집니다. fetch만으로 업로드 progress 이벤트를 다루기는 아직 불편합니다. 그래서 진행률 바가 중요한 화면에서는XMLHttpRequest.upload.onprogress를 쓰고, 단순 업로드에서는 fetch를 쓰는 식으로 Claude Code에 명확히 지시하는 편이 안전합니다.

HTML과 fetch로 최소 구현 만들기

React와 S3부터 시작하지 말고, 먼저 브라우저 흐름을 확인합니다. 아래 예시는 파일 선택, 타입/크기 확인, 이미지 미리보기, FormData 전송을 포함합니다.

<form id="upload-form">
  <input id="file-input" name="file" type="file" accept="image/png,image/jpeg,application/pdf" />
  <button type="submit">업로드</button>
</form>
<img id="preview" alt="" style="max-width: 240px; display: none;" />
<p id="message"></p>

<script type="module">
  const MAX_BYTES = 5 * 1024 * 1024;
  const allowedTypes = new Set(["image/png", "image/jpeg", "application/pdf"]);
  const form = document.querySelector("#upload-form");
  const input = document.querySelector("#file-input");
  const preview = document.querySelector("#preview");
  const message = document.querySelector("#message");

  input.addEventListener("change", () => {
    const file = input.files?.[0];
    preview.style.display = "none";
    preview.removeAttribute("src");
    message.textContent = "";

    if (!file) return;
    if (!allowedTypes.has(file.type)) {
      message.textContent = "PNG, JPEG, PDF만 업로드할 수 있습니다.";
      input.value = "";
      return;
    }
    if (file.size > MAX_BYTES) {
      message.textContent = "파일은 5MB 이하이어야 합니다.";
      input.value = "";
      return;
    }
    if (file.type.startsWith("image/")) {
      preview.src = URL.createObjectURL(file);
      preview.style.display = "block";
      preview.onload = () => URL.revokeObjectURL(preview.src);
    }
  });

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const file = input.files?.[0];
    if (!file) {
      message.textContent = "파일을 먼저 선택하세요.";
      return;
    }

    const formData = new FormData();
    formData.append("file", file);

    const response = await fetch("/api/upload", {
      method: "POST",
      body: formData
    });

    const result = await response.json();
    message.textContent = response.ok ? `저장됨: ${result.name}` : result.error;
  });
</script>

클라이언트 검증은 사용자에게 빠르게 오류를 보여주기 위한 장치입니다. 보안의 마지막 방어선은 아닙니다. 악의적인 클라이언트는 이 JavaScript를 우회할 수 있으므로 서버가 다시 검증해야 합니다.

React에서 미리보기와 실제 진행률 구현

React에서는 상태를 분리해 두는 것이 좋습니다. 선택한 파일, 미리보기 URL, 진행률, 오류, 저장된 파일명을 각각 다루면 나중에 Claude Code가 드래그 앤 드롭이나 재시도를 추가하기 쉽습니다.

import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";

const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "application/pdf"]);

type UploadResult = { ok: true; name: string; size: number; type: string };

export function FileUploadBox() {
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
  const [progress, setProgress] = useState(0);
  const [error, setError] = useState<string | null>(null);
  const [uploadedName, setUploadedName] = useState<string | null>(null);
  const canUpload = useMemo(() => selectedFile && !error, [selectedFile, error]);

  useEffect(() => {
    return () => {
      if (previewUrl) URL.revokeObjectURL(previewUrl);
    };
  }, [previewUrl]);

  function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0] ?? null;
    setUploadedName(null);
    setProgress(0);
    setError(null);
    if (previewUrl) URL.revokeObjectURL(previewUrl);
    setPreviewUrl(null);

    if (!file) return setSelectedFile(null);
    if (!ALLOWED_TYPES.has(file.type)) {
      setSelectedFile(null);
      return setError("PNG, JPEG, PDF만 업로드할 수 있습니다.");
    }
    if (file.size > MAX_BYTES) {
      setSelectedFile(null);
      return setError("파일은 5MB 이하이어야 합니다.");
    }
    setSelectedFile(file);
    if (file.type.startsWith("image/")) setPreviewUrl(URL.createObjectURL(file));
  }

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (!selectedFile) return;
    const formData = new FormData();
    formData.append("file", selectedFile);
    const result = await uploadWithProgress(formData, setProgress);
    setUploadedName(result.name);
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <input type="file" accept="image/png,image/jpeg,application/pdf" onChange={handleFileChange} />
      {previewUrl && <img src={previewUrl} alt="선택한 파일 미리보기" width={240} />}
      {selectedFile && <p>{selectedFile.name} / {Math.round(selectedFile.size / 1024)}KB</p>}
      {error && <p role="alert">{error}</p>}
      <progress value={progress} max={100}>{progress}%</progress>
      <button type="submit" disabled={!canUpload}>업로드</button>
      {uploadedName && <p>저장됨: {uploadedName}</p>}
    </form>
  );
}

function uploadWithProgress(formData: FormData, onProgress: (progress: number) => void) {
  return new Promise<UploadResult>((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("POST", "/api/upload");
    xhr.upload.addEventListener("progress", (event) => {
      if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
    });
    xhr.addEventListener("load", () => {
      const body = JSON.parse(xhr.responseText || "{}");
      if (xhr.status >= 200 && xhr.status < 300) resolve(body);
      else reject(new Error(body.error ?? "Upload failed"));
    });
    xhr.addEventListener("error", () => reject(new Error("Network error")));
    xhr.send(formData);
  });
}

진행률은 신뢰의 문제입니다. fetch로 보내면서 타이머로 숫자만 올리는 UI는 느린 네트워크에서 사용자를 속이는 것처럼 보일 수 있습니다. 실제 진행률이 필요하면 XHR을 쓰고, 필요하지 않다면 단순한 로딩 상태가 더 정직합니다.

서버 검증은 반드시 다시 한다

아래는 Next.js Route Handler 예시입니다. 첫 버전은.local-uploads에 저장합니다. 운영에서는 이 부분을 S3, Cloud Storage, Azure Blob Storage, R2 어댑터로 교체합니다.

// app/api/upload/route.ts
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextRequest, NextResponse } from "next/server";

export const runtime = "nodejs";

const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
  ["image/png", ".png"],
  ["image/jpeg", ".jpg"],
  ["application/pdf", ".pdf"]
]);

export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const value = formData.get("file");
  if (!(value instanceof File)) {
    return NextResponse.json({ error: "파일이 없습니다." }, { status: 400 });
  }

  const expectedExt = ALLOWED_TYPES.get(value.type);
  const originalExt = path.extname(value.name).toLowerCase();
  if (!expectedExt) return NextResponse.json({ error: "허용되지 않은 MIME 타입입니다." }, { status: 400 });
  if (value.size === 0 || value.size > MAX_BYTES) {
    return NextResponse.json({ error: "파일은 1바이트 이상 5MB 이하이어야 합니다." }, { status: 400 });
  }
  if (expectedExt === ".jpg" && ![".jpg", ".jpeg"].includes(originalExt)) {
    return NextResponse.json({ error: "JPEG 확장자가 올바르지 않습니다." }, { status: 400 });
  }
  if (expectedExt !== ".jpg" && originalExt !== expectedExt) {
    return NextResponse.json({ error: "MIME 타입과 확장자가 일치하지 않습니다." }, { status: 400 });
  }

  const bytes = Buffer.from(await value.arrayBuffer());
  const storedName = `${randomUUID()}${expectedExt === ".jpg" ? ".jpg" : expectedExt}`;
  const uploadDir = path.join(process.cwd(), ".local-uploads");
  await mkdir(uploadDir, { recursive: true });
  await writeFile(path.join(uploadDir, storedName), bytes, { flag: "wx" });
  return NextResponse.json({ ok: true, name: storedName, size: value.size, type: value.type });
}

최소한 MIME 타입, 확장자, 크기, 저장명, 저장 위치를 확인해야 합니다. 더 높은 위험의 문서라면 파일 시그니처, 이미지 디코딩, PDF 검사, 바이러스 스캔, 사용자 인증, 조직 권한, 용량 제한, 감사 로그도 필요합니다.

S3나 Cloud Storage로 옮기는 기준

로컬 저장은 학습과 프로토타입에는 좋지만 SaaS 사용자 파일의 장기 저장소로는 부적합합니다. 파일이 고객 자산이고, 서버가 여러 대가 될 수 있고, 백업이나 수명 주기 관리가 필요하고, 다운로드 트래픽이 늘 수 있다면 객체 스토리지를 사용해야 합니다.

처음부터 브라우저가 S3에 직접 올릴 필요는 없습니다. 작은 파일은 애플리케이션 서버가 받아 검증한 뒤 S3에 저장해도 충분합니다. 파일이 커지거나 트래픽이 늘 때 presigned URL과 multipart upload를 도입하면 됩니다.

기존 Next.js 로컬 파일 업로드를 S3 저장으로 변경해 주세요.
현재 상태: /api/upload가 FormData를 받고 MIME, 확장자, 크기를 검증합니다.
제약: 원본 파일명을 S3 key로 쓰지 말고 uploads/yyyy/mm/dd/{uuid}.ext에 저장합니다.
제약: image/png, image/jpeg, application/pdf만 허용하고 최대 5MB입니다.
제약: bucket은 private으로 유지하고 API는 공개 URL이 아니라 파일 ID만 반환합니다.
산출물: app/api/upload/route.ts, lib/storage/s3.ts, 실패 케이스 테스트, README 환경 변수 설명.
확인: 초과 크기, 확장자 불일치, 미로그인, 정상 업로드 테스트를 설명해 주세요.

실무 예시와 실패 사례

예시 1은 프로필 이미지입니다. 미리보기, 크롭, 재시도 UX가 중요합니다. 초보자라면 PNG, JPEG, WebP만 허용하고 SVG는 신중하게 다루는 편이 좋습니다.

예시 2는 CSV 가져오기입니다. 업로드보다 컬럼명, 행 수, 문자 인코딩, 중복 데이터, 롤백 전략이 더 중요합니다. Claude Code에는 CSV 샘플과 실패 리포트 형식까지 주는 것이 좋습니다.

예시 3은 청구서와 계약서 PDF입니다. 공개 URL을 바로 반환하지 말고 private 저장소에 넣은 뒤 인증과 권한 확인 후 짧은 시간의 서명 URL을 발급합니다.

자주 하는 실수는accept를 보안으로 착각하는 것, 원본 파일명을 저장명으로 쓰는 것, 업로드 직후 공개 URL을 반환하는 것, 가짜 진행률을 보여주는 것, S3 권한을 넓게 주는 것입니다.

Claude Code에 붙여 넣을 프롬프트

이 Next.js 앱에 안전한 파일 업로드를 추가해 주세요.
목표: SaaS 관리 화면에서 PNG/JPEG/PDF를 한 번에 하나씩 업로드합니다.
클라이언트: React 컴포넌트에서 File API를 사용하고 파일명, 크기, 이미지 미리보기, 오류, 업로드 상태를 표시합니다.
전송: FormData로 /api/upload에 POST합니다. 실제 진행률이 필요하면 XMLHttpRequest를 사용하고 fetch를 쓰지 않는 이유를 설명합니다.
서버: app/api/upload/route.ts에서 FormData를 받고 MIME 타입, 확장자, 5MB 제한, 빈 파일을 검증합니다.
저장: 원본 파일명은 저장명으로 쓰지 않고 UUID + 확장자로 .local-uploads에 저장합니다.
금지: public/에 직접 저장하지 않습니다. 확장자 검사만으로 완전한 보안이라고 말하지 않습니다. S3 bucket을 public으로 만들지 않습니다.
검증: 초과 크기, 확장자 불일치, 파일 없음, 정상 업로드 테스트를 설명합니다.
참조: MDN File API, FormData, Fetch API.

Masa 검증 메모

이 흐름을 시험하면서 가장 크게 품질이 좋아진 부분은 “fetch로 단순 업로드”와 “실제 진행률 업로드”를 분리한 것입니다. 그냥 진행률 바를 만들어 달라고 하면 보기에는 그럴듯하지만 실제 전송량과 연결되지 않은 UI가 나올 수 있었습니다.

또 하나는 처음부터 S3로 가지 않는 것입니다. 로컬 저장으로 File API, FormData, 서버 검증, 미리보기를 먼저 고정하면 문제를 분리하기 쉽습니다. 그 다음 저장 어댑터만 S3로 바꾸는 편이 초보자에게 훨씬 이해하기 쉽습니다.

정리

안전한 파일 업로드는 입력 필드가 아니라 사용자 기기, 애플리케이션 서버, 저장소, 권한 모델 사이의 경계입니다. Claude Code는 이 경계를 빠르게 구현할 수 있지만, 프롬프트에는 File API, FormData, 서버 검증, 크기 제한, 생성된 저장명, 정직한 진행률, private 객체 저장소라는 제약을 분명히 써야 합니다.

실제 저장소와 권한까지 함께 설계하고 싶다면 Claude Code 교육 및 상담에서 업로드 흐름, S3 저장, 서명 URL, 테스트, 리뷰 체크리스트까지 다룰 수 있습니다. 무료 PDF와 학습 자료로 먼저 패턴을 확인해도 좋습니다.

#Claude Code #file upload #FormData #S3 #React #security
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.