* 케이스를 마주할 때마다 업데이트 할 예정

 

Frontend 에서 처리하기 용이하면서도 RESTful API 관점을 잃지 않고 HTTP 응답 상태 코드를 어떻게 약속할지에 대한 생각과 의견이다.

 


상태 코드 Case

1. GET 조회 시 빈 데이터

- customer/order/{userId} : userId 에 해당하는 order 리스트를 조회했는데, 해당 유저의 오더 건이 없는 경우

- product?word={text} : 쿼리로 조회시 검색 결과가 없는 경우

[응답]

status : 200
response : {
	result : true,
	data: []
}

 

[추가 고려사항]

- result : true / false 의 용도를 어떻게 정의해야 하는가?

- 사실상 result : false 인 케이스는 없지 않을까? 필요하긴 한가? -> 일단 현재는 필요없다고 생각함

 

[UI]


2. GET 조회 시 존재하지 않는 데이터 (자원이 없음)

- product/{productId} : productId 로 조회하는 페이지에서 해당 ProductId 가 없는 경우

[응답]

status: 404
message : "존재하지 않는 제품입니다."

 

[UI]

 

[Other Case]

- 500 에러로 리턴 : 에러코드의 의미가 맞지 않음

status: 500
message : "존재하지 않는 제품입니다."

 

- 200에 result: false 로 리턴 : 해당 케이스는 성공이 아님

status: 200
response : {
	result: false,
    status: 404,
    message: "존재하지 않는 제품입니다."
    subCode: "NOT_FOUND"
}

3. PUT/DELETE 수정/삭제시 없는 데이터를 수정/삭제 하려는 경우 (자원이 없음)

[응답]

status: 404
message : "존재하지 않는 제품입니다."

 

[UI]

- 사이트 내부적으로 사용하는 alert 를 띄우고, 요청내용이 처리되지 않도록 처리, 이전 페이지로 이동

 

[Other Case]

- 500 에러로 리턴 : 에러코드의 의미가 맞지 않음

status: 500
message : "존재하지 않는 제품입니다."

 

- 200에 result: false 로 리턴 : 해당 케이스는 성공이 아님

status: 200
response : {
	result: false,
    status: 404,
    message: "존재하지 않는 제품입니다."
    subCode: "NOT_FOUND"
}

4. Client 의 잘못된 요청 - Bad Request

- 요청을 보내는 파라미터 값 (path, query, body) 이 유효하지 않은 경우

- Type 불일치, 필수값 누락 등

[응답]

status : 400
message: "[channelSapCode] : 공백일 수 없습니다"

 

[UI]

1. GET & 잘못된 queryKey : 메인 페이지로 리다이렉트


 

2. GET & 잘못된 queryKey : 빈 데이터와 동일하게 처리

- 사실상 올바른 queryKey 에는 "" 빈 스트링이 들어온 것으로 보고, 빈 데이터 결과를 리턴함

 


 

3. GET & queryKey 데이터 타입 불일치 : 에러 페이지로 리턴

https://www.airbnb.co.kr/?monthly_start_date=2024-03-01

 

- 아래와 같이 date 형식을 다르게 보냈을 때

https://www.airbnb.co.kr/?monthly_start_date=20240301


 

4. PUT/POST 에서 전달 하는 데이터의 형식이 올바르지 않는 경우

- 사실상 프론트와 백이 동일한 로직으로 걸러주어야 함

- 만에하나 백으로 넘어왔을 경우, 사이트 내부적으로 사용하는 alert 를 띄우고, 요청내용이 처리되지 않도록 처리

 


 

  • setState 업데이트는 async 작업이다
    • 연속적으로 빠르게 도는 경우 모든 update를 보장할 수 없다
    • immer 의 produce 함수 안에서 필요한 내용을 만들어서 setState 한다→ Functional update form of the state setter

 

Bad Case

// 1. MLList 필터링 & 인덱스 찾기
const targetIdxList = checkedProductId.map((item) => {
  return productListData[productPackageIdx].MLList.findIndex((el) => el.productId === item);
});

// 2. 인덱스 맵 돌려서 값 변경
targetIdxList.forEach((itemIdx) => {
  if (itemIdx >= 0) {
    const nextState = produce(productListData, (draftState) => {
      draftState[productPackageIdx].MLList[itemIdx].plpSize = e.target.value;
    });
    setProductListData(nextState);
  }
});

 

 

화면

  • 타겟 idx 를 찾아서 for 문을 돌리지만, 맨 마지막 인덱스값만 “L” 로 변경이 되고, 이전 값들은 변경이 되지 않는다
  • BEFORE

  • AFTER

 

 

Good Case

  • 함수형으로 생성하여 update 한다
// Combine both updates into a single update
const combinedStateUpdate = produce(nextState, (draftState) => {
  // 1. MLList 필터링 & 인덱스 찾기
  const targetIdxList = checkedProductId.map((item) => {
    return draftState[productPackageIdx].MLList.findIndex((el) => el.productId === item);
  });
  // 2. 인덱스 맵 돌려서 값 변경
  targetIdxList.forEach((itemIdx) => {
    if (itemIdx >= 0) {
      draftState[productPackageIdx].MLList[itemIdx].plpSize = e.target.value;
    }
  });
});

setProductListData(combinedStateUpdate);

1. 수동으로 새로고침한 경우에만 특정 함수를 trigger 시키는 hook(url Params 삭제)

 

useDeleteUrlParamsWhenManualRefresh.ts

  • 코드
// input 삭제할 url parms key list

import { useEffect } from "react";
import { useSearchParams } from "react-router-dom";

export default function useDeleteUrlParamsWhenManualRefresh(paramsToDelete) {
  const [searchParams, setSearchParams] = useSearchParams();

  const setFirstLoadFlag = () => {
    localStorage.removeItem("firstLoadDone");
  };

  const deleteUrlParamsWhenManuallyRefreshed = () => {
    paramsToDelete.map((param) => {
      searchParams.delete(param);
      searchParams.delete(param);
    });
    setSearchParams(searchParams);
  };

  useEffect(() => {
    window.addEventListener("beforeunload", setFirstLoadFlag);
    if (localStorage.getItem("firstLoadDone") === null) {
      localStorage.setItem("firstLoadDone", "1");
      deleteUrlParamsWhenManuallyRefreshed();
    }

    return () => {
      window.removeEventListener("beforeunload", setFirstLoadFlag);
    };
  }, []);
}

 

 

MyComponent.tsx

  • 사용하는 컴포넌트 코드
// 수동 새로고침시 필터 세팅 url params 삭제
useDeleteUrlParamsWhenManualRefresh(["detailType", "detailText"]);

 

 

  • 화면

1. Form 의 required field 확인하는 hook

  • input
    • fromRef: 타겟 Form element
    • formDataState : 관리되는 form 데이터 state
    • submitFlag : 제출 버튼 눌렀는지 여부
  • output
    • isFormValidated : validation 통과 되었는지 boolean

 

useFormValidation.ts

  • 코드
import { useEffect, useState } from "react";
// input : formRef, 제출할 formData, submitFlag
// output : 통과 여부

export const useFormValidation = ({ formRef, formDataState, submitFlag }) => {
  const [isFormValidated, setIsFormValidated] = useState(false);

  useEffect(() => {
    if (submitFlag) {
      const inputs = [...formRef.current.elements];
      console.log("inputs", inputs);
      inputs.forEach((el) => {
        // input & select 만 체크 && // required 만 체크
        if (["SELECT", "INPUT", "TEXTAREA"].includes(el.tagName)) {
          if (!el.value && el.required) {
            el.classList.add("is-invalid");
            if (el.parentNode.lastChild.id !== "required-noti-text") {
              const requiredErrorTextDiv = document.createElement("div");
              requiredErrorTextDiv.innerHTML = "Required";
              requiredErrorTextDiv.style.cssText = "color: #E63757; font-size: 10px; margin-top: 0.25rem";
              requiredErrorTextDiv.id = "required-noti-text";
              el.parentNode.appendChild(requiredErrorTextDiv);
            }
          } else {
            el.classList.remove("is-invalid");
            const requiredErrorTextDiv = el.parentNode.querySelector("#required-noti-text");
            if (requiredErrorTextDiv) el.parentNode.removeChild(requiredErrorTextDiv);
          }
        }
      });
      // 성공 여부
      const invalid = document.querySelectorAll(".is-invalid");
      if (invalid.length === 0) setIsFormValidated(true);
    } else {
      // submitFlag false 일때 valid 결과 false 리턴
      setIsFormValidated(false);
    }
  }, [submitFlag, formDataState]);

  return { isFormValidated };
};

 

 

MyComponent.tsx

  • 사용하는 컴포넌트 코드
const [submitFlag, setSubmitFlag] = useState(false);
const formElement = useRef<any>(null);
const [resultData, setResultData] = useState({});

const { isFormValidated } = useFormValidation({
  formRef: formElement,
  formDataState: bannerDetailPutData,
  submitFlag,
});

const onClickSave = () => {
	setSubmitFlag(true);
	if (isFormValidated) {
	  productListPostMutate.mutate(resultData);
	}
}

return (
	<Form ref={formElement}>
		<Button type="button" onClick={onClickSave}>save</Button>
		...
		<Row className="mb-2">
	    <Col md={12} xs={12}>
	      <Form.Control
	        required
	        as="textarea"
	        rows={1}
	        style={{ resize: "none" }}
	        placeholder="Input Meta Title"
	        disabled={pageInfo === "detail" && !editFlag}
	        name="metaTitle"
	        value={el?.metaTitle || ""}
	        onChange={(e) => onChangeDragItem(e, idx)}
	      />
	    </Col>
	  </Row>
		...
	<Form>
)

1. Resize 될 때마다 현재 window 의 width가 breakpoint 보다 up 인지 down 인지 물어보는 hook

  • input : 없음
  • output
    • breakpoints : up / down 메소드를 가지고 있는 객체

 

useResizeBreakpoints.ts

  • 코드
import { useState, useEffect } from "react";

interface GridBreakpoints {
  xs: number;
  sm: number;
  md: number;
  lg: number;
  xl: number;
  xxl: number;
}

interface Breakpoints {
  up: (bp: keyof GridBreakpoints) => boolean;
  down: (bp: keyof GridBreakpoints) => boolean;
}

interface UseResizeBreakpointsResult {
  breakpoints: Breakpoints;
}

export const useResizeBreakpoints = (): UseResizeBreakpointsResult => {
  const gridBreakpoints: GridBreakpoints = {
    xs: 0,
    sm: 576,
    md: 768,
    lg: 992,
    xl: 1200,
    xxl: 1540,
  };

  const [width, setWidth] = useState<number>(window.innerWidth);
  const [height, setHeight] = useState<number>(window.innerHeight);

  const [breakpoints, setBreakpoints] = useState<Breakpoints>({
    up: (bp) => width >= gridBreakpoints[bp],
    down: (bp) => width < gridBreakpoints[bp],
  });

  const updateDimensions = () => {
    setWidth(window.innerWidth);
    setHeight(window.innerHeight);
  };

  useEffect(() => {
    window.addEventListener("resize", updateDimensions);
    return () => window.removeEventListener("resize", updateDimensions);
  }, []);

  useEffect(() => {
    setBreakpoints({
      up: (bp) => width >= gridBreakpoints[bp],
      down: (bp) => width < gridBreakpoints[bp],
    });
  }, [width]);

  return { breakpoints };
};

 

 

MyComponent.tsx

  • 사용하는 컴포넌트 코드
const { breakpoints } = useResizeBreakpoints();

...

return (
	...
	{breakpoints.down("sm") ? <div>SM 사이즈보다 작을때 </div> : <div>SM 사이즈보다 클때 </div>}
	...
)

 

  • 화면
    • window 사이즈가 breakPoint 에서 sm 사이즈 보다 클 때

  • window 사이즈가 breakPoint 에서 sm 사이즈 보다 클 때

1. 타겟 element 가 viewport 안에 보이는지 판단하는 hook

  • input
    • element: 관찰하고자 하는 타겟 element React.ref
    • rootMargin : 관찰 타겟 주위의 여백, new IntersectionObserver() 생성자에, option 으로 들어가는 값.
      • 예시. "10px 20px 30px 40px" (위, 오른쪽, 아래, 왼쪽). 값은 백분율이 될 수 있습니다. 기본값은 0.
      • 타겟 주변의 여백도 포함해서, 그 여백까지 타겟으로 보고, viewport 에서 사라졌는지 판단함.
      • IntersectionObserver.option.rootMargin 더보기
  • output
    • isVisible : 해당 타겟이 현재 viewport 안에 보이는지 여부 boolean.
    • observer : 생성한 IntersectionObserver 인스턴스
      • unmount 시키면서, 생성한 observer 인스턴스 관찰 unobserve 시키기 위함

 

useVisibilityObserver.ts

  • 코드
import { useState, useEffect, RefObject } from "react";

interface VisibilityObserverResult {
  isVisible: boolean;
  observer: IntersectionObserver | null;
}

const useVisibilityObserver = (
  element: RefObject<HTMLElement | null>,
  rootMargin: string,
): VisibilityObserverResult => {
  const [isVisible, setState] = useState(false);
  const [observer, setObserver] = useState<IntersectionObserver | null>(null);

  useEffect(() => {
    if (element.current) {
      const intersectionObserver = new IntersectionObserver(
        ([entry]) => {
          setState(entry.isIntersecting);
        },
        { rootMargin },
      );

      setObserver(intersectionObserver);

      intersectionObserver.observe(element.current);

      // Cleanup: Disconnect the observer when the component unmounts
      return () => {
        intersectionObserver.disconnect();
      };
    }
  }, [element, rootMargin]); // Make sure to include 'element' and 'rootMargin' in the dependency array

  return { isVisible, observer };
};

export default useVisibilityObserver;

 

 

MyComponent.tsx

  • 사용하는 컴포넌트 코드
const targetElRef = useRef<HTMLDivElement | null>(null);
const { isVisible: inViewport, observer } = useVisibilityObserver(targetElRef, "100px");

useEffect(() => {
  return () => {
    observer && targetElRef.current && observer.unobserve(targetElRef.current);
  };
}, [observer]);


...

return (
	...
	<div ref={targetElRef}>TEST</div>
	
	...
	
	<div style={{ margin: "10px", display: inViewport ? "block" : "none" }}>BOTTOM</div>
	...
)

 

  • 화면
    • TEST 박스가 화면안에 보일때는 BOTTOM 박스도 노출됨

  • TEST 박스가 화면 밖으로 사라지면 BOTTOM 글씨도 사라짐

1. async 로 Mock Data 제공하는 hook

  • input
    • resolvedData: 제공할 mock data
    • waitingTime : setTimeout 기다리는 시간, 몇 초 후 리턴
  • output
    • { loading, setLoading, data, setData } : 로딩, 리턴받는 데이터 state

 

useFakeFetch.ts

  • 코드
import { useEffect, useState } from "react";

interface FakeFetchResult<T> {
  loading: boolean;
  setLoading: React.Dispatch<React.SetStateAction<boolean>>;
  data: T[];
  setData: React.Dispatch<React.SetStateAction<T[]>>;
}

const useFakeFetch = <T>(resolvedData: T[], waitingTime = 500): FakeFetchResult<T> => {
  const [loading, setLoading] = useState(true);
  const [data, setData] = useState<T[]>([]);

  useEffect(() => {
    let isMounted = true;

    const timeoutId = setTimeout(() => {
      if (isMounted) {
        setData(resolvedData);
        setLoading(false);
      }
    }, waitingTime);

    return () => {
      isMounted = false;
      clearTimeout(timeoutId);
    };
  }, [resolvedData, waitingTime]);

  return { loading, setLoading, data, setData };
};

export default useFakeFetch;

 

MyComponent.tsx

  • 사용하는 컴포넌트 코드
const { loading: priceLoading, data: pricing } = useFakeFetch(
  pricingData,
  1000
);
const { data: newNotifications, setData: setNewNotifications } =
  useFakeFetch(rawNewNotifications);
const { data: earlierNotifications, setData: setEarlierNotifications } =
  useFakeFetch(rawEarlierNotifications);

 

useQuery 를 사용하면서 특정 조건에 따라서 query 를 실행/미실행 하려고 한다

  const { data, error, isLoading } = useQuery(
    ["test", "detail", id],
    () => getDetail({ id }),
	{enabled: !!id}
   )

 

 

id가 있을 때만 query 요청하기

조건 기준이 되는 값은 반드시 queryKey 에 포함이 되어있어야 enabled 가 작동된다

 

 

id 가 없어서 query 요청을 안하는 경우인데도 isLoading = true 로 설정되어, 로딩 스피너가 계속 돌았다.

 

이에 대한 해결책은

react-query v4에서 isInitialLoading이라는 새로운 인자를 반환해준다.

 

react-query 공식 문서에서 isInitialLoading 에 대한 설명을 보면, isFetching 과 isLoading 의 AND 연산 값이다

즉 실제로 fetching 하고 있는가 + loading 중인가 체크함

  • isInitialLoading: boolean
    • Is true whenever the first fetch for a query is in-flight
    • Is the same as isFetching && isLoading

 

 

관련 링크

https://github.com/TanStack/query/issues/3584

 

[Beta] Disabled query has `isLoading: true` property · Issue #3584 · TanStack/query

Describe the bug In react-query@beta version has strange behaviour. By default query that has enabled: false option has isLoading: true property (see code sandbox for details). In stable release re...

github.com

 

https://tanstack.com/query/v4/docs/react/reference/useQuery

 

useQuery | TanStack Query Docs

Does this replace [Redux, MobX, etc]?

tanstack.com

 

  // 최초 마운트시 실행 되지 않고, dependencies 가 실제 변경된 경우에만 실행
  
  const didMount = useRef(false);
  useEffect(() => {
    if (didMount.current) {
      setValue("connectProductId", null);
    } else didMount.current = true;
  }, [watch("countryId")]);
목적 : 테이블 데이터를 엑셀로 Export

 

방법1) 프론트에서 처리 -> UI 상 보여지는 테이블 데이터를 그대로 엑셀 추출
방법2) 백엔드에서 처리 -> 프론트에서 가공되지 않은 원본 데이터를 추출

 

1. 프론트 처리

엑셀 처리를 위한 라이브러리 사용

https://www.npmjs.com/package/xlsx

 

xlsx

SheetJS Spreadsheet data parser and writer. Latest version: 0.18.5, last published: 2 years ago. Start using xlsx in your project by running `npm i xlsx`. There are 4148 other projects in the npm registry using xlsx.

www.npmjs.com

import * as XLSX from "xlsx";

 

ExcelButton 컴포넌트의 onClick 함수

const worksheet = XLSX.utils.json_to_sheet(data);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
XLSX.writeFile(workbook, "OrderList.xlsx");

 

 

2. 백엔드에서 처리하여 excel 파일을 blob 형태로 리턴 -> excel 파일을 링크화하여 로컬에 다운

컴포넌트 호출 부분

import { getOrderListExcelFile } from "hooks/queries/order";


<ExportButton data={orderListData} excelExportFunction={getOrderListExcelFile} className="p-1 me-3 fs--1" />

 

 

API 요청 함수 부분

get 요청의 option에서 responseType : "blob" 로 지정해주어야 함

이 요청 함수를 <ExportButton> 컴포넌트의 excelExportFunction Prop으로 넘겨준다

// order list excel download
export const getOrderListExcelFile = async () => {
  return axiosInstance
    .get(`/order/download`, { responseType: "blob" })
    .then((res) => res.data)
    .catch((error) => error);
};

 

* 추가 Post 로 요청하기

// order list excel download
export const getOrderListExcelFile = async () => {
  return axiosInstance
    .post(`/order/download`, null, { responseType: "blob" })
    .then((res) => res.data)
    .catch((error) => error);
};

 

 

<ExportButton> 컴포넌트 

방법1(프론트), 방법2(백) 동시 커버

API로 리턴받은 데이터와 원하는 추출 파일 형식(Excel) 을 'downloadFile' 함수로 전달

import React from "react";
import { Button } from "react-bootstrap";
import * as XLSX from "xlsx";
import classNames from "classnames";
import { toast } from "react-toastify";
import { downloadFile } from "helpers/download-file";

interface IExportButtonProps {
  data: any;
  excelExportFunction?: () => Promise<any>;
  className: string;
}

const CONTENT_TYPE_EXCEL = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";

export default function ExportButton({ data, excelExportFunction, className }: IExportButtonProps) {
  const onClickExcel = async () => {
    // 부모 컴포넌트에서 별도의 다운로드 API 함수(excelExportFunction)를 prop으로 내려보내줬을 경우
    if (excelExportFunction) {
      const res = await excelExportFunction();
      // 에러 발생시 res.response.data에 담긴 blob을 parse해서 error message 출력
      if (res.response && res.response.status !== 200) {
        const blobToText = await res.response.data.text();
        const errorResponse = JSON.parse(blobToText);
        toast.error(errorResponse.message);
        return;
      }
      downloadFile(res, CONTENT_TYPE_EXCEL);
    } else {
      // 부모 컴포넌트에서 다운로드 API를 내려보내지 않은 경우 프론트에서 처리
      const worksheet = XLSX.utils.json_to_sheet(data);
      const workbook = XLSX.utils.book_new();
      XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
      XLSX.writeFile(workbook, "OrderList.xlsx");
    }
  };
  return (
    <Button variant="falcon-default" className={classNames(className)} onClick={onClickExcel}>
      Export
    </Button>
  );
}

 

 

blob 말아서 다운로드 링크로 만들고 로컬에 다운로드 하는 함수

리턴 받은 res 데이터(blob), 추출할 컨텐트 타입(Excel 파일 형태) 를 매개변수로 전달받고

<a>링크를 만들고, body 에 추가한 후 click하여 다운로드 후 삭제 처리

import dayjs from "dayjs";
const currentDate = dayjs(new Date()).format("YYYY-MM-DD");

export const downloadFile = (file, contentType) => {
  const a = document.createElement("a");
  const blob = new Blob([file], { type: contentType });
  a.href = window.URL.createObjectURL(blob);
  a.target = "_blank";
  a.download = `downloaded_file_${currentDate}`;
  a.style.display = "none";
  document.body.appendChild(a);
  a.click();
  URL.revokeObjectURL(a.href);
  document.body.removeChild(a);
};

 

+ Recent posts