aws s3 bucket을 통한 파일 업로드 api 작성

Park Bumsoo·2025년 3월 21일
0

Next.js14 AppRouter

목록 보기
8/10

코드만 존재하며 설명은 추후 설명예정입니다.

Component.tsx

"use client";
import { ChangeEvent, MouseEvent, useEffect, useRef, useState } from "react";
import classes from "./fileUploader.module.scss";
import Image from "next/image";
import addBtn from "/public/pages/reservation/add.svg";
import EtcWrapper from "./_section/etcWrapper";
import { v4 as uuidv4 } from "uuid";

export interface FileCallBackType {
  fileName: string;
  filePath: string;
}
interface FileUploaderTypes {
  onChangeFile: (urls: Array<FileCallBackType>) => void;
  onChangeDeleteFile?: (urls: Array<FileCallBackType>) => void;
  bucketName: string;
  s3Path: string;
  maxFileCount?: number;
  maxFileSize?: number;
  isImage?: boolean;
  value?: Array<FileCallBackType>;
}

interface UploadFileDataType {
  url: string;
  fileName: string;
}

const FileUploader = ({
  onChangeFile,
  onChangeDeleteFile,
  bucketName,
  maxFileCount = 5,
  maxFileSize = 10,
  isImage = false,
  s3Path,
  value,
}: FileUploaderTypes) => {
  const MAX_FILES = maxFileCount;
  const MAX_FILE_SIZE_MB = maxFileSize;

  const fileUploadRef = useRef<HTMLInputElement>(null);
  const [fileNames, setFileNames] = useState<Array<string>>([]);
  const [fileUrls, setFileUrls] = useState<Array<string>>([]);
  const [sendFiles, setSendFiles] = useState<Array<any>>([]);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const onClickFileSearch = (e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    if (fileUploadRef.current) {
      fileUploadRef.current.click();
    }
  };

  const uploadFile = async (file: File): Promise<UploadFileDataType | null> => {
    const formData = new FormData();
    const uniqueFileName = `${uuidv4()}___${file.name}`;

    formData.append("file", file);
    formData.append("fileName", uniqueFileName);
    formData.append("fileType", file.type);
    formData.append("bucketName", bucketName);
    formData.append("s3Path", s3Path);

    const response = await fetch("/api/upload", {
      method: "POST",
      body: formData, // FormData를 직접 전송
    });

    if (!response.ok) {
      console.error("Failed to upload file");
      return null;
    }

    const data = await response.json();

    return { url: data.url, fileName: uniqueFileName };
  };

  const onChangeFileItem = async (e: ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (!files) return;

    // 파일 개수 체크
    if (files.length > MAX_FILES) {
      setErrorMessage(`You can only upload up to ${MAX_FILES} files.`);
      return;
    }

    // 파일 크기 및 이미지 타입 체크
    const validFiles = Array.from(files).filter((file) => {
      if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
        setErrorMessage(`Each file must be under ${MAX_FILE_SIZE_MB}MB.`);
        return false;
      }
      if (isImage && !file.type.match(/^image\/(jpeg|png|gif|bmp|webp)$/)) {
        setErrorMessage("Only image files are allowed.");
        return false;
      }
      return true;
    });

    // 오류가 발생한 경우 초기화
    if (validFiles.length !== files.length) {
      return;
    }

    // API 라우트를 통해 파일 업로드 및 URL 저장
    const urlsArray: Array<string> = [...fileUrls];
    const fileNameArray: Array<string> = [...fileNames];

    const sendData = [];

    for (const file of validFiles) {
      const { url, fileName }: any = await uploadFile(file);
      sendData.push({ fileName: file.name, filePath: url });
      if (url) {
        urlsArray.push(url);
      }

      // if (fileName) {
      //   fileNameArray.push(fileName);
      // }
      if (fileName) {
        fileNameArray.push(fileName);
      }
    }
    onChangeFile([...sendFiles, ...sendData]);
    setFileUrls([...fileUrls, ...urlsArray]); // 업로드된 파일 URL 상태 업데이트
    setFileNames([...fileNames, ...fileNameArray]);
    setErrorMessage(null); // 오류 메시지 초기화
  };

  useEffect(() => {
    if (!value || Array.isArray(value) === false) return;
    if (Array.isArray(value)) {
      const names = value.map((v) => v.fileName);
      setFileNames(names);
      const urls = value.map((v) => v.filePath);
      setFileUrls(urls);
      setSendFiles(value);
    }
  }, [value]);

  useEffect(() => {}, []);

  return (
    <div className={classes.wrapper}>
      <div className={classes.uploadWrapper}>
        <input
          className={classes.titleInput}
          type="text"
          value={fileNames
            .map((name) => name.split("___").reverse()[0])
            .join(", ")}
          readOnly
        />
        <button className={classes.whiteButton} onClick={onClickFileSearch}>
          <Image src={addBtn} alt="" />
        </button>
        <input
          className={classes.hiddenFileInput}
          type="file"
          name="imageUpload"
          onChange={onChangeFileItem}
          ref={fileUploadRef}
          multiple
        />
      </div>

      {errorMessage && <p className={classes.error}>{errorMessage}</p>}
      <EtcWrapper
        fileUrls={fileUrls}
        fileNames={fileNames}
        sendFiles={sendFiles}
        s3Path={s3Path}
        bucketName={bucketName}
        setFileUrls={setFileUrls}
        setFileNames={setFileNames}
        setSendFiles={setSendFiles}
        onChangeDeleteFile={onChangeDeleteFile}
      />
    </div>
  );
};

export default FileUploader;

Etc wrapper

import { Dispatch, SetStateAction } from "react";
import classes from "../fileUploader.module.scss";
import { FiFileText, FiMinus } from "react-icons/fi";
import { useSetAtom } from "jotai";
import { addPopupAtom } from "@/atom/popup";
import ImagePrevModal from "./imagePrev";
import Image from "next/image";
import { FileCallBackType } from "../fileUploader";

interface EtcWrapperTypes {
  s3Path: string;
  bucketName: string;
  fileUrls: Array<string>;
  fileNames: Array<string>;
  sendFiles: Array<FileCallBackType>;
  setFileUrls: Dispatch<SetStateAction<Array<string>>>;
  setFileNames: Dispatch<SetStateAction<Array<string>>>;
  setSendFiles: Dispatch<SetStateAction<Array<FileCallBackType>>>;
  onChangeDeleteFile?: (urls: Array<FileCallBackType>) => void;
}
const EtcWrapper = ({
  fileUrls,
  fileNames,
  sendFiles,
  s3Path,
  bucketName,
  setFileUrls,
  setFileNames,
  setSendFiles,
  onChangeDeleteFile,
}: EtcWrapperTypes) => {
  /********************* ATOM **********************/
  const addPopup = useSetAtom(addPopupAtom);
  /********************* ATOM **********************/
  const onClickFile = (url: string, alt: string) => {
    return addPopup({
      type: "popup",
      content: <ImagePrevModal url={url} alt={alt} />,
    });
  };

  const onClickDelete = async (index: number) => {
    const key = `uploads/${s3Path}/${fileNames[index]}`; // S3에서 삭제할 파일의 Key 경로

    const response = await fetch("/api/upload/delete", {
      method: "DELETE",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ bucketName, key }),
    });

    if (response.ok) {
      console.log("File deleted successfully");
      // 성공 시 fileUrls와 fileNames에서 해당 파일을 제거
      setFileUrls((prevUrls) => prevUrls.filter((_, i) => i !== index));
      setFileNames((prevNames) => prevNames.filter((_, i) => i !== index));
      setSendFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));

      if (onChangeDeleteFile) {
        onChangeDeleteFile([...sendFiles.filter((_, i) => i !== index)]);
      }
    } else {
      console.error("Failed to delete file");
    }
  };
  return (
    <div className={classes.etcWrapper}>
      {/* 다운로드 */}
      {/* <div className={classes.urlList}>
        {fileUrls.map((url, index) => (
          <p key={index}>
            <a href={url} target="_blank" rel="noopener noreferrer">
              {fileNames[index]}
            </a>
          </p>
        ))}
      </div> */}

      {/* 미리보기 */}
      <div className={classes.previewList}>
        {fileUrls.map((url, index) => (
          <div key={index} className={classes.previewItem}>
            <div
              onClick={() => onClickDelete(index)}
              className={classes.deleteIconWrapper}
            >
              <FiMinus />
            </div>
            {Array.isArray(fileNames) &&
            fileNames.length > 0 &&
            fileNames[index].match(/\.(jpg|jpeg|png|gif|bmp|webp)$/i) ? (
              <div onClick={() => onClickFile(url, fileNames[index])}>
                <img
                  src={url}
                  alt={fileNames[index]}
                  className={classes.previewImage}
                />
                {/* <Image
                  src={url}
                  alt={fileNames[index]}
                  className={classes.previewImage}
                /> */}
              </div>
            ) : (
              <div>
                <FiFileText size={100} color="#cccccc" />
              </div>
            )}
          </div>
        ))}
      </div>
    </div>
  );
};
export default EtcWrapper;

사용

   /**
   * 파일 저장
   */
  const onChangeUploadFile = (urls: Array<FileCallBackType>) => {
    setState({ name: "fileData", value: urls });
  };

return(
      <FileUploader
        onChangeFile={onChangeUploadFile}
        onChangeDeleteFile={onChangeUploadFile}
        bucketName={"xplat-s3-bucket"}
        s3Path={"admin/consulting"}
        value={orderFormData.fileData}
      />
)

route.ts (Upload)

// app/api/upload/route.ts
import { NextResponse } from "next/server";
import { S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";

const s3Client = new S3Client({
  region: "ap-northeast-2",
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

// Helper function to convert ReadableStream to Buffer
async function streamToBuffer(
  stream: ReadableStream<Uint8Array>
): Promise<Buffer> {
  const reader = stream.getReader();
  const chunks = [];
  let done, value;
  while (!done) {
    ({ done, value } = await reader.read());
    if (value) chunks.push(value);
  }
  return Buffer.concat(chunks);
}

export async function POST(req: Request) {
  try {
    // Convert Request's ReadableStream to Buffer
    const contentType = req.headers.get("content-type") || "";
    if (!contentType.startsWith("multipart/form-data")) {
      return NextResponse.json(
        { error: "Invalid content type" },
        { status: 400 }
      );
    }

    // Parse form fields manually
    const formData = await req.formData();
    const file = formData.get("file") as Blob | null;
    const fileName = formData.get("fileName") as string;
    const fileType = formData.get("fileType") as string;
    const bucketName = formData.get("bucketName") as string;
    const s3Path = formData.get("s3Path") as string;

    if (!file || !fileName || !fileType || !bucketName) {
      return NextResponse.json(
        { error: "Missing required fields" },
        { status: 400 }
      );
    }

    // Convert Blob to Buffer
    const arrayBuffer = await file.arrayBuffer();
    const fileBuffer = Buffer.from(arrayBuffer);

    // const uniqueFileName = `${uuidv4()}-${fileName}`;
    const key = `uploads/${s3Path}/${fileName}`;

    const upload = new Upload({
      client: s3Client,
      params: {
        Bucket: bucketName,
        Key: key,
        Body: fileBuffer,
        ContentType: fileType,
        ContentDisposition: `attachment; filename="${encodeURIComponent(
          fileName
        )}"`,
      },
    });

    console.log("Starting upload...");
    await upload.done();
    console.log("File uploaded successfully to S3.");

    const downloadUrl = `https://${bucketName}.s3.amazonaws.com/${key}`;

    console.log("Download URL:", downloadUrl);

    return NextResponse.json({ url: downloadUrl });
  } catch (error) {
    console.error("Error uploading file:", error);
    return NextResponse.json(
      { error: "Failed to upload file" },
      { status: 500 }
    );
  }
}

route.ts(delete)

// app/api/delete/route.ts
import { NextResponse } from "next/server";
import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";

const s3Client = new S3Client({
  region: "ap-northeast-2",
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

export async function DELETE(req: Request) {
  try {
    const { bucketName, key } = await req.json();

    if (!bucketName || !key) {
      return NextResponse.json(
        { error: "Missing required parameters" },
        { status: 400 }
      );
    }

    const deleteParams = {
      Bucket: bucketName,
      Key: key,
    };

    const command = new DeleteObjectCommand(deleteParams);
    await s3Client.send(command);

    return NextResponse.json({ message: "File deleted successfully" });
  } catch (error) {
    console.error("Error deleting file:", error);
    return NextResponse.json(
      { error: "Failed to delete file" },
      { status: 500 }
    );
  }
}
profile
프론트엔드 개발자 ( React, Next.js ) - 업데이트 중입니다.

0개의 댓글