웹 프론트엔드 파트를 공부하다 보면, 필연적으로 알아야 할 것이 서버와의 API 통신이다. 프론트엔드 개발자라고 하더라도 서버에서 어떤 식으로 API를 만드는지, 그리고 어떠한 방식으로 데이터가 각 URL에 저장되는지는 알아야 한다.
그래서, 간단하게 로컬 DB를 만든 뒤에 express를 사용해서 API를 설계하는 방법에 대해 포스팅해보려고 한다.
시작하기에 앞서 폴더 구조가 이러한 형태로 되어있음을 참고하기 바란다. 이 포스팅에서는 messages만 다룬다.
1. 프로젝트 기초 세팅
yarn init -y
yarn add express cors uuid
yarn add --dev nodemon
yarn init -y는 프로젝트를 새로 시작하면서, 패키지 생성파일을 설치하는 과정이다.
express: Node.js를 사용한 API 설계를 좀 더 쉽게 해주는 프레임워크이다.
cors: API통신의 과정에서 서버 단의 데이터가 클라이언트로 보내져야 하는 과정이 필요한데, 이 때에 다른 도메인과의 자원 요청 및 전송 등이 SOP(Same-Origin Policy)라는, 동일 출처 정책에 의해 차단되어있다.이 라이브러리를 통해 서버 도메인과의 자원 요청과 전송이 가능해진다.
uuid: Universally Unique IDentifier. 고유한 id라는 뜻으로, 후술하겠지만 메시지를 만든 후 이를 보내는 과정에서 id에 랜덤한 값을 부여하기 위해 사용한다.
nodemon: 서버 코드 변경 시마다 서버를 껐다 켰다 하는 불필요한 과정을 생략해준다. 저장 시에 바로바로 반영되게 해주는 라이브러리.
1-1. nodemon.json
{
"watch": ["src"],
"ignore": ["db/**/*"],
"env": {
"NODE_ENV": "development"
}
}
최상단 경로에 넣어준다. 앞서 설명한 nodemon의 성질 및 환경 등에 대한 설정을 한 것으로 보면 된다.
2. DB 설계
DB는 그냥 json 형식으로 설계했다. 일부만 가져왔다.
[
{
"id": "8",
"userId": "steadily",
"timestamp": 1234570410123,
"text": "8 mock text"
},
{
"id": "7",
"userId": "worked",
"timestamp": 1234570470123,
"text": "7 mock text"
},
{
"id": "6",
"userId": "worked",
"timestamp": 1234570530123,
"text": "6 mock text"
},
{
"id": "5",
"userId": "steadily",
"timestamp": 1234570590123,
"text": "5 mock text"
},
{
"id": "4",
"userId": "worked",
"timestamp": 1234570650123,
"text": "4 mock text"
},
{
"id": "3",
"userId": "worked",
"timestamp": 1234570710123,
"text": "3 mock text"
},
{
"id": "2",
"userId": "steadily",
"timestamp": 1234570770123,
"text": "2 mock text"
},
{
"id": "1",
"userId": "steadily",
"timestamp": 1234570830123,
"text": "1 mock text"
}
]
3. index.js 세팅
import express from "express";
import cors from "cors";
import messagesRoute from "./routes/messages.js";
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(
cors({
origin: "http://localhost:3000",
credentials: true,
})
);
const routes = [...messagesRoute];
routes.forEach(({ method, route, handler }) => {
app[method](route, handler);
});
app.listen(8000, () => {
console.log("server listening on 8000");
});
4행은 express의 사용을 선언,
5행은, 클라이언트 단에서 보내주는 데이터를 자동으로 파싱하여 가장 필요한 부분인 body만을 추출해주는 body-parser 모듈을 사용할 때 선언하는 행으로, 내부 extended는 중첩된 객체를 사용할지 말지 여부를 결정하는 부분이다. 자세한 내용은 스택오버플로우를 참조
cors 부분을 보면, origin은, localhost:3000과의 자원 요청 및 전송을 허용한다는 뜻이다.그리고, 서로 다른 origin을 갖는 도메인끼리의 API통신을 하는 경우 당연하게도 쿠키가 request header에 들어가지 않는다. 이 credentials: true를 선언해줌으로써 쿠키가 request header 내에 삽입되고, 그에 따라 서버에 오는 요청의 주체가 누구인지 확인할 수 있게 된다. 와닿지 않으면 그냥 늘 붙여주면 된다고 생각하면 좋을 것 같다.
후술할 messages.js route의 값들을 routes라는 변수로 받아와서, 후술할 route의 세 가지 요소인 method, route, handler를 파라미터로 받아와서 method에 따라 다르게 들어가게끔 하였다.
마지막으로 app.listen을 통해 설정한 포트와의 연결, 그리고 서버 연결이 되었을 때 이후 처리를 할 수 있다. 콘솔에 문구를 띄워줌으로써 연결이 되었는지 되지 않았는지를 볼 수 있게끔 하였다.
3. dbController.js
import fs from "fs";
import { resolve } from "path";
const basePath = resolve();
const filenames = {
messages: resolve(basePath, "src/db/messages.json"),
};
export const readDB = (target) => {
try {
return JSON.parse(fs.readFileSync(filenames[target], "utf-8"));
} catch (err) {
console.error(err);
}
};
export const writeDB = (target, data) => {
try {
return fs.writeFileSync(filenames[target], JSON.stringify(data));
} catch (err) {
console.error(err);
}
};
우리는 readDB를 통해 위에서 선언한 messages.json을 파싱하는 과정을 거친다. 즉 데이터를 파싱해서 가져오는 것을 의미한다. writeDB를 통해 타겟을 지정한 뒤 새로 덮어 씌울 데이터를 가져오게 된다. 여기서는 messages.json에 "자바스크립트 값 형태로 되어있는 데이터를 JSON 형태로 변환"한 값을 덮어씌우는 것이다.
4. message route 생성하기(API 설계)
여기에서 REST API의 요소를 사용한다. 전체 메시지를 가져오는 GET, 메시지를 만드는 POST, 메시지를 수정하는 PUT, 삭제하는 DELETE의 4가지 요소를 전부 구현한다.
4-1. GET
get 메소드는 전체 메시지를 가져오거나 id 하나에 대한 메시지를 가져올 때 사용한다.
import { readDB, writeDB } from "../dbController.js";
import { v4 } from "uuid";
const getMsgs = () => readDB("messages");
const setMsgs = (data) => writeDB("messages", data);
const messagesRoute = [
{
// 전체 메시지를 가져오는 명령어
method: "get",
route: "/messages",
handler: (req, res) => {
const msgs = getMsgs();
res.send(msgs);
},
}
]
export const messagesRoute;
Node.js에서 API 설계 시 크게 method, route, handler 세 가지 요소가 필요하다. method는 HTTP Method와 같고, route는 이 get으로 가져온 값이 위치하는 URL, 그리고 handler는 이 데이터를 가공하는 요소이다. 여기서는 그냥 데이터를 가져오는 것밖에는 딱히 할 게 없으므로 앞전에 만들어뒀던 readDB를 사용하여 messages.json의 값을 파싱하여 응답을 전송한다(request.send()).
우리가 이 전체 메시지를 가져오는 라우트로 설정한 /messages에 DB의 값이 잘 들어와있는 것을 볼 수 있다.
import { readDB, writeDB } from "../dbController.js";
import { v4 } from "uuid";
const getMsgs = () => readDB("messages");
const setMsgs = (data) => writeDB("messages", data);
const messagesRoute = [
{
// id 하나에 대한 메시지 가져오기
method: "get",
route: "/messages/:id",
handler: ({ params: { id } }, res) => {
try {
const msgs = getMsgs();
const msg = msgs.find((m) => m.id === id);
if (!msg) throw Error("not found");
res.send(msg);
} catch (err) {
res.status(404).send({ error: err });
}
},
},
]
export const messagesRoute;
이제 id 하나의 값만 가져오는 경우를 살펴보자. 앞부분과 다른점은 아무래도 특정 id의 값을 가져와야 하기 때문에 handler의 파라미터로 id가 들어간다는 점, 그리고 해당 id를 가진 값을 찾는 과정이 있다는 점이 있겠다.
데이터 전체를 가져오는 것과는 다르게 try-catch를 사용하였는데, 이는 클라이언트에서는 id가 나와있는데 서버에는 없는 경우, 혹은 그 반대의 경우 오류가 발생할 가능성이 있으므로 그에 대한 대비책으로서의 성격을 지닌다. 에러가 발생했을 경우 404(Not Found)에러 응답값을 전송한다.
find API를 사용하여 해당 id가 파라미터의 id와 같은지, 즉 URL에 :id로 들어갈 id와 같은것인지를 확인하는 과정을 거친다. 당연히 DB에 있는 데이터의 수보다 더 큰 숫자를 넣는다면 그 값은 존재하지 않을 것이므로, msg가 없을 경우 not found라는 Error를 던지게끔 처리를 하였다. if문을 건너뛰었다는 건 결국 msg가 존재한다는 것이므로 그 응답 값을 전송한다.
messages/:id의 값을 통해 해당 id를 가진 데이터를 띄워주는 것을 볼 수 있다.
4-2. POST
POST는 데이터를 보내는 메소드인데, 여기에는 새로운 데이터를 추가하는 과정이라고 보면 된다.
import { readDB, writeDB } from "../dbController.js";
import { v4 } from "uuid";
const getMsgs = () => readDB("messages");
const setMsgs = (data) => writeDB("messages", data);
const messagesRoute = [
{
// 메시지 만들기(create)
method: "post",
route: "/messages",
handler: ({ body }, res) => {
const msgs = getMsgs();
const newMsg = {
id: v4(),
text: body.text,
userId: body.userId,
timestamp: Date.now(),
};
msgs.unshift(newMsg);
// 데이터가 추가된 전체 완성 메시지가 구성됨
setMsgs(msgs);
res.send(newMsg);
},
},
]
export const messagesRoute;
새로운 메시지를 만드는 API이므로, 메시지에 들어갈 주된 값인 body가 필요하다. 그에 따라 파라미터로 body가 함수 형태로 들어가게 되었다. 똑같이 getMsgs를 통해 messages.json의 값을 받아오는데, 여기서 이제 newMsg라는 새로운 변수가 등장한다.
newMsg
- id: v4(): 상술한 대로 uuid 함수를 불러와서, 그중에서도 랜덤하게 생성하는 v4를 불러온 것이다.
- UUID란 주로 분산 컴퓨팅 환경에서 사용되는 식별자이다. 중앙관리시스템이 있는 환경이라면 각 세션에 일련번호를 부여해줌으로써 유일성을 보장할 수 있겠지만 중앙에서 관리되지 않는 분산 환경이라면 개별 시스템이 id를 발급하더라도 유일성이 보장되어야만 할 것이다. 이를 위해 탄생한 것이 범용고유식별자 UUID (Universally Unique IDentifier) 이다. (출처: [TIL] UUID란?)
- text: body.text: body의 text를 text로 가져온다는 뜻이다.
- userId: body.userId: body의 userId를 userId라는 변수로 가져온다는 뜻이다. messages.json을 보면 알겠지만 변수명을 당연히 똑같이 맞춰야한다!
- timestamp: Date.now(): 생성일시를 POST로 데이터를 보내는 그 시간으로 정하겠다는 뜻이다.
msgs.unshift(newMsg)를 통해 위와 같은 과정으로 새롭게 만든 newMsg를 msgs, 즉 messages.json의 맨 앞에 추가한다. message.json을 보면 알겠지만 id값이 1부터 점점 커질수록 아래에서 위로 올라가는 형태를 띠고 있다. 그렇기 때문에 새로운 id는 맨 위에 위치해야 한다.
4-3. PUT
PUT은 이미 가지고 있는 데이터의 값을 수정할 때 사용한다. 수정하는 경우라도 전체 데이터 수정이 아니라, 특정 id값을 갖는 데이터를 수정하는 경우에 해당하므로 route에 id값까지 넣어준다.
import { readDB, writeDB } from "../dbController.js";
import { v4 } from "uuid";
const getMsgs = () => readDB("messages");
const setMsgs = (data) => writeDB("messages", data);
const messagesRoute = [
{
// update message
method: "put",
route: "/messages/:id",
handler: ({ body, params: { id } }, res) => {
try {
const msgs = getMsgs();
const targetIdx = msgs.findIndex((msg) => msg.id === id);
if (targetIdx < 0) throw "메시지가 없습니다";
if (msgs[targetIdx].userId !== body.userId) throw "사용자가 다릅니다";
const newMsg = { ...msgs[targetIdx], text: body.text };
msgs.splice(targetIdx, 1, newMsg);
setMsgs(msgs);
res.send(newMsg);
} catch (err) {
res.status(500).send({ error: err });
}
},
},
]
export const messagesRoute;
이전과 마찬가지로 messages.json의 값을 getMsgs()로 가져온다. 그리고 파라미터로 받아온 id의 값과 같은 인덱스를 findIndex API를 통해서 찾는다. 존재하는 인덱스가 없을 경우 -1을 반환하는데, targetIdx가 0보다 작은 경우로 걸러냄으로써 에러 발생을 방지한다. 그리고 메시지의 주체인 body가 갖고 있는 userId와 우리가 찾은 targetIdx번째에 해당하는 값이 다를 경우에도 throw Error을 함으로써 이후에 발생할 에러를 방지하였다. 새로운 메시지는 body.text, 그러니까 우리가 수정한 값 text가 씌워진 targetIdx번째 메시지이다. 이를newMsg 변수에 부여하였다.
그리고, splice API를 사용하여 targetIdx에 해당하는 값을 하나 지우고 우리가 방금 위에서 수정해서 새로 만든 newMsg로 바꾼다.
Array.prototype.splice() 메소드 자세히 알아보기
이제 그 아래에는 전부 위에서 하던 것과 똑같다. 단지 catch 부분에서 500 에러를 띄운다는 점이 다르다. 클라이언트와 서버의 연결에서 발생하는 문제를 막기 위해 try-catch를 한다고 하였는데, catch에 해당하는 경우는 결국 서버 단의 문제가 생겼다고 볼 수 있다. 따라서 500(Internal Server Error) 에러를 띄워준다.
4-4. DELETE
DELETE는 이름에서 알겠지만 삭제하는 메소드이다. 이 역시 특정 id값을 삭제하는 API를 만들 것이므로 id값을 route URL에 넣어준다.
import { readDB, writeDB } from "../dbController.js";
import { v4 } from "uuid";
const getMsgs = () => readDB("messages");
const setMsgs = (data) => writeDB("messages", data);
const messagesRoute = [
{
// delete message
method: "delete",
route: "/messages/:id",
handler: ({ params: { id }, query: { userId } }, res) => {
try {
const msgs = getMsgs();
const targetIdx = msgs.findIndex((msg) => msg.id === id);
if (targetIdx < 0) throw "메시지가 없습니다";
if (msgs[targetIdx].userId !== userId) throw "사용자가 다릅니다";
msgs.splice(targetIdx, 1);
setMsgs(msgs);
res.send(id);
} catch (err) {
res.status(500).send({ error: err });
}
},
},
]
export const messagesRoute;
이부분은 PUT과 거의 똑같은데, 한가지 차이점이 있다면 새로운 메시지를 만들 필요가 없다는 것, 그리고 splice API 사용 시 대체해줄 값을 지정할 필요가 없다는 것에 있다. splice API에서 세번째 요소에 대체할 값이 들어가게 되는데, 이 세번째 요소는 선택사항으로 넣지 않으면 그냥 그 부분이 사라지게 된다. DELETE라는 이름에 맞게끔 아무것도 대체해주지 않아야 하므로 세번째 요소를 넣지 않는다.
이렇게 GET, POST, PUT, DELETE를 직접 구현해봤다. 이제 클라이언트와의 연결이 필요한데, 이 부분은 여건이 되면 따로 포스팅 하겠다.