[Node.js] 파일 업로드 : multer

Yunhye Park·2023년 10월 23일
0
post-thumbnail

지난 수업 Recap

🔹 기본 http 모듈은 지원하지 않는 기능도 많고 사용이 번거로워서 외부 모듈인 express를 설치해 개발하는 게 용이하다.
🔹 외부 모듈은 설치가 필요하다. ex) express, ejs, axios
🔹 node 환경에서는 백과 프론트 모두 조작할 수 있다.
🔹 ejs 템플릿 엔진을 활용하면 자바스크립트가 내장된 html 형식으로 작성 가능.

🔸 GET 요청은 쿼리스트링으로, POST 요청은 body에 데이터가 담긴다.
🔸 url에 정보 노출시킬 게 아니라면 POST로 하는 게 옳다.

🔅 자바스크립트의 비동기 HTTP 처리 방식(AJAX)으로 jquery ajax, axios, fetch 등이 있다.

🔅 폼 전송은 일반 폼 전송(동기)과 동적 폼 전송(비동기)으로 나뉜다.
🔅 일반 폼 : form 속성의 action과 method 설정 / button type submit
🔅 동적 폼 : form 속성에 name 부여 / button type button


오늘은 파일 업로드 미들웨어 multer 사용해보자.

0. 환경 세팅

✔️ 외부 모듈이라서 설치하는 과정이 필요하다. 써드 파티가 아니라 명령어만으로는 처리를 못하고, require로 불러오기도 하자.

// index.js
const multer = require("multer");

✔️ 터미널 명령어 : npm i multer

✔️ form에 인코딩 타입 설정
그간 body-parser 라이브러리로 POST 요청 시 데이터를 손쉽게 body에 담았지만, 이미지, 파일, 동영상 같은 멀티파일 데이터는 추가적인 처리가 필요하다. 멀티파일이 있다는 걸 프로그래밍이 인지하기 위한 설정인 듯하다.

이제 본격적으로 클라이언트에서 파일 데이터를 받아서 처리해보자.

1. multer 설정

우선 일반 폼 전송으로 이미지 파일을 받아오자.

파일을 받을 때 input type은 file이다. 일반 폼 전송을 할 생각이기에 button을 submit type으로 지정하고 action 속성에 요청 받을 위치를 설정했다.

    <h2>디테일한 multer 설정 : storage 이용 </h2>
    <form action="/multer"
         method="post"
         enctype="multipart/form-data">
       <input type="file" name="userfile" />
       <br />
       <input type="text" name="title" />
       <br />
  	   <button type="submit">파일 업로드</button>
    </form>

ejs 파일을 다 작성했으면 이제 요청을 받고 처리할 영역 즉 js 파일을 작성해야 한다. multer를 사용하면 따로 설정 없이 그대로 파일을 전송 받거나 파일 이름과 경로를 디테일하게 설정할 수 있다. 순차적으로 살펴보자.

1.1 기본 세팅대로

우선 기본 설정이다. multer는 객체를 인자로 받는다. 어느 위치에 해당 파일을 저장할지 destination을 설정해준다.

그리고 클라이언트와 서버 사이에서 파일 데이터를 처리해야 하니까 미들웨어로 등록힌다. 말 그대로 요청 받을 곳과 요청에 대한 반응을 처리할 두 인자 사이에 기입해주면 된다.

// 파일 저장 위치 기본 설정
const upload = multer({
	dest: "uploads/"
});

// 미들웨어 등록
app.post("/upload", upload.single("userfile"), (req, res)=>{
    res.send("file upload");
});

지금은 파일을 하나만 받을 거라서 single 메서드를 사용했다. single 메서드는 인자로 type이 file인 input의 name을 받는다. 여러 파일 받을 땐 array(), fields()를 사용하면 된다.

그런데..

기본 설정으로 파일을 받으면 정체 불명의 글자로 이루어진, 심지어 확장자도 없는 파일로 저장된다. 확장자를 맨 뒤에 기입하면 제대로 된 파일인지 확인할 수야 있다지만 매번 체크할 수 없는 노릇이다.

파일 경로와 이름을 설정해서 뭐가 뭔지 구별해야겠다.

1.2 직접 파일 경로, 이름 설정

POST 요청할 주소를 바꾸고 storage 프로퍼티를 사용해 파일 위치와 이름을 지정했다. 그리고 파일 크기는 5MB로 제한해봤다.

파일 위치(destination)와 이름(filename)을 설정한다는 건 결국 특정 행동을 조작하는 것이니까 함수로 받는다. 둘다 req, file, done 세 가지 매개변수를 받는다.

destination은 완료되었을 때(done)만 사용해 null, 파일을 받을 위치를 인자로 입력한다.

filename은 파일명_고유식별번호.확장자 이렇게 받으려고 한다.

  • 파일명

첨부된 파일 정보는 file 인자로 넘어오고, file은 다양한 키값을 지닌 객체로 이루어졌다.

클라이언트에 입력된 파일명은 어디에 있을까? 파일 경로에 있을 것이다. 이때 path를 사용하면 파일 경로를 활용해 여러가지를 조작할 수 있다.

const path = require('path');

path의 extname 메서드를 사용해 file 객체의 originalname에서 확장자만 골라낸다.

➡️ 확장자만 추출할 때 : path.extname(file.originalname)

이번엔 파일 이름 부분만 가져와보자. path의 또 다른 메서드인 basename이 제격이다. basename은 두 개의 인자를 받는다. 하나는 확장자가 포함된 파일의 풀네임, 다른 하나는 확장자다.

➡️ 이름 부분만 추출할 때 : path.basename(file.originalname)

  • 고유 식별 번호

동명의 이름과 확장자가 사용된 파일이 여럿일 수 있으니 현재 시간을 기준(Date.now())으로 고유한 숫자를 생성했다.

const uploadDetail = multer({
    storage: multer.diskStorage({
        destination: function(req, file, done){
            done(null, "uploads/");
        },
        filename: function(req, file, done){
            const ext = path.extname(file.originalname); // jpg
            const baseName = path.basename(file.originalname, ext);
          // cat
            const fileName = baseName + "_" + Date.now() + ext;
          // cat_2324343.jpg        
            done(null, fileName);
        }
   }),
    limits: {fileSize: 5 * 1024 * 1024} // 5MB 제한
});

함수 인자 안에 객체가 들어서는 복잡한 구조를 간단히 정리하자면 이렇다.

{storage: multer.diskStorage( { destination: f{}, filename: f{} } ), limits: {} }

이미지 파일을 응답으로 렌더하기

파일을 성공적으로 받았는지 저장된 위치(여기선 uplaods)를 보면 알 수 있지만, user에게도 확인 겸 자신이 첨부한 파일을 렌더해서 보여주면 어떨까? user가 입력한 파일의 title까지 보여주면서 말이다.

그럼 요청 처리를 res.send가 아닌 res.render로 해주고, 필요한 정보를 객체로 담아 html 파일로 넘긴다. 이미지의 경로는 파일 객체의 path에, 또 다른 input인 title은 body에 담겨온다.

// POST 요청 처리와 미들웨어 등록
app.post("/upload/detail",
         uploadDetail.single("userfile"),
         (req, res)=>{
    res.render("result", {
        src: req.file.path,
        title: req.body.title
    });
})

그런데 정적 파일은 클라이언트가 그냥 접근할 수 없다. 특정 경로를 라우팅해서 받아온 이미지 파일의 디렉토리를 열람하게 미들웨어를 설정해야 한다.

app.use()는 express에서 미들웨어 역할을 수행한다. .get()이나 .post()와 다르게 요청 url을 지정하지 않아도 사용할 수 있다. 그렇게 하면 url에 상관없이 매번 실행된다.

하지만 미들웨어로 사용할 것이니, 파일이 저장된 경로를 두번째 인자로 넘겨서 해당 위치에 클라이언트가 접근할 수 있도록 하자. 이때 폴더 세부 구조를 작성하기 번거로워서 절대경로를 나타내는 __dirname으로 시작하여 작성해 주었다.

app.use("/uploads", express.static(__dirname + "/uploads"))

결과

2. 파일 여럿 업로드 하려면?

지금은 하나의 파일만 이렇게 담아왔는데 만약 여러 파일을 전송 받고 싶으면 어떻게 할까?

이 경우도 둘로 나뉘겠다. 하나의 input에 여럿을 받을 수도, 여러 input에 하나씩 받을 수도 있으니 말이다.

2.1 하나의 input에 파일 여러 개

우선 form 속성에 multiple를 추가한다. 그럼 클라이언트에서 파일을 여러 개 선택할 수 있다.

<h2>파일 여러개 업로드 : 하나의 input 이용</h2>
  <form action="/upload/array"
        method="post"
        enctype="multipart/form-data"
        mutiple>
    <input type="file" name="userfile" />
    <br />
    <input type="text" name="title" />
    <br />
    <button type="submit">UPLOAD</button>
    </form>

그럼 파일을 single이 아닌 여럿으로 받아와야 할 테다. 이럴 때 미들웨어는 array를 사용한다.

app.post("/upload/array",
         uploadDetail.array("userfile"),
         (req, res)=>{
    res.send("여러 파일 업로드 성공");
});

💡 여러 파일을 렌더링할 순 없나?

이날 수업에서 계속 에러를 만났다. 알고보니 single메서드를 미들웨어로 사용하는 폼에서 예제였는데 나혼자 여러 개의 파일을 받는 폼에다가 같은 내용을 작성하고 있었다.

에러는 해결했지만 궁금증이 남았다. 여러 파일을 렌더링하려면 어떻게 해야 하지?

array는 배열 객체다. 고로 req.file로 받아오면 에러가 발생할 거다. 어떻게 하면 좋을까 고민하다가 수업 중 동적 폼 전송에서 사용했던 req.files가 떠올랐다! req.file 객체 여럿을 한데 모은 배열이다. [ {}, {} ] 이런 식으로.

result.ejs 파일을 만들어 클라이언트 측에 업로드한 파일을 렌더링 해보자!

app.post('/several', upload.array('file'), (req, res)=>{
    res.render('result', {
        images: req.files, // files로 기입
        title: req.body.title
    });
})

result 파일에서 할 일을 두 단계로 쪼갰다.

  1. 파일이 첨부되었는지 if문으로 확인
    (validation은 아니고, 파일이 있어야 이미지를 보여줄 수 있으니까)
  2. 첨부됐으면 배열 속 객체를 순회하여 path를 img src에 넣어준다.

배열 순회는 map, filter, find 등이 있지만 forEach를 사용해 각 객체를 file 파라미터로 받아 그 객체의 path로 접근하여 src에 기입해줬다.

🔍 왜 forEach인가?

순회한 결과물(첨부 이미지)을 <li></li>로 추가하려고 한다. 그럼 조건에 맞는 배열의 모든 값을 반환해야 하고, 반환값은 딱히 필요하지 않다. 배열의 특정값을 수정할 게 아니라서 순회만 할 수 있으면 된다.

  • find : 조건에 해당하는 배열의 첫번째 값만 반환하니까 제외.
  • filter : 배열 중 조건을 통과하는(true) 값만 모아다가 새 array를 return해서 의도와 다른 선택지 같았다.
  • map : 원본 array를 훼손하지 않고 원본을 변형한 새 array를 반환하는 용도라서 어울리지 않는다.

forEach는 새 배열을 반환하지 않고 배열의 모든 요소를 순회하니까 제격!

<body>
        <ul>
            <% if (images && images.length > 0) { %>
                <% images.forEach(file => { %>
                    <li>
                        <img src="<%= file.path %>" alt="img" />
                        <%= title %>을 입력하셨네요.
                    </li>
                <% }); %>
            <% } %>
        </ul>
</body>

2.2 여러 input에 파일 하나씩

input을 여러 개 만들어 각각 하나씩 가져오려면, input을 여럿 만들면 된다.

그리고서 fields 메서드를 사용해 미들웨어를 등록하자. 이때 메서드에 복수형이 붙은 것에 주목하자. input이 여러 개라는 건 인자로 보낼 input name도 여럿이라는 의미이다. 각각을 객체로 만들어 배열 형태로 보낸다.

app.post("/upload/fields",
         uploadDetail.fields([
  			{name: "userfile1"},
   			{name: "userfile2"}]),
         (req, res)=>{
    res.send("여러 파일 업로드 성공 ver 2");
});

3. 동적 폼 전송

이전까지는 모두 페이지를 완전히 이동하는 동기 폼 전송 방식이었다. 마지막으로 같은 페이지 내에서 결과를 반환하는 비동기로 처리해보자.

기본 페이지와 결과 페이지를 따로 만들어 설정한 것과 달리, 이번엔 모든 작업을 html 파일 한 곳에 처리할 수 있다. 물론 스크립트 양이 많아지면 따로 파일을 추가해도 된다.

동적 폼 전송을 작성할 땐 form의 속성 중 name이 중요하다. 바닐라 자바스크립트로 axios를 작성할 때 이 속성으로 form을 선택하기 때문이다.

꼭 확인하기

  • action / method / enctype 속성 없음. name만 설정.
  • button type은 button
  • (button type을 기본인 submit으로 두어 addEventHandler로 e 인자를 넘겨 e.prevaentDefault()를 해줄 수도 있지만, form name으로 고르는 것보다 다소 번거롭다.)

이제 button에 onclick 이벤트를 걸어 동적으로 폼 전송하는 함수를 연결한다.

    <h2>동적 폼 전송(axios) 이용한 파일 업로드</h2>
    <form name="dynamic-upload">
        <input type="file" name="userfile" />
        <br />
        <input type="text" name="title" />
        <br />
        <button type="button" onclick="dyUpload()">동적 업로드</button>
        <div id="result"></div>
    </form>

id가 result인 곳에 응답 결과로 클라이언트 측에 첨부한 이미지를 보여줄 거다.

중요한 특징

input type=file은 text 타입의 input과는 달리 value로 접근할 수 없다. form의 name 속성으로 객체에 해당 input 태그에 접근한 후, value 속성이 아닌 files에 접근한다.

        function dyUpload(){
            const form = document.forms["dynamic-upload"];
			console.log(form.userfile.files);
          // 후략

복수형인 것에서 보이듯 이 속성은 여러 파일을 담는 배열 객체다. 딱 하나의 파일을 전송해도 배열형인 건 여전하다. 따라서, 인덱싱을 필수로 하자.

또, data에 담을 객체 생성 방식도 정해졌다. 객체 리터럴이 아닌 new 연산자와 함께 생성자 함수를 호출해야 한다. 모든 data를 생성자 함수 FormData의 인스턴스 객체 자식(.append())으로 추가하면 된다.

    <script>
        function dyUpload(){
            const form = document.forms["dynamic-upload"];
      
            const formData = new FormData();
            formData.append("title", form.title.value);
            formData.append("userfile", form.userfile.files[0]);
      
            axios({
                method: "post",
                url: "/upload/dynamic",
                data: formData,
                headers: {"content-type": "multipart/form-data"}
        }).then((res)=>{
            console.log(res.data);
            const result = document.getElementById("result")
            result.innerHTML = `<img src="/${res.data.src}" />`
        })
        }
    </script>

요청 응답으로 이미지 파일을 보여줄 것이라 미들웨어를 등록해 객체로 전송했다. 렌더링을 하면 html 페이지를 새로 불러온다는 의미이므로 res.send라는 걸 염두하자. form의 다른 요소도 같이 전달해서 결과 화면에 여러 값을 보여줄 수도 있겠다.

app.post("/upload/dynamic",
         uploadDetail.single("userfile"),
         (req, res)=>{
    res.send({src: req.file.path});
})

참고

app.use 란?? 미들웨어란??


추가로 알아볼 것

  • (일반 폼 전송) 응답 성공 시 여러 이미지 파일을 렌더하고 싶다면? 해결!
  • 업로드 경로로 설정해 둔 디렉토리가 존재하지 않으면 자동적으로 생성해 그 폴더 안에 이미지 파일을 전송한다. 그런데 실습할 땐 업로드 폴더가 자동 생성이 안 되어서 TypeError가 떴다.
    • 시도 1) path를 불러오는 것 말고 설치까지 해야 하나 싶어서 install 했었는데 수업 파일을 보니 package.json에 해당 모듈이 없었다.
    • 시도 2) 수업 파일을 한 줄씩 비교했는데 문제는 해결되지 않았다.
    • 시도 3) 결국 직접 폴더를 만들어 보니 문제가 해결됐다.
  • 아래처럼 fs를 사용해 명령을 추가해봤다. 하지만 여전히 원인은 미지수..
const fs = require('fs');

try {
	fs.accessSync('uploads');
} catch(error) {
	console.log('업로드 폴더가 없어서 생성합니다.');
	fs.mkdirSync('uploads');
}

덧붙이는 말

  • 내용을 이해할 수 있는데 아직 익숙치 않아선지 정리하고 적응할 시간이 필요한 것 같다. 그 시간만 제대로 확보하면 충분히 해낼 수 있는 기분이 든다.

  • 자바스크립트는 프론트 백 모두를 할 수 있단 게 얼마나 커다란 장점인지 와 닿는다. 도무지 이해할 수 없는 요상한 결과를 보여줘서 싫어하는 사람들도 많다지만 나는.. 자바스크립트가 재밌고 좋다. 그리고 객체라는 개념이 참 철학적이란 걸 이해하게 되어서 더 흥미롭다.

  • 프론트부터 백까지 전영역을 해보고 싶단 생각이 든다.

  • 수업 듣는 날에 할 것과 없는 날에 할 일을 정리했다.

    • 화목토는 수업 끝나면 과제까지 끝내고, 남은 시간엔 리액트 수업을 들으며 실습 중이다.
    • 다른 요일엔 헬스 / 수업 복습 / 수업 내용 블로그 작성 / 리액트 공부 / 하나 톺아볼 개념 공부 후 블로그 작성.. 무슨 다 블로그로 귀결된다. 근데 써봐야 정리되는 게 있다. 최소한 목차 구성하는 것도 도움이 된다. 그리고 난 글쓰기를 좋아해서... 어려운 개념은 확실히 시간이 오래 걸리지만 쉽게 배우면 오래 안 가니까.
profile
일단 해보는 편

0개의 댓글