요청과 응답은 이벤트 방식. 클라이언트로부터 요청이 왔을 때 어떤 작업을 수행할 지 이벤트 리스너를 미리 등록해둬야 한다.
const http = require('http');
http.createServer((req, res) => {
//여기에 어떻게 응답할지 적는다.
})
http 모듈에는 createServer 메서드가 있다. 콜백함수를 넣을 수 있으며 요청이 들어올 때마다 매번 콜백 함수가 실행된다. 여기에 응답을 적으면 됨! createServer의 콜백 부분을 보면 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 이벤트까지.
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를 원하는 만큼 호출하면 된다.
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(시간 초과) 처리한다
서버에 요청을 보낼 시 주소를 통해서 요청의 내용을 표현. 주소가 /index.html 이면 서버의 index.html을 보내달라는 뜻. /about.html 이면 about.html을 보내달라는 뜻.
REST는 REpresentational State Transfer의 줄임말. 서버의 자원을 정의, 자원에 대한 주소를 지정하는 방법을 가리키는 일종의 약속.
주소는 의미를 명확히 전달하기 위해 명사로 구성. user이면 사용자 정보에 관련된 자원을 요청. post라면 게시글에 관련된 자원을 요청하는 것이라고 추측.
HTTP 요청 메서드
- GET: 서버 자원을 가져오고자 할 때 사용함. 요청의 본문에 데이터를 넣지 않는다. 서버로 보내야 한다면 쿼리스트링을 사용함
- POST: 서버에 자원을 새로 등록하고자 사용. 요청의 본문에 새로 등록할 데이터를 넣어 보낸다
- PUT: 서버의 자원을 요청에 들어 있는 자원을 치환하고자 할 때 사용. 요청의 본문에 치환할 데이터를 넣어 보낸다
- PATCH: 서버 자원 일부만 수정하고자 할 때 사용. 요청의 본무에 일부 수정할 데이터를 넣어 보낸다
- DELETE: 서버의 자원을 삭제하고자 할 때 사용. 요청의 본문에 데이터를 넣지 않는다
- OPTIONS: 요청을 하기 전 통신 옵션을 설명하기 위해 사용. 12장에서 자주 보게 될 것!
주소 하나가 요청 메서드를 여러 개 가질 수 있다.
만약 위 메서드를 표현하기 애매한 로그인 같은 동작이 있다면 그냥 POST를 사용하면 된다. 이렇게 주소와 메서드만 보고 요청의 내용을 알아볼 수 있다는 것이 장점. GET 메서드 같은 경우에는 브라우저가 캐싱(기억)할 수도 있으므로 같은 주소로 GET 요청을 할 때 서버에서 가져오는 것이 아니라 캐시에서 가져올 수도. 이렇게 캐싱되면 성능이 좋아진다.
그리고 HTTP 통신을 사용하면 클라이언트가 누구든 상관 없이 같은 방식으로 서버와 소통할 수 있다. iOs, 안드로이드, 웹, 다른 서버가 모두 같은 주소로 요청을 보낼 수 있다. 즉, 서버와 클라이언트가 분리되어 있다. 이렇게 서버와 클라이언트를 분리하면, 추후에 서버를 확장할 때 클라이언트에 구애되지 않아 좋다.
HTTP 메서드 | 주소 | 역할 |
---|---|---|
GET | / | restFront.html 파일 제공 |
GET | /about | about.html 파일 제공 |
GET | /users | 사용자 목록 제공 |
GET | 기타 | 기타 정적 파일 제공 |
POST | /user | 사용자 등록 |
PUT | /user/사용자id | 해당 id의 사용자 수정 |
DELETE | /user/사용자id | 해당 id 사용자 제거 |
a { color : blue ; text-decoration: none ; }
<!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>
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 = '';
});
<!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>
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번 포트에서 서버 대기 중입니다');
});
요청과 응답은 모두 헤더와 본문을 가지고 있다.
- 헤더는 요청 또는 응답에 대한 정보를 가지고 있는 곳
- 본문은 서버와 클라이언트 간에 주고 받을 실제 데이터를 담아두는 공간
개발자 도구의 Network 탭에서 요청 중 하나를 클릭해보면 더 상세하게 요청과 응답을 살펴볼 수 있다. 위의 그림을 보면 POST/users 요청의 헤더와 본문이 나와 있다.- General: 공통된 헤더
- Request Headers : 요청의 헤더
- Response Headers : 응답의 헤더
- Request Payload : 요청의 본문
위는 GET/users의 응답 본문. res.end로 보냈던 문자열이 보닌다. JSON의 경우 preview에서 더 깔끔하게 확인 가능
주의할 점은 데이터가 메모리에 저장되어 있으므로 서버를 종료하면 데이터가 소실된다는 점. 데이터를 영구적으로 저장하기 위해서는 DB를 이용해야.