NEXTJS 로 twitter클론 해보기(multer이용해서 이미지 업로드 하기)

LeeJaeHoon·2021년 12월 16일
0
post-thumbnail

백엔드

multer

Multer는 파일 업로드를 위해 사용되는 multipart/form-data 를 다루기 위한 node.js 의 미들웨어 입니다. 효율성을 최대화 하기 위해 busboy 를 기반으로 하고 있습니다.

설치

npm i multer

사용법

storage

디스크 스토리지 엔진은 파일을 디스크에 저장하기 위한 모든 제어 기능을 제공합니다.

limits

파일의 크기 제한에 사용됩니다.

const multer = require("multer");
const path = require("path");

const upload = multer({
  storage: multer.diskStorage({
    destination(req, file, done) {
      done(null, "uploads");
    },
    filename(req, file, done) {
      // file.originalname 파일 이름 (이재훈.png)
      const ext = path.extname(file.originalname); //확장자 추출(.png)
      const basename = path.basename(file.originalname); // 이름 추출 (이재훈)
      done(null, basename + new Date().getTime() + ext); // (이재훈213123123.png)
    },
  }),
  limits: { fieldSize: 20 * 1024 * 1024 }, // 20MB
});

destination 과 filename 의 두가지 옵션이 가능합니다. 두 옵션 모두 파일을 어디에 저장할 지를 정하는 함수입니다.

destination 옵션은 어느 폴더안에 업로드 한 파일을 저장할 지를 결정합니다. 이는 string 형태로 주어질 수 있습니다 (예. '/tmp/uploads'). 만일 destination 옵션이 주어지지 않으면, 운영체제 시스템에서 임시 파일을 저장하는 기본 디렉토리를 사용합니다.

filename 은 폴더안에 저장되는 파일 명을 결정하는데 사용됩니다. 만일 filename 이 주어지지 않는다면, 각각의 파일은 파일 확장자를 제외한 랜덤한 이름으로 지어질 것입니다.

router.post("/images", isLoggedIn, upload.array("image"), postImages);

req.files 는 image 라는 파일정보를 배열로 가지고 있습니다.

exports.postImages = (req, res, next) => {
  return res.json(req.files.map(v => v.filename));
};

프론트로 파일명을 보내줍니다.

Uploads폴더에 이미지를 넣었으니 프론트에서 해당 파일에 접근할 수 있게 해줘야합니다.

app.use("/", express.static(path.join(__dirname, "uploads")));

Upload Post

.none()

오직 텍스트 필드만 허용합니다. 만일 파일이 업로드 되었을 경우, "LIMIT_UNEXPECTED_FILE" 와 같은 에러 코드가 발생할 것입니다. 이는 upload.fields([]) 와 같은 동작을 합니다.

router.post("/", isLoggedIn, upload.none(), createPost);
exports.createPost = async (req, res, next) => {
  try {
    const { content, image } = req.body;
    const { id: UserId } = req.user;
    const post = await Post.create({
      content,
      UserId,
    });
    if (image) {
      if (Array.isArray(image)) {
        // 이미지를 여러개 올리면 image: ["이.png", "재.png"]
        const dbImages = await Promise.all(
          image.map(imagePath => Image.create({ src: imagePath }))
        );
        await post.addImages(dbImages);
      } else {
        // 이미지를 하나만 올리면 image: "이.png"
        const dbImage = await Image.create({ src: image });
        await post.addImages(dbImage);
      }
    }
		...

프론트로부터 Post의 content와 image경로를 받고 Post를 create해줍니다.

만약 image가 있고, image가 배열이라면 Promise.all을 통해 Image를 만들어주고 addImages를 통해 post와 image를 연결해줍니다.

HashTag

추가적으로 사용자가 content에 #과 같은 해쉬태그를 넣었을 떄 HashTag또한 만들어야 합니다.

exports.createPost = async (req, res, next) => {
  try {
    const { content, image } = req.body;
    const { id: UserId } = req.user;
    const hashtags = content.match(/#[^\s#]+/g);
    const post = await Post.create({
      content,
      UserId,
    });
    if (hashtags) {
      const result = await Promise.all(
        hashtags.map(hashtag =>
          Hashtag.findOrCreate({
            where: { name: hashtag.slice(1).toLowerCase() },
          })
        )
      ); // [[노드,true],[리액트,true]]
      await post.addHashtags(result.map(v => v[0]));
    }
	...

먼저 hashtags가 있는지 찾습니다. match메서드의 인자로 정규표현식을 쓰면 해당 정규표현식에 해당하는 것만 배열로 만들어 줍니다.

배열로 만든 hashtags를 findOrCreate메서드를 사용하여 db에 저장해줍니다.

findOrCreate

findOrCreate 메서드는 쿼리 옵션을 충족하는 항목을 찾을 수 없는 경우 테이블에 항목을 생성합니다

반환값이 배열인데 첫번째 인덱스에는 해당 인스턴스를, 두번째 인덱스로는 해당 인스턴스가 이미 존재했는지 여부를 Boolean으로 표시해줍니다.

마지막으로는 addHashtag메서드를 사용하여 post와 hashtag를 연결해줍니다.

프론트엔드

FormData

FromData란 ajax로 폼 전송을 가능하게 해주는 FormData 객체입니다.

페이지 전환 없이 폼 데이터를 제출 하고 싶을 때 바로 FormData 객체를 사용합니다.

append() 메소드로 key-value 값을 하나씩 추가해주면 됩니다.

만든 FromDatadispatch해줍니다.

const PostForm = () => {
  //...
  const onChangeImages = useCallback(e => {
    console.log("images", e.target.files);
    const imageFormData = new FormData();
    [].forEach.call(e.target.files, f => {
      imageFormData.append("image", f);
    });
    dispatch({
      type: UPLOAD_IMAGES_REQUEST,
      data: imageFormData,
    });
  });
  return (
    <Form
      style={{ margin: "10px 0 20px" }}
      encType="multipart/form-data"
      onFinish={onSubmitBtn}
    >
     {/* ... */
      <div>
        <input
          type="file"
          name="image"
          multiple
          hidden
          ref={imageInput}
          onChange={onChangeImages}
        />
       {/* ... */
    </Form>
  );
};

export default PostForm;

saga를 통해 formData 백엔드로 보내고 백엔드에서 받은 파일명을 담아 다시 dispatch해줍니다

function uploadImagesAPI(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.error(err);
    yield put({
      type: UPLOAD_IMAGES_FAILURE,
      error: err.response.data,
    });
  }
}

받은 파일명을 state에 넣어줍니다.

case UPLOAD_IMAGES_SUCCESS: {
	draft.imagePaths = action.data;
  draft.uploadImagesLoading = false;
  draft.uploadImagesDone = true;
  break;
}

Upload Post

현재 text가 비어 있으면 경고창이뜨게 해줍니다.

FormData로 현재 state에 있는 imagePathsappend해주고 content또한 append해줍니다.

그렇게 완성된 formData를 action data에 넣어 dispatch해줍니다.

const onSubmitBtn = useCallback(() => {
    if (!text || !text.trim()) {
      return alert("게시글을 작성하세요.");
    }
    const formData = new FormData();
    imagePaths.forEach(image => {
      formData.append("image", image);
    });
    formData.append("content", text);
    dispatch({
      type: ADD_POST_REQUEST,
      data: formData,
    });
  }, [text, imagePaths]);

0개의 댓글