Express는 어떻게 만들어졌을까? : 미들웨어 편

kakasoo·2021년 5월 29일
2

How to make Express

목록 보기
2/2
post-thumbnail

어떻게 글을 써야 모두가 좋아할 글이 될 수 있을까요?

지난 시간에 이어서

저번 시간에는 Express의 구조를 보았습니다. 복습 겸 말씀을 드리자면 ( 제 복습이기도 합니다. ) Express는 Application을 내보내는 함수였습니다. Application은 router를 프로퍼티로 가지고 있었고, 따라서 Application을 Router의 일종으로 보아도 된다고 하였습니다.

다시 이 router는 path에 따라서 Layer를 가지고 있습니다. Router가 경로를 분기해주는 친구라고 한다면, Layer는 도달점이라고 생각하면 되겠군요. 즉, Router는 path에 따라 다른 Layer로 안내해준다고 하면 되겠습니다.

( App의 Router를 지칭할 때는 router, prototype이나 class를 지칭할 때는 Router라고 하고 있습니다. )

그렇지만 이 Layer가 마지막 도달점인 것은 아닙니다. 각각의 method에 따라 다른 handler 함수들을 가지고 있기 때문입니다. ( 이 handler는 사용자가 등록해줘야 했습니다. ) 따라서 Layer는 다시 Route를 가지고 있고, 이 Route는 다음 Layer로 분기 처리해주는 친구입니다.

아마도 Application의 Router와 구분하기 위해서 Route라고 이름 지었겠죠?

Route는 method에 따라 각기 다른 Layer를 가지고 있습니다.

그래서 아래처럼 그림이 그려진다고, 제가 말씀드린 거 같습니다.

그런데 이거로 모든 구조가 끝난 것은 아닙니다. 지금까지 말한 것이 뼈대라고 한다면, 더 다양한 기능을 위해서, 다른 기관이 필요하겠죠. 뼈대 다음이면 근육이라 표현해야 할까요?

따라서 아래처럼 해야 할 거 같네요.

이 글에서 설명하는 주제는?

  1. 우리는 Custom Response와 Custom Request를 만들어야 합니다.
    • 특히나 Response에는 다양한 응답을 할 수 있도록 함수들을 만들어 주어야 합니다.
  2. router.use()를 구현합니다.
    • 그러면 당연히 app.use ( Application.use ) 도 구현해야 겠지요!
    • 갑자기 뜬금없이 use가 나온 이유, 뒤에서 설명됩니다!
  3. 초기 설정을 정해주어야 합니다.
    • 예컨대 express는 x-powered-by 같은 custom header를 삽입하기도 하죠.
    • 사실 서버가 자신의 프레임워크를 밝히는 게 좋다고는 생각은 안합니다.
    • 초기 설정에는 사실 비밀이 있습니다!
  4. 저번에 함수를 여러 개 받을 수 있게 했지만 실제 동작을 테스트 하진 않았으니, 오늘 합시다!
    • 미들웨어를 사용하면서 홈페이지에 메시지 띄우기 ( OK )
    • 이 때는 오늘이라고 썼는데, 결국...

Express, Application, 그리고 그 내부는?

따라서, 일단 저번의 파일에서 Application을 T-Express로부터 분리합니다.

( TExpress는 제가 만든 Express clone을 지칭하는 것입니다. )

const { METHODS } = require("http");
const Router = require("./Router");

const App = (req, res, next) => {
    if (!this.router) {
        throw new Error("No router defined on this app.");
    }
    this.router.handle(req, res, next);
};

METHODS.forEach((METHOD) => {
    const method = METHOD.toLowerCase();
    App[method] = (path, ...callback) => {
        if (!this.router) {
            this.router = new Router();
        }

        const route = this.router.route(path);
        // route[method] = callback;
        route[method].apply(route, callback);
    };
});

module.exports = App;
const Application = require("./app");
const PORT = 3000;

const TExpress = () => {
    const app = Application;
    return app;
};

module.exports = TExpress;

다시 한 번 파일을 분리를 하고 나니 TExpress가 상당히 볼품없어진 것 같습니다.

하지만 괜찮습니다. 이제 이 함수에 새로운 코드를 추가해 넣을 거니깐요. 다만, TExpress는 이제 Application을 반환하는 함수라고만 정의해서는 안될 거 같습니다. TExpress는 app의 구성품을 채운다고 생각해야 할 거 같습니다.

그런데 질문들이 나올 수 밖에 없네요.

"app의 구성품이 뭔데요?"

app의 구성품은 위에서 언급된 Request와 Response가 있습니다. Request가 처리를 거쳐 Response로 돌아오죠. 이 과정에서 처리가 Application이었습니다. 이제 Request와 Response를 만들면 됩니다.

"이미 Request와 Response는 있지 않나요?"

맞아요. 사실 http.createServer(app)을 넣은 시점에서, Request, Response는 app의 파라미터로 주어지게 됩니다. 매번 사용자 요청이 있을 때마다 주어지죠. 하지만 지금 코드의 Response에는 render, json, jsonp, send 등 express에서 자주 쓰던 함수가 하나도 없습니다. 이런 걸 채워주지 않는다면 편의성을 느끼긴 힘들 거 같습니다.

이런 함수들은, 각각의 함수에 맞는 데이터를 전달해주는 것 외에도, header를 설정하는 데에도 도움을 줄 겁니다.

"흠, 그러면 요청(Request)과 응답(Response) 객체는 http가 application으로 넘기는 거고..."

"그렇죠?"

"그러면 요청과 응답이 들어오고 나서 application에서 custom Request, Response로 바뀌나요?"

"그렇죠! 뭔가 떠오를 거 같지 않나요? 우리는 이런 구조를 많이 봤습니다. 바로 미들웨어입니다."

( 또 express에서는 사실, app.request.app과 같이 app의 request가 app 자신을 가리킬 수 있게 순환 참조로 되어 있습니다. Request만 그런 게 아니고 Response도 마찬가지고요. 이런 부분도 구현을 해둬야 할 거 같아요. )

첫번째 미들웨어

// Request.js
const http = require("http");

class Request extends http.IncomingMessage {
    constructor() {
        super();
    }
}

module.exports = Request;
// Response.js
const http = require("http");

class Response extends http.ServerResponse {
    constructor() {
        super();
    }

    send(message) {
        this.end("send Func : " + message);
    }
}

module.exports = Response;
const Application = require("./application");
const Request = require("./Request");
const Response = require("./Response");

const TExpress = () => {
    const app = Application;

    app.request = Object.create(Request.prototype, { // 프로토타입으로 지정해야 합니다.
        app: {
            configurable: true, // 객체의 프로퍼티를 수정하거나 삭제 가능
            enumerable: true, // 객체 속성 열거 시 노출 여부
            writable: true, // 할당 연산자로 수정 가능 여부
            value: app, // 값
        },
    });

    app.response = Object.create(Response.prototype, {
        app: {
            configurable: true, // 객체의 프로퍼티를 수정하거나 삭제 가능
            enumerable: true, // 객체 속성 열거 시 노출 여부
            writable: true, // 할당 연산자로 수정 가능 여부
            value: app, // 값
        },
    });

    return app;
};

module.exports = TExpress;

위 코드는 custom Request와 Response는 class 방식으로 작성하되, TExpress에서는 Object.create를 사용하였습니다. TExpress는 express에 있는 코드를 그대로 따라 쓴 것입니다. 사실 Object.create로 하고 싶지 않을 수도 있습니다만, 그렇게 하는 편이 좋을 거 같다고 판단했습니다. 되도록이면 ES6 코드로 고쳐드리고 싶었지만, 프로토타입을 사용한다고 해서 딱히 구식의 코드인 것은 아니니깐요. 이보다 명확한 게 없다면 굳이 ES6로 고칠 필요는 없으니깐요. 그래서 그냥 그대로 가져왔습니다.

( 다른 말로 하면, 제가 이보다 잘 구현할 자신이 없었습니다. ) 혼자 공부하는 거라면 원작보다 조금 부족할지라도 자신만의 코드를 만들고 좋아할 수야 있겠지만, 다른 분들께 보여준다고 생각하면 마냥 그럴 수도 없더군요. 이는 원래 코드를 참고했습니다.

하지만 이렇게 한다고 해서 동작하는 것은 아닙니다. 왜 일까요?

아직 위는 app의 프로퍼티에 각각 request, response라는 이름으로 값을 넣어준 것 뿐이지, 실제로 app 함수가 동작할 때 request, response가 바뀐 것은 아니기 때문입니다.

언제든지 바꿀 수 있게 그저, 값을 미리 알려주었을 뿐입니다. 그러면 실제로 바꿔주는 코드가 있어야 겠군요.

"그러면 그게 첫 미들웨어가 되야 겠군요. 그 미들웨어는 app의 request, response를 참고하는 거고."

"맞아요. 초기 설정으로 해주고 싶지만, 사용자가 request를 보내야 그 request를 확장할 수 있지, 보내지도 않은 request를 확장할 수는 없으니깐요. 미들웨어를 통해서 request를 확장해주는 게 옳습니다."

맞습니다. 하지만 대입 연산하듯이 바꾸는 게 다는 아니겠죠? 아래 코드를 먼저 봅시다.

// application.js
const { METHODS } = require("http");
const Router = require("./Router");

// rename : App to Application
const Application = (req, res, next) => {
    if (!this.router) {
        throw new Error("No router defined on this app.");
    }
    this.router.handle(req, res, next);
};

METHODS.forEach((METHOD) => {
    const method = METHOD.toLowerCase();
    Application[method] = (path, ...callback) => {
        if (!this.router) {
            this.router = new Router();
        }

        const route = this.router.route(path);
        // route[method] = callback;
        route[method].apply(route, callback);
    };
});

module.exports = Application;

위 코드는 Application인데요, 예컨대 여기에 request와 response를 확장한 것으로 바꿔주는 코드를 넣어주면 해결이 되는 걸까요?

결론부터 말씀드리자면 그렇지 않습니다. 일단, req와 res는 알고 있지만 우리가 확장해서 사용할 Request, Response가 없습니다. 그리고 있다고 하더라도 무턱대고 바꿔서도 안 됩니다. 그냥 대입 연산 하듯이 바꿔버리면 req 객체에 있던 url과 method가 사라져 버립니다.

Router.use()와 미들웨어

이 차례에서, 일단 use가 필요합니다. 왜냐하면, 들어온 Request와 Response를 저희가 만들 custome Request, Response로 바꿔주는 게 사실, 첫 번째 middleware이기 때문이죠. 이 부분은 이미 충분히 납득할 수 있습니다. 사실 이미 써봤잖아요? 물론 미들웨어를 use 없이 그냥 구현해도 되지만, 앞으로 많이 쓰일 것이기 때문에 이 타이밍에 구현하는 게 적절할 거 같습니다.

미들웨어라는 아이디어를 착안하고 구현에 옮긴 게 언제인지는 모르겠습니다만, 실제 Express.js에서도 미들웨어가 첫 등장하는 순간이 이 Request와 Response니깐요.

일단 Router.use가 제대로 동작하는지 알려면 Middleware가 있어야 합니다. 이 논리 흐름을 잠시 따라와 주시겠어요?

  1. 일단 Router.use를 만들고 싶다!
  2. 근데 Router.use가 동작하는지 보려면 미들웨어가 있어야 겠네?
  3. 그런데 미들웨어가 있으려면 next()가 있어야 겠네?
    • Express를 다뤄보지 않았다면 모를 수도 있습니다.
  4. 그러면 application의 요소 어딘가에 request, response, next를 넣어주어야겠다!
  5. 그러면 어차피 만들 미들웨어, 이걸 첫 번째 미들웨어로 만들지 뭐!

"하이고... 만들 게 산더미처럼 많군요... next랑 router.use랑 middleware랑... 쉽지가 않습니다."

일단은, Middleware.init() 이라는 함수가 있다고 가정합시다. 이 코드는 아래와 같습니다.

// Middleware.js
// 새로 추가된 파일입니다.

const Middleware = {
    init: (app) => (req, res, next) => {
        req.res = res;
        res.req = req;
        req.next = next;

        Object.setPrototypeOf(req, app.request);
        Object.setPrototypeOf(res, app.response); // 이게 된다면, res에도 send 메서드가 생깁니다.

        next();
    },
};

module.exports = Middleware;
// Application.js
const { METHODS } = require("http");
const Router = require("./Router");
const MiddleWare = require('./MiddleWare'); // 새로 추가된 부분입니다!

const Application = (req, res, next) => {
    if (!this.router) {
        throw new Error("No router defined on this app.");
    }
    this.router.handle(req, res, next);
};

METHODS.forEach((METHOD) => {
    const method = METHOD.toLowerCase();
    Application[method] = (path, ...callback) => {
        if (!this.router) {
            this.router = new Router();
            this.router.use(Middleware.init(this)); // 새로 추가된 부분입니다!
        }

        const route = this.router.route(path);
        route[method].apply(route, callback);
    };
});

module.exports = Application;

그렇게 해서 구현된 middleware를 require한 다음, Method에 넣어줍시다. 주석을 확인해보면 METHOD의 forEach문에 Middleware.init이 this.route.use의 파라미터로 들어간 게 보일 겁니다. 이 부분이 바로 express에 첫 번째 미들웨어로 아까 말한 바와 같이 동작합니다!

아래는 express.js의 코드를 참고로 하여 만든 use 함수입니다. Router class의 메서드입니다.

use(...fn) {
      let path;
      if (fn.length !== 1 && typeof fn[0] !== "function") {
          path = fn.unshift();
      }

      for (const middleware of fn) {
          if (typeof middleware !== "function") {
              throw new TypeError(
                  `Router.use() requires a middleware function but got a ${typeof middleware}`
              );
          }

          const middlewareLayer = new Layer(path, {}, fn);
          middlewareLayer.route = undefined; // 미들웨어기 때문에 분기처리하지 않는다. 다음으로 이동하게 한다.

          this.stack.push(middlewareLayer);
      }
      return this;
  }

미들웨어로는 함수의 배열이 들어올 수 있게 하였습니다. 그렇지만 첫번째 인자로 path가 들어올 수도 있기 때문에, length가 1이 아니고 fn[0]이 function이 아닌 경우는 path라고 생각했습니다.

그래서 path를 fn 배열에서 뽑아주고, fn 배열을 돌면서 각각의 미들웨어를 Layer로 만들어서 추가해주는 방식입니다. express를 보니 Layer에 또 다른 목적이 있다는 것을 알 수 있군요.

Layer는 path, handler 외에도 middleware를 간직하는 용도도 있던 모양입니다.

정리하면, 지금까지 한 것은 이렇습니다.

  1. Application에서 router를 만들어줄 때, router에 미들웨어를 넣었습니다.
  2. 위 동작을 위해 Router.use() 메서드를 만들었습니다.
  3. 위 동작을 위해 Middleware.init() 이라는 메서드를 만들었습니다.

하지만, 놀랍게도, 당연하다는 듯이 이 프로그램은 동작하지 않습니다. 왜냐하면 지금 우리가 만든 코드에서는, 미들웨어의 Layer를 방문하질 않고 있거든요! 우리는 이제 next() 에 대해서도 고민해봐야 합니다! 하나의 Request에 대해서 여러 개의 Layer를 방문할 수 있게 해주어야 합니다.

( 더 멀리 본다면, 나중에는 path를 정규표현식을 써서 고칠 필요도 있겠습니다. )

next()

앞서, 뜬금없이 next()가 나왔습니다만, 이는 express.js를 다뤄본 사람이라면 누구나 봤을 거라고 생각했기 때문입니다. 그렇지만 next()가 정확히 무엇인지에 대해서 말하라 한다면 다들 곤란할 거 같아요. 이름이 워낙 직관적인지라 저도 그냥 "다음 꺼!" 라는 생각만 들더군요.

우리가 사용한 방식에 따르면, next는 다음 Middleware Function으로 넘어가거나, 다음 handler로 넘어가거나, 또는 err 처리를 할 때 사용하곤 했습니다.

이를 지금까지 이야기한 걸 통해서 설명하면, 이렇게 설명할 수 있을 거 같아요.

"next는 다음 Layer (Middleware Function, handler, error handler ) 로 넘어가는 함수이다."

// Router.js의 Router.handle 함수
handle(req, res) {
    const url = req.url; // 1) url을 알아낸 다음에,
    const method = req.method.toLowerCase();

    for (let i = 0; i < this.stack.length; i++) {
        const curLayer = this.stack[i];

        if (curLayer.path !== url) {
            // 2) 각각의 Layer에서 path가 일치하는 걸 찾습니다.
            continue;
        }

        if (!curLayer.route.hasMethod(method)) {
            // 3) 일치한다면 method에 해당하는 함수가 있는지 체크합니다.
            continue;
        }
        curLayer.handleRequest(req, res); // 3) 일치하는 Layer에게 요청된 method를 실행합니다.
    }
}

위 코드는 기존에 구현한 코드입니다. 그런데 이 코드는 next()의 역할을 대신 하기도 했습니다. 원래 이동하는 건 next()의 역할인데, handle 함수가 실행될 때 탐색까지 해버리고 있죠. 이걸 분리할 때가 온 거 같습니다.

자바스크립트는 함수 안에 함수를 구현할 수 있으니, 이를 이용해봅시다.

// Router.js의 Router.handle 함수
handle(req, res, out) {
    let idx = 0;
    const url = req.url;
    const method = req.method.toLowerCase();

    const next = (err) => {
        if (err) {
            throw new Error(`next(), It may be Router or Layer Error.`);
        }

        while (idx < this.stack.length) {
            const curLayer = this.stack[idx++];

            // 여기도 나중엔 별도의 함수로 대체해줍시다.
            if (curLayer.path !== url) {
                continue;
            }

            // 미들웨어의 경우 route가 없을 것이기 때문에 건너 뜁니다.
            if (!curLayer.route) {
                curLayer.handleRequest(req, res, next);
                continue;
            }

            if (!curLayer.route.hasMethod(method)) {
                continue;
            }

            curLayer.handleRequest(req, res, next); // 추가로 next도 이제 넣어주었습니다.
        }
    };
    next();
}

일단 handle에서 next를 분리해준 다음에 넣어줍시다. 그 다음, 미들웨어를 처리하기 위해 가운데 if문을 하나 추가했습니다. 미들웨어의 경우 함수를 바로 실행시켜줄 수 있게 handleRequest를 실행시키고 continue 해주었습니다.

또한 for문을 while문으로 바꾸어 주었습니다. 이는 idx 라는 값을 토대로 몇 번째 layer를 방문했는지 체크하기 위함입니다. 미들웨어에서 next를 했을 때, 그 idx 이후의 값부터 탐색하게 하기 위함입니다.

( 아무래도 for문이면, 이전의 idx를 체크하기가 힘들기 때문이에요. for문으로 해도 로직을 구성하는 데 애로사항이 없다면 상관이 없습니다. )

참고로 저는 req,res가 있는 부분이면 이제 next를 모두 넣어주었습니다. ( 대신에 done이 있으면 done을 next 대신 파라미터로 넣어주었고, next가 이미 있는 구간이라 선언이 중복된다면 바깥 쪽 next를 out이라는 명칭으로 고쳐주었습니다. )

다만, 이런 부분은 나중에 에러 처리를 할 때 한 꺼번에 정리를 하도록 하겠습니다. 지금은 번잡해지더라도 이해해주세요.

주의할 점

만약 여기까지 따라했을 때, write 이후에 end 명령을 해선 안 된다는 내용의 에러가 나온다면, 또는 그와 비슷한 에러가 나온다면 아마도 이는 response의 end method가 2번 이상 발생했기 때문입니다. 이미 응답한 이후에는 새로운 메시지를 보낼 수 없다는 의미입니다.

Application도 클래스 형태로 만들자

지금껏 길게도 설명했지만, 이 코드들은 동작하지 않습니다.

// Application 코드의 의 일부
const Application = (req, res, next) => {
    console.log("Application :", Application);
    console.log("Application : ", this);
    if (!this.router) {
        throw new Error("No router defined on this app.");
    }

    this.router.handle(req, res, next);
};

여기서 console.log 두 개를 찍었습니다. 위와 아래의 차이는 뭘까요?

// 1번째 console.log
Application : <ref *1> [Function: Application] {
  acl: [Function (anonymous)],
  bind: [Function (anonymous)],
  checkout: [Function (anonymous)],
  connect: [Function (anonymous)],
  copy: [Function (anonymous)],
  delete: [Function (anonymous)],
  get: [Function (anonymous)],
  head: [Function (anonymous)],
  link: [Function (anonymous)],
  lock: [Function (anonymous)],
  'm-search': [Function (anonymous)],
  merge: [Function (anonymous)],
  mkactivity: [Function (anonymous)],
  mkcalendar: [Function (anonymous)],
  mkcol: [Function (anonymous)],
  move: [Function (anonymous)],
  notify: [Function (anonymous)],
  options: [Function (anonymous)],
  patch: [Function (anonymous)],
  post: [Function (anonymous)],
  pri: [Function (anonymous)],
  propfind: [Function (anonymous)],
  proppatch: [Function (anonymous)],
  purge: [Function (anonymous)],
  put: [Function (anonymous)],
  rebind: [Function (anonymous)],
  report: [Function (anonymous)],
  search: [Function (anonymous)],
  source: [Function (anonymous)],
  subscribe: [Function (anonymous)],
  trace: [Function (anonymous)],
  unbind: [Function (anonymous)],
  unlink: [Function (anonymous)],
  unlock: [Function (anonymous)],
  unsubscribe: [Function (anonymous)],
  request: Function { app: [Circular *1] },
  response: Function { app: [Circular *1] }
}

// 2번째 console.log
Application :  { router: Router { stack: [ [Layer], [Layer] ] } }

결과는 이렇습니다.

Application과 this는 다릅니다. 이 Application의 경우 실행될 때의 객체를 따라가기 때문에 this는 app이 됩니다. Application과 app ( TExpress의 실행 결과로 생성된 app )인 거죠.

"아니, Route에서도 forEach를 썼는데 이 때는 this binding 문제가 없었잖아요?"

"이 문제를 설명하려고 하면 어디서부터 어디까지 설명할지 난감합니다. 일단 제가 말씀드릴 수 있는 건 function으로 만드는 것과 화살표 함수를 쓰는 것의 this binding은 다르다는 것이 첫 번째고, 두 번째는 class 내부에서의 this는 인스턴스를 가리키게 된다는 점입니다."

첫 번째를 말씀드리는 이유는, Express.js는 화살표 함수가 없던 시절의 코드였기 때문에 전부 function으로 해결하였고, 필요에 따라 function.prototype.bind로 해결하곤 했습니다. 또는 this를 self 라는 변수에 담아 쓰고는 했죠. ( 사실 좋은 방법은 아니라고 생각합니다. express가 잘 만들어졌기 때문에 문제가 없을 뿐이지, 이렇게 변수에 담으면 확장성이 떨어지니깐요. )

두 번째를 말씀드리는 이유는, 마찬가지로 Express.js에서는 class도 없었다는 점 때문입니다. 당연히 만드는 방식이 다를 수 밖에 없습니다.

갑자기 Express 얘기를 조금 하게 되었는데요, 제가 이걸 코드로 재현할 당시에, 당장 Router, Route, Layer는 class로 만들어도 무방하다는 생각이 들었습니다. 그렇지만 Application은 다릅니다. Application의 경우, server의 처리기 역할을 하는 함수여야 하니깐요.

그래서 일단 Application만 클래스 형태를 띄지 않은 것입니다. 클래스로 바꾸지 않으면 밑처럼 될 겁니다.

const { METHODS } = require("http");
const Middleware = require("./Middleware");
const Router = require("./Router");

const Application = (req, res, next) => {
    if (!Application.router) {
        throw new Error("No router defined on this app.");
    }

    Application.router.handle(req, res, next);
};

METHODS.forEach((METHOD) => {
    const method = METHOD.toLowerCase();
    Application[method] = (path, ...callback) => {
        if (!Application.router) {
            Application.router = new Router();
        }

        Application.router.use(Middleware.init(Application));

        const route = Application.router.route(path);
        route[method].apply(route, callback);
    };
});

module.exports = Application;

this binding 문제를 해결해줄 때 위처럼 할 수도 있습니다. 이후로도 고칠 부분은 있겠으나, 위처럼 this를 모두 Application으로 바꾸면, 굳이 this를 사용할 필요 없이 해결할 수는 있습니다.

문제는 없으나, 사실 예쁜 코드는 아니라고 생각이 듭니다. 모두 예쁜 코드를 원하실 겁니다.

그러니 조금 돌아가더라도, 클래스를 만들어봅시다.

const { METHODS } = require("http");
const Middleware = require("./Middleware");
const Router = require("./Router");

class Application {
    constructor() {
        METHODS.forEach((METHOD) => {
            const method = METHOD.toLowerCase();
            this[method] = (path, ...callback) => {
                if (!this.router) {
                    this.router = new Router();
                }

                this.router.use(Middleware.init(this));

                const route = this.router.route(path);
                route[method].apply(route, callback);
            };
        });
    }

    handle(req, res, next) {
        if (!this.router) {
            throw new Error("No router defined on this app.");
        }

        this.router.handle(req, res, next);
    }
}

module.exports = new Application();

클래스로 수정하였습니다. 이제 이 Application로 생성된 인스턴스가 실행(?)될 때 인스턴스의 handle 메서드가 실행되면 됩니다. ( 인스턴스인데 실행된다고 하니 조금 어색하죠? )

function과 화살표 함수의 차이에 주의!

제목과 달리 이 차이를 설명하지는 않습니다. 앞서 만든 application의 메서드를 약간 수정하겠습니다. 😂

const { METHODS } = require("http");
const Middleware = require("./Middleware");
const Router = require("./Router");

class Application {
    constructor() {
        METHODS.forEach((METHOD) => {
            const method = METHOD.toLowerCase();
            this[method] = function (path, ...callback) // 달라진 부분입니다!
                this.lazyRouter(); // 달라진 부분입니다!

                const route = this.router.route(path);
                route[method].apply(route, callback);
            };
        });
    }

    lazyRouter() // 생성자에서 분리해낸 부분입니다. 이유는 다음에 나옵니다!
        if (!this.router) {
            this.router = new Router();
        }

        this.router.use(Middleware.init(this));
    }

    handle(req, res, next) {
        if (!this.router) {
            throw new Error("No router defined on this app.");
        }

        this.router.handle(req, res, next);
    }
}

module.exports = new Application();

달라진 부분은 function입니다. 전부 다 화살표 함수로 구현하고 싶었는데, 그게 불가능한 거 같아 부득이하게 function 키워드를 사용하였습니다.

this[method] = (path, ...callback) => { // this가 Application이 됩니다.
this[method] = function (path, ...callback) // this가 app이 됩니다. ( 인스턴스 )

화살표 함수로 가능하신 분은 의견을 주셨으면 좋겠습니다.

이렇게 변경한 이유는, 앞으로 app에 추가해줘야 할 게 있기 때문입니다.

"그냥 server.createServer(app.handle) 이라고 하면 안되나요?"
"당연히 안됩니다. 그랬다가는 저 처리 함수 내부에서 this가 server되니깐요."

Function app과 Application의 mixin

const Application = require("./application");
const Request = require("./Request");
const Response = require("./Response");

const TExpress = () => {
    // const app = Application;

    const app = (req, res, next) => app.handle(req, res, next); // 바뀐 부분입니다.
    Object.setPrototypeOf(app, Application); // 바뀐 부분입니다.

    app.request = Object.create(Request.prototype, {
        app: {
            configurable: true,
            enumerable: true,
            writable: true,
            value: app,
        },
    });

    app.response = Object.create(Response.prototype, {
        app: {
            configurable: true,
            enumerable: true,
            writable: true,
            value: app,
        },
    });

    return app;
};

module.exports = TExpress;

이제 이 긴 시간이 끝났습니다.

원래는 app이 곧 Application이었지만, 이제는 app은 app.handle을 실행하는 함수로 만들어야 합니다. 클래스에서 실제 처리를 담당하는 handle 메서드를 만들었기 때문입니다.

당연히 function app에는 app.handle이 없을 것이기 때문에, app에 Application을 mixin 해주어야 합니다. Express.js를 만들 때에는 mixin이라는 라이브러리를 사용했지만, 우리에겐 필요없는 라이브러리입니다. Object 메서드에 이미 있으니깐요!

( Express.js에서는 Array.prototype.flat도 없고, Object.create, setPrototypeOf, Object.create 등을 모두 라이브러리로 구현하고 있습니다. 제가 그 당시 개발을 해본 적이 없습니다만, 아마 ECMAScript에 해당 기능들이 표준으로 정립되지 않았을 시절이어서 그런 거 같습니다. 추론입니다. )

아직은 Custom Response의 send가 원래 Response의 end와 다를 게 없지만, 일단 사용자가 직접 정의해서 쓸 수 있다는 점에서 장족의 발전입니다.

"아직 미들웨어는 완성되지 않았는데요? Application.use()가 없잖아요?"

"맞습니다. Application.use()가 없죠. 일단 첫 번째 미들웨어인 Middleware.init을 위해 router에다가 직접 구현했으니깐요. 바로 아래 코드로 보여드리겠습니다."

Application.use()와 app.lazyRouter()

const { METHODS } = require("http");
const Middleware = require("./Middleware");
const Router = require("./Router");

class Application {
    constructor() {
        METHODS.forEach((METHOD) => {
            const method = METHOD.toLowerCase();
            this[method] = function (path, ...callback) {
                this.lazyRouter();

                const route = this.router.route(path);
                route[method].apply(route, callback);
            };
        });
    }

    lazyRouter() {
        if (!this.router) {
            this.router = new Router();
        }

        this.router.use(Middleware.init(this));
    }

    handle(req, res, next) {
        if (!this.router) {
            throw new Error("No router defined on this app.");
        }

        this.router.handle(req, res, next);
    }

    use(...fn) {
        this.lazyRouter();

        let path = "/";
        if (fn.length !== 1 && typeof fn[0] !== "function") {
            path = fn.unshift();
        }

        for (const middleware of fn) {
            if (typeof middleware !== "function") {
                throw new TypeError(
                    `Router.use() requires a middleware function but got a ${typeof middleware}`
                );
            }

            this.router.use(middleware);
        }
        return this;
    }
}

module.exports = new Application();

Application.use는 사실 router.use를 살짝 변형한 것입니다. 사실 코드를 다 가져다 쓰면서 router.use를 호출하는 역할이라고 봐도 되겠습니다.

추가적으로 this.lazyRouter가 있다는 점 정도.

우리는 lazyRouter를 앞서 handler에서 분리했습니다. 사용자의 request가 있다면 일단 handle 함수가 동작하고, 이 안에서 this.router가 있는지 없는지 체크하여 만약 아직 Router가 Application 안에 없다면 생성을 했었죠? 그걸 분리했던 겁니다. 미들웨어가 생기면 이걸 분리할 수 밖에 없습니다. 왜냐하면, 미들웨어가 먼저 작성될지, 아니면 메서드에 대한 요청이 먼저 작성될지 알 수 없기 때문입니다.

따라서 app.use() 내에서도 this.lazyRouter()를 호출해줍니다. 이미 Application 내부에 router가 있다면 상관없긴 합니다만.

lazyRouter()의 용도를 이제 알겠나요? 별 거 없어 보이지만 Express.js 코드가 워낙 길다보니 이 코드를 처음 봤을 때 왜 있는지를 알 수 없었습니다. 곳곳에 놓인 lazyRouter가 이해가 가지 않았죠. 제가 당황했기 때문인지, 다른 분들도 혹여 고심하실까 강조하게 되네요.

결론

const http = require("http");
const TExpress = require("./index");

const app = TExpress();

const PORT = 3000;

app.use(() => console.log(123));

app.get("/", (req, res, next) => res.send("send root.")); // TExpress의 메서드 별 함수를 등록하는 구간

const server = http.createServer(app); // 사용자가 들어오는 구간
server.listen(PORT, () => console.log("Server is opened."));

미들웨어로 console.log(123)을 출력하는 함수를 넣어주었을 때 제대로 동작합니다. 홈페이지에 내용도 제대로 떴고요.

21.05.29

공부를 하면서 만드는 것이기 때문에 이 글은 언제든지 수정될 수 있습니다. 제가 만든 코드가, 나중에 보니 잘못 만들어진 것일 수도 있고요. 나름 책임감을 가지고 글을 쓰고 있습니다만, 잘못된 정보나 코드를 전달할까봐 항상 노심초사하고 있습니다. 다른 JavaScript 개발자에게 도움이 될 글을 쓰고 싶은데, 레거시 코드를 ES6로 고쳐 쓰면서 설명하려니 쉽지가 않네요.

또, 말만 레거시지, Express 자체가 너무 잘 만든 코드라서, 제가 고치는 게 더 이상해보이기도 하고요. 얼마나 공부해야 이런 걸 직접 생각해내고 만들 수 있을지, 까마득한 경지긴 합니다. 도대체 왜 Koa.js를 만드는 거죠?

어쨌거나, 몇 년 동안 업데이트되고 있는 코드를, 제가 따라 만든다고 해서 즉석으로 뚝딱 하고 만들 수 있을 거라곤 생각하지 않았습니다. 다만 힘들어도, 여러 모로 배울 수 있다는 게 오히려 좋은 일이기도 하죠. 긴 글이 되고 있습니다만, 읽어주시는 분들이 계시다면 정말로 감사합니다.

21.05.31

전체 코드는 이 브랜치의 src 폴더를 통해 확인할 수 있습니다. 😁

profile
자바스크립트를 좋아하는 "백엔드" 개발자

0개의 댓글