/ JAVASCRIPT, TYPESCRIPT

Express.js 따라 만들기

ts/js 로 express 따라 만들기

ts/js 로 express 따라 만들기

node 로 웹 서버를 만들때부터 express 를 자주 사용했었는데 express 의 내부 동작은 잘 알지 못한채로 그저 app.use 를 사용했던 것 같다.

그래서 이번 기회에 express 를 socket 으로 구현해보려고 한다.

http 모듈이 아니라 왜 socket 을 사용해야할까?

일단 웹 서버 라는걸 생각해보면 당연히 http 모듈을 사용하는 것이 편하다.

소켓으로 구현한다고 해도 클라이언트가 http 프로토콜로 보낸다면 웹 서버에서도 소켓을 http 모듈처럼 사용해야할 것이다.

그럼에도 socket 으로 구현해보면 좋은 이유는 결국 http 통신도 소켓 통신 이라는 것이다.

node 의 http 모듈에서 서버를 생성할 때, 결국 소켓 모듈 을 상속받아서 사용한다.

소켓 모듈은 EventEmitter 모듈을 상속받는데, 데이터가 들어오거나 소켓 연결이 생성되거나 끊어졌을 때 등의 이벤트가 발생하게끔 만들어졌기 때문이다.

그래서 소켓을 활용한다는것은 node.js 개발자로서 가장 아래까지 내려가볼 수 있는 기회라고 생각한다.

추후에는 handshake 과정까지 만들어보고싶다.

그래도 추후에 꼭 네트워크 공부는 추가로 하자..

Socket 으로 Listen

Socket 으로 Express 를 구현하기로 했으니 당연하게도 socket 을 활용해서 서버를 만들어야한다.

이는 위에서 언급했던 node 의 net.server 를 활용하면 된다.

여기서 우리가 만들 Express 가 net.server 를 상속받을지, 아니면 net.Server 객체를 그냥 사용할지에 대해서 고민을 할 수 있는데 net.Server 를 상속받는다는 것은 net.Server 의 메소드를 오버라이딩 한다든지, 추가 메소드를 만드는 등의 작업이 있을 경우 유효하다고 생각한다.

우리의 커스텀 Express 에서는 net.Server 의 메소드들을 사용할 것이지, 추가로 메소드를 생성/변경 등을 하지 않을 것 같아서 상속 받지는 않을 예정이다.

typescript
import net from 'net';

class Express {

  listen(port: number, callback: () => void) {

    const server = net.createServer((socket) => {
      socket.on('data', (data) => {
        /*
            ...
            TODO: socket 으로 넘어온 Request 를 Express 에서 쓸 수 있도록 파싱
            ...
        */
      });
    });

    server.listen(port, () => {
      callback();
    });

  }
}


net.CreateServer 를 통해서 tcp 연결을 만들고, data 이벤트가 발생했을 때 데이터들을 가지고 우리가 사용하기 편하도록 파싱하는 과정을 거치면 될 것 같다.

🤔 socket 은 stream 이다!

여기서 생각해보면 좋을 점은 socket 은 Stream 을 활용한다.

node 의 net.Socketstream.Duplex 를 상속받아서 사용하고 있다.

Stream 에 대한 정리는 여기서 볼 수 있다.

즉, 클라이언트가 보낸 모든 데이터 가 수신될 때까지 기다리는게 아니라 stream 으로 (순차적으로) 데이터가 들어오면 그 즉시 버퍼에 쌓이고 버퍼에 데이터가 일정량 이상 쌓일 때마다 chunk 단위로 data 이벤트가 발생한다.

(보통은 버퍼가 넘치기 전에, 혹은 데이터가 전부 다 들어왔을 때 ‘data’ 이벤트가 발생한다.)

다시 말해서 들어오는 데이터가 크다면 데이터가 나누어져서 들어올 수 있다 ( === 한 번의 통신에 여러 번의 data 이벤트가 발생할 수 있다) 는 이야기이다.

따라서 buffer 의 크기보다 큰 데이터를 클라이언트에서 보냈다면, 여러 번의 data 이벤트가 발생할 수 있다.

실시간으로 데이터를 처리해야하는(ex. 유튜브 같은 영상 스트림) 경우에는 들어오는 데이터마다 처리를 해주면 된다.

반면에 용량이 매우 큰 이미지/동영상 파일은 모든 chunk 들을 다 합쳐서 하나로 만들어야할 것이다.

이에 대한 처리까지 있으면 좋을 것 같다!

소켓 통신을 http 통신 처럼

http 모듈을 사용한다면 소켓 통신으로 들어오는 RequestExpress 의 Request 처럼 바꿔줄 필요가 있다.

예를 들어 socket 으로 들어온 HTTP Request 는 아래처럼 들어올 것이다.

typescript
GET /favicon.ico HTTP/1.1
Host: localhost:3000
Connection: keep-alive
sec-ch-ua-platform: "macOS"
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36
sec-ch-ua: "Chromium";v="129", "Not=A?Brand";v="8"
DNT: 1
sec-ch-ua-mobile: ?0
Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: no-cors
Sec-Fetch-Dest: image
Referer: <http://localhost:3000/index.html>
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: sid=1234; coo=11

helloworld this is a body


이렇게 하나의 거대한 문자열을 잘 파싱해서 우리의 express 에서 쓰기 편하도록 쉽게 만드는 작업이 필요하다.

파싱의 큰 기준은 두 가지 이다.

  1. Header 와 Body 는 \\r\\n\\r\\n 으로 구분된다.
  2. Header 끼리는 \\r\\n 으로 구분된다.

맨 윗 줄을 보면, GET /favicon.ico HTTP/1.1 이라고 나오는데

Method, url, Protocol/Version 으로 구분해주어 아래 처럼 하나의 객체로 만들어주자.

typescript
class ExpressRequest {
  headers?: Header;

  body: string | object = '';

  method?: string;

  // 전체 url
  url?: string;

  protocol?: string;

  version?: string;
}


🤔 url 의 Query 부분이나 Params 부분도 처리하자

Params

실제로 express 를 써보면 app.use('/card/:username/:cardId, ...) 와 같이 : 를 사용하는 것을 볼 수 있다.

사용자가 Request 를 보낼 때는 /card/hoeh/3?columnId=5&columnName=today 같이 ? 문자 이후에 있는 query 도 있을 수 있다.

그렇다면 위에서 파싱한 url 부분에서 query, param 부분도 분리해주면 좋을 것 같다.

typescript
class ExpressRequest {
  headers?: Header;

  body: string | object = '';

  method?: string;

  // 전체 url
  url?: string;

  protocol?: string;

  version?: string;

  // 여기까지가 SocketRequest

  // /user/:id 에서 id
  params: object = {};

  // /search?keyword=naver { keyword : naver }
  query: object = {};

  // /user?id=123 에서 /user
  path?: string;
}


이런 식으로, url 로 부터 params, query, path 까지 파싱해서 Request 에 넣어주도록 하자.

파싱할 때는 node 의 querystring 모듈과 path-to-regexp 을 활용해보자.

app.use()

app.use 는 무엇을 하는 메소드일까? express 를 사용할 때면 이런식으로 사용했던 것 같다.

typescript
app.use('/card', (req, res, next) => {
  // ...
  res.send('helloworld');
});


/card 의 url 로 들어온 요청에 대해서 (req, res, next) => {} 에 해당하는 함수를 실행하는 것 같은데 그렇다면 이 함수는 무슨 함수일까?

Middleware, Layer, Router

express 에서는 이런 함수를 middleware 라고 부른다. 미들웨어는 말 그대로 중간에 끼어있는 함수로 보면 된다.

Request 를 받아서, Response 를 보내기 전의 중간 처리 과정을 하는 함수를 전부 middleware 라고 부른다.

그러면 express 는 개발자가 middleware 를 등록해서 모든 요청마다 url 을 검사해서, url 조건을 충족하면 등록된 middleware 를 실행한다 의 흐름으로 진행하는구나!

라고 간단하게 생각했는데 여기서 LayerRouter 의 개념이 또 등장한다.

Layer 가 무엇인지 보니, middleware 를 한 번 감싸주는 계층이다.

엥? Layer 로 굳이 감싸는 이유가 뭐야? 라고 생각이 들었는데 여러가지 이유가 있다.

  • Method, Url 검사

middleware 가 실행되기 전에 사용자가 등록한 /card url 과 들어온 Request 의 url 을 비교해야 한다.

또한 특정 메소드의 요청에만 미들웨어를 실행되게 등록했다면 Method 비교도 해야할 것이다.

  • 에러 처리

사용자가 등록한 (req, res, next) => {} 함수에서 에러를 처리하려면, 미들웨어 안에서 try, catch 를 작성해야하는데 그렇다면 모든 미들웨어에 try, catch 가 있어야 한다.

실제 express 에서는 만약 에러가 발생하면 next(err) 의 방법으로 다음 미들웨어로 err 를 넘긴다. Layer 계층에서는 middleware 를 실행하기 전에 err 가 넘어왔는지 확인하고, 넘어왔다면 에러가 발생했다고 판단해서 에러를 처리할 수 있다.

즉 Layer 계층에서 전역적으로 에러를 처리해줄 수 있다.

  • 같은 Url, Method 에 여러 개의 미들웨어 등록

말 그대로 여러 개의 미들웨어가 등록된다면 이를 관리하는 계층이 필요할 수 밖에 없다.

🤔 Layer 안에서 next

🤔 Middleware 가 비동기일 경우에 에러 핸들링

Response 객체로 socket.write

🤔 Response 에 Header 와 Body 를 따로 write 하기

커스텀 express 를 활용해서 웹 서버 코드 작성

만들어둔 express 를 로컬 모듈로 바꿔보자

타입스크립트 타입을 위한 d.ts 만들기

만약 자바스크립트로 http, express 코드를 작성하고 실제 app 은 타입스크립트로 작성한다면

http, express 에 타입을 명시해주는 d.ts 가 필요하다.

Express 에서는 Request 의 DTO 를 직접 검사해줘야한다

ZOD 를 활용해보자