1. fetch로 서버 연결
2. GET : 전체 데이터 조회
3. POST : 새로운 글 생성
4. DELETE : 특정 글 삭제
결과물
서버
서버 쪽은 정말 간단하게 끝났다... 재밌어!
폴더 구조
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 요청이 실패되는 모습이다.
클라이언트 (수정된 부분만 작성)
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);
})
.
.
.
🟣 (설명 1) 서버에서 요청에 필요한 헤더를 확인하는 방법
- 일반적으로 GET 요청 시에는 요청 본문이 없기 때문에 Content-Type 헤더를 설정할 필요가 없다. (그러나, 몇몇 서버에서는 GET 요청에도 Content-Type 헤더를 필요로 할 수 있다.)
- 따라서, 요청을 보내기 전에 해당 서버에서 원하는 메소드를 요청 시 Content-Type 헤더를 요구하는지 확인하는 것이 좋다. 만약 서버에서 해당 헤더를 요구한다면, 요청을 보낼 때 Content-Type 헤더를 설정해주어야한다.
- 서버에서 요청 시 필요한 헤더를 확인하는 가장 좋은 방법은 해당 API의 문서를 참조하는 것이다. 대개의 경우, API 문서에서는 서버에서 요청에 필요한 헤더에 대한 정보를 제공한다.
- 만약 API 문서가 없거나, 요청을 보낼 서버에 대한 정보가 없는 경우, 개발자 도구(Network 탭)를 사용하여 요청에 대한 정보를 확인할 수 있다. Headers 탭에서는 요청 헤더와 응답 헤더 모두를 확인할 수 있다. 요청을 보내기 전에 필요한 헤더를 확인하고 설정해주면 올바른 요청을 보내어 서버에서 정상적인 응답을 받을 수 있다.
- [크롬 브라우저 기준으로 개발자 도구 열기]
- 크롬 브라우저에서 F12 키를 누르거나, 브라우저 우측 상단의 세로 점 3개 아이콘을 클릭한 후 "More tools" > "Developer tools"를 선택
- [Network 탭 선택]
- 개발자 도구 창에서 Network 탭을 클릭
- [요청 보내기]
- 요청을 보내고자 하는 웹사이트로 이동 (예: http://localhost:4000)
- 해당 웹사이트에서 요청을 보내는 기능(예: 로그인, 검색, 게시물 등록)을 실행
- [요청 정보 확인]
- 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을 적극 이용하자!
728x90
'📌 TOY-PROJECT > 2303 문답게시판' 카테고리의 다른 글
[연습 프로젝트] 문답 게시판 _ pagination & local storage 코드 분석 (0) | 2023.03.18 |
---|---|
[연습 프로젝트] 문답 게시판_클라이언트 : 페이지네이션 등 (0) | 2023.03.16 |
[연습 프로젝트] 컴포넌트(component) 만들기 (0) | 2023.03.12 |
[연습 프로젝트] 나만의 아고라 스테이츠 만들기 (0) | 2023.03.12 |