최근 fetch
에 대해서 열심히 공부하고 있던 중
fetch
를 실습해볼 서버를 express
를 이용해서 만든 후
만든 서버를 가지고 열심히 request
를 보내는 연습을 하고 있다.
GET , POST , PUT
등을 열심히 실습하던 도중 기왕 실습하는거 이전에 만든 todolist
의 단점을 보완하고자 API
를 이용해서 만들어보기로 하였다.
바닐라 자바스크립트로 To do list 만들기 !
이전까지의 과정은 이 블로그를 참조해주세요!
express
모듈을 이용하여 기록 저장하기이 3개를 중점적으로 구현해보려고 한다.
JSON
양식 생각해보기서버에서 GET , POST , PUT , PATCH
등의 메소드를 비동기적으로 처리하기 위해서
서버 단에서 수신하고 보내주는 JSON
의 생김새는 다음과 같기를 원했다.
{
id : 목표의 index,
text : 정한 목표,
done : 성취 여부
}
성취한 목표가 그 자리에서 성취된 목표로 남는 것이 아니라 성취되지 않은 목표들이 가장 윗단에 존재하고, 성취한 목표는 하단에 존재하게 하고 싶었다.
그래서 두 가지 방법을 생각했었다.
성취한 목표는 id
값을 변경하어 서버에 PATCH
메소드를 날려 서버 단에서 JSON
파일을 변경하고, 변경한 목표를 id
순으로 다시 전부 GET
해오기
content
영역을 uncompleted-zone , completed-zone
구역으로 나눈 후 성취한 목표를 completed
존으로 옮기기
두 가지 방법 중 2번째 방법을 선택하기로 했다.
어차피 2번째 방법도 서버 단에서 PATCH
를 해서 id
를 변경해야 하는 것은 동일하지만 서버단에 GET
메소드를 날려 띄워오는 것보다 구역을 나눠 자바스크립트로만 구역을 변경함으로서 불필요한 네트워크 통신을 줄일 수 있을 것이라고 생각했다.
zone-uncompleted
에 띄워지게 하기 <!-- 변경 하기 전 html-->
<div id="content"></div>
<!-- 변경 한 후의 html -->
<div id="content">
<div id="zone-uncompleted"></div>
<div id="zone-completed"></div>
</div>
그래서 영역을 다음과 같이 나눠주었다.
이후 새로운 값이 들어 올 때 content
에 띄우는 것이 아니라 zone-uncompleted
에 띄우도록 변경해주었다.
//변경 전 코드
// inner-wrapper 를 생성하여 천천히 content 에 띄우는 함수
const createInnerWrapper = () => {
const $innerWrapper = document.createElement('div');
$innerWrapper.classList.add('inner-wrapper');
$content.appendChild($innerWrapper);
...
};
//변경 후 코드
const $zoneUncompleted = document.querySelector('#zone-uncompleted');
const $zoneCompleted = document.querySelector('#zone-completed');
// inner-wrapper 를 생성하여 천천히 content 에 띄우는 함수
const createInnerWrapper = () => {
const $innerWrapper = document.createElement('div');
$innerWrapper.classList.add('inner-wrapper');
$zoneUncompleted.appendChild($innerWrapper); // 새로 설정된 목표는 성취되지 않은 목표들에 띄우기
...
// 변경하기 전 코드
// button-wrapper 내의 O 버튼이 눌리면 completedGoals를 올리고
// typed-goal의 텍스트를 수정하는 함수 (완료 선 긋기, id = completed 로 변경)
// 완료된 목표의 innerWrapper 에서 불빛이 나게 하자
const goalComplete = (event) => {
const $button = event.target;
const $buttonWrapper = event.target.parentNode;
const $typedGoal = $buttonWrapper.previousSibling;
const $innerWrapper = $typedGoal.parentNode;
$typedGoal.id = 'completed';
$innerWrapper.style.boxShadow = '0px 0px 5px blue';
completedGoals += 1;
$typedGoal.style.textDecoration = 'line-through';
$typedGoal.style.color = '#aaa';
$button.id = '';
};
이전 코드는 content
영역에서 색상만 변경되게 했다면
const goalComplete = (event) => {
const $button = event.target;
const $buttonWrapper = event.target.parentNode;
const $typedGoal = $buttonWrapper.previousSibling;
const $innerWrapper = $typedGoal.parentNode;
// 위치를 변경시키기
$zoneCompleted.insertBefore($innerWrapper, $zoneCompleted.firstChild);
존재하는 위치를 $zoneUncompleted => $zoneCompleted
로 변경해주었다.
express
서버 만들기const express = require('express');
const cors = require('cors');
const app = express();
app.use(express.json()); // for parsing application/json
app.use(express.urlencoded({ extended: true })); // for parsing
// cors 해결
app.use(cors());
npm
에 있는 express
의 튜토리얼 코드를 가지고 서버를 만들어주었다.
이후 서버가 열릴 포트 번호를 이용해 서버를 열어주었다.
// 서버를 호스팅 할 포트 번호 설정
// 호스팅되는 서버의 주소 : https://localhost:3000
app.listen(3000, (req, res) => {
console.log('server start !');
});
app.get('/', (req, res) => {
res.send('hello world');
});
서버를 만들었으니 데이터들을 저장할 곳과 JSON
을 준비하자
// 데이터 베이스 생성
const database = {
uncompleted: [
{
id: 1,
text: '치킨500개먹기',
},
],
completed: [
{
id: 1,
text: '피자 500개 먹기',
},
],
};
다음처럼 데이터 베이스를 만들어주었다.
내 투두리스트는 zone-uncompleted
, zone-completed
로 나뉘어서 사용될 것이기 때문에 두가지 프로퍼티를 가지는 JSON
형태로 만들어주었다.
앞으로 목표를 설정하거나 삭제, 수정 할 때 마다 해당 배열에 들어가도록 할 것이다.
Client
와Sever
본문을 모두 적고나서 제목들을 보니
Client
,Sever
이렇게 나눠놨는데 구분을 위해 내 혼자 지은 것이다.
Client
라고 적힌 것은 웹 페이지에서request
를 보내는 이벤트 핸들러에 해당하고,Sever
라고 적은 것은 서버 단에서 받은request
에 대한 응답을 의미한다.
GET
설정하기Client
이제 서버를 만들었으니, 서버에 저장된 JSON
파일을 가지고 와서 todolist
에 띄울 수 있도록 이벤트 핸들러를 등록해주자
코드를 모듈화 시켜두지 않고 그냥 적었더니 매우 지저분하다.
const url = 'http://localhost:3000/todo';
const makePage = (todoLists, stateName) => {
todoLists.forEach((todo) => {
// node 를 만들어 parentNode 에 담기
const isCompleted = stateName === 'completed';
const $parentNode = isCompleted ? $zoneCompleted : $zoneUncompleted;
const $innerWrapper = document.createElement('div');
$innerWrapper.classList.add('inner-wrapper');
$parentNode.appendChild($innerWrapper);
// todo 에 들어있는 text를 typedGoal 노드에 적기
const $typedGoal = document.createElement('div');
$typedGoal.classList.add('typed-goal');
if (isCompleted) $typedGoal.classList.add('completed');
$typedGoal.textContent = todo.text;
$innerWrapper.appendChild($typedGoal);
// button wrapper 와 button 만들기
const $buttonWrapper = document.createElement('div');
$buttonWrapper.classList.add('button-wrapper');
const $complete = document.createElement('button');
const $delete = document.createElement('button');
[$complete.textContent, $delete.textContent] = ['⭕', '❌️'];
$complete.classList.add(isCompleted ? 'off' : 'complete');
if (!isCompleted) $complete.classList.add('complete');
$delete.classList.add('delete');
[$complete, $delete].forEach((button) =>
$buttonWrapper.appendChild(button),
);
$innerWrapper.appendChild($buttonWrapper);
requestAnimationFrame(() => {
$innerWrapper.style.width = '90%';
$innerWrapper.style.height = '30px';
$buttonWrapper.style.opacity = '1'; // 서서히 나타나는 효과
$typedGoal.style.opacity = '1';
});
});
};
fetch(url)
.then((res) => res.json())
.then(({ uncompleted, completed }) => {
makePage(uncompleted, 'uncompleted');
makePage(completed, 'completed');
totalGoals += uncompleted.length + completed.length;
completedGoals += completed.length;
updateProgressText();
updateProgressBar();
})
.catch((e) => console.error(e));
Sever
// get 요청에 대한 메소드
// todo 에 GET 메소드를 날리면 data 베이스를 보내주기
app.get('/todo/:state?/:id?', (req, res) => {
const state = req.params.state ? req.params.state.toLowerCase() : null;
const itemId = req.params.id ? parseInt(req.params.id, 10) : null;
if (!state && !itemId) {
res.json(database);
return;
}
if (!itemId && (state === 'uncompleted' || state === 'completed')) {
res.json(database[state]);
return;
}
if (!itemId || !database[state]) {
res.status(404).json({ error: '유효하지 않은 상태 또는 ID' });
return;
}
const result = database[state].find((data) => data.id === itemId);
if (!result) {
res.status(404).json({ error: '해당 ID 가 존재하지 않습니다' });
} else {
res.json(result);
}
});
GET
요청에 대한 서버의 response
를 만들어주었다.
:state
, :id
는 JSON
계층에서 uncompleted , completed
를 구분하는 state
, 목표의 id
를 구분짓는 id
로 라우팅 매개변수
로 사용해주었다.
이후 state , id
는 GET
요청시 존재 할 수도, 존재하지 않을 수도 있기 때문에 ?
를 붙여줘 선택적으로 사용 할 수 있도록 해주었다.
만약 id
를 선택하지 않고 state
만 선택 할 경우엔 해당 state
의 JSON
파일을 보내주었고
state
, id
모두 선택 할 경우 해당 파일을,
올바르지 않은 경로일 경우엔 404
에러를 주고 state , id
모두 입력한 것이 올바를 경우엔 해당 파일을 보내 주었다.
확인해보자
/todo
로 접근하기/todo/completed
로 접근하기todo/completed/1
로 접근하기올바르지 않은 ID
올바르지 않은 상태
매우 ~ 잘된다 구우웃 ~
서버단에서 uncompleted , completed
프로퍼티가 있는 JSON
객체를 가져와 페이지를 만들어주었다.
과연 잘 작동하나 확인해보자
야호 서버가 열리면 요청도 잘 하고 잘 받아서 렌더링도 된다.
이후 서버가 열려있는 상태라면 새로고침 할 때 마다 서버 측에 GET
요청을 보내 설정한 목표들이 렌더링 된다.
POST
요청 보내기Client
$submit.addEventListener('click', () => {
console.log($input.value);
totalGoals += 1;
if ($input.value === '') return;
fetch(url + '/uncompleted', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: totalGoals - completedGoals,
text: $input.value,
}),
}).then(() => {
$input.value = '';
});
});
$submit
버튼에 이벤트 핸들러를 등록 시켜주었다.
POST
요청을 보낼 때 전체 목표를 하나씩 올려주었다.
이 때 id
는 uncompleted
한 목표들의 개수가 순차적으로 쌓일 수 있도록 전체 목표 - 성취된 목표
의 번호로 넣어주었다.
Sever
// POST 에 대한 메소드
// POST 는 uncompleted 에만 주어진다.
app.post('/todo/uncompleted', (req, res) => {
const { id, text } = req.body;
// 필수 필드 확인
if (!id || !text) {
return res.status(400).json({ error: 'ID와 텍스트는 필수 항목입니다.' });
}
database.uncompleted.push({
id,
text,
});
// 클라이언트에 JSON 응답 보내기
res.json({ message: 'Item added to uncompleted list successfully' });
});
POST
요청은 새로운 목표를 설정 할 때에만 존재하기 때문에 uncompleted
프로퍼티 배열에 추가해준다.
이 때 id , text
가 존재하지 않을 경우 400
상태 코드를 보낸다.
잘 되나 확인해보자
잘 보내진다 ~~ 야호
PUT
설정하기PATCH
는 성취한 목표를 아래로 내렸을 때 JSON
파일에서도 해당 목표의 저장을 uncompleted
배열에서 completed
배열로 변경 할 수 있도록 해야 한다.
이 때 uncompleted
, completed
배열 모두 배열의 상태가 변경 됨에 따라 id
들을 모두 싹 갈아엎어줘야 한다.
싹 갈아 엎는 것을 관리하기 위해 배열이 아닌 우선순위 큐 형태로 관리 할까 생각했지만, 지금은 맛보기니까 그냥 배열로 해서
N
시간 복잡도로 가자고 ~!! 누가 투두리스트를 1억개씩 하겠어 키킥
Client
$content.addEventListener('click', (event) => {
// 요구되는 사항이 아니면 얼리 리턴
if (event.target.tagName !== 'BUTTON') return;
if (!event.target.classList.contains('complete')) return;
const $button = event.target;
const $typedGoal = event.target.parentNode.previousSibling;
const text = $typedGoal.textContent;
fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text }),
})
.then(() => {
// 비동기 작업 처리가 종료되면 버튼의 중복 작업을 막기 위해 클래스 변경
$button.classList.remove('complete');
$button.classList.add('off');
})
.catch(console.error);
});
별거 없다. complete
버튼이 눌리면 해당 버튼이 존재하는 영역의 text
를 서버 단에 보내어 PUT
요청을 한다.
Sever
// PUT 설정하기
app.put('/todo', (req, res) => {
const { text } = req.body; // 성취된 목표의 text 를 할당 받음
const targetIndex = database.uncompleted.findIndex((item) => {
return item.text === text;
});
if (targetIndex === -1) {
res.status(400).json({ error: '잘못된 요청입니다' });
return;
}
const target = database.uncompleted.splice(targetIndex, 1)[0];
//completed 배열 맨 앞으로 넣기
database.completed.unshift(target);
// 인덱스들 모두 변경하기
database.uncompleted.forEach((item, index) => (item.id = index + 1));
database.completed.forEach((item, index) => (item.id = index + 1));
res.send(database);
});
클라이언트가 요청하는 request
의 body
문에 담긴 text
를 이용해 uncompleted
배열에서 해당 객체를 splice
해온다.
이후 해당 객체를 completed
배열로 옮긴 후 데이터베이스에 존재하는 객체들의 id
를 싹~ 갈아엎어준다.
좀 더 로직을 생각하면
uncompleted
배열에서는 삭제 된 인덱스부터 반복문을 시작해도 됐을 것 같다.
잘 되나 확인해보자
잘 된다
야호 ~~~!!
이 부분이 시간이 제일 오래걸렸다.
시간이 오래걸렸던 이유는 다른 이벤트 핸들러에서 성취된 목표의 클래스를 변경했는데
해당 이벤트 핸들러를 만들 때 클래스가 변경된단 사실을 모르고 계속 내가 조건문을 틀렸나 .. 하고 쳐다봤다.
상태를 변경하는 등의 행위는 비동기 작업 처리 후 넣는게 좋은가 ? 고민이다.
DELETE
메소드 만들기이것만 만들면 끝이다 ~~!!
딜리트는 삭제를 저장하기 위한 메소드이다.
Client
$content.addEventListener('click', (event) => {
if (
event.target.tagName !== 'BUTTON' ||
!event.target.classList.contains('delete')
)
return;
const $button = event.target;
const $typedGoal = $button.parentNode.previousSibling;
const text = $typedGoal.textContent;
fetch(url, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text }),
}).catch(console.error);
});
POST
와 크게 다를거 없다. 삭제 버튼이 눌린 목표의 텍스트를 적어 서버에 보낸다.
Sever
// delete 요청
app.delete('/todo', (req, res) => {
const { text } = req.body;
const targetName = database.uncompleted.some((todo) => todo.text === text)
? 'uncompleted'
: 'completed';
database[`${targetName}`] = database[`${targetName}`].filter(
(todo) => todo.text !== text,
);
database[`${targetName}`].forEach((todo, index) => (todo.id = index + 1));
res.send('success');
});
서버 단에서는 uncompleted
배열과 completed
배열 중 어떤 배열에 해당 값이 존재하는지 확인하고 , 해당 값을 찾아 filter
를 이용해 해당 값을 제외한 배열로 교체한다.
이후 인덱스값 주루룩 다 감아버리기 ~!!
잘 되나 확인해보자
잘 된다
이렇게 오래걸릴지 몰랐다 ..
우선 내가 처음으로 해본 express
는 제대로 서버가 어떻게 통신을 응답받아야 하는지
엄밀히 공부하고 만든게 아니라 테스트만을 위해 만든거라 맞는지 잘 모르겠다.
내일 다른 사람들이 express
로 만든 코드들을 깃허브를 뒤져 찾아봐야겠다. :)
이번에 하면서 느낀거는
다양한 이벤트 핸들러들과 , 비동기 작업이 있을 때
어떤 태그의 상태 변경을 하는 것은 비동기 작업이 종료된 후에 해야겠다고 느꼈다.
그게 아니면 비동기 작업 처리 중 해당 상태 변경을 찾지 못해 코드의 에러를 찾는데 힘이 들더라
항상 하면서 느끼는 것은 전역적인 변수들을 어떻게 관리해야 할지 감이 잘 안온다.
얼른 자바스크립트 딥다이브를 마저 읽은 후에 리팩토링에 대한 서적을 읽어봐야지 ..
아 그리고 HTTP
공부 열심히 해야지