Express는 어떻게 만들어졌을까? : Router, Route, Layer

kakasoo·2021년 5월 19일
19

How to make Express

목록 보기
1/2
post-thumbnail

나는 express를 알고 있을까?

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

const server = http.createServer(app);
server.listen(PORT, () => console.log("Server is opened."));

일반적으로 node.js와 Express.js를 사용해 서버를 만든다면 위의 코드는 공통으로 들어갈 것입니다. 그렇기 때문에 위 코드를 이해하는 데에는 큰 어려움이 없을 것 같습니다. 그런데 공부를 하면서 헷갈리는 부분이 참 많았죠. 하지만 당장 서버를 만드는 데에 필요한 것 같지는 않아 방치하다 이제야 그 고민을 마주합니다.

  1. Framework는 뭐지?
  2. Server와 Express의 차이는 뭐지?
  3. ( 나아가서 ) 애초에 나는 이 둘을 "제대로" 다루고 있는 건 맞나?
  4. ( 나아가서 ) 애초에 나는 이 둘을 "알고" 다루고 있는 것은 맞나?

이런 질문들의 답은 항상 글귀를 읽기보다도 직접 코드를 작성함에서 얻곤 했습니다. 그러면 이 질문들에 답하기 위해서라도, 다시 코드를 보도록 할까요?

const http = require("http");
const express = require("express");

const app = express(); // 사실 app은 express를 이용해 만든 것이었습니다.
const PORT = 3000;
const server = http.createServer(app);

server.listen(PORT, () => console.log("Server is opened."));

핵심이 되는 부분은 여기서 app 입니다. 이 app이 express를 사용해 만든 application이기 때문입니다. 그러니까, 앞서 고민거리였던 서버와 프레임워크에 대한 구분은 이렇게 설명할 수 있을 것 같습니다.

"app은 express 범주이고, 그 app을 http에 올리면 Server인 거야."

또는

"Server를 만들 때 실질적인 동작에 관한 부분이 app에 해당해."

( + 서버와 프레임워크 모두 Node.js라는 언어로 작성되고 있고요. )

제가 이해한 것을 비유로 설명드리자면, 저는 서버와 어플리케이션의 관계를 레코드판에 비유하곤 합니다. ( 저도 레코드를 써본 적은 없습니다만 ) 우리가 축음기로 음악을 듣기 위해서는 레코드가 필요합니다. 즉, 음악을 듣기 위해서는 레코드판이 필요한 것이지만, 어떤 음악인지는 결국 레코드에 달린 문제입니다. 여기서 축음기를 Server, 레코드를 Application에 비유하면 적절할 거 같습니다.

"그러면 Express는 음악을 만들기 쉽게 도와주는 툴이라고 생각하면 되겠군요."

네. 저는 그렇게 생각하고 있습니다. 그러면 얼추 1번과 2번의 질문에 나름 답변이 된 거 같습니다. 그러면 나머지 3번과 4번을 풀기 위해서는 어떻게 해야 할까요?

운이 좋게도 3번에 대한 정답은 Express 공식 문서를 통해 해결할 수 있을 것이고, 4번에 대한 문제는 조금 복잡하긴 하지만 github에서 Express 코드를 직접 해석해보면서 해결할 수 있을 것입니다. 하지만 이는 시간을 상당히 잡아먹는 데다가, 사실 내부 구조를 모른다고 한들 Express로 웹을 만드는 게 불가능한 것도 아니기 때문에 굳이 도전해보고 싶은 마음이 크게 들지 않을 거 같습니다.

그래서 제가 대신 하고자 합니다! 여러분들을 위해 github Express.js의 코드를 읽고, 해석해서, ES6에 익숙한 여러분들이, 이해할 수 있도록 도울 생각입니다! ( 우레와 같은 박수! )

여러분들이 Express를 다뤄본 적 있는, 다뤄본 JavaScript 개발자라면, 이 글을 읽는 게 결코 시간 낭비는 아닐 것입니다.

Express 없이 서버 만들기

Express에 대해서 이해하는 가장 좋은 방법은, 사실 Express를 써보지 않는 것일지도 모릅니다. 그래서 일단 Express 없이, Express와 똑같이 동작하는 서버를 만들어보고자 합니다.

아래는 간단하게 "HELLO WORLD"를 보여주는 코드입니다.

// Express를 사용한 코드
const http = require("http");
const express = require("express");

const app = express(); // 사실 app은 express를 이용해 만든 것이었습니다.

app.get("/", (req, res, next) => {
    res.send("HELLO WORLD!");
});

const PORT = 3000;
const server = http.createServer(app);

server.listen(PORT, () => console.log("Server is opened."));
// node.js 공식 홈페이지 About page에 나와 있는 코드
const http = require("http");

const PORT = 3000;
const server = http.createServer((req, res) => {
    res.setHeader("Content-Type", "text/plain");
    res.end("HELLO WORLD!");
});

server.listen(PORT, () => console.log("Server is opened."));

app을 지웠으니, 당연히 그 부분에 대한 코드를 작성해주어야 합니다. app을 대체함으로써 우리가 작성해준 내용을 보면, 결국 아래처럼 생각해볼 수 있을 거 같습니다.

  1. app은 콜백 함수이다.
  2. URL 경로에 따라서 라우팅 해주는 기능이 필요하다.
  3. ( 위에는 get만 나와 있지만 ) method에 대해서도 라우팅 해주는 기능이 필요하다.
  4. Header들을 추가해주어야 한다.

( 물론 이 외에도 해야 할 게 아주 많습니다. 가령 res.send()는 원래 response에는 없는 method입니다.이 역시 express가 response를 확장해 만든 method이기 때문입니다. )

그러면 어떤 함수 ( ex. express )를 실행한 결과에 의해 app이 만들어진다고 가정하고 작성해봅시다. app 자체가 콜백 함수였으니, 그렇다면 그 함수는 함수를 return 하는 종류의 함수일 것입니다.

const http = require("http");
const PORT = 3000;

// 이 부분은 분리해서 모듈이나 라이브러리로 만든다고 가정합니다.
const TExpress = () => (req, res) => {
    res.setHeader("Content-Type", "text/plain");
    res.end("HELLO WORLD!");
};

const app = TExpress();
const server = http.createServer(app);

server.listen(PORT, () => console.log("Server is opened."));
const TExpress = () => (req, res) => {
    res.setHeader("Content-Type", "text/plain");
    res.end("HELLO WORLD!");
};

어떤 application (요청에 대한 처리를 담고 있는 콜백 함수) 을 생성하여 http.createServer()에 전달하기만 하면 되는 것이니, http와 그 관련 코드들은 변하지 않을 것입니다. 따라서 TExpress라는 부분만 잘 작성해두면 될 거 같습니다.

하지만 이런 방식으로 작성한다면, 각 URL 단위, 메서드 단위로 코드를 분리해낼 수 없을 거 같습니다. 일단 분리를 하고 나서 생각하는 게 좋을 거 같습니다.

URL에 따라 다르게 동작하기

메서드에 대한 고민은 일단 접어둡시다. 메서드는 오직 GET 하나만 가능한 서버를 만들 겁니다. 일단 편의 상 경로는 아래처럼 세 가지가 있다고 가정하고 해봅시다.

  • /
  • /cats
  • /dogs

세 가지 경로에 대해서 분기를 한다고 하면 이렇게도 가능할 것입니다.

const TExpress = () => (req, res) => {
    console.log("Current URL is : ", req.url);
    if (req.url === "/") {
        res.setHeader("Content-Type", "text/plain");
        res.end("root.");
    } else if (req.url === "/cats") {
        res.setHeader("Content-Type", "text/plain");
        res.end("cats.");
    } else if (req.url === "/dogs") {
        res.setHeader("Content-Type", "text/plain");
        res.end("dogs.");
    }
};

하지만 코드가 늘어날수록 관리하기 힘들 거 같습니다. 그러니 확장성을 위해서라도 URL에 따라 분리해주는 것이 좋을 거 같습니다. 우리는 이미 express에서 그런 코드를 보았습니다. 아래처럼 작성했습니다.

const app = express();

app.get('/', (req,res,next) => res.send('root.'));
app.get('/cats', (req,res,next) => res.send('cats.'));
app.get('/dogs', (req,res,next) => res.send('dogs.'));

이 코드는 app.get()을 실행하라는 의미가 아닙니다! 이 코드의 정확한 의미는 첫 번째 파라미터로 받는 path에 두 번째 파라미터로 받고 있는 콜백함수를 전달하는 것입니다. 여기서 이 콜백함수는 편의 상 handler 라고 부르도록 하겠습니다.

이렇게 특정 메서드에 대해 URL 경로와 handler를 파라미터로 주면, 해당 경로에 대해서 파라미터를 등록해주면 될 것입니다.

그러면 이걸 먼저 만들어보도록 할까요?

const TExpress = () => {
    const app = (req, res) => {
        const url = req.url;
        const method = req.method.toLowerCase();
        app[url][method](req, res);
    };

    return app;
};

먼저 함수의 실행을 고쳐보았습니다. 초기의 코드를 보면 처리를 담당하는 app 이라는 함수를 반환하는 형태였습니다. 그렇지만, 그렇게 작성할 경우 어떤 메서드, 어떤 path든 상관없이 하나의 로직을 거치게 됩니다.

따라서 분기 처리를 하기 위해서 app이라는 함수 자체를 고칠 필요가 있었습니다. 따라서 url과 method에 따라 다르게 들어가게끔 처리하였습니다.

하지만 이 코드에는 아직 메서드에 따라 handler를 등록해주는 로직이 없습니다. 그 코드를 추가해보겠습니다.

const TExpress = () => {
    const app = (req, res) => {
        const url = req.url;
        const method = req.method.toLowerCase();
        app[url][method](req, res);
    };

    app["get"] = (path, callback) => {
        if (!app[path]) {
            app[path] = {};
        }

        app[path]["get"] = callback;
    };
    return app;
};

app['get'] 부분이 메서드를 등록하는 함수인 것을 알 수 있습니다. 만약 application에 아직 그 path에 대한 객체 ( 나중에는 이것을 Router 라고 부르게 될 것입니다. ) 가 만들어지지 않았다면 일단 빈 객체를 만들어주고, 그 빈 객체에 대한 메서드로 handler를 지정해주면 됩니다.

( 여담입니다만, URL을 등록할 수는 있어도, 아직 URL에 따라 객체를 분리해주는 수준은 아닙니다. )

이제 이 코드는 동작합니다!

const http = require("http");
const PORT = 3000;
const TExpress = require('./TExpress.js');

const app = TExpress();
app.get("/", (req, res, next) => res.end("root."));

const server = http.createServer(app);

server.listen(PORT, () => console.log("Server is opened."));

아직 많은 부분에서 express에 비해 부족한 코드입니다. 하지만 겉보기만이라도 express와 비슷하게 동작하는 것 같습니다. 바로 눈에 띄는 차이점은, res.send() 함수가 express에서 만든 함수라서 아직 이 코드에서는 사용할 수 없다는 점입니다.

method에 따라 다르게 동작하기

사실 이 부분은 이미 다들 눈치챘을 것 같습니다. get 메서드를 등록하듯이, 다른 메서드들도 모두 등록해주면 되는 거니깐요. 다행히도 우리는 모든 메서드 이름을 스트링 배열로 받을 수가 있습니다.

const { METHODS } = require("http");

const TExpress = () => {
    const app = (req, res) => {
        const url = req.url;
        const method = req.method.toLowerCase();
        app[url][method](req, res);
    };

    // app["get"] = (path, callback) => {
    //     if (!app[path]) {
    //         app[path] = {};
    //     }

    //     app[path]["get"] = callback;
    // };

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

            app[path][method] = callback;
        };
    });
    return app;
};

http 모듈에는 METHODS를 받아올 수 있습니다. 이것을 forEach문을 돌면서 각각 등록하는 과정을 거치면 됩니다. 어떤가요? 차례대로 보니까 이해가 가지 않나요? 바로 METHODS를 보여주드리면 app을 생성한 후에 app.get(path, callback)과 코드 내부가 헷갈릴까봐 따로 보여드린 것이었습니다.

메서드에 따라 handler를 등록해주는 함수를 만드는 것, 그것을 실행해서 handler를 등록해주는 것, 이 둘은 명확하게 다른 영역의 코드입니다.

  1. 메서드에 따라 handler를 등록해주는 함수 ( = app[methodName] )
  2. 등록된 handler를 실행해주는 함수 ( = app )
    • 실행 시에 app 함수 내부에서 req를 읽고 올바른 경로로 route 해준다.

Router의 필요성

application은 어쩌면 Router라고 봐도 될지 모르겠습니다. 갑자기 이런 말을 하는 게 의아할 수 있겠습니다만, 사실 앞서 구현한 코드에는 Application 하나만이 존재할 뿐, 다른 Router는 존재하지 않습니다.

"그래도 사용하는 데에는 아무런 문제가 없지 않았나요?"

그렇습니다. 아직 path 기능이 완벽하진 않고 ( 지금은 완벽하게 매칭되어야만 하니깐요. ), 그 외에도 추가해야 할 게 산더미처럼 많지만, 어쨌든 원하는대로 동작하는 것을 볼 수 있긴 했습니다. 그렇지만 이렇게 되면 결국 한 덩어리로 만드는 것입니다. 당연히 코드가 길어질수록 힘들어질 것입니다.

앞서 app이라는 함수에 if문으로 분기 처리하는 게 지저분했기 때문에 분리한 거잖아요? 결국 같은 이유입니다. 지저분하다는 걸 알면 깨끗하게 해야겠죠. 이는 결과적으로 코드, 파일을 분리하는 데에 도움을 줍니다. 지금과 같은 상태로는 express의 메서드인 use를 쓸 수 없을 테니깐요.

아래는 express.js를 사용한 예시입니다.

// /dog.js
const express = require('express');

const router = express.router();
router.get('/', (req,res,next) => res.send('dogs'));

module.export = router;
const express = require('express');
const dogRouter = require('/dog.js');

const app = express();
app.use(dogRouter); // 이런 기능을 위해서라도 Router는 분리될 필요성이 있습니다.

module.export = app;

위 코드에 주석처리한 것과 같이, 나중을 위해서라도 Router는 분리될 필요성이 있습니다. 저 기능은 사실 상 각각 완성된 Router들을 하나로 묶어서 application의 구성품을 채우는 거니깐요.

그럼 다시 TExpress로 가봅시다.

const { METHODS } = require("http");

const TExpress = () => {
    const app = (req, res) => {
        const url = req.url;
        const method = req.method.toLowerCase();
        app[url][method](req, res);
    };

    METHODS.forEach((METHOD) => {
        const method = METHOD.toLowerCase();
        app[method] = (path, callback) => {
            if (!app[path]) {
                app[path] = {}; // 사실 여기가 Router였습니다.
            }

            app[path][method] = callback;
        };
    });
    return app;
};

우리는 if 문을 통해 app[path]가 없는 경우 빈 객체를 만들어주도록 하였습니다. 이 빈 객체에 대한 언급은 없었습니다만, 사실 저 부분이 Router에 해당합니다. 우리는 Router에 method를 등록해준 것이죠. 더 자세하게 하면, Router 안에 Route가 있다느니, 이런 얘기가 오갑니다만, 일단은 순서대로 진행을 해봅시다.

자, 진행에 앞서 질문을 해야 할 거 같습니다.

"application은 Router일까요?"

"Router도 application이라고 할 수 있을까요?"

제가 정의할 건 아니지만, 저는 둘이 겹치는 범위가 있다고 생각합니다. application은 그 자체로 Router라고도 생각하고요. 비유하자면, application은 나무의 줄기입니다. 이 나무의 줄기에 여러 개의 가지들이 뻗어 나갑니다. 이 가지들이 Router 입니다. 나무 줄기도 나무의 가지냐고 묻는다면, 글쎄요, 저는 그렇게 볼 수도 있지 않나 생각합니다. 사실 이 편이 더 이해하기 쉬울 거 같기도 하고요.

납득이 안될 수도 있을 거 같습니다. 그렇지만 납득을 했다고 가정하고 진행할 수 밖에 없습니다. 왜냐하면 express.js는 그런 생각을 기초로 하여 만든 것 같기 때문입니다. ( 같다고는 확언할 수 없겠습니다. ) 제 생각엔, application은 일종의 router이고, router는 일종의 mini app이라고 보는 게 맞는 거 같습니다.

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

        this.router[method] = callback; // 아직 수정할 부분이 남아 있습니다!
    };
});

따라서 this.router를 만들어주었습니다. app[path]에 해당하는 부분이 this.router 였던 것이죠. 사실 상, app이 첫번째 router이기 때문에 생성해준 것입니다. 그렇지만 이렇게 분리됨에 따라 이전에 되던 동작도 이제 먹히질 않습니다. 따라서 router에는 여러 개의 path를 담아둘 필요성이 생겼습니다.

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;
    };
});

아직 Router의 내부를 다루지는 않았지만, 이전에는 Application에 모든 path가 등록되어 있던 것을, path 단위로 나눌 수 있게 되었습니다. ( 아직 동작하지는 않습니다. ) this.router의 route method가 path에 따라 새로운 Route를 만들고, 그것을 return 해주고 있습니다.

따라서 return 받은 route의 method에 callback 등록해주게끔 수정된 것입니다! 그러면 이제 route 라는 method만 구현해두면 될 거 같습니다.

// Router.js
// 나중에는 이 파일을 클래스 별로 분리할 예정입니다.

class Layer {
  	constructor(path, option, handler) {
      	this.path = path;
        this.option = option;
        this.handler = handler;
    }
}

class Route {
    constructor(path) {
      	this.path = path;
      	this.stack = [];
    }

    dispatch(req, res, done) {
        const method = req.method.toLowerCase();

        req.route = this;

        for (const curLayer of this.stack) {
            if (curLayer.method && curLayer.method !== method) {
                continue;
            }
            curLayer.handleRequest(req, res);
        }

      	// 사실 dispatch에는 에러를 처리하기 위한 부분이 있어야 합니다.
        // throw new Error("No Layer on this route.");
    }
}

class Router {
    constructor() {
        this.stack = [];
    }

    route(path) {
        const route = new Route(path);
      	const layer = new Layer(path, {}, route.dispatch.bind(route));
        layer.route = route; // layer 안에 자신의 route가 무엇인지 등록해둡니다.
        this.stack.push(layer);

        return route;
    }
}

module.exports = Router;

갑작스럽게 너무 많은 것이 등장했지만, 일단 이것들에 대한 구분은 바로 다음 번에 말하기로 하고, 일단 동작하게끔 해봅시다. 어쨌거나, 이 코드에서 알 수 있는 것은 Router의 route method를 보면 분명해집니다. layer가 있고, layer의 내부에 route가 있습니다. Router.route method는 이런 구조를 만든 다음에, 최종적으로 생성된 layer를 router의 stack에 저장하고, route는 반환해주는 것입니다.

( 미리 말하자면, Router (app.router) 안에 여러 개의 Layer가 있고, 그 Layer는 기능이 되는 함수나 path를 저장하는 쌍입니다. Layer는 저장하는 대상이 달라질 수 있기 때문에 나중에 설명드리고자 하는 것입니다! )

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가 반환될 것이고, 그 route의 method에 callback 함수가 등록된 것까지, 이제 이해가 될 것입니다.

하지만 이 상태로는 이벤트를 처리해주지 못합니다. app 함수도 수정을 해줘야 하니깐요.

const { METHODS } = require("http");

const TExpress = () => {
    const app = (req, res) => {
        // const url = req.url;
        // const method = req.method.toLowerCase();
        // app[url][method](req, res);

        if (!this.router) {
            throw new Error("No router defined on this app.");
        }
        this.router.handle(req, res); // 이제 this.router를 통해서 처리하게 합니다.
    };

    METHODS.forEach((METHOD) => {
    	const method = METHOD.toLowerCase();
      	app[method] = (path, ...callback) => { // callback이 배열 형태라고 가정합니다.
          if (!this.router) {
              this.router = new Router();
          }

          const route = this.router.route(path); // 이 함수가 추가되었습니다.
          // route[method] = callback;
          route[method].apply(route, callback); // 등록해주는 함수를 대입이 아니라 apply를 사용합니다.
        };
    });
    return app;
};

일단 위 코드처럼 this.router에 handle method를 통해 처리한다고 칩시다. 그러면 다시 Router 코드를 보러 가야겠군요. 거기서 handle 함수를 아래처럼 고쳐줍니다.

class Layer {
  constructor(path, option, handler) {
        this.path = path;
        this.option = option;
        this.handler = handler;
    }
}

class Route {
    constructor(path) {
        this.path = path;
      	this.stack = [];
    }

    dispatch(req, res, done) {
        const method = req.method.toLowerCase();

        req.route = this;

        for (const curLayer of this.stack) {
            if (curLayer.method && curLayer.method !== method) {
                continue;
            }
            curLayer.handleRequest(req, res);
        }

        throw new Error("No Layer on this route.");
    }
}

class Router {
    constructor() {
        this.stack = [];
    }

    route(path) {
        const route = new Route(path);
        const layer = new Layer(path, {}, route.dispatch.bind(route));
        layer.route = route; // layer 안에 자신의 route가 무엇인지 등록해둡니다.
        this.stack.push(layer);

        return route;
    }

    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.
                continue;
            }

            curLayer.handleRequest(req, res); // 3) 일치하는 Layer에게 요청된 method를 실행합니다.
        }
    }
}

module.exports = Router;

path가 같은 Layer를 찾았다면, 그 Layer에게 해당 method에 대응할 함수가 있는지를 찾아야 합니다. 없다면 탐색을 이어나가면 되고 있다면 아마도 찾고자 하는 Layer가 맞을 것입니다. 따라서 이 Layer에게 handle() 함수가 필요합니다. 이 함수들을 차례대로 만들어보겠습니다. 먼저, Route의 hasMethod입니다.

class Route {
    constructor(path) {
        this.path = path;
      	this.stack = [];
        this.methods = {};
    }

    hasMethod(method) {
//        if (this.methods.all) {
//            return true;
//        }

        return Boolean(this.methods[method]);
    }

    dispatch(req, res, done) {
        const method = req.method.toLowerCase();

        req.route = this;

        for (const curLayer of this.stack) {
            if (curLayer.method && curLayer.method !== method) {
                continue;
            }
            curLayer.handleRequest(req, res);
        }

        throw new Error("No Layer on this route.");
    }
}

그냥 Boolean 형태로, 있냐 없냐만 체크해서 돌려주면 되기 때문에 간단하게 작성되었습니다. ( methods를 저장하기 위해서 this.methods를 만들었습니다. ) 당장은 all에 대한 부분은 다루지 않겠습니다.

작성하고 보니 이상한 점을 볼 수 있습니다. this.methods에서 탐색을 하는 것은 좋은데, 애초에 우리는 this.methods에 method 이름을 넣은 적이 없었습니다. 이 부분을 먼저 해결해줘야 할 거 같습니다. 방법은 이미 알고 있을 것입니다. 우리가 application에서 method를 등록하는 함수를 만들었으니깐요.

const { METHODS } = require("http");

class Layer {
    constructor(path, option, handler) {
        this.path = path;
        this.option = option;
        this.handler = handler;
    }

    handle() {}
}

class Route {
    constructor(path) {
        this.path = path;
        this.stack = [];
        this.methods = {};

        METHODS.forEach((METHOD) => {
            const method = METHOD.toLowerCase();

            this[method] = (...handlers) => { // 1) 여러 개의 함수들을 받을 수도 있다.
                for (const handler of handlers) {
                    const layer = new Layer("/", {}, handler); // 2) 함수 하나 당 하나의 Layer를 만든다.
                    layer.method = method; // 3) layer에 method가 무엇인지 지정한다.

                    this.methods[method] = true; // 4) route에는 method가 있음을 나타낸다.
                    this.stack.push(layer); // 5) route의 stack에 layer를 넣는다.
                    return this;
                }
            };
        });
    }

    hasMethod(method) {
        if (this.methods.all) {
            return true;
        }

        return Boolean(this.methods[method]);
    }

    dispatch(req, res, done) {
        const method = req.method.toLowerCase();

        req.route = this;

        for (const curLayer of this.stack) {
            if (curLayer.method && curLayer.method !== method) {
                continue;
            }
            curLayer.handleRequest(req, res);
        }

        throw new Error("No Layer on this route.");
    }
}

또 코드가 복잡해진 것처럼 보이지만, 결국 등록해주는 함수를 만들었을 뿐입니다. 특이할 점을 1,2,3,4,5로 표시해서 주석을 달았는데 차례대로 설명해보겠습니다. 그 중에서 첫 번째인 파라미터 부분만 설명드리자면, 미들웨어처럼, 여러 개의 함수들을 받을 수 있습니다. 따라서 handlers라는 배열 형태로 받도록 한 것입니다.

( 다시 말씀드리지만, Router, Route, Layer의 관계는 추후 설명해드릴 것입니다! )

Route에도 등록하는 함수를 만들었으니, application에도 고칠 부분이 생겼습니다.

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

    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); // 등록해주는 함수를 대입이 아니라 apply를 사용
        };
    });
    return app;
};

좋습니다. 이제 정말로, 정말로 Layer.handleRequest() 부분만 작성해주면 됩니다!

( method의 이름을 handleRequest 라고 한 이유는, express의 명칭을 사용하기 위해서입니다. )

class Layer {
    constructor(path, option, handler) {
        this.path = path;
        this.option = option;
        this.handler = handler;
    }

    handleRequest(req, res) {
    	this.handler(req,res);
    }
}

여기까지 작성되었다면 코드는 정상적으로 동작합니다. 아래는 전체 코드입니다. 전체 코드를 다시 읽어보시고, 위 흐름을 이해해주셨으면 합니다. 제가 잘 설명했는지는 자신이 없습니다만, 코드는 거짓말을 안하니깐요. ( 문제가 있다면, 굳이 말씀드리지 않아도 모두 아시겠지만, express의 문제가 아니라 제가 작성한 코드의 문제입니다. )

const { METHODS } = require("http");

class Layer {
    constructor(path, option, handler) {
        this.path = path;
        this.option = option;
        this.handler = handler;
    }

    handleRequest(req, res) {
        this.handler(req, res);
    }
}

class Route {
    constructor(path) {
        this.path = path;
        this.stack = [];
        this.methods = {};

        METHODS.forEach((METHOD) => {
            const method = METHOD.toLowerCase();

            this[method] = (...handlers) => {
                for (const handler of handlers) {
                    const layer = new Layer("/", {}, handler);
                    layer.method = method;

                    this.methods[method] = true;
                    this.stack.push(layer);
                    return this;
                }
            };
        });
    }

    hasMethod(method) {
        if (this.methods.all) {
            return true;
        }

        return Boolean(this.methods[method]);
    }

    dispatch(req, res, done) {
        const method = req.method.toLowerCase();

        req.route = this;

        for (const curLayer of this.stack) {
            if (curLayer.method && curLayer.method !== method) {
                continue;
            }
            curLayer.handleRequest(req, res);
        }

        throw new Error("No Layer on this route.");
    }
}

class Router {
    constructor() {
        this.stack = [];
    }

    route(path) {
        const route = new Route(path);
        const layer = new Layer(path, {}, route.dispatch.bind(route));
        layer.route = route;
        this.stack.push(layer);

        return route;
    }

    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) {
                continue; // 각각의 Layer에서 path가 일치하는 걸 찾습니다.
            }

            if (!curLayer.route.hasMethod(method)) {
                continue; // 일치한다면 method에 해당하는 함수가 있는지 체크합니다.
            }

            curLayer.handleRequest(req, res); // 일치하는 Layer에게 요청된 method를 실행합니다.
        }
    }
}

module.exports = Router;

prototype으로 되어있던 것을, 제 나름대로 ES6로 고쳐가며 만들고 있습니다. 틀린 점이 있다면 언제든지 말씀해주세요. 저도 아직 모르는 게 많은 개발자기 때문에 ( 아직 학생입니다. ) 많은 분의 도움이 필요합니다.

Express의 구조 ( Router, Route, Layer )

잠깐 쉬어가는 느낌으로, 그림을 가지고 설명하겠습니다. 플로우 차트 기호 같은 걸 전혀 모르셔도 좋습니다. ( 애초에 프로그램 시작 말고는 그걸 따르지도 않았습니다. 저도 잘 몰라서요! )

지금 생각하니 괜히 코드를 넣었나 싶네요. 처음에 프로그램이 시작하면 express() 함수는 application을 만듭니다. application은 생성됨과 동시에 HTTP의 각 메서드들에 handler 함수를 등록하는 함수를 만듭니다.

이후 app.get, app.post 등과 같이 첫 번째 파라미터로 path를, 두 번째 파라미터로 handler 함수를 전달해주면 아까 생성된 함수들이 동작합니다. 앞에 get, post 등 HTTP 메서드 이름으로 함수를 실행합니다. ( 편의 상 저는 Register 함수라고 부르고 있습니다. )

이 레지스터 함수는 this.router를 생성하고, Layer, Route를 path에 맞게 만든 다음 handler를 등록해주는 함수입니다. 실행될 경우 우측과 같이 Layer를 만듭니다. 물론 Layer만 만들고 끝나는 게 아닙니다.

Layer 안에서 Route가 만들어집니다.

Layer 내부에 만들어진 Route는 또 다시 여러 개의 Layer를 가지는 중첩 구조로 되어 있습니다. 그런데 이번 Layer들은 path를 가지는 대신에 method를 분기로 가집니다. 각 method별 handler에 따라 Layer가 생성됩니다!

여기까지가 서버를 작성하는 흐름입니다. 물론 실제로는 app.get() 메서드 3개만 작성했기 때문에 각 Layer의 Route 내부에 handler 함수는 get만 등록되어 있을 것입니다.

다음으로는 사용자가 get 요청을 보냈을 때 어떻게 되는지를 보겠습니다.

사용자 요청이 들어오면 app이 실행됩니다.

const server = http.createServer(app);

사실 위 코드는, app으로 처리되는 server를 만든다는 의미를 가지고 있습니다. 따라서 app은 함수였습니다. app(req,res)가 실행되는 셈이네요. 그렇기 때문에 express 내부에서 app은 자신이 가진 router의 handle 메서드를 실행시킵니다.

app.router ( 코드에서는 this.router )의 handle은 자신의 stack ( 코드에서는 this.stack )에서 path가 일치하고 HTTP 메서드에 함수도 제대로 등록이 된 Layer가 있는지를 체크합니다. 있으면 그걸 실행시키면 되는 거죠.

( 우리 코드에서는 에러 처리가 많이 빈약합니다. )

위에서 말한 this.stack은 Layer들을 저장한 공간이었습니다.

사실 이런 느낌이었던 거죠. 설명에 자신이 없어서 그림을 많이 추가해보았습니다.

자, 진행에 앞서 질문을 해야 할 거 같습니다.

"application은 Router일까요?"
"Router도 application이라고 할 수 있을까요?"

제가 정의할 건 아니지만, 저는 둘이 겹치는 범위가 있다고 생각합니다. application은 그 자체로 Router라고도 생각하고요. 비유하자면, application은 나무의 줄기입니다. 이 나무의 줄기에 여러 개의 가지들이 뻗어 나갑니다. 이 가지들이 Router 입니다. 나무 줄기도 나무의 가지냐고 묻는다면, 글쎄요, 저는 그렇게 볼 수도 있지 않나 생각합니다. 사실 이 편이 더 이해하기 쉬울 거 같기도 하고요.

저는 앞서 이런 글을 작성했었는데, 이제 이 말을 왜 했는지 설명할 수 있을 거 같습니다. Router 안에 Layer들이 있습니다. Layer들은 각각 path를 기준으로 생성이 됩니다. Layer들은 다시 Route를 가집니다. Route 안에 Layer들이 있습니다. Layer들은 각각 method를 기준으로 생성이 됩니다.

뭔가 Router 내부의 Layer와 Route 내부의 Layer가 비슷한 구조인 거 같지 않나요?

Layer만 비슷한 게 아닙니다. Router와 Route도 비슷한 구조죠. 둘 다 메서드를 등록하기 위한 register 함수를 가지고 있는 것도 똑같습니다. 등록을 하고, 실행하는 것까지 똑같죠. 그래서 이름을 Router, Route 라고 지은 걸지도 모르겠습니다.

이제 여기까지 이해하셨다면, 저와 같이 프레임워크의 기능을 좀 더 다채롭게 만들어 봅시다. ( To be continue... )


21.05.24

  • Route.prototype.dispatch 에서 Error 처리가 주석처리되지 않은 문제 수정.
  • 이미지 경로로 인해 alt가 나오는 것을, 경로 수정.

21.05.25

여기서 전체 코드를 확인하실 수 있습니다. ( 해당 브랜치의 src 폴더 )

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

4개의 댓글

comment-user-thumbnail
2021년 5월 20일

좋은 글 감사합니다. :-) 다음 글도 기대되네요 ~!

1개의 답글
comment-user-thumbnail
2021년 5월 27일

정말 좋은글이네요 즐겨찾기 추가하고 자주 볼게요 감사합니다!

1개의 답글