Multer로 프로필 이미지 업로드 구현하기

hoon·2023년 7월 17일
0
post-thumbnail

1. Multer를 사용한 프로필 이미지 업로드 시스템 설정

이 프로젝트에서 회원가입이 성공적으로 완료되었다면 사용자가 자신의 프로필 이미지를 업로드 할 수 있다. 이를 구현하기 위해, Node.js 환경에서 파일 업로드를 다루는 데 매우 유용한 라이브러리인 Multer를 사용해 보자.

Multer는 multipart/form-data를 처리하기 위한 node.js의 미들웨어로, 이는 주로 사용자가 웹페이지를 통해 서버에 파일을 업로드할 때 사용된다.

다음은 Multer의 기본 설정이 담긴 multerConfig.js 파일로, Multer를 이용해 사용자가 업로드한 이미지를 서버에 저장하는 설정을 하는 모듈이다.

// multerConfig.js

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

// 파일을 저장할 디렉토리 설정
const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    let uploadPath;
    if (file.fieldname === "profileImage") {
      uploadPath = "uploads/profile"; // 프로필 이미지 저장 경로
    } else if (file.fieldname === "cafeImage") {
      uploadPath = "uploads/cafe"; // 카페 이미지 저장 경로
    } else {
      uploadPath = "uploads/default"; // 기본 이미지 저장 경로
    }
    cb(null, uploadPath);
  },

  filename: function (req, file, cb) {
    cb(null, Date.now() + path.extname(file.originalname)); // 파일 이름 설정
  },
});

// 이미지 파일 확인
const fileFilter = (req, file, cb) => {
  const allowedTypes = ["image/jpeg", "image/jpg", "image/png"];

  if (!allowedTypes.includes(file.mimetype)) {
    const error = new Error("허용되지 않는 파일 형식입니다");
    error.code = "INCORRECT_FILETYPE";
    return cb(error, false);
  }

  cb(null, true);
};

const upload = multer({
  storage: storage,
  fileFilter,
  limits: {
    fileSize: 10000000, // 파일 사이즈 10MB로 제한
  },
});

module.exports = upload;

이제 구체적인 부분들을 하나씩 살펴보도록하자.

1-1. Multer와 path 모듈을 import

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

먼저 Multer와 path 모듈을 import 한다. Multer는 파일 업로드를 위한 모듈이고, path는 파일과 디렉토리 경로를 작업하는데 사용되는 Node.js의 내장 모듈이다.

1-2 파일 경로와 이름 설정

다음으로 Multer의 diskStorage 메서드를 이용해 저장될 파일의 경로(destination)와 이름(filename)을 설정한다.

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    let uploadPath;
    if (file.fieldname === "profileImage") {
      uploadPath = "uploads/profile"; // 프로필 이미지 저장 경로
    } else if (file.fieldname === "cafeImage") {
      uploadPath = "uploads/cafe"; // 카페 이미지 저장 경로
    } else {
      uploadPath = "uploads/default"; // 기본 이미지 저장 경로
    }
    cb(null, uploadPath);
  },

  filename: function (req, file, cb) {
    cb(null, Date.now() + path.extname(file.originalname)); // 파일 이름 설정
  },
});

destination에 전달되는 함수는 파일이 저장될 경로를 결정한다. 해당 프로젝트에서는 파일이 프로필 이미지인지 카페 이미지인지에 따라 다른 경로에 저장되도록 설정하였다. 그 외의 경우는 'uploads/default'라는 경로에 저장되도록 설정되어 있다.

filename에 전달되는 함수는 파일의 이름을 결정한다. 이 경우, 파일의 원래 확장자(path.extname(file.originalname))를 유지하면서, 파일 이름 앞에 현재 시간을 붙여 유일성을 보장하도록 하였다.

1-3 업로드 파일 형식 결정

const fileFilter = (req, file, cb) => {
  const allowedTypes = ["image/jpeg", "image/jpg", "image/png"];

  if (!allowedTypes.includes(file.mimetype)) {
    const error = new Error("허용되지 않는 파일 형식입니다");
    error.code = "INCORRECT_FILETYPE";
    return cb(error, false);
  }

  cb(null, true);
};

fileFilter 함수는 어떤 파일을 허용할 것인지 결정하는 역할을 한다. 허용되는 파일 형식들을 allowedTypes 배열에 지정하고, 업로드 된 파일의 형식(file.mimetype)이 이 배열에 포함되어 있지 않으면 에러를 발생시키며, 포함되어 있으면 파일 업로드를 허용한다.

1-4 파일 크기 제한

const upload = multer({
  storage: storage,
  fileFilter,
  limits: {
    fileSize: 10000000, // 파일 사이즈 10MB로 제한
  },
});

module.exports = upload;

마지막으로 multer 함수에 위에서 정의한 storage, fileFilter와 파일 크기 제한을 옵션으로 넘겨준다. 이렇게 설정된 multer 인스턴스를 upload라는 이름으로 export하여, 다른 모듈에서 이를 사용할 수 있다. 해당 프로젝트에서는 파일 크기를 10MB까지 허용하였다.

이를 통해 사용자가 업로드한 파일의 저장 경로, 파일 이름, 허용되는 파일 형식, 파일 크기 등을 쉽게 제어할 수 있다.

2. 프로필 이미지 업로드 시스템 구현

그럼 코드의 각 부분을 자세히 설명하겠습니다.

2-1 클라이언트가 프로필 이미지 정보를 제출

다음은 클라이언트가 사용자 프로필 이미지 정보를 제출하는 AddProfileImg.jsx 컴포넌트이다.

// src/components/signup/addProfileImg/AddProfileImg.jsx

import React, { useState, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';

import BaseModal from '../../common/BaseModal.jsx';
import {
  addProfileImgModalToSignupModal,
  addProfileImgModalTosignupSuccessModal,
} from '../../../store/modalSlice.js';
import { updateProfileImage } from '../../../store/authSlice.js';

import { updateUserProfileImage } from '../../../api/auth.js';

import {
  AddProfileImgModalContent,
  AddProfileModalText,
  ProfileImgLabel,
  ProfileImgInput,
  UploadProfileImgBtn,
  NoUploadProfileImgBtn,
  SubmitProfileImgBtn,
} from './AddProgileImgStyle.js';

import userProfile from '../../../../public/assets/icons/user.svg';

const AddProfileImg = () => {
  // 리덕스 스토어로부터 현재 모달의 상태 가져오기
  const isAddProfileImgModalVisible = useSelector(
    state => state.modal.isAddProfileImgModalVisible
  );
  const dispatch = useDispatch();

  // 프로필 이미지 미리보기와 이미지 업로드 여부를 관리하 상태
  const [preview, setPreview] = useState(null);
  const [isImageUploaded, setIsImageUploaded] = useState(false);

  // input 참조를 저장
  const fileInputRef = useRef(null);

  // 모달을 회원가입 모달로 전환
  const handleAddProfileImgModalToSignupModal = () => {
    dispatch(addProfileImgModalToSignupModal());
  };

  // 모달을 회원가입 성공 모달로 전환
  const handleAddProfileImgModalTosignupSuccessModal = () => {
    dispatch(addProfileImgModalTosignupSuccessModal());
  };

  // 프로필 이미지 등록을 처리
  const handleAddProfileImgSubmit = async event => {
    event.preventDefault();

    try {
      if (isImageUploaded) {
        // 이미지 파일을 formData에 추가
        const file = fileInputRef.current.files[0];
        // formData.append('profileImage', file);

        // 서버에 파일을 전송
        const response = await updateUserProfileImage(file)

        console.log(response.data);

        // 서버로부터 받은 사용자 정보로 프로필 이미지 상태 업데이트
        dispatch(updateProfileImage(response.data.profileImage));
      }

      handleAddProfileImgModalTosignupSuccessModal();
    } catch (error) {
      console.log(error);
    }
  };

  // 이미지를 선택하면 해당 이미지를 미리보기로 설정
  const handleImageChange = event => {
    event.stopPropagation();

    let reader = new FileReader();
    let file = event.target.files[0];

    reader.onloadend = () => {
      setPreview(reader.result);
      setIsImageUploaded(true);
    };

    if (file) {
      reader.readAsDataURL(file);
    }
  };

  // 파일 선택 대화상자를 열어 사용자가 이미지를 선택
  const handleChooseFile = event => {
    event.preventDefault();
    fileInputRef.current.click();
  };

  return (
    <BaseModal
      isVisible={isAddProfileImgModalVisible}
      onBack={handleAddProfileImgModalToSignupModal}
      title='프로필 생성하기'
    >
      <AddProfileImgModalContent onSubmit={handleAddProfileImgSubmit}>
        <AddProfileModalText>
          {isImageUploaded
            ? '좋아요!'
            : `프로필 이미지 또는 업로드 버튼을 클릭해서\n이미지를 업로드 하세요`}
        </AddProfileModalText>
        <ProfileImgLabel
          htmlFor='user-img'
          background={preview || userProfile.src}
        ></ProfileImgLabel>
        <ProfileImgInput
          type='file'
          id='user-img'
          name='user-img'
          onChange={handleImageChange}
          ref={fileInputRef}
        ></ProfileImgInput>

        {isImageUploaded ? (
          <>
            <SubmitProfileImgBtn onClick={handleAddProfileImgSubmit}>
              완료
            </SubmitProfileImgBtn>
            <UploadProfileImgBtn
              onClick={event => handleChooseFile(event)}
              isImageUploaded={isImageUploaded}
            >
              사진 변경하기
            </UploadProfileImgBtn>
          </>
        ) : (
          <>
            <UploadProfileImgBtn
              onClick={event => handleChooseFile(event)}
              isImageUploaded={isImageUploaded}
            >
              사진 업로드하기
            </UploadProfileImgBtn>
            <NoUploadProfileImgBtn onClick={handleAddProfileImgSubmit}>
              나중에 할게요
            </NoUploadProfileImgBtn>
          </>
        )}
      </AddProfileImgModalContent>
    </BaseModal>
  );
};

export default AddProfileImg;

2-1-1. 사용자 이미지 업로드 UI 컴포넌트 정의

먼저, 사용자가 이미지를 업로드할 수 있도록 UI를 제공하는 React 컴포넌트를 정의한다.

const AddProfileImg = () => {
  // 리덕스 스토어로부터 현재 모달의 상태 가져오기
  const isAddProfileImgModalVisible = useSelector(
    state => state.modal.isAddProfileImgModalVisible
  );
  const dispatch = useDispatch();

  // 프로필 이미지 미리보기와 이미지 업로드 여부를 관리하 상태
  const [preview, setPreview] = useState(null);
  const [isImageUploaded, setIsImageUploaded] = useState(false);

  // input 참조를 저장
  const fileInputRef = useRef(null);
  //...

isAddProfileImgModalVisible는 현재 모달이 보이는지를 확인하고, dispatch는 액션을 Redux에 전달하는 함수이다. previewisImageUploaded는 각각 이미지의 미리보기 URL과 이미지가 업로드되었는지를 관리하는 상태이며, fileInputRef는 이미지 파일을 선택하는데 사용하는 file input 요소에 대한 참조이다.

2-1-2. 프로필 이미지 파일 서버에 전송

  // 프로필 이미지 등록을 처리
  const handleAddProfileImgSubmit = async event => {
    event.preventDefault();

    try {
      if (isImageUploaded) {
        // 이미지 파일을 formData에 추가
        const file = fileInputRef.current.files[0];

        // 서버에 파일을 전송
        const response = await updateUserProfileImage(file)

        console.log(response.data);

        // 서버로부터 받은 사용자 정보로 프로필 이미지 상태 업데이트
        dispatch(updateProfileImage(response.data.profileImage));
      }

      handleAddProfileImgModalTosignupSuccessModal();
    } catch (error) {
      console.log(error);
    }
  };

handleAddProfileImgSubmit 함수는 사용자가 이미지를 선택하고 '완료' 버튼을 눌렀을 때 호출된다. 이 함수는 선택된 이미지 파일을 서버에 전송하고, 서버로부터 응답을 받아 Redux 스토어의 프로필 이미지를 업데이트한다.

2-2. 서버에서 프로필 이미지 파일 처리

서버에서는 Multer를 사용하여 클라이언트로부터 전송된 파일을 받아서 처리합니다.

exports.updateProfileImage = [
  upload.single("profileImage"),
  async (req, res, next) => {
    const { id } = req.user;

    try {
      if (!req.file) {
        return res.status(400).json({
          message: "프로필 이미지 파일이 전송되지 않았습니다.",
        });
      }

upload.single("profileImage")는 클라이언트로부터 전송받은 'profileImage'라는 이름의 파일을 받아서 처리한다.

2-3. 데이터베이스의 사용자 프로필 이미지 업데이트

다음으로 이 파일을 사용하여 데이터베이스의 사용자 프로필 이미지를 업데이트 한다.

      // 사용자 정보 업데이트
      await User.update({ profileImage: req.file.path }, { where: { id } });

      // 변경된 사용자 정보 검색
      const user = await User.findOne({ where: { id } });

      // 세션에 사용자 정보 업데이트
      req.session.user.profileImage = user.profileImage;

      // 업데이트 성공 메시지 반환
      return res.status(200).json({
        message: "프로필 이미지가 성공적으로 업데이트되었습니다.",
        profileImage: user.profileImage,
      });
    } catch (error) {
      console.error(error);
      return next(error);
    }
  },
];

이 함수는 클라이언트가 전송한 이미지 파일을 디스크에 저장한 후, 파일의 저장 경로를 데이터베이스의 사용자 프로필 이미지로 설정한다. 그 후에는 이 변경된 사용자 정보를 세션에 업데이트하고, 클라이언트에 업데이트가 성공적으로 이루어졌음을 알려준다.

이 방식을 사용하면 클라이언트가 서버에 이미지 파일을 전송하고, 서버가 이 파일을 받아서 사용자의 프로필 이미지를 업데이트하는 과정을 수행할 수 있다. 이 과정은 클라이언트가 프로필 이미지를 선택하고 업로드 버튼을 누르면 시작되며, 서버가 클라이언트로부터 이미지 파일을 받아서 저장하고 데이터베이스를 업데이트하고, 업데이트가 성공적으로 이루어졌음을 클라이언트에 알리는 방식으로 진행된다.

그런 다음, 서버는 클라이언트에게 이 이미지의 URL을 반환하고, 클라이언트는 이 URL을 사용하여 프로필 이미지를 표시한다. 이렇게 하면 사용자는 자신의 프로필 이미지를 업로드하고 업데이트할 수 있게 된다.

2-4 프로필 이미지 미리보기

마지막으로, 프론트엔드에서는 이 이미지를 미리보기하여 사용자가 업로드할 이미지를 미리 확인할 수 있도록 하는 기능을 추가하였다. 이를 위해 FileReader API를 사용하여 사용자가 선택한 이미지 파일을 읽어 들여 미리보기 이미지를 생성한다.

  // 이미지를 선택하면 해당 이미지를 미리보기로 설정
  const handleImageChange = event => {
    event.stopPropagation();

    let reader = new FileReader();
    let file = event.target.files[0];

    reader.onloadend = () => {
      setPreview(reader.result);
      setIsImageUploaded(true);
    };

    if (file) {
      reader.readAsDataURL(file);
    }
  };

handleImageChange 함수는 사용자가 이미지를 선택하면 호출되며, 이 함수는 FileReader 객체를 생성하고 이 객체를 사용하여 이미지 파일을 읽는다. 파일 읽기가 완료되면 reader.onloadend 이벤트 핸들러가 호출되어 프리뷰 이미지를 설정하고, 이미지가 업로드되었음을 나타내는 상태를 업데이트한다.

3. 테스트

이제 프로필이미지 업로드 기능 구현이 완료되었다면 테스트 해보도록 하자.

먼저 회원가입을 진행한다.

성공적으로 회원가입을 완료하면 프로필이미지 업로드 모달창이 나타난다. 사용자의 편의성을 위해서 ‘나중에 할게요’ 버튼을 클릭하거나 모달의 바깥 영역을 클릭하면 프로필 이미지를 업로드 하지 않더라도 기본 프로필 이미지로 해당 애플리케이션을 이용할 수 있도록 하였다.

‘사진 업로드하기 버튼’을 클릭하거나 프로필 이미지 미리보기 영역을 클릭하여 이미지를 업로드 할 수 있다.

‘사진 변경하기’ 버튼을 클릭하거나 프로필 이미지 미리보기 영역을 클릭하여 다른 이미지로 변경할 수 있다.

‘완료’ 버튼을 클릭하면 회원가입 완료 모달을 확인할 수 있다.

프로필 이미지 업로드가 성공적으로 완료된 것을 알 수 있다.

4. 보완할점

프로필이미지를 업로드할 때, 이미지 형식에 맞지 않는 파일을 업로드할때 적절한 에러 처리를 프론트 쪽에서 진행하지 못했다. 따라서 사용자는 어떤 부분에서 문제가 있는지 인식하지 못하는 현상이 발생하였다. 보다 세밀한 에러처리를 통해서 사용자의 편의성을 높힐 필요성을 느꼈다.

profile
프론트엔드 학습 과정을 기록하고 있습니다.

0개의 댓글