프로젝트/풀스택 프로젝트

4-6. [React + Node.js Express] 프로필 사진 업로드

찰리-누나 2022. 12. 10.

 

서버에 사진을 업로드하려면 multer 라이브러리를 이용해야 한다. 여기서 서버는 로컬 서버를 가리킨다. 로컬 서버가 아닌 클라우드에 업로드를 하려면 AWS와 같은 클라우드 서비스와 함께 다른 라이브러리를 사용해야 한다. 일단은 로컬 서버, 즉 서버를 만든 폴더에 업로드하는 것으로 진행해본다.

 


 

Frontend

 

 

input태그에 type 속성을 file로 설정했을 경우, 크롬에서의 기본 스타일은 이렇게 생겼다. 파일 선택 버튼을 기본으로 제공해주고, 파일을 업로드하면 파일의 정보가 오른쪽에 뜨는 형태이다.

<input type="file"/>

하지만 프로필 업로드를 위해 사용하기에는 너무나 못생겼다. React는 uesRef()라는 훅을 가지고 있는데, 이 훅을 이용하면 다른 DOM 요소에 접근할 수 있다. 따라서 <img> 태그와 <input> 태그를 사용하여, <img> 태그를 클릭하면 <input> 태그가 선택되도록 디자인 해 본다. 더불어 이미지 클릭으로 인한 파일 업로드를 유도할 수 있도록, 마우스 오버를 하면 img의 투명도를 0.5로 반감시킨다.

 

왼쪽 : 마우스를 올리지 않은 상태, 오른쪽 : 마우스를 올린 상태

 

 

About user.js

 

저번에 작성했던 About user.js를 수정한다. useRef를 사용하려면 import 해주어야 한다.

const imageInput = useRef();

 

먼저 이미지를 받을 곳을 디자인 해준다. 가로 세로 120px의 원모양의 <img> 태그를 만들어 <input> 태그와 연결시켜 줄 것이다. hoverOpa는 Mouse Hover 이벤트가 일어나면 img태그의 opacity(투명도)를 반감시켜주기 위해 설정한 State이다. 리액트에서 마우스 오버 이벤트는 onMouseOver를 통해 얻어올 수 있다. img태그의 onClick에 useRef로 받아온 current의 click 이벤트를 연결해주고, input에 ref로 만들어둔 ref 변수를 연결한다. onChange에 파일 업로드를 감지할 수 있는 함수를 작성해 주면 된다.

const [hoverOpa,setOpa] = useState(1)

const onClickInput = () => {
      imageInput.current.click();
      }   
    

return (
		 ...
 
 		<div classNam="imgZone">
            
              <img
              onMouseOver={()=>setOpa(0.5)}
              onMouseOut={()=>{setOpa(1)}}
              className="uploadImage"
              style={{width:'120px',height:'120px', borderRadius:'50%',border:'2px solid lightgray',
              alignItems:'center', justifyContent:'center', cursor:'pointer', opacity:hoverOpa}}
              onClick={onClickInput}
              alt='프로필 이미지'
              
              src={imageSrc? imageSrc : test}/>

            <input type="file" 
              style={{display:'none'}} 
              
              onChange={onLoadFile} 
              ref={imageInput} />
		</div>
        
        ...
    )

 

이제 img 부분을 누르면 아래와 같이 파일 업로드 창이 보여진다.

 

src={imageSrc? imageSrc : test} 는 사용자가 이미지를 업로드하지 않았을 때는 기본으로 설정되어있는 프로필 사진을 보여주고, 사용자가 이미지를 업로드했을 때에는 미리보기용 이미지를 보여준다. 로컬 서버에 이미지를 저장하는 코드는 [변경사항 저장] 텍스트 버튼을 눌러야만 반영된다. 따라서 img 태그를 눌러 이미지를 업로드 했을 때, 내가 업로드한 이미지를 미리보기 할 수 있도록 사용자가 업로드한 파일 객체를 얻어오는 부분이 필요하다. 서버에 저장하는 것이 아니기 때문에 백엔드로 보내지 않고, 프론트엔드에서 모두 처리한다.

 

React Dropzone이라는 라이브러리를 이용할 수도 있는데, 기본만으로도 충분하기 때문에 평범하게 e태그를 이용해 파일 객체를 받아온다.

  const onLoadFile = (e) => {
    
        const file = e.target.files;
        console.log('onLoadFile',file)
        setFiles(file);
        console.log('state에 저장 완료 files',files)

콘솔이 출력된 모습

 

 

파일의 이름은 testImage.png이다. files객체에는 사용자가 업로드한 파일에 대한 정보들이 들어있다. 한개의 파일만 업로드를 받을 것이고, 그 정보는 files[0]에 들어있으므로 해당 정보를 변수에 저장해준다.

   let fileBlob = e.target.files[0]

 

FileReader 객체는 웹에서 File 또는 Blob객체를 이용해 내용을 읽고, 사용자의 컴퓨터에 저장할 수 있게 해준다.

또한 File 객체는 <input> 태그를 이용하여 유저가 선택한 파일들의 결과로 반환된 FileList 객체를 얻을 수 있다.

https://developer.mozilla.org/ko/docs/Web/API/FileReader

 

FileReader - Web API | MDN

FileReader 객체는 웹 애플리케이션이 비동기적으로 데이터를 읽기 위하여 읽을 파일을 가리키는File 혹은 Blob 객체를 이용해 파일의 내용을(혹은 raw data버퍼로) 읽고 사용자의 컴퓨터에 저장하는

developer.mozilla.org

위에서 이벤트 타겟으로 얻어온 파일 객체를 FileReader.readAsDataURL에 전달해주면, 전달받은 바이너리 파일을 Base64 Encode 문자열로 변환하여 result에 담아준다.

https://developer.mozilla.org/ko/docs/Web/API/FileReader/readAsDataURL

 

FileReader.readAsDataURL() - Web API | MDN

readAsDataURL 메서드는 컨텐츠를 특정 Blob 이나 File에서 읽어 오는 역할을 합니다. 읽어오는 read 행위가 종료되는 경우에, readyState (en-US) 의 상태가 DONE이 되며, loadend (en-US) 이벤트가 트리거 됩니다.

developer.mozilla.org

 

파일리더 메서드의 행동이 성공적으로 완료되면 onload를 호출해 지정한 메소드를 실행해줄 수 있다.

FileReader.onload

 

 

따라서 과정은 다음과 같다.

1. FileReader 객체를 만든 뒤, readAsDataURL에 사용자가 업로드한 파일을 전달한다.
2. 비동기 처리를 위해 Promise를 만든다.
3. onload를 이용해 state에 사용자가 입력한 파일의 result 정보(변환된 결과)를 저장한다.

4. resolve()로 Promise를 이행 상태로 만들어준다.
  const reader = new FileReader();
          reader.readAsDataURL(fileBlob);
          return new Promise((resolve) => {
            reader.onload = () => {
              setImageSrc(reader.result);
              resolve();
            };
          });
    
      }

완성된 onLoad메소드를 input태그에 연결해주면, 사용자가 이미지를 업로드 할 때마다 즉각적으로 업로드한 이미지를 미리보기할 수 있게 해 준다.

 

 

혹시 누군가 필요할까 싶어 적은 미리보기만 완성한 프론트 전체 코드(백엔드 api 없음)

import React, { useEffect, useRef, useState } from "react";


function Previews() {

  const imageInput = useRef();  

  const[files,setFiles] = useState('')
  const [imageSrc, setImageSrc] = useState('');

  const [userImg,setImg] = useState('')

  
const onClickInput = () => {
  imageInput.current.click();
  }   


  const onLoadFile = (e) => {

    const file = e.target.files;
    console.log('onLoadFile',file)
    setFiles(file);
    console.log('state에 저장 완료 files',files)

    let fileBlob = e.target.files[0]
    
      const reader = new FileReader();
      reader.readAsDataURL(fileBlob);
      return new Promise((resolve) => {
        reader.onload = () => {
          setImageSrc(reader.result);
          resolve();
        };
      });

  }
  

  return(
    <div>
      <input type="file" 
      style={{display:'none'}} 
      
      onChange={onLoadFile} 
      ref={imageInput} />
     <img

     className="uploadImage"
     style={{width:'120px',height:'120px', borderRadius:'50%',border:'2px solid lightgray',
     alignItems:'center', justifyContent:'center', cursor:'pointer',backgroundColor:'white'}}
     onClick={onClickInput}
     
     src={imageSrc? imageSrc : ''}

     >

     </img>

    </div>
  )
}

  export default Previews;

 

 

이제 백엔드 api를 작성하고, 연결해 줄 것이다. 저번 결과물로 [변경사항 저장] 버튼을 누르면 이름, 닉네임, 비밀번호가 수정되었다. 따라서 이미지를 저장하는 api를 연결하여, 버튼을 눌렀을 때 함께 실행되도록 해주면 된다. CallUpdate 메소드를 수정해준다.

 

로컬 서버에 이미지를 저장한 후, 해당 이미지를 db에 저장해주는 순서로 처리한다.

 function CallUpdate() {

        
        let formData = new FormData
        const config = {
        header : {'content-type': 'multipart/form-data'}
        }
        // files[0]은 업로드된 파일의 정보를 담고 있다.
        formData.append("file",files[0])
        console.log(files)

 

FormData에는 form필드와 그 값을 나타내는 key/value값의 쌍을 쉽게 생성할 수 있는 방법을 제공한다. formdata.append를 통해, 새 데이터를 추가해줄 수 있다. config는 백엔드에서 multer를 이용하기 위해 전달해 주어야 하는 헤더값이다. 

https://developer.mozilla.org/ko/docs/Web/API/FormData

 

FormData - Web API | MDN

FormData 인터페이스는 form 필드와 그 값을 나타내는 일련의 key/value 쌍을 쉽게 생성할 수 있는 방법을 제공합니다. 또한 XMLHttpRequest.send() (en-US) 메서드를 사용하여 쉽게 전송할 수 있습니다. 인코딩

developer.mozilla.org

 

post를 이용해 file 정보를 전달해준다. /user/uploadImgFolder를 보내면, 백엔드는 전달받은 Data를 받아 사진을 로컬 서버에 저장하고, 결과를 반환한다. 이때 response의 url에 이미지가 저장된 경로를 담아 보내 프론트가 경로를 저장할 수 있게 해 준다. 

        axios.post('/api/user/uploadImgFolder', formData, config)
        .then(response=> {
        //성공했을 경우
        if(response.data.success) {
        console.log('사진 업로드 성공',response)
        // state에 이미지 url 저장
        let dbImg = response.data.url

 

body에 image를 담아 회원 정보 수정 api를 요청하여, db에 이미지 경로가 저장되도록 한다. redux에도 이미지 경로를 저장해준다.

        let body = {
            email:values.email,
            name:values.name,
            nikname:values.nikname,
            image:dbImg,
            password:values.ckpassword
          }
    
             axios.post('/api/user/update', body)
              .then(response=>{console.log('회원정보 수정 완료',response.data) 
            //   redux셋팅
              dispatch(GET_USER({email:values.email, name:values.name, nikname:values.nikname, role:redux.setUser.u_role,image:dbImg}))
         
         
                navigate('/')
              // eslint-disable-next-line no-restricted-globals
             location.reload()
          })
        } else {
        alert('프로필사진 업로드를 실패했습니다.')
          }
        })

      
     }

 

 

로컬 서버에 저장된 이미지를 리액트가 사용하기 위해서는, 프론트 로컬호스트 주소에 이미지가 저장된 폴더의 경로를 붙여서 사용해야 한다. 정규식을 이용해 str을 원하는 경로까지 자른 다음, localhost 주소를 붙여주는 식으로 사용했다. 결과물을 변수에 저장한 뒤 img의 src에 전달해주면 된다.

let copy = redux.setUser.u_image
str = copy.substring(14)
test = 'http://localhost:3000/'+str

 

 

 

 

Backend

 

 

 

백엔드에서 받은 이미지를 로컬 서버에 저장하기 위해서는 multer라는 라이브러리가 필요하다. npm install multer로 라이브러리를 설치해주고, import해준다.

const multer = require('multer')

 

server/routes/users.js

 

storage를 만든다. multer의 diskStorage를 이용한다. 옵션으로는 destination, filename, fileFilter이 있다. destination은 저장될 경로를, filename는 파일 이름을, fileFilter는 업로드 가능한 파일의 확장자를 셋팅한다.

 

destination은 콜백함수에 두번째 인자로 주어진 경로에 받은 파일을 저장한다. 리액트의 퍼블릭(public)폴더에 이미지를 저장하면, 오직 이미지 파일 이름만으로도 쉽게 이미지를 이용할 수 있다. 따라서 프론트의 public 폴더에 저장할 것이다.

const storage = multer.diskStorage({
    // 받아온 file을 두번째 인자로 주어진 경로에 저장한다.
    destination: (req, file, cb) => {
        cb(null, "./client/public")
    },

저장할 filename을 설정해준다.

  // 저장할 파일의 이름을 설정한다.
    filename: (req, file, cb) => {        
        cb(null, `${Date.now()}_${file.originalname}`);       
        // 또는 const fileName = file.originalname.toLowerCase().split(' ').join('-');
        // cb(null, v4() + '-' + fileName)
      // (uuidv4 O) 7c7c98c7-1d46-4305-ba3c-f2dc305e16b0-통지서
      // (uuidv4 X) 통지서
    },

프로필 이미지이기 때문에 png, jpg, jpeg파일만 가능하도록 파일 필터를 설정해 주었다.

fileFilter: (req, file, cb) => {
        // 파일 확장자를 출력하여 검사한다.
        const ext = path.extname(file.originalname)
        if(ext == "image/png" 
           || file.mimetype == "image/jpg" 
           || file.mimetype == "image/jpeg"){
            cb(null, true);
        } else {
            cb(null, false);
            return cb(new Error('Only .png .jpg and .jpeg format allowed!'));
        }
    }
});

 

multer의 single은 파일 하나의 업로드를 다루는 메소드이다. 변수를 선언해 multer에 설정한 세팅과 single을 연결해준다.

const upload = multer({storage:storage}).single("file")

 

프론트에서 api를 호출하면 받아줄 api를 작성한다. upload에 (req, res, err) 으로 받아온 req를 넣어 실제로 로컬 서버 폴더에 저장한다.

router.post('/uploadImgFolder', (req,res)=>{
    upload(req,res,err=> {
        if(err){
            return res.json({success:false, inText:'업로드 과정에서의 에러'})
        }
        return res.json({success:true,inText:"폴더에 업로드 성공", url:res.req.file.path, fileName:res.req.file.filename})
    })

})

 

 

회원 정보를 업데이트 할 때, body에 image를 담아서 보냈으므로 /update 라우터에 image파일을 추가하도록 수정해준다.

router.post('/update', function(req,res){

    // 비밀번호를 변경하지 않는 경우
    if(req.body.password === '') {
        User.findOneAndUpdate({ email:req.body.email }, {nikname:req.body.nikname, name:req.body.name, image:req.body.image}, (err, doc) => {
            if (err) return res.json({ success: false, err });
            return res.status(200).send({
                success: true, noPasswordChange:true
            });
        });
    } else {
    // 비밀번호를 변경하는 경우
        User.findOne({ email: req.body.email }, (err, user) => {
        if (!user) {
            return res.json({
                loginSuccess: false,
                message: "정보를 찾지 못했습니다."
            });} else if (user) {

                // client에서 받아온 정보로 변경할 정보 set
                user.name=req.body.name
                user.nikname=req.body.nikname
                user.image = req.body.image
                user.password=req.body.password
                
                // save 호출 위해 User set
                const userUpdate = new User(user)

                // Update 위해 save 호출
                // ========= User.js => userSchema.pre('save', function( next ) { bcrypt Password } =========

                userUpdate.save((err, userInfo) => {
                if(err) return res.json({success:false, err})
                return res.status(200).json({
                    success:true, save:'저장에 성공하였습니다.',userInfo:userInfo
                })
            })} 
        })
    }
})

 

 

결과물

 

댓글