[Node.js] http 모듈로 RESTful API 서버 만들기

김씨·2023년 1월 18일
1

Node.js

목록 보기
6/17
post-thumbnail

REST란, Representational State Transfer의 약어로 소프트웨어 아키텍쳐 스타일을 일컫는다.

좀 더 풀어서 설명하면, REST의 구성 요소로 자원(Resource), 행위(Verb), 표현(Representation)이 존재하는데, 여기서 자원은 URI를, 행위는 HTTP Method를 뜻하며 표현은 응답 포맷(JSON, XML, TEXT 등)을 뜻한다.

예를 들어 특정 서버의 /users/1 이라는 URI로 GET 요청을 보내면 { id: 1, name: "a" }라는 데이터를 받게 된다고 생각해보자.

프로그래밍은 항상 영어 중심의 사고 방식을 갖고 있다.
그래서 이를 영어로 연결해 보면 하나의 문장이 된다.
GET /users/1 -> 즉 1번 유저를 가져오라는 뜻이 된다.

그래서 Request-Line에서 GET, POST 등의 메소드가 가장 첫 부분에 오고, 그 뒤에 URI가 나왔던 것이다.

영어 문장을 읽듯 생각할 수 있기 때문에.

이러한 방식으로 읽기 때문에 URI는 명사 형태로 작성할 것이 권장된다.
GET /read/users/1같은 형태의 동사가 섞인 URI는 부자연스러운 문장이 되기 때문이다.
가져와 사용자를, 가져와 읽어 사용자를
두 문장 중에 어떤 문장이 더 자연스러운지 생각해보자.

그러나 이것은 이상적인 것일 뿐, 항상 가능한 것은 아니다.
/login, /signup 등의 URI를 어떻게 명사로 표현할까.
대부분의 서버에서 이런 URI는 그냥 동사로 쓴다.

최대한 지키는 것이 권장될 뿐이지 100% 지키기란 불가능하며, 언제나 타협이 필요하다.

정리하면 REST는 동사로 표현되는 HTTP Method를 통해 읽기/쓰기/수정/삭제 등의 행위를, 대체로 명사로 표현되는 URI를 통해 리소스를 표현하는 인터페이스를 가지는 아키텍쳐 스타일 정도로 생각하면 될듯하다.

이제 간단한 TODO를 읽고 쓰고 수정하고 삭제하는 API 서버를 Node.js로 구현해보자.

간단하지만 순수 Node.js로 구현하여 코드가 상당히 길어진다.
그래서 좀 더 보기 편하게 각 부분별로 살펴보도록 하겠다.

임의의 ID를 발급해주는 함수다.
각 TODO를 만들 때마다 이 함수에 의해 임의의 ID를 갖게 된다.

function newID() {
    return Math.floor(Math.random() * 1000000).toString();
}

데이터베이스 대신 Map에 새로 만들 TODO들을 저장할 것이다.
처음에 임의의 ID를 갖는 TODO1가 기본적으로 들어간다.

const db = new Map();
db.set(newID(), {
    title: 'TODO1',
    content: 'DO SOMETHING',
});

그리고 서버를 생성한다.

http.createServer((req, res) => {
    // send html representing all todos
    if (req.url === '/') {
            res.write('<h1>TODOS</h1>');
            res.write('<ul>');
            for (const [id, {title, content}] of db) {
                res.write(`<li>[id(${id})]${title}: ${content}</li>`);
            }
            res.write('</ul>');
            res.end();
            return;
    }
//  ...
}

'/'로 요청을 보내면 현재 Map에 들어 있는 모든 TODO를 html로 보여줄 것이다.

그 다음이 조금 길어지는데,

   // CRUD todos
    if (req.url.split('/')[1] === 'todos') {
      // ...
    }

req.url.split('/')[1]todos인 경우,
http://localhost:3000/todos로 접속하면 req.url/todos가 되는데, 이를 split하면 ['', 'todos']가 되어 [1]에 있는 요소는 todos가 된다.

/todos/a와 같이 todos 뒤에 또 다른 경로가 존재하더라도 ['', 'todos', 'a']가 되어 [1]번 요소는 여전히 todos가 된다.

 // get todo by id
        if (req.method === 'GET') {
            const id = req.url.split('/')[2];
            const todo = db.get(id);
            if (!todo) {
                res.writeHead(400);
                res.end('id not found');
                return;
            }

            res.writeHead(200, {
                'Content-Type': 'text/html',    
            });
            res.end(`<h1>id: ${todo.id}, title: ${todo.title}, content: ${todo.content}</h1>`);
            return;

/todos로 요청이 들어왔고 HTTP Method가 GET인 경우이다.
TODO의 id도 함께 URI로 받아올 것이기 때문에 req.url.split('/')[2]id에 넣어준다.

GET /todos/123456으로 접속하면 id는 123456이 될 것이다.
그리고 해당 id를 가진 TODO가 Map에 있나 검사한다.
없으면 id를 찾지 못했다는 응답을 상태 코드를 400으로 지정하여 보내준다.

있으면 정상적으로 다음 코드들이 실행되어 해당 id를 가진 TODO의 id, 제목, 내용이 렌더링 될 것이다.

 else if (req.method === 'POST') {
            // create todo
            let data = {};
            req.on('data', chunk => {
                data = JSON.parse(chunk);
            });

            req.on('end', () => {
                db.set(newID(), data);
                res.writeHead(200, {
                    'Content-Type': 'text/html',
                });
                res.end('<h1>ok</h1>');
                return;
            });

            req.on('error', () => {
                res.writeHead(400);
                res.end('something went wrong');
                return;
            })
        }
// ...

POST /todos로 요청이 왔을 때는 새로운 TODO를 만들어 Map에 저장할 것이다.
새로운 TODO를 만들기 위해서는 제목, 내용에 대한 정보가 필요한데, 이를 json으로 받아서 파싱한 뒤, newID() 함수로 임의의 id를 만들어 Map에 저장한다.

json 파싱은 이전에 봤던 내용이므로 이해하기 어렵지 않을 것이다.
중복 id 등에 대한 오류 처리는 따로 해주지 않았다.

 else if (req.method === 'PUT') {
            const id = req.url.split('/')[2];
            const todo = db.get(id);
            if (!todo) {
                res.writeHead(400);
                res.end('id not found');
                return;
            }

            req.on('data', chunk => {
                data = JSON.parse(chunk);
            });

            req.on('end', () => {
                const newTodo = {
                    ...todo,
                };
                if (data.title) {
                    newTodo.title = data.title;
                }
                if (data.content) {
                    newTodo.content = data.content;
                }
                db.set(id, newTodo);
                res.writeHead(200, {
                    'Content-Type': 'text/html',
                });
                res.end('<h1>OK</h1>');
                return;
            });

            req.on('error', () => {
                res.writeHead(400);
                res.end('something went wrong');
                return;
            })
        }
// ...

다음은 PUT /todos/{id}로 요청을 받았을 때, 이미 존재하는 TODO를 id로 조회하여 입력된 내용을 수정하는 기능이다.

GET /todos/{id} 때와 마찬가지로 id는 URI에서 분리하여 가져온다.
그 후 존재 여부를 검사하여 존재하지 않는 id인 경우 id not found라는 메세지와 함께 상태 코드 400을 클라이언트에 보내준다.

존재하는 id라면 json으로 파싱하여 Map을 업데이트 한다.

 else if (req.method === 'DELETE') {
            const id = req.url.split('/')[2];
            const todo = db.get(id);
            if (!todo) {
                res.writeHead(400);
                res.end('id not found');
                return;
            }

            db.delete(id);
            res.writeHead(200);
            res.end(`todo id ${id} deleted`);
        } else {
            res.writeHead(404, {
                'Content-Type': 'text/html',
            });
            res.end('<h1>NOT FOUND</h1>');
        }
    }
}).listen(3000);

마지막으로 DELETE /todos/{id}로 요청이 들어오면 존재 여부를 검사한 뒤 오류 처리를 해주고, 존재한다면 해당 id를 가진 TODO 삭제 후 잘 삭제되었다는 메세지를 보내준다.

간단하지만 순수 Node.js로 구현하여 꽤나 장황했던 코드가 끝이 났다.

Postman을 통해 정상적으로 동작하는지 확인해보고 마무리하겠다.


서버를 실행하고 GET '/'로 요청을 보내면, 최초로 할당된 하나의 TODO가 나타나는 것을 볼 수 있다.

body에 json 데이터를 작성하고 POST /todos로 요청을 보내면 <h1>ok</h1>을 응답으로 받는다.

이후 GET '/'로 다시 요청을 보내보면, 새로운 TODO가 추가된 것을 볼 수 있다.

id는 새로 추가될 때 랜덤으로 생성되므로 서버가 실행될 때마다 다를 것이다.
여기서는 새로 만든 TODO가 744015라는 id를 갖기 때문에 이 id를 통해 TODO를 업데이트 해보겠다.


title만 업데이트 하기 위해 { "title": "update" } json body와 함께 PUT /todos/744015로 요청을 보냈으며, 응답이 정상적으로 온 것을 확인할 수 있다.


다시 한 번 GET /으로 요청을 보내면 역시 id가 744015인 TODO의 제목이 updated로 바뀐 것을 볼 수 있다.

이번에는 content까지 바꿔보자.


역시 잘 작동한다.

json body에 빈 객체를 보낸다면?


응답은 정상적으로 오지만 아무 것도 수정되지 않았다.



DELETE /todos/744015도 역시 잘 작동한다.

GET /todos/744015로 삭제된 TODO를 조회하면 상태코드 400과 함께 id를 찾지 못했다는 응답을 받는다.

이렇게 간단한 CRUD 서버를 Node.js로만 구현해보았는데, 별 기능이 없는데도 불구하고 코드가 상당히 길고 지저분한 느낌이 들었다.

Node.js 진영에서 가장 유명한 웹 프레임워크인 express를 사용하면 코드가 훨씬 간결하고 정리된 느낌을 준다.
우리는 Node.js로 서버를 구현해봤기 때문에 그 차이를 훨씬 더 크게 체감할 수 있을 것이다.

전체 코드는 다음과 같다.

const http = require('http');

function newID() {
    return Math.floor(Math.random() * 1000000).toString();
}
const db = new Map();
db.set(newID(), {
    title: 'TODO1',
    content: 'DO SOMETHING',
});

http.createServer((req, res) => {
    // send html representing all todos
    if (req.url === '/') {
            res.write('<h1>TODOS</h1>');
            res.write('<ul>');
            for (const [id, {title, content}] of db) {
                res.write(`<li>[id(${id})]${title}: ${content}</li>`);
            }
            res.write('</ul>');
            res.end();
            return;
    }

    // CRUD todos
    if (req.url.split('/')[1] === 'todos') {

        // get todo by id
        if (req.method === 'GET') {
            const id = req.url.split('/')[2];
            const todo = db.get(id);
            if (!todo) {
                res.writeHead(400);
                res.end('id not found');
                return;
            }

            res.writeHead(200, {
                'Content-Type': 'text/html',    
            });
            res.end(`<h1>id: ${todo.id}, title: ${todo.title}, content: ${todo.content}</h1>`);
            return;
        } else if (req.method === 'POST') {
            // create todo
            let data = {};
            req.on('data', chunk => {
                data = JSON.parse(chunk);
            });

            req.on('end', () => {
                db.set(newID(), data);
                res.writeHead(200, {
                    'Content-Type': 'text/html',
                });
                res.end('<h1>ok</h1>');
                return;
            });

            req.on('error', () => {
                res.writeHead(400);
                res.end('something went wrong');
                return;
            })
        } else if (req.method === 'PUT') {
            const id = req.url.split('/')[2];
            const todo = db.get(id);
            if (!todo) {
                res.writeHead(400);
                res.end('id not found');
                return;
            }

            req.on('data', chunk => {
                data = JSON.parse(chunk);
            });

            req.on('end', () => {
                const newTodo = {
                    ...todo,
                };
                if (data.title) {
                    newTodo.title = data.title;
                }
                if (data.content) {
                    newTodo.content = data.content;
                }
                db.set(id, newTodo);
                res.writeHead(200, {
                    'Content-Type': 'text/html',
                });
                res.end('<h1>OK</h1>');
                return;
            });

            req.on('error', () => {
                res.writeHead(400);
                res.end('something went wrong');
                return;
            })
        } else if (req.method === 'DELETE') {
            const id = req.url.split('/')[2];
            const todo = db.get(id);
            if (!todo) {
                res.writeHead(400);
                res.end('id not found');
                return;
            }

            db.delete(id);
            res.writeHead(200);
            res.end(`todo id ${id} deleted`);
        } else {
            res.writeHead(404, {
                'Content-Type': 'text/html',
            });
            res.end('<h1>NOT FOUND</h1>');
        }
    }
}).listen(3000);

0개의 댓글