Devlog

[NodeJS + ExpressJS] 웹서버에서 파일을 다루는 방법을 알아보자 본문

Language/node.js

[NodeJS + ExpressJS] 웹서버에서 파일을 다루는 방법을 알아보자

recoma 2022. 8. 20. 02:49
728x90

 

개요

이번에 진행한 초미니 ExpressJS 프로젝트. 이 안에는 이번 포스트에서 설명할 파일 업로드/다운로드 로직이 들어있습니다!

며칠 전 그동안 자체 제작한 알고리즘을 웹으로 배포하는 쪼그만 프로젝트를 진행해 최근에 배포를 했습니다.

일단 알고리즘 하나를 올렸는데 이 알고리즘은 Input과 Output이 파일단위였기에 파일을 업로드 또는 다운로드하는 로직을 구현을 했어야 했습니다. 따라서 이번 포스트에서는 ExpressJS에서 파일 업로드/다운로드 방법 뿐만 아니라 그리고 업로드된 파일 객체에선 무엇이 들어있고, 또 어떻게 활용할 수 있는지 살펴보려 합니다.

Specification:
OS: Ubuntu 20.04 (WSL2 in Windows10)
Node: 16.x
Lang: Javascript
테스트용 클라이언트: postman

프로젝트 준비

어디 적당한 디렉토리를 찾고 그 위에 노드 프로젝트를 생성합니다.

$ npm init

express를 설치합니다.

$ npm i express

index.js 파일을 아래와 같이 입력합니다. 간단한 api 하나만 만들거기 때문에 소스코드는 따로 분리하지 않습니다. 이 API는 파일을 클라이언트로부터 받고 똑같은 파일을 리턴하는 API입니다. echo의 파일 버전이라고 볼 수 있겠군요.

const express = require('express');
const app = express();

app.post('/file', (req, res) => {
    res.json({'hello': 'world'});
});

app.listen(3000, () => {
    console.log('Booting');
});

아래 명령어를 입력해서 서버를 킵니다.

$ node index.js

postman에 들어가서(http://localhost:3000/file POST) 출력값이 {'hello': 'world'}라고 뜨면 정상적으로 작동되었다는 뜻입니다.

파일 업로드 해보기

코딩하기

일단 API는 비어있기 때문에 파일 업로드 로직을 구현해야 합니다. 코딩을 하기 전에 "formidable" 이라는 패키지를 설치해 줍니다. 이 패키지는 업로드된 파일 정보를 얻기 위해 request데이터를 파싱합니다.

$ npm i formidable

그리고 아래와 같이 코드를 수정합니다.

const express = require('express');
const formidable = require('formidable');
const app = express();

app.post('/file', (req, res) => {
    
    const form = formidable({
        multiples: false,   // 단일 파일 업로드
    });
    form.parse(req, (err, field, file) => {
        if (err) {
            console.log('Get Error');
            console.log(err);
        } else {
            const fileData = file.file;
            console.log(fileData.originalFilename); // 파일 이름
        }
    });
    res.json({'hello': 'world'});
});

app.listen(3000, () => {
    console.log('Booting');
});

코드를 작성했으면 서버를 키고 postman에서 파일과 함께 API를 날려봅니다. 저는 pcfl.mid라는 파일을 사용했습니다.

API 요청을 날리고 콘솔창에 자신이 전송했던 파일 이름이 출력되면 정상적으로 작동이 된 겁니다.

Booting
pcfl.mid

originalFilename

눈치채셨겠지만, 파일 이름을 확인할 때 fileData.name이 아닌 fileData.originalFilename을 사용했습니다. 그렇다면 newFilename의 정체는 무엇일까요? 한번 확인해 봅시다. 코드를 한줄 더 추가하고 실행합니다.

        } else {
            const fileData = file.file;
            console.log(fileData.originalFilename); // 파일 이름
            console.log(fileData.newFilename);
        }
Booting
pcfl.mid
88d836d0c79c6e6d810de6d00

어떤 랜덤한 이름이 출력된것 같지만 아직 정체를 모르겠습니다. 하지만 방법이 없는 것은 아닙니다. 코드를 한줄 더 추가합니다.

console.log(fileData.originalFilename); // 파일 이름
console.log(fileData.newFilename);
console.log(fileData.filepath)
Booting
pcfl.mid
ead4b2b697815d8ad4fd41d00
/tmp/ead4b2b697815d8ad4fd41d00

fileData.filepath는 fileData.newFilename이라는 이름을 가진 파일이 저장되어 있는 루트를 말합니다. 그렇다면, 이 파일의 정체가 무엇일까요? 추측컨데 아마 newFile은 originalFile의 임시 복사본으로 예상됩니다. mid파일은 용량이 크므로 저는 텍스트 파일을 생성해서 이 안에 "hello world"를 입력해서 테스트 해 보겠습니다. 파일에 접근해야 하기 때문에 fs 모듈을 임포트 한 다음 코드를 수정합니다.

const fileData = file.file;
console.log(fileData.filepath);
console.log(fs.readFileSync(fileData.filepath, {encoding: 'utf-8'}));
/tmp/e0aa14e2d6c7fb2b8de0e8301
hello world!

업로드 했던 파일의 내용과 정확히 일치합니다.

이로써 NodeJS는 파일 업로드 요청을 받으면 Request에 들어있는 파일 데이터를 임시로 특정 위치에 파일을 저장한다는 것을 알 수 있습니다. 따라서 파일의 데이터를 읽을 땐 filepath 위치의 파일을 읽어오면 됩니다.

리눅스에서 /tmp는 리눅스가 돌아감에 있어서 필요한 임시파일들을 저장하는 곳입니다. 데스크탑 또는 개인용으로 사용할 때는 /tmp의 필요성을 몰라도 되지만. 서버로 활용할 경우, 외부로부터의 보안 (브라우저를 통하여 /tmp에 접근하는 경우 등...)을 고려하여 특정 조치를 취하거나 따로 /tmp 전용 파티션을 나누기도 합니다. OS가 Windows라면 /tmp가 아닌 다른 위치에 저장이 되어있을 것입니다.

formidable 세팅

파일을 읽어오는 방법을 찾았긴 했지만 한가지 더 궁금한 점이 있습니다.

app.post('/file', (req, res) => {
    
    const form = formidable({
        multiples: false,   // 단일 파일 업로드
    });

파일 데이터를 파싱하기 위해 객체를 생성하는 코드입니다. 인자값으로 들어가 있는 object는 파일을 여러개받는 옵션을 설정하는 "multiples: false"가 있는 것으로 보아 파일 데이터를 파싱하는 데 사용되는 옵션처럼 보입니다. 그렇다면 option에 어떤 것들을 설정할 수 있는 지 알아봅시다.

    const form = formidable({
        multiples: false,
        maxFileSize: 100,	// 100B
    });

maxFileSize는 업로드 할 수 있는 최대 파일의 크기를 의미합니다. 200MB가 default이며 여기서는 100B로 제한을 걸었습니다.

app.post('/file', (req, res) => {
    
    const form = formidable({
        multiples: false,
        maxFileSize: 100,   // 100B
    });
    form.parse(req, (err, field, fileDatas) => {
        if (err) {
            console.log(err);
        } else {
            console.log('ok');
        }
    });
    res.json({'hello': 'world'});
});

API 함수를 아래와 같이 작성하고 100B 이상의 파일을 업로드 하려고 시도하면

Booting
FormidableError: options.maxFileSize (100 bytes) exceeded, received 32272 bytes of file data
...
httpCode: 413

err 인자값이 생기게 되고 이에 따라 err의 내용을 호출합니다. 저같은 경우 32272B 크기의 파일을 올려놨기 때문에 에러가 발생했습니다. 이에 따라 HTTP 에러코드는 413이 호출되었습니다. 413 코드는 "요청 엔터티가 서버에 의해 정의된 제한보다 크다"를 의미합니다. 최대 크기 뿐만 아니라 최소 크기도 세팅할 수 있습니다.예를 들어  "minFileSize: 100"를 추가하면 100B 이하의 파일 데이터를 업로드 하려고 하면 에러가 발생합니다.

    const form = formidable({
        multiples: false,
        filter: ({name, originalFilename, mimetype}) => {
            console.log(name);
            console.log(originalFilename);
            console.log(mimetype);
            return true;
        },
    });

"filter"는 파일 데이터가 올라왔을 때 이 파일이 조건에 맞는 지 체크하는 함수 입니다. 인자값으로 name, originalFilename, mimetype 세개가 들어옵니다. 리턴값은 true/false로 조건에 맞다면 true를 맞지 않다면 false를 출력합니다.

mimetype은 content-type, originalFilename은 파일 이름입니다. name은 key를 의미합니다.  쉽게 말해 postman에서 API를 요청할 때 form의 key를 의미합니다. 

그러면 솔직히  name이 아니라 변수명을 key로 하는 게 맞지 않냐는 불만이 나올 수 있겠지만, 공식 문서에서는 name으로 나와있으니 할 말이 없네요.

app.post('/file', (req, res) => {
    
    const form = formidable({
        multiples: false,
        filter: ({name, originalFilename, mimetype}) => {
            console.log(originalFilename);
            return mimetype && mimetype.includes('text');
        },
    });
    form.parse(req, (err, field, file) => {
        if (err) {
            console.log(err);
        } else {
            if (Object.keys(file).length === 0) console.log('no exists');
            else console.log('exists');
        }
    });
    res.json({'hello': 'world'});
});

업로드 된 파일이 text라면 filter는  true, 다른 타입이라면 false를 호출합니다. 하지만 아까 maxFileSize처럼 에러를 호출하지 않고 form.parse의 file에 빈 object 객체가 들어오게 됩니다. 따라서 예외처리를 err가 아닌 file에 적용시켜야 합니다. 여기서는 file의 길이를 활용해 filter를 통과했다면 exists, 그렇지 않으면 no exists를 호출합니다.

hello-world.txt
exists

텍스트 파일을 집어넣으면 file에 데이터가 들어오지만

DatabaseDiagram.png
no exists

그림 파일을 집어넣으면 file는 빈 객체가 됩니다. 필터를 통과하지 못했기 때문입니다.

    const form = formidable({
        multiples: false,
        uploadDir: "./data"
    });

uploadDir은 클라이언트로부터 받은 파일이 저장되는 위치를 지정합니다. 따로 지정을 하지 않으면 아까처럼 /tmp 에저장이 됩니다.

Booting
/mnt/e/practice/nodejs/file/data/4e5205a52ccead057c7a17b00

uploadDir을 설정하고 실제로 filepath를 출력해 보면 /tmp가 아닌 아까 지정했던 data 디렉토리에 저장이 되어 있는 것을 볼 수 있습니다.

하지만 임시 데이터가 지워지지 않는다!

default root(/tmp)에서는 리눅스 운영체제가 알아서 처리해 주지만 따로 지정해 주면 이렇게 데이터가 남습니다. uploadDir을 지정할 때, 임시 파일에 대한 처리 전략도 고민을 해봐야 할 필요가 있습니다.

 

그 밖에도 임시 파일 이름을 따로 설정할 때 사용하는 filename(함수), 비어 있는 파일 업로드를 허용하는 allowEmptyFiles 등 여러가지가 더 있지만 이쯤에서 설명을 마치도록 하겠습니다. 모든 option은 공식 문서에서 확인하실 수 있습니다.

 

formidable

A node.js module for parsing form data, especially file uploads.. Latest version: 2.0.1, last published: 10 months ago. Start using formidable in your project by running `npm i formidable`. There are 1427 other projects in the npm registry using formidable

www.npmjs.com

파일 다운로드 하기

파일 다운로드는 상당히 쉽습니다. 그냥 res에 download를 달아버리면 그만입니다. 하지만 download는 비동기 함수이므로 이 점에 유의해야 합니다.

    const form = formidable({
        uploadDir: './data',
        multiples: false,
    });

data 디렉토리르 만들고 option을 위와 같이 설정합니다. 임시 파일들은 data 디렉토리에 저장이 됩니다.

    form.parse(req, (err, field, file) => {
        if (err) {
            res.status(400).json({'error': '업로드 실패'});
        } else {
            const filepath = file.file.filepath;
            res.status(200).download(filepath, 'output.txt', (err) => {
                if (err) res.status(400).json({'error': '다운로드 실패'});
                else res.end();
            });
        }
    });

그다음 아래와 같이 입력합니다. download() 함수에서는 총 3개의 파라미터가 들어갑니다.

첫 번 째 파라미터는 다운로드 대상의 파일, 두 번 째는 다운로드 될 파일의 이름(여기서는 output.txt라는 이름의 파일로 다운로드가 됩니다.). 마지막은 콜백 함수 입니다.

콜백 함수에서는 에러를 나타내는 err가 파라미터로 들어갑니다. 따라서 에러가 없으면 end()로 response를 닫아서 응답을 해줍니다.

 

하지만 아까도 말했다시피 data 디렉토리에는 더이상 필요 없는 파일이 남게 됩니다. 이때 res.end()로 response를 닫은 다음에 파일 삭제 함수인 unlink를 추가하면 됩니다.

            res.status(200).download(filepath, 'output.txt', (err) => {
                if (err) res.status(400).json({'error': '다운로드 실패'});
                else {
                    res.end();
                    fs.unlink(filepath, () => {
                        console.log('removed!');
                    });
                }
            });

이것으로 파일 업로드/다운로드에 대한 기본적인 설명이 끝났습니다. 간단한 파일 Echo API 전체 코드는 다음과 같습니다.

const express = require('express');
const formidable = require('formidable');
const app = express();
const fs = require('fs');

app.post('/file', (req, res) => {
    
    const form = formidable({
        uploadDir: './data',
        multiples: false,
    });
    form.parse(req, (err, field, file) => {
        if (err) {
            res.status(400).json({'error': '업로드 실패'});
        } else {
            const filename = file.file.originalFilename;
            const filepath = file.file.filepath;
            res.status(200).download(filepath, filename, (err) => {
                if (err) res.status(400).json({'error': '다운로드 실패'});
                else {
                    res.end();
                    fs.unlink(filepath, () => {
                        console.log('removed!');
                    });
                }
            });
        }
    });
});

app.listen(3000, () => {
    console.log('Booting');
});

부록: 실전 코드

실제로 제 미니 프로젝트에서 구현된 코드 입니다. midi파일을 받고 특정 명령어를 통해 output 파일의 주소를 받아 클라이언트에게 전달한 다음 그 midi파일을 삭제합니다.

 

GitHub - SweetCase-Cobalto/resolver: 내 인생에 귀찮은 일들을 자동화 해줄 Node기반의 웹서비스 [Server, Expr

내 인생에 귀찮은 일들을 자동화 해줄 Node기반의 웹서비스 [Server, Express.js] - GitHub - SweetCase-Cobalto/resolver: 내 인생에 귀찮은 일들을 자동화 해줄 Node기반의 웹서비스 [Server, Express.js]

github.com

const formidable = require('formidable');
const exec = require('child_process').exec;
const fs = require('fs');

const pcfl = (req, res) => {
    /*
        파일을 사용한 작업이므로
        Json이 아닌 일반 Form을 사용합니다.
        File의 mimetype은 반드시 midi
    */


    const form = formidable({
        multiples: false,
        filter: ({name, originalFileName, mimetype}) => {
            return mimetype && (
                mimetype.includes('audio/midi')
                || mimetype.includes('audio/mid')
            )
        }
    });
    form.parse(req, (err, field, file) => {
        if (Object.keys(file).length === 0) {
            // File Not Exists == Not Midi
            res.status(400)
                .json({msg: '확장자 .mid 또는 .midi 파일을 업로드 해 주세요.'});
            return;
        }
        if (err) {
            // Parsing Error
            res.status(400).json({msg: '입력 데이터가 정상적이지 않습니다.'});
            return;
        }
        // interval 갖고오기
        const interval = parseFloat(field.interval);
        if (Number.isNaN(interval)) {
            res.status(400).json({msg: '정수 또는 실수를 입력해야 합니다.'});
            return;
        }
        if (interval < 0.001 || interval > 0.5) {
            res.status(400).json({msg: 'interval 범위는 0.001이상 0.5이하 입니다.'});
            return;
        }
        // 파일 데이터 갖고오기
        const fileData = file.file;
        const name = fileData.originalFilename;         // 파일 이름
        const tmpName = fileData.newFilename;           // 임시 Input 파일 이름
        const tmpPath = fileData.filepath;              // 임시 데이터가 저장되는 곳
        // 명령어 생성
        const cmd = `python submodules/PCFL/pcfl.py ${tmpPath} tmp/${tmpName} ${interval}`;
        exec(cmd, (e, out, stderr) => {
            // 명령어 실행을 통한 PCFL 작동
            if (stderr) res.status(400).json({'msg': stderr});
            else {
                // 작업 성공 시 다운로드
                res.status(200).download(`tmp/${tmpName}`, name, (err) => {
                    if (err) res.status(400).json({msg: '파일 다운로드에 실패했습니다.'});
                    else {
                        res.end();
                        // 다운로드 후 임시파일 삭제
                        fs.unlink(`tmp/${tmpName}`, () => {
                            // Nothing
                            // TODO 추후에 로그 관련 데이터를 다룰 예정
                        });
                    }
                });
            }
        });
    });
}

module.exports = pcfl;

 

728x90
반응형