fetch , express 이용하여 ToDoList 버전 업그레이드 하기 (CRUD)

ChoiYongHyeun·2024년 1월 11일
0

망가뜨린 장난감들

목록 보기
4/19
post-thumbnail

최근 fetch 에 대해서 열심히 공부하고 있던 중

fetch 를 실습해볼 서버를 express 를 이용해서 만든 후

만든 서버를 가지고 열심히 request 를 보내는 연습을 하고 있다.

GET , POST , PUT 등을 열심히 실습하던 도중 기왕 실습하는거 이전에 만든 todolist 의 단점을 보완하고자 API 를 이용해서 만들어보기로 하였다.

바닐라 자바스크립트로 To do list 만들기 !
이전까지의 과정은 이 블로그를 참조해주세요!


현재까지 구현된 기능

  • 목표를 작성하면 투두리스트에 띄우는 기능
  • 목표를 완료하면 투두리스트에서 색상과 완료 선을 긋는 기능
  • 목표를 삭제하면 투두리스트에서 목표가 제거되는 기능
  • 작성한 목표 수와 완료된 목표 수에 따라 완료 정도의 지표를 수정하는 기능

추가하고 싶은 기능

  • 설정된 목표들이 저장 될 수 있도록 express 모듈을 이용하여 기록 저장하기
    현재는 데이터를 저장하고 있는 곳이 없어 목표를 저장하더라도 새로고침 하면 모든 기록이 사라진다.
  • 성취할 목표만 작성하는 것이 아니라, 이미 한 일들또한 작성 할 수 있도록 하기
  • 성취된 목표는 성취되지 않은 목표들 이후로 위치를 변경하기
  • 목표 수정 기능 생성하기

이 3개를 중점적으로 구현해보려고 한다.


서버와 주고 받을 JSON 양식 생각해보기

서버에서 GET , POST , PUT , PATCH 등의 메소드를 비동기적으로 처리하기 위해서

서버 단에서 수신하고 보내주는 JSON 의 생김새는 다음과 같기를 원했다.

{
	id : 목표의 index,
  	text : 정한 목표,
  	done : 성취 여부 
}

서버를 만들기 전 기존 코드 수정하기

성취된 목표는 성취되지 않은 목표들의 가장 최하단에 위치하게 하기

성취한 목표가 그 자리에서 성취된 목표로 남는 것이 아니라 성취되지 않은 목표들이 가장 윗단에 존재하고, 성취한 목표는 하단에 존재하게 하고 싶었다.

그래서 두 가지 방법을 생각했었다.

  1. 성취한 목표는 id 값을 변경하어 서버에 PATCH 메소드를 날려 서버 단에서 JSON 파일을 변경하고, 변경한 목표를 id 순으로 다시 전부 GET 해오기

  2. 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 형태로 만들어주었다.

앞으로 목표를 설정하거나 삭제, 수정 할 때 마다 해당 배열에 들어가도록 할 것이다.


ClientSever

본문을 모두 적고나서 제목들을 보니 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 , :idJSON 계층에서 uncompleted , completed 를 구분하는 state , 목표의 id 를 구분짓는 id라우팅 매개변수 로 사용해주었다.

이후 state , idGET 요청시 존재 할 수도, 존재하지 않을 수도 있기 때문에 ? 를 붙여줘 선택적으로 사용 할 수 있도록 해주었다.

만약 id 를 선택하지 않고 state 만 선택 할 경우엔 해당 stateJSON 파일을 보내주었고

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 요청을 보낼 때 전체 목표를 하나씩 올려주었다.

이 때 iduncompleted 한 목표들의 개수가 순차적으로 쌓일 수 있도록 전체 목표 - 성취된 목표 의 번호로 넣어주었다.

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);
});

클라이언트가 요청하는 requestbody 문에 담긴 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 공부 열심히 해야지

profile
빨리 가는 유일한 방법은 제대로 가는 것이다

0개의 댓글