HTTP 모듈을 사용한 서버 다뤄보기
브라우저에는 서버에 요청을 보내기 위해 fetch 같은 HTTP 요청을 보내는 도구가 기본적으로 내장되어 있다.
- 서버는 클라이언트(브라우저)의 HTTP 요청에 맞게 응답을 보낼 수 있도록 코드를 작성해야 한다.
- Node.js는 HTTP 요청을 보내거나, 응답을 받을 수 있는 도구들을 제공한다.
- HTTP 요청을 처리하고 응답을 보내 주는 프로그램을 웹 서버(Web Server)라고 부른다.
- Node.js의 http 모듈을 이용해 웹 서버를 만들 수 있다.
- Node.js에서 파일을 읽거나 쓰기 위해 fs 모듈을 사용하듯이, HTTP 요청과 응답을 다루기 위해 HTTP 모듈을 사용한다.
이 HTTP 모듈 공식 문서중 무엇부터 봐야 할까?
- HTTP 모듈 공식 문서에 들어가면 방대한 양의 메서드에 답답할 수 있다.
- 공식 문서를 읽고 문제를 해결하는 게 가장 중요하지만, 어디서부터 접근해야 할지 결정하는 일은 다소 어렵다.
- Node.js가 제공하고 있는 HTTP 트랜잭션 해부(Anatomy of an HTTP Transaction) 공식 가이드 문서를 통해 Mini-Node Server를 완성하는 데 큰 도움을 받을 수 있다.
- 물론 이 문서에도 낯선 용어가 등장한다. 키워드 하나하나를 전부 이해하겠다는 생각보다는, 가이드에 따라 코드를 직접 작성하면서 요청을 보내거나 받는 방식을 확인해 본다.
도움이 된 유튜브 영상
구현 화면
- 아래와 같이 문구를 적어 'toUpperCase', 'toLowerCase' 등으로 요청을 보내면 서버에서 알맞게 처리하여 응답을 보내야 한다.
초기 세팅 1) 서버 실행
node server/basic-server.js
- 해당 디렉토리로 진입한 다음, 위 커맨드를 CLI에 입력한다.
- 서버를 종료하려면 Ctrl+C를 눌러 프로그램을 강제 종료한다.
- 서버 코드를 수정하면 저장 후에, 프로그램을 매번 다시 실행해야 한다.
- 매번 파일을 실행하지 않고, npm start 명령어를 이용하는 방법은 없을까?
- 서버를 매번 실행시키지 않아도 되는 개발도구가 있다.
- nodemon을 이용하면, 서버를 매번 실행시킬 필요가 없다.
npm install nodemon으로 설치한다.- package.json의 "scripts"에 아래 코드를 추가한다.
"start": "nodemon server/basic-server.js"
- 혹은 npx로 nodemon을 따로 설치하지 않고 실행한다.
- 예) 아래와 같이 npx nodemon으로 server/basic-server.js 파일을 실행한다.
npx nodemon server/basic-server.js
- nodemon을 이용하면, 서버를 매번 실행시킬 필요가 없다.
초기 세팅 2) 클라이언트 실행
- client/index.html를 만들고 웹 브라우저에서 실행한다.
- 특정 포트로 클라이언트를 실행하고 싶다면, serve를 이용할 수 있다.
npx serve -l 포트번호 client/
웹서버 만들기
구현할 웹 서버의 기능
- 클라이언트의 액션(버튼 클릭)에 따라 각기 다른 HTTP 요청을 서버로 보내고,
- HTTP 요청에 담아 보낸 단어를 소문자 또는 대문자로 응답을 받아 화면에 보여 준다.
Endpoint에 따른 메서드와 기능 설명
Endpoint(URL) | Method | 기능 |
/lower | POST | 문자열을 소문자로 만들어 응답해야 한다 |
/upper | POST | 문자열을 대문자로 만들어 응답해야 한다 |
- POST에 문자열을 담아 요청을 보낼 때는 HTTP 메시지의 body(payload)를 이용한다.
- 서버는 요청에 따른 적절한 응답을 클라이언트로 보내야 한다.
- CORS 관련 헤더를 OPTIONS 응답에 적용해야 한다.
- 즉, 클라이언트의 preflight request에 대한 응답을 돌려줘야 한다.
- preflight request에 대한 응답 헤더는 이미 작성되어 있다. (아래)
// ** preflight request에 대한 응답 헤더
const http = require('http'); // http 모듈을 불러온다.
// 모든 node 웹 서버 애플리케이션은 웹 서버 객체를 만들어야 한다. createServer 메서드를 이용해 서버를 만든다.
// createServer에는 콜백함수를 넣을 수 있으며 요청이 들어올 때마다 매번 콜백 함수가 실행된다. (매개변수 request, response)
// 여기에 응답을 적으면 된다.
const server = http.createServer((request, response) => {
// (설명1) writeHead메소드를 이용해 헤더 데이터를 전송한다
response.writeHead(200, defaultCorsHeader);
const defaultCorsHeader = {
'Access-Control-Allow-Origin': '*', // (설명2)
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', // (설명3)
'Access-Control-Allow-Headers': 'Content-Type, Accept', // (설명4)
'Access-Control-Max-Age': 10 // (설명5)
};
})
설명(1) : respond 객체 메서드
[res.writeHead(statusCode[, statusMessage][,headers])
응답에 대한 정보를 기록하는 메서드이다. 즉, http의 header를 설정하는 메소드이다.
(res 또는 response 혼용해서 쓰겠음)
예를 들면 response.statusCode = 202 와 (상태를 보여주는 코드, 202는 성공적인 요청임을 뜻함)
response.setHeader('Content-Type', 'application/json') (응답에 대한 정보를 보내는데 콘텐트 형식이 json임을 알림)
response.writeHead(200, {'Content-Type': 'application/json'}) 이다.
다른 예: res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
-> 두 번째 인수는 응답에 대한 정보를 보내는데 콘텐트 형식이 HTML임을 알리고 있다. 또한 한글 표시를 위해서 charset을 utf-8로 지정했다. 이 정보가 기록되는 부분을 헤더(Header)라고 부른다.
[res.write]
메서드의 첫 번째 인수는 클라이언트로 보낼 데이터이다. res.write('<h1>Hello!</h1>')처럼 HTML 모양의 문자열을 보냈지만 버퍼를 보낼수도 있다. 데이터가 기록되는 부분은 본문(Body)이라고 부른다.
[res.end]
응답(response)을 종료하는 메서드로 인수가 있다면 그 데이터도 클라이언트로 보내고 응답을 종료한다.
브라우저는 응답 내용을 받아서 렌더링 한다.
설명(2) : Access-Control-Allow-Origin
주어진 origin으로부터의 요청 코드와 공유될 수 있는지를 나타냄.
// credential이 없는 요청들에 와일드카드로써 문자 값 "*"이 명시될 수 있다.
// 이 값은 브라우저에 리소스에 접근하는 임의의 origin으로부터의 요청 코드를 허용함을 알린다.
Access-Control-Allow-Origin: *
// origin을 명시한다. 하나의 origin만 명시될 수 있다.
// 예: Access-Control-Allow-Origin: https://developer.mozilla.org
// -> 브라우저에 https://developer.mozilla.org로부터의 요청을 허용한다고 알리는 응답
Access-Control-Allow-Origin: <origin>
Access-Control-Allow-Origin: null
설명(3) : Access-Control-Allow-Methods
실제 요청이 만들어질 때 클라이언트가 보낼 수도 있는 HTTP headers를 서버에게 알리기 위해 브라우저가 preflight request를 발급(issue)할 때 사용된다. 사전 요청(preflight request)은 항상 OPTIONS이며 실제 요청과 동일한 메소드를 사용하지 않으므로 이 헤더가 필요하다.
설명(4) : Access-Control-Allow-Headers
preflight request의 응답에 사용되는 헤더로, 실제 요청 때 사용할 수 있는 HTTP 헤더의 목록을 나열함.
// 지원하는 헤더의 이름으로 쉼표로 구분하여 원하는 만큼 헤더를 나열할 수 있다.
Access-Control-Allow-Headers: <header-name>[, <header-name>]*
// "*" 값은 자격 증명이 없는 요청(쿠키 혹은 HTTP 인증 정보가 없는 요청)일 경우 특수한 와일드 카드로 처리된다.
// 자격증명을 포함하는 경우 단순히 "*"라는 이름을 갖는 특별한 의미가 없는 헤더로 취급된다.
// 단, Authorization 헤더는 와일드카드에 포함되지 않으며 명시적으로 나열해야 한다.
Access-Control-Allow-Headers: *
[요청]
이 Preflight 요청은 Preflight 요청 헤더인 Access-Control-Request-Method, Access-Control-Request-Headers 및 Origin, 이 세가지 Preflight 요청 헤더를 포함하는 OPTIONS 요청이다.
OPTIONS /resource/foo Access-Control-Request-Method: DELETE Access-Control-Request-Headers: origin, x-requested-with Origin: https://foo.bar.org
[응답]
만약 서버가 DELETE 메소드에 CORS 요청을 허용한다면 Access-Control-Allow-Methods에 DELETE, 그리고 다른 지원하는 메소드를 포함하여 응답한다.
HTTP/1.1 200 OK Content-Length: 0 Connection: keep-alive Access-Control-Allow-Origin: https://foo.bar.org Access-Control-Allow-Methods: POST, GET, OPTIONS, DELETE Access-Control-Max-Age: 86400
설명(5) : Access-Control-Max-Age
how long the results of a preflight request can be cached.
-> 다른 preflight request를 보내지 않고, preflight request에 대한 응답을 캐시할 수 있는 시간(초)를 제공한다. 만약 값이 86400이라면 권한은 86,400초(1일) 동안 캐시될 수 있다. 각 브라우저의 최대 캐싱 시간은 Access-Control-Max-Age가 클 수록 우선순위가 높다.
// 예: Cache results of a preflight request for 10 minutes:
Access-Control-Max-Age: 600
코드 구현
basic-server.js
const { response } = require('express');
const http = require('http');
const PORT = 4999;
const ip = 'localhost';
const server = http.createServer((request, response) => { // request는 client의 요청객체, server의 응답객체
// "preflighted" request는 "simple requests” 와는 달리,
// 먼저 OPTIONS 메서드를 통해 다른 도메인의 리소스로 HTTP 요청을 보내
// 실제 요청이 전송하기에 안전한지 확인한다.
// if(메소드가 OPTIONS일 때) {CORSE 설정을 해준다.}
// CORS (body가 필요없기 때문에 headers만 넘어온다)
if(request.method === "OPTIONS"){
response.writeHead(200, defaultCorsHeader)
response.end(); //end 메소드는 모든 응답 헤더와 본문이 전송되었음을 서버에 알린다
}
// if(메소드가 post고, endpoint가 '/upper'이면){대문자로 응답}
// else if(메소드가 post고, endpoint가 '/lower'이면){소문자로 응답}
// else {나머지는 에러 (bad request)}
if(request.method === 'POST' && request.url === "/upper"){
let body = [];
request.on('data', (chunk) => { // 🟣 (설명1)
body.push(chunk);
}).on('end', () => { //.on('end',) request 요청에서 데이터가 다 전달되었으면
// 바로 문자열로 바꾼부분에 대문자로 바준다.
body = Buffer.concat(body).toString().toUpperCase();
response.writeHead(200, defaultCorsHeader) // 🟣 (설명2)
response.end(body);
});
}else if(request.method === "POST" && request.url === "/lower"){
let body = [];
request.on('data', (chunk) => { // 🟣 (설명3)
body.push(chunk);
}).on('end', () => {
body = Buffer.concat(body).toString().toLowerCase(); //이번에는 소문자로 바꿔준다
response.writeHead(200, defaultCorsHeader)
response.end(body);
});
}else{ //모두 아닐 때에 응답도 필요하니 request 요청을 먼저 받고, 그에 따른 response 응답을 보내준다.
request.on('error', (err) => {
response.writeHead(404, defaultCorsHeader) // 🟣 (설명4)
console.error(err, "bad request");
});
}
// console.log(`http request method is ${request.method}, url is ${request.url}`);
});
// local host:4999 서버 연결 (http로 연결 X)
// localhost는 현재 컴퓨터의 내부 주소로 외부에서는 접근할 수 없고 자신의 컴퓨터에서만 접근할 수 있으므로 서버 개발 시 테스트용으로 많이 사용한다.
// 이러한 숫자 주소를 IP(Internet Protocol)라고 부른다.
// Starts the HTTP server listening for connections.
// This method is identical to server.listen() from net.Server.
server.listen(PORT, ip, () => {
console.log(`http server listen on ${ip}:${PORT}`);
});
const defaultCorsHeader = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Accept',
'Access-Control-Max-Age': 10
};
설명(1) : request.on(event, listener)
- on 메서드는 특정 event를 listen할 수 있게 해주는 기능으로 지정된 이벤트(유저의 버튼 클릭이나 네트워크에 리소스를 요청하는 것 등) 처리를 통합하는 것으로, 첫번째 인수에 이벤트 이름을, 두번째 인수에 통합 처리(함수)를 각각 지정한다.
- request.on('data', ) 부분은 request 요청에서 데이터 도착시 처리하는 부분이고,
- request.on('end', )는 data처리가 다 끝났음을 알려주는 부분이다.
- 여기서 인자로 'data'와 콜백함수를 넣어줌으로써 'data event'가 발생할 때마다 해당 함수를 수행한다.
- on의 인자로 들어가는 콜백함수에서 데이터 chunk를 인자로 받아서 만들어놓은 배열에 저장한다.
- request on 메서드에 인자 'end'와 콜백함수를 넣어서 data event를 listen하는 on 기능이 종료되면, 읽어온 데이터를 buffer에 concat하여 string으로 바꿔서 파싱해준다.
설명(2) : MDN의 클라이언트-서버 완전한 통신 예시
1. 첫 번째 통신은 preflight request/response
OPTIONS /resources/post-here/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type
HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
2. preflight request가 완료되면 실제 요청을 전송
POST /resources/post-here/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
X-PINGOTHER: pingpong
Content-Type: text/xml; charset=UTF-8
Referer: https://foo.example/examples/preflightInvocation.html
Content-Length: 55
Origin: https://foo.example
Pragma: no-cache
Cache-Control: no-cache
<person><name>Arun</name></person>
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:40 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 235
Keep-Alive: timeout=2, max=99
Connection: Keep-Alive
Content-Type: text/plain
[Some GZIP'd payload]
설명(3) : chunk
let body = [];
request.on('data', (chunk) => {
body.push(chunk);
}).on('end', () => {
body = Buffer.concat(body).toString();
// 여기서 `body`에 전체 요청 바디가 문자열로 담겨있다.
});
-> 각 'data' 이벤트에서 발생시킨 청크는 Buffer이다. 이 청크가 문자열 데이터라는 것을 알고 있다면 이 데이터를 배열에 수집한 다음 'end' 이벤트에서 이어 붙인 다음 toString()으로 문자열로 만드는 것이 가장 좋다.
-> data.toString()을 거치지 않고 data만 출력하면 <Buffer 72 65 61 64 20 6d 65 20 62 72 6f 21>와 같이 Buffer가 출력된다.
//** Join the array into one buffer object:
var buf1 = Buffer.from('a');
var buf2 = Buffer.from('b');
var buf3 = Buffer.from('c');
var arr = [buf1, buf2, buf3];
var buf = Buffer.concat(arr);
console.log(buf); // <Buffer 61>, <Buffer 62>, <Buffer 63>
데이터를 조각(청크, chunk)내어 buffer에 채운 후 다 차면 buffer를 통째로 옮기고 새 buffer에 아직 옮기지 못한 데이터 조각을 다시 채운다. 데이터 조각을 buffer에 채우는 일을 버퍼링(buffering)이라고 부른다. 영상이 버퍼링 중이라며 재생되지 않는 경우를 종종 경험했을텐데 buffer에 데이터를 채울 때까지 기다리는 버퍼링 작업을 말한다.
buffer가 다 차면 이를 전송하고 다시 buffer를 채우는 버퍼링 작업을 연속하는 것이 스트림(stream)이다. 단발성 single buffer도 존재하지만 지속적으로 buffer가 나오는 것을 stream buffer라고 한다. 버퍼를 이용해 데이터를 전송하는 '흐름'이 스트림이라고 이해하면 된다.
설명(4) : console.error()
- 웹 콘솔에 에러 메시지를 출력한다.
결과 네트워크
Preflight
POST request
그 밖에 알게된 점 (메모)
'FE > Server' 카테고리의 다른 글
쿠키(Cookie)의 개념과 작동원리 (0) | 2023.05.02 |
---|---|
mkcert를 이용한 로컬에서 HTTPS 서버 만들기 (0) | 2023.04.28 |
[Network] Airline 서버 구현 (2) : 서버 개발하기 (0) | 2023.04.09 |
[Network] Airline 서버 구현 (1) : Airline Server API document (0) | 2023.04.06 |
[Network] Express로 간단한 웹 서버 만들어보기 (라우터) (0) | 2023.04.05 |