* 위 내용을 정리하였음
4.1 요청과 응답 이해하기
- 클라이언트에서 서버로 request를 보내고 내용을 읽고 처리한 후 response를 보낸다.
- 요청과 응답은 이벤트 방식이다. 요청이 왔을 때 어떤 작업을 수행할지 이벤트 리스너를 미리 등록해두어야 한다.
- 웹 브라우저에서 요청을 처리하기 위해서 http 서버가 있어야 한다.
- http 모듈의 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://localhost:8080 또는 http://127.0.0.1:8080에 접속하면 아래 같은 화면이 나온다.
- localhost는 현재 컴퓨터의 내부 주소이다. 127.0.0.1을 대신 사용해도 된다.
- 포트는 서버 내에서 프로세스를 구분하는 번호이다. 즉 서버는 프로세스에 포트를 다르게 할당하여 들어오는 요청을 구분한다.
- http(80), 3306(mysql) 등으로 붙인다.
- 즉 위의 예제는 8080포트 번호에 노드 서버(프로세스)를 연결하였다.
- 실제로 배포할때는 80또는 443을 사용할 것이다.
- 리눅스와 맥에서는 1024번 이하의 포트에 연결할 때 관리자 권한이 필요하다. (sudo node server)
- createServer 뒤에 listen 메서드를 붙인다. 인자로 공개할 포트번호와 콜백을 넣어준다.
- 즉 8080포트로 요청을 기다리고 있는 상태이다.
- res.writeHead에서 200은 성공적인 요청임을 의미한다. 두번째 인수는 응답에 대한 정보를 보내는데 이 정보들이 기록되는 부분을 헤더라고 한다.
- 2xx: 성공을 알리는 상태코드 200(성공)
- 3xx: 다른 페이지로 이동
- 4xx: 요청 오류, 401(권한 없음, 404(찾을 수 없음)
- 5xx: 서버오류
- res.write의 첫번째 인수는 클라이언트에게 보낼 데이터이다. 이 부분이 body이다.
- res.end는 인수로 받은 값이 있다면 데이터를 클라이언트에게 보내고 응답을 종료한다.
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);
});
- listen 메서드에 콜백을 넣지 않고 listening, error등의 이벤트 리스너를 붙여도 된다.
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('8081번 포트에서 서버 대기 중입니다!');
});
- 포트번호를 다르게하여 한번에 여러 서버를 실행할 수 있다.
// server2.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Node.js 웹 서버</title>
</head>
<body>
<h1>Node.js 웹 서버</h1>
<p>만들 준비되셨나요?</p>
</body>
</html>
// server2.js
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' });
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번 포트에서 서버 대기 중입니다!');
});
- 위 예시는 8081번으로 클라이언트의 요청이 들어왔을 때, html 파일을 fs로 읽어와서 응답으로 전송한다.
4.2 REST와 라우팅 사용하기
- 서버에 요청을 보낼 때는 주소를 통해 요청의 내용을 표현한다. 주소가 /index.html이면 서버의 index.html을 보내달라는 것이다.
- html말고 css, js 혹은 이미지 파일을 요청할수도 있다. 이런 요청의 내용은 주소를 통해 표현된다.
- REST는 (REpresentational State Transfer) 서버의 자원을 정의하고 자원에 대한 주소를 지정하는 방법이다.
- 즉 서버에 요청을 보낼 때 사용하는 주소 체계를 말한다.
- 예를들면 /user이면 사용자와 관련된 자원을 요청하는 것이고 /post라면 게시글에 관련된 자원을 요청하는 것이라고 추측할 수 있다.
- 여기에 http 요청 메서드를 사용해서 좀 더 명확하게 동작을 행하게 할 수 있다.
• GET: 서버 자원을 가져올 때. 요청의 본문에 데이터를 넣지 않음. 데이터를 서버로 보내야 한다면 쿼리스트링을 사용
• POST: 서버에 자원을 새로 등록하고자 할 때. 요청의 본문에 새로 등록할 데이터를 넣어 보냄.
• PUT: 서버의 자원을 요청에 들어 있는 자원으로 치환하고자 할 때. 요청의 본문에 치환할 데이터를 넣어 보냄.
• PATCH: 서버 자원의 일부만 수정하고자 할 때. 요청의 본문에 일부 수정할 데이터를 넣어 보냄.
• DELETE: 서버의 자원을 삭제하고자 할 때. 요청의 본문에 데이터를 넣지 않는다.
• OPTIONS: 요청을 하기 전에 통신 옵션을 설명하기 위해.
- ex) GET 메서드의 /user 주소로 요청을 보내면 사용자 정보를 가져오는 요청
- ex) POST 메서드의 /user 주소로 요청을 보내면 새로운 사용자를 등록
- 이렇게 배운 REST 주소 체계를 따르는 서버를 RESTful하다고 한다.
- 실습에 앞서 먼저 대략적인 주소를 먼저 설계한다.
- 이 책의 저자의 깃허브 저장소의 코드를 가지고 실습한다. (https://github.com/zerocho/nodejs-book)
const http = require('http');
const fs = require('fs').promises;
const users = {}; // 데이터 저장용
http.createServer(async (req, res) => {
try {
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': 'application/json; charset=utf-8' });
return res.end(JSON.stringify(users));
}
// /도 /about도 /users도 아니면
try {
const data = await fs.readFile(`.${req.url}`);
return res.end(data);
} catch (err) {
// 주소에 해당하는 라우트를 못 찾았다는 404 Not Found error 발생
}
} else if (req.method === 'POST') {
if (req.url === '/user') {
let body = '';
// 요청의 body를 stream 형식으로 받음
req.on('data', (data) => {
body += data;
});
// 요청의 body를 다 받은 후 실행됨
return req.on('end', () => {
console.log('POST 본문(Body):', body);
const { name } = JSON.parse(body);
const id = Date.now();
users[id] = name;
res.writeHead(201, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('ok');
});
}
} 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;
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
return res.end('ok');
});
}
} else if (req.method === 'DELETE') {
if (req.url.startsWith('/user/')) {
const key = req.url.split('/')[2];
delete users[key];
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
return res.end('ok');
}
}
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번 포트에서 서버 대기 중입니다');
});
- req.method로 요청 메서드를 구분한다.
- 요청 메서드 종류를 구분하고나면 req.url로 요청 주소를 구분하여 해당하는 소스를 보내준다.
- req.on('data'), req.on('end')처럼 사용하는 이유는 req, res도 내부적으로 스트림으로 되어있기 때문이다. 즉 데이터를 꺼내거나 수정할때 데이터가 스트림 형식으로 전달되기 때문에 이벤트처리를 해야 된다.
- name 주소로 요청을 보낸다.
- 메서드는 요청의 종류를 나타내고 xhr은 AJAX 요청이라는 뜻이다.
- 이렇게 요청에 대한 응답 헤더, 본문을 살펴볼수 있다.
- 헤더는 요청/응답에 대한 정보를 가지고 있고 본문은 실제 서버와 클라이언트가 주고받는 데이터를 담아두는 공간이다.
4.3 쿠키와 세션 이해하기
- 클라이언트에서 보내는 요청만으로는 누가 요청을 보냈는지 알수 없다.
- 우리가 로그인을 하면 새로고침이나 새로운 요청을 해도 로그아웃 되지 않도록 하려면 쿠키와 세션에 대해 알아야 한다.
- 즉 클라이언트가 서버에게 지속적으로 자기가 누구인지 알려주는 것이다.
- 사용자가 누구인지 기억하기 위해 서버는 요청의 응답에 쿠키를 함께 보낸다.
- 웹 브라우저가 쿠키를 저장해 두었다가 해당 클라이언트가 다시 요청을 할때 쿠키를 동봉해서 서버에게 보낸다.
- 서버는 쿠키를 확인해서 사용자가 누구인지 아는 것이다.
- 브라우저에서는 쿠키를 자동으로 동봉해서 보내주기 때문에 서버에서 쿠키를 보낼때만 코드를 작성하면 된다.
- 서버가 미리 클라이언트에게 쿠키를 만들어서 보내면, 앞으로 들어오는 요청에 대해 미리 주었던 쿠키를 통해 사용자가 누구인지 추적한다.
- 쿠키는 헤더에 담겨 전송된다.
- 또한 쿠키는 유효기간이 있다.
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번 포트에서 서버 대기 중입니다!');
});
- 요청자의 쿠키는 req.headers.cookie로 확인할 수 있다.
- 서버는 'Set-Cookie'로 브라우저에게 mycookie=test라는 문자열(쿠키)을 기록하라고 명령한다.
- 쿠키는 ;로 구분되는 문자열들이다.
- 이번에는 쿠키를 통해 사용자를 식별하는 방법을 살펴보자.
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;
}, {});
http.createServer(async (req, res) => {
const cookies = parseCookies(req.headers.cookie); // { mycookie: 'test' }
// 주소가 /login으로 시작하는 경우
if (req.url.startsWith('/login')) {
const { query } = url.parse(req.url);
const { name } = qs.parse(query);
const expires = new Date();
// 쿠키 유효 시간을 현재시간 + 5분으로 설정
expires.setMinutes(expires.getMinutes() + 5);
res.writeHead(302, {
Location: '/',
'Set-Cookie': `name=${encodeURIComponent(name)}; Expires=${expires.toGMTString()}; HttpOnly; Path=/`,
});
res.end();
// name이라는 쿠키가 있는 경우
} 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(8084, () => {
console.log('8084번 포트에서 서버 대기 중입니다!');
});
- 가장 먼저 쿠키의 내용을 파싱한다. mycookie = 'test' 를 { mycookie: 'test'}로 객체 형식으로 바꾸는 것이다.
- 주소가 /login으로 시작하는 경우 url과 querystring으로 사용자의 이름을 꺼낸다.
- 쿠키 만료시간을 설정하고
- 302, 리다이렉트 주소와 함께 쿠키를 헤더에 넣는다.
- 이 응답을 받은 브라우저는 쿠키를 확인하고 해당 주소로 리다이렉트 한다.
- 헤더에는 한글을 넣을 수 없기 때문에 인코딩을 해서 넣는다.
- 쿠키에는 Expires, HttpOnly처럼 옵션을 부여할 수 있다.
- 쿠키명=쿠키값: 기본적인 쿠키의 값. mycookie=test 같이 설정.
- Expires=날짜: 만료 기한. 이 기한이 지나면 쿠키가 제거. 기본값은 클라이언트가 종료될 때까지이다.
- Max-age=초: Expires와 비슷하지만 날짜 대신 초를 입력. 해당 초가 지나면 쿠기가 제거. Expires보다 우선.
- Domain=도메인명: 쿠키가 전송될 도메인을 특정. 기본값은 현재 도메인.
- Path=URL: 쿠키가 전송될 URL을 특정할 수 있다. 기본값은 ‘/’이고, 이 경우 모든 URL에서 쿠키를 전송할 수 있다.
- Secure: HTTPS일 경우에만 쿠키가 전송.
- HttpOnly: 설정 시 자바스크립트에서 쿠키에 접근할 수 없다. 쿠키 조작을 방지하기 위해 설정하는 것이 좋다.
- 주소가 /로 시작하는데 cookie가 있다면 cookie.name으로 환영 인사말을 띄워준다.
- 주소가 /로 시작하는데 쿠키가 없다면 cookie2.html 소스를 읽어와서 이름을 입력하는 화면을 보내준다.
- 이름을 입력하기 전
- 이렇게 브라우저에 쿠키가 저장되어있다. 개인정보가 노출되어있고 쿠키의 조작이 가능하기 때문에 민감한 정보를 숨기는 방법을 알아보자.
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=/`,
});
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번 포트에서 서버 대기 중입니다!');
});
- uniqueInt라는 숫자를 이름 대신 쿠키에 담아서 보낸다.
- 사용자의 진짜 이름과 만료시간은 session이라는 객체에 저장되어 있다.
- cookie.session이 있고, 만료기한을 넘기지 않았다면 session에서 실제 사용자 정보를 가져와 사용한다. 즉 서버에 사용자 정보를 저장하고 클라이언트와는 세선 아이디로만 소통한다.
- 이 방식이 세션이다. 세션 아이디는 꼭 쿠키를 사용해서 주고받지 않아도 되지만 가장 간단한 방법이다.
- 세션을 위해 사용하는 쿠키를 세션쿠키라고 한다.
- 실제로는 세션을 위처럼 변수에 저장하지는 않는다. 서버를 재시작하거나 멈추거나 메모리가 부족하면 다 휘발되기 때문이다.
- 보통은 레디스(Redis)나 멤캐시드(Memcached) 같은 데이터베이스에 넣어둔다.
- 위에 처럼 얄팍하게 만든 쿠키 세션을 사용하면 보안상 너무 취약하기 때문에 실제 서비스에서는 사용하지 말자.
- 안전하게 검증된 다른 사람의 모듈을 사용하자.
4.4 https와 http2
- https 모듈은 웹 서버에 SSL 암호화를 추가한다.
- GET이나 POST 요청을 할 때 오가는 데이터를 암호화해서 중간에 누가 패킷을 가로채도 내용을 확인할 수 없다.
- 로그인이나 결제가 필요한 창에서 https 적용은 필수다.
- SSL이 적용된 웹 사이트에 방문하면 아래 그림과 같이 브라우저 주소창에 자물쇠 표시가 나온다.
- 암호화를 적용하려면 그것을 인증해줄 수 있는 기관이 필요하다.
- 인증서는 인증 기관에서 구입해야 하며, Let’s Encrypt 같은 기관에서 무료로 발급해주기도 한다.
const https = require('https');
const fs = require('fs');
https.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번 포트에서 서버 대기 중입니다!');
});
- 인증서를 발급 받으면 제공되는 pem이나 crt, key 확장자를 가진 파일들을 createServer 메서드의 첫번째 인수로 전달한다.
- cert, key, ca 옵션 객체에 fs.readFileSync 메서드로 읽어서 알맞게 넣는다.
- 실제 서버에서는 80 포트 대신 443 포트를 사용하면 된다.
- http2 모듈은 SSL암호화와 더불어 최신 http 프로토콜인 http/2를 사용할 수 있게 한다.
- 패킷을 보낼때 파이프라이닝을 통해 더 빠르고 효율적인 구현이 가능하다.
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번 포트에서 서버 대기 중입니다!');
});
- http2를 적용한 모습이다. 다를게 별로 없다.
4.5 cluster
- 싱글 프로세스로 동작하는 노드가 CPU 코어를 모두 사용할 수 있게 해주는 모듈이다.
- 포트를 공유하는 노드 프로세스를 여러 개 둘 수도 있으므로, 요청이 많이 들어왔을 때 병렬로 실행된 서버의 개수만큼 요청이 분산되게 할 수 있다. (서버에 무리를 덜 줄 수 있다.)
- 코어가 8개가 있는 서버라도 노드는 보통 코어를 하나만 사용하지만 cluster 모듈을 사용하면 코어 하나당 노드 프로세스 하나가 돌아가게 할 수 있다.
- 장점은 성능이 개선될 수 있는 것이고 단점으로는 프로세스끼리는 메모리를 공유하지 못한다는 것이다.
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
console.log(`마스터 프로세스 아이디: ${process.pid}`);
// CPU 개수만큼 워커를 생산
for (let i = 0; i < numCPUs; i += 1) {
cluster.fork();
}
// 워커가 종료되었을 때
cluster.on('exit', (worker, code, signal) => {
console.log(`${worker.process.pid}번 워커가 종료되었습니다.`);
console.log('code', code, 'signal', signal);
});
} else {
// 워커들이 포트에서 대기
http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.write('<h1>Hello Node!</h1>');
res.end('<p>Hello Cluster!</p>');
}).listen(8086);
console.log(`${process.pid}번 워커 실행`);
}
- 마스터 프로세스는 코어 수만큼 워커를 생성하고 동일한 8086 포트에서 대기하게 한다.
- 8086번 포트로 요청이 들어오면 만들어진 워커 프로세스에 요청을 분배한다.
'ComputerScience > NodeJs' 카테고리의 다른 글
node - 6 Express로 웹 서버 만들기 (0) | 2022.01.22 |
---|---|
node - 5 Package Manager (0) | 2022.01.15 |
node - 3.7 이벤트 이해하기 ~ 3.8 예외처리 (0) | 2022.01.09 |
node - 3.6 파일 시스템 접근하기 (0) | 2022.01.07 |
node - 3.5 노드 내장 모듈 사용하기 (0) | 2022.01.06 |