쿠키와 세션
클라이언트에서 보내는 요청에는 한 가지 큰 단점이 있다.
바로 누가 요청을 보내는지 모른다는 것이다. 물론 요청을 보내는 IP 주소나 브라우저의 정보를 받아올 수는 있다. 하지만 여러 컴퓨터가 공통으로 IP 주소를 가지거나, 한 컴퓨터를 여러 사람이 사용할 수도 있다.
그렇다면 로그인을 구현하여 관리하면 된다. 하지만 로그인을 구현하려면 쿠키와 세션에 대해 알고 있어야 한다.
로그인한 후에는 새로고침을 해도 로그아웃이 되지 않는다. 클라이언트가 서버에 누구인지 지속적으로 알려주고 있기 때문이다.
누구인지 기억하기 위해 서버는 요청에 대한 응답을 할 때 쿠키라는 것을 같이 보낸다. 쿠키는 유효 기간이 있으며 name=GR8와 같이 단순한 '키-값'의 쌍이다. 서버로부터 쿠키가 오면 웹 브라우저는 쿠키를 저장해두었다가 다음에 요청할 때마다 쿠키를 동봉해서 보낸다. 서버는 요청에 들어있는 쿠키를 읽어서 사용자가 누구인지 파악한다.
브라우저는 쿠키가 있다면 자동으로 동봉해서 보내주므로 따로 처리할 필요는 없다.
서버에서 브라우저로 쿠키를 보낼 때만 코드를 작성하여 처리하면 된다.
즉, 서버는 미리 클라이언트에 요청자를 추정할 만한 정보를 쿠키로 만들어 보내고, 그다음부터는 클라이언트로부터 쿠키를 받아 요청자를 파악한다. 쿠키는 요청의 헤더(Cookie)에 담겨 전송된다. 브라우저는 응답의 헤더(Set-Cookie)에 따라 쿠키를 저장한다.
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(9001, () => {
console.log('9001포트 서버 대기중....');
});
쿠키는 문자열 형식으로 존재하고 세미콜론으로 구분된다.
createServer 메서드의 콜백에서는 req 객체에 담겨 있는 쿠키를 가져온다. 쿠키는 req.headers.cookie에 들어 있다.
req.headers는 요청의 헤더를 의미한다. 응답의 헤더에 쿠키를 기록해야 하므로 res.writeHead 메서드를 사용한다.
/favicon.ico는 요청한 적이 없는데 두 개가 기록되어 있다.
favicon은 웹 사이트 텝에 보이는 이미지를 뜻한다.
아직까지는 쿠키가 누구인지 식별해주지 못하고 있다.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>cookie&session</title>
</head>
<body>
<form action="/login">
<input id="name" name="name" placeholder="이름을 입력하세요" />
<button id="login">로그인</button>
</form>
</body>
</html>
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;
}, {});
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();
//쿠키 유효 시간을 현재 시간 +5분으로 설정
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(9004, () => {
console.log('9004포트 서버 대기중....')
});
쿠키는 mycookie=test 같은 문자열이다. 이를 쉽게 사용하기 위해 자바스크립트 객체 형식으로 바꿨다.
{mycookie: 'test'} 같은 형태가 된다.
주소가 /login으로 시작할 경우에는 url과 querystring 모듈로 각각 주소와 주소에 딸려오는 query를 분석한다.
그리고 쿠키의 만료 시간도 지금으로부터 5분 뒤로 설정했다. 이제 302응답 코드, 리다이렉트 주소와 함께 쿠키를 헤더에 넣는다. 헤더에는 한글을 설정할 수 없으므로 name 변수를 encodeURIComponent 메서드로 인코딩했다.
또한 Set-Cookie의 값으로는 제한된 ASCII 코드만 들어가야 하므로 줄바꿈을 넣으면 안 된다.
그 외의 경우, 먼저 쿠키가 있는지 없는지를 확인한다. 쿠키가 없다면 로그인할 수 있는 페이지를 보낸다.
처음 방문한 경우에는 쿠키가 없으므로 cookie2.html이 전송된다.
새로고침을 해도 로그인이 유지된다.
하지만 이방법은 매우 위험하다 Application 탭에서 쿠키가 노출되어 있기 때문이다.
이를 보완해보자.
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();
//쿠키 유효 시간을 현재 시간 +5분으로 설정
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(8005, () =>{
console.log('8005번 포트 서버 대기중...');
});
쿠키에 이름을 담아서 보내는 대신, uniqueInt라는 숫자 값을 보냈다. 사용자의 이름과 만료 시간은 uniqueInt 속성명 아래에 있는 session이라는 객체에 대신 저장한다. 이제 cookie.session이 있고 만료 기한을 넘기지 않았다면 session 변수에서 사용자 정보를 가져와 사용한다.
이 방식이 세션이다. 서버에 사용자 정보를 저장하고 클라이언트와는 세션 아이디로만 소통한다.
세션 아이디는 꼭 쿠키를 사용해서 주고받지 않아도 된다. 하지만 많은 웹 사이트가 쿠키를 사용한다.
쿠키를 사용하는 방법이 가장 간단하기 때문이다. 세션을 위해 사용하는 쿠키를 세션 쿠키라고 부른다.
실제 배포용에서는 위와 같이 변수에 저장하지 않는다. 서버가 멈추거나 재시작되면 변수가 초기화되기 때문이다.
그래서 보통은 레디스(Redis)나 맴캐시드(Memcached) 같은 데이터베이스에 넣어둔다.
https & http2
https 모듈은 웹 서버에 SSL 암호화를 추가한다. GET이나 POST 요청을 할 때 오가는 데이터를 암호화해서 중간에 다른 사람이 요청을 가로채더라도 내용을 확일할 수 없게 한다.
const fs = require('fs');
const https = require('https');
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 포트 서버 대기...');
});
서버에 암호화를 적용하려면 https 모듈을 사용해야 한다. 하지만 https는 아무나 사용할 수 있는 것은 아니다.
암호화를 적용하는 만큼, 그것을 인증해줄 수 있는 기관도 필요하다. 인증서는 인증 기관에서 구입해야 한다.
http2 모듈은 SSL 암호화와 더불어 최신 HTTP 프로토콜인 http/2를 사용할 수 있게 한다.
http/2는 요청 및 응답 방식이 기존 http/1.1보다 개선되어 훨씬 효율적으로 요청을 보낸다.
const fs = require('fs');
const http2 = require('http2');
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 포트 서버 대기...');
});
모듈 이름과 메서드 이름만 다르고 나머지는 똑같다.
cluster
cluster 모듈은 기본적으로 싱글 프로세스로 동작하는 노드가 CPU 코어를 모두 사용할 수 있게 해주는 모듈이다.
포트를 공유하는 노드 프로세스를 여러 개 둘 수도 있으므로, 요청이 많이 들어왔을 때 병렬로 실행된 서버의 개수만큼 요청이 분산되게 할 수 있다. 즉, 서버에 무리가 덜 가게 된다.
예를 들어 코어가 여덟 개인 서버가 있을 때, 노드는 보통 코어를 하나만 활용한다. 하지만 cluster 모듈을 설정하여 코어 하나당 노드 프로세스 하나가 돌아가게 할 수 있다. 성능이 꼭 여덟 배가 되는 것은 아니지만 코어를 하나만 사용할 때에 비해 성능이 좋다. 하지만 메모리를 공유하지 못하는 등의 단점이 있다.
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if(cluster.isMaster){
console.log(`마스터 프로세스 아이디: ${process.pid}`);
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}번 워커 실행`);
}
마스터 프로세스는 CPU 개수만큼 워커 프로세스를 만들고, 8086번 포트에서 대기한다. 요청이 들어오면 만들어진 워커 프로세스에 요청을 분배한다. 워커 프로세스가 실질적인 일을 하는 프로세스이다.
직접 cluster 모듈로 클러스터링을 구현할 수도 있지만, 실무에서는 pm2 등의 모듈로 cluster 기능을 사용하곤 한다.