2024-06-14 프로젝트 관리 중인 데이터를 json-server로 RESTful하게 리팩토링 하기
상황
현재 본인의 프로젝트는 App 컴포넌트에서 expeness라는 상태를 만들고 여기에 json 형태의 데이터를 입력하여 이를 컴포넌트에 props를 내려 디테일 페이지에서 렌더링 하고 있다.
function App() {
const [expenses, setExpenses] = useState([
{
id: "59454ecd-0f61-422a-89d9-3213915343f2",
month: 1,
date: "2024-01-05",
item: "식비",
amount: 100000,
description: "세광양대창",
},
{
id: "4f60bace-03dc-458d-b0dc-d89ada034b29",
month: 1,
date: "2024-01-10",
item: "도서",
amount: 40500,
description: "모던 자바스크립트",
},
{
id: "34e14f86-1b9d-462d-af79-6dd9b5d1fcc5",
month: 2,
date: "2024-02-02",
item: "식비",
amount: 50000,
description: "회식",
},
{
id: "52f8e60d-5998-4f82-961d-4ab0cb3f26b1",
month: 2,
date: "2024-02-02",
item: "간식",
amount: 500,
description: "아이스크림",
},
{
id: "e678e3f4-5aa1-4ccd-a1c7-86e839c4ac9e",
month: 2,
date: "2024-02-02",
item: "여행",
amount: 1055000,
description: "일본여행",
},
{
id: "c9caf250-8c8a-4dde-9f0e-b86e72cbaad2",
month: 2,
date: "2024-02-02",
item: "미용",
amount: 155000,
description: "미용실",
},
{
id: "b0247fe5-7d54-45fe-9945-7f8687b0ded5",
month: 2,
date: "2024-02-02",
item: "도서",
amount: 95000,
description:
"자율주행차량 운전주행모드 자동 전환용 인식률 90% 이상의 다중 센서 기반 운전자 상태 인식 및 상황 인식 원천 기술 개발",
},
]);
json server 셋업하기
1. 패키지 설치하기
yarn add -D json-server
2. package.json에 서버 실행 명령어 추가하기
// package.json
"scripts": {
"json": "json-server --watch db.json --port 5011"
}
위 명령어를 입력하면 앞으로 서버를 열 때 yarn json만 입력하면 json server가 가동된다.
3. db.json 작성하기
{
"expenses": [
{
"id": "46869445-5f9c-4650-941f-41bd9457bc07",
"month": 1,
"date": "2024-01-03",
"item": "개인사업",
"amount": 1000000,
"description": "테슬라 모델 3",
"createdBy": "Musk"
},
{
"id": "a5c0d8de-d010-4d07-9934-29765051a7ea",
"month": 1,
"date": "2024-01-07",
"item": "개인사업",
"amount": 1000,
"description": "X(구 트위터)",
"createdBy": "Musk"
},
{
"id": "0d0a966e-5c35-4bf0-aca4-f2b1265a432d",
"month": 1,
"date": "2024-01-13",
"item": "식비",
"amount": 2000,
"description": "메가커피",
"createdBy": "Rosie"
},
{
"id": "9aba6d72-5ee7-4f02-a4d4-e8bbff9ded4b",
"month": 1,
"date": "2024-01-17",
"item": "식비",
"amount": 21000,
"description": "대광참치",
"createdBy": "Rosie"
},
{
"id": "b82b213e-8164-4c89-94b0-7246cdcfc4f9",
"month": 1,
"date": "2024-01-21",
"item": "식비",
"amount": 412000,
"description": "BBQ",
"createdBy": "Me"
},
{
"id": "d180194f-7c27-428a-8cd9-ad9114e58821",
"month": 1,
"date": "2024-01-22",
"item": "도서",
"amount": 23000,
"description": "리액트 마스터",
"createdBy": "Me"
},
{
"id": "0abf06c3-8f0e-4ea1-9691-7facab0a91cb",
"month": 1,
"date": "2024-01-30",
"item": "외식",
"amount": 100000,
"description": "세광양대창",
"createdBy": "Me"
}
]
}
미리 준비된 fake data이다.
json server의 장점은 테스트 목적이긴 하지만 RESTful한 API 요청을 해볼 수 있다는 것이다.
위 db는 "expenses"라는 key에 배열이 담겨 있는 형태이므로, API 요청을 할 때 엔드포인트는 /expenses가 된다.
이를 RESTful하게 API 요청을 보낸다고 하면 아래와 같이 정리할 수 있다.
- GET /expenses : 모든 비용 항목 가져오기
- GET /expenses/{id} : 특정 ID를 가진 비용 항목 가져오기
- POST /expenses : 새로운 비용 항목 생성
- PUT /expenses/{id} : 특정 ID를 가진 비용 항목 전체 수정
- PATCH /expenses/{id} : 특정 ID를 가진 비용 항목 부분 수정
- DELETE /expenses/{id} : 특정 ID를 가진 비용 항목 삭제
fetch 대신 axios로 API 요청 보내기
패키지 설치하기
yarn add axios
api.js 파일 생성하기
필요한 곳에서 바로 axios 함수를 호출해도 되지만, 조금 더 재사용성을 높인 사용을 연습하기 위해 axios instance와 interceptors를 사용해보도록 하겠다.
일단 GET 요청부터 잘 되는지 보기 위해 인터셉터는 제외하고 기본 사항만 인스턴스로 작성한다.
import axios from "axios";
export const jsonApi = axios.create({
baseURL: 'http://localhost:5011',
timeOUT: 5000,
headers: {'Content-Type': 'application/json'}
})
export default 하지 않은 이유는 내 프로젝트에서 이 API 주소만 있는 것이 아니라 토큰 인증 서버도 있기 때문이다. 일단 json server 부터 처리 후 추후 작성하고자 한다.
headers의 내용은 jsonApi 인스턴스를 사용한 API 요청에서는 json 형태로 데이터를 받아오겠다는 의미이다.
최상단 컴포넌트에서 GET 요청 응답 받기
일단 json 서버의 데이터를 사용할 것이기에 기존 상태를 제거한다.
GET 요청을 보내 받은 데이터를 저장할 expenses 상태를 작성하고, 세트 메뉴처럼 로딩 상태와 에러 상태도 같이 초기화해준다.
로딩과 에러 상태가 왜 필요한 지는 GET 요청을 보내는 로직을 보면 이해할 수 있다.
const [expenses, setExpenses] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect 훅을 통해 GET 요청을 보내서 응답받은 결과를 expenses 상태에 할당한다.
axios 또한 기본적으로 fetch 함수이기에 비동기로 작성하고, try...catch...finally문을 통해 무조건 실행할 것(try), 에러 처리 방법(catch), 뭐가 됐든 끝나고 해야할 것(finally)를 명시해준다.
useEffect(() => {
const fetchExpenses = async () => {
setLoading(true);
try {
const { data } = await jsonApi.get('/expenses');
setExpenses(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchExpenses();
}, []);
if (loading) return <div>로딩중입니다</div>;
if (error) return <div>에러 발생 : {error}</div>;
console.log('json 서버에서 불러온 데이터입니다요 =>', expenses);
useEffect 훅은 의존성 배열을 빈 배열로 둬서 컴포넌트가 최초 마운트 됐을 때 fetch 하도록 한다.
fetch가 모두 종료되면 로딩 상태를 false로 다시 돌려준다.
로딩 상태와 에러 상태는 필요 없으면 렌더링 하지 않아도 되지만 무작정 기다리는 사용자 경험을 피하기 위해서 현재 상태를 알려주는 것이다.
fetch가 시작되면 로딩을 true로, 종료되면 false로 돌려준다.
데이터가 잘 불러와진다.
Detail 컴포넌트에서 DELETE 요청 보내기
CRUD 중 R 다음으로 D 가 쉬운 것 같아 D 부터 리팩토링 해보겠다.
먼저 기존 코드에서는 삭제 버튼에 deleteExpense라는 함수가 이벤트 핸들러로 연결되어 있음을 확인했다.
<Button danger="true" onClick={deleteExpense}>
삭제
</Button>
// 삭제 함수
const deleteExpense = () => {
const newExpenses = expenses.filter((expense) => expense.id !== id);
setExpenses(newExpenses);
navigate("/");
};
json server 에서 DELETE 요청에 해당하는 RESTful한 axios 요청 방법은 아래와 같다.
const { data } = await axios.delete(`baseURL/expenses/${id}`);
그런데 이미 axios instance를 작성해두었으니, 이 함수를 교체해보도록 하자.
삭제 함수를 아래와 같이 작성한다.
const deleteExpense = async(id) => {
try {
await jsonApi.delete(`/expenses/${id}`);
setExpenses(prevExpenses => prevExpenses.filter(expense => expense.id !== id));
alert(`${description} 항목을 삭제하였습니다.`);
navigate('/');
} catch (error){
alert('삭제 과정에서 에러가 발생했습니다 : ' + error.message);
}
};
삭제 함수의 로직을 살펴 보자면...
- 함수가 호출되는 부분에서 id를 매개 변수로 받는다.
- try...catch문을 사용한다. finally를 사용하지 않은 이유는 로딩 처리를 할 필요가 없다고 느껴져서이다.
- 함수를 실행하면 비동기 함수로 jsonApi 인스턴스에 delete 요청을 보내고, 매개 변수로 받은 id를 파라미터로 넣는다.
- 그리고 삭제가 되면 expenses 상태를 바꾼다.
- 그냥 바꾸는 것이 아니라 expenses의 삭제 전 배열을 순회하면서 현재 매개 변수로 전달 된 지금 삭제하고자 하는 이 아이템의 id를 빼 놓고 나머지만 새로운 배열로 반환해서 exponses 상태 변경 함수에 전달한다.
- 그리고 삭제가 완료되었다는 alert을 띄운 후 홈으로 이동시킨다.
- description은 json server에 담겨 있는 객체들의 key 중 하나이고, item의 제목이자 내용이다.
- 어떻게 바인딩 해서 가져올 수 있었냐면 아래와 같이 사전에 상태로 정의해놓았기 때문이다.
이 상태들은 detail page에서 입력 필드를 관리하기 위해 만든 상태이다.
export default function Detail({ expenses, setExpenses }) {
const navigate = useNavigate();
const { id } = useParams();
const selectedExpense = expenses.find((element) => element.id === id);
const [date, setDate] = useState(selectedExpense.date);
const [item, setItem] = useState(selectedExpense.item);
const [amount, setAmount] = useState(selectedExpense.amount);
const [description, setDescription] = useState(selectedExpense.description);
...
<label htmlFor="description">내용</label>
<input
type="text"
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="지출 내용"
/>
그리고 이렇게 작성한 함수를 삭제 버튼에 onClick 이벤트 핸들러로 연결해준다.
중요한 것은 id를 매개 변수로 받아와야 한다는 것이다.
<Button danger="true" onClick={() => deleteExpense(id)}>
삭제
</Button>
버튼에서 id를 받아올 수 있는 이유는 react-router-dom에서 제공하는 useParams 훅을 사용해서 { id } 로 구조 분해 할당하고 있기 때문이다. 경로가 /detail/:id 이렇게 구성 되어 있다면 추출이 된다.
const { id } = useParams(); // URL에서 `id` 추출
이런 path를 페이지 라우팅 하는 부분에서 부여했기 때문에 가능하다.
<Route
path="/detail/:id"
element={<PrivateRoute element={<Detail expenses={expenses} setExpenses={setExpenses} />} />}
/>
CreateExpense 컴포넌트에서 POST 요청 보내기 (게시)
CRUD에서 Create를 리팩토링 해보고자 한다.
먼저 Create하고 있는 기존의 함수를 찾아 로직을 확인한다.
// CreateExpense.jsx
const handleAddExpense = () => {
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
if (!datePattern.test(newDate)) {
alert("날짜를 YYYY-MM-DD 형식으로 입력해주세요.");
return;
}
const parsedAmount = parseInt(newAmount, 10);
if (!newItem || parsedAmount <= 0) {
alert("유효한 항목과 금액을 입력해주세요.");
return;
}
const newExpense = {
id: uuidv4(),
month: parseInt(newDate.split("-")[1], 10),
date: newDate,
item: newItem,
amount: parsedAmount,
description: newDescription,
};
setExpenses([...expenses, newExpense]);
setNewDate(`2024-${String(month).padStart(2, "0")}-01`);
setNewItem("");
setNewAmount("");
setNewDescription("");
};
다소 복잡해 보이지만 하나씩 뜯어 보겠다.
- 유효성 검사
- newDate 상태에 받는 값이 YYYY-MM-DD가 맞는지 정규식으로 검증하고, 통과하지 못하면 alert창을 띄우고 함수를 종료한다.
- newAmount 상태에 받는 값을 paseInt 메서드를 통해 10진수 정수로 반환한 뒤 0보다 작거나, newItem에 값이 없으면 역시 함수를 종료 시킨다.
- newDescription(지출 내역)의 경우에는 유효성 검사를 따로 하지 않고 있다.
- 유효성 검사를 통과하면 입력필트에 작성한 내용을 newExpense라는 변수에 담는다.
- id는 uuid 라이브러리를 통해 고유한 값을 부여해주고 있다.
- 상태 변경
- setExpenses([...expenses, newExpense]) : 기존 expenses 상태의 불변성을 유지하면서 방금 만든 newExpense를 밀어 넣어 주고 있다.
- 그리고 나머지는 입력 필드를 비워주는 함수이다. data의 경우에는 원하는 형식이 있어서, 미리 값을 채워 넣는 것이다.
- 그리고 저장 버튼에 이벤트 핸들러로 함수를 연결 해주고 있다.
<AddButton onClick={handleAddExpense}>저장</AddButton>
이제 본격적으로 POST 함수를 작성하기 전 axios의 RESTful한 함수 호출 방법은 무엇인지 점검해보겠다.
const { data } = await axios.post(`baseURL/expenses`, newExpense);
리액트만으로 상태를 변경할 때는 불변성 유지를 해줘야 했지만 위 POST 요청의 경우에는, 새로운 객체를 하나 더 추가해주는 함수이기 때문에 불변성 유지를 따로 해주지 않아도 저절로 된다.
다만 렌더링은 새로 해야 하기 때문에 expensese 상태에는 불변성을 유지하며 newExpense를 추가해 주었다.
const [newDate, setNewDate] = useState(
`2024-${String(month).padStart(2, "0")}-01`
);
const [newItem, setNewItem] = useState("");
const [newAmount, setNewAmount] = useState("");
const [newDescription, setNewDescription] = useState("");
const handleAddExpense = async () => {
try {
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
if (!datePattern.test(newDate)) {
alert('날짜를 YYYY-MM-DD 형식으로 입력해주세요.');
return;
}
const parsedAmount = parseInt(newAmount, 10);
if (!newItem || parsedAmount <= 0) {
alert('유효한 항목과 금액을 입력해주세요.');
return;
}
const newExpense = {
id: uuidv4(),
month: parseInt(newDate.split("-")[1], 10),
date: newDate,
item: newItem,
amount: parsedAmount,
description: newDescription,
};
await jsonApi.post(`/expenses/`, newExpense);
setExpenses([...expenses, newExpense]);
alert(`${newItem} 항목을 등록하였습니다.`);
setNewDate(`2024-${String(month).padStart(2, "0")}-01`);
setNewItem("");
setNewAmount("");
setNewDescription("");
} catch (error) {
alert('등록 과정에서 에러가 발생했습니다 : ' + error.message);
}
};
기존의 유효성 검사 등은 그대로 작성한 뒤 try...catch 문으로 await jsonApi.post(`/expenses/`, newExpense) 만 추가해주었다.
로딩도 필요 없어 보이기 때문에 finally는 생략했다. 그리고 매개 변수로 받을 정보도 없기에 함수가 달려 있는 버튼도 건드리지 않았다.
Detail 컴포넌트에서 POST 요청 보내기 (수정)
업데이트는 또 디테일 페이지에서 입력 필드를 바로 수정해서 진행하고 있다.
기존의 로직을 살펴보면 아래와 같다.
const editExpense = () => {
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
if (!datePattern.test(date)) {
alert("날짜를 YYYY-MM-DD 형식으로 입력해주세요.");
return;
}
if (!item || amount <= 0) {
alert("유효한 항목과 금액을 입력해주세요.");
return;
}
const newExpenses = expenses.map((expense) => {
if (expense.id !== id) {
return expense;
} else {
return {
...expense,
date: date,
item: item,
amount: amount,
description: description,
};
}
});
setExpenses(newExpenses);
navigate("/");
};
역시 형식에 맞게 유혀성 검사를 진행한 후 통과하게 되면 newExpenses라는 새로운 배열을 만드는데, 여기에는 불변성을 유지하며 변화된 데이터만 두 번째 매개 변수로 전달하여 새로운 배열로 반환하고 있다.
이것을 다시 setExpenses 상태 변경 함수에 담아 렌더링 할 상태 expenses의 값을 변경하고 있다.
RESTful한 axios POST 요청 예제를 보겠다. (update)
const { data } = await axios.patch(`baseURL/todos/${id}`, { title: "수정된제목" });
이번에는 바꿀 아이템의 id를 매개 변수로 받아 와야 하니 수정 버튼을 연결할 이벤트 핸들러도 수정해주어야 할 것 같다.
리팩토링 된 코드는 다음과 같다.
const editExpense = async (id) => {
try {
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
if (!datePattern.test(date)) {
alert('날짜를 YYYY-MM-DD 형식으로 입력해주세요.');
return;
}
if (!item || amount <= 0) {
alert("유효한 항목과 금액을 입력해주세요.");
return;
}
const updatedExpense = {
...selectedExpense, // 원래의 모든 필드를 유지
date: date,
item: item,
amount: amount,
description: description,
};
await jsonApi.put(`/expenses/${id}`, updatedExpense);
setExpenses(expenses.map(expense => (expense.id === id ? updatedExpense : expense)));
alert(`${item} 항목을 수정하였습니다.`);
// 초기화
const month = today.getMonth() + 1; // 현재 월 가져오기
setNewDate(`2024-${String(month).padStart(2, "0")}-01`);
setNewItem("");
setNewAmount("");
setNewDescription("");
} catch (error) {
alert('수정 과정에서 에러가 발생했습니다 : ' + error.message);
}
};
코드의 흐름을 알아 보겠다.
- 함수명은 그대로 editExpense로 둔다.
- 유효성 검사 하는 부분도 그대로 둔다.
- 그리고 updatedExpense라는 변수를 새로 만든다. 여기는 불변성을 유지하면서 바뀐 부분만 탐지되어 들어가도록 구조 분해 할당하여 작성해준다. 그런데 이것은 배열 전체가 아니라, 지금 바꾸고 있는 하나의 아이템만 수정하는 내용이다.
- 여기까지 완료됐다면 이제 axios 인스턴스에 put 요청을 보낸다. 첫 번째 매개 변수로 바꿀 정보를 실어 보내면 되는데 기존의 정보는 ${id}라는 엔드포인트로 지정을 하고 있고, 두 번째 매개 변수로는 그 정보를 어떻게 수정할 것인지 실어서 보내주어야 한다. id까지 타고 들어가면 데이터 형식이 객체 형태로 되어 있을 테니, 두번째 매개 변수도 객체의 형태로 보내야 한다. 이것은 PUT 요청의 특성이고 PATCH 요청은 바뀐 정보만 실어 보내면 서버가 알아서 감지한다.
- PUT 요청이 싫다면 PATCH 요청으로 바꿔도 된다. axios 요청을 보내는 메서드와 매개 변수는 모두 같고, PUT 키워드만 PATCH로 바꿔 주면 된다. 그리고 두 번째 매개 변수에 바뀐 데이터만 담긴 변수를 보내주면 된다. 불변성을 유지해서 기존 데이터까지 실어 보낼 필요가 없다.
- 여기까지가 json server에서 데이터 수정이 완료된 것이다. 사용자 경험을 높이기 위해 alert 창 등으로 경과를 띄워주면 된다.
- 그리고 setState 함수를 이용해서 원본 배열 상태를 바꾸어 준다.
setExpenses(expenses.map(expense => (expense.id === id ? updatedExpense : expense)));
- 이 과정을 거치는 이유는, 서버에는 이미 데이터가 반영되었지만 사용자의 화면에서 데이터를 다시 바뀐 데이터를 포함해서 리렌더링 해줘야 하기 때문에 상태를 변경시키는 것이다. 홈으로 다시 나가면 서버에 GET 요청을 다시 보내 상태가 알아서 바뀌고 리렌더링도 잘 되기는 한다. 하지만 이 부분은 왜 필요한지 다양한 이유를 찾았는데 공감이 되지 않아 더 알아봐야겠다.
- 상태 변경을 어떻게 하냐면, expenses라는 기존 배열 상태를 반복 순회하면서 지금 현재 아이템의 id와 일치하는 항목을 찾아서 updatedExpense로 바꾸어 주고, 일치하는 게 없다면 그냥 현재 아이템을 그대로 유지하게끔 하는 로직이다.
'Programing > TIL' 카테고리의 다른 글
2024-06-16 (나의 생각) AI 시대와 개발자 (0) | 2024.06.16 |
---|---|
2024-06-15 TanStack Query로 서버 상태 관리하기 (0) | 2024.06.16 |
2024-06-13 axios, json-server, TanStack Query - RESTful (1) | 2024.06.13 |
2024-06-12 프론트 엔드 학습 가이드(로드맵) (1) | 2024.06.12 |
2024-06-11 JWT 회원가입 인증/인가 실습하기 (0) | 2024.06.11 |
댓글