게시판 - 파일첨부 기능 만들기 1 (업로드)

소스코드

이 게시물에는 코드작성이 포함되어 있습니다. 소스코드를 받으신 후 진행해 주세요. MEAN Stack/개발 환경 구축에서 설명된 프로그램들(git, npm, atom editor)이 있어야 아래의 명령어들을 실행할 수 있습니다.

이 게시물의 소스코드는 게시판 만들기(고급) / 게시판 - 글번호, 조회수 표시하기에서 이어집니다.

board.git 을 clone 한 적이 있는 경우: 터미널에서 해당 폴더로 이동 후 아래 명령어들을 붙여넣기합니다. 폴더 내 모든 코드가 이 게시물의 코드로 교체됩니다. 이를 원치 않으시면 이 방법을 선택하지 마세요.

git reset --hard
git pull
git reset --hard 6ba2aa3
git reset --soft 28580df
npm install
atom .

board.git 을 clone 한 적이 없는 경우: 터미널에서 코드를 다운 받을 폴더로 이동한 후 아래 명령어들을 붙여넣기하여 board.git 을 clone 합니다.

git clone https://github.com/a-mean-blogger/board.git
cd board
git reset --hard 6ba2aa3
git reset --soft 28580df
npm install
atom .

- Github에서 소스코드 보기: https://github.com/a-mean-blogger/board/tree/6ba2aa35dd5f209b71dcdf56c5888c648efd315b


많은 분들이 요청해 주셨던 파일첨부 기능입니다!

파일 역시 post, user, comment와 마찬가지로 'file'이라는 resource로써 db에 저장합니다. 이 때 실제 파일은 db에 저장되지 않고 서버에 저장되지만, file 모델에는 업로드된 파일의 정보를 저장하고, 이 정보를 이용해 서버에서 파일을 찾을 수 있게 합니다.

** 여기서 잠시 이 resource(리소스)라는 개념에 대해 잘 생각해 봅시다. 여기서의 리소스는 '시스템 리소스가 부족하여 프로그램을 실행할 수 없습니다'에서의 리소스와는 그 의미가 다릅니다. 이 어플리케이션(웹사이트도 어플리케이션의 한 종류입니다)을 이루는 구성요소로 생각하시면 됩니다.

파일업로드를 위해서는 HTML의 form을 아래와 같이 설정해 주어야 합니다.

  1. form 태그의 enctype을 "multipart/form-data"로 설정(<form ... enctype="multipart/form-data" ... >)
  2. input 태그의 type을 file로 설정(<input ... type="file" ... >)

이렇게 하여 파일을 form 데이터의 하나로 서버에 업로드할 수 있습니다.

하지만 input type이 file인 input은 서버의 body-parser로 parse되지 않습니다! form에 포함된 파일은 multer라는 package를 사용해서 읽어와야 합니다.

이번 강의에서는 파일을 업로드하고 'file' 모델의 데이터를 생성하는 법까지만 알아보겠습니다. 다운로드는 다음 강의에서..

폴더 구조

Package 설치

$ npm i multer --save

코드 - .ignore, util.js, index.js

본격적으로 파일 업로드에 대해 알아 보기 전에, 위 세가지 파일들에 대한 코드 변화를 살펴봅시다.

# .ignore

node_modules
uploadedFiles # 1

uploadedFiles는 업로드된 파일이 multer에 의해 저장되는 공간입니다. post route 코드에서 해당 폴더에 저장되도록 설정했습니다. 이 파일들은 소스코드의 일부가 아니므로 git에 저장되면 안됩니다.

1. .ignore에 uploadedFiles 폴더를 추가하여 해당 폴더와 그 안의 파일들이 git에 저장되지 않도록 하였습니다.

// util.js

...

util.bytesToSize = function(bytes) { // 1
   var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
   if (bytes == 0) return '0 Byte';
   var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
   return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i];
}

module.exports = util;

1. 파일의 사이즈는 byte로 file 모델에 저장되는데, 웹사이트에서는 보기 좋게 이 byte를 KB, MB 등으로 고쳐주는 bytesToSize함수를 추가하였습니다.

// index.js

...

// Custom Middlewares
app.use(function(req,res,next){
  res.locals.isAuthenticated = req.isAuthenticated();
  res.locals.currentUser = req.user;
  res.locals.util = util; // 1
  next();
});

...

1. 위 bytesToSize함수를 ejs에서 사용할 수 있도록 res.locals.util에 util을 담았습니다.(물론 bytesToSize함수 뿐만 아니라 util의 모든 함수들이 이제 ejs에서 사용가능합니다.)

코드 - Models

// models/File.js

var mongoose = require('mongoose');

// schema
var fileSchema = mongoose.Schema({ // 1
  originalFileName:{type:String},
  serverFileName:{type:String},
  size:{type:Number},
  uploadedBy:{type:mongoose.Schema.Types.ObjectId, ref:'user', required:true},
  postId:{type:mongoose.Schema.Types.ObjectId, ref:'post'},
  isDeleted:{type:Boolean, default:false},
});

// model & export
var File = mongoose.model('file', fileSchema);

// model methods
File.createNewInstance = async function(file, uploadedBy, postId){ // 2
  return await File.create({
      originalFileName:file.originalname,
      serverFileName:file.filename,
      size:file.size,
      uploadedBy:uploadedBy,
      postId:postId,
    });
};

module.exports = File;

1. file 모델의 스키마입니다. 각각의 항목(property)들을 살펴봅시다.

  • originalFileName: 업로드된 파일명입니다.
  • serverFileName: 같은 이름의 파일이 업로드되는 경우를 대비하여 모든 업로드된 파일은 파일명이 바뀌어 저장됩니다. 실제 서버에 저장된 파일 이름을 저장합니다.
  • size: 업로드된 파일의 크기입니다.
  • uploadedBy: 어느 user에 의해 업로드되었는지를 기록합니다. 파일업로드는 로그인 된 유저에게만 혀용되므로 required가 추가되었습니다.
  • postId: 이 파일이 어느 post와 관련있는지를 기록합니다. 아마 나중에 게시판이외의 경로로 업로드되는 경우가 있을것 같아서 required는 하지 않았습니다.
  • isDeleted: comment와 마찬가지로 파일을 지우는 경우에 실제 파일이나 DB의 file 데이터를 지우지 않고 isDeleted를 이용하여 처리합니다.

2. createNewInstance함수는 file, uploadedBy, postId를 받아 file모델의 객체을 DB에 생성하고 생성한 객체(인스턴스)를 리턴합니다. 함수에 전달되는 file 인자는 multer로 생성된 file 정보가 들어있는 객체인데요, 이 file 객체의 구조는 multer의 공식 npm 페이지 (https://www.npmjs.com/package/multer#file-information) 에서 볼 수도 있고, 아니면 console.log디버깅으로 직접 살펴볼 수도 있습니다.

다음으로 Post 모델입니다.

// models/Post.js

...

// schema
  ...
  numId:{type:Number},
  attachment:{type:mongoose.Schema.Types.ObjectId, ref:'file'}, // 1
  createdAt:{type:Date, default:Date.now},
  ...

file와 post의 관계를 만들어주는 부분입니다. 왜 post에도 file ref(reference)가 있고, file에도 post ref가 있으냐면, post는 현재 게시물이 가지고 있는 file을 ref로 가지고 있는 것이고, file은 생성될 당시의 post를 ref로 가집니다.

즉, 게시물에 파일이 첨부되었다가 첨부파일을 새로 업로드하게 되면 post는 두번째 업로드된 파일만 ref로 가지게 됩니다. 이때 만약 file에 post ref가 없다면 첫번째 업로드된 파일은 어느 post를 위해 업로드되었는지 그 정보를 잃거 되므로 postId를 따로 기록하는 것입니다.

코드 - Post Views

<!-- views/posts/new.ejs -->

        ...
      </nav>

      <form action="/posts<%= getPostQueryString() %>" enctype="multipart/form-data" method="post"><!-- 1 -->

        <div class="form-group">
          <label for="title">Title</label>
          ...
        </div>

        <div class="form-group">                                                          <!-- 2 -->
          <label for="attachment">Attachment</label>                                      <!-- 2 -->
          <input type="file" name="attachment" class="form-control-file" id="attachment"> <!-- 2 -->
        </div>                                                                            <!-- 2 -->

        <div class="form-group">
          <label for="body">Body</label>
          ...

1 & 2. post-new에 강의의 앞부분에서 설명했던 것처럼 form 태그의 enctype을 "multipart/form-data"로 설정하고 type이 "file"인 input 태그를 추가하였습니다.

2. 새로운 input 태그의 "name"항목은 attachment(첨부파일)으로 설정했습니다. name의 값을 통해 서버에서 이 값에 접근할 수 있습니다.

<!-- views/posts/show.ejs -->

        ...

          <div class="col-md-7 col-lg-8 col-xl-9 order-sm-2 order-md-1">
 <!-- 1 --> <% if(post.attachment) { %>
 <!-- 1 -->   <div class="ml-2">
 <!-- 1 -->     <small>📁 Attachment: <%= post.attachment.originalFileName %> (<%= util.bytesToSize(post.attachment.size) %>)</small>
 <!-- 1 -->   </div>
 <!-- 1 --> <% } %>
            <div class="post-body p-2" data-search-highlight="body"><%= post.body %></div>
          </div>

          ...

1. post-show 페이지에 첨부파일이 있는 경우 파일 이름과 크기를 보여주는 코드를 추가했습니다. 현재는 파일 정보만 볼 수 있고 다음 강의에서 파일을 클릭하여 다운로드하는 기능을 추가합니다.

코드 - Post Route

// routes/posts.js

var express  = require('express');
var router = express.Router();
var multer = require('multer'); // 1
var upload = multer({ dest: 'uploadedFiles/' }); // 2
var Post = require('../models/Post');
var User = require('../models/User');
var Comment = require('../models/Comment');
var File = require('../models/File'); // 3
var util = require('../util');

...
// create
router.post('/', util.isLoggedin, upload.single('attachment'), async function(req, res){ // 4-1
  var attachment = req.file?await File.createNewInstance(req.file, req.user._id):undefined; // 4-2
  req.body.attachment = attachment; // 4-3
  req.body.author = req.user._id;
  Post.create(req.body, function(err, post){
    if(err){
      ....
    }
    if(attachment){                 // 4-4
      attachment.postId = post._id; // 4-4
      attachment.save();            // 4-4
    }                               // 4-4
    res.redirect('/posts'+res.locals.getPostQueryString(false, { page:1, searchText:'' }));
  });
});

...

// show
  ...

  Promise.all([
      Post.findOne({_id:req.params.id}).populate({ path: 'author', select: 'username' }).populate({path:'attachment',match:{isDeleted:false}}), // 5
      Comment.find({post:req.params.id}).sort('createdAt').populate({ path: 'author', select: 'username' })
    ...
}

1 & 2. multer package를 불러오고, 파일이 저장될 폴더 위치를 dest에 넣어서 upload 변수를 설정하였습니다. 이 과정은 모두 multer의 공식 npm 페이지(https://www.npmjs.com/package/multer)에 설명되어 있습니다.

3. file model을 불러옵니다.

4-1. upload.single('attachment') 미들웨어가 추가되고 마지막 callback함수 앞에 async 키워드를 추가하였습니다.

upload.single(폼_Input_이름)는 파일 하나(single)를 form으로 부터 읽어옵니다. 여기서는 폼_Input_이름이 'attachment'이므로 <input type=file name='attachment' ...>로 부터 파일을 읽어옵니다. 읽어온 파일은 2번에 의해 'updatedFiles' 폴더에 저장되고 그 정보가 req.file에 담겨집니다.

asyncawait 키워드는 게시판 - 페이지 기능 만들기 1강의에서 설명했던 것 기억하시죠?

4-2. req.file이 존재하면 createNewInstance함수를 이용해서 file 모델의 인스턴스를 생성합니다.

** document(도큐먼트), collection(콜렉션), schema(스키마), model(모델), instance(인스턴스)의 개념을 잘 구별합시다. file을 대상으로 다시 한번 설명합니다.

  • file document는 mongo DB에 저장되어 있는 각각의 file 데이터들입니다.
  • file collection은 mongo DB에 저장되어 있는 file document들의 집합입니다. document를 각각의 파일로 생각하면, collection은 그 파일들을 담고 있는 폴더로 생각할 수 있겠네요.
  • file 스키마는 db에 file 데이터가 저장되는 형태(어떠한 항목들이 있으며, 각 항목의 타입)을 뜻합니다.
  • file 모델은 DB의 file collection을 프로그래밍으로 조작할 수 있게 오브젝트로 만든 것입니다. static class와 같은 개념입니다.
  • file 인스턴트는 하나의 file document를 조작할 수 있게 오브젝트로 만든 것입니다.

4-3. 이렇게 만들어진 file 모델을 req.body.attachemnt에 담아서 post가 생성될 때 같이 저장될 수 있게 합니다.

4-4. post가 생성된 후 생성된 post의 _id를 file 모델의 postId에 담고 save함수를 이용하여 DB에 저장합니다.

5. post 모델에 populate({path:'attachment',match:{isDeleted:false}})가 추가되었습니다. 이로써 post모델에 attachment(file 모델)가 하위 객체로 생성되는데, match:{isDeleted:false}로 인해 attachment의 isDeleted가 false인 경우에만 생성됩니다. 즉 isDeleted가 true인 경우에는 실제 데이터가 지워지진 않았지만 데이터를 가져오지 않음으로 지워진 것처럼 행동하게 하는 것입니다.

실행 결과

우선 아래와 같이 파일을 첨부하여 새글을 작성합니다.

작성한 글을 열어봅시다.

파일이 다운로드되지는 않지만 파일 이름과 사이즈가 표시됩니다.


프로젝트 폴더의 uploadedFiles 폴더에 파일이 생성된 것을 볼 수 있습니다.

마치며...

이번강의에서는 file 모델을 생성하고, 서버에 파일을 저장하는 것까지 알아보았습니다. 다음 강의에서는 어떻게 서버의 파일을 route을 통해서 다운 받는지 알아봅시다.

multer package에 대해 좀 더 자세히 알고 싶으신 분들은 아래의 관련 강의를 참고해주세요.

댓글

-
-21 2020.03.28
파일 첨부 기능 올려주셔서 너무 감사합니다ㅠㅠㅠ 안 그래도 multer 써서 text와 file 모두 업로드 하는 게시판 만드려고 하는데 라우팅 에러, post에러, upload 에러 에러란 에러는 다 뜨더라고요ㅠㅠㅠ 열심히 정독해서 해보겠습니다! 감사합니다ㅠㅠ
I
Ian H 2020.03.28
@-21,
저도 파일업로드 처음만들땐 엄청 헤맸어요 ㅠㅠ 화이팅!
-
-21 2020.04.02
모듈을 file과 post 따로 나눠서 서버에 올리는건 file에 있는 내용은 직접 접근이 안 되서 그런건가요? post.attachment.originalFilename 이런식으로 접근하고 있는데 그냥 바로 file.originalFilename 이런식으로는 접근 못하나요? 
I
Ian H 2020.04.02
@-21,
바로 파일로 접근할 수도 있습니다. 다음 강의를 보면 아시겠지만, post.attachment는 게시물과 첨부파일을 연결해주는 역할이며 다운로드 자체는 post없이 바로 file을 검색하여 다운로드합니다.
혹시 제가 질문을 잘못이해한 것이면 다시 질문을 풀어서 해주세요~
김예성 2020.04.06
게시글 잘 보고 있습니다. 혹시 첨부 파일을 여러개 업로드하려고 하는데 upload.array 로 변경하는 것 까지 확인했습니다. 본 소스에서는 어떻게 적용하여야 하나요?
I
Ian H 2020.04.06
@김예성,
현재 post.attachment가 하나의 파일정보를 담게 되어 있으니, 이부분을 array로 변경하여 여러개의 파일 정보를 담을 수 있게 수정할 수 있겠지요. 아니면 이미 file모델에 postId가 포함되어 있으니 post를 읽는 부분에서 aggregation을 사용($lookup)하고 post 모델에서는 attachment를 지워버려도 됩니다.
이제 post와 file이 one to one 에서 one to many로 변경되었으니 이에 맞게 route과 view를 수정해 주면 되겠습니다.
한번 혼자 해보시고 잘안되면 또 댓글남겨주세요^^
김예성 2020.04.07
@Ian H,
routes.posts. //create 부분에 upload.single('attachment') 구문을 upload.array('attachment') 로 변경하였습니다. route 와 view 수정을 해야한다고 하셨는데 어떤 부분을 수정해야할지 감이 안잡히네요. 혹시 수정해야할 부분을 알려주실 수 있으실까요?
I
Ian H 2020.04.08
@김예성,
위 코드는 '하나'의 파일을 받아서 post - file이 'one to one' 관계를 가지고 있습니다. array로 파일을 받으면 post - file 'one to many' 관계가 되었으니 array을 돌면서 각각 file 모델을 생성하고 이 관계를 형성해 주어야 겠지요.
현재 post - comment의 관계가 'one to many' 관계이므로 이 부분을 잘 연구해 보시고 한번 시도해보세요. 위 코드의 comment는 다양한 기능들로 인해 굉장히 복잡하므로 https://www.a-mean-blog.com/ko/blog/Node-JS-첫걸음/게시판-만들기-고급/게시판-댓글-기능-만들기-1-쓰기-보기 의 코드를 보시면서 공부해 보시기 바랍니다~~
댓글쓰기

이 글에 댓글을 다시려면 SNS 계정으로 로그인하세요. 자세히 알아보기

UP