Node.js에서 EJS 이미지 파일 업로드 및 삭제 구현

dyeon-dev·2025년 8월 20일
post-thumbnail

클라이언트에서 이미지 업로드하기

html에서 이미지를 업로드하기 위해서는 input 태그의 type="file"을 사용하면 된다.
그리고 addEventListener로 이벤트 변경이 일어나는걸 감지해서 img 태그의 profileImage.src에 해당 파일을 삽입한다.

<form id="mypageForm" class="mypage-form">
  <div class="form-image">
    <img src="/images/profile.jpg" alt="프로필 이미지" id="profileImage" name="profileImage">
    <input type="file" id="input-file" accept="image/*">
    <label for="input-file">수정</label>
  </div>
</form>

<script>
  let profileImage = document.getElementById('profileImage');
  let inputFile = document.getElementById('input-file');

  inputFile.addEventListener('change', function(e) {
      const file = e.target.files[0];
      if (file) {
          profileImage.src = URL.createObjectURL(file);
      }
  })
</script>

그럼 이미지 파일을 선택하고 업로드가 된다. 그런데 이 파일을 서버에 POST 요청을 하면 문제가 생기고 DB에 저장할 수도 없다! 파일을 무작정 body에 담아서 POST 요청을 보내면 서버는 파일을 가져오지 못한다.

해결방법: multer 미들웨어 사용

일반적으로 클라이언트에서 서버로 폼 데이터(form data)가 전송될 때 해당 데이터는 인코딩되어 전송된다. 이때 파일을 전송하려면 form 태그의 enctype 속성을 "multipart/form-data"로 바꿔줘야 한다. enctype 속성은 입력된 데이터가 서버에 전송될 때 인코딩되는 방식을 결정한다.
디폴트는 application/x-www-form-urlencoded인데, 이 경우 모든 문자를 인코딩하여 전송하게 되며, 위에서 언급한 multipart/form-data는 모든 문자를 인코딩하지 않고 전송한다.
mutler가 바로 이 multipart/form-data를 다루기 위한 node.js의 미들웨어다.

  1. 뷰 폼을 multipart/form-data로 전송하도록 수정
<form id="mypageForm" class="login-form" enctype="multipart/form-data">
  1. multer 모듈 설치
    `npm i multer'

  2. 라우터에서 multer 미들웨어를 추가해 파일을 public/uploads에 저장

  • 가장 기본적인 형태의 multer 사용이다. destination으로 경로를 지정해주었고, filename으로 파일명을 지정해주었다.
  • 파일명 생성 로직을 UUID 기반의 안전한 규칙으로 구현했다.
  • 이때 mime type 설정을 해주면 원하는 타입만 필터링이 가능하다. image 파일만 업로드가 가능하도록 필터를 추가하여 다른 파일은 거부되도록 했다.
  • 파일사이즈는 최대 5MB로 제한되게 하여 비용/저장 공간을 관리했다.
  • 이로인해 전송받은 파일은 node.js 내부의 uploads 디렉터리에 저장될 것이다.
const express = require('express')
const router = express.Router()
const multer = require('multer')
const path = require('path')
const fs = require('fs')

// 업로드 스토리지 설정 (public/uploads)
const storage = multer.diskStorage({
	destination: (req, file, cb) => {
		const uploadPath = path.join(__dirname, '..', 'public', 'uploads')
		fs.mkdir(uploadPath, { recursive: true }, (err) => cb(err, uploadPath))
	},
	filename: (req, file, cb) => {
		const ext = path.extname(file.originalname).toLowerCase()
		const unique = (crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex'))
		cb(null, `${unique}${ext}`)
	},
})

// 이미지 파일만 허용
const fileFilter = (req, file, cb) => {
	if (file.mimetype && file.mimetype.startsWith('image/')) return cb(null, true)
	cb(new Error('이미지 파일만 업로드할 수 있습니다'))
}
const upload = multer({ storage, fileFilter, limits: { fileSize: 5 * 1024 * 1024 } })
  1. 컨트롤러에서 req.file을 읽어 profileImage 경로(/uploads/파일명)를 DB에 저장
// POST /mypage
const postMypage = async (req, res) => {
	try {
		const { nickname, password } = req.body
		const username = res.locals.user.username

		const fieldsToUpdate = {}
		// 파일 업로드가 있는 경우 public 상대 경로로 저장
		if (req.file) {
			const relativePath = path.posix.join('/uploads', req.file.filename)
			fieldsToUpdate.profileImage = relativePath
		}
    }
}

이미지 삭제하기

  • 삭제 버튼을 만들고 클릭하면 profileImage.src에 기본 이미지를 할당하고 inputFile.value = ''처리를 해준다.
  • 이렇게까지만 하면 서버로 삭제의사가 전달되지 않아 DB에 profileImage가 그대로 남아있다.

해결방법: 삭제 플래그 설정

이미지를 제거하면 그 결과를 서버에게 알려줘야한다. 그런데 직접적으로 알릴 방법이 없다. 그래서 삭제 버튼을 누르면 변경되는 값(0,1)을 통해 서버가 감지해서 기존 파일을 삭제하고 DB에서 삭제가 이뤄지도록 구현했다.

  • 뷰에 숨은 필드 removeProfileImage를 추가해, 삭제 버튼 클릭 시 서버로 값이 1로 전송되도록 한다.
  • 컨트롤러에서 removeProfileImage === '1'이면 기존 파일을 삭제하고(fs.unlink), DB의 profileImage를 null로 업데이트한다.
  • 새 파일이 업로드되면 그 경로가 우선 적용된다.
  • 이렇게 하면 프로필 삭제 후 저장 → 다시 들어가면 기본 이미지로 보인다.
<div class="form-image-buttons">
  <label for="input-file">수정</label>
  <label id="delete-image">삭제</label>
</div>

<input type="hidden" id="removeProfileImage" name="removeProfileImage" value="0">

                  
<script>
  let profileImage = document.getElementById('profileImage');
  let inputFile = document.getElementById('input-file');

  inputFile.addEventListener('change', function(e) {
      const file = e.target.files[0];
      if (file) {
          profileImage.src = URL.createObjectURL(file);
          // 파일을 새로 선택하면 removeProfileImage를 0으로 되돌린다.
          document.getElementById('removeProfileImage').value = '0'
      }
  })

  let deleteImage = document.getElementById('delete-image');
  deleteImage.addEventListener('click', function(e) {
    profileImage.src = '/images/profile.jpg';
    inputFile.value = ''
    document.getElementById('removeProfileImage').value = '1'
  })
</script>
// POST /mypage
const postMypage = async (req, res) => {
	try {
		const { nickname, password, removeProfileImage } = req.body
		const username = res.locals.user.username

		const fieldsToUpdate = {}
		// 삭제 플래그가 있으면 이미지 제거
		if (removeProfileImage === '1') {
			// 기존 파일 삭제 시도
			const current = await findUserByUsername(username)
			if (current && current.profileImage) {
				const absolute = path.join(__dirname, '..', 'public', current.profileImage)
				fs.unlink(absolute, () => {})
			}
			fieldsToUpdate.profileImage = null
		}
		// 새 파일 업로드가 있는 경우 설정 (삭제 플래그보다 우선)
		if (req.file) {
			const relativePath = path.posix.join('/uploads', req.file.filename)
			fieldsToUpdate.profileImage = relativePath
		}
    }
}

0개의 댓글