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

Bor·2021년 12월 27일
1

Node.js

목록 보기
6/11

4.1 요청과 응답 이해하기

  • 클라이언트에서 서버로 요청(request)을 보내고
  • 서버에서는 요청의 내용을 읽고 처리한 뒤 크라이언트에 응답(response)을 보낸다

요청과 응답은 이벤트 방식. 클라이언트로부터 요청이 왔을 때 어떤 작업을 수행할 지 이벤트 리스너를 미리 등록해둬야 한다.

const http = require('http');
http.createServer((req, res) => {
    //여기에 어떻게 응답할지 적는다. 
})

http 모듈에는 createServer 메서드가 있다. 콜백함수를 넣을 수 있으며 요청이 들어올 때마다 매번 콜백 함수가 실행된다. 여기에 응답을 적으면 됨! createServer의 콜백 부분을 보면 req와 res 매개변수가 있다.

  • req : 요청에 관한 정보들
  • res : 응답에 관한 정보들을 담고 있다
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번 포트에서 서버 대기 중!')
})
결과:

8080번 포트에서 서버 대기 중!

localhost와 포트란?

근데 https로는 연결이 되지 않는다? 왜??

localhost는 현재 컴퓨터의 내부 주소. 외부에서는 접근할 수 없고 자신의 컴퓨터에서만 접근할 수 있으므로 서버 개발 시 테스트용으로 많이 사용. 이러한 숫자 주소를 IP(Internet Protocol)라고 부른다.

createServer 메서드 뒤에 listen 메서드를 붙이고 클라이언트에 공개할 포트 번호와 포트 연결 완료 후 실행될 콜백함수를 넣는다. 이제 이 파일을 실행하면 서버는 8080 포트에서 요청이 오기를 기다린다.

res 객체에는 res.writeHead와 res.write, res.end 메서드가 있다. res.writeHead는 응답에 대한 정보를 기록하는 메서드. 첫번째 인수로 성공적인 요청임을 뜻하는 200, 두 번째 인수로 응답에 대한 정보를 보내는데 콘테느 형식이 HTML임을 알리고 있다. 또한 한글 표시를 위해서 charset을 utf-8로 지정했다. 이 정보가 기록되는 부분을 헤더(Header)라고 부른다.

res.write 메서드의 첫 번째 인수는 클라이언트로 보낼 데이터. 지금 HTML 모양의 문자열을 보냈지만 버퍼를 보낼수도. 데이터가 기록되는 부분은 본문(Body)라고 부른다.

res.end는 응답을 종료하는 메서드. 인수가 있다면 그 데이터도 클라이언트로 보내고 응답을 종료. 브라우저는 응답 내용을 받아서 렌더링 한다.

서버구조도

listen 메서드에 콜백함수를 넣은 대신, 다음과 같이 서버에 listening 이벤트 리스너를 붙여도 된다. 추가로 error 이벤트까지.

server1-1.js
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', () => {
    console.error(error);
});

한 번에 여러 서버를 실행할 수도 있다. createServer를 원하는 만큼 호출하면 된다.

server1-2.js
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번 포트에서 서버 대기 중!')
});

포트 번호가 같으면 EADDRINUSE 에러가 발생. 단, 실무에서는 이런 식으로 서버를 여러 개 띄우는 일은 드물다. res.write와 res.end에 일일이 HTML을 적는 것은 비효율적이므로 미리 HTML 파일을 만드는 것이 좋다.

<!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'});
        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 listening on port')
});

요청이 들어오면 먼저 fs 모듈로 HTML 파일을 읽는다. data 변수에 저장된 버퍼를 그대로 클라이언트에게 보내면 된다. 예기치 못한 에러가 발생한 경우에는 에러 메세지를 응답한다. 에러 메세지는 일반 문자열이므로 text/plain을 사용한다.

HTTP 상태 코드

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

  • 2xx : 성공을 알리는 상태 코드. 대표적으로 200(성공), 201(작성됨)이 많이 사용됨
  • 3xx : 리다이렉션(다른 페이지로 이동)을 알리는 상태 코드. 어떤 주소를 입력했는데 다른 주소의 페이지로 넘어갈 때 이 코드가 사용. 대표적으로 301(영구 이동), 302(임시 이동)이 있다. 304(수정되지 않음)는 요청의 응답으로 캐시를 사용했다는 뜻
  • 4xx : 요청 오류. 요청 자체에 오류가 있을 때 표시. 대표적으로 400(잘못된 요청), 401(권한 없음), 403(금지됨), 404(찾을 수 없음)가 있다.
  • 5xx : 서버 오류. 요청은 제대로 왔지만 서버에 오류가 생겼을 때 발생. 이 오류를 res.writeHead로 클라이언트에 직접 보내는 경우는 거의 없고 예기치 못한 에러 발생 시 서버가 알아서 5xx 대 코드를 보낸다. 500(내부 서버 오류), 502(불량 게이트웨이), 503(서비스 사용할 수 없음)이 자주 사용된다.
  • 반드시 응답을 보내줘야!! 요청 처리 과정 중에 에러가 발생했다고 해서 응답을 보내지 않으면 안 된다. 요청이 성공했든 실패했든 응답을 클라이언트로 보내서 요청이 마무리되었음을 알려야 한다. 응답을 보내지 않는다면, 클라이언트는 서버로부터 응답이 오길 하염없이 기다리다가 일정 시간 후 Timeout(시간 초과) 처리한다

4.2 REST와 라우팅 사용하기

  • 서버에 요청을 보낼 시 주소를 통해서 요청의 내용을 표현. 주소가 /index.html 이면 서버의 index.html을 보내달라는 뜻. /about.html 이면 about.html을 보내달라는 뜻.

  • REST는 REpresentational State Transfer의 줄임말. 서버의 자원을 정의, 자원에 대한 주소를 지정하는 방법을 가리키는 일종의 약속.

  • 주소는 의미를 명확히 전달하기 위해 명사로 구성. user이면 사용자 정보에 관련된 자원을 요청. post라면 게시글에 관련된 자원을 요청하는 것이라고 추측.

HTTP 요청 메서드

  • GET: 서버 자원을 가져오고자 할 때 사용함. 요청의 본문에 데이터를 넣지 않는다. 서버로 보내야 한다면 쿼리스트링을 사용함
  • POST: 서버에 자원을 새로 등록하고자 사용. 요청의 본문에 새로 등록할 데이터를 넣어 보낸다
  • PUT: 서버의 자원을 요청에 들어 있는 자원을 치환하고자 할 때 사용. 요청의 본문에 치환할 데이터를 넣어 보낸다
  • PATCH: 서버 자원 일부만 수정하고자 할 때 사용. 요청의 본무에 일부 수정할 데이터를 넣어 보낸다
  • DELETE: 서버의 자원을 삭제하고자 할 때 사용. 요청의 본문에 데이터를 넣지 않는다
  • OPTIONS: 요청을 하기 전 통신 옵션을 설명하기 위해 사용. 12장에서 자주 보게 될 것!

주소 하나가 요청 메서드를 여러 개 가질 수 있다.

  • GET 메서드의 /user 주소로 요청을 보내면 사용자 정보를 가져오는 요청이라는 것을 알 수 있고
  • POST 메서드의 /user 요청을 보내면 새로운 사용자를 등록하려 한다는 것을 알 수 있다.

만약 위 메서드를 표현하기 애매한 로그인 같은 동작이 있다면 그냥 POST를 사용하면 된다. 이렇게 주소와 메서드만 보고 요청의 내용을 알아볼 수 있다는 것이 장점. GET 메서드 같은 경우에는 브라우저가 캐싱(기억)할 수도 있으므로 같은 주소로 GET 요청을 할 때 서버에서 가져오는 것이 아니라 캐시에서 가져올 수도. 이렇게 캐싱되면 성능이 좋아진다.

그리고 HTTP 통신을 사용하면 클라이언트가 누구든 상관 없이 같은 방식으로 서버와 소통할 수 있다. iOs, 안드로이드, 웹, 다른 서버가 모두 같은 주소로 요청을 보낼 수 있다. 즉, 서버와 클라이언트가 분리되어 있다. 이렇게 서버와 클라이언트를 분리하면, 추후에 서버를 확장할 때 클라이언트에 구애되지 않아 좋다.

HTTP 메서드주소역할
GET/restFront.html 파일 제공
GET/aboutabout.html 파일 제공
GET/users사용자 목록 제공
GET기타기타 정적 파일 제공
POST/user사용자 등록
PUT/user/사용자id해당 id의 사용자 수정
DELETE/user/사용자id해당 id 사용자 제거
restFront.css
a { color : blue ; text-decoration: none ; }
restFront.html
<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <title>RESTful SERVER</title>
    <link rel="stylesheet" href="./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="./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');
        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
                    });
                    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);
                }
            });
            userDiv.appendChild(span);
            userDiv.appendChild(edit);
            userDiv.appendChild(remove);
            userDiv.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: name
        });
        getUser();
    } catch (err) {
        console.error(err);
    }
    e.target.username.value = '';
});
about.html
<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <title>RESTful SERVER</title>
    <link rel="stylesheet" href="./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;

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);
            }
            //주소가 /도 /about도 아니면 
            try {
                const data = await fs.readFile(`${req.url}`)
                return res.end(data);
            } catch (err) {
                // 주소가 해당하는 라우트를 못 찾았다는 404 error 발생 
            }
        }
        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 listening on port')
});
  • 코드 보면 req.method로 HTTP 요청 메서드를 구분. 메서드가 GET이면 다시 req.url로 요청 주소를 구분 주소가 /일 때는 restFront.html을 제공하고, 주소가 /about 이면 about.html 파일을 제공
  • 이외의 경우에는 주소에 적힘 파일을 제공. /restFront.js 라면 restFront.js 파일을 제공, /restFront.css라면 그 파일을 제공
  • 만약 존재하지 않는 파일을 요청했거나 GET 메서드 요청이 아닌 경우라면 404DPFJ. 응답과정에서 예기치 못한 에러가 발생하면 500에러(BUT 실무에서 500을 전송하는 경우는 극히 드물다)

res.end 앞에 return은 왜 붙일까?

res.end를 호출해도 함수가 종료되지 않는다. 노드는 자바스크립트 문법을 따르기 때문에 return을 붙이지 않는 한 함수가 종료되지 않는다. 따라서 다음에 코드가 이어지는 경우에 return을 써서 명시적으로 함수를 종료. return을 붙이지 않아서 res.end 같은 메서드가 여러번 실행 된다면

Error: Can't set headers after they are sent to the client 에러가 발생

나머지 부분을 완성해서 프로그램 완성!

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));
                }
                // /도 /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);
                        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);
            res.end(err);
        }
    })
    .listen(8082, () => {
        console.log('8082번 포트에서 서버 대기 중입니다');
    });
  • 다른 HTTP 요청 메서드들을 추가하고, 데이터베이스 대용으로 users라는 객체를 선언해 사용자 정보를 저장. POST/user 요청에는 사용자를 새로 저장하고 PUT/user/아이디 요청에서는 해당 아이디의 사용자 데이터를 수정하고 있다. DELETE/user/아디이 요청 에서는 해당 아이디의 사용자를 제거
  • POST, PUT 요청에서의 특이점. req.on('data')와 req.on('end') 의 사용. 요청 본문에 들어 있는 데이터를 꺼내기 위함. req와 res도 내부적으로는 스트림(각각 readStream, writeStream)으로 되어 있으므로 요청/응답의 데이터가 스트림 형식으로 전달됨. 또한 on에서 볼 수 있듯 이벤트도 달려 있따. 데이터는 문자열이므로 JSON으로 만드는 JSON.parse 과정이 필요

헤더와 본문


요청과 응답은 모두 헤더와 본문을 가지고 있다.

  • 헤더는 요청 또는 응답에 대한 정보를 가지고 있는 곳
  • 본문은 서버와 클라이언트 간에 주고 받을 실제 데이터를 담아두는 공간

    개발자 도구의 Network 탭에서 요청 중 하나를 클릭해보면 더 상세하게 요청과 응답을 살펴볼 수 있다. 위의 그림을 보면 POST/users 요청의 헤더와 본문이 나와 있다.
  • General: 공통된 헤더
  • Request Headers : 요청의 헤더
  • Response Headers : 응답의 헤더
  • Request Payload : 요청의 본문

    위는 GET/users의 응답 본문. res.end로 보냈던 문자열이 보닌다. JSON의 경우 preview에서 더 깔끔하게 확인 가능

주의할 점은 데이터가 메모리에 저장되어 있으므로 서버를 종료하면 데이터가 소실된다는 점. 데이터를 영구적으로 저장하기 위해서는 DB를 이용해야.

0개의 댓글