React

 

 

백엔드 노드 서버 구축하기

 

버전이 다르기 때문에 소스가 강좌와 다를 수 있다.

버전

next:  13.0.4

antd:  5.0.1

 

소스 : https://github.dev/braverokmc79/node-bird-sns

 

제로초 소스 : https://github.com/ZeroCho/react-nodebird

 

 

 

 

59. 이미지 업로드를 위한 multer

강의 :

https://www.inflearn.com/course/%EB%85%B8%EB%93%9C%EB%B2%84%EB%93%9C-%EB%A6%AC%EC%95%A1%ED%8A%B8-%EB%A6%AC%EB%89%B4%EC%96%BC/unit/48846?tab=curriculum

 

 

이미지 업로드를 위한 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 미들웨어

강의 :

https://www.inflearn.com/course/%EB%85%B8%EB%93%9C%EB%B2%84%EB%93%9C-%EB%A6%AC%EC%95%A1%ED%8A%B8-%EB%A6%AC%EB%89%B4%EC%96%BC/unit/48847?tab=curriculum

 

백엔드 

 

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.해시태그 등록하기

 

강의 : https://www.inflearn.com/course/%EB%85%B8%EB%93%9C%EB%B2%84%EB%93%9C-%EB%A6%AC%EC%95%A1%ED%8A%B8-%EB%A6%AC%EB%89%B4%EC%96%BC/unit/48848?tab=curriculum

 

 

백엔드

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);
    }
});




~

 

 

 

 

 

 

 

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

지금 잘 다스려진 태평한 세상이라고 해도 어지러워지는 난세가 될 것을 잊어서는 안 된다. 그렇게 함으로써 몸이 편안할 수가 있고 국가를 보전할 수가 있는 것이다. -역경

댓글 ( 4)

댓글 남기기

작성