웹 백엔드

Node.js - 파일 업로드

토리쟁이 2024. 1. 31. 23:57

 

 

 

이번 포스팅에서는 node.js에서의 멀티파트 데이터 처리에 대해 알아보려고 한다.


 

 

여태까지는 데이터가 문자열인 경우의 클라이언트와 서버 간 데이터 전송에 대해 공부해 보았는데, 문자열이 아닌 이미지를 주고 받고 싶을 땐 어떻게 하면 될까?

 

 

 

앞서 클라이언트와 서버 간의 데이터 전송에서 데이터를 쉽게 처리할 수 있도록 도와주는 라이브러리인 body-parser에 대해 다뤘었다. body-parser는 post 방식으로 통신할 때, 요청(request)의 body에 들어간 데이터를 받을 수 있게 도와주는 역할을 한다. 하지만,  body-parser로 멀티파트 데이터(이미지, 동영상, 파일 등)를 받는다면 원본 멀티파트 데이터가 아닌, 그저 파일명을 텍스트로 바꾼 문자열이 전송될 뿐이다.(사진 참고) 이처럼  body-parser은 멀티파트 데이터를 처리하지 못한다는 치명적인 단점이 존재한다. 이러한 문제점을 해결하기 위해 멀티파트 데이터 전송에 쓰이는 multer에 대해 공부해 볼 것이다.

 

 

 

 

multer

  • 파일 업로드를 위해 사용되는 미들웨어
  • express로 서버를 구축할 때 가장 많 사용되는 미들웨어
  • 이미지, 동영상, 파일 등 과 같은 멀티파트 데이터를 멀티파트 형식으로 업로드할 때 사용되는 미들웨어
    • 멀티파트(Multipart)
      • HTTP 요청/응답에서 여러 종류의 데이터를 동시에 전송하기 위해 사용되는 방식
      • 일반적으로 파일 업로드와 관련된 데이터를 전송하는데 주로 사용됨
      • HTTP 프로토콜은 기본적으로 텍스트 기반의 요청과 응답을 처리함 but, 파일과 같은 이진 데이터를 전송할 때는 이진 데이터를 텍스트 형식으로 인코딩하는 것이 비효율적+제한적 → 멀티파트는 이러한 이진 데이터를 인코딩하지 않고 원본 형식으로 전송할 수 있도록 해줌
      • 멀티파트 요청은 post 방식을 사용하며,  headers에 "Content-type":"multipart/form-data" 설정 필요

 

 

 

 

다음은, multer 미들웨어 사용 방법에 대해 알아보고자 한다.

 

위의 그림과 같이 설치 후 require()을 통해 불러와서 사용하면 된다.

 

 

 

 또한, multer  사용시 form 태그의 enctype 속성으로 "multipart/form-data"를 반드시 설정해야 한다.

 

 

 

이제 multer을 사용해 파일을 업로드해보자.

 

 

 

파일 업로드 경로 설정

파일을 어디에 업로드(저장)할지 저장공간의 경로를 지정하는 것이다.

const multer = require("multer"); // multer 불러오기

// static 폴더 설정
// app.use('/가상 경로(원하는 이름으로-)', express.static(실제 폴더 경로))
// 실제 폴더 경로: __dirname + /폴더명
 // __dirname: 현재의 파일이 위치한 폴더의 절대 경로
 // 업로드가 어디에 있는지 경로를 알려주는 코드
 app.use("/uploads", express.static(__dirname + "/uploads"));
 
 
 // 파일 업로드 경로 설정
 const upload = multer({
 // 파일을 업로드하고 그 파일이 저장될 경로를 지정하는 속성(따라서, 자동으로 생성되응 폴더라서 따로 만들 필요X)
  dest: "uploads/",
});

 

 

 

위의 코드를 보면, static 폴더를 지정해주는 부분이 있는데 좀 더 자세히 보고자 한다.

먼저 정적 파일(static file)이란, 이미지, css, js 파일 등 변하지 않는 파일을 의미한다.

express 모듈은 이러한 정적 파일들을 손쉽게 제공해주는 기능을 가지고 있다.

만약 해당 기능을 이용하지 않는다면 경로에 맞는 파일을 제공해주면 되지만, 이는 굉장히 번거롭고 코드를 복잡하게 만든다.

 

하지만 express의 static 메소드를 사용하게 된다면, 정적 파일들이 들어있는 정적 폴더를 원하는 이름으로 사용할 수 있게 된다. 사용자가 어떠한 정적 파일에 접근하고자 한다면, 위와 같이 설정한 정적 파일 폴더에 해당 파일이 존재하는지 검색하는 것이다. 이와 같은 방법은 지정된 폴더에 저장된 파일만을 제공한다는 점에서도 보안적인 이점이 있다.

express.static 메소드는 node 프로세스가 실행되는 디렉토리에 상대적이기 때문에, 절대경로를 사용하는 것이 안전하다.

 

 

경로를 지정해줬으면, 이제 하나의 파일을 업로드 하는 것은 가능해진다.

코드는 다음과 같다.

<h2>파일 1개 업로드</h2>
    <!--multipart/form-data는 method가 post일 때만 사용 가능-->
    <form action="/upload" method="post" enctype="multipart/form-data">
      <input type="file" name="userfile" />
      <br />
      <input
        type="text"
        name="title"
        placeholder="사진 제목을 입력해주세요요"
      />
      <br />
      <br />
      <button type="submit">업로드</button>
    </form>

 

// app.post("/upload", upload(위의 코드에서 파일 경로설정으로 선언한 변수).single("input의 name값"), function (req, res) {}
app.post("/upload", upload.single("userfile"), function (req, res) {
  console.log(req.file); // 파일 업로드 성공 결과(파일 정보)
  console.log(req.body); // 파일뿐만 아니라 다른 데이터 정보 확인 가능
  res.send("파일 업로드 완료");
});

 

 

default.jpg 이미지를 업로드해보았다.

 

위의 사진과 같이, uploads 폴더 아래에 파일이 업로드된 것을 확인할 수 있었다.

하지만 해당 파일을 열면, 아래 사진과 같은 경고가 뜨면서 이미지를 볼 수 없다.

 

 

그래서, 파일명에 확장자 .jpg를 붙여주니 이미지를 정상적으로 확인할 수 있었다. 

 

 

하지만, 하나의 파일을 업로드할 때마다 파일의 확장자를 붙여줘야 데이터 확인이 가능하다는 점과 위와 같이 숫자와 영문자로 조합된 파일명은, 하나의 파일을 확인함에 있어서 다소 많은 불편함이 따른다는 것을 체감할 수 있다.

 

 

 

 

 

이와 같은 불편함을 해결하기 위해, 다음과 같이 디테일한 multer 설정이 필요하다.

/*
multer 디테일 설정
- storage: 저장 공간의 정보
    diskStorage: 파일을 디스크에 저장하기 위한 모든 제어기능 제공
    destination:저장 경로(어디에 저장할지)
    filename: 파일 이름 관련 정보
- limits: 파일 제한 관련 정보
- fileSize: 파일사이즈를 바이트 단위로 제한
*/

const uploadDetail = multer({
  storage: multer.diskStorage({
    destination: function (req, file, cb) {
      cb(null, "uploads/"); // 파일이 저장될 목적지(경로)
    },

    filename: function (req, file, done) {
      // done도 콜백 함수
      // path.extname(원본파일명): 확장자 추출
      const extension = path.extname(file.originalname);
      // path.basename(file.originalname, extension) 원본파일명에서 확장자를 뺀 것
      done(
        null,
        path.basename(file.originalname, extension) + Date.now() + extension
      ); // 파일 이름 설정: 원본파일명+ 현재까지 경과된 밀리초 + 확장자
    },
  }),
  // 파일 크기 제한 설정
  limits: { fileSize: 5 * 1024 * 1024 },
});

 

 

설정에 대한 정보는 주석으로 작성해 놓았다.

 

더 디테일한 업로드 설정 후, 똑같이 default.jpg 이미지를 업로드해 보았다.

 

그 결과, 추가적인 조작없이 바로 이미지를 확인할 수 있었고 파일명도 명확하게 저장되어 어떤 파일이 업로드되더라도 쉽게 식별할 수 있게 되었다.

 

 

 

이제, 하나의 파일이 아닌 여러 개의 파일을 업로드해보자.

여러 개의 파일을 업로드하는 경우는 2가지로 나눠볼 수 있다.

첫 째, 하나의 요청 안에 여러 개의 파일을 업로드하는 경우

둘 째, 하나의 요청이 아닌 여러 개의 요청으로 여러 개의 파일을 업로드하는 경우

이 2가지 경우를 모두 살펴보고자 한다.

 

 

 

 

1. 하나의 요청 안에 여러 개의 파일을 업로드

.array() 사용

파일 정보는 req.file이 아닌, req.files에 들어있음

 

app.post(
  "/uploads/array",
  uploadDetail.array("multifiles"),
  function (req, res) {
    console.log(req.files); // [{}, {}, {}...] 배열로 요청됨(하나를 업로드해도 배열 형태임)
    console.log(req.body); // 파일 외의 정보
    res.send("파일 업로드 완료");
  }
);

 

 

 

2. 하나의 요청이 아닌 여러 개의 요청으로 여러 개의 파일을 업로드

.fields() 사용

파일 정보는  req.files에 들어있음

app.post(
  "/uploads/fields",
  uploadDetail.fields([
    { name: "file1" }, // input name 작성
    { name: "file2" },
    { name: "file3" },
  ]),
  function (req, res) {
    console.log(req.files);
    console.log(req.body); // 파일 외의 정보
    console.log(req.files.files1[0].originalname);
    /* 
객체 형태로..
{file1:[{}, {}], file2:[{}], name 속성:[{}, {}]}
*/

    res.send("파일 업로드 완료");
  }
);

 

 

 

참고로, 이미지와 텍스트를 동시에 전송하고 싶은 경우엔 ... 문법을 사용해 요소를 꺼내서 하나로 합쳐주던가 or 객체 형태로 보내주면 된다.(동적 파일 업로드 예제에 있음)

 

 

 

 

 

마지막으로, Axios를 사용하여 페이지 전환 없이 동적 파일을 업로드하는 실습을 해보자.

여태까지의 실습은 파일을 선택 후 업로드하면, 새로운 페이지로 이동하면서 파일이 업로드 되었다.

하지만 이번 예제는, 파일을 선택 후 프로필 업로드 버튼을 누르면, 페이지의 이동없이 프로필 사진이 바뀌는 실습이기 때문에, 페이지의 이동 없이 멀티 파트 데이터를 전송하기 위해서는,  FormData 클래스를 사용해야 한다.

 

 

뷰 코드

<h2>동적 파일 업로드</h2>
    <div class="dynamic">
      <input type="file" id="dynamicFile" name="dynamicFile" />
      <br />
      <input
        type="text"
        id="dynamicTitle"
        name="dynamicTitle"
        placeholder="프로필 설명"
      />
      <br />
      <button type="button" onclick="fileUpload()">프로필 업로드</button>
      <button type="button" onclick="fileAndTextUpload()">
        프로필 업로드(with text)
      </button>
      <br />
      <br />
      <h3>결과화면</h3>
      <img
        src="/static/img/default.jpg"
        alt="기본 프로필 이미지"
        width="200"
        height="200"
        class="profile"
      />
      <div class="tit"></div>
    </div>

 

 

스크립트

<script>
      function fileUpload() {
        // id 값으로 바로 접근할 수 있지만, 명시적으로 선언해주는 방법 추천
        //console.log(dynamicTitle);
        //console.log(dynamicFile);
        const file = document.getElementById("dynamicFile");
        const img = document.querySelector(".profile");
        
        /*
            자바스크립트에서 기본적으로 제공하는 클래스인 FormData,
            이미지 pdf 등 파일을 "페이지 전환 없이", 비동기적으로 제출하고 싶을 때 사용
            */
            
        const formData = new FormData();

        console.log(file);
        console.dir(file); // console.log()보다 더 자세한 정보가 나옴(객체형태로-)
        console.log(file.files); // 배열형태로 들어있음
        console.log(file.files[0]); // 첫 번째 파일

        // formData.append('네임', value)
        // <input name='dynamicFile' value=file.files[0] />
        formData.append("dynamicFile", file.files[0]);

        axios({
          method: "POST",
          url: "/dynamicUpload",
          data: formData,
          Headers: {
            "Content-Type": "multipart/form-data",
          },
        }).then((res) => {
          console.log(res);
          console.log(res.data); // 파일과 관련된 정보
          console.log(res.data.path); // 업로드된 이미지 정보
          // img 태그에 업로드한 사진 올리기
          img.src = res.data.path;
        });
      }

      function fileAndTextUpload() {
      /*
            자바스크립트에서 기본적으로 제공하는 클래스인 FormData,
            이미지 pdf 등 파일을 "페이지 전환 없이", 비동기적으로 제출하고 싶을 때 사용
       */
            
        const formData = new FormData();
        const file = document.getElementById("dynamicFile");
        const title = document.getElementById("dynamicTitle");
        const img = document.querySelector(".profile");
        const resultTitle = document.querySelector(".tit");
		
        // formData.append(name, value)
        // name과 value를 가진 폼 필드 추가
        formData.append("dynamicFile", file.files[0]); // <input name='dynamicFile' value=file.files[0] />
        formData.append("dynamicTitle", title.value);

        axios({
          method: "POST",
          data: formData,
          url: "/dynamicUpload",
          headers: {
            "Content-type": "multipart/form-data",
          },
        })
          .then((res) => {
            console.log(res.data);
            console.log(res.data.tilte);
            console.log(res.data.fileInfo);
            console.log(res.data.fileInfo.path);
            img.src = res.data.fileInfo.path;
            resultTitle.innerText = res.data.title.dynamicTitle;
          })
          .catch((err) => {
            console.log(err);
          });
      }
    </script>

 

위의 코드를 보면, FormData을 사용했는데, formData에 대해 정리해보자.

 

 

FormData

  • 폼을 쉽게 보내도록 도와주는 객체
  • HTML 폼 데이터
  • 자바스크립트에서 기본적으로 제공하는 클래스 → new ~ 생성자로 생성
  • 멀티 파트 데이터를 페이지 전환 없이 비동기적으로 전송하고자 할 때 사용
  • 함수
    • .append(name, value): form에 지정한 name과 value를 갖는 input을 추가(name 중복 가능하게 추가)
    • . delete(name): 해당 name을 갖는 필드 제거
    • .get(name): 주어진 name에 해당하는 필드의 value 반환
    • .getAll(name): 해당 name에 해당하는 필드의 모든 value를 반환
    • .has(name): 해당 name을 가진 필드의 여부에 따라 true/false 반환
    • .set(name, value): 기존 name에 해당하는 모든 필드를 제거 후 지정한 name과 value를 갖도록 추가

 

 

 

 

FormData 객체를 생성 후, getElement~~를 이용해 전송하고자 하는 파일, 텍스트(타이틀)를 가지고 와서 .append()함수를 사용해 지정한 name값을 갖고 value로 해당 파일과 텍스트값을 갖는 필드를 생성해 주었다. 그 후, axios 방식을 이용해 위에서 만든 FormData 객체를 전송한 것이다. 

 

app.post(
  "/dynamicUpload",
  uploadDetail.single("dynamicFile"),
  function (req, res) {
    console.log(req.file);
    console.log(req.body);
    
    // res.send(...req.file, ...req.body);
    
    res.send({ title: req.body, fileInfo: req.file }); // 추후 점표기법으로 접근하면 된다 ex) fileInfo.title

  }
);

 

백에서는 그 FormData를 받아 req에 들어간 데이터를 파일과 텍스트(타이틀)을 각각 나눠 객체로 만들어서 response(응답)해준다. 다시, ejs파일로 올라가서 axios 통신이 성공적으로 이루어지고 나서 실행되는 .then() 안을 살펴보자. 

프로필 사진을 바꿔줘야 하기 때문에, 선택해 놓았던 img 태그의 .src(경로) 속성을 응답(response)받은 데이터에 들어가 있는 파일의 경로를 지정해줌으로써, 업로드했던 이미지가 프로필 사진으로 등록될 수 있던 것이다.

 

 

 

최종 결과

 

 

 

 

 

 

 

 


참고

 

정적 파일 https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=pjok1122&logNo=221545195520

 

멀티파트 https://sharonprogress.tistory.com/197

static 설정  https://velog.io/@hi4190/app.useexpress.staticdirname-public

__dirname https://reload1bronze.tistory.com/97