BackEnd/Node.js

Node.js 스터디 3주차

Bubbles 2022. 2. 5. 00:33

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로 바뀌지만 서버 자체가 멈추진 않는다.