웹 개발

[Node.js] Multer를 이용한 프로필 이미지 업로드 (MongoDB + Express)

sdesign416 2026. 7. 17. 16:19

Express 환경에서 파일 업로드의 한계

Express는 기본적으로 파일 업로드를 자체적으로 처리하지 못한다.
일반적인 JSON 데이터나 텍스트 기반의 Form 데이터는 기본 파서로 쉽게 처리할 수 있다. 
하지만 이미지나 압축 파일 같은 이진(Binary) 데이터는 multipart/form-data 형식으로 전송되기 때문에, Express 자체 기능만으로는 데이터를 해석할 수 없어 별도의 처리가 필요하다.


Multer란?

Multer는 Node.js 환경에서 파일 업로드를 처리하기 위해 사용되는 미들웨어이다.
Express와 함께 사용할 수 있으며, 파일크기제한, 파일 형식 필터링 등 조건 설정도 가능하다.
 

💡 Multer 핵심 기능

  • 파일 저장소 설정: 업로드된 파일을 서버의 특정 폴더에 안전하게 저장하거나, 버퍼 형태로 메모리에 올려 클라우드 스토리지로 바로 전송할 수 있다.
  • 파일 필터링: 확장자나 mimetype을 검사하여 허용된 형식의 파일만 받을 수 있다.
  • 용량 제한: 파일 크기, 파일 개수 등을 제한할 수 있다.

동작 흐름: 프로필 이미지 기능

  1. 연필(수정) 버튼 클릭
  2. 이미지 선택
  3. 선택한 이미지 미리보기 + 연필(수정) 버튼이 체크(저장) 버튼으로 변경
  4. 체크(저장) 버튼 클릭
  5. 서버 업로드
  6. MongoDB에 이미지 경로 저장
  7. 다음 로그인 시 저장된 이미지 출력

 

각 파일별 역할

  • 프론트: 파일 선택 및 전송
  • 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 multer

Express는 기본적으로 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
}