HTTP 모듈로 서버 만들기

gdhi·2024년 1월 9일

Node.js

목록 보기
3/7
post-thumbnail

실제로 돌아가는 서버를 만든다. 지금까지 배운 다양한 모듈을 사용할 예정이며, 전부 외우지 않아도 된다. 해당 모듈이 어떤 역할을 했는지 기억나지 않는다면 돌아가서 다시 보자.

실제 서버 동작에 필요한 쿠키와 세션 처리를 살펴보고 요청 주소별 라우팅 방법도 배운다.

📖요청과 응답 이해하기

서버는 클라이언트가 있기에 동작한다. 클라이언트에서 서버로 요청(request)을 보내고, 서버에서는 요청의 내용을 읽고 처리한 뒤 클라이언트에 응답(response)을 보낸다.
따라서 서버에는 요청을 받는 부분과 응답을 보내는 부분이 있어야 한다. 요청과 응답은 이벤트 방식이라고 생각하면 된다. 클라이언트로부터 요청이 왔을 때 어떤 작업을 수행할지 이벤트 리스너를 미리 등록해둬야 한다.


📌이벤트 리스너를 가진 노드서버 만들기

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

👉 허용 후 localhost:8080 이동



📍동작 원리



📍localhost와 포트

localhost는 현재 컴퓨터의 내부 주소를 가리키며, 외부에서는 접근할 수 없고 자신의 컴퓨터에서만 접근할 수 있다. 따라서 서버를 개발할 때 테스트용으로 많이 사용된다. localhost 대신 127.0.0.1을 주소로 사용해도 같다. 이러한 숫자 주소를 IP(Internet Protocol)라고 한다.

포트는 서버 내에서 프로세스를 구분하는 번호이다. 서버는 HTTP 요청을 대기하는 것 외에도 다양한 작업을 한다. 데이터베이스와도 통신해야 하고, FTP 요청을 처리하기도 한다. 따라서 서버는 프로세스에 포트를 다르게 할당해 들어오는 요청을 구분한다. 유명한 포트 번호로는 21(FTP), 80(HTTP), 443(HTTPS), 3306(MYSQL)이 있다. 포트 번호는 IP 주소 뒤에 콜론(:)과 함께 붙여 사용

현재 예제에서는 임의의 포트 번호 8080노드 서버(프로세스)를 연결했다. 따라서 http://localhost:8080으로 접근해야 한다.

그런데 http://gilbut.co.kr 같은 사이트들은 포트 번호를 따로 표시하지 않는다. 바로 80번 포트를 사용하기 때문. 80번 포트를 사용하면 주소에서 포트를 생략할 수 있있다. https의 경우에는 443번 포트를 생략할 수 있습니다. 따라서 http://gilbut.co.kr:80으로 요청해도 길벗 홈페이지에 접속된다.

이 책에서 80번 포트를 사용하지 않는 이유는 충돌을 방지하기 위해서다. 일반적으로 컴퓨터에서 80번 포트는 이미 다른 서비스가 사용하고 있을 확률이 크다. 보통 포트 하나에 서비스를 하나만 사용할 수 있으므로 다른 서비스가 사용하고 있는 포트를 사용하려고 하면 에러가 발생한다. 따라서 예제를 실행할 때는 다른 포트 번호들을 사용하고, 실제로 배포할 때는 80번 또는 443번 포트를 사용한다.

한 가지 더 알아둘 점은 리눅스에서는 1024번 이하의 포트에 연결할 때 관리자 권한이 필요하다. 따라서 명령어 앞에 sudo를 붙여야 합니다. 예를 들면 node server1 대신 sudo node server1을 입력해야 한다. 예제에서는 8080번 포트를 사용하므로 sudo를 붙이지 않아도 된다.

포트 충돌

다른 서비스가 사용하고 있는 포트를 사용할 경우 Error: listen EADDRINUSE :::포트 번호 같은 에러가 발생한다. 이런 경우, 그 서비스를 종료하거나 노드의 포트를 다른 번호로 바꾸면 된다. 서비스가 종료되지 않는다면 전에 배운 에러 해결 방법을 참고



📌리스너로 서버 열기

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! 1</h1>');
    res.end('<p>Hellot Server! 1</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! 2</h1>');
    res.end('<p>Hello Server! 2</p>');
  })
.listen(8081, () => { // 서버 연결
      console.log('8081번 포트에서 서버 대기 중입니다!');
});



📌html 사용하기

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <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 {
        // 요청이 들어오면 fs모듈로 HTML 파일을 읽는다
        // data 변수에 저장된 버퍼를 그대로 클라이언트에 보냄
        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번 포트에서 서버 대기 중입니다!');
    });



📍HTTP 상태 코드

200이나 500과 같은 숫자는 HTTP 상태 코드이다. res.writeHead에 첫 번째 인수로 상태 코드를 넣었는데, 브라우저는 서버에서 보내주는 상태 코드를 보고 요청이 성공했는지 실패했는지를 판단한다.

대표적인 상태 코드

상태 코드 범주설명
2XX: 성공성공을 나타내는 상태 코드. 주로 200(성공), 201(작성됨)이 사용됨.
3XX: 리다이렉션다른 페이지로 이동을 나타내는 상태 코드. 주로 301(영구 이동), 302(임시 이동)가 사용됨. 304(수정되지 않음)는 응답에 캐시를 사용했음을 나타냄.
4XX: 클라이언트 오류요청 자체에 오류가 있음을 나타내는 상태 코드. 주로 400(잘못된 요청), 401(권한 없음), 403(금지됨), 404(찾을 수 없음)가 사용됨.
5XX: 서버 오류서버에 오류가 생겼을 때 나타나는 상태 코드. 주로 500(내부 서버 오류), 502(불량 게이트웨이), 503(서비스를 사용할 수 없음)이 사용됨.

무조건 응답을 보내야 한다

요청 처리 과정 중에 에러가 발생했다고 해서 응답을 보내지 않으면 안된다. 요청이 성공했든 실패했든 응답을 클라이언트로 보내서 요청이 마무리되었음을 알려야 한다. 응답을 보내지 않는다면, 클라이언트는 서버로부터 응답이 오길 하염없이 기다리다가 일정 시간이 지난 후 Timeout(시간 초과) 처리한다.









📖REST와 라우팅 사용하기

REST는 REpresentational State Transfer의 줄임말로, 서버의 자원을 정의하고 자원에 대한 주소를 지정하는 방법을 가리킨다. 일종의 약속이라고 봐도 무방. 자원이라고 해서 꼭 파일일 필요는 없고 서버가 행할 수 있는 것들을 통틀어서 의미한다고 보면 된다

HTTP 메서드설명
GET서버 자원을 가져오고자 할 때 사용. 데이터를 쿼리스트링으로 전송.
POST서버에 자원을 새로 등록하고자 할 때 사용. 데이터를 요청 본문에 포함하여 전송.
PUT서버의 자원을 요청에 포함된 자원으로 치환하고자 할 때 사용. 요청 본문에 치환할 데이터를 포함하여 전송.
PATCH서버 자원의 일부를 수정하고자 할 때 사용. 요청 본문에 일부 수정할 데이터를 포함하여 전송.
DELETE서버의 자원을 삭제하고자 할 때 사용. 데이터를 요청 본문에 포함하지 않음.
OPTIONS요청 전에 통신 옵션을 설명하기 위해 사용. 주로 CORS와 관련하여 사용됨.


📌RESTful한 웹 서버 만들기

REST를 따르는 서버를 RESTful하다고 표현한다.

대략적인 주소 설계

👉 이런식으로 주소 구조를 미리 머릿속에 정리해둔 후 코딩을 시작하면 더욱 체계적으로 프로그래밍할 수 있다.


📍restFront.css

a { 
    color: blue; text-decoration: none; 
}



📍restFront.html

<!DOCTYPE html>
<html lang="ko">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>RESTful SERVER</title>
    <link rel = "stylesheet" href="./16_restFront.css">
</head>

<body>
    <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
    </nav>

    <div>
        <form id="form">
            <input type="text" id="username">
            <button type="submit">등록</button>
        </form>
    </div>

    <div id="list"></div>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script src="./18_restFront.js"></script>
</body>

</html>



📍restFront.js

async function getUser() { // 로딩 시 사용자 정보를 가져오는 함수
    try {
        const res = await axios.get('/users');
        const users = res.data;
        const list = document.getElementById('list');
        // div를 비우지 않으면 중복되서 들어감. 
        // 1 등록 후 2 등록시 1과 2가 list로 등록되는 것이 아닌 1 1 2 로 등록됨
        list.innerHTML = '';
        // 사용자마다 반복적으로 화면 표시 및 이벤트 연결
        Object.keys(users).map(function (key) {
            const userDiv = document.createElement('div');
            const span = document.createElement('span');
            span.textContent = users[key]; // 스크립트에서 []는 자바로 치면 List
          	// 버튼 생성
            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 });
                    getUser();
                } catch (err) {
                    console.error(err);
                }
            });
          	// 삭제 버튼 생성
            const remove = document.createElement('button');
            remove.textContent = '삭제';
            remove.addEventListener('click', async () => { // 삭제 버튼 클릭
                try {
                    await axios.delete('/user/' + key);
                    getUser();
                } catch (err) {
                    console.error(err);
                }
            });
          	// div에 span(등록한 이름) 넣기
            userDiv.appendChild(span);
          	// div에 edit(수정 버튼) 넣기
            userDiv.appendChild(edit);
          	// div에 remove(삭제 버튼) 넣기
            userDiv.appendChild(remove);
          	// userDiv 리스트 넣기 👉 리스트 안에 있는 것들 전부 출력
            list.appendChild(userDiv);
            console.log(res.data);
        });
    } catch (err) {
        console.error(err);
    }
}

window.onload = getUser; // 화면 로딩 시 getUser 호출
// 폼 제출(submit) 시 실행
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 });
        getUser();
    } catch (err) {
        console.error(err);
    }
    e.target.username.value = '';
});



📍about.html

<!DOCTYPE html>
<html lang="ko">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>RESTful SERVER</title>
    <link rel = "stylesheet" href="./16_restFront.css">
</head>

<body>
    <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
    </nav>
    <div>
        <h2>소개 페이지입니다.</h2>
        <p>사용자 이름을 등록하세요!</p>
    </div>
</body>

</html>



📍restServer.js

const http = require('http');
const fs = require('fs').promises;
const path = require('path');

const users = {};// 데이터 저장용

http.createServer(async (req, res) => {
    try {
        console.log(req.method, req.url);
        // req.method로 HTTP 요청 메소드 구분
        // 메소드가 GET일 때
        if (req.method === 'GET') {
            // 주소가 / 이면 restFront.html 제공
            if (req.url === '/') {
                const data = await fs.readFile(path.join(__dirname, '17_restFront.html'));
                res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
                return res.end(data);
            }
            // 주소가 /about 이면 about.html 제공
            else if (req.url === '/about') {
                const data = await fs.readFile(path.join(__dirname, '19_about.html'));
                res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
                return res.end(data);
            } 
             // 주소가 /users 이면 주소에 적힌 파일 제공
            else if (req.url === '/users') {
                res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
                return res.end(JSON.stringify(users));
            }
            // /도 /about도 /users도 아니면
            try {
                const data = await fs.readFile(path.join(__dirname, req.url));
                return res.end(data);
            } catch (err) {
                // 주소에 해당하는 라우트를 찾지 못했다는 404 Not Found error 발생
            }
        }
        // 메소드가 POST일 때 
        else if (req.method === 'POST') {
            // 주소가 /user 이면 사용자를 새로 저장
            if (req.url === '/user') {
                let body = '';
                // 요청의 body를 stream 형식으로 받음
                req.on('data', (data) => {
                    body += data; // name : 등록할 이름
                });
                // 요청의 body를 다 받은 후 실행됨
                return req.on('end', () => {
                    console.log('POST 본문(Body):', body);
                  	// name : 등록할 이름 (분할 할당)
                    const { name } = JSON.parse(body);
                    const id = Date.now(); // 현재 시간, 1980년 부터 지난 초
                    users[id] = name; // name : 등록할 이름
                    res.writeHead(201, { 'Content-Type': 'text/html; charset=utf-8' });
                    res.end('등록 성공');
                });
            }
        }
        // 메소드가 PUT일 때 
        else if (req.method === 'PUT') {
            // 주소가 /user/아이디 이면 사용자 데이터 수정
            if (req.url.startsWith('/user/')) { //[0] [1]/user [2]/key 값
                const key = req.url.split('/')[2]; //[2]는 key값
                let body = '';
                req.on('data', (data) => {
                    body += data; // += name: 변경할 내용
                });
                return req.on('end', () => {
                    console.log('PUT 본문(Body):', body);
                    users[key] = JSON.parse(body).name; // 수정
                    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
                    return res.end(JSON.stringify(users));
                });
            }
        }
        // 메소드가 DELETE일 때
        else if (req.method === 'DELETE') {
            // 주소가 /user/아이디 이면 사용자 데이터 삭제
            if (req.url.startsWith('/user/')) {
                const key = req.url.split('/')[2];
                delete users[key];
                res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
                return res.end(JSON.stringify(users));
            }
        }
        res.writeHead(404); // 위에 맞는 url이 없을 때 동작
        return res.end('NOT FOUND');
    } catch (err) { // 서버 동작 중에 발생한 에러
        console.error(err);
        res.writeHead(500);
        res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
        res.end(err);
    }
})
    .listen(8082, () => {
        console.log('8082번 포트에서 서버 대기 중입니다');
    });



📍결과

메인

👉 Home 클릭했을 때

👉 About 클릭했을 때


등록

👉 입력 안했을 때

👉 등록(css로 인해 클릭한 링크색이 변하지 않음)

👉 user

👉 안중근도 등록 했을 때

👉 users

👉 Network에서 Method 탭 확인


수정

👉 입력 안했을 때

👉 이순신에서 강동원 수정

👉 안중근에서 서강준 수정


삭제

👉 강동원 삭제

👉 서강준 삭제



📍Network 탭

Network 탭에서 네트워크 요청 내용을 실시간으로 볼 수 있다. REST 방식으로 주소를 만들었으므로 주소와 메소드만 봐도 요청 내용을 유추할 수 있다. Name요청 주소를, Method요청 메소드를, StatusHTTP 응답 코드를, Protocol통신 프로토콜을, Type요청의 종류를 의미한다. xhrAJAX 요청이다.

Network 탭에서 POST /users는 사용자를 등록하는 요청임을 알 수 있다. DELETE /users/1505550586127은 해당 키를 가진 사용자를 제거하는 요청이다. 등록, 수정, 삭제가 발생할 때마다 GET /users로 갱신된 사용자 정보를 가져오고 있다.

헤더와 본문


요청과 응답은 모두 헤더와 본문을 갖고 있다. 헤더는 요청 또는 응답에 대한 정보를 갖고 있는 곳이고 본문은 서버와 클라이언듵 간에 주고받을 실제 데이터를 담아두는 공간이다.
개발자 도구의 Network탭에서 요청 중 하나를 클릭해보면 요청과 응답을 더 상세히 살펴볼 수 있다.









📖쿠키와 세션 이해하기

클라이언트에서의 요청에는 보내는 사용자의 신원을 확인할 수 없는 단점이 있다. IP 주소나 브라우저 정보는 사용자를 명확히 식별하기 어렵다. 이를 극복하기 위해 로그인 시스템을 구현할 수 있지만, 이를 위해서는 쿠키와 세션에 대한 이해가 필요하다.

웹 사이트에서 로그인하면 서버는 사용자에게 쿠키를 부여하고, 이 쿠키는 사용자를 식별하는 데 사용된다. 쿠키는 유효 기간이 있고, 서버가 응답할 때 함께 전송된다. 브라우저는 쿠키를 저장하고, 다음 요청 시에는 자동으로 쿠키를 동봉하여 서버로 전송한다. 서버는 이를 통해 사용자를 식별하고 해당 사용자에게 맞는 응답을 제공한다. 이를 통해 로그인 후에도 사용자의 신원을 유지할 수 있다.

서버는 초기에 클라이언트에게 요청자를 추정할 정보를 담은 쿠키를 보내고, 그 후에는 클라이언트로부터 받은 쿠키를 통해 요청자를 식별한다. 쿠키는 사용자를 추적하는 데 활용되며, 이를 주기적으로 삭제하여 개인정보 유출을 방지하는 것이 권장된다. 쿠키는 요청 헤더(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(8083, () => {
        console.log('8083번 포트에서 서버 대기 중입니다');
    });



📌사용자를 식별하는 방법


📍cookie2.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <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 path = require('path');

/*
    쿠키는 mycookie=test 같은 문자열이다 
    parseCookies 함수는 쿠키 문자열을 쉽게 사용하기 위해 자바스크립트 객체 형식으로 바꾸는 함수이다
    이 함수를 거치면 { mycookie: 'test'}가 됨
    내부 내용은 중요한 게 아니므로 이해하지 않아도 된다 
    그저 parseCookies 함수가 문자열을 객체로 바꿔준다고만 알고 있으면 됨
*/
const parseCookies = (cookie = '') =>
    cookie
        .split(';')
        .map(v => v.split('='))
        .reduce((acc, [k, v]) => {
            acc[k.trim()] = decodeURIComponent(v);

            console.log('k : ', k); // name
            console.log('v : ', v); // value
            console.log('acc : ', acc); // { name: '로그인할 이름' }

            return acc;
        }, {});

http.createServer(async (req, res) => {
    const cookies = parseCookies(req.headers.cookie);

    /*
        로그인 요청(GET /login)을 처리하는 부분 
        form은 GET 요청인 경우 데이터를 쿼리스트링으로 보내기에 URL 객체로 쿼리스트링 부분을 분석 함 
        쿠키의 만료 시간은 지금으로부터 5분 뒤로 설정
        이제 302 응답 코드, 리다이렉트 주소와 함께 쿠키를 헤더에 넣는다 
        브라우저는 이 응답 코드를 보고 페이지를 해당 주소로 리다이렉트 
        헤더에는 한글을 설정할 수 없으므로 name 변수를 encodeURIComponent 메소드로 인코딩 함 
        또한, Set-Cookie의 값으로는 제한된 ASCII 코드만 들어가야 하므로 줄바꿈을 넣어서는 안 된다.
    */
    if (req.url.startsWith('/login')) {
        const url = new URL(req.url, 'http://localhost:8084');       
        const name = url.searchParams.get('name');   
        const expires = new Date();
        // 쿠키 유효 시간을 현재 시간 + 5분으로 설정
        expires.setMinutes(expires.getMinutes() + 5);
        
        res.writeHead(302, {
            Location: '/',
            // split(';') 실행
            // 👉 name=${encodeURIComponent(name)}, Expires=${expires.toGMTString()}, httpOnly, path=/
            // map(v => v.split('=')) 실행
            // 👉 name(key) ; name=${encodeURIComponent(name)}(value)
            'Set-Cookie': `name=${encodeURIComponent(name)}; Expires=${expires.toGMTString()}; httpOnly; path=/`
        });
        res.end();

        console.log('url : ', url);
        console.log('name : ', name);
        console.log('expires : ', expires);
        
    }
    /*
         그 외의 경우(/로 접속했을 때 등), 먼저 쿠키가 있는지 없는지를 확인하고 
         쿠키가 없다면 로그인할 수 있는 페이지를 보냄. 
         처음 방문한 경우에는 쿠키가 없으므로 cookie2.html이 전송됨. 
         쿠키가 있다면 로그인한 상태로 간주해 인사말을 보냄.
    */
    // 주소가 /이면서 name이라는 쿠키가 있는 경우 
    else if (cookies.name) {
        res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
        res.end(`${cookies.name}님 안녕하세요`);
    }
    // 주소가 /이면서 name이라는 쿠키가 없는 경우
    else {
        try {
            const data = await fs.readFile(path.join(__dirname, '22_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, name=zerocho
Expires=날짜쿠키의 만료 기한. 해당 날짜가 지나면 쿠키가 제거됨. 기본값은 클라이언트 종료까지
Max-age=초Expires와 유사하지만 초 단위로 쿠키 수명을 설정. 초가 지나면 쿠키가 제거됨. Expires보다 우선
Domain=도메인명쿠키가 전송될 도메인을 지정. 기본값은 현재 도메인
Path=URL쿠키가 전송될 URL을 지정. 기본값은 '/'로 모든 URL에서 쿠키 전송 가능
SecureHTTPS일 경우에만 쿠키가 전송됨
HttpOnly설정 시 자바스크립트에서 쿠키에 접근 불가. 보안 강화



📍결과

👉 크롬을 껐다가 켜도, 서버를 껐다가 켜도 로그인이 유지 된다


👉 5분 지나면 쿠키가 없어져 다시 로그인 창으로 간다.



📌세션으로 관리하기

바로 위 방식은 매우 위험하다. 현재 쿠키가 노출되어 있고 조작될 가능성이 있다. 민감한 개인정보를 쿠키에 저장하는 것은 적절하지 않다. 따라서 코드를 수정하여 서버가 사용자 정보를 안전하게 관리하도록 변경해야한다.


📍session.js

const http = require('http');
const fs = require('fs').promises;
const path = require('path');

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);
    // url 시작이 /login 경우 실행
    if(req.url.startsWith('/login')){
        // url 객체 생성
        const url = new URL(req.url, 'http://localhost:8085');
        // url 파라미터 name 값을 name 변수에 대입
        const name = url.searchParams.get('name');
        // Date 객체 생성 👉 expires
        const expires = new Date();
        // expires의 분을 현재 시간의 +5분 한 값으로 SET
        expires.setMinutes(expires.getMinutes() + 5);
        // 현재시간 정보를 uniqueInt 변수에 대입
        const uniqueInt = Date.now();
        // 위에 생성한 객체 리터럴 session
        // session Key => [uniqueInt]
        // 위의 키에 {name, expires}; 값으로 대입
        // session {uniqueInt : {name, expires}}
        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' });
        // session 안의 key가 cookies.session 👉 {name, expires} 의 name 사용 한다
        res.end(`${session[cookies.session].name}님 안녕하세요`)
    }
    // login, cookies 둘 다 없을 때
    else {
        try{
            const data = await fs.readFile(path.join(__dirname, '../Day07/22_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번 포트에서 서버 대기 중');
    });

👉 5분 지나면 사라진다



❓어떤 방식으로 동작?

이 방식은 사용자 정보를 서버에 저장하고 클라이언트와는 세션 아이디로만 소통하는 방법이다. 세션 아이디를 쿠키를 사용하지 않고 주고받을 수도 있지만, 웹 사이트 대다수는 쿠키를 이용한 방식을 선호한다. 간편하고 편리해서 그렇다.

다만, 배포 시에는 세션을 변수에 저장하지 않는다. 서버가 꺼지거나 다시 시작하면 저장된 변수가 초기화되며, 메모리 문제도 발생할 수 있다. 대체로 레디스나 멤캐시드와 같은 데이터베이스에 세션을 저장한다.

그러나 이 코드는 보안 상의 이유로 주의가 필요하다. 개념 설명을 위한 간단한 예시일 뿐, 실제로 서비스에 사용하면 안전하지 않다. 실제 서비스에서는 검증된 코드나 모듈을 활용하여 보안을 강화하는 것이 좋다. 이에 대한 안전한 사용법은 나중에 6장에서 자세히 다루게 된다.









https와 http2

https 모듈은 웹 서버에 SSL 암호화를 추가한다. GET이나 POST 요청을 할 때 오가는 데이터를 암호화해서 중간에 다른 사람이 요청을 가로채더라도 내용을 확인할 수 없게 한다. 요즘은 로그인이나 결제가 필요한 창에서 https 적용이 필수가 되는 추세다

SSL이 적용된 웹 사이트에 방문하면 다음 그림과 같이 브라우저 주소창에 자물쇠 표시가 나온다.

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



📌Https

위 예제에서 암호화를 적용하려면 https 모듈을 사용해야 하는데 https는 아무나 사용할 수 없다. 암호화를 적용하는 만큼 그것을 인증해줄 수 있는 기관도 필요하다. 인증서는 인증 기관에서 구입해야 하는데 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번 포트에서 서버 대기 중입니다!');
    });

👉 인증서가 없음


http 모듈createServer 메서드는 두 개의 인수를 받는다. 두 번째 인수는 서버 로직이고, 첫 번째 인수는 SSL 인증서에 관련된 옵션 객체다. SSL 인증서 파일들은 pem, crt, 또는 key 확장자를 가질 수 있어, 이를 fs.readFileSync 메서드로 읽어 cert, key, ca 옵션에 할당한다. 서버는 443번 포트를 사용해서 SSL 암호화된 통신을 한다.

노드의 http2 모듈을 이용하면 SSL 암호화와 함께 최신 HTTP 프로토콜인 http/2를 활용할 수 있다. http/2는 기존 http/1.1보다 요청 및 응답 방식이 효율적이어서 웹 속도가 개선된다. 실제로는 http/1.1도 파이프라인 기술을 사용하긴 하지만, http/2가 더 효율적이라 할 수 있다.

다음 그림은 http/2http/1.1과 비교했을 때 확연한 차이를 보여주면서, 더 효율적인 통신을 가능케 한다. 이런 걸 통해 웹 개발에서 더 나은 성능과 보안을 확보할 수 있다고 할 수 있겠다.



📌Http2

const http2 = require('http2');, http2.createSecureServer( 만 바꿔주면 된다.









📖cluster

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

워크스레드랑 비슷하다고 볼 수 있다.

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



📌server1.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);
        cluster.fork();
    });
}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>');
      setTimeout(() => { // 워커가 존재하는지 확인하기 위해 1초마다 강제 종료
        process.exit(0); // 프로세스 강제종료
      }, 1000);
    }).listen(8086);
  
    console.log(`${process.pid}번 워커 실행`);
}

👉 http://localhost:8086/이동

👉 새로고침 여러번

0개의 댓글