백엔드 노드 서버 구축하기
버전이 다르기 때문에 소스가 강좌와 다를 수 있다.
버전
next: 13.0.4
antd: 5.0.1
소스 : https://github.dev/braverokmc79/node-bird-sns
제로초 소스 : https://github.com/ZeroCho/react-nodebird
59. 이미지 업로드를 위한 multer
강의 :
이미지 업로드를 위한 multer
nodejs 에 백엔드에 multer 라이브러리를 추가해야 멀티 파일 업로드 가능
$ npm i multer
백엔드
routes/post.js
const { json } = require('body-parser'); const express = require('express'); const { Post, User, Image, Comment } = require('../models'); const { isLoggedIn, isNotLoggedIn } = require('./middlewares'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const router = express.Router(); try { //업로드 폴더가 존재하는 지 확인 없으면 에러 fs.accessSync('uploads'); } catch (error) { console.log("업로드 폴더가 없으므로 생성합니다."); fs.mkdirSync('uploads'); } //multer 은 개별적으로 함수로 미들웨어 처리 const upload = multer({ storage: multer.diskStorage({ destination(req, file, done) { done(null, 'uploads'); }, filename(req, file, done) { //제로초.png //path는 노드에서 기본적으로 제공 const ext = path.extname(file.originalname); //확장자 추출(.png) const basename = path.basename(file.originalname, ext);//제로초라는 이름만 추출 된다. done(null, basename + new Date().getTime() + ext); //제로초3213421312.png } }), limits: { fileSize: 20 * 1024 * 1024 } //20MB }); //이미지 업로드 // 하나만 올릴경우 => upload.single('image') , text나 json : upload.none() router.post('/images', isLoggedIn, upload.array('image'), async (req, res, next) => { //POST /post/images console.log(req.files); res.json(req.files.map((v) => v.filename)); }); ~
프론트엔드
components/PostForm.js
import React, { useCallback, useEffect, useRef } from 'react'; import { Form, Input, Button } from 'antd'; import { useSelector, useDispatch } from 'react-redux'; import { addPost, UPLOAD_IMAGES_REQUEST } from '../reducers/post'; import useInput from '../hooks/useInput'; const PostForm = () => { const { imagePaths, addPostDone, addPostLoading, addPostError } = useSelector((state) => state.post); const dispatch = useDispatch(); const imageInput = useRef(); const [text, onChangeText, setText] = useInput(''); useEffect(() => { if (addPostDone) { setText(''); } }, [addPostDone]) useEffect(() => { if (addPostError) { alert(addPostError); } }, [addPostError]) const onSubmit = useCallback(() => { dispatch(addPost(text)); setText(""); }, [text]); const onClickImageUpload = useCallback(() => { imageInput.current.click(); }, [imageInput.current]); const onChangeImages = useCallback((e) => { console.log('images', e.target.files); const imagesFormData = new FormData(); //e.target.files 이 forEach 메서드가 없기 때문에 배열의 [].forEach.call를 빌려써서 사용한다. [].forEach.call(e.target.files, (f) => { imagesFormData.append('image', f); }); dispatch({ type: UPLOAD_IMAGES_REQUEST, data: imagesFormData }); }, []); return ( <Form style={{ margin: '10px 0 20px' }} encType="multipart/form-data" onFinish={onSubmit}> <Input.TextArea value={text} onChange={onChangeText} maxLength={140} placeholder="어떤 신기한 일이 있었나요?" /> <div className='mt-5'> <input type="file" name="image" multiple hidden ref={imageInput} onChange={onChangeImages} style={{ display: "none" }} /> <Button onClick={onClickImageUpload}>이미지 업로드</Button> <Button type="primary" htmlType='submit' style={{ float: 'right' }} loading={addPostLoading} >글작성</Button> </div> <div> { imagePaths.map((v) => ( <div key={v} style={{ display: "inline-block" }}> <img src={v} style={{ width: '200px' }} alt={v} /> <div> <Button>제거</Button> </div> </div> )) } </div> </Form> ); }; export default PostForm;
reducers/post.js
import shortid from 'shortid'; import produce from 'immer'; import { faker } from '@faker-js/faker'; faker.seed(123); export const initialState = { ~ uploadImagesLoading: false, uploadImagesDone: false, uploadImagesError: null } ~ export const UPLOAD_IMAGES_REQUEST = 'UPLOAD_IMAGES_REQUEST'; export const UPLOAD_IMAGES_SUCCESS = 'UPLOAD_IMAGES_SUCCESS'; export const UPLOAD_IMAGES_FAILURE = 'UPLOAD_IMAGES_FAILURE'; ~ //이전 상태를 액션을 통해 다음 상태로 만들어내는 함수(불변성은 지키면서) const reducer = (state = initialState, action) => produce(state, (draft) => { switch (action.type) { //이미지 업로드 case UPLOAD_IMAGES_REQUEST: draft.uploadImagesLoading = true; draft.uploadImagesDone = false; draft.uploadImagesError = null; break; case UPLOAD_IMAGES_SUCCESS: { draft.imagePaths = action.data; draft.uploadImagesLoading = false; draft.uploadImagesDone = true; break; } case UPLOAD_IMAGES_FAILURE: draft.uploadImagesLoading = false; draft.uploadImagesError = action.error; break; ~ }); export default reducer;
saga/post.js
import { all, fork, put, throttle, delay, takeLatest, call } from 'redux-saga/effects'; import axios from 'axios'; import { ADD_POST_REQUEST, ADD_POST_SUCCESS, ADD_POST_FAILURE, ADD_COMMENT_REQUEST, ADD_COMMENT_SUCCESS, ADD_COMMENT_FAILURE, REMOVE_POST_REQUEST, REMOVE_POST_SUCCESS, REMOVE_POST_FAILURE, LOAD_POSTS_REQUEST, LOAD_POSTS_SUCCESS, LOAD_POSTS_FAILURE, generateDummyPost, LIKE_POST_REQUEST, LIKE_POST_SUCCESS, LIKE_POST_FAILURE, UNLIKE_POST_REQUEST, UNLIKE_POST_SUCCESS, UNLIKE_POST_FAILURE, UPLOAD_IMAGES_REQUEST, UPLOAD_IMAGES_SUCCESS, UPLOAD_IMAGES_FAILURE } from '../reducers/post' import { ADD_POST_TO_ME, REMOVE_POST_OF_ME } from '../reducers/user'; //이미지 업로드 function uploadImagesAPI(data) { //form data 를 json 형식으로 감싸면 안된다. {name:data} return axios.post(`/post/images`, data); } function* uploadImages(action) { try { const result = yield call(uploadImagesAPI, action.data); yield put({ type: UPLOAD_IMAGES_SUCCESS, data: result.data }); } catch (err) { console.log(err); yield put({ type: UPLOAD_IMAGES_FAILURE, error: err.response.data }); } } function* watchUploadImages() { yield takeLatest(UPLOAD_IMAGES_REQUEST, uploadImages); } ~ export default function* postSaga() { yield all([ fork(watchUploadImages), fork(watchAddPost), fork(watchAddComment), fork(watchRemovePost), fork(watchLoadPosts), fork(watchLikePost), fork(watchUnlikePost) ]); }
60. express.static 미들웨어
강의 :
백엔드
static 설정
app.js
~ const path = require('path'); //express 에 static 함수가 존재 path.join 을 하면 운영체제 상관없이 경로설정을 잡아준다. app.use('/', express.static(path.join(__dirname, 'uploads'))); ~
routes/post.js
const { json } = require('body-parser'); const express = require('express'); const { Post, User, Image, Comment } = require('../models'); const { isLoggedIn, isNotLoggedIn } = require('./middlewares'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const router = express.Router(); try { //업로드 폴더가 존재하는 지 확인 없으면 에러 fs.accessSync('uploads'); } catch (error) { console.log("업로드 폴더가 없으므로 생성합니다."); fs.mkdirSync('uploads'); } //multer 은 개별적으로 함수로 미들웨어 처리 const upload = multer({ storage: multer.diskStorage({ destination(req, file, done) { done(null, 'uploads'); }, filename(req, file, done) { //제로초.png //path는 노드에서 기본적으로 제공 const ext = path.extname(file.originalname); //확장자 추출(.png) const basename = path.basename(file.originalname, ext);//제로초라는 이름만 추출 된다. done(null, basename + '_' + new Date().getTime() + ext); //제로초3213421312.png } }), limits: { fileSize: 20 * 1024 * 1024 } //20MB }); //이미지 업로드 // 하나만 올릴경우 => upload.single('image') , text나 json : upload.none() router.post('/images', isLoggedIn, upload.array('image'), async (req, res, next) => { //POST /post/images console.log(req.files); res.json(req.files.map((v) => v.filename)); }); //** passport 특성상 로그인 하면, 라우터 접근시 항상 deserializeUser 실행해서 req.user 를 만든다. req.user.id로 접근해서 정보를 가져올 수 있다. //POST /post router.post('/', isLoggedIn, upload.none(), async (req, res, next) => { try { const post = await Post.create({ content: req.body.content, UserId: req.user.id }); if (req.body.image) { //1.업로드 이미지가 여러개인경우 => image : [제로초.png, 부기초.png] if (Array.isArray(req.body.image)) { //await Promise.all ~ map ~ Image.creat 처리하면 이미지들의 URL 배열 데이터들이 DB 에 저장 된다. const images = await Promise.all(req.body.image.map((image) => Image.create({ src: image }))); //Image 테이블의 PostId 컬럼값이 업데이트 처리된다. await post.addImages(images); } else { //2. 업로드 이미지가 하나인 경우 => image : 제로초.png const image = await Image.create({ src: req.body.image }); await post.addImages(image); } } const fullPost = await Post.findOne({ where: { id: post.id }, include: [{ model: Image, }, { model: Comment, include: [{ model: User, //댓글 작성자 attributes: ['id', 'nickname'] }] }, { model: User, //게시글 작성자 attributes: ['id', 'nickname'] }, { model: User, //좋아요 누른 사람 as: 'Likers', attributes: ['id'] } ] }) res.status(201).json(fullPost); } catch (error) { console.error(" Post 에러 : ", error); next(error); } }); ~
프론트엔드
components/PostForm.js
import React, { useCallback, useEffect, useRef } from 'react'; import { Form, Input, Button } from 'antd'; import { useSelector, useDispatch } from 'react-redux'; import { addPost, ADD_POST_REQUEST, UPLOAD_IMAGES_REQUEST, REMOVE_IMAGE } from '../reducers/post'; import useInput from '../hooks/useInput'; const PostForm = () => { const { imagePaths, addPostDone, addPostLoading, addPostError } = useSelector((state) => state.post); const dispatch = useDispatch(); const imageInput = useRef(); const [text, onChangeText, setText] = useInput(''); useEffect(() => { if (addPostDone) { setText(''); } }, [addPostDone]) useEffect(() => { if (addPostError) { alert(addPostError); } }, [addPostError]) const onSubmit = useCallback(() => { if (!text || !text.trim()) { return alert('게시글을 작성하세요.'); } //이미지 주소까지 같이 업로드 const formData = new FormData(); imagePaths.forEach((p) => { formData.append('image', p); }); formData.append('content', text); //현재 이미지가 아니라 이미지주소라 formData 를 사용하지 않아도 되나 현재 nodejs 에서 //upload.none() 사용하기 위해 FormData 데이터 전송 처리 dispatch({ type: ADD_POST_REQUEST, data: formData }); setText(""); }, [text, imagePaths]); const onClickImageUpload = useCallback(() => { imageInput.current.click(); }, [imageInput.current]); const onChangeImages = useCallback((e) => { console.log('images', e.target.files); const imagesFormData = new FormData(); //e.target.files 이 forEach 메서드가 없기 때문에 배열의 [].forEach.call를 빌려써서 사용한다. [].forEach.call(e.target.files, (f) => { imagesFormData.append('image', f); }); dispatch({ type: UPLOAD_IMAGES_REQUEST, data: imagesFormData }); }, []); //리스트 배열에서 인덱스값 파라미터 를 가져오려면 고차함수 사용 const onRemoveImage = useCallback((index) => () => { dispatch({ type: REMOVE_IMAGE, data: index }) }); return ( <Form style={{ margin: '10px 0 20px' }} encType="multipart/form-data" onFinish={onSubmit}> <Input.TextArea value={text} onChange={onChangeText} maxLength={140} placeholder="어떤 신기한 일이 있었나요?" /> <div className='mt-5'> <input type="file" name="image" multiple hidden ref={imageInput} onChange={onChangeImages} style={{ display: "none" }} /> <Button onClick={onClickImageUpload}>이미지 업로드</Button> <Button type="primary" htmlType='submit' style={{ float: 'right' }} loading={addPostLoading} >글작성</Button> </div> <div> { imagePaths.map((v, i) => ( <div key={v} style={{ display: "inline-block" }}> <img src={`http://localhost:3065/${v}`} style={{ width: '200px', marginRight: '5px', height: '125px' }} alt={v} /> <div> <Button onClick={onRemoveImage(i)}>제거</Button> </div> </div> )) } </div> </Form> ); }; export default PostForm;
reducers/post.js
~ export const REMOVE_IMAGE = 'REMOVE_IMAGE'; ~ //이전 상태를 액션을 통해 다음 상태로 만들어내는 함수(불변성은 지키면서) const reducer = (state = initialState, action) => produce(state, (draft) => { switch (action.type) { //이미지는 서버에서 삭제처리 안해서 다음과 같이 프론트에서만 이미지 제거 처리 case REMOVE_IMAGE: draft.imagePaths = draft.imagePaths.filter((v, i) => i != action.data); break; ~
61.해시태그 등록하기
백엔드
routes/post.js
~ //** passport 특성상 로그인 하면, 라우터 접근시 항상 deserializeUser 실행해서 req.user 를 만든다. req.user.id로 접근해서 정보를 가져올 수 있다. //POST /post router.post('/', isLoggedIn, upload.none(), async (req, res, next) => { try { const hashtags = req.body.content.match(/#[^\s#]+/g); const post = await Post.create({ content: req.body.content, UserId: req.user.id }); if (hashtags) { //findOneCreate => 있으면 가져오고 없으면 등록처리한다. const result = await Promise.all(hashtags.map((tag) => Hashtag.findOrCreate( { where: { name: tag.slice(1).toLowerCase() } } ))); //result 값 예 => +[[노드, true] , [리액트, true]] await post.addHashtag(result.map((v) => v[0])); } if (req.body.image) { //1.업로드 이미지가 여러개인경우 => image : [제로초.png, 부기초.png] if (Array.isArray(req.body.image)) { //await Promise.all ~ map ~ Image.creat 처리하면 이미지들의 URL 배열 데이터들이 DB 에 저장 된다. const images = await Promise.all(req.body.image.map((image) => Image.create({ src: image }))); //Image 테이블의 PostId 컬럼값이 업데이트 처리된다. await post.addImages(images); } else { //2. 업로드 이미지가 하나인 경우 => image : 제로초.png const image = await Image.create({ src: req.body.image }); await post.addImages(image); } } const fullPost = await Post.findOne({ where: { id: post.id }, include: [{ model: Image, }, { model: Comment, include: [{ model: User, //댓글 작성자 attributes: ['id', 'nickname'] }] }, { model: User, //게시글 작성자 attributes: ['id', 'nickname'] }, { model: User, //좋아요 누른 사람 as: 'Likers', attributes: ['id'] } ] }) res.status(201).json(fullPost); } catch (error) { console.error(" Post 에러 : ", error); next(error); } }); ~
댓글 ( 4)
댓글 남기기