백엔드 노드 서버 구축하기
버전이 다르기 때문에 소스가 강좌와 다를 수 있다.
버전
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)
댓글 남기기