본문 바로가기

Node.js/Express

Express - 미들웨어 (Middleware)

미들웨어 기본

미들웨어는 익스프레스의 핵심이다. 미들웨어는 요청과 응답의 중간에 위치한다 하여 미들웨어라고 불린다. 후에 다룰 라우터와 에러 핸들러 또한 미들웨어의 일종이며, 미들웨어가 곧 익스프레스의 전부라고 해도 과언이 아니다. 미들웨어는 요처오가 응답을 조작하여 기능을 추가하기도 하며 나쁜 요청을 걸러내기도 한다.

 

미들웨어는 app.use와 함께 사용된다. app.use(미들웨어) 꼴로 사용된다. 저번에 만들었던 서버에 미들웨어를 연결해보자.

 

const express = require('express');

const app = express();
app.set('port', process.env.PORT || 3000);

app.use((req, res, next) => {
    console.log('모든 요청에 다 실행됩니다.');
    next();
})
app.get('/', (req, res, next) => {
    console.log('GET / 요청에서만 실행됩니다.');
    next();
}, (req, res) => {
    throw new Error('에러는 에러 처리 미들웨어로 갑니다.');
});

app.use((err, req, res, next) => {
    console.error(err);
    res.status(500).send(err.message);
})

app.listen(app.get('port'), () => {
    console.log(app.get('port'), '번 포트에서 대기 중.');
});

 

app.use에 매개변수가 req, res, next인 함수를 넣으면 된다. 미들웨어는 위에서부터 아래로 순서대로 실행되면서 요청과 응답 사이에 기능을 추가할 수 있다. next라는 세 번째 매개변수는 다음 미들웨어로 넘어가는 함수이다. next를 실행하지 않으면 다음 미들웨어가 실행되지 않는다.

 

첫 번째 인수로 주소를 넣어주지 않는다면 미들웨어는 모든 요청에서 실행되고, 주소를 넣는다면 해당하는 요청에서만 실행된다. 아래와 같이 사용할 수 있다.

 

app.use(미들웨어) 모든 요청에서 해당 미들웨어 실행
app.use('/path', 미들웨어) path로 시작하는 요청에서 미들웨어 실행
app.post('/path', 미들웨어) path로 시작하는 POST 요청에서 미들웨어 실행

 

위 코드를 보면 app.get에는 미들웨어가 두 개 연결되어 있다. next를 호출하면 다음 미들웨어로 넘어갈 수 있다. 두 번째 미들웨어에서 에러를 발생시키고, 그 에러는 아래에 있는 에러 처리 미들웨어에 전달된다.

 

에러 처리 미들웨어는 매개변수가 err, req, res, next로 네 개다. 모든 매개변수를 사용하지 않더라도 매개변수가 반드시 네 개여야 한다.

 

자주 사용되는 미들웨어 패키지

미들웨어는 요청과 응답에 다양한 기능을 추가하도록 해주기 때문에 이미 많은 유용한 기능들이 패키지로 만들어져 있다. 실무에 자주 사용하는 패키지들을 설치해보자. npm i morgan cookie-parser express-session dotenv 로 설치하여 사용해보자. dotenv는 미들웨어는 아니지만 process.env를 관리할 수 있게 해준다.

 

설치 후 app.js를 아래와 같이 수정해주자.

 

const express = require('express');
const morgan = require('morgan');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const dotenv = require('dotenv');
const path = require('path');

dotenv.config();
const app = express();
app.set('port', process.env.PORT || 3000);

app.use(morgan('dev'));
app.use('/', express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(session({
    resave: false,
    saveUninitialized: false,
    secret: process.env.COOKIE_SECRET,
    cookie: {
        httpOnly: true,
        secure: false
    },
    name: 'session-cookie'
}));

app.use((req, res, next) => {
    console.log('모든 요청에 다 실행됩니다.');
    next();
})
app.get('/', (req, res, next) => {
    console.log('GET / 요청에서만 실행됩니다.');
    next();
}, (req, res) => {
    throw new Error('에러는 에러 처리 미들웨어로 갑니다.');
});

app.use((err, req, res, next) => {
    console.error(err);
    res.status(500).send(err.message);
})

app.listen(app.get('port'), () => {
    console.log(app.get('port'), '번 포트에서 대기 중.');
});

 

.env 파일 (파일명 없이, 확장자 .env 파일) 도 만들어준다.

 

COOKIE_SECRET=cookiesecret

 

위 미들웨어에는 req, res, next 매개변수가 전달되지 않는 것처럼 보이지만 실제로는 미들웨어 내부에 들어있기 때문에 걱정할 필요 없다.

 

dotenv 패키지는 .env 파일을 읽어 process.env로 만든다. 그 후 COOKIE_SECRET에 cookiesecret 값이 할당된다. process.env를 별도의 파일로 관리하는 이유는 보안과 설정의 편의성 때문이다. 비밀 키들을 소스 코드에 그대로 적어둔다면 소스 코드가 유출될 경우 관련 키들이 그대로 유출될 가능성이 있다. 따라서 .env와 같은 별도의 파일에 비밀 키를 적어두고 dotenv 패키지로 비밀 키를 로딩하는 방식으로 관리한다면 소스 코드가 유출되더라도 .env 파일만 잘 관리하면 비밀 키를 지킬 수 있다.

 

이제 각 미들웨어의 기능을 알아보자.

 

morgan

morgan에 연결 후 포트에 접속하면 기존 로그 외에 추가적인 로그를 볼 수 있다. 위 코드를 실행하여 3000번 포트에 들어간 후 콘솔을 보면 아래와 같은 로그가 찍혀있는 것을 볼 수 있다.

 

3000 번 포트에서 대기 중.
모든 요청에 다 실행됩니다.
GET / 요청에서만 실행됩니다.
Error: 에러는 에러 처리 미들웨어로 갑니다.
...에러 스택 트레이스...
GET / 500 37.752 ms - 50

 

마지막 로그는 morgan 미들웨어에서 나오는 것이다. morgan은 요청과 응답에 대한 정보를 콘솔에 기록한다.

 

위 코드에서는 인수로 dev를 넣었는데 이 외에 combined, common, short, tiny 등을 넣을 수 있다. 개발 환경에서는 dev를, 배포 환경에서는 combined를 이용하면 좋다.

 

각 인수와 로그로 찍히는 결과는 아래 포스트에서 확인하자.

 

https://chan180.tistory.com/164

 

[Node.js] morgan logger API

최근 개인적으로 Node.js스터디를 진행하고 있습니다. 책을 한권 구해 차근차근 따라가던 중 express의 logger 미들웨어가 동작하지 않더군요. 우선 기본 개념은 인터넷 참조없이 책만으로 해결하려�

chan180.tistory.com

 

static

static 미들웨어는 express에서 제공하는 기본 미들웨어이며 express 객체 안에서 꺼내 바로 사용할 수 있다. 아래와 같이 사용한다.

 

app.use('요청 경로', express.static('실제 경로'));

app.use('/', express.static(path.join(__dirname, 'public')));

 

두 번째 use를 보면 기본 경로인 / 로 왔을 때, express는 public 폴더에 있는 해당 경로의 파일을 찾는다. 즉 https://localhost:3000/css/style.css 와 같은 요청을 하게 되면, 실제 static에서 반환하는 파일은 public/css/style.css가 된다.

 

이 경우 실제 서버의 폴더 경로에는 public이 들어 있더라도 요청 주소에는 public이 들어 있지 않다. 서버의 폴더 경로와 요청 경로가 다르므로 외부인이 서버의 구조를 쉽게 파악할 수 없으며, 이는 보안에 큰 도움이 된다.

 

또한 정적 파일들을 알아서 제공해주기 때문에 fs.readFile로 파일을 직접 읽어서 전송할 필요가 없다. 만약 요청 경로에 해당하는 파일이 없으면 알아서 내부적으로 next를 호출한다. 그러나 파일을 발견했다면 다음 미들웨어는 실행되지 않는다. 응답으로 파일을 보내고 next를 호출하지 않기 때문이다.

 

body-parser

요청의 본문에 있는 데이터를 해석하여 req.body 객체로 만들어주는 미들웨어이다. 보통 폼 데이터 또는 AJAX 요청의 데이터를 처리한다. 그러나 멀티파트 (이미지, 동영상. 파일) 데이터는 처리하지 못한다. 그 경우 multer 모듈을 사용한다.

 

body-parser는 익스프레스 4.16.0 버전부터 일부 기능이 익스프레스에 내장되게 되었다. 예를 들어 위에서 사용한 express.json 과 express.urlencoded 형식 외에, Raw, Text 형식의 데이터를 추가로 해석하고 싶을 때, body-parser를 설치하여 사용하면 된다. Raw는 요청의 본문이 버퍼 데이터일 때, Text는 텍스트 데이터일 때 해석하는 미들웨어이다.

 

URL-encoded는 주소 형식으로 데이터를 보내는 방식이다. 폼 전송은 URL-encoded 방식을 주로 사용한다. urlencoded 메서드를 보면 { extended: false } 라는 옵션이 들어있는데, 이 옵션이 false면 노드의 querystring 모듈을 사용하여 쿼리스트링을 해석하고, true면 qs 모듈 (npm 패키지 중 하나) 을 사용하여 쿼리스트링을 해석한다. qs는 querystring의 기능을 확장한 모듈이다.

 

cookie-parser

cookie-parser는 요청과 함께 들어온 쿠키를 해석하여 req.cookies객체로 만든다. cookie-parser 미들웨어는 다음과 같이 사용한다.

 

app.use(cookieParser(비밀키));

 

해석된 쿠키들은 req.coookies 객체에 들어가며, 유효 기간이 지난 쿠키는 알아서 걸러낸다.

 

첫 번째 인수로 비밀 키를 넣어줄 수 있다. 서명된 쿠키가 있는 경우, 제공한 비밀 키를 통해 해당 쿠키가 내가 만든 쿠키임을 검증할 수 있다. 쿠키는 클라이언트에서 위조하기 쉬우므로, 비밀 키를 통해 만들어낸 서명을 쿠키 값 뒤에 붙이는 것이다. 서명이 붙으면 쿠키가 name=name.sign 형태가 된다. 또한 서명된 쿠키는 req.cookies 대신 req.signedCookies 객체에 들어간다.

 

cookie-parser는 쿠키를 생성할 때 쓰는 것은 아니다. 쿠키를 생성/제거하기 위해서는 res.cookie, res.clearCookie 메서드를 사용해야 한다. res.cookie(키, 값, 옵션) 형식으로 사용한다. 아래는 예제이다.

 

res.cookie('name', 'beomseok', {
    expires: new Date(Date.now() + 900000),
    httpOnly: true,
    secure: true
});
res.clearCookie('name', 'beomseok', { httpOnly: true, secure: true });

 

쿠키를 지우려면 키, 값, 옵션도 정확히 일치해야 지워진다 (expires나 maxAge 제외). 옵션 중 signed라는 옵션이 있는데, 이를 true로 설정하면 쿠키 뒤에 서명이 붙는다. 내 서버가 쿠키를 만들었다는 것을 검증할 수 있으므로 대부분의 경우 서명 옵션을 키는 것이 좋다. 서명을 위한 비밀 키는 cookieParser 미들웨어에서 인수로 넣은 process.env.COOKIE_SECRET이 된다.

 

express-session

세션 관리용 미들웨어. 로그인 등의 이유로 세션을 구현하거나 특정 사용자를 위한 데이터를 임시적으로 저장해둘 때 매우 유용하다. 세션은 사용자별로 req.session 객체 안에 유지된다.

 

app.use(session({
    resave: false,
    saveUninitialized: false,
    secret: process.env.COOKIE_SECRET,
    cookie: {
        httpOnly: true,
        secure: false,
    },
    name: 'session-cookie'
}));

 

이와 같은 방식으로 사용한다. express-session은 1.5 버전 이전까지 내부적으로 cookie-parser를 사용하고 있었기 때문에 cookie-parser 미들웨어보다 뒤에 위치해야 했지만 1.5버전 이후로 사용하지 않게 되어 순서가 상관 없게 되었다. 그러나 혹시 모를 상황 (버전 호환) 을 대비하여 뒤에 놓는 것이 안전하다.

 

express-session은 인수로 세션에 대한 설정들을 받는다. resave는 요청이 올 때 세션에 수정사항이 생기지 않더라도 세션을 다시 저장할지 설정하는 것이고 saveUninitialized는 세션에 저장할 내역이 없더라도 처음부터 세션을 생성할지 설정하는 것이다.

 

express-session은 세션 관리 시 클라이언트에 세션 쿠키를 보낸다. 안전하게 쿠키를 전송하려면 쿠키에 서명을 추가해야하고, 쿠키를 서명할 때 secret의 값이 필요하다. cookie-parser의 secret과 같게 설정하는 것이 좋다.

 

cookie 옵션은 세션 쿠키에 대한 설정이다. maxAge, domain, path, expires, sameSite, httpOnly, secure 등의 일반적인 쿠키 옵션들이 모두 제공된다. store라는 옵션도 있는데, 이는 세션을 메모리가 아닌 서버에 저장하도록 하는 옵션이다. 이는 나중에 다뤄보도록 하자.

 

multer

멀터는 사용 방법이 다소 어려운 미들웨어다. 이미지, 동영상 등을 비롯한 여러 가지 파일들을 멀티파트 형식으로 업로드할 때 사용하는 미들웨어이다. 멀티파트 형식이란 enctype이 multipart/form-data 인 폼을 통해 업로드하는 데이터의 형식을 의미한다. 먼저 multipart.html 파일을 다음과 같이 만들어 데이터를 업로드할 수 있도록 해보자.

 

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
</head>
<body>
    <form action="/upload" method="post" enctype="multipart/form-data">
        <input type="file" name="image">
        <input type="text" name="title">
        <button type="submit">Submit</button>
    </form>
</body>
</html>

 

이제 multer를 설치하고 사용해보자. multer 패키지 안에는 여러 종류의 미들웨어가 들어있다. 이들을 알아보기 전 기본적인 설정부터 알아보자.

 

const multer = require('multer');

const upload = multer({
    storage: multer.diskStorage({
        destination(req, file, done) {
            done(null, 'uploads/');
        },
        filename(req, file, done) {
            const ext = path.extname(file.originalname);
            done(null, path.basename(file.originalname, ext) + Date.now() + ext);
        }
    }),
    limits: { fileSize: 5 * 1024 * 1024 }
});

 

multer에 넣은 인수들은 다음과 같다. 먼저 storage 속성에 어디에 (destination) 어떤 이름으로 (filename) 저장할지를 넣었다. 두 함수의 req 매개변수에는 요청에 대한 정보, file 객체에는 업로드한 파일에 대한 정보가 있으며 done 매개변수는 함수이다. 첫 번째 인수에는 에러가 있다면 에러를 넣고, 두 번째 인수에는 실제 경로나 파일 이름을 넣어주면 된다. req나 file의 데이터를 가공해 done으로 넘기는 식이다.

 

현재 설정으로는 uploads라는 폴더에 [파일명 + 현재시간.확장자] 파일명으로 업로드하는 것을 볼 수 있다. limits 속성에는 업로드에 대한 제한 사항을 걸어두었다. 위 예제에서는 파일 크기를 5MB (5 * 1024 * 1024 byte) 로 제한해두었다.

 

다만 위 설정을 실제로 활용하기 위해선 서버에 uploads 폴더가 꼭 존재해야 한다. 없다면 직접 만들어주거나 fs 모듈로 서버를 시작할 때 생성하는 방식이 있다. 설정이 끝난다면 위에서 선언해준 upload 변수가 생기게 되는데, 이 안에 다양한 종류의 미들웨어가 존재한다. 먼저 파일을 하나만 업로드하는 경우에는 single 미들웨어를 사용한다.

 

app.post('/upload', upload.single('image'), (req, res) => {
    console.log(req.file, req.body);
    res.send('ok');
})

 

single 미들웨어를 라우터 미들웨어 앞에 넣어두면 multer 설정에 따라 파일 업로드 후 req.fiile 객체가 생성된다. 인수는 input 태그의 name이나 폼 데이터의 키와 일치하게 넣으면 된다. 업로드가 성공하면 결과는 req.file 객체 안에 들어간다. req.body에는 파일이 아닌, 데이터인 title이 들어가있다.

 

req.file 객체는 다음과 같이 생겼다.

 

{
    fieldname: 'img',
    originalname: 'hello.png',
    encoding: '7bit',
    mimetype: 'image/png',
    destination: 'uploads/',
    filename: 'hello1567238581123.png',
    path: 'uploads//hello1567238581123.png',
    size: 44933
}

 

여러 파일을 업로드하는 경우엔 HTML의 input 태그에 multiple을 쓰면 된다. 이 경우 미들웨어는 single 대신 array로 교체하면 된다.

 

app.post('/upload', upload.array('many'), (req, res) => {
    console.log(req.file, req.body);
    res.send('ok');
});

 

업로드 결과도 req.file 대신 req.files 배열에 들어간다. 파일을 여러 개 업로드하지만 input 태그나 폼 데이터의 키가 다른 경우엔 fields 미들웨어를 사용한다. 예를 들어 HTML 파일이 아래와 같을 경우,

 

<form action="/upload" method="post" enctype="multipart/form-data">
  <input type="file" name="image1">
  <input type="file" name="image2">
  <input type="text" name="title">
  <button type="submit">Submit</button>
</form>

 

이 경우엔 fields 미들웨어를 사용하고, 인수로 input 태그들의 name들을 각각 적는다.

 

app.post('/upload',
    upload.fields([{ name: 'image1' }, { name: 'image2' }]),
    (req, res) => {
        console.log(req.file, req.body);
        res.send('ok');
    }
);

 

업로드 결과는 각각 req.files.image1, req.files.image2에 들어간다.

 

특수한 경우에, 파일이 아님에도 멀티파트 형식으로 업로드하는 경우가 있다. 그 경우 none 미들웨어를 사용한다.

 

app.post('/upload', upload.none(), (req, res) => {
    console.log(req.file, req.body);
    res.send('ok');
});

 

이 경우 파일을 업로드하지 않으므로 req.body만 존재한다.

 

미들웨어의 특성과 활용

미들웨어의 특성들에 대해 알아보자. 미들웨어는 req, res, next를 매개변수로 가지는 함수 (에러 처리 미들웨어만 예외적으로 err, req, res, next) 로써 app.use나 app.get, app.post 등으로 장착한다. 특정한 주소의 요청에만 미들웨어가 실행되게 하려면 첫 번째 인수로 주소를 넣는다.

 

또한, 아래와 같이 동시에 여러개의 미들웨어를 장착할 수도 있다.

 

app.use(
    morgan('dev'),
    express.static('/', path.join(__dirname, 'public')),
    express.json(),
    express.urlencoded({ extended: false }),
    cookieParser(process.env.COOKIE_SECRET)
);

 

위 미들웨어들은 내부적으로 next를 호출하므로 연달아 쓸 수 있다. next를 호출하지 않는 미들웨어는 res.send나 res.sendFile 등의 메서드로 응답을 보내야한다. express.static 같은 미들웨어는 정적 파일을 제공할 때 next 대신 res.sendFile 메서드로 응답을 보낸다. 따라서 정적 파일을 제공하는 경우 그 뒤의 미들웨어들은 실행되지 않는다. 즉, 미들웨어의 장착 순서에 따라 어떤 미들웨어들은 실행이 되지 않을 수도 있다.

 

만약 next도 호출하지 않고 응답도 보내지 않으면 클라이언트는 응답을 받지 못하기 때문에 하염없이 기다리게 된다.

 

next 함수에는 인수도  넣을 수 있다. 단, 인수를 넣는다면 특수한 동작을 한다. 'route'라는 인수를 넣으면 다음 라우터의 미들웨어로 바로 이동하고, 그 외의 인수를 넣는다면 바로 에러 처리 미들웨어로 이동한다. 즉, next(err) 형식으로 에러를 넘기면 바로 (err, req, res, next) 의 인수 네 개를 가지는 에러 처리 미들웨어로 넘어간다.

 

미들웨어 간 데이터를 전달하는 방법도 있다. 세션을 계속 사용한다면 req.session 객체에 데이터를 넣어도  되지만 세션이 유지되는 동안에 데이터도 계속 유지된다는 단점이 있다. 만약 요청이 끝날 때까지만 데이터를 유지하고 싶다면 아래와 같이 req 객체에 데이터를 넣어두면 된다.

 

app.use(
    (req, res, next) => {
        req.data = 'new data...';
        next();
    },
    (req, res, next) => {
        ...
    }
)

 

현재 요청이 처리도는 동안 req.data를 통해 미들웨어 간 데이터를 공유할 수 있으며, 새로운 요청이 올 시 req.data는 초기화된다. 속성명이 꼭 data일 필요는 없다. 그러나 다른 미들웨어 속성과 겹치지 않게 조심하자. 가령 속성명 body는 body-parser 미들웨어와 기능이 겹칠 수 있다.

 

또한, 미들웨어 안에 미들웨어를 넣을 수도 있다. 예를 들어 아래와 같이 처리할 수 있다.

 

app.use((req, res, next) => {
    if (process.env.NODE_ENV === 'production') {
        morgan('combined')(req, res, next);
    } else {
        morgan('dev')(req, res, next);
    }
});

 

이 패턴이 유용한 이유는 기존 미들웨어의 기능을 req, res와 next로 확장할 수 있기 때문이다. 예를 들어 위에서 조건문에 따라 다른 미들웨어를 적용한다던지 등이 있을 것이다.

 

출처

Node.js 교과서 개정 2판 - 길벗, 조현영

https://chan180.tistory.com/164