안녕하세요. IT 엘도라도 에 오신 것을 환영합니다.
글을 쓰는 것은 귀찮지만 다시 찾아보는 것은 더 귀찮습니다.
완전한 나만의 것으로 만들기 위해 지식을 차곡차곡 저장해 보아요.   포스팅 둘러보기 ▼

자바스크립트 (JavaScript)

[JavaScript] Express 핵심 개념 익히기 (Node.js 백 엔드)

피그브라더 2022. 5. 18. 22:11

Express는 Node.js 환경의 백 엔드 프레임워크이다. 즉, JavaScript로 구현하는 서버인 것이다. 마치 Django가 Python으로 구현하는 서버인 것과 마찬가지이다. 원래 필자는 Django 프레임워크에 가장 익숙한 개발자인데, 다른 백 엔드 프레임워크도 한 번 경험해보고 싶어서 공식 문서를 읽으며 나름대로 공부를 해보았다. 그런데 Express는 정말 최소한의 기능만을 직관적으로 제공하고 있다는 느낌을 받았고, 몇 가지 핵심적인 개념만을 알면 금방 익힐 수 있겠다는 생각이 들었다. 그래서 이번 포스팅을 통해 그 핵심적인 개념을 정리해보려 한다.

 

1. Express 시작하기

Express의 핵심 개념을 본격적으로 알아보기 전에, 간단한 Express의 사용 방법 및 예시부터 살펴보자.

 

1-1. Express 설치

Express 프로젝트를 생성하고 Express를 설치한다.

# Express 프로젝트 생성
mkdir express-project
cd express-project

# Node.js 프로젝트로 초기화 (entry point : app.js)
npm init

# Express 설치
npm install express

 

1-2. Express 서버 코드 작성

엔트리 포인트 파일(app.js)에 다음과 같이 기본적인 Express 서버 코드를 작성한다. 각 코드에 대한 자세한 설명은 뒤에서 진행할 것이기 때문에, 여기서는 간단히 살펴보기만 해도 충분하다.

import express from 'express';

// Express 앱 생성
const app = express();

// 라우트 핸들러 설정 (=> 하나의 라우트 핸들러로 구성된 미들웨어 스택 구축)
app.get('/', (req, res) => console.log('hello'));

// 3000번 포트에서 HTTP 서버 실행
app.listen(3000, () => console.log('Express app listening on port 3000'));

/**
 * 서버 구동을 위한 app.listen(port, callback) 함수는
 * Node.js가 기본적으로 제공하는 http 패키지를 이용하여
 * 다음과 같이 구현될 뿐, 특별한 코드가 아니다.
 */
import http from 'http';
const httpServer = http.createServer(app);
httpServer.listen(port, callback);

 

1-3. Express 서버 실행

엔트리 포인트 파일(app.js)이 위치한 경로에서 다음과 같은 명령어로 Express 서버를 실행한다. 그리고 브라우저로 localhost의 3000번 포트에 접속해 보면 그럴듯한 웹 페이지가 뜨지는 않지만 콘솔에 로그가 찍히는 것을 확인할 수 있을 것이다.

node app.js

 

1-4. Express Generator

Express Generator는 여러 JavaScript 파일, Jade 템플릿, 서브 디렉토리 등을 갖춘 완전한 형태의 Express 프로젝트를 쉽게 생성할 수 있도록 도와주는 Node.js 패키지이다. 위에서 보여준 것은 정말 어떠한 도구의 도움도 받지 않고 가장 기본적이고 순수한 형태의 Express 프로젝트를 생성하는 과정이었다. 그러나 Express Generator를 사용하면 Express 프로젝트의 생성 및 몇몇 기본적인 설정 등을 쉽고 빠르게 진행할 수 있다.

# Express Generator를 이용하여 Express 프로젝트 생성
npx express-generator --view=pug express-project
cd express-project

# Node.js 패키지 일괄 설치
npm install

# 디버깅 모드로 Express 서버 실행
DEBUG=express-project:* npm start
React 프로젝트를 쉽게 생성할 수 있도록 도와주는 create-react-app 패키지와 비슷한 역할을 한다고 볼 수 있다.

 

2. 미들웨어 스택 (Middleware Stack)

Express 서버 코드의 핵심은 결국 클라이언트의 각 요청을 처리하는 라우팅 로직을 구현하는 것이고, 라우팅 로직을 구현하는 것은 곧 미들웨어 스택을 구축하는 것과 같다. 미들웨어 스택이란 클라이언트의 요청을 처리하는 함수들이 설정된 순서대로 차곡차곡 저장되어 있는 구조를 말한다. 이는 미들웨어, 라우트 핸들러, 에러 핸들러로 구성되며, 이들 중 일부는 라우터라는 단위로 묶을 수 있다(다음 그림 참고). 그러면 이제 이러한 개념들에 대해 하나씩 알아보도록 하자.

출처 : https://medium.com/@viral_shah/express-middlewares-demystified-f0c2c37ea6a1

참고로 여기서는 용어의 혼란을 막기 위해 미들웨어, 라우트 핸들러, 에러 핸들러를 통칭하여 콜백 함수라고 부르도록 하겠다. 결국 미들웨어 스택은 콜백 함수들로 이뤄진 스택이다. 그러나 실제로 공식 문서에서는 이 세 가지를 그냥 미들웨어라고 통칭하여 부르기도 한다는 점을 참고해주기 바란다.

 

3. 미들웨어

▎app.use(path, callback[, callback...])

  • path : 마운트 경로 (문자열, 문자열 패턴, 정규식 중 하나)
  • callback : 미들웨어 또는 미들웨어로 이뤄진 배열

미들웨어는 미들웨어 스택을 구성하는 콜백 함수의 한 종류이다. 위 코드는 미들웨어를 설정(마운트)하는 방법을 나타내며, path에 매칭 되는 경로로 시작하는 요청일 때 명시된 미들웨어(들)를 실행하도록 설정한다. path를 생략할 경우 기본값인 '/'가 지정된다.

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

app.use((req, res, next) => {
  console.log('Time:', Date.now());
  next();  // 다음 콜백 함수로 제어를 넘긴다.
});

위 코드에 따르면, 경로가 /로 시작하는 요청, 즉 모든 요청에 대해 요청 시각을 콘솔에 출력하게 된다.

 

3-1. Express가 제공하는 내장 미들웨어

Express는 다음과 같은 세 종류의 내장 미들웨어를 제공한다. 정확히는 미들웨어를 반환하는 함수들이다.

  • express.static() : Static 파일에 대한 요청을 처리하는 미들웨어
  • express.json() : 요청의 페이로드에 담겨 있는 JSON 데이터를 파싱 하는 미들웨어
  • express.urlencoded() : 요청의 페이로드에 담겨 있는 URL 인코딩 데이터를 파싱 하는 미들웨어

 

3-2. 내장 미들웨어 express.static()을 이용한 Static 파일 설정

Express 서버에서 Static 파일을 제공하도록 설정하려면 express.static(root) 함수의 반환 값에 해당하는 내장 미들웨어를 설정해야 한다. 이를 위해서는 app.use(path, express.static(root))와 같이 작성해야 한다. path는 마운트 경로이며 생략할 경우 기본값인 '/'가 지정된다. 그리고 root에는 Express 서버가 Static 파일들을 찾는 디렉토리의 경로를 지정하면 된다. 다음 예시를 보자.

const path = require('path');

// 경로가 /static으로 시작하는 요청에 대해 public 폴더에 존재하는 Static 파일로 응답
app.use('/static', express.static(path.join(__dirname, 'public')));

참고로 위 예시에서 볼 수 있듯이 express.static('public')처럼 쓰는 것보다는 express.static(path.join(__dirname, 'public'))처럼 쓰는 것이 안전하다. 전자는 node 프로세스를 실행한 작업 경로에 존재하는 public 디렉토리에서 정적 파일들을 찾지만, 후자는 해당 파일의 경로에 존재하는 public 디렉토리에서 정적 파일들을 찾기 때문이다.

 

4. 라우트 핸들러

▎app.METHOD(path, callback[, callback...])

  • METHOD : get, post, put, patch, delete, all 등 (요청의 메소드)
  • path : 라우트 경로 (문자열, 문자열 패턴, 정규식 중 하나)
  • callback : 라우트 핸들러 또는 라우트 핸들러로 이뤄진 배열

라우트 핸들러는 미들웨어 스택을 구성하는 콜백 함수의 한 종류이다. 위 코드는 라우트 핸들러를 설정하는 방법을 나타내며, path에 매칭 되는 경로의 요청일 때 명시된 라우트 핸들러(들)를 실행하도록 설정한다.

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

app.get('/hello', (req, res) => {
  res.send('hello world');
});

위 코드에 따르면, 경로가 /hello인 요청에 대해 'hello world'를 응답하게 된다.

참고로 이와 같이 특정 경로와 메소드의 요청을 라우트 핸들러(들)로 처리하도록 정의한 부분을 라우트(Route)라고 한다. 만약 하나의 라우트가 여러 개의 라우트 핸들러를 갖는다면, 해당 라우트는 미들웨어 서브 스택을 이루게 된다.

 

5. 라우터

▎const router = express.Router()

특정 경로에 맵핑할 미들웨어 스택을 별도의 모듈로 정의한 것으로, 작은 버전의 Express 앱이라고도 볼 수 있다. router 객체에 대해서도 app 객체에 대해 사용했던 메소드를 동일하게 사용하여 미들웨어 스택을 구축할 수 있다. router 자체도 미들웨어이므로 일반적인 미들웨어와 같이 app.use() 함수를 이용하여 설정(마운트)하면 된다.

const express = require('express');
const app = express();
const router = express.Router();

router.use((req, res, next) => {
  console.log('Time:', Date.now());
  next();  // 다음 콜백 함수로 제어를 넘긴다.
});

router.get('/hello', (req, res) => {
  res.send('hello world');
});

// 경로가 /dev로 시작하는 요청에 대해 router 마운트
app.use('/dev', router);

위 코드에 따르면, 경로가 /dev로 시작하는 요청에 대해 요청 시각을 콘솔에 출력한다. 그리고 경로가 /dev/hello인 요청에 대해서는 'hello world'를 응답하게 된다. 이것이 /dev 경로에 라우터를 마운트한 결과이다.

express() 인스턴스에 대해 설정하는 미들웨어를 애플리케이션 레벨 미들웨어라고 하고, express.Router() 인스턴스에 대해 설정하는 미들웨어를 라우터 레벨 미들웨어라고 한다. 참고로 여기서 말하는 미들웨어는 공식 문서 기준의 용어로, 이 글에서 정한 기준에서는 콜백 함수를 가리킨다고 생각하면 된다. (애플리케이션/라우터 레벨 콜백 함수)

 

6. 콜백 함수(미들웨어, 라우트 핸들러, 에러 핸들러)의 매개변수

▎req, res, next

req, res 객체는 Node.js에 내장된 req, res 객체의 확장판으로, 각각 HTTP 요청과 HTTP 응답을 나타낸다. req 객체에서 HTTP 요청에 대한 내용을 참조할 수 있고, res 객체의 특정 메소드를 호출함으로써 클라이언트에게 응답하고 요청-응답 사이클을 종료시킬 수 있다. next() 함수는 미들웨어 스택에서 바로 다음에 존재하는 콜백 함수를 가리킨다. 각 콜백 함수는 반드시 res 객체를 이용하여 요청-응답 사이클을 종료시키거나 next() 함수를 이용하여 다음 콜백 함수를 호출해야 한다.

req, res, next는 콜백 함수에 전달되는 매개변수일 뿐이기 때문에 마음대로 네이밍이 가능하지만, 관습적인 측면에서 웬만하면 이 이름들을 그대로 사용하는 것을 권장한다.
미들웨어, 라우트 핸들러의 경우 이와 같이 세 개의 매개변수를 갖지만, 에러 핸들러의 경우에는 err 매개변수까지 포함하여 총 네 개의 매개변수를 갖는다. 에러 핸들러에 대해서는 뒤에서 더 자세히 설명한다.

 

7. next() 함수의 활용

콜백 함수가 여러 개인 경우, 각 콜백 함수는 매개변수로 전달받는 next() 함수를 호출하여 다음 콜백 함수로 제어를 넘길 수 있다.

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

app.use((req, res, next) => {
  console.log('Time:', Date.now());
  next();  // 다음 콜백 함수로 제어를 넘긴다.
});

app.get('/hello', (req, res) => {
  res.send('hello world');
});

그런데 만약 라우트 핸들러에서 next('route') 함수를 호출하면 해당 라우트에 속한 나머지 라우트 핸들러들을 전부 건너뛸 수 있고, 라우터에 설정된 콜백 함수에서 next('router') 함수를 호출하면 해당 라우터에 속한 나머지 콜백 함수들을 전부 건너뛸 수 있다.

const express = require('express');
const app = express();
const router = express.Router();

router.use((req, res, next) => {
  console.log('Time:', Date.now());
  next();  // 다음 콜백 함수로 제어를 넘긴다.
});

// 라우트 1 (콜백 함수 1 + 콜백 함수 2)
router.get('/hello', [
  (req, res) => {
    console.log('hello world 1');
    next('route')  // 두 번째 콜백 함수를 건너뛴다.
  },
  (req, res) => {
    console.log('hello world 2');  // 실행되지 않는다.
  }
]);

// 라우트 2 (콜백 함수 3)
router.get('/hello', (req, res) => {
  console.log('hello world 3');
  next('router')  // 네 번째 콜백 함수를 건너뛴다.
});

// 라우트 3 (콜백 함수 4)
router.get('/hello', (req, res) => {
  console.log('hello world 4');  // 실행되지 않는다.
});

app.use('/dev', router);

app.get('/dev/hello', (req, res) => {
  res.send('end');
});

그리고 'route' 혹은 'router'가 아닌 값 val에 대해 next(val) 함수를 호출하면 val은 현재 요청에서 발생한 에러 객체로 취급된다. 그러면 나머지 미들웨어나 라우트 핸들러들은 전부 건너뛰고, 해당 에러 객체를 매개변수로 넘기면서 (첫 번째로 설정된) 에러 핸들러를 호출하게 된다. 에러 핸들러에 대해서는 바로 아래에서 더 자세히 설명한다.

 

8. 에러 핸들러

에러 핸들러는 네 개의 매개변수(차례대로 err, req, res, next)를 갖는 특별한 종류의 콜백 함수를 말하며, 콜백 함수에서 발생하는 에러를 감지하여 처리하는 역할을 맡는다. 따라서 에러 핸들러는 미들웨어 스택에서 미들웨어나 라우트 핸들러보다 나중에 설정되어야 할 것이다.

// 다음과 같이 반드시 매개변수를 네 개 가져야 에러 핸들러가 된다.
function errorHandler(err, req, res, next) {
  res.status(500);
  res.render('error', { error: err });
}

콜백 함수의 동기 코드에서 발생하는 에러는 자동으로 에러 핸들러에게 전달된다. 즉, 나머지 미들웨어나 라우트 핸들러들은 전부 건너뛰고 해당 에러 객체를 매개변수로 넘기면서 (첫 번째로 설정된) 에러 핸들러를 호출하는 것이다.

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

app.get('/hello', (req, res) => {
  throw new Error('Broken');
});

app.get('/hello', (req, res) => {
  console.log('hello world');  // 실행되지 않는다.
});

app.use((err, req, res, next) => {
  res.status(500);
  res.render('error', { error: err });
});

그런데 콜백 함수에서 호출되는 비동기 코드에 의해 발생하는 에러는 next() 함수를 통해 직접 에러 핸들러에게 전달해야 한다. 앞서 말했듯 next() 함수 호출 시 매개변수로 'route' 혹은 'router'가 아닌 값을 넘기면 그 값은 에러 객체로 취급되기 때문이다.

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

app.get('/hello', (req, res, next) => {
  setTimeout(() => {
    try {
      throw new Error('BROKEN');
    } catch (err) {
      next(err);  // err는 에러 객체로 취급되어 에러 핸들러에게 전달된다.
    }
  }, 100);
});

한편, Express 5부터는 콜백 함수가 거부된 Promise 객체를 반환하는 경우 자동으로 그 거부된 값이 에러 핸들러에게 전달되기 때문에 async 함수에서 에러가 발생하거나 await 하는 Promise 객체가 거부되는 경우 자동으로 해당 에러 객체 혹은 거부된 값이 에러 핸들러에게 전달된다.

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

app.get('/hello', async (req, res) => {
  const user = await getUserById(req.params.id);
  res.send(user);
});

참고로 Express는 기본적으로 미들웨어 스택의 맨 마지막에 디폴트 에러 핸들러를 설정한다. 이는 클라이언트에게 stack trace를 포함한 에러의 정보를 응답한다. 물론 이는 개발 환경의 경우이고, NODE_ENV 환경 변수의 값이 production으로 설정되어 있다면 에러 화면을 대신 응답한다. 그리고 디폴트 에러 핸들러는 응답이 이미 일부 쓰인 상황에서 에러가 발생하는 경우 해당 연결을 닫고 요청을 실패 처리하는 기능까지 포함하고 있다. 따라서 커스텀 에러 핸들러를 작성한다면, 응답이 이미 일부 쓰인 상황에서 에러가 발생하는 경우 해당 에러를 디폴트 에러 핸들러에게 위임하도록 처리해야 한다.

function errorHandler (err, req, res, next) {
  if (res.headersSent) {
    return next(err);
  }
  res.status(500);
  res.render('error', { error: err });
}