Express에서 multer를 이용한 업로드, 다운로드 구현
2018-06-20 13:03:40

개요

  1. 클라이언트에서 form input file에 이미지를 등록 후 서버에 전송
  2. 서버에서는 multer를 통해 file 정보를 받아서 필요한 데이터를 db에 저장
  3. 다운로드 버튼을 누르면 여러 이미지의 경우 zip으로 묶어서, 아니면 걍 다운로드

클라이언트

중요한 건 form 태그의 enctype=”multipart/form-data”가 있어야 함.

input file 태그는 원하는 개수에 맞게 추가하거나 multiple 옵션을 주면 됨.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<form class="" action="" method="post" enctype="multipart/form-data">
<div class="field">
<label class="label">title</label>
<div class="control">
<input class="input" type="text" name="title" placeholder="title">
</div>
</div>

<div class="field">
<div class="file is-primary has-name">
<label class="file-label">
<input class="file-input" type="file" name="attachment">
<span class="file-cta">
<span class="file-icon">
<i class="fa fa-image"></i>
</span>
<span class="file-label">
Choose a image…
</span>
</span>

</label>
</div>
</div>

<div class="field is-grouped" style="margin-top:30px;">
<div class="control">
<button class="button is-primary">Submit</button>
</div>
<div class="control">
<a href="/" class="button is-primary is-outlined">Cancel</a>
</div>
</div>
</form>

업로드, 다운로드 로직

express 혼자서는 multipart/form-data 데이터를 핸들링 할수가 업기 때문에

multer라는 모듈을 사용 한다.

zip 다운로드의 경우 node-zip을 사용.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/* eslint-disable no-new-require */
/* eslint-disable new-cap */

const path = require('path');
const fs = require('fs');
const multer = require('multer');
const crypto = require('crypto');
const config = require('../../config/config'); // 환경 설정

const uploadFolder = '/upload/'; // NOTE: 업로드한 이미자가 잇을 express static folder


/**
* TODO: 디렉토리가 존재하는지 체크
* @param {String} 절대 경로
*/
const dirExists = (absolutePath) => {
try {
return fs.statSync(absolutePath).isDirectory();
} catch (err) {
// console.log('dirExists err', err); // NOTE: 폴더가 존재하지 않으면 'ENOENT' 에러를 반환함.
return false;
}
};

/**
* TODO: 파일이 있는지 체크
* @param {Array} 절대 경로
*/
const fileExists = (files) => {
let result = true;
for (let i = 0; i < files.length; i += 1) {
try {
fs.statSync(files[i]).isFile();
} catch (err) {
// console.log('fileExists err', err);
result = false;
break;
}
}
return result;
};

/**
* TODO: multer 기본 세팅
* @param {String} 업로드할 폴더명
*/
const multerInit = (folder) => {
if (!folder) return false;
const storage = multer.diskStorage({
destination: (req, file, callback) => {
const uploadRoot = path.join(config.root, '/public/upload/'); // 시스템 절대 경로
const uploadPath = path.join(config.root, `/public/upload/${folder}`);
if (!dirExists(uploadRoot)) fs.mkdirSync(uploadRoot); // NOTE: 업로드 폴더 체크 후 생성.
if (!dirExists(uploadPath)) fs.mkdirSync(uploadPath); // NOTE: 업로드 폴더 체크 후 생성.
callback(null, uploadPath);
},
filename: (req, file, callback) => { // NOTE: 파일명 변환
callback(null, `${Date.now()}.${file.mimetype.split('/')[1]}`);
},
});
return multer({
storage,
limits: { fileSize: 2097152 }, // NOTE: 업로드 파일은 2mb 제한
fileFilter: (req, file, callback) => {
if (file.mimetype.indexOf('image') === -1) {
return callback(new Error('이미지 파일만 업로드 가능'));
}
return callback(null, true);
},
});
};

/**
* TODO: db에 저장할 path 만들기.
* @description multer에서 주는 path는 시스템 경로이기 때문에 그대로 저장하면 클라이언트에서 view로 쓸수 업음.
* @param {*} filePath
*/
const changeDBPath = (filePath) => {
const origin = filePath;
const start = origin.match(uploadFolder).index;
let result = '';
for (let i = start; i < origin.length; i += 1) {
result += origin[i];
}
return result;
};

/**
* TODO: db에 저장된 path를 실제 업로드 path로 변경.
* @description dbpath는 client view용 (express static) 경로 이기 때문에 시스템 경로로 바꿔줘야 다운로드가 진행이 됨.
* @param {*} dbPath
*/
const changeUploadPath = (dbPath) => path.join(config.root, `/public${dbPath}`);

/**
* TODO: req.files에서 path, name, originalName만 가지고 오기
* @description db 저장용
* @param {Array} files req.files
*/
const convertMulterData = (files) => {
if (files.length === 0) return false;
try {
const uploadPaths = files.map((file) => changeDBPath(file.path));
const fileName = files.map((file) => file.filename);
const fileOriginalName = files.map((file) => file.originalname);
return { uploadPaths, fileName, fileOriginalName };
} catch (err) {
console.log('convertMulterData err', err);
return false;
}
};


/**
* TODO: 여러 파일을 다운받아야 할 경우엔 zip으로 묶어서 넘긴다.
* @param {Array} uploadPath db에 저장된 업로드 경로
* @param {Array} originalname db에 저장된 원래 파일명
*/
const zipDownload = (dbPath, originalname) => new Promise((resolve, reject) => {
if (!dbPath || !originalname) return reject(new Error('다운로드에 필요한 파라미터 누락'));
if (dbPath.length !== originalname.length) return reject(new Error('다운로드 파리미터 확인'));
if (!Array.isArray(dbPath) || !Array.isArray(originalname)) return reject(new Error('다운로드 파일 타입 체크'));
const uploadPath = dbPath.map((p) => changeUploadPath(p)); // NOTE: dbpath -> uploadpath
if (!fileExists(uploadPath)) return reject(new Error('업로드된 파일이 존재 하지 않음'));
const zip = new require('node-zip')();
for (let i = 0; i < uploadPath.length; i += 1) {
zip.file(originalname[i], fs.readFileSync(uploadPath[i]));
}
const zipDownloadRoot = path.join(config.root, '/public/upload/zip');
if (!dirExists(zipDownloadRoot)) fs.mkdirSync(zipDownloadRoot); // NOTE: zip 만들 폴더 체크 후 생성.
const zipPath = `${zipDownloadRoot}/${Date.now()}_${crypto.randomBytes(8).toString('hex')}.zip`;
const addZip = zip.generate({ base64: false, compression: 'DEFLATE' }); // NOTE: zip 파일 생성.
fs.writeFileSync(zipPath, addZip, 'binary'); // NOTE: zip 파일 저장
return resolve(zipPath);
});

exports.changeDBPath = changeDBPath;
exports.changeUploadPath = changeUploadPath;
exports.multerInit = multerInit;
exports.convertMulterData = convertMulterData;
exports.zipDownload = zipDownload;

db

db는 mongodb로 view, 다운로드에 필요한 정보를 저장 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Example model

const mongoose = require('mongoose');

const { Schema } = mongoose;

const uploadsSchema = new Schema({
title: String, // 글 제목
uploadImagePath: Array, // view 이미지 경로
uploadImageName: Array, // view 이미지 파일명
imageOriginalName: Array, // download 원래 파일명
create_at: {
type: Date,
default: Date.now(),
},
});

mongoose.model('uploads', uploadsSchema);

업로드, 다운로드 route

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118

const router = require('express').Router();
const mongoose = require('mongoose');
const upload = require('../lib/local_upload_util');

const UploadModel = mongoose.model('uploads');

module.exports = (app) => {
app.use('/', router);
};

router.get('/', (req, res, next) => {
UploadModel.find((err, lists) => {
if (err) return next(err);
return res.render('list', {
title: '업로드한 리스트',
lists,
});
});
});

// 이미지 1개 업로드 (싱글)
router.get('/single', (req, res, next) => {
res.render('single', {
title: '이미지 1개 업로드 form',
});
});

// 이미지 1개 업로드 (싱글) 처리
router.post('/single', (req, res, next) => {
const uploadInit = upload.multerInit('images').single('attachment');
uploadInit(req, res, (err) => {
if (err) return next(err);
const uploadData = new UploadModel({
title: req.body.title,
uploadImagePath: [upload.changeDBPath(req.file.path)],
uploadImageName: [req.file.filename],
imageOriginalName: [req.file.originalname],
});
return uploadData.save((saveErr) => {
if (saveErr) return next(saveErr);
return res.redirect('/');
});
});
});

// 이미지 여러개 업로드 (Array)
router.get('/array', (req, res, next) => {
res.render('array', {
title: '이미지 여러개 업로드 form',
});
});

// 이미지 여러개 업로드 (Array) 처리
router.post('/array', (req, res, next) => {
const uploadInit = upload.multerInit('images').array('attachment');
uploadInit(req, res, (err) => {
if (err) return next(err);
const convertMulterData = upload.convertMulterData(req.files);
if (!convertMulterData) return next(new Error('업로드된 파일들의 정보를 변환할 수 업음'));
const uploadData = new UploadModel({
title: req.body.title,
uploadImagePath: convertMulterData.uploadPaths,
uploadImageName: convertMulterData.fileName,
imageOriginalName: convertMulterData.fileOriginalName,
});
return uploadData.save((saveErr) => {
if (saveErr) return next(saveErr);
return res.redirect('/');
});
});
});

// 지정된 이미지 input 업로드 (fields)
router.get('/fields', (req, res, next) => {
res.render('fields', {
title: '이미지 각각 테스트 form',
});
});

// 이미지 각각 업로드 (fields) 처리
router.post('/fields', (req, res, next) => {
const uploadInit = upload.multerInit('images').fields([{ name: 'attachment1', maxCount: 1 }, { name: 'attachment2', maxCount: 1 }]);
uploadInit(req, res, (err) => {
if (err) return next(err);
const uploadData = new UploadModel({
title: req.body.title,
uploadImagePath: [upload.changeDBPath(req.files.attachment1[0].path), upload.changeDBPath(req.files.attachment2[0].path)],
uploadImageName: [req.files.attachment1[0].filename, req.files.attachment2[0].filename],
imageOriginalName: [req.files.attachment1[0].originalname, req.files.attachment2[0].originalname],
});
return uploadData.save((saveErr) => {
if (saveErr) return next(saveErr);
return res.redirect('/');
});
});
});

router.get('/download', (req, res, next) => {
// eslint-disable-next-line no-underscore-dangle
UploadModel.findOne({ _id: req.query._id }, (err, data) => {
if (err) return next(err);
if (data.uploadImagePath.length === 1) { // NOTE: 업로드 한개는 그냥 바로 다운로드.
res.cookie('isDownload', 'complete', { // NOTE: 로딩 바를 위한 쿠키 값.
maxAge: 10000,
});
res.setHeader('Content-disposition', 'attachment'); // NOTE: 컨텐츠 타입 첨부파일로 설정.
return res.download(upload.changeUploadPath(data.uploadImagePath[0]), data.imageOriginalName[0]);
}
return upload.zipDownload(data.uploadImagePath, data.imageOriginalName).then((result) => {
res.cookie('isDownload', 'complete', { // NOTE: 로딩 바를 위한 쿠키 값.
maxAge: 10000,
});
res.setHeader('Content-disposition', 'attachment'); // NOTE: 컨텐츠 타입 첨부파일로 설정.
return res.download(result);
}).catch((zipDownloadErr) => next(zipDownloadErr));
});
});

싱글(하나의 파일)인 경우엔 req.file, 나머지는 req.files에 파일 정보가 넘어 온다.

  • fieldname: input file 태그 이름
  • originalname: 파일의 원본 이름
  • encoding: 파일의 인코딩
  • mimetype: 파일의 확장자
  • size: 파일 크기
  • filename: disk storage 에만 해당하는 프로퍼티로 모듈에서 작성한 바뀐 파일명
  • path: disk storage 에만 해당하는 프로퍼티로 임시 저장된 파일의 시스템 경로

동작 짤