게시판 - 파일첨부 기능 만들기 4 (수정/삭제)

소스코드

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

이 게시물의 소스코드는 게시판 만들기(고급) / 게시판 - 파일첨부 기능 만들기 3 (리스트에 아이콘 추가)에서 이어집니다.

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

git reset --hard
git pull
git reset --hard bb5ce02
git reset --soft 29f8df4
npm install
atom .

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

git clone https://github.com/a-mean-blogger/board.git
cd board
git reset --hard bb5ce02
git reset --soft 29f8df4
npm install
atom .

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


첨부파일을 수정하거나 삭제하는 기능을 추가하여 첨부파일 기능을 완성해봅시다.

폴더 구조

코드 - ejs

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

      <form action="/posts/<%= post._id %>?_method=put<%= getPostQueryString(true) %>" enctype="multipart/form-data" method="post"> <!-- 1 -->

        <div class="form-group">
          ...
        </div>

        <div class="form-group"> <!-- 2 -->
          <label for="title">Attachment</label>

          <input type="hidden" id="attachment" name="attachment" value="<%= post.attachment?post.attachment._id:'' %>">  <!-- 2-1 --> 
          <% if(post.attachment){ %> <!-- 2-2 --> 
            <div class="input-group mb-3" id="currentAttachemnt">
 <!-- 2-3 --> <input type="text" class="form-control" value="<%= post.attachment.originalFileName %>" readonly />
              <div class="input-group-append">
   <!-- 2-4 --> <button class="btn btn-outline-secondary" type="button" onclick="$('#attachment').val('');$('#currentAttachemnt').hide();$('#newAttachment').removeClass('d-none');">Delete</button>
              </div>
            </div>
          <% } %>
          <input type="file" id="newAttachment" class="form-control-file <%= post.attachment?'d-none':'' %>" name="newAttachment"> <!-- 2-5 -->
        </div>

        <div class="form-group">
          <label for="body">Body</label>
          <textarea id="body" name="body" rows="5" class="form-control <%= (errors.body)?'is-invalid':'' %>"><%= post.body %></textarea>
          <% if(errors.body){ %>
            <span class="invalid-feedback"><%= errors.body.message %></span>
          <% } %>
        </div>

1. 게시판 - 파일첨부 기능 만들기 1 (업로드) 강의의 post new view의 form과 마찬가지로 enctype="multipart/form-data"를 form 태그에 추가해줍니다.

2. Attachment 필드가 추가됩니다. 첨부파일이 있는 경우, 첨부파일의 이름과 삭제 버튼을 보여주고, 첨부파일이 없는 경우 첨부파일을 업로드할 수 있는 버튼(input type="file")을 보여줍니다.

2-1. 현재 게시물의 첨부파일 정보를 담고 있는 input name="attatchment"를 hidden type으로 form에 추가해줍니다. 물론 첨부파일이 없다면 value는 ''입니다.

2-2. 첨부파일의 이름과 삭제버튼은 post의 attachment가 있는 경우에만 표시합니다.

2-3. 현재 첨부된 파일의 이름을 표시하는 부분입니다.

2-4. 첨부파일 삭제버튼입니다. 이 버튼을 클릭하면 2-1번의 attachment의 값을 지우고(''로 변경), 2-2번의 태그들을 숨기고, 2-5의 태그를 보여줍니다.

2-5. 파일 업로드 버튼입니다. 첨부파일이 있는 경우 'd-none' css class가 추가되어 숨겨져 있다가, 2-4번의 버튼을 누르면 보여집니다. 첨부된 파일은 'newAttachment'로 서버에 전달됩니다.

코드 - js

// routes/posts.js

...

// edit
    ...
    Post.findOne({_id:req.params.id})                           // 1
      .populate({path:'attachment',match:{isDeleted:false}})    // 1
      .exec(function(err, post){                                // 1
        if(err) return res.json(err);
        res.render('posts/edit', { post:post, errors:errors });
      });
  ...

// update
router.put('/:id', util.isLoggedin, checkPermission, upload.single('newAttachment'), async function(req, res){ // 2
  var post = await Post.findOne({_id:req.params.id}).populate({path:'attachment',match:{isDeleted:false}}); // 2-1
  if(post.attachment && (req.file || !req.body.attachment)){ // 2-2
    post.attachment.processDelete();
  }
  req.body.attachment = req.file?await File.createNewInstance(req.file, req.user._id, req.params.id):post.attachment; // 2-3
  req.body.updatedAt = Date.now();
  ...

1. 게시판 - 파일첨부 기능 만들기 1 (업로드) 강의의 show route과 마찬가지로 populate을 설정하여 attachment.isDeleted가 false인 경우에만 attachment를 populate합니다.

2. multer를 사용한 upload.single('newAttachment') 미들웨어가 추가되었고, callback함수에 async 키워드가 추가되었습니다.

2-1. 1번과 같은 방식으로 post에 attachment를 populate합니다. 첨부파일 비교를 위해 기존의 post를 불러오는 과정입니다.

2-2. 수정 전의 post에 attachment가 존재했었지만, 현재 multer를 통해 req.file이 생성되었거나 form body의 attachment가 없다면 file 인스턴스의 processDelete함수를 호출합니다.

2-3. req.file이 존재하면 file 모델의 createNewInstance함수로 attachment를 만들어 넣습니다.

실행 결과

첨부파일이 있는 게시물의 수정 버튼을 누릅니다.

현재 첨부되어 있는 파일 이름이 표시됩니다. delete을 눌러봅시다.

첨부된 파일 이름이 사라지고 새로운 파일을 첨부할 수 있는 버튼이 생겼습니다. 이상태로 게시물을 저장하면 게시물에서 첨부파일이 지워지게 됩니다. 새로운 파일을 첨부하고 저장해봅시다.

첨부파일이 변경되었습니다.

마치며...

이것으로 첨부파일 기능에 관련된 강의는 끝입니다. 하지만 파일첨부 강의는 끝이 아닙니다. 다음 강의에서는 파일을 서버에 저장하지 않고, 서드파티 사이트의 API를 통해 저장하는 방법에 대해 알아보겠습니다.

댓글

강민규1 2020.09.09
안녕하세요 덕분에 노드를 잘 공부하고 있는 사람입니다.
강의를 보면서 항상 감사한 마음을 가지고 있습니다 ㅎㅎ
다름이 아니라
오탈자(?)가 있어서 제보 드립니다. 첨부파일 수정 소스에서 
routes/posts.js 스크린샷(?)에는 아래와 같이 나와 있습니다.
req.body.attachment = req.file?await createNewAttachment(req.file, req.user._id, req.params.id):post.attachment; // 2-3
해당 소스에 대한 설명은
2-3. req.file이 존재하면 file 모델의 createNewFile함수로 attachment를 만들어 넣습니다. 로 되어 있구요
아무리 찾아도 createNewAttachment 이 함수나 createNewFile 이 함수를 찾을 수가 없어서 깃에서 파일을 내려 받아서 확인해 봤더니
req.body.attachment = req.file?await File.createNewInstance(req.file, req.user._id, req.params.id):post.attachment;
이런 소스로 되어 있었습니다.
해당 부분에서 참고해 주시기 바랍니다 ^^
I
Ian H 2020.09.09
@강민규1,
제보주셔서 감사합니다. 해당부분은 수정하였습니다. 감사합니다!
f
fronthan 2021.03.11
선생님 제보합니다. views/posts/edit.ejs 의 2-5번 부분 ID가 중복 선언되었네요 ㅎㅎ 감사하게 잘 보고 있습니다. ^^
I
Ian H 2021.03.11
@fronthan,
중복된 부분 제거하였어요. 알려주셔서 감사합니다^^
K
KWANGJE MOON 2021.05.12
안녕하세요 선생님! 혹시 이미지 파일을 업로드 했을때 show 부분에 이미지가 뜨게 하는법 없을까요??
I
Ian H 2021.05.12
@KWANGJE MOON,
views/posts/show.ejs 에
            <% if(post.attachment) { %>               <div class="ml-2">      <!-- 1 --> <small>📁 Attachment: <a href="/files/<%= post.attachment.serverFileName %>/<%= post.attachment.originalFileName %>"><%= post.attachment.originalFileName %></a> (<%= util.bytesToSize(post.attachment.size) %>)</small>               </div>             <% } %>
이 부분을 보시면 파일 주소가 이미 show.ejs로 전달되고 있는 것을 볼 수 있죠. 파일 주소를 알 수 있으니 파일이 그림파일이면 img 태그를 써서 원하는 위치에 넣어주면 되겠습니다^^
K
KWANGJE MOON 2021.05.13
@Ian H,
감사합니다 선생님! 예를들어 원하는 위치에 <img src="<%= post.attachment %>"> 이런식으로 해주면 된다는 소리신가요??
I
Ian H 2021.05.13
@KWANGJE MOON,
그림파일의 주소는 "/files/<%= post.attachment.serverFileName %>/<%= post.attachment.originalFileName %>" 입니다!
K
KWANGJE MOON 2021.05.14
@Ian H,
선생님 제가 애초에 질문을 잘못드렸네요ㅠㅠ show 부분이 아니라 index부분에 이미지가 뜨게하고싶었습니다. <% if(post.attachment) { %>         <a href="/posts/<%= post._id %><%= getPostQueryString() %>"><img class="card-img-top" src="/files/<%=           post.attachment.serverFileName %>/<%= post.attachment.originalFileName %>"/></a> <% } %>
이런식으로 하였는데 이미지가 뜨지를 않습니다. 하지만 show부분에서는 정상적으로 뜨는것을 확인하였습니다!!
I
Ian H 2021.05.14
@KWANGJE MOON,
index view에서 해당 부분의 post(오브젝트)는 posts(배열)로 index에 전달되고, 이 posts는 index route에서 
posts = await Post.aggregate(   ... ).exec();
를 통해 생성됩니다.
반면 show view 에서 post는 show route에서
 Promise.all([       ...     ])     .then(([post, comments]) => {       ...     })...
를 통해 생성됩니다.
https://www.a-mean-blog.com/ko/blog/단편강좌/_/node-js-디버깅-방법 을 사용해서 view의 posts, show의 post 생성 직후에 각각 객체의 값들을 확인하면,  show의 post 오브젝트는 post.attachment.serverFileName, post.attachment.originalFileName 가 존재하는 반면, index의 posts 배열 속 post 오브젝트에는 존재하지 않는 것을 볼 수 있죠.
index의 post 오브젝트에는 post.attachment.serverFileName, post.attachment.originalFileName가 존재하지 않으므로 당연히 <img class="card-img-top" src="/files/<%= post.attachment.serverFileName %>/<%= post.attachment.originalFileName %>"/> 를 사용하면 그림이 보이지 않습니다.
과제를 하나 내어드릴게요.
index route의 
posts = await Post.aggregate(   ... ).exec();
이 부분을 수정해서 post.attachment.serverFileName, post.attachment.originalFileName가 나오도록 직접 한번 수정해 보세요^^
한번 해보시고 안되면 댓글 남겨주세요!
K
KWANGJE MOON 2021.05.15
@Ian H,
과제까지 내주시다니 감사합니다 선생님ㅋㅋㅋㅋ 선생님 블로그는 신세계네요!! 항상 감사드립니다!! posts = await Post.aggregate([         Post.findOne({ _id: req.params.id }).populate({ path: 'author', select: 'username' }).populate({ path: 'attachment', match: { isDeleted: false } }), ...
한번 수정을 해보았습니다. 근데 오류가 뜨네요ㅠㅠ
I
Ian H 2021.05.15
@KWANGJE MOON,
지금 Model의 aggregate 함수에 대한 이해가 전혀 없으신데,  1. https://www.a-mean-blog.com/ko/blog/Node-JS-첫걸음/게시판-만들기-고급/게시판-댓글-기능-만들기-4-댓글-수-표시 강의에서 aggregate에 관한 부분을 한번 더 읽어 보시고, 2. https://www.a-mean-blog.com/ko/blog/Node-JS-첫걸음/게시판-만들기-고급/게시판-파일첨부-기능-만들기-3-리스트에-아이콘-추가 에서 Post.aggregate의 구조를 완벽하게 이해하신 후 다시한번 도전해 보세요.
그래도 잘 모르시겠으면 그냥 정답알려드릴게요^^; 제가 답을 안알려드릴려는게 아니라, 직접 고민을 해봐야 실력이 늘게 됩니다!
K
KWANGJE MOON 2021.05.16
@Ian H,
선생님 100% 공감합니다!! aggregate함수 이해후에 한번 다시 도전해보겠습니다. 감사합니다!!
K
KWANGJE MOON 2021.05.16
@Ian H,
aggregate개념을 보니 전보다는 확실히 이해가 되는것같습니다.
posts = await Post.aggregate([       { $match: {_id:req.params.id}},    //추가       { $lookup: {           from: 'users',           localField: 'author',           foreignField: '_id',           as: 'author'       } },       { $unwind: {         path: '$author',      //추가         select: '$username'   //추가       } },       { $sort : { createdAt: -1 } },       { $skip: skip },       { $limit: limit },       { $lookup: {           from: 'comments',           localField: '_id',           foreignField: 'post',           as: 'comments'       } },       { $lookup: {           from: 'files',           localField: 'attachment',           foreignField: '_id',           as: 'attachment'       } },       { $unwind: {         path: '$attachment',         match: {isDeleted:false},          //추가         preserveNullAndEmptyArrays: true       } },       { $project: {         title: 1,         price: 1,         author: {           username: 1,         },         views: 1,         numId: 1,         attachment: { $cond: [{$and: ['$attachment', {$not: '$attachment.isDeleted'}]}, true, false] },         createdAt: 1,         commentCount: { $size: '$comments'}       } },     ]).exec();
조언해주시면 감사하겠습니다ㅠㅠ
I
Ian H 2021.05.17
@KWANGJE MOON,
1. { $match: {_id:req.params.id}}  현재 index페이지를 작업하고 계신데 이렇게 바꿔버리면 게시판리스트에 게시물이 하나밖에 안보이겠죠? 이부분은 테스트를 하기 위해 이렇게 바꾸신거 같은데 혹시나 해서 알려드립니다.
2. { $unwind: {         path: '$author',      //추가         select: '$username'   //추가       } },      ...       { $unwind: {         path: '$attachment',         match: {isDeleted:false},          //추가         preserveNullAndEmptyArrays: true       } }, unwind에 select, match을 추가하셨는데, 사실 unwind는 select 항목이나 match 항목이 없습니다. 어떤 항목들을 쓸 수 있는지는 공식 문서를 읽어보면 알 수 있어요. 구글에 'mongodb aggregate $unwind'와 같이 검색하시면 mongodb.com의 '$unwind (aggregation) — MongoDB Manual' 문서가 가장 먼저 뜨는데, 이처럼 mongodb.com의 문서이고 MongoDB Manual가 들어간 문서가 mongo db 공식 설명서 입니다. 이 문서를 보시면 사용할 수 있는 항목은 path: <field path>, includeArrayIndex: <string>,  preserveNullAndEmptyArrays: <boolean> 요 세가지 뿐입니다. 각각의 항목이 무슨일을 수행하는지도 명확히 설명되어 있죠. 이처럼 공식 문서 찾고 읽는 연습도 프로그래머에게 아주 중요합니다. 
현재 올바른 방향으로 가고 계시고, 혼자서 할 수 있을 것이라고 생각되서 한번 더 숙제를 내드릴게요.
먼저  aggregate의 첫번째 명령인 $match만 남기고 나머지를 다 지운 후 post[0]의 값을 확인합니다. 아래와 같이 코드가 바뀌겠죠
    posts = await Post.aggregate([       { $match: searchQuery },     ]).exec();     console.log(post[0]); // 혹은 https://www.a-mean-blog.com/ko/blog/단편강좌/_/node-js-디버깅-방법 으로 여기에 break point 생성 후 post[0]값 확인
다음으로 aggregate의 두번째 명령어까지 남기고 post[0]의 값을 다시 확인합니다.     posts = await Post.aggregate([       { $match: searchQuery },       { $lookup: {           from: 'users',           localField: 'author',           foreignField: '_id',           as: 'author'       } },     ]).exec();    console.log(post[0]); // 혹은 https://www.a-mean-blog.com/ko/blog/단편강좌/_/node-js-디버깅-방법 으로 여기에 break point 생성 후 post[0]값 확인
이런식으로 각각의 aggregate 명령어가 post를 어떤 식으로 변경하는지를 확인해 보세요. 참고로 { $match: searchQuery }, { $sort : { createdAt: -1 } }, { $skip: skip }, { $limit: limit } 은 post 오브젝트를 변환하는게 아니라 posts 배열 속 post 오브젝트들을 정렬하거나 선택하는 명령어로 post 오브젝트 자체는 바뀌지 않습니다. 나머지 명령어들은 post 오브젝트의 형태나 값이 반드시 한군데 이상 변합니다. 잘 살펴보세요.
이러한 방법으로 각각의 명령어가 post를 정확히 어떤식으로 바꾸는지 확인하시면서 최종적으로는 KWANGJE MOON 님께서 필요하신 post.attachment.serverFileName, post.attachment.originalFileName 를 표시할 수 있게 코드를 변경해 보시기 바랍니다. 할 수 있어요^^ 화이팅!
K
KWANGJE MOON 2021.05.18
@Ian H,
선생님 드디어 해결되었습니다!!! 선생님 말씀대로 console로 하나씩 찍어보니까 훨씬 수월했네요ㅠㅠ 과제 내주신 덕분에 실력도 향상 된거같습니다. 항상 감사드립니다!!!
K
KWANGJE MOON 2021.05.18
@Ian H,
혹시나해서 제가 구현한 코드 보여드리겠습니다. posts = await Post.aggregate([       { $match: searchQuery },       { $lookup: {         from: 'users',         localField: 'author',         foreignField: '_id',         as: 'author'       }       },       { $unwind: '$author'},       { $sort : { createdAt: -1 } },       { $skip: skip },       { $limit: limit },       { $lookup: {         from: 'files',         localField: 'attachment',         foreignField: '_id',         as: 'attachment'       }       },       { $unwind: {         path: '$attachment',         preserveNullAndEmptyArrays: true       }       },     ]).exec();   }
이렇게 하니 오류없이 이미지도 정상적으로 업로드 되었습니다!
I
Ian H 2021.05.18
@KWANGJE MOON,
와 축하드려요^^ 수고하셨습니다.
한가지 추가하자면, $project 를 완전히 빼버리셨는데 그럼 post.commentCount가 생성되지 않습니다.  현재 목표는 그림의 출력이니 꼭 필요한 부분은 아지미나 마지막으로 $project를 수정해서 post.commentCount 도 나오고 이미지도 나오도록 수정해 보세요. 이것까지 혼자서 할 수 있으면 aggregate의 기초는 익혔다고 할 수 있습니다^^ $project는 굉장히 자주 쓰이거든요
K
KWANGJE MOON 2021.05.19
@Ian H,
넵 감사합니다!
댓글쓰기

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

UP