본문 바로가기

[연습 프로젝트] 문답 게시판_서버 개발

[연습 프로젝트] 문답 게시판_서버 개발

 

1. fetch로 서버 연결
2. GET : 전체 데이터 조회 
3. POST :  새로운 글 생성 
4. DELETE : 특정 글 삭제

 

결과물 

 

GitHub PR

 


 

서버

 

서버 쪽은 정말 간단하게 끝났다... 재밌어!

 

 

폴더 구조

 

1. my-agora-states-server

  • my-agora-states-server/app.js 는 서비스에 필요한 미들웨어와 웹 서버를 실행하는 코드가 작성되어 있다.

2. router

  • my-agora-states-server/router/ 안에는 discussion API 요청을 수행하는 라우터가 작성되어 있다. 작성된 라우터 내용을 통해 API 요청을 받을 수 있다.

3. controller

  • my-agora-states-server/controller/ 안에는 정의된 API 요청을 수행하는 코드가 작성되어 있다. 

4. repository

  • my-agora-states-server/repository/discussions.js 는 서비스에서 제공하는 게시글 데이터가 작성되어 있다. (이 데이터로 서비스를 구현)

5. test

  • GET 요청에 한해서 간단한 테스트 케이스가 작성되어 있다. 

 

app.js
const express = require('express');
const app = express();
const cors = require('cors');
const morgan = require('morgan');

// morgan 미들웨어는 HTTP 요청 logger를 편리하게 사용할 수 있는 미들웨어이다.
app.use(morgan('tiny'));

// cors를 적용, Express 내장 미들웨어인 express.json()을 적용 
app.use(cors());
app.use(express.json());

const port = 4000;
const discussionsRouter = require('./router/discussions');

app.use('/discussions', discussionsRouter);   // app.use()를 활용하여 /discussions 경로로 라우팅  


app.get('/', (req, res) => {
  res.status(200).send('fe-sprint-my-agora-states-server');   // 서버 상태 확인을 위해 상태 코드 200과 함께 응답을 보낸다.

});

app.use((req, res, next) => {
  res.status(404).send('Not Found!');   // 경로를 못 찾을 때 
});

app.use((err, req, res, next) => {      // 기타 에러를 잡아준다.
  console.error(err.stack);
  res.status(500).send({
    message: 'Internal Server Error',
    stacktrace: err.toString()
  });
});

const server = app.listen(port, () => {
  console.log(`[RUN] My Agora States Server... | http://localhost:${port}`);
});

module.exports.app = app;
module.exports.server = server;

 

discussions.js
const { discussionsController } = require('../controller');
const { findAll, findById, create, update, deleteByPostingId } = discussionsController;
const express = require('express');
const router = express.Router();

router.get('/', findAll);      // 모든 discussions 목록을 조회하는 라우터를 작성
router.get('/:id', findById);  // id에 맞는 discussion을 조회하는 라우터를 작성
router.post('/', create);      
router.put('/:id', update);
router.delete('/:id', deleteByPostingId); 

module.exports = router;

 

index.js
const { agoraStatesDiscussions } = require("../repository/discussions");
let discussionsData = agoraStatesDiscussions;


const discussionsController = {
  // 🟡 [GET] /discussions?query={query} 요청을 수행
  findAll: (req, res) => {
    if (req.query.query) {
      const filteredDiscussions = discussionsData.filter((discussion) => {
        return discussion.title.toUpperCase().includes(req.query.query.toUpperCase())
      });
      return res.status(200).json(filteredDiscussions) 
    }
    res.status(200).send(discussionsData)    // query 없으면 전체 데이터
  },

  // 🟡 [GET] /discussions/:id 요청을 수행 (요청으로 들어온 id와 일치하는 discussion을 응답)
  findById: (req, res) => {
    const { id } = req.params;
    const idNumber = Number(id) // 🟣 (설명1)
    if (!isNaN(idNumber)) { // 숫자형인지 확인 
    const filteredDiscussionsById = discussionsData.filter((discussion) => discussion.id === idNumber ) 
    if (filteredDiscussionsById.length === 0) { // 유효한 id지만 필터링된 데이터가 없는 경우,
      return res.status(404).json(`There is no posting by ID (${idNumber}) you were searching for.`) 
      } 
    return res.status(200).json(filteredDiscussionsById) }  // 데이터가 있으면 보내준다.
    else { return res.status(400).json(`Invalid ID: ${id}`) }  // 숫자형이 아닌 경우
  },

  // 🟡 [POST] /discussions 요청을 수행
  create: (req, res) => {
    const discussionInfo = {...req.body, "id" : Math.random()}
    discussionsData.unshift(discussionInfo)
    return res.status(201).json(discussionInfo)  // 🟣 (설명2)
  },

  // 🟡 [PUT] /discussions/:id 요청을 수행
  update: (req, res) => {
    const {id} = req.params;
    const idNumber = Number(id)
    const bodyData = req.body; // 업데이트 희망하는 데이터

    if (id) {
      const filteredDiscussionsById = discussionsData.filter((discussion) => discussion.id === idNumber ) // 찾음 
      if (filteredDiscussionsById.length === 0) {
        return res.status(404).json(`there is no posting by Id(${id}) you were searching for`)
      }
      filteredDiscussionsById[0] = {...filteredDiscussionsById[0], ...bodyData}
      return res.status(200).json(filteredDiscussionsById[0])  // 업데이트 
    } 
  },

  // 🟡 [DELETE] /discussions/:id 요청을 수행
  deleteByPostingId: (req, res) => {
    const {id} = req.params;
    const idNumber = Number(id)
    if (id) {
      const filteredDiscussionsById = discussionsData.filter((discussion) => discussion.id === idNumber ) // 찾음 
      if (filteredDiscussionsById.length === 0) {
        return res.status(404).json(`there is no posting by Id(${id}) you were searching for`)
      }
      discussionsData = discussionsData.filter((discussion) => discussion.id !== idNumber )
      return res.status(200).json(filteredDiscussionsById[0])  // 업데이트 
    } 
  }

};

module.exports = {
  discussionsController,
};

 

🟣 (설명 1) const idNumber = Number(id)

  • HTTP 요청의 URL에서 id 값은 문자열(String) 타입으로 전달된다. 
  • const filteredDiscussionsById = discussionsData.filter((discussion) => discussion.id === id ) 이렇게 하면 filteredDiscussionsById 배열이 빈 배열([])이 된다.
  • 이 문제를 해결하려면 id 변수를 숫자형으로 변환하고 filteredDiscussionsById 배열을 필터링하면 된다.

 

🟣 (설명 2.. 이라기보단 반성) return res.status(201).json(discussionInfo)

  • 사건의 발단은... 뭔가 더 친절하게 응답을 보내주고 싶었다..!!! (절대 따라하지 마시오..)
  • res.status(201).json(`Successfully uploaded : ${discussionInfo}`)와 같이 문자열 템플릿을 사용하여 보내보았다
    -> discussionInfo 변수가 객체(Object)라서 다른 경우와는 달리 [object Object]와 같은 문자열로 출력되었다.
  • 해결 방법으로는 discussionInfo 객체를 JSON 문자열로 변환하여 반환하는 방법이 있었다. 아래와 같이 수정하니 되었다. 
return res.status(201).json(`Successfully uploaded : ${JSON.stringify(discussionInfo)}`)

 

응답 메시지는 성공. 하지만 그 뒤의 GET 요청 실패.

 

  • 하지만 객체도, 문자도 아닌 요상한 형태로 출력이 되었고 (당연하지만....) 그 뒤의 GET 요청이 실패되는 모습이다.

 


 

클라이언트 (수정된 부분만 작성)

 

1. fetch로 GET 요청: 전체 데이터 조회 (read)

  • fetch 메소드로 데이터를 받아온 후에 로컬 스토리지에 저장하고 이후에 데이터를 사용할 때, 로컬 스토리지에서 데이터를 가져오는 것이 좋다. 이러한 방식으로, 데이터를 한 번 받아온 후에는 다음 번 데이터를 사용할 때 fetch 메소드를 다시 호출할 필요가 없어서 네트워크 대역폭을 절약할 수 있다.
  • 먼저 fetch() 함수를 사용하여 데이터를 가져온 후에, res.json() 메서드를 사용하여 데이터를 JSON 형식으로 변환한다. 그 다음, then() 메서드를 사용하여 agoraStatesDiscussions 변수에 데이터를 할당한다.
  • 마지막으로 localStorage.setItem() 메서드를 사용하여 agoraStatesDiscussions 변수를 로컬 스토리지에 문자열 형식으로 저장한다. 이 때, JSON.stringify() 함수를 사용하여 객체를 문자열로 변환한다.
  • 만약 에러가 발생하면, catch() 메서드를 사용하여 에러를 처리할 수 있다. 
let data;  // 1라인에 선언 
.
.
. // 게시판 데이터를 DOM으로 바꿔주는 함수 (이전 글 참고)
. // 페이지 렌더링하는 함수 (이전 글 참고)
.
.
const dataFromLocalStorage = localStorage.getItem("agoraStatesDiscussions"); 
if (dataFromLocalStorage) {
    data = JSON.parse(dataFromLocalStorage)
  } else {
  }
  fetch(`http://localhost:4000/discussions`)
    .then(res => res.json())
    .then(res => {
      data = res;
      render(ul, 0, limit);                    
      localStorage.setItem('agoraStatesDiscussions', JSON.stringify(data)); // 받아와서 바로 localStorage에 저장   
      console.log(dataFromLocalStorage)
    })
      .catch(error => {
        console.error('Error fetching discussions', error);
      });

 

  • HTTP 프로토콜에서 요청을 보내기 위해서는 반드시 요청 메소드(Method)를 지정해주어야 한다. (GET, POST, PUT, DELETE 등)
  • 기본적으로 fetch 함수에서는 요청 메소드를 지정하지 않으면 기본값으로 GET 요청을 보내게 된다. 
  • 아래는 받아오자마자 localStorage에 저장되어 있는 모습이다.

 

2. fetch로 POST 요청 : 새로운 글 생성 (create)

 const submitBtn = document.querySelector("button.submit")
submitBtn.addEventListener('click', submitContentResult)

function submitContentResult(event) {  // '글 등록' 버튼 누를 때 fetch POST 요청을 보낸다.
 .
 . // 이전 글 참고
 .
 .
 let newOne = {                            // POST에 보낼 글 정보를 newOne에 담아준다.
    createdAt: new Date().toISOString(),
    title: userTitle ,
    author: userName,
    bodyHTML: userStory }
 .
 .
 fetch(`http://localhost:4000/discussions`, {
  method: 'POST',
  body: JSON.stringify(newOne),            // body에 JSON문자열로 변환한 newOne을 담는다.
  headers: { 'Content-Type' : 'application/json' } })  // 🟣 (설명 1) 
  .then(res => res.json())
  .then(res => {
    console.log(res)
    data.unshift(res);                                                     // 데이터에 새 정보(객체) 넣어준다.
    localStorage.setItem("agoraStatesDiscussions", JSON.stringify(data));  // 업데이트된 localStorage
    render(ul, 0, limit);
  }) 
.
.
.

 

localStorage에 저녁 메뉴와(맞다,, 배고프다) 관련된 새로운 글이 저장되었다.

 

console에도 요청에 대한 응답이 잘 찍혔다.

 

🟣 (설명 1) 서버에서 요청에 필요한 헤더를 확인하는 방법

  • 일반적으로 GET 요청 시에는 요청 본문이 없기 때문에 Content-Type 헤더를 설정할 필요가 없다. (그러나, 몇몇 서버에서는 GET 요청에도 Content-Type 헤더를 필요로 할 수 있다.)
  • 따라서, 요청을 보내기 전에 해당 서버에서 원하는 메소드를 요청 시 Content-Type 헤더를 요구하는지 확인하는 것이 좋다. 만약 서버에서 해당 헤더를 요구한다면, 요청을 보낼 때 Content-Type 헤더를 설정해주어야한다.

    • 서버에서 요청 시 필요한 헤더를 확인하는 가장 좋은 방법은 해당 API의 문서를 참조하는 것이다. 대개의 경우, API 문서에서는 서버에서 요청에 필요한 헤더에 대한 정보를 제공한다.
    • 만약 API 문서가 없거나, 요청을 보낼 서버에 대한 정보가 없는 경우, 개발자 도구(Network 탭)를 사용하여 요청에 대한 정보를 확인할 수 있다. Headers 탭에서는 요청 헤더와 응답 헤더 모두를 확인할 수 있다. 요청을 보내기 전에 필요한 헤더를 확인하고 설정해주면 올바른 요청을 보내어 서버에서 정상적인 응답을 받을 수 있다.

      1. [크롬 브라우저 기준으로 개발자 도구 열기]
        • 크롬 브라우저에서 F12 키를 누르거나, 브라우저 우측 상단의 세로 점 3개 아이콘을 클릭한 후 "More tools" > "Developer tools"를 선택
      2. [Network 탭 선택]
        • 개발자 도구 창에서 Network 탭을 클릭
      3. [요청 보내기]
        • 요청을 보내고자 하는 웹사이트로 이동 (예: http://localhost:4000)
        • 해당 웹사이트에서 요청을 보내는 기능(예: 로그인, 검색, 게시물 등록)을 실행
      4. [요청 정보 확인]
        • Network 탭에서 요청이 기록된 항목을 클릭
        • Headers 탭을 클릭하여 요청에 대한 정보를 확인 (이 때, 서버에서 필요로 하는 헤더가 있는지 확인)

 

3. fetch로 DELETE 요청 : 특정 글 삭제 (delete)

 

잘못 쓴 코드 (삭제 버튼이 렌더링 후 1회만 실행되었다.)
  • 날짜 span 태그 옆에 휴지통 아이콘을 넣어 이벤트를 걸어주었다.
  • querySelectorAll을 사용하여 모든 i.fa-trash-can 요소를 선택한 다음, 이들에 대한 각각의 이벤트 리스너를 추가해야 한다.
  • forEach 메서드를 사용하여 각각에 대한 이벤트 리스너를 추가한다.
  • (이벤트 핸들러 함수 deleteResult에서는 event.target 대신 this를 사용하여 클릭된 요소를 참조할 수 있다.)
  • 하지만 작동이 되지 않았다!!!
const deleteBtn = document.querySelectorAll("i.fa-trash-can")

  deleteBtn.forEach(deleteBtn => {
    deleteBtn.addEventListener('click', deleteResult);
  });

function deleteResult(event) {
    let id = event.target.parentElement.parentElement.children[0].children[1].textContent; 
   fetch(`http://localhost:4000/discussions/${id}`, {
   method: 'DELETE'}) 
   .then(res => res.json())
   .then(res => console.log(res)) 
   data = data.filter((posting) => posting.id !== Number(id) )
   localStorage.setItem("agoraStatesDiscussions", JSON.stringify(data)); 
   render(ul, 0, limit);
   }

 

다시 짠 코드 (이벤트가 한 번만 발생하는 문제 해결)
  • 이벤트가 한 번만 발생한 이유는, 이벤트 리스너가 추가되는 요소가 동적으로 생성되었기 때문이다.
  • 만약, 이벤트 리스너를 추가하는 요소가 나중에 동적으로 추가된다면, 초기에 존재하지 않았던 요소에 대해서는 이벤트 핸들러가 등록되지 않는다.
  • 이 경우에는 이벤트 위임(Event Delegation)을 사용하여 해결할 수 있다. 이벤트 위임은 이벤트를 처리하기 위해 이벤트를 수신 대상의 조상 요소에게 위임하는 방법이다.

    • 예를 들어, deleteBtn을 감싸는 부모 요소가 있다면, 이벤트를 이 부모 요소에서 처리하도록 하면 된다. 부모 요소에 대한 이벤트 핸들러를 추가하고, 클릭된 요소의 타입을 확인하여 필요한 작업을 수행하면 동적으로 추가된 요소에 대해서도 이벤트를 처리할 수 있다.
    • 아래와 같이 deleteBtn을 감싸는 부모 요소이며, 동적으로 구현되지 않는 ul에 이벤트 위임을 구현할 수 있다.
    • 이벤트가 발생한 요소가 fa-trash-can 클래스를 가지고 있는 경우에만 필요한 작업을 수행한다. 따라서 ul 요소에 추가되는 모든 자식 요소 중에서 fa-trash-can 클래스를 가진 요소가 클릭되면 이벤트 핸들러가 실행된다.

 

const ul = document.querySelector('ul');  // 위에서 렌더링을 위해 미리 해놨다.

.
.
.

const deleteBtn = document.querySelectorAll("i.fa-trash-can")

ul.addEventListener('click', (event) => {
  // 🟣 (설명1)
  if (event.target.classList.contains('fa-trash-can')) { 
    // 🟣 (설명2)
    const id = event.target.parentElement.parentElement.children[0].children[1].textContent;
    fetch(`http://localhost:4000/discussions/${id}`, {
      method: 'DELETE',
      headers: { 'Content-Type' : 'application/json' }
    })
    .then(res => res.json())
    .then(res => console.log(res));
    
    data = data.filter((posting) => posting.id !== Number(id) );
    localStorage.setItem("agoraStatesDiscussions", JSON.stringify(data));
    render(ul, 0, limit);
  }
});

 

🟣 (설명 1) 부모 요소에게 이벤트 위임했을 때 조건문 처리

  •  if (event.target.classList.contains('fa-trash-can'))
  • 위 코드는 'fa-trash-can' 클래스(휴지통 아이콘)를 가지고 있는 요소를 클릭했을 때만 실행되도록 이벤트를 필터링하는 조건문으로 ul 속의 다른 요소를 클릭하면 조건문 안의 코드는 실행되지 않는다.
  • 만약 의도하지 않은 요소에도 'fa-trash-can' 클래스가 추가된다면, 해당 요소를 클릭했을 때도 불필요한 이벤트가 발생할 수 있다.

 

🟣 (설명 2) event와 target으로 부모 요소와 자식 요소에 접근하기

  • 부모 요소의 id에 접근하기 : event.target.parentElement.id
  • 첫 번째 자식의 class에 접근하기 : event.target.children[0].class

휴지통 아이콘을 클릭하면 해당 글의 id를 확인해서 글을 삭제하고 싶었다.

  • 아래와 같이 머나먼 여정을 떠났다...
  • 휴지통 아이콘의 부모의 부모 엘리먼트의 0번째 자식의 1번째 자식이 가지고 있는 textContent(ID) 

 

const id = event.target.parentElement.parentElement.children[0].children[1].textContent;

 

div 속성에 dataset을 넣어서 활용하는 것이 더 나아보인다..

 

 

더 좋은 방법을 알게 된다면 수정할 예정...!


 

간단한 테스트까지 해보면 끝!

 

 

테스트 

  클라이언트 서버
유닛 테스트 컴포넌트 테스트  (1년 내외로 쓸 거면) 하나의 API를 테스트
통합 테스트 하나의 페이지, 여러 컴포넌트, states props
-> 트윗 등록 시 화면이 변경되어야 한다.
MVC / Layer AR.. / API, DB, 엔티티..
E2E 테스트
(기능 테스트)
 end to end .. 사용자~ Postman

 

Postman을 적극 이용하자!

GET
POST
PUT

 

 

728x90
⬆︎