4장 http 모듈로 서버 만들기(2)

Bor·2021년 12월 28일
0

Node.js

목록 보기
7/11

4장 http 모듈로 서버 만들기 두번째 이야기!

쿠키와 세션 이해하기

클라이언트에서 보내는 요청에는 한 가지 큰 단점. 바로 누가 보내는지 모른다. 물론 요청을 보내는 IP 주소나 브라우저의 정보를 받아올 수는 있다. 하지만 여러 컴퓨터가 공통으로 IP주소를 가지거나 한 컴퓨터를 여러 명이 사용할 수도.

우리가 웹 사이트에 방문해서 로그인을 할 때 내부적으로는 쿠리와 세션을 사용. 로그인을 한 후에는 새로고침(새로운 요청)을 해도 로그아웃이 되지 않음. 클라이언트가 서버에 내가 누구인지를 지속적으로 알려주고 있기 때문.

내가 누구인지 기억하기 위해 서버는 요청에 대한 응답을 할 때 쿠키라는 것을 같이 보냅니다. 쿠키는 유효 기간이 있으며 name = boram과 같이 단순하 '키-값'의 쌍. 서버로부터 쿠키가 오면 웹 브라우저는 쿠키를 저장해뒀다가 다음에 요청 시 쿠키를 동봉해서 보낸다. 서버는 요청에 들어 있는 쿠키를 읽어서 사용자가 누군지 파악.

브라우저는 쿠키가 있다면 자동으로 동봉해서 보내주므로 따로 처리할 필요가 없다. 서버에서 브라우저로 쿠키를 보낼 때만 코드를 작성해서 처리하면 된다.

서버는 미리 클라이언트에 요청자를 추정할 만한 정보로 쿠키를 만들어 보내고, 그다음부터는 클라이언트로부터 쿠키를 받아 요청자를 파악. 쿠키는 우리가 누우인지 추적하고 있는 것. 개인정보 유출 방지를 위해서 쿠키를 주기적으로 지우라고 권고하는 이유!!

쿠키는 요청의 헤더에 담겨 전송. 브라우저는 응답의 헤더(Set-Cookie)에 따라 쿠키를 저장. 서버에서 직접 쿠키를 만들어 요청자의 브라우저에 넣어보자!

cookie.js

const http = require('http');

http.createServer((req, res) => {
        console.log(req, req.headers.cookie);
        res.writeHead(200, {
            'Set-Cookie': 'mycookie = test'
        });
        res.end('Hello Cookie');
    })
    .listen(8083, () => {
        console.log('8083번 포트에서 서버 대기 중입니다.')
    })

쿠키는 name = boram;year=199x 처럼 문자열 형식으로 존재. 쿠키간에는 세미콜론으로 구분. createServer 메서드의 콜백에서는 req 객체가 담겨 있는 쿠키를 가져온다. 쿠키는 req.headers.cookie에 들어 있다. req.headers는 요청의 헤더. 쿠키는 앞서 말한 것처럼 요청과 응답의 헤더를 통해 오간다.

응답이 헤더에 쿠키를 기록해야 하므로 res.writeHead 메서드를 사용.

  • Set-Cookie는 브라우저에게 다음과 같은 쿠키의 같을 저장하라는 의미.

실제로 응답을 받은 브라우저는 mycookie = test라는 쿠키를 저장.

localhost:8083에 접속하면 req.url과 req.headers.cookie에 대한 정보를 로깅하도록. req.url은 주소의 path와 search 부분을 알린다. 요청은 분명 한 번만 보냈는데 두 개가 기록되어 있다. /favicon.ico는 요청한 적이 없는데!

첫 번째 요청(/)에서는 쿠키에 대한 정보가 없다고, 두 번째 요청(/favicon.ico)에서는 {mycookie:'test'}가 기록되었다. 브라우저는 파비콘이 뭔지 HTML에서 유추할 수 없으면 서버에 파비콘 정보에 대한 요청을 보낸다. 현재 예제에서는 HTML에 파비콘에 대한 정보를 넣어두지 않았으므로 브라우저가 추가로 /favicon.ico을 요청.

요청 두 개를 통해 서버가 제대로 쿠키를 심었음을 확인. 첫 번째 요청(/)을 보내기 전에 브라우저가 어떠한 쿠키 정보도 가지고 있지 않음. 서버는 응답의 헤더에 mycookie=test라는 쿠키를 심으라고 브라우저에게 명령(Set-Cookie)했음. 따라서 브라우저는 쿠키를 심었고 두 번째 요청(/facicon.ico)의 헤더에 쿠키가 들어 있음을 확인할 수 있다.

cookie2.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <title>쿠키&세션 이해하기</title>
</head>

<body>
    <form action="/login">
        <input id="name" name="name" placeholder="이름을 입력하세요" />
        <button id="login">로그인</button>
    </form>
</body>

</html>

cookie2.js

const http = require('http');
const fs = require('fs').promises;
const url = require('url');
const qs = require('querystring');

➊ 쿠키는 mycookie = test 같은 문자열. 이를 쉽게 사용하기 위해서 
자바스크립트 객체 형식으로 바꾸는 함수. 이 함수를 거치면 {mycookie:'test'}가 된다. 
parseCookies 함수가 문자열을 객체로 바꿔준다

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);

➋ 주소가 /login 으로 시작하는 경우에는 url과 querystring 모듈로 각각 주소와 주소에 딸려오는 query를 분석. 
그리고 쿠키의 만료 시간도 5분 뒤로 설정. 302 응답코드, 리다이렉트 주소와 함께 쿠키를 헤더에 넣는다. 
브라우저는 이 응답 코드를 보고 페이지를 해당 주소로 리다이렉트. 
헤더에는 한글을 설정할 수 없으므로 name 변수를 encodeURIcomponent 메서드로 인코딩. 
또한 Set-Cookie의 값으로는 제한된 ASCII 코드만 들어가야 하므로 줄바꿈을 넣으면 안 된다. 

// 주소가 /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();

➌ 그 외의 경우(/로 접속했을 때 등), 먼저 쿠키가 있는지 없는지 확인. 
쿠키가 없다면 로그인할 수 있는 페이지를 보낸다. 
처음 방문한 경우에는 쿠키가 없으므로 cookie2.html이 전송된다. 
쿠키가 있다면 로그인한 상태로 간주하여 인사말을 보낸다. 

  // 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번 포트에서 서버 대기 중입니다!');
  });

Set-Cookie로 쿠키를 설정할 때 만료 시간(Expires)과 HttpOnly, Path 같은 옵션을 부여. 쿠키를 설정할 때는 각종 옵션을 넣을 수 있으며, 옵션 사이에 세미콜론을 써서 구분. 쿠키에는 들어가면 안 되는 글자들이 있는데 대표적으로 한글과 줄바꿈, 한글은 encodeURIComponent로 감싸서 넣는다.

  • 쿠키명 = 쿠키값: 기본적인 쿠키값. mycookie = test 또는 name = boram 처럼 설정.
  • Expires = 날짜 : 만료기한. 이 기한이 지나면 쿠키가 제거. 기본값은 클라이언트가 종료될 때까지
  • Max-age = 초 : Expires와 비슷하지만 날짜 대신 초를 입력할 수. 해당 초가 지나면 쿠키가 제거된다. Expires보다 우선
  • Domain = 도메인명: 쿠키가 전송될 도메인 특정. 기본값 => 현재 도메인
  • Path = URL: 쿠키가 전송될 URL을 특정. 기본값은 '/'이고 이 경우 모든 URL에서 쿠키 전송 가능
  • Secure: HTTPS일 경우에만 쿠키가 전송된다
  • HttpOnly: 설정 시 자바스크립트에서 쿠키에 접근 불가. 쿠키 조작을 방지하기 위해 설정하는 것이 좋다.



해냈다!! 새로고침을 해도 로그인이 유지. 원하는 대로 동작하기만 하지만 이 방식은 상당히 위험하다. 혀재 Application 탭에서 보이는 것처럼 쿠키가 노출되어 있음. 또한, 쿠키가 조작될 위험도. 따라서 이름 같은 민감한 개인정보를 쿠키에 넣어두는 것은 적절하지 못하다.

다음과 같이 코드를 변경하여 서버가 사용자 정보를 관리하도록 만듭니다.

session.js

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번 포트에서 서버 대기 중입니다!');
    });

cookie2.js 와는 살짝 달라진 부분. 쿠키에 이름을 담아서 보내는 대신, uniqueInt라는 숫자 값을 보냈다. 사용자의 이름과 만료 시간은 uniqueInt 속성명 아래에 있는 session이라는 객체에 대신 저장. 이제 cookie.session이 있고 만료 기한을 넘지 않았다면 session 변수에서 사용자 정보를 가져와 사용. 다른 부분은 동일.

세션은 서버에 사용자 정보를 저장하고 클라이언트와는 세션 아이디로만 소통. 세션 아이디는 꼭 쿠키를 사용해서 주고 받지 않아도 된다. 하지만 많은 웹 사이트가 쿠키를 사용. 쿠키를 사용하는 방법이 제일 간단하므로. 세션을 위해 사용하는 쿠키를 세션 쿠키라고 부른다.

물론 실제 배포용 서버에서는 세션을 위와 같이 변수에 저장하지 않는다. 서버가 멈추거나 재시작되면 메모리에 저장된 변수가 초기화 & 서버의 메모리가 부족하면 세션을 저장하지 못하는 문제도 발생. 그래서 보통 레디스나 멤캐시드와 같은 DB에 넣어둔다.

서비스를 새로 만들 때마다 쿠키와 세션을 직접 구현할 수 없다. 게다가 지금 코드로는 쿠키를 악용한 여러 가지 위협을 방어하지도 못한다.

요부분! 추가 학습하기!!

https 와 http2

https 모듈은 웹 서버에 SSL 암호화를 추가한다. GET이나 POST 요청을 할 때 오가는 데이터를 암호화해서 중간에 다른 사람이 요청을 가로채더라도 내용을 확인할 수 없다. 요즘은 로그인이나 결제가 필요한 창에 https 적용이 필수가 되는 추세. SSL이 적용된 웹사이트를 방문하면 주소창에 자물쇠 표시가 나온다.

암호화를 적용하려면 https 모듈을 사용해야. 하지만 https는 아무나 사용할 수 있는 것이 아니다. 암호화를 적용하는 만큼, 그것을 인증해줄 수 있는 기관도 필요. 인증서는 인증 기관에서 구입해야 하며, Let's Encrypt 같은 기관에서 무료로 발급해주기도. 인증서 발급 과정은 복잡하고 도메인도 필요하므로 인증서를 발급하는 방법은 이 책에서 소개하지 않는다 ㅠ. 발급받은 인증서가 있다면 다음과 같이 하면 된다.

server1-3.js

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번 포트에서 서버 대기 중입니다!');
  });

다른 것은 거의 비슷하지만 createServer 메서드가 인수를 두 개 받는다. 두 번째 인수 http 모듈과 같이 서버 로직이고, 첫 번째 인수는 인증서에 관련된 옵션 객체! 인증서를 구입하면 pem 이나 crt, 또는 key 확장자를 가진 파일들을 제공한다. 파일들을 fs.readFileSync 메서드를 읽어서 cert, key, ca 옵션에 맞게 넣으면 된다. 실제 서버에서는 80 포트 대신 443 포트를 사용하면 된다.

노드의 http2 모듈은 SSL 암호화와 더불어 최신 HTTP 프로토콜인 http/2 를 사용할 수 있게 함. http/2는 요청 및 응답 방식이 기존 http/1.1보다 개선되어 훨씬 효율적으로 요청을 보낸다. http/2를 사용하면 웹의 속도도 많이 개선된다.

아래 그림을 보면 http/1.1과 http/2의 차이를 확연하게 느낄 수 있다. 실제로는 http/1.1도 파이프라인이라는 기술을 적용하므로 이렇게 큰 차이가 나지는 않는다. 하지만 http/2가 훨씬 효율적인 것만은 분명하다.

4.5 cluster

클러스터 모듈은 기본적으로 싱글 프로세스로 동작하는 노드가 CPU 코어를 모두 사용할 수 있게 해주는 모듈. 포트를 공유하는 노드 프로세스를 여러 개 둘 수 도 있으므로, 요청이 많이 들어왔을 때 병렬로 실행된 서버의 개수만큼 요청이 분산되게 할 수 있다. 서버에 무리가 덜 가게 되는 셈.

예를 들어 코어가 여덟 개인 서버가 있을 때, 노드는 보통 코어를 하나만 활용한다. 하지만 클러스터 모듈을 설정하여 코어 하나당 노드 프로세스 하나가 돌아가게 할 수 있다. 성능이 꼭 여덟 배가 되는 것은 아니지만 코어를 하나만 사용할 때에 비해 성능이 개선된다. 하지만 장점만 있는 것은 아닌게 메모리를 공유하지 못하는 단점도 있다. 세션을 메모리에 저장하는 경우 문제가 될 수 있다. 이는 레디스 등의 서버를 도입하여 해결할 수 있다.

컴퓨터 구조에서 배웠다!

cluster.js

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}번 워커 실행`);
}

worker_threads의 예제와 모양이 비슷. 다만 스레드가 아니라 프로세스. 클러스터에는 마스터 프로세스와 워커 프로세스가 있다. 마스터 프로세스는 CPU 개수만큼 워커 프로세스를 만들고, 8086번 포트에서 대기. 요청이 드어오면 만들어진 워커 프로세스에 요청을 분배!

직접 cluster 모듈로 클러스터링을 구현할 수도 있지만, 실무에서는 pm2 등의 모듈로 cluster 기능을 사용하곤 한다. pm2 모듈은 이후에 설명. 다시 REST와 라우팅으로 돌아가 보자! 4.2 절의 웹 서버 주소는 HTML 또는 CSS 같은 정적 파일을 요청하는 주소와 서버의 users 자원을 요청하는 주소로 크게 나뉜다. 만약 파일이나 자원의 수가 늘어나면 그에 따라 서버 주소의 종류도 많아져야 한다.

그런데 if문이 많아져서 가독성이 구려진다. 주소의 수가 많아질수록 코드는 계속 길어진다. 여기에 쿠키와 세션을 추가하게 되면 더 복잡해질 것. 그래서 Express가 등장!

0개의 댓글