-
Notifications
You must be signed in to change notification settings - Fork 5
라이브러리 검토
현재 여행 계획에 대한 데이터 구조가 매우 복잡한 상태 입니다. (3중으로 중첩된 객체 구조)
// example
{
"days": [
{
"places": [
{
"placeName": "잠실한강공원",
"position": {
"lat": "37.5175896",
"lng": "127.0867236"
},
"todos": [
{
"content": "함덕 해수욕장 산책",
"isChecked": true
}
]
}
]
}
]
}
todos 내 todo의 content를 변경하는 과정에서 너무 많은 선작업들이 있어 번거롭다고 느끼게 되었습니다.
const onChangeContent = ({
content,
dayIndex,
placeIndex,
todoId,
}: {
content: string;
dayIndex: number;
placeIndex: number;
todoId: string;
}) => {
setTravelPlanDays((prevTravelPlansDays) => {
// 1. 이전 여행 계획 일정의 얕은 복사본 생성
const newTravelPlans = [...prevTravelPlansDays];
// 2. 지정된 일자와 장소에 해당하는 place 객체 찾기
const place = newTravelPlans[dayIndex]?.places[placeIndex];
// 3. place 객체나 todos 배열이 없으면 이전 상태 반환
if (!place?.todos) return prevTravelPlansDays;
// 4. todos 배열에서 지정된 todoId와 일치하는 todo의 인덱스 찾기
const todoIndex = place.todos.findIndex((todo) => todo.id === todoId);
// 5. 일치하는 todo가 없으면 이전 상태 반환
if (todoIndex === -1) return prevTravelPlansDays;
// 6. todos 배열을 새로 매핑하여 지정된 todo의 content 업데이트
place.todos = place.todos.map((todo, index) =>
index === todoIndex
? {
...todo,
content: content.slice(
FORM_VALIDATIONS_MAP.title.minLength,
FORM_VALIDATIONS_MAP.title.maxLength,
),
}
: todo,
);
// 7. 업데이트된 여행 계획 일정 반환
return newTravelPlans;
});
};
따라서 이러한 번거로움을 위해, 데이터 구조 변경의 필요성을 느끼게 되었습니다.
https://react.dev/learn/updating-objects-in-state#write-concise-update-logic-with-immer에 따르면 복잡한 객체 구조에 대해 use-immer를 사용하도록 권장하고 있습니다.
use-immer로 변경한 onChangeContent는 아래와 같습니다!
const onChangeContent = ({
content,
dayIndex,
placeIndex,
todoId,
}: {
content: string;
dayIndex: number;
placeIndex: number;
todoId: string;
}) => {
setTravelPlanDays((previousTravelPlanDays) => {
// todo로 바로 접근
const todo = previousTravelPlanDays[dayIndex]?.places[placeIndex]?.todos?.find(
(todo) => todo.id === todoId,
);
// todo가 존재하면 todo.content에 content 추가
if (todo) {
todo.content = content.slice(
FORM_VALIDATIONS_MAP.title.minLength,
FORM_VALIDATIONS_MAP.title.maxLength,
);
}
});
};
장점은 아래와 같다고 느꼈습니다.
- 간결한 상태 업데이트 로직
- 복잡한 중첩 객체나 배열을 직접 수정하는 것처럼 코드를 작성할 수 있다.
- 스프레드 연산자나 Object.assign() 등으로 복사하는 코드를 줄일 수 있다.
- 가독성
- 상태 업데이트 로직이 더 직관적이고 명확해진다.
- 불변성 자동 유지
- Immer가 내부적으로 불변성을 보장하므로, 휴먼 에러를 줄일 수 있다.
- 백엔드 5, 프론트엔드 3인 상황에서 프론트엔드가 기능 구현이 뒤쳐지는 상황입니다.
- 프론트엔드가 기능 개발을 위해 시간 투자를 많이 해야합니다.
- 현재 팀에서 스프린트 구현이 완료 된 상태에서, 인증 & 인가까지 추가해야 합니다.
-
인가 처리가 필요한 부분
- 여행기 등록, 여행 계획 조회, 여행 계획 등록 - 추후 각 기능의 수정 & 삭제에 대해서도 인가 처리가 필요
-
그래도 라이브러리를 최소화 하기 위해 APIClient 객체를 어떻게든 재활용해보려고 했지만 만약 재활용하게 된다면 기능 개발에 제약이 있을거 같다고 생각했습니다.
이유는 아래와 같습니다.
-
APIClient
import HTTPError from "@errors/HTTPError"; export default class APIClient { static validateResponse(response: Response, errorMessage: string) { if (!response.ok) { throw new HTTPError(response.status, errorMessage); } } static async get<T>(endpoint: string, headers: Record<string, string> = {}, body?: T) { return this.request<T>("GET", endpoint, body ?? null, headers); } static async post<T extends Record<string, unknown>>( endpoint: string, body?: T, headers: Record<string, string> = {}, ) { return this.request<T>("POST", endpoint, body ?? null, headers); } static async patch<T extends Record<string, unknown>>( endpoint: string, body?: T, headers: Record<string, string> = {}, ) { return this.request<T>("PATCH", endpoint, body ?? null, headers); } static delete(endpoint: string, headers: Record<string, string> = {}) { return this.request("DELETE", endpoint, null, headers); } static async request<T>( method: "GET" | "DELETE" | "PATCH" | "POST", endpoint: string, body: T | null, headers: Record<string, string> = {}, ): Promise<Response> { const url = `${process.env.REACT_APP_BASE_URL}${endpoint}`; const options = { method, headers: { "Content-Type": "application/json", ...headers, }, body: body ? JSON.stringify(body) : null, }; const response = await fetch(url, options); return response; } }
여기서 만약 인증 처리가 필요하다면 다음과 같은 선택지가 있다고 생각했습니다.
- 별도의 AuthAPIClient 추가
- APIClient에 type 추가
1번의 경우 코드 중복이 너무 심각할 것이라고 생각했고, 2번의 선택지로 코드를 구성해보았습니다.
-
code
// ... export default class APIClient { // ... static async get<T>( // ... isAuth?: boolean, ) { return this.request<T>("GET", endpoint, body ?? null, headers, isAuth); } static async post<T extends Record<string, unknown>>( // ... isAuth?: boolean, ) { return this.request<T>("POST", endpoint, body ?? null, headers, isAuth); } static async patch<T extends Record<string, unknown>>( // ... isAuth?: boolean, ) { return this.request<T>("PATCH", endpoint, body ?? null, headers, isAuth); } static delete(endpoint: string, headers: Record<string, string> = {}, isAuth?: boolean) { return this.request("DELETE", endpoint, null, headers, isAuth); } static async request<T>( // ... isAuth?: boolean, ): Promise<Response> { // ... const getToken = () => localStorage.getItem("tourootAccessToken"); // ... const options = { method, headers: { ...headers, ...(isAuth ? { Authorization: `Bearer ${getToken()}` } : {}), }, body: body ? JSON.stringify(body) : null, }; } }
// non-auth const response = await APIClient.get('/some-endpoint'); // auth const response = await APIClient.get('/some-endpoint', {}, {}, true);
2번의 경우에도 isAuth를 별도로 개발자가 추가해줘야하는 단점이 있으며 휴먼 에러의 가능성이 발생할 수 있다고 생각했습니다. (만약 params를 객체 형태로 변경해도 마찬가지라고 생각합니다.)
현재 로직 뿐만 아니라 추후 accessToken 만료 시 refresh token 로직을 apiClient에 추가해야하는 상황인데, 이 로직 추가 자체가 러닝 커브가 많이 높을 것이라고 팀원 들과 판단하게 되었습니다.
- authClient(인증 & 인가 처리)와 client 객체를 구분 지을 예정입니다.
- authClient의 경우 interceptor를 추가할 예정입니다.
- accessToken을 Authorization 헤더에 추가
- accessToken 만료시 refresh token을 통해 accessToken 재 발급
- api error 발생 시 모니터링 도구를 통해 logging 하는 것 추가 및 httpError 추가
2번의 경우 모두 interceptor 내부에서 처리 가능하므로 개발자는 authClient만 사용하면 내부에서 자동으로 처리하여 기능 개발에 집중이 가능할 것 같다고 생각했습니다.
팀원들이 별도로 신경 쓸 필요 없이 내부에서 자동으로 처리하게 함으로써, 기능 개발에 집중할 수 있는 것을 기대하고 있습니다 🙂