Node.js 스터디 3주차
Node.js 교과서 4장 Http모듈로 서버 만들기를 공부하고 정리한 글
4.1 요청과 응답 이해하기
서버는 클라이언트의 요청을 받아 내용을 읽고 처리한 뒤, 응답을 보내준다.
따라서 요청을 받는 부분과 응답을 보내는 부분이 필요하다. 어떤 요청에서 어떤 작업을 수행할지도 등록해두어야 한다.
const http = require('http');
http.createServer((req, res) => {
// 보낼 응답
});
http 모듈을 사용해서 웹 브라우저 요청을 처리한다. 요청이 들어올 때마다 콜백함수가 실행된다.
const http = require('http');
http.createServer((req, res) => { //서버 객체 생성
res.writeHead(200, {'Content-Type' : 'text/html; charset=utf-8'}); //응답 헤더
res.write('<h1>Hello Node!</h1>'); //응답 본문
res.end('<p>Hello Server!</p>'); //응답 데이터 전송 종료
})
.listen(8080, () => { //서버 연결, 첫 번째 인자는 포트 번호, 두 번째 인자는 실행할 콜백 함수
console.log('8080번 포트에서 서버 대기 중입니다!');
});
실행 결과는 아래처럼 나온다.
웹페이지(프론트)측에서 응답인 Hello Node! Hello Server을 확인할 수 있고, vscode상에는 listen에 작성한 8080번 포트에서 서버 대기중입니다 콘솔 출력을 확인할 수 있다.
listening 리스너를 붙이는 방식도 가능하다.
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, {'Content-Type' : 'text/html; charset=utf-8'});
res.write('<h1>Hello Node!</h1>');
res.end('<p>Hello Server!</p>');
});
server.listen(8080);
server.on('listening', () => {
console.log('8080번 포트에서 서버 대기 중입니다. ');
});
server.on('error', (error) => {
console.error(error);
})
잘 쓰이진 않지만, createServer을 여러 번 호출하면 서버를 여러개 동시 실행할 수 있다. (단 포트 번호는 달라야 함)
const http = require('http');
http.createServer((req, res) => {
res.writeHead(200, {'Content-Type' : 'text/html; charset=utf-8'});
res.write('<h1>Hello Node!</h1>');
res.end('<p>Hello Server!</p>');
})
.listen(8080, () => {
console.log('8080번 포트에서 서버 대기 중입니다!');
});
http.createServer((req, res) => {
res.writeHead(200, {'Content-Type' : 'text/html; charset=utf-8'});
res.write('<h1>Hello Node!</h1>');
res.end('<p>Hello Server!</p>');
})
.listen(8081, () => {
console.log('8080번 포트에서 서버 대기 중입니다!');
});
write와 end에 일일이 HTML적는 방식말고 미리 만들어둔 파일을 저번 단원에서 공부한 fs모듈을 사용해 읽어서 전송할 수 있다.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Node.js 웹 서버</title>
</head>
<body>
<h1>Node.js 웹 서버</h1>
<p>만들 준비되셨나요?</p>
</body>
</html>
const http = require('http');
const fs = require('fs').promises;
http.createServer(async (req, res) => {
try {
const data = await fs.readFile('./server2.html');
res.writeHead(200, { 'Content-Type' : 'text/html; charset=utf-8 '}); //html 파일
res.end(data);
} catch (err) {
console.error(err);
res.writeHead(500, { 'Content-Type' : 'text/plain; charset=utf-8 '}); //에러메시지는 일반 텍스트
res.end(err.message);
}
})
.listen(8081, () => {
console.log('8081번 포트에서 서버 대기중입니다. ');
})
data 변수에 fs모듈의 readFile메소드로 server2.html파일을 버퍼로 읽고, 그대로 클라이언트에 전달해준다.
4.2 REST와 라우팅 사용하기
- REST란 무엇인가?
✔ REpresentational State Transfer의 줄임말로, 서버의 자원을 정의하고 이 자원에 대한 주소를 지정하는 약속을 의미
✔ 자원을 이름(표현)으로 구분하여 자원의 상태(정보)를 주고 받는 것이다.
✔ HTTP 프로토콜을 활용하기에 웹의 장점 활용이 가능하다.
- 자원 / 행위 / 표현 3가지로 구성이 된다
자원 : URI (인터넷 상의 자원을 식별하기 위한 문자열의 구성)
행위 : HTTP Method (EX : GET, POST, PUT, DELETE등..)
표현 : 클라이언트 - 서버가 주고받는 데이터 형식(xml, json등)
- REST의 특징?
✔ 서버-클라이언트 구조 (자원을 요청하는 쪽이 Client, 자원을 제공하는 쪽이 Server)
✔ HTTP 프로토콜이 Stateless함 -> REST역시 무상태성
-> statelss는 클라이언트의 상태 정보(세션, 쿠키정보 등을)를 별도로 기록하지 않는다. 들어오는 요청만 단순히 처리함.
✔ 캐싱 기능 사용 가능 - 캐싱 처리 가능 여부를 명시해야 함
✔ 인터페이스 일관성
✔ 계층 구조 (REST서버는 다중 계층으로 구성될 수 있으며 클라이언트는 직접 통신하는지 중간서버(프록시)와 통신하는지 알 수 없습니다)
참고한 블로그 : https://dev-coco.tistory.com/97
REST란? REST API 와 RESTful API의 차이점?
참고 REST(REpresentational State Transfer)란? REST의 정의 "REpresentational State Transfer" 의 약자로, 자원을 이름(자원의 표현)으로 구분해 해당 자원의 상태(정보)를 주고 받는 모든 것을 의미합니다. 즉..
dev-coco.tistory.com
REST를 따르는 서버를 RESTful 하다고 표현한다. REST에 기반한 서버 주소 구조를 미리 설계하고 시작하면 체계적으로 프로그래밍 할 수 있다.
* 소문자, 명사를 사용하여 주소를 명시하며, /(슬래쉬) 기호로 계층을 구분한다. /로 끝내면 혼용의 여지가 있으니 /로 끝내지 말 것
메소드 | 주소 | 역할 |
GET | / | restFront.html파일 제공 |
GET | /about | about.html파일 제공 |
GET | /users | 사용자 목록 제공 |
GET | 기타 정적 파일 | |
POST | /user | 사용자 등록 |
PUT | /user/:id | 해당 id 사용자 수정 |
DELETE | /user/:id | 해당 id 사용자 제거 |
프론트 html 코드도 있지만 생략하고 .. 서버 코드는 아래와 같다
async function getUser() { //사용자 정보를 가져오는 함수
try {
const res = await axios.get('/users'); //users 주소로 GET 요청 왔을 때 처리.
const users = res.data;
const list = document.getElementById('list');
list.innerHTML = '';
//사용자마다 반복적으로 화면 표시하고 이벤트를 연결함
Object.keys(users).map(function (key) {
const userDiv = document.createElement('div');
const span = document.createElement('span');
span.textContent = users[key];
//수정 버튼 클릭시 이벤트
const edit = document.createElement('button');
edit.textContent = '수정';
edit.addEventListener('click', async() => {
const name = prompt('바꿀 이름을 입력하세요');
if(!name) {
return alert('이름을 반드시 입력하셔야 합니다. ');
} // 이름이 없는 경우
try {
await axios.put('/user/' + key, {name}); // /user/:id 로 PUT(해당 ID 사용자 수정)
getUser(); //다시 유저 정보 불러옴
}
catch(err) {
console.error(err);
}
});
//삭제 버튼 클릭시 이벤트
const remove = document.createElement('button');
remove.textContent = '삭제';
remove.addEventListener('click', async () => {
try {
await axios.delete('/user/' + key); // /user/:id 로 DELETE(해당 ID 사용자 삭제)
getUser();
} catch(error) {
console.error(error);
}
});
userDiv.appendChild(span);
userDiv.appendChild(edit);
userDiv.appendChild(remove);
list.appendChild(userDiv);
console.log(res.data);
});
} catch (err) {
console.error(err);
}
}
window.onload = getUser; // 화면 로드시 getUser호출
//폼 제출시
document.getElementById('form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = e.target.username.value;
if(!name) {
return alert('이름을 입력하세요');
}
try {
await axios.post('/user', {name}); // /user로 POST(해당 사용자 새로 등록)
getUser();
} catch(err) {
console.error(err);
}
e.target.username.value = '';
});
아래 코드에서 메소드와 URL에 따라 파일을 제공한다.
const http = require('http');
const fs = require('fs').promises;
const users = {};
http.createServer(async (req, res) => {
try {
console.log(req.method, req.url);
if(req.method === 'GET') {
if(req.url === '/') {
const data = await fs.readFile('./restFront.html');
res.writeHead(200, { 'Content-Type' : 'text/html; charset = utf-8'});
return res.end(data);
} else if (req.url === '/about') {
const data = await fs.readFile('./about.html');
res.writeHead(200, {'Content-Type' : 'text/html; charset=utf-8' });
return res.end(data);
} else if (req.url === '/users') {
res.writeHead(200,{ 'Content-Type' : 'text/plain; charset = utf-8'});
return res.end(JSON.stringify(users));
}
try {
const data = await fs.readFile(`.${req.url}`);
return res.end(data);
} catch (err) {
}
} else if (req.method === 'POST') {
if(req.url === '/user') {
let body = '';
req.on('data', (data) => {
body += data;
});
return req.on('end', () => {
console.log('POST 본문(body):', body);
const { name } = JSON.parse(body);
const id = Date.now();
users[id] = name;
res.writeHead(201);
res.end('등록 성공');
});
}
} else if (req.method === 'PUT') {
if(req.url.startsWith('/user/')) {
const key = req.url.split('/')[2];
let body = '';
req.on('data', (data) => {
body += data;
});
return req.on('end', () => {
console.log('PUT 본문 (Body):', body);
users[key] = JSON.parse(body).name;
return res.end(JSON.stringify(users));
});
}
}
else if (req.method === 'DELETE') {
if(req.url.startsWith('/user/')) {
const key = req.url.split('/')[2];
delete users[key];
return res.end(JSON.stringify(users));
}
}
res.writeHead(404);
return res.end('NOT FOUND');
} catch (err) {
console.error(err);
res.writeHead(500, {'Content-Type' : 'text-plain; charset=utf-8'});
res.end(err.message);
}
})
.listen(8082, () => {
console.log('8082번 포트에서 서버 대기 중입니다.');
});
- Express로 라우터 분리가 안되어 있어서 if문의 중첩이 연결되어 있다. 뒤에서 배울 Express모듈과 라우팅을 사용하면 좀 더 깔끔해진다.
- 아래는 실행화면이다.
+) 데이터를 영구적으로 저장하려면 DB사용을 해야한다.
4.3 쿠키와 세션 이해하기
HTTP는 connectionless하고 stateless하다. 서버가 적절한 응답을 하고 나면 연결이 끊어지고, 클라이언트의 정보를 저장해두지 않는다. 그렇다면 로그인 후 인증 상태를 어떻게 유지할 수 있는가?
- 쿠키 ?
✔ 요청을 보내는 클라이언트에 대한 정보.
✔ 서버의 경우 처음 요청이 들어왔을 때 요청자를 추정할만한 정보를 쿠키로 만들어 보내면 클라이언트는 쿠키 정보를 저장해놨다 다음 요청부터 쿠키를 함께 서버에 전송함으로써 사용자를 구분할 수 있다.
const http = require('http');
http.createServer((req, res) => {
console.log(req.url, req.headers.cookie);
res.writeHead(200, { 'Set-Cookie' : 'mycookie=test' });
res.end('Hello Cookie');
})
.listen(8083, () => {
console.log('8083번 포트에서 서버 대기 중입니다!');
});
실행하면 아래와 같은 결과를 확인할 수 있다.
로그인 상태를 유지해보는 코드를 만들어보자!
const http = require('http');
const fs = require('fs').promises;
const url = require('url');
const qs = require('querystring');
const parseCookies = (cookie='') =>
cookie //쿠키는 문자열 형태 parseCookies로 객체 형태로 변경
.split(';')
.map(v => v.split('='))
.reduce((acc, [k,v]) => {
acc[k.trim()] = decodeURIComponent(v);
return acc;
} , {});
http.createServer(async (req, res) => {
const cookies = parseCookies(req.headers.cookie);
if(req.url.startsWith('/login')) {
const {query} = url.parse(req.url);
const {name} = qs.parse(query);
const expires = new Date();
expires.setMinutes(expires.getMinutes() + 5);
res.writeHead(302, {
Location : '/',
'Set-Cookie' : `name=${encodeURIComponent(name)}; Expires = ${expires.toGMTString()}; HttpOnly: Path=/`,
});
res.end();
} else if (cookies.name) {
res.writeHead(200, { 'Content-Type' : 'text/plain; charset=utf-8'});
res.end(`${cookies.name}님 안녕하세요`)
} else {
try {
const data = await fs.readFile('./cookie2.html');
res.writeHead(200, {'Content-Type' : 'text/html; charset=utf-8'});
res.end(data);
} catch (err) {
res.writeHead(500, {'Content-Type' : 'text/plain; charset=utf-8'});
res.end(err.message);
}
}
})
.listen(8080, () => {
console.log('8080번 포트에서 서버 대기중입니다!');
});
+) url.parse와 qs.parse가 deprecated되었는데... host부분없이 pathname만 오는 주소라서 whatwg방식이 처리할 수 없는듯 하다.
실행결과는 아래와 같다.
- 쿠키명=쿠키값 : 기본적인 쿠키의 값
- Expires = 날짜 : 만료 기한, 쿠키가 이시간이 지나면 자동 제거.
- max-age = 초 : 해당 초가 지나면 쿠키가 제거
- 세션?
✔ 세션또한 쿠키처럼 클라이언트 정보를 저장할 수 있으나, 쿠키는 브라우저의 쿠키 저장소에 저장되는 반면 세션은 서버에 저장된다.
✔ 서버에 사용자 정보를 저장하고 세션아이디로 클라이언트와 소통
const http = require('http');
const fs = require('fs').promises;
const url = require('url');
const qs = require('querystring');
const parseCookies = (cookie = '') =>
cookie
.split(';')
.map(v=> v.split('='))
.reduce((acc, [k, v]) => {
acc[k.trim()] = decodeURIComponent(v);
return acc;
}, {});
const session = {};
http.createServer(async (req, res) => {
const cookies = parseCookies(req.headers.cookie);
if(req.url.startsWith('/login')) {
const {query} = url.parse(req.url);
const {name} = qs.parse(query);
const expires = new Date();
expires.setMinutes(expires.getMinutes() + 5);
const uniqueInt = Date.now();
session[uniqueInt] = {
name,
expires,
};
res.writeHead(302, {
Location : '/',
'Set-Cookie' : `session=${uniqueInt}; Expires = ${expires.toGMTString()}; HttpOnly: Path=/`,
}); //uniqueInt : 쿠키에 이름 대신 숫자 값 보냄
res.end();
} else if (cookies.session && session[cookies.session].expires > new Date()){
res.writeHead(200, {'Content-Type' : 'text/plain; charset=utf-8'});
res.end(`${session[cookies.session].name}님 안녕하세요`);
} else {
try {
const data = await fs.readFile('./cookie2.html');
res.writeHead(200, {'Content-Type' : 'text/html; charset=utf-8'});
res.end(data);
} catch (err) {
res.writeHead(500, {'Content-Type' : 'text/plain; charset=utf-8'});
res.end(err.message);
}
}
})
.listen(8085, () => {
console.log('8085번 포트에서 서버 대기중입니다!');
});
보통 보안상의 문제 등으로 직접 구현하기보단 npm에 있는 모듈 등을 사용하는 경우가 많다!
추가로 참고한 블로그 : https://hyuntaeknote.tistory.com/3
HTTP 는 Stateless 한데 로그인은 어떻게 구현할 수 있을까? (세션/쿠키를 이용한 인증)
개요 소셜 네트워크 서비스 'AGORA'를 제작하면서 가장 먼저 개발한 기능은 회원가입/로그인 입니다. 혼자 사용하거나 모든 정보가 공개된 웹 어플리케이션이 아니라면 대부분의 웹 어플리케이
hyuntaeknote.tistory.com
4.4 https와 http2
- https : 기존의 http 프로토콜을 암호화시킨 버전이다. SSL/TLS등을 통해 전송되는 정보들을 암호화시켜 보안 측면에서 뛰어나다.
서버에 암호화를 적용하려면 https모듈을 사용하며, 인증서 발급이 필요하다. 인증서가 있는 경우에는 아래와 같이 작성하면 된다. 기존 작성 코드 중, createServer의 첫 번째 인수에 인증서 관련 옵션을 넣는다고 생각하면 된다.
const http = require('https');
http.createServer({
cert : fs.readFileSync('도메인 인증서 경로'),
key : fs.readFileSync('도메인 비밀키 경로'),
ca : [
fs.readFileSync('상위 인증서 경로'),
fs.readFileSync('상위 인증서 경로'),
],
}, (req, res) => {
res.writeHead(200, {'Content-Type' : 'text/html; charset=utf-8'});
res.write('<h1>Hello Node!</h1>');
res.end('<p>Hello Server!</p>');
})
.listen(443, () => {
console.log('443번 포트에서 서버 대기 중입니다!');
}); //실제 서버에서는 443포트
- http2 : 최신 HTTP 프로토콜
http/1.1 : 하나의 연결 당 하나의 요청을 처리한다. 3-way handshacking이 반복적으로 발생하며 RTT(round-trip time, 전송하고 응답이 돌아올 때까지 걸리는 시간)가 증가하게 된다.
-> http2는 이를 개선해서, 속도 상향을 위해 하나의 커넥션으로 동시에 여러개 주고받을 수 있으며, 클라이언트의 요청을 최소화하기 위해 요청하지 않은 정보도 보내줄 수 있다. (server-push)
const http2 = require('http2');
const fs = require('fs');
http2.createSecureServer({
cert : fs.readFileSync('도메인 인증서 경로'),
key : fs.readFileSync('도메인 비밀키 경로'),
ca : [
fs.readFileSync('상위 인증서 경로'),
fs.readFileSync('상위 인증서 경로'),
],
}, (req, res) => {
res.writeHead(200, {'Content-Type' : 'text/html; charset=utf-8'});
res.write('<h1>Hello Node!</h1>');
res.end('<p>Hello Server!</p>');
})
.listen(443, () => {
console.log('443번 포트에서 서버 대기 중입니다!');
});
사용법은 https모듈과 거의 유사하다.
4.5 cluster
- cluster 모듈이란?
싱글 프로세스로 동작하는 노드가 CPU코어를 모두 사용할 수 있게끔 만들어 주는 모듈
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length; //CPU 코어 개수
if(cluster.isMaster) {
console.log(`마스터 프로세스 아이디 : ${process.pid}`);
for (let i = 0; i<numCPUs; i+= 1) {
cluster.fork(); //CPU코어 개수만큼 fork
}
cluster.on('exit', (worker, code, signal) => {
console.log(`${worker.process.pid}번 워커가 종료되었습니다.`);
console.log('code', code, 'signal', signal);
cluster.fork(); //워커 하나 종료 시 새로운 워커 생성, 서버 종료를 막는다.
});
} else {
http.createServer(async (req, res) => {
res.writeHead(200, { 'Content-Type' : 'text/html; charset = utf-8'});
res.write('<h1>Hello Node!</h1>');
res.end('<p>Hello Cluster!</p>');
setTimeout(() => {
process.exit(1);
}, 1000);
}).listen(8086);
console.log(`${process.pid}번 워커 실행`);
}
본인의 cpu코어 개수만큼 fork했기 때문에 실행결과를 확인하면 출력된 워커 갯수가 코어 개수와 동일하다. 나는 i7 그램을 사용하는데, 8개의 워커가 출력되어 있음을 확인할 수 있다.
- 위 코드처럼 새로운 워커 생성으로 막기보단 오류 자체의 원인을 해결해야함.
- 클러스터링으로 예기치 못한 에러로 서버의 종료를 막을 순 있음
- 실무에서는 주로 pm2모듈을 사용해서 cluster 기능을 사용한다.
-> 추가로 pm2는 이렇게 생겼다. pm2 status 쳤을 때 나오는 화면인데, 에러가 발생하면 status error로 바뀌지만 서버 자체가 멈추진 않는다.