Express 환경에서 파일 업로드의 한계
Express는 기본적으로 파일 업로드를 자체적으로 처리하지 못한다.
일반적인 JSON 데이터나 텍스트 기반의 Form 데이터는 기본 파서로 쉽게 처리할 수 있다.
하지만 이미지나 압축 파일 같은 이진(Binary) 데이터는 multipart/form-data 형식으로 전송되기 때문에, Express 자체 기능만으로는 데이터를 해석할 수 없어 별도의 처리가 필요하다.
Multer란?
Multer는 Node.js 환경에서 파일 업로드를 처리하기 위해 사용되는 미들웨어이다.
Express와 함께 사용할 수 있으며, 파일크기제한, 파일 형식 필터링 등 조건 설정도 가능하다.
💡 Multer 핵심 기능
- 파일 저장소 설정: 업로드된 파일을 서버의 특정 폴더에 안전하게 저장하거나, 버퍼 형태로 메모리에 올려 클라우드 스토리지로 바로 전송할 수 있다.
- 파일 필터링: 확장자나 mimetype을 검사하여 허용된 형식의 파일만 받을 수 있다.
- 용량 제한: 파일 크기, 파일 개수 등을 제한할 수 있다.

동작 흐름: 프로필 이미지 기능
- 연필(수정) 버튼 클릭
- 이미지 선택
- 선택한 이미지 미리보기 + 연필(수정) 버튼이 체크(저장) 버튼으로 변경
- 체크(저장) 버튼 클릭
- 서버 업로드
- MongoDB에 이미지 경로 저장
- 다음 로그인 시 저장된 이미지 출력
각 파일별 역할
- 프론트: 파일 선택 및 전송
- Router: 요청 경로 연결
- Multer: 파일 검사 및 서버 폴더 저장
- Controller: 저장된 파일 경로 처리
- Repository: MongoDB에 경로 저장
1. 이미지 선택 (front)
사용자가 연필버튼을 클릭하면 숨겨진 파일 선택창을 열 수 있도록 생각하였다.
accept="image/*"를 사용하면 이미지 파일만 선택할 수 있는 파일 탐색기가 열린다.
<div class="PImgSec">
<!-- 클릭 시 파일 선택창 열림 -->
<button type="button" id="profileEditBtn"><i class="ri-pencil-line"></i></button>
<!-- 숨겨진 파일 선택창 -->
<input type="file" id="profileImageInput" accept="image/*" hidden>
<!-- 파일 등록 전 기본 이미지 -->
<div class="imgBox">
<img src="./images/noprofile.png" alt="기본 프로필 이미지">
</div>
</div>const profileEditBtn = document.getElementById("profileEditBtn")
const profileImageInput = document.getElementById("profileImageInput")
const profileImg = document.querySelector(".PImgSec .imgBox img")
// 이미지 저장 변수
let selectedProfileImage = null
// 이미지 수정 버튼 클릭
profileEditBtn.addEventListener("click", () => {
// 이미지가 아직 선택되지 않은 상태
if (!selectedProfileImage) {
profileImageInput.click()
return
}
// 이미지가 선택된 상태라면 서버에 저장
uploadProfileImage()
})2. 선택한 이미지 미리보기 (front)
이미지를 선택하면 바로 서버에 보내는것이 아닌 사용자가 미리 확인한 후 서버에 업로드 할 수 있는 방식으로 구현했다.
이 단계에선 아직 서버에 저장되진 않고 바뀐 체크 아이콘을 눌러야 실제 서버에 저장된다.
이미지 선택 → 적용된 미리보기 → 체크버튼 클릭 → 서버 업로드
FileReader는 선택한 이미지를 브라우저 화면에 미리 보여주기 위해 사용하였다.
실제 서버 전송은 선택된 File 객체를 FormData에 담아 처리하였다.
profileImageInput.addEventListener("change", (e) => {
const file = e.target.files[0]
if (!file) return
// 이미지 파일인지 확인
if (!file.type.startsWith("image/")) {
alert("이미지 파일만 선택할 수 있습니다.")
profileImageInput.value = ""
return
}
selectedProfileImage = file
// 선택한 이미지 미리보기
const reader = new FileReader()
reader.onload = (e) => {
profileImg.src = e.target.result
profileImg.alt = "선택한 프로필 이미지"
}
reader.readAsDataURL(file)
// 연필 아이콘을 체크 아이콘으로 변경
profileEditBtn.innerHTML = '<i class="ri-check-line"></i>'
})3. FormData로 서버에 파일 전송
일반 회원정보의 경우 JSON으로 전송이 가능했지만 이미지 파일은 FormData를 사용하여 전송해주어야 한다.
async function uploadProfileImage() {
const token = localStorage.getItem("token")
if (!token) {
alert("로그인이 필요합니다.")
location.href = "/login.html"
return
}
if (!selectedProfileImage) return
const formData = new FormData()
// 서버에서 받을 필드명
formData.append("profileImage", selectedProfileImage)
try {
profileEditBtn.disabled = true
const response = await fetch("/auth/profile-image", {
method: "PATCH",
headers: {Authorization: `Bearer ${token}`},
body: formData
})
const data = await response.json()
if (!response.ok) {
alert(data.message || "프로필 이미지 저장에 실패했습니다.")
return
}
alert("프로필 이미지가 저장되었습니다.")
// 선택 상태 초기화
selectedProfileImage = null
profileImageInput.value = ""
// 체크 아이콘을 다시 연필 아이콘으로 변경
profileEditBtn.innerHTML = '<i class="ri-pencil-line"></i>'
} catch (error) {
console.error("프로필 이미지 저장 오류:", error)
alert("프로필 이미지 저장에 실패했습니다.")
} finally {
profileEditBtn.disabled = false
}
}
💡 서버에서 받을 필드명 설정
여기서 서버에서 받을 필드명이 중요한데, 첫 번째 값인 profileImage가 서버에서 파일을 받을 때 사용할 필드명이다.
해당 필드명은 백엔드에서도 반드시 같은 이름을 사용해주어야 한다.
프론트와 백엔드의 필드명이 다를경우 req.file에 파일이 들어오지 않게된다.
// Front
formData.append("profileImage", selectedProfileImage)
// Back
uploadProfile.single("profileImage")
💡 FormData는 Content-Type을 직접 지정하지 않는다.
일반 JSON 데이터를 전송할 때는 "Content-Type": "application/json" 을 직접 지정했지만,
FormData는 브라우저가 자동으로 multipart/form-data 헤더와 boundary 값을 생성해주기 때문에 직접 지정하지 않고 인증 토큰값만 넣어주었다.
headers: {
Authorization: `Bearer ${token}`
}4.Multer 설치 ⭐
Multer는 Express에서 multipart/form-data 형식으로 전송된 파일을 처리하는 미들웨어다.
- multipart/form-data 요청 처리
- 파일 저장 위치 설정
- 저장 파일명 설정
- 파일 크기 제한
- 파일 타입 검사
npm install multerExpress는 기본적으로 JSON과 URL-encoded 데이터는 처리할 수 있지만 파일 데이터는 직접 처리하지 못하기 때문에 Multer를 사용한다.
5. Multer 미들웨어 추가
우선 middleware/profile_upload.mjs 파일을 만들어 Multer전용 미들웨어를 따로 관리해주었다.
import multer from "multer"
import path from "path"
import fs from "fs"
// 현재 실행 중인 프로젝트 기준 절대 경로 생성
const uploadPath = path.resolve("public", "uploads", "profile")
// 폴더가 없으면 자동 생성
if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath, { recursive: true })
}
이미지 경로는 아래와 같으며, profile폴더를 따로 만든 이유는
나중에 다른 업로드 기능을 고려하여 파일을 종류별로 분리하기 위해 우선 폴더를 따로 생성해주었다.
public/
└── uploads/
└── profile/
5-1. 파일 저장 방식 설정
const storage = multer.diskStorage({
// 폴더 설정
destination: (req, file, callback) => {
// callback(null, "public/uploads/profile")
callback(null, uploadPath)
},
// 파일 이름 설정
filename: (req, file, callback) => {
// 원본 파일 확장자 추출
const extension = path.extname(file.originalname)
// 현재 시간을 이용 파일명 생성
const filename = `${Date.now()}${extension}`
callback(null, filename)
}
})
5-2. 파일 크기, 타입 제한
프론트에서 지정한 accept="image/*" 를 사용하면 파일 선택창에서 이미지 파일을 우선 선택할 수 있도록 제한할 수 있지만,
프론트 설정만으로는 충분하지 않아 서버에서도 파일 형식을 검사하였다.
※ accept는 서버 검증이 아니라 파일 선택을 돕는 프론트 설정
서버에서는 fileFilter를 이용해 업로드 요청의 MIME 타입을 확인하여 이미지 파일만 업로드되도록 기본적인 검증을 수행하였다.
export const uploadProfile = multer({
storage, // 파일 저장 방식 설정
// 최대 파일 크기: 5MB
limits: {
fileSize: 5 * 1024 * 1024
},
// 업로드 파일 검사
fileFilter: (req, file, callback) => {
// 업로드된 파일의 타입(MIME Type)이 image/로 시작하는지 확인
// image/png 또는 image/jpeg → 허용
// application/pdf → 거부
if (file.mimetype.startsWith("image/")) {
// 검사 통과 → 업로드 허용
callback(null, true)
} else {
// 검사 실패 → 업로드 거부
callback(
new Error("이미지 파일만 업로드할 수 있습니다."),
false
)
}
}
})6. Router 연결
import { uploadProfile } from "../middleware/profile_upload.mjs"
router.patch(
"/profile-image",
isAuth,
uploadProfile.single("profileImage"),
authController.updateProfileImage
)7. Controller에서 파일 경로 생성
실제 파일은 public/uploads/profile/1784263709857.jpg 해당 경로로 저장된다.
이때 public은 정적 파일 경로로 설정해두었기 때문에 브라우저에선 /uploads/profile/1784263709857.jpg 로 접근한다.
export async function updateProfileImage(req, res) {
if(!req.file){
return res.status(400).json({
message: "선택된 이미지가 없습니다."
})
}
// 브라우저가 접근할 이미지 주소
const profileImage = `/uploads/profile/${req.file.filename}`
// 이미지 경로 DB에 저장
const updatedUser = await authRepository.updateProfileImage(
req.user._id, profileImage
)
const { userpw, ...safeUser } = updatedUser
return res.status(200).json({
message: "프로필 이미지가 저장되었습니다.",
user: safeUser
})
}8. Repository 이미지 경로 저장함수 추가
export async function updateProfileImage(id, profileImage) {
const result = await getUser().findOneAndUpdate(
{ _id: new ObjectId(id) },
{ $set: { profileImage } },
{ returnDocument: "after" }
)
return result
}
'웹 개발' 카테고리의 다른 글
| [React] 자주 사용하는 Hook (useState, useEffect, useRef, useMemo, useCallback, useCont (0) | 2026.07.18 |
|---|---|
| [React] Props와 map()으로 리스트 렌더링하기 (key가 필요한 이유) (0) | 2026.07.16 |
| [React] 리액트 기본개념 (SPA, 리액트 사용방법, 동작방식) (0) | 2026.07.15 |
| [JavaScript] Node.js에서 .js와 .mjs 파일의 차이점 (0) | 2026.06.30 |
| [JavaScript] 정규식(Regex)에서 +는 언제 사용할까? (0) | 2026.06.22 |