[express] multer로 이미지 및 파일 업로드 / 다운로드 구현하기(ft. 한글 파일명 깨짐 현상)

박기영·2023년 2월 5일
1

Express

목록 보기
2/2

파일 업로드/다운로드는 정말 너무나도 흔하게 사용되는 기능이다. 반드시 구현할 줄 알아야한다.
Udemy 강의를 통해 배우게 된 내용과 직접 부딪히며 알아본 내용을 기록하고자 한다.
React, express를 활용하였다.
사용된 multer의 버전은 ^1.4.5-lts.1이다.
참고로 조사 결과 한글 깨짐 이슈는 1.4.4 버전에서는 발생하지 않는 것으로 보인다.

구현해야할 것

우선, 어떤 것을 구현해야하는지 정확하게 짚고 넘어가자.

  1. 이미지 및 파일 업로드 기능 만들기(frontend)
  2. 이미지 및 파일 저장 라우터 만들기(backend)
  3. 이미지 및 파일 다운로드 라우터 만들기(backend)
  4. 이미지 및 파일 다운로드 기능 만들기(frontend)

순서는 크게 상관없다. 다만, 업로드를 해야 다운로드 기능도 만들어 볼 수 있기에
크게 1,2(업로드)와 3,4(다운로드)로 묶어볼 수 있겠다.
아래부터는 글을 줄이기 위해 이미지 및 파일을 파일이라고 줄여부르겠다.

백엔드 폴더 구조

코드를 보며 경로가 헷갈리는 경우, 코드가 헷갈리는 경우를 방지하기 위해 폴더 경로를 첨부합니다.
프론트쪽은 경로에 대해 크게 문제가 될 것이 없을 것이므로, 생략합니다.

업로드 기능(frontend)

프론트쪽에서는 업로드 기능을 만들기 위해서 무엇을 해야할까?
데이터를 알맞게 가공해서, 생성된 api로 데이터를 보내주면 된다.
여기서 가공이라는 말은, 내가 보내고자하는 데이터가 파일이라는 것을 명시하는 것이다.

input 태그를 통한 파일 입력

// FileUpload.tsx

import React, { useState, useEffect } from "react";
import File from "./File";

interface FileUploadProps {
  id: string;
  onInput: (id: string, value: any, isValid: boolean) => void;
}

function FileUpload(props: FileUploadProps) {
  const [file, setFile] = useState<any>([]);
  const [isValid, setIsValid] = useState(false);

  const { id, onInput } = props;

  useEffect(() => {
    onInput(id, file, isValid);
  }, [id, file, isValid, onInput]);

  const pickedHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
    let pickedFile = [];

    if (e.target.files) {
      for (let i = 0; i < e.target.files.length; i++) {
        pickedFile.push(e.target.files[i]);
      }

      setFile([...file, ...pickedFile]);
      setIsValid(true);
    } else {
      setIsValid(false);
    }
  };

  const onDelete = (clickedIndex: number) => {
    const deletedFile = file.filter(
      (_: any, index: number) => index !== clickedIndex
    );

    setFile(deletedFile);
  };

  return (
    <div>
      <div className="py-2 border-b-2 border-[#ffcdd2]">
        <div className="mb-2">
          <span className="mr-2">첨부 파일 :</span>

          <label
            className="mr-2 rounded-lg p-1 border-2 border-[#ffcdd2] hover:bg-[#ffcdd2] hover:text-white hover:font-semibold hover:cursor-pointer"
            htmlFor={props.id}
          >
            + 파일 추가하기
          </label>

          <input
            id={props.id}
            className="hidden"
            type="file"
            multiple
            accept=".jpg, .png, .jpeg, .pdf, .word"
            onChange={pickedHandler}
          />
        </div>

        {file &&
          file.map((item: any, index: number) => (
            <File
              item={item}
              index={index}
              onDelete={onDelete}
              key={item.name}
            />
          ))}
      </div>
    </div>
  );
}

export default FileUpload;

가장 먼저 만들어야하는 것은 파일을 입력하는 input 태그를 만드는 것이다.
input 태그의 type 속성을 file로 설정하면 된다.
여러 개의 파일을 입력할 수 있게 하고싶은 경우 multiple 속성을 추가해주면 된다.
accept 속성을 활용하여 허용할 파일 확장자를 설정할 수도 있다.

많은 경우 input 태그의 모양이 예쁘지않아서 CSS로 숨김 처리를 해놓고,
label이나 button 등을 활용하여 예쁜 버튼을 디자인하여 사용하는 모양이다.
(아래 이미지는 label 태그이다.)

필자는 input 태그와 연결할 때 쉽게 떠올릴 수 있는 label 태그를 사용하여, 두 태그를 연결해줬다.
label 태그를 클릭하면 input 태그를 클릭한 것으로 동작하게 된다.
클릭 후, 파일을 업로드하면 onChange에 넣어둔 함수가 작동하게 된다. 자세하게 살펴보자.

const pickedHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
  // 업로드된 파일을 저장하는 배열
  let pickedFile = [];

  if (e.target.files) {
    // 파일의 개수만큼 파일 정보를 배열에 넣는다.
    for (let i = 0; i < e.target.files.length; i++) {
      pickedFile.push(e.target.files[i]);
    }

    // 업로드를 여러 번에 나눠서 할 경우, 이전에 존재하던 업로드 파일이 사라지는 것을 방지하기 위해,
    // 스프레드 연산자를 사용해 이전에 선택해놓은 파일을 보존한다.
    setFile([...file, ...pickedFile]);
    setIsValid(true);
  } else {
    setIsValid(false);
  }
};

필자는 파일을 여러 개 입력할 수 있게 해놨기 때문에 반복문 처리를 했지만,
하나만 업로드하는 상황이라면

e.target.files[0]

을 통해, 파일에 접근하면 된다.

아래 콘솔창은 pickedFile을 출력한 것이다.
아래와 같은 형태로 파일이 입력된다는 것을 알고 넘어가자.
파일 확장자, 파일 이름, 파일 크기 등의 정보가 들어가 있다.

이렇게 업로드한 파일은 당연하지만 유저 눈에 보이지 않는다.
우리 프론트엔드 개발자들이 이 부분을 처리해줘야한다.

파일명 보여주기 및 파일 삭제 기능

// File.tsx

import React from "react";

function File({ item, index, onDelete }: any) {
  return (
    <div className="flex justify-between mb-1 rounded hover:bg-[#ffebee] px-2">
      <p className="truncate w-[250px] sm:w-[400px] md:w-[500px] lg:w-[600px]">
        {index + 1}. {item.name}
      </p>
      <button onClick={() => onDelete(index)}>삭제</button>
    </div>
  );
}

export default File;

앞서 살펴본 FileUpload 컴포넌트에 자식으로 들어가 있는 컴포넌트이다.
필자는 파일의 개수를 보여주기 위해 index를 활용하여 번호를 넣어줬고,
이 때, index는 0번부터 시작하므로 index + 1을 통해 1번부터 시작하게 해줬다.
그리고, 파일 업로드 시 입력되는 데이터의 name 값을 이용하여 파일명을 명시했다.

특정 파일을 삭제하고 싶은 경우에는 FileUpload 컴포넌트에 작성한 onDelete 함수를 사용한다.

const onDelete = (clickedIndex: number) => {
  // 삭제를 선택한 파일의 index와 일치하지 않는다면 삭제하지 않는다.
  const deletedFile = file.filter(
    (_: any, index: number) => index !== clickedIndex
  );

  setFile(deletedFile);
};

file이라는 state는 배열이므로 filter()를 통해 간단하게 삭제 기능을 구현했다.

서버에 전송할 FormData 생성하기

// LectureWritePage.tsx

import React, { useContext } from "react";
import { useNavigate } from "react-router-dom";

import Button from "../../components/Button";
import PostInput from "../../components/PostInput";
import Layout from "../../layout/Layout";
import { useForm } from "../../hoc/useForm";
import FileUpload from "../../components/FileUpload";
import getDate from "../../utils/getDate";
import getYoutubeLink from "../../utils/getYoutubeLink";
import { useHttpClient } from "../../hoc/http-hook";
import { AuthContext } from "../../context/auth-context";

function LectureWritePage() {
  const auth = useContext(AuthContext);

  const [formState, inputHandler] = useForm(
    {
      title: {
        value: "",
      },
      link: {
        value: "",
      },
      file: {
        value: null,
      },
      description: {
        value: "",
      },
    },
    null
  );

  const navigate = useNavigate();

  const cancelHandler = () => {
    const cancel = window.confirm(
      "취소할 경우 모든 내용이 사라집니다. 그래도 괜찮으신가요?"
    );

    if (cancel) {
      navigate(-1);
    }
  };

  const { sendRequest } = useHttpClient();

  const submitHandler = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const formData = new FormData();

    formData.append("title", formState.inputs.title.value);
    formData.append("description", formState.inputs.description.value);

    const submitTime = getDate();
    formData.append("date", submitTime);

    const parsedLink = getYoutubeLink(formState.inputs.link.value);
    formData.append("link", parsedLink);

    for (const file of formState.inputs.file.value) {
      formData.append("files", file);
    }

    try {
      const response = await sendRequest(
        "http://localhost:8080/api/lecture/write",
        "POST",
        formData,
        {
          Authorization: "Bearer " + auth.token,
        }
      );

      if (response.uploadSuccess) {
        alert("강의 등록 성공!");
        navigate("/lecture");
      }
    } catch (err) {}
  };

  return (
    <Layout>
      <div className="px-2 mt-4 md:px-8 md:pt-8 lg:px-12 lg:pt-12 xl:px-32 xl:pt-20">
        <form className="flex flex-col" onSubmit={submitHandler}>
          <div className="py-1 border-b-2 border-[#ffcdd2]">
            작성자 : 관리자
          </div>

          <PostInput
            id="title"
            label="제목 :"
            type="text"
            placeholder="제목을 입력하세요(최소 1자, 최대 30자)"
            required
            value={formState.inputs.title.value}
            onInput={inputHandler}
            minLength={1}
            maxLength={30}
            isTextarea={false}
          />

          <PostInput
            id="link"
            label="영상 링크 :"
            type="text"
            placeholder="유튜브 영상 링크를 입력해주세요"
            required
            value={formState.inputs.link.value}
            onInput={inputHandler}
            isTextarea={false}
          />

          <FileUpload id="file" onInput={inputHandler} />

          <PostInput
            id="description"
            placeholder="내용을 입력하세요(최소 1자, 최대 1000자)"
            required
            value={formState.inputs.description.value}
            onInput={inputHandler}
            minLength={1}
            maxLength={1000}
            isTextarea={true}
          />

          <div>
            <Button
              submitMode={false}
              clickHandler={cancelHandler}
              isValid={false}
            >
              취소
            </Button>

            <Button submitMode={true} isValid={true}>
              등록
            </Button>
          </div>
        </form>
      </div>
    </Layout>
  );
}

export default LectureWritePage;

이제 업로드 단계에서 가장 중요한 부분이다.
FormData()를 사용하여 파일을 서버에 보낼 수 있게 만드는 작업을 해보자.

보통 파일만 보내는게 아니라, 텍스트나 각종 정보를 같이 보내는 경우가 많기 때문에,
필자의 프로젝트 코드를 그대로 가져와봤다.

여기서 중요한 것은 onSubmitHandler 함수가 되겠다. 자세하게 살펴보자.

const submitHandler = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();

  // FormData 생성을 위한 인스턴스 생성
  const formData = new FormData();

  // FormData에는 append를 통해 데이터를 집어넣는다.
  formData.append("title", formState.inputs.title.value);
  formData.append("description", formState.inputs.description.value);

  const submitTime = getDate();
  formData.append("date", submitTime);

  const parsedLink = getYoutubeLink(formState.inputs.link.value);
  formData.append("link", parsedLink);

  // 여러 개의 파일을 입력하는 경우에는 반복문을 사용한다.
  for (const file of formState.inputs.file.value) {
    formData.append("files", file);
  }

  // 필자는 fetch API를 커스텀하여 사용했다.
  // 일반적인 fetch를 사용한다면 formData는 body에 들어가야한다.
  try {
    const response = await sendRequest(
      "http://localhost:8080/api/lecture/write",
      "POST",
      formData,
      {
        Authorization: "Bearer " + auth.token,
      }
    );

    if (response.uploadSuccess) {
      alert("강의 등록 성공!");
      navigate("/lecture");
    }
  } catch (err) {}
};

fetch(필자의 경우 sendRequest)를 통해 API 통신을 진행해주자.
필요한 정보를 담아 가공한 formData를 전송하였으며, header에는 인증을 위한 token을 담아주었다.

여기서 어떤 분들은 이상함을 느낄 수도 있다.
왜 header에 Content-Type : multipart/form-data를 안 쓰는거지? 하고 말이다.

FormData를 전송하는 경우 설정하지 않아도 Content-Type이 설정되기 때문이다.
아래 이미지는 크롬 개발자 도구 Network 탭을 통해 살펴본 전송 데이터의 Content-Type이다.

필자는 분명 아무런 설정도 하지않았음에도 multipart/form-data가 설정된 것을 볼 수 있다.

업로드 기능(backend)

이제 프론트쪽에서 데이터 전송을 해줬다. 보내기만 하는건 의미가 없다는 것을 잘 알고 있다..
이를 서버에 저장하기 위해서 작업을 해줘야한다.
필자는 multer 라이브러리를 활용하여 파일을 저장할 것이다.

파일을 저장하기 위한 과정은 아래와 같다.

app.js -> lectures-routes.js -> file-upload.js -> lecture-controllers.js

여기서 핵심은 파일이 담긴 데이터를 처리할 때 쓰일 미들웨어인 file-upload.js이다.

multer 사용을 위한 초기 작업

// app.js

const fs = require("fs");
const path = require("path");
const express = require("express");

const usersRoutes = require("./routes/users-routes");
const lectureRoutes = require("./routes/lectures-routes");
const downloadRoutes = require("./routes/download-routes");

const app = express();

// 전송된 파일을 담기위한 폴더
app.use(
  "/uploads/attachments",
  express.static(path.join("uploads", "attachments"))
);

// API 통신에 사용될 라우터
app.use("/api/download", downloadRoutes);
app.use("/api/lecture", lectureRoutes);
app.use("/api/users", usersRoutes);

// 에러 핸들링
app.use((error, req, res, next) => {
  if (req.file) {
    fs.unlink(req.file.path, (err) => {
      console.log(err);
    });
  }

  if (res.headerSent) {
    return next(error);
  }

  res.status(error.code || 500);
  res.json({ message: error.message || "An unknown error occured!" });
});

이 부분은 설명 생략!

multer 설정하기

// file-upload.js

const multer = require("multer");
const { v4: uuidv4 } = require("uuid");

// 허용할 파일 확장자
const MIME_TYPE_MAP = {
  "image/png": "png",
  "image/jpeg": "jpeg",
  "image/jpg": "jpg",
  "application/pdf": "pdf",
};

const fileUpload = multer({
  // 파일 최대 용량
  limits: 10000000,
  storage: multer.diskStorage({
    // 파일 저장 경로
    destination: (req, file, cb) => {
      cb(null, "uploads/attachments");
    },
    
    // 파일 저장시 이름 설정
    filename: (req, file, cb) => {
      // file.mimetype을 통해 전송된 파일의 확장자를 얻을 수 있다.
      const ext = MIME_TYPE_MAP[file.mimetype];

      // uuid 라이브러리를 통해 파일명 무작위 생성
      // 무작위생성파일명.jpg 처럼 파일명이 생성될 것이다.
      cb(null, uuidv4() + "." + ext);
    },
  }),
  
  // 파일 확장자 검증
  fileFilter: (req, file, cb) => {

    const isValid = !!MIME_TYPE_MAP[file.mimetype];

    let error = isValid ? null : new Error("Invalid mime type!");

    cb(error, isValid);
  },
});

module.exports = fileUpload;

uploads/attachments라는 경로에 전송된 파일이 저장될 것이다.
그 때, filename에서 설정한대로 파일명이 설정된다.

프론트쪽 내용에서 파일이 저장될 때, 어떤 key-value를 지니는지 살펴봤었다.
type이라는 key에는 파일의 확장자가 담겨 있었는데, 이를 활용하여 파일명을 설정한다.

파일명이 한글인 경우 파일명이 깨지는 현상

필자는 uuid를 사용해서 임의로 파일명을 변경했는데,
파일 업로드 기능을 제공하는 수많은 사이트들을 보면 파일명을 유저가 업로드한 이름 그대로 사용한다.
그렇다면 uuid로 임의로 변경할게 아니라, 파일 이름을 직접 사용해줘야겠다.

참고로, 파일 이름에 접근할 때 사용하는 속성은 file.originalname이다.

우선은, 파일명이 영어인 경우에 대해서 살펴보자.
필자가 업로드한 파일명은 english-File-Name.png이다.

바로 위에서 살펴본 multer 부분 코드에서 filename 설정 부분에서 file을 콘솔로 찍어봤다.

파일명이 영어인 경우에는 깨짐 현상 없이 잘 넘어오는 것을 확인할 수 있다.

이번에는 한글로 파일명을 바꿔서 진행해보자.
필자가 업로드한 파일명은 한글인데요_깔깔깔.png이다.

!!!!!!!!! 뭔 괴상한 코드로 변경되었다.

혹시나 한글만 그런건가 싶어 파일명으로 日本語です.png로 바꿔서 진행해보았다.
그러나 일본어도 마찬가지로 깨지는 걸 볼 수 있다.

왜 이런 현상이 발생하는걸까?

multer의 busboy

관련 이슈 깃허브를 보면 multer가 의존하고 있는 busboy라는 패키지가
파일명을 UTF-8 방식이 아니라 latin1 방식으로 저장하기 때문에 발생하는 것이라고 한다.

Mac의 경우 파일명을 인코딩할 때 UTF-8 방식을 사용하는데,
Window의 경우는 또 다른 인코딩 방식을 사용하기 때문에 다른 방법을 사용해야한다고 합니다.
필자는 Mac 기준으로 진행했습니다.

즉, 파일명을 그대로 사용하기 위해서는 latin1UTF-8로 변경해줘야한다.
따라서, filename 속성을 설정하는 부분에서 다음과 같이 처리해 줄 것이다.

file.originalname = Buffer.from(file.originalname, "latin1").toString("utf8");

이제 file.originalname을 확인해보자.

짠! 정상적으로 파일명이 들어온다!

저장하기 위한 파일명 설정하기

앞서 필자는 uuid와 파일에서 얻은 확장자인 ext를 사용해 파일명을 생성했다.
그러나, 이제는 파일명을 그대로 저장할 수 있으므로 굳이 uuid만 사용할 필요가 없다.

따라서, 코드를 아래와 같이 수정했다.
filename 속성 설정 부분이 변경되었다.

// file-upload.js

const multer = require("multer");
const { v4: uuidv4 } = require("uuid");

const MIME_TYPE_MAP = {
  "image/png": "png",
  "image/jpeg": "jpeg",
  "image/jpg": "jpg",
  "application/pdf": "pdf",
};

const fileUpload = multer({
  limits: 10000000,
  storage: multer.diskStorage({
    destination: (req, file, cb) => {
      cb(null, "uploads/attachments");
    },
    filename: (req, file, cb) => {
      const ext = MIME_TYPE_MAP[file.mimetype];

      console.log(file);

      // 한글 파일명 깨짐 해결
      file.originalname = Buffer.from(file.originalname, "latin1").toString(
        "utf8"
      );

      // file.originalname에서 확장자 제거한 파일명 추출하기
      file.originalname = file.originalname.split(`.${ext}`)[0];

      // 파일명 중복을 방지하기 위해 uuid 붙여주기
      cb(null, file.originalname + uuidv4() + "." + ext);
    },
  }),
  fileFilter: (req, file, cb) => {
    const isValid = !!MIME_TYPE_MAP[file.mimetype];

    let error = isValid ? null : new Error("Invalid mime type!");

    cb(error, isValid);
  },
});

module.exports = fileUpload;

앞에서 살펴본 결과, file.originalname파일명.확장자의 형태로 넘어오게 되는데
이를 고려하지 않고 file.originalname + "." + ext로 파일을 저장하게 되면

아래와 같은 형태로 저장이 된다.

한글인데요_깔깔깔.png.png

확장자가 두 번 반복되어 버린다. 이는 좋지 않은 저장 방식이다.
따라서 필자는 file.originalname에서 확장자 부분을 제거했다.

주의할 점은 파일명에는 . 문자를 사용할 수 있다는 것이다.
.을 기준으로 split한 뒤 맨 앞에 문자만 따오면 파일명을 얻을 수 있을 것 같지만

한글.인데요.깔깔깔.png

이런 파일명을 저장하는 경우에는 한글만 추출되게 될 것이다. 한글.인데요.깔깔깔이 필요한데 말이다.

확실한 처리를 위해서 .${ext}를 사용해서 .확장자를 기준으로 split을 하도록하자.
그렇게되면 한글.인데요.깔깔깔이 추출되게 된다.

여기서 끝이 아니다.
diskStorage에 저장할 때, 동일한 파일명이 저장되는 경우
원래 존재하던 파일에 새로운 파일을 덮어씌워버린다.

이는 매우 치명적인 에러이다.
다른 사용자가 같은 파일명을 사용하는 경우, 기존 사용자의 데이터가 사라져버릴 뿐만 아니라
기존 사용자가 업로드 했던 파일이 변경되어 버리니 말이다.

따라서 중복 방지를 위해, uuid를 활용했다.
파일을 파일명 + uuid + . + 확장자의 형태로 저장하여,
중복 방지까지 진행해주자.

아래 이미지는 그 결과물이다.

라우터에 미들웨어 적용하기

// lectures-routes.js

const express = require("express");
const lectureControllers = require("../controllers/lecture-controllers");
const fileUpload = require("../middleware/file-upload");
const checkAuth = require("../middleware/check-auth");

const router = express.Router();

// 인가된 유저인지 확인
router.use(checkAuth);

// 이 라우터에 들어온 데이터는 file-upload.js 를 통해 처리된 후,
// controllers에 있는 함수를 실행하게 된다.
router.post(
  "/write",
  fileUpload.array("files", 10),
  [
    check("title").isLength({ min: 1 }),
    check("description").isLength({ min: 1 }),
    check("link").not().isEmpty(),
  ],
  lectureControllers.createLecture
);

module.exports = router;

여기서 알고 넘어가야하는 것이 있다. fileUpload가 사용되는 부분을 살펴보자.

fileUpload.array("files", 10)

array는 뭐고, files는 뭘까?

array는 여러 개의 파일을 다루는 경우에 해당하는 메서드이다.
앞서 multiple 속성을 사용하여 여러 개의 파일을 한번에 전송할 수 있게 만들었기에
array를 통해 처리해줘야한다.

그렇다면 하나의 파일만 보내는 경우에는 어떻게 해야할까?

fileUpload.single("files")

위와 같이 single을 사용해주면 된다.

다음으로 files는 프론트쪽에서 FormDataappend 할 때, 파일을 넣어줬던 공간의 이름이다.
상기해보면 다음와 같은 코드였다.

formData.append("files", file);

여기 적혀있는 files를 백쪽에서도 동일하게 사용해줘야한다.

가장 뒤에 적혀있는 숫자 10은 파일의 최대 개수를 의미한다.
이는 프론트쪽에서도 예외처리를 해줄 수 있을 것이다.(필자는 누락했다 ㅎㅎ)

아래는 이 미들웨어를 통해 uploads/attachments 경로에 파일이 추가되는 것을 보여주는 영상이다.

DB에 데이터 저장하기

서버에 있는 폴더에 파일을 저장했다고 끝나는 것이 아니다.
결국 수많은 정보들은 DB에 기록된다. 최종적으로는 DB에 있는 기록을 찾아서 사용하는 것이 목적이다.
이번에는 DB에 어떤 식으로 저장해야하는지 알아보자.

// lecture-controllers.js

const createLecture = async (req, res, next) => {
  const errors = validationResult(req);

  if (!errors.isEmpty()) {
    return next(
      new HttpError("Invalid inputs passed, please check your data", 422)
    );
  }

  // 프론트쪽에서 넘겨줬던 데이터들
  // 파일 데이터를 제외하고 FormData에 있던 값들을 전부 확인 할 수 있다.
  const { title, description, date, link } = req.body;

  // 여러 개의 파일을 저장해야하므로, 배열에 파일들을 저장한다.
  let lectureFile = [];

  // 파일은 req.files를 통해 확인할 수 있다.(multer 미들웨어로 인한 것)
  for (const file of req.files) {
    const fileData = {
      path: file.path, // path로 파일 경로를 얻는다.
      name: file.originalname, // originalname으로 파일명을 얻는다.
      ext: file.mimetype.split("/")[1], // mimetype으로 확장자를 얻는다.
    };

    lectureFile.push(fileData);
  }

  const createdLecture = new Lecture({
    title,
    description,
    date,
    link,
    file: lectureFile,
  });

  try {
    await createdLecture.save();
  } catch (err) {
    const error = new HttpError(
      "Saving lecture is failed...Please try again.",
      500
    );

    next(error);
  }

  res.status(201).json({ uploadSuccess: true });
};

여기서 핵심적인 부분은 아래와 같다.

for (const file of req.files) {  
    const fileData = {
      path: file.path,
      name: file.originalname,
      ext: file.mimetype.split("/")[1],
    };

    lectureFile.push(fileData);
}

req.files를 통해 전송된 파일 데이터에 접근하며, 아래와 같이 데이터가 보일 것이다.
참고로 file을 콘솔에 찍은거라 배열의 형태가 아님에 주의하자.

file.path를 통해 해당 파일의 경로를 얻어낸다.
path를 통해 해당 파일이 어떤 기능을 위해서 저장된 것인지 구분이 될 것이며,
다운로드 기능에서 해당 파일을 특정하여 불어올 수 있게 해준다.

file.originalname에서는 파일명을 얻어낸다.
이는 나중에 다운로드 기능에서 사용하기 위함인데,
유저에게 uuid가 제거된 파일명(업로드 당시의 원본 파일명)을 제공하는 것이 목적이다.
업로드 시에는 중복 방지를 위해 uuid를 사용해 저장했고,
다운로드 시에는 해당 파일 경로에 접근하여 uuid를 제거 된 원본 파일명을 제공한다.

file.mimetype에서는 저장된 파일의 확장자를 얻는다.
확장자가 필요한 것이므로 /를 기준으로 split하여 활용한다.
이 또한, 다운로드 시에 유저에게 원본 파일명을 보여주기 위한 것으로
uuid로 인해 원본파일명 + uuid + . + 확장자가 되어버린 파일명에서
원본파일명 + . + 확장자를 만들어서 보여주는 것이 목적이다.

만약 전달되는 파일이 한 개로 제한되는 상황이라면, 접근법이 아주 약간 달라진다.

const data = req.file.path;

굳이 배열을 만들 필요도 없고, 반복문을 돌릴 필요도 없어진다.
req.files가 아니라 req.file을 통해 접근한다.
복수형과 단수형의 차이라서 쉽게 이해가 된다.

저장된 데이터를 DB에서 확인해보자.

경로가 저장된 것이 확인된다!

다운로드 기능(backend)

업로드를 구현했다면, 이제 다운로드를 구현해야한다.
다운로드 기능이 없다면 업로드 기능이 의미가 없으니 꼭 하나의 묶음으로 생각하고 구현해야한다.

백쪽에서 다운로드를 구현하는 것은 매우 간단하다.

라우터 설정하기

// download-routes.js

const express = require("express");
const downLoadControllers = require("../controllers/download-controllers");

const router = express.Router();

router.get("/:fileName", downLoadControllers.downloadFile);

module.exports = router;

백쪽에서 다운로드 기능을 실행하기 위해서는 프론트쪽에서 API 통신을 실행할 필요가 있다.
즉, 특정 파일을 클릭했을 때 다운로드 기능이 실행이 된다는 것인데,
필자는 프론트로부터 파일 이름을 파라미터로 받아와서 사용했다.

파일 다운로드 기능 구현하기

// download-controllers.js

const fs = require("fs");
const HttpError = require("../models/http-error");

const downloadFile = async (req, res, next) => {
  // API 경로에 있는 fileName 파라미터를 가져와서 사용한다.
  const fileName = req.params.fileName;

  let isFileExist;

  try {
    // fs.existsSync()를 사용하여 파일 존재 여부를 검증한다. Boolean 타입의 값을 반환한다.
    isFileExist = fs.existsSync(`uploads/attachments/${fileName}`);
  } catch (err) {
    const error = new HttpError(
      "File searching is failed...Please try again.",
      500
    );

    next(error);
  }

  // 파일이 존재하지 않는다면 에러 처리
  if (!isFileExist) {
    const error = new HttpError("There is no file. Please try again.", 500);

    next(error);
  }

  try {
    // download()를 사용해서 파일을 프론트쪽으로 보내준다.
    res.download(`uploads/attachments/${fileName}`);
  } catch (err) {
    const error = new HttpError(
      "File download is faild. Please try again.",
      500
    );

    next(error);
  }
};

exports.downloadFile = downloadFile;

핵심적인 부분은 아래와 같다.

res.download(`uploads/attachments/${fileName}`);

res.download를 통해 API 통신의 결과로 서버에 저장되어있는 파일을 보내준다.
메서드 내부에 적어준 경로에 있는 파일이 전송된다.

다운로드 기능(frontend)

프론트쪽에서는 다운로드를 위한 API 통신을 실행하고,
결과로 받아온 데이터를 유저에게 다운로드 시켜주면 된다.

API 통신 및 a 태그에 결과 반영

프론트쪽에서 다운로드를 구현하기 위해서는 a 태그를 사용한다.
a 태그에 download 속성을 추가하면 파일을 다운로드할 수 있게된다.
그러나, 파일의 개수가 몇 개인지 모르는 상황에 태그를 만들어 놓을수는 없는 노릇이다.
따라서, a 태그는 동적으로 생성하는 것이 바람직할 것 같다.

// PostContentHeader.tsx

import React from "react";

import { AiOutlinePaperClip } from "react-icons/ai";

interface FilesType {
  path: string;
  name: string;
  ext: string;
}

interface PostContentHeaderProps {
  title: string;
  date: string;
  files: FilesType[];
  purpose: string;
  nickname?: string;
}

function PostContentHeader(props: PostContentHeaderProps) {
  const downloadFile = async (file: FilesType, index: number) => {
    // path에서 diskStorage에 저장되었던 파일 이름만을 추출하여 사용한다.
    // 이 때의 파일명은 uuid가 존재하는 값이다.
    const fileNameInDB = file.path.split(
      `uploads/${props.purpose === "QandA" ? "questions" : "attachments"}/`
    )[1];

    const responseData = await fetch(
      // 다운로드 API 통신 실행
      `${process.env.REACT_APP_BASE_URL}/download/${
        props.purpose === "lecture" ? "lecture" : "qa"
      }/${fileNameInDB}`
    );

    // 받아온 파일을 다루기 위해서 Blob 타입으로 설정한다.
    // 이미지나 파일은 이진 데이터로 처리됙 때문에 json으로 하면 안된다.
    const blobData = await responseData.blob();
    const url = window.URL.createObjectURL(blobData);
    
    // 동적으로 a 태그를 생성한다.
    const link = document.createElement("a");
    
    // a 태그 클릭시 다운로드 될 파일의 경로이다.
    link.href = url;
    
    // download 속성에 값을 입력하면 파일 다운로드 시 파일명을 설정할 수 있다.
    // 원본 파일명인 name에 접근한다.
    // 이 때의 파일명은 uuid가 제거된 원본 파일명이다.
    link.download = `${file.name}`;
    
    // body에 a 태그를 추가한다.
    document.body.appendChild(link);
    
    // a 태그를 강제로 클릭한다.
    link.click();
    
    // a 태그를 제거한다.
    link.remove();
    
    // 생성했던 url도 제거해준다.
    window.URL.revokeObjectURL(url);
  };

  return (
    <div className="flex flex-col bg-[rgba(0,0,0,0.1)] rounded px-2 mb-4">
      <h1>제목 : {props.title}</h1>

      {props.nickname && <p>작성자 : {props.nickname}</p>}

      <p>업로드 날짜 : {props.date}</p>

      <p>첨부 파일 : </p>

      {props.files.map((file: any, index: number) => (
        <button
          className="flex items-center"
          onClick={() => downloadFile(file, index)}
          key={index}
        >
          <AiOutlinePaperClip className="mr-2" />

          // 유저에게 원본 파일명과 확장자를 보여준다.
          // 업로드 당시의 파일명을 그대로 보여주게 된다.
          {`${file.name}.${file.ext}`}
        </button>
      ))}
    </div>
  );
}

export default PostContentHeader;

앞서 DB에 저장되어 있던 파일 정보에는 경로가 문자열로 적혀있었다.
필자는 경로는 제외하고 파일명만 사용하고 싶었기 때문에 split을 통해 문자열을 처리해 사용했다.
이 부분은 파일 저장 시 사용한 폴더 구조에 따라 유동적으로 변경될 것이다.
필자는 두 개의 폴더만 사용했으므로, 폴더 구조를 하드 코딩해서 split을 사용했다.

API 통신을 할 때, 이전까지는 sendRequest라는 커스텀 함수로 fetch를 사용했던 것에 반해,
이번에는 일반적인 fetch를 사용했는데, 그 이유는 이미지나 파일은 json 데이터가 아니기 때문이다.
이진 데이터로 처리되기 때문에 Blob 속성으로 변환해서 사용해줘야한다.
필자의 sendRequest에는 해당 기능을 구현해놓지 않았기 때문에 일반적인 fetch를 사용했다.

유저는 button 태그를 눌렀지만, 그 이벤트로 인해 실행되는 함수는
동적으로 생성한 a 태그를 강제로 클릭하게 된다.
또한 a 태그에는 서버쪽에서 받아온 파일 정보가 들어가 있으므로, 해당 파일을 다운로드 받게 된다.

a 태그에 download 속성에 값을 입력했는데, 이는 다운로드 시 파일명을 바꿔준다.
아래 예시를 보자.

서버에 저장되어있는 uuid만 사용된 파일명이 보인다.
index를 활용해서 첨부 파일 {index + 1}의 형태로 다운로드될 파일 이름을 변경한다.

위 파일을 클릭하면, 해당 파일이 다운로드 되는데 그 때 모습은 다음과 같다.

뒤에 붙은 (2)는 필자가 실험하느라 여러번 다운로드 받아서 그런 것이니 신경쓰지 않아도 된다.
해당 파일을 확인해보면, 의도했던 대로 정확하게 그 파일이 다운로드 되는 것을 확인할 수 있다.

물론, 위 예시는 다운로드 시 파일명의 변경이 가능하다는 것을 보여주기 위한 것이고
필자는 원본 파일명을 그대로 다운로드까지 이어가기를 바라기 때문에
link.download 부분에서 file.name을 사용했다.

참고 자료

whichmean님 블로그
zerocho님 블로그
7942yongdae님 블로그
algoroot님 블로그
developer-alle님 블로그
제로초님 인프런 강의 질문글
paulpung님 블로그
saii42님 블로그

profile
나를 믿는 사람들을, 실망시키지 않도록

0개의 댓글