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.Socket 은 stream.Duplex 를 상속받아서 사용하고 있다.
Stream 에 대한 정리는 여기서 볼 수 있다.
즉, 클라이언트가 보낸 모든 데이터
가 수신될 때까지 기다리는게 아니라 stream 으로 (순차적으로) 데이터가 들어오면 그 즉시 버퍼에 쌓이고
버퍼에 데이터가 일정량 이상 쌓일 때마다 chunk 단위로 data
이벤트가 발생한다.
(보통은 버퍼가 넘치기 전에, 혹은 데이터가 전부 다 들어왔을 때 ‘data’ 이벤트가 발생한다.)
다시 말해서 들어오는 데이터가 크다면 데이터가 나누어져서 들어올 수 있다 ( === 한 번의 통신에 여러 번의 data
이벤트가 발생할 수 있다) 는 이야기이다.
따라서 buffer 의 크기보다 큰 데이터를 클라이언트에서 보냈다면, 여러 번의 data
이벤트가 발생할 수 있다.
실시간으로 데이터를 처리해야하는(ex. 유튜브 같은 영상 스트림) 경우에는 들어오는 데이터마다 처리를 해주면 된다.
반면에 용량이 매우 큰 이미지/동영상 파일은 모든 chunk 들을 다 합쳐서 하나로 만들어야할 것이다.
이에 대한 처리까지 있으면 좋을 것 같다!
소켓 통신을 http 통신 처럼
http 모듈을 사용한다면 소켓 통신으로 들어오는 Request
를 Express 의 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 에서 쓰기 편하도록 쉽게 만드는 작업이 필요하다.
파싱의 큰 기준은 두 가지 이다.
- Header 와 Body 는
\\r\\n\\r\\n
으로 구분된다. - 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 를 실행한다
의 흐름으로 진행하는구나!
라고 간단하게 생각했는데 여기서 Layer
와 Router
의 개념이 또 등장한다.
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 를 활용해보자
Subscribe to hoeeeeeh
Get the latest posts delivered right to your inbox