[JS] Udemy 문벅스 카페 메뉴 앱 만들기 - Section4 : 웹서버를 띄우고, api를 요청하여 메뉴판 관리

요들레이후·2023년 1월 16일
0

Javascript

목록 보기
10/11
post-thumbnail

📌 step3. 요구사항 분석 - 서버와의 통신을 통해 메뉴 관리하기

왜 웹서버가 필요할까?

우리가 웹에 존재하는 대부분의 컨텐츠들은 업데이트가 가능하다. 업데이트된 컨텐츠가 여러군데에 분산되어있다면 동시 갱신이 어렵고 비용이 많이 든다. 사용자가 컨텐츠를 직접 관리하면 보안상 문제와 비현실적인 문제임. 그래서 client와 웹 서버로 분리해서 데이터를 요청 및 관리함. 웹 서버에서 데이터의 상태를 효율적으로 관리, 변경된 사항은 클라이언트들에게 공유를 잘하기 위함.

TODO 서버 요청 부분

  • 웹 서버를 띄운다.
  • 서버에 새로운 메뉴명이 추가될 수 있도록 요청한다.
  • 서버에 카테고리별 메뉴리스트를 불러온다.
  • 서버에 메뉴가 수정될 수 있도록 요청한다.
  • 서버에 메뉴의 품질상태가 토글될 수 있도록 요청한다.
  • 서버에 메뉴가 삭제될 수 있도록 요청한다.

TODO 리펙터링 부분

  • localStorage에 저장하는 로직은 지운다
  • fetch 비동기 api를 사용하는 부분을 async await을 사용하여 구현한다.

TODO 사용자 경험

  • API 통신이 실패하는 경우에 대해 사용자가 알 수 있게 alert으로 예외처리를 진행한다.
  • 중복되는 메뉴는 추가할 수 없다.



📌 에러 발생 : Uncaught TypeError - map, length, push… tlqkf..

Uncaught TypeError: Cannot read properties of undefined (reading 'map')

이건 강의에 나와있지 않은 에러이다. 내가 이 에러때문에 리액트로 애를 먹었었지..

에러내용을 살펴 보면 undefined의 속성인 map을 읽을 수 없다는 내용이다.

즉 undefined.map을 했다는 소리이다.

그러면 this.menu[this.currentCategory]내부에 있는 item키가 없다는 걸로 유추할 수 있다.

삼항연산자나 물음표를 추가하여 작성해보겠다.

this.menu[this.currentCategory]
      ?.map((item, index) => {....

물음표의 뜻은 this.menu[this.currentCategory] && this.menu[this.currentCategory].map 과 같은 뜻이다. this.menu[this.currentCategory]가 있을 때만 뒤의 로직을 수행한다는 것이다.

일단 삼항연산자로 써서 해결하였으나,

Uncaught TypeError: Cannot read properties of undefined (reading 'length')

에러 내용을 살펴보면 길이를 재고자 하는 변수의 값이 없거나 잘못된 것이라는 것. 길이를 재기 전에 변수의 값이 있는지 없는 지 확인해야한다.

이 또한 마찬가지로 undefined로 인해 발생한 에러이기에 삼항연산자로 처리(?)했는데

Uncaught TypeError: Cannot read properties of undefined (reading 'push')

this.menu[this.currentCategory]가 undefined이기 때문에 Array의 protoType인 push method를 쓸 수 없다는 에러이다.

this.menu[this.currentCategory]!.push({name: MenuName});

으로 일단 콘솔창 에러는 없앴으나, 앱 작동이 안되는 문제가 생겼다 !

  • 정말 어이없게도, vscode를 껐다 키니까 작동이 됨. 거참 어이가 없지만, 나중에 저런 오류들이 발생하게 된다면 참고하기 위해 지우진 않을 것임.



📌 웹 서버 요청으로 메뉴 생성하기(POST)

fetch()

서버에 데이터를 요청하거나 변경을 할 때는 fetch()라는 메서드를 사용

  • 첫번째 인자로는 url, 어떤 요청 주소로 보내면 실제 그 요청을 서버에서 처리함
  • 두번째 인자로는 option, 서버에 데이터를 받아오는지, 생성, 수정, 삭제를 하는지

요청이 여러번 사용되기 때문에 재사용되는 객체로 만들어서 사용해보는 방식으로 작성

const BASE_URL = 'http://localhost:3000/api';
fetch(`${BASE_URL}/`, 옵션)

addMenuName 수정 → API요청

상태를 변경해주는 로직부분을 지우고, 서버에 요청하는 로직을 추가

  1. BASE_URL의 category가 어떤 카테고리인지 접근
  2. POST : 객체나 데이터가 새로 생성될 때 사용
  3. 주고받는 컨텐츠 타입을 결정
  4. 주고받는 컨텐츠 형태 → json형태로 주고 받을 것임
  5. localStorage에 데이터를 저장할 때 문자열로 저장하느라 JSON.stringify()를 사용했던 것, name이라는 키와 vale가 있는 객체를 만들어서 서버에 요청
  6. 서버에 응답을 받으려면 .then이라는 체이닝을 사용
  7. 응답 객체에서 서버가 내려준 데이터를 받으려면 응답객체.json() 메서드 사용
  8. 응답받은 데이터를 출력한다.
fetch(`${BASE_URL}/category/${this.currentCategory}/menu`, {// 1.
      method: "POST",                          // 2.
      headers: {                               // 3.
        "Content-Type": "application/json",    // 4.
	},
  body: JSON.stringify({ name: MenuName }),    // 5.
}).then((response) => {                        // 6.
  return response.json();                // 7.
})
.then((data) => {                        // 8.
  console.log(data);
});




📌 웹 서버 요청으로 전체메뉴 불러오기(GET)

에스프레소 메뉴 전체를 화면에 렌더링 → 기존 코드에서 바로 밑에다가 fetch를 해서 데이터를 불러오게 하는 요청을 할 수 있다고 생각한다

⇒ 하지만 이렇게 데이터 요청을 하면 한 가지 문제가 생긴다.

코드 상 메뉴 추가가 먼저 작성 되었는데, 왜 뒤에 작성된 것이 먼저 찍혔을까..? → 싱글스레드


자바스크립트 싱글스레드

: 자바스크립트는 한 번에 하나의 일만 수행 가능하다. ‘

예를 들어 카페에 가서 바닐라더블샷을 시켰는데, 주문하고 나면 점원이 바닐라더블샷을 만들고 있는 동안 내 뒤에 있는 사람들은 주문을 못한다.

좀 더 효율적으로 주문 접수와 주문 제조를 할 수 있다면 진동벨을 줘서 뒷 사람 주문을 받을 수 있다. 또한, 내 바닐라 더블샷을 만드는 동안 뒷 사람의 아메리카노가 더 빨리 만들어진다면 뒷 사람의 아메리카노가 먼저 나올 수 있다.

진동벨을 주는 이유는 첫번째 주문받은 거 제조 하느라 뒤에 요청 못받으면 멈춰있게 되는 것 → 실시간 통신이 안됨 → 그래서 진동벨이라는 약속이 필요하게 됨 ⇒ promise 객체

이것이 웹과의 통신에 중요한 로직. 서버에 요청을 하고 나면 진동벨을 받아가는 것, 그래야 여러 클라이언트들이 계속 요청을 했을 때 서버가 바로바로 받을 수 있음. 내가 먼저 요청한 것에 대해 먼저 답변을 받지 못할 수 있다.

⇒ 그럼에도 불구하고 비동기 통신의 불러오는 순서를 조율하고 싶다면 async-await을 사용하면 됨


async와 await

async와 await이라는 특별한 문법을 사용하면 프로미스를 좀 더 편하게 사용할 수 있다.

  • async

async는 function앞에 위치하고, 해당 함수는 항상 프로미스를 반환한다.

프로미스가 아닌 값을 반환하더라도 이행상태의 프로미스로 값을 감싸 이행된 프로미스가 반환되도록 한다.

async function f() {
  return 1;
}

f().then(alert); // 1

// 명시적으로 프로미스를 반환하는 것도 가능하다.
async function f() {
  return Promise.resolve(1);
}

f().then(alert); // 1
  • await

await는 async 함수 안에서만 동작합니다. 자바스크립트는 await 키워드를 만나면 프로미스가 처리될 때까지 기다리고, 결과는 그 이후 반환된다.

let value = await promise;
async function f() {

  let promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve("완료!"), 1000)
  });

  let result = await promise; // 프라미스가 이행될 때까지 기다림 (*)

  alert(result); // "완료!"
}

f();

위의 코드는 함수를 호출하고, 함수 본문이 실행되는 도중에 (*)로 표시한 줄에서 실행이 잠시 중단되었다가 프로미스가 처리되면 실행이 재개된다. 이때 프로미스 객체의 result 값이 변수 result에 할당된다. 따라서 위 예시를 실행하면 1초 뒤에 “완료!”가 출력된다.

const addMenuName = async () => {
    if ($('#menu-name').value === '') {
      alert('값을 입력해주세요.');
      return;
    }
    const menuName = $('#menu-name').value;

		// 메뉴 생성
    await fetch(`${BASE_URL}/category/${this.currentCategory}/menu`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ name: menuName }),
    })
      .then((response) => {
        return response.json();
      });

		// 전체 메뉴 불러오기
    await fetch(`${BASE_URL}/category/${this.currentCategory}/menu`)
      .then((response) => {
        return response.json();
      })
      .then((data) => {
        console.log(data);
        this.menu[this.currentCategory] = data;
        render();
        $('menu-name').value = '';
      });
  };

우리 코드에서는 함수 앞에 async를 붙이고, 기다렸으면 하는 로직에 await을 붙인다.




📌 MenuApi 객체 분리 - 메뉴 생성, 불러오기

메뉴 데이터 초기화

  1. init부분에서 localStorage에 저장하는 로직을 지우고 api로 전체 메뉴를 불러오는 코드를 넣는다.
  2. 중복되는 코드는 하나의 객체를 만들어 중복을 제거한다. > MenuApi 객체 생성
const MenuApi = {
  // 모든 메뉴 불러오는 API 요청(GET->default)
  async getAllMenuByCategory(category) {
    await fetch(`${BASE_URL}/category/${category}/menu`)
			.then(async(response) => {
				return response.json();
			})
			.then((data) => {
				return data;
			});
  },
};

this.init = async () => {
  this.menu[this.currentCategory] = await MenuApi.getAllMenuByCategory(
    this.currentCategory,
  );
  render();
  initEventListeners();
};
  • 맨 처음 앱을 실행할 때 카테고리 별로 메뉴를 불러와주는 작업을 진행하게 됨
  • 비동기 통신 메서드를 사용하는 곳에서는 anync-await 키워드를 계속 사용해야한다.
  • this → menuApi를 가리키고 있어서, menuApi는 카테고리라는 속성을 가지고 있지 않기 때문에 this.currentCategory가 아닌 category로 url을 설정해줘야함.

오류 : promise TypeError : 빈 배열인데 순열 요구해서 뜨는 오류

getAllMenuByCategory 함수의 전체 리턴값을 넘겨줘야하는데, 함수 체이닝한 곳에서 불러와서 함수 리턴값의 데이터를 직접 return하는 방식으로 고침

⇒ response값에 response객체를 받아와서 바로 데이터를 리턴해줌(해결)

const MenuApi = {
  // 모든 메뉴 불러오는 API 요청(GET->default)
  async getAllMenuByCategory(category) {
    const response = await fetch(`${BASE_URL}/category/${category}/menu`);
    return response.json();
  },
};

...

// addMenuName에도 적용
const addMenuName = async() => {
...

	this.menu[this.currentCategory] = await MenuApi.getAllMenuByCategory(
      this.currentCategory,
    );
...
}

메뉴 생성 API 리펙토링

  • 메뉴 생성할 땐 POST 방법
const MenuApi = {
  // 모든 메뉴 불러오는 API 요청(GET->default)
  async getAllMenuByCategory(category) {
    const response = await fetch(`${BASE_URL}/category/${category}/menu`);
    return response.json();
  },
  // 메뉴 생성하는 API요청(POST)
  async createMenu(category, name) {
    const response = await fetch(`${BASE_URL}/category/${category}/menu`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ name }),
    });
    if (!response.ok) {
      console.error('에러가 발생했습니다.');
    }
  },
};
  • response.ok : 서버에서 성공한 객체인지만 확인할 때 쓰는 속성
const addMenuName = async () => {
  if ($('#menu-name').value === '') {
    alert('값을 입력해주세요.');
    return;
  }
  const menuName = $('#menu-name').value;
  await MenuApi.createMenu(this.currentCategory, menuName);
  this.menu[this.currentCategory] = await MenuApi.getAllMenuByCategory(
    this.currentCategory,
  );
  render();
  $('menu-name').value = '';
};
  • 여기서는 this가 App이기 때문에, App안에서 선언한 것이기 때문에 this.currentCategory로 파라미터를 전달

카테고리를 클릭했을 때 데이터를 불러오기 추가

const initEventListeners = () => {

	....

  $('nav').addEventListener('click', async (e) => {
    const isCategoryButton =
      e.target.classList.contains('cafe-category-name');
    if (isCategoryButton) {
      const categoryName = e.target.dataset.categoryName;
      this.currentCategory = categoryName;
      $('#category-title').innerText = `${e.target.innerText} 메뉴 관리`;
      this.menu[this.currentCategory] = await MenuApi.getAllMenuByCategory(
        this.currentCategory,
      );
      render();
    }
  });
};



📌 MenuApi 객체 분리 - 메뉴 수정

서버 메뉴 수정 요청(PUT)

  • 메뉴 수정할 땐 PUT 방법
const MenuApi = {
  

	.....

  // 메뉴 수정하는 API요청(PUT)
  async updateMenu(category, name, menuId) {
    const response = await fetch(
      `${BASE_URL}/category/${category}/menu/${menuId}`,
      {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ name }),
      },
    );
    if (!response.ok) {
      console.error('에러가 발생했습니다.');
    }
    return response.json();
  },
};

  • 우리가 menuId를 알 수 있는 방법이 rendering할 때 배열의 인덱스로 넘겨줬는데, 지금은 서버에서 받아오는 id값이 있기 때문에 이것을 menuItem.id로 바꿔서 활용

  • updateMenuName
const updateMenuName = async (e) => {
  const menuId = e.target.closest('li').dataset.menuId;
  const $menuName = e.target.closest('li').querySelector('.menu-name');
  const updatedMenuName = prompt('메뉴명을 수정하세요', $menuName.innerText);
  await MenuApi.updateMenu(this.currentCategory, updatedMenuName, menuId);
  this.menu[this.currentCategory] = await MenuApi.getAllMenuByCategory(
    this.currentCategory,
  );
  render();
};

API요청해서 수정 → 수정하고 나서 데이터 리스트를 받아와준다.




📌 MenuApi 객체 분리 - 품절 처리

품절 처리(PUT)

  • 어떤 메뉴인지 서버에서 찾아야하므로 menuId를 넘겨줌
const MenuApi = {
  
	.....

  // 메뉴 품절 토글(PUT)
  async toggleSoldOutMenu(category, menuId) {
    const response = await fetch(
      `${BASE_URL}/category/${category}/menu/${menuId}/soldout`,
      {
        method: 'PUT',
      },
    );
    if (!response.ok) {
      console.error('에러가 발생했습니다.');
    }
  },
};

soldOutMenu에 로컬 스토리지 저장 부분 지우고, 직접 토글하는 부분도 지워준다.

토글하는 요청 진행되고 > 변경된 상태를 포함해서 메뉴 리스트를 불러와준다.

const soldOutMenu = async(e) => {
  const menuId = e.target.closest('li').dataset.menuId;
  await MenuApi.toggleSoldOutMenu(this.currentCategory, menuId);
  this.menu[this.currentCategory] = await MenuApi.getAllMenuByCategory(
    this.currentCategory,
  );
  render();
};



📌 MenuApi 객체 분리 - 메뉴 삭제

메뉴 삭제(DELETE)

const MenuApi = {
  // 메뉴 삭제(DELETE)
  async deleteMenu(category, menuId) {
    const response = await fetch(
      `${BASE_URL}/category/${category}/menu/${menuId}`,
      {
        method: 'DELETE',
      },
    );
    if (!response.ok) {
      console.error('에러가 발생했습니다.');
    }
  },
};

removeMenuName함수에서 기존의 splice로 메뉴를 삭제하고 localStorage에 세팅하는 코드 지워준다.

const removeMenuName = async (e) => {
  if (!confirm('정말 삭제하시겠습니까?')) return;
  const menuId = e.target.closest('li').dataset.menuId;
  await MenuApi.deleteMenu(this.currentCategory, menuId);
  this.menu[this.currentCategory] = await MenuApi.getAllMenuByCategory(
    this.currentCategory,
  );
  render();
};



📌 리팩터링

API 호출 분리

  • API 호출 파일 분리

현재 한 파일에 function App과 API 객체가 같이 있으므로,

src/api/index.js에 API 호출해주는 코드를 따로 분리해준 뒤 import 해준다.

// src/api/index.js
const BASE_URL = 'http://localhost:3000/api';

const MenuApi = {
  // 모든 메뉴 불러오는 API 요청(GET->default)
  async getAllMenuByCategory(category) {
    const response = await fetch(`${BASE_URL}/category/${category}/menu`);
    return response.json();
  },
  // 메뉴 생성하는 API요청(POST)
  async createMenu(category, name) {
    const response = await fetch(`${BASE_URL}/category/${category}/menu`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ name }),
    });
    if (!response.ok) {
      console.error('에러가 발생했습니다.');
    }
  },
  // 메뉴 수정하는 API요청(PUT)
  async updateMenu(category, name, menuId) {
    const response = await fetch(
      `${BASE_URL}/category/${category}/menu/${menuId}`,
      {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ name }),
      },
    );
    if (!response.ok) {
      console.error('에러가 발생했습니다.');
    }
    return response.json();
  },
  // 메뉴 품절 토글(PUT)
  async toggleSoldOutMenu(category, menuId) {
    const response = await fetch(
      `${BASE_URL}/category/${category}/menu/${menuId}/soldout`,
      {
        method: 'PUT',
      },
    );
    if (!response.ok) {
      console.error('에러가 발생했습니다.');
    }
  },
  // 메뉴 삭제(DELETE)
  async deleteMenu(category, menuId) {
    const response = await fetch(
      `${BASE_URL}/category/${category}/menu/${menuId}`,
      {
        method: 'DELETE',
      },
    );
    if (!response.ok) {
      console.error('에러가 발생했습니다.');
    }
  },
};

export default MenuApi;
// src/js/index.js
import MenuApi from "../api/index.js"

function App() { ........ }
  • render()위에 메뉴 불러오는 코드 render함수 내에 작성


중복되는 메뉴 추가하지 않기

서버에서 처리가 되어있는 부분이 있어서, 중복된 메뉴를 입력하면 error메시지를 보내줌.

서버로 요청을 보내기 전에 프론트에서도 사용자가 잘못된 요청을 아예 하지 않게끔, 중복 메뉴 추가하지 못하게끔 기능 추가

const addMenuName = async () => {
  if ($('#menu-name').value === '') {
    alert('값을 입력해주세요.');
    return;
  }
  const duplicatedItem = this.menu[this.currentCategory].find(
    (menuItem) => menuItem.name === $('#menu-name').value,
  );
  console.log(duplicatedItem);
  if (duplicatedItem) {
    alert('이미 등록된 메뉴입니다. 다시 입력해주세요.');
    $('#menu-name').value = '';
    return;
  }

  const menuName = $('#menu-name').value;
  await MenuApi.createMenu(this.currentCategory, menuName);
  render();
  $('#menu-name').value = '';
};
  • dublicatedItem : **this.menu[this.currentCategory]**에 사용자가 입력한 메뉴가 없어야 하는데, 그것을 찾는 객체
  • find : 배열에서 어떤 요소가 있는지 찾는 메서드 ⇒ 인자로 배열의 원소를 받을 수 있음

⇒ 같은 게 없다면 담기는 것이 없고, 같은 게 있다면 담기는 것이 있을 것


API 호출 객체 추상화 - 리팩토링

1. HTTP_METHOD 옵션 객체화

const HTTP_METHOD = {
  POST(data) {
    return {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(data),
    };
  },
  PUT(data) {
    return {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
      },
      body: data ? JSON.stringify(data) : null,
			// body값에 데이터가 있으면 보내고, 없으면 null
    };
  },
  DELETE() {
    return {
      method: 'DELETE',
    };
  },
};

2. response 받는 객체 (url, option)

1) 요청에 대해 response 데이터가 있을 때

const request = async (url, option) => {
  const response = await fetch(url, option);
  if (!response.ok) {
    alert('에러가 발생했습니다.');
    console.error('에러가 발생했습니다.');
  }
  return response.json();
};

2) 요청에 대해 response 데이터가 없을 때

const requestWithoutJson = async (url, option) => {
  const response = await fetch(url, option);
  if (!response.ok) {
    alert('에러가 발생했습니다.');
    console.error(e);
  }
  return response;
};

3. MenuApi 객체화 시킨 것 적용

const MenuApi = {
  // 모든 메뉴 불러오는 API 요청(GET->default)
  // 불러오는 요청 => option이 따로 필요 없음
  // fetch가 알아서 get으로 인식
  async getAllMenuByCategory(category) {
    return request(`${BASE_URL}/category/${category}/menu`);
  },

  // 메뉴 생성하는 API요청(POST)
  async createMenu(category, name) {
    return request(
      `${BASE_URL}/category/${category}/menu`,
      HTTP_METHOD.POST({ name }),
    );
  },

  // 메뉴 수정하는 API요청(PUT)
  async updateMenu(category, name, menuId) {
    return request(
      `${BASE_URL}/category/${category}/menu/${menuId}`,
      HTTP_METHOD.PUT({ name }),
    );
  },

  // 메뉴 품절 토글(PUT)
  async toggleSoldOutMenu(category, menuId) {
    return request(
      `${BASE_URL}/category/${category}/menu/${menuId}/soldout`,
      HTTP_METHOD.PUT(),
    );
  },

  // 메뉴 삭제(DELETE)
  async deleteMenu(category, menuId) {
    return requestWithoutJson(
      `${BASE_URL}/category/${category}/menu/${menuId}`,
      HTTP_METHOD.DELETE(),
    );
  },
};



Step3. 순서 정리

  1. 웹서버 띄우기
  2. BASE_URL 웹 서버 변수 먼저 선언
  3. 비동기 처리 해당 부분 확인, 웹서버에 요청하게끔 코드 짜기
  4. 서버네 요청한 후 데이터를 받아 화면에 렌더링 하기
  5. 리펙터링
    • localStorage 부분 지우고
    • API 파일 따로 만들어서 진행
    • 페이지 렌더링과 관련해 중복되는 부분 제거
    • 서버 요청할 때 option 객체
    • 카테고리 버튼 클릭시 콜백 함수 분리

이렇게 바닐라 자바스크립트로 문벅스 메뉴관리 끝! 시간 되면 React로 재구성 해보는 것도 나쁘지 않은 것 같다는 생각이 든다..!

profile
성공 = 무한도전 + 무한실패

0개의 댓글