관리 메뉴

거니의 velog

(23) 리액트와 상품 API 서버 연동 2 본문

SpringBoot_React 풀스택 프로젝트

(23) 리액트와 상품 API 서버 연동 2

Unlimited00 2024. 3. 5. 13:39

2. 등록 페이지와 컴포넌트 처리

* 등록 작업은 AddPage.js 이름의 페이지 컴포넌트를 추가하고 라우터를 설정해 주는 것으로 시작한다.

import React from "react";

const AddPage = () => {
  return (
    <div className="p-4 w-full bg-white">
      <div className="text-3xl font-extrabold">Products Add Page</div>
    </div>
  );
};

export default AddPage;

(1) 라우팅 설정

* router/productsRouter.js에는 AddPage에 대한 라우팅 정보를 설정한다.

import React, { Suspense, lazy } from "react";
import { Navigate } from "react-router-dom";

const productsRouter = () => {
  const Loading = <div>Loading....</div>;
  const ProductsList = lazy(() => import("../pages/products/ListPage"));
  const ProductsAdd = lazy(() => import("../pages/products/AddPage"));
  return [
    {
      path: "list",
      element: (
        <Suspense fallback={Loading}>
          <ProductsList />
        </Suspense>
      ),
    },
    {
      path: "",
      element: <Navigate replace to="/products/list" />,
    },
    {
      path: "add",
      element: (
        <Suspense fallback={Loading}>
          <ProductsAdd />
        </Suspense>
      ),
    },
  ];
};

export default productsRouter;

* 브라우저에서 /products/add 경로로 접근하거나 /products/ 관련 메뉴에서 ADD 링크에서 이동이 가능해진다.


(2) 상품의 AddComponent와 API 호출

* AddPage 컴포넌트에서 실제 화면의 내용을 구성하는 작업은 components 폴더에 products 폴더를 생성하고 AddComponent.js를 작성해서 진행한다.

import React from "react";

const AddComponent = () => {
  return (
    <div className="border-2 border-sky-200 mt-10 m-2 p-4">
      <div className="flex justify-center">
        <h1>Product Add Component</h1>
      </div>
    </div>
  );
};

export default AddComponent;

* /pages/products/AddPage.js 내 AddComponent를 import 한다.

import React from "react";
import AddComponent from "../../components/products/AddComponent";

const AddPage = () => {
  return (
    <div className="p-4 w-full bg-white">
      <div className="text-3xl font-extrabold">Products Add Page</div>
      <AddComponent />
    </div>
  );
};

export default AddPage;

* 브라우저에서 /products/add 경로를 통해서 AddComponent가 추가된 것을 확인한다.

* AddComponent의 내용은 Todo 예제의 AddComponents를 참고해서 첨부파일을 추가할 수 있는 구성으로 작성하는데 우선은 화면 구성부터 완료한다. 첨부파일은 useRef()를 사용해서 처리한다.

import React, { useRef, useState } from "react";

const initState = {
  pname: "",
  pdesc: "",
  price: 0,
  files: [],
};

const AddComponent = () => {
  const [product, setProduct] = useState({ ...initState });
  const uploadRef = useRef();
  const handleChangeProduct = (e) => {
    product[e.target.name] = e.target.value;
    setProduct({ ...product });
  };
  const handleClickAdd = (e) => {
    console.log(product);
  };

  return (
    <div className="border-2 border-sky-200 mt-10 m-2 p-4">
      <div className="flex justify-center">
        <div className="relative mb-4 flex w-full flex-wrap items-stretch">
          <div className="w-1/5 p-6 text-right font-bold">Product Name</div>
          <input
            className="w-4/5 p-6 rounded-r border border-solid border-neutral-300 shadow-md"
            name="pname"
            type={"text"}
            value={product.pname}
            onChange={handleChangeProduct}
          ></input>
        </div>
      </div>
      <div className="flex justify-center">
        <div className="relative mb-4 flex w-full flex-wrap items-stretch">
          <div className="w-1/5 p-6 text-right font-bold">Desc</div>
          <textarea
            className="w-4/5 p-6 rounded-r border border-solid border-neutral-300 shadow-md resize-y"
            name="pdesc"
            rows="4"
            onChange={handleChangeProduct}
            value={product.pdesc}
          >
            {product.pdesc}
          </textarea>
        </div>
      </div>
      <div className="flex justify-center">
        <div className="relative mb-4 flex w-full flex-wrap items-stretch">
          <div className="w-1/5 p-6 text-right font-bold">Price</div>
          <input
            className="w-4/5 p-6 rounded-r border border-solid border-neutral-300 shadow-md"
            name="price"
            type={"number"}
            value={product.price}
            onChange={handleChangeProduct}
          ></input>
        </div>
      </div>
      <div className="flex justify-center">
        <div className="relative mb-4 flex w-full flex-wrap items-stretch">
          <div className="w-1/5 p-6 text-right font-bold">Files</div>
          <input
            ref={uploadRef}
            className="w-4/5 p-6 rounded-r border border-solid border-neutral-300 shadow-md"
            type={"file"}
            multiple={true}
          ></input>
        </div>
      </div>
      <div className="flex justify-end">
        <div className="relative mb-4 flex p-4 flex-wrap items-stretch">
          <button
            type="button"
            className="rounded p-4 w-36 bg-blue-500 text-xl  text-white "
            onClick={handleClickAdd}
          >
            ADD
          </button>
        </div>
      </div>
    </div>
  );
};

export default AddComponent;

* 먼저, 브라우저에서 화면이 아래와 같이 정상적으로 구성되었는지 확인한다.


[useRef()와 FormData]

* useRef()는 기존의 자바스크립트에서 document.getElementById()와 유사한 역할을 한다. 리액트의 컴포넌트는 태그의 id 속성을 활용하면 나중에 동일한 컴포넌트를 여러 번 사용해서 화면에 문제가 생기기 때문에(웹 표준 위배 : HTML 태그의 id는 중복되어서는 안된다는 규칙) useRef()를 이용해서 처리한다.

* 예제에서는 ADD 버튼을 클릭했을 때 첨부파일에 선택된 정보를 읽어내서 첨부파일의 정보를 파악하고 이를 Ajax 전송에 사용하는 FormData 객체로 구성해야 한다. FormData 타입으로 처리된 모든 내용은 Axios를 이용해서 서버를 호출할 때 사용하게 된다.

* AddComponent의 ADD 버튼의 처리는 아래와 같이 변경된다.

const AddComponent = () => {
  (...)
  
  const handleClickAdd = (e) => {
    const files = uploadRef.current.files;
    const formData = new FormData();
    for (let i = 0; i < files.length; i++) {
      formData.append("files", files[i]);
    }
    //other data
    formData.append("pname", product.pname);
    formData.append("pdesc", product.pdesc);
    formData.append("price", product.price);
    console.log(formData);
  };

  (...)
}

* useRef를 이용할 때는 current 라는 속성을 활용해야 현재 DOM 객체를 참조하게 된다. Ajax를 전송할 때는 FormData 객체를 통해서 모든 내용을 담아서 전송하게 된다.


[productsAPI의 개발]

* Axios와의 통신은 api 폴더 내에 productsApi.js 파일을 작성해서 처리한다. 이전의 todo의 경우 단순한 JSON 데이터를 처리했지만 이번에는 multipart/form-data 방식으로 서버를 호출해야만 한다.

* api 폴더에 productsApi.js 파일을 추가하고 아래와 같은 내용으로 작성한다.

import axios from "axios";
import { API_SERVER_HOST } from "./todoApi";

const host = `${API_SERVER_HOST}/api/products`;

export const postAdd = async (product) => {
  const header = { headers: { "Content-Type": "multipart/form-data" } };
  // 경로 뒤 '/' 주의
  const res = await axios.post(`${host}/`, product, header);
  return res.data;
};

* postAdd() 에서 주의해야 하는 점은 Axios가 기본적으로 Content-Type을 application/json 을 이용하기 때문에 일 업로드를 같이할 때는 multipart/form-data 헤더 설정을 추가해 주어야 한다는 점이다.

* AddComponent에서는 버튼을 클릭했을 때 postAdd()를 호출하도록 코드를 수정한다. 전송해야 하는 모든 데이터는 FormData 객체로 처리한다.

import React, { useRef, useState } from "react";
import { postAdd } from "../../api/productsApi";

const initState = {
  (...)
};

const AddComponent = () => {
  (...)
  
  const handleClickAdd = (e) => {
    const files = uploadRef.current.files;
    const formData = new FormData();
    for (let i = 0; i < files.length; i++) {
      formData.append("files", files[i]);
    }
    //other data
    formData.append("pname", product.pname);
    formData.append("pdesc", product.pdesc);
    formData.append("price", product.price);
    console.log(formData);

    postAdd(formData);
  };

  (...)
};

export default AddComponent;

* 작성된 AddComponent는 /products/add 링크에서 확인하고 이전 장에서 스프링 부트로 구성된 서버를 실행한 후에 상품 추가를 시도해 본다.

* ADD 버튼을 클릭한 후에 브라우저의 개발자 도구에서 전달되는 내용(payload)을 확인했을 때 아래 오른쪽 그림과 같이 파일 데이터들은 binary 데이터로 전달되는 것을 확인해야 한다(아래 files 항목은 여러 개의 파일을 전송했을 경우이다).

* 브라우저에서는 서버가 정상적으로 상품을 추가했는지 응답(response) 데이터로 확인할 수 있다.

* 서버에서는 정상적으로 파일 업로드가 되었는지 업로드 폴더를 확인하고 데이터베이스에서 해당 번호의 상품 이미지들이 정상적으로 처리되었는지 확인한다. 위의 테스트에서는 2개의 이미지를 전송했으므로 썸네일 이미지를 포함해서 4개의 파일이 생성된다.


[진행 모달창과 결과 모달창]

* API 서버와 통신이 필요한 모든 기능은 서버에서 데이터를 가져오는(fetch) 시간을 고려해야 한다. 흔히 '처리중입니다...' 혹은 '로딩중...' 과 같은 메시지가 보이는 모달창을 통해서 이를 처리하는 경우가 많은데 파일 업로드와 같은 경우 단순한 텍스트를 이용할 때 보다 많은 시간이 걸리기 때문에 진행에 대한 모달창을 보여주고 처리가 끝나면 결과를 모달창으로 보여주는 것이 바람직하다.

* 진행 모달창을 작성하기 위해서 components의 commons 폴더에 FetchingModal을 추가한다.

import React from "react";

const FetchingModal = () => {
  return (
    <div
      className={`fixed top-0 left-0 z-[1055] flex h-full w-full  place-items-center justify-center bg-black bg-opacity-20`}
    >
      <div className=" bg-white rounded-3xl opacity-100 min-w-min h-1/4  min-w-[600px] flex justify-center items-center ">
        <div className="text-4xl font-extrabold text-orange-400 m-20">
          Loading.....
        </div>
      </div>
    </div>
  );
};

export default FetchingModal;

* FetchingModal은 ResultModal과 유사하지만 외부에서 보이거나 사라지도록 제어되기 때문에 속성으로 전달받아야 하는 데이터가 없다는 점이 다르다.

* AddComponent는 서버와의 통신 상태를 fetching이라는 상태를 useState()를 통해서 제어한다. API 서버를 호출할 때 fetching 상태를 true로 해 주고 데이터를 가져온 후 false로 변경해서 화면에서 사라지도록 한다.

import React, { useRef, useState } from "react";
import { postAdd } from "../../api/productsApi";
import FetchingModal from "../common/FetchingModal";

(...)

const AddComponent = () => {
  const [product, setProduct] = useState({ ...initState });
  const uploadRef = useRef();
  const [fetching, setFetching] = useState(false);

  const handleChangeProduct = (e) => {
    (...)
  };
  const handleClickAdd = (e) => {
    (...)

    setFetching(true);

    postAdd(formData).then((data) => {
      setFetching(false);
    });
  };

  return (
    <div className="border-2 border-sky-200 mt-10 m-2 p-4">
      {fetching ? <FetchingModal /> : <></>}
      (...)
    </div>
  );
};

export default AddComponent;

* 위의 코드가 적용되면 ADD 버튼을 클릭했을 때 FetchingModal이 보였다가 서버 통신이 끝나면 사라지게 된다.


* 일부러 인터넷 속도를 느리게 하여 Loading 모달 창이 잘 뜨는지 확인하고 싶다면 다음의 옵션을 체크하여 테스트 한다.

* 테스트 후에는 다시 원상복귀시킨다.

* 혹은 FetchingModal을 좀 더 오랫동안 보이도록 하고 싶은 경우 API 서버의 ProductController에서는 고의로 등록작업 처리 시에 Thread.sleep()을 이용해서 약간의 시간이 걸리도록 코드를 수정해 줄 수 있다.

    // 상품을 등록하는 메서드입니다.
    @PostMapping("/")
    public Map<String, Long> register(ProductDTO productDTO) {
        // 요청된 상품 정보를 로그에 출력합니다.
        log.info("register : " + productDTO);

        // 상품 DTO에서 업로드된 파일 목록을 가져옵니다.
        List<MultipartFile> files = productDTO.getFiles();

        // 파일 유틸을 사용하여 파일을 저장하고, 저장된 파일 이름 리스트를 가져옵니다.
        List<String> uploadFileNames = fileUtil.saveFiles(files);

        // 상품 DTO에 업로드된 파일 이름 리스트를 설정합니다.
        productDTO.setUploadFileNames(uploadFileNames);

        // 상품 서비스를 호출하여 상품을 등록하고, 상품 번호를 받아옵니다.
        Long pno = productService.register(productDTO);

        // FetchingModal 테스트를 위한 딜레이 시간 주기
        try {
            Thread.sleep(2000); // 2초간 살려둬~
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 결과를 Map 형태로 반환합니다.
        return Map.of("result", pno);
    }

* 브라우저를 이용해서 새로운 상품을 등록하면 아래와 같이 FetchingModal  창이 보인 후 사라지게 된다.


[결과 모달창 처리]

* FetchingModal 은 서버와의 통신 과정에서만 보이고, 등록/수정/삭제의 경우 처리 결과를 보여줄 필요가 있다. 이에 대한 처리는 ResultModal을 이용해서 처리한다.

* 현재 API 서버에서 상품이 등록되면 전송되는 JSON 데이터는 result라는 속성을 가지게 된다.

* AddComponent에서는 이를 활용해서 화면에 결과를 보여주도록 구성한다.

import React, { useRef, useState } from "react";
import { postAdd } from "../../api/productsApi";
import FetchingModal from "../common/FetchingModal";
import ResultModal from "../common/ResultModal";

const initState = {
  pname: "",
  pdesc: "",
  price: 0,
  files: [],
};

const AddComponent = () => {
  const [product, setProduct] = useState({ ...initState });
  const uploadRef = useRef();
  const [fetching, setFetching] = useState(false);
  const [result, setResult] = useState(null);

  const handleChangeProduct = (e) => {
    product[e.target.name] = e.target.value;
    setProduct({ ...product });
  };
  const handleClickAdd = (e) => {
    const files = uploadRef.current.files;
    const formData = new FormData();
    for (let i = 0; i < files.length; i++) {
      formData.append("files", files[i]);
    }
    //other data
    formData.append("pname", product.pname);
    formData.append("pdesc", product.pdesc);
    formData.append("price", product.price);
    console.log(formData);

    setFetching(true);

    postAdd(formData).then((data) => {
      setFetching(false);
      setResult(data.result);
    });
  };
  const closeModal = () => {
    //ResultModal 종료
    setResult(null);
  };

  return (
    <div className="border-2 border-sky-200 mt-10 m-2 p-4">
      {fetching ? <FetchingModal /> : <></>}
      {result ? (
        <ResultModal
          title={"Product Add Result"}
          content={`${result}번 등록 완료`}
          callbackFn={closeModal}
        />
      ) : (
        <></>
      )}
      (...)
  );
};

export default AddComponent;

* FetchingModal 과 ResultModal이 적용되면 화면에서는 ADD 버튼을 클릭했을 때 FetchingModal이 보여지고, 서버와의 통신이 끝나면 ResultModal이 동작하는 것을 볼 수 있다.


[등록 후 목록 페이지 이동]

* 등록에서 마지막으로 남은 작업은 ResultModal이 닫히면 목록 페이지로 이동하는 것이다. 라우팅과 관련된 작업은 이미 hooks 폴더 내에 useCustomMove()를 작성해 두었기 때문에 이를 활용한다.

* AddComponent의 경우 useCustomMove의 실행 결과에서 moveToList만 필요한 상황이므로 이를 이용해서 ResultModal이 닫히는 closeModal 함수에서 moveToList()를 호출하도록 구성한다.

* AddComponent의 주요 코드는 아래와 같이 작성된다.

import React, { useRef, useState } from "react";
import { postAdd } from "../../api/productsApi";
import FetchingModal from "../common/FetchingModal";
import ResultModal from "../common/ResultModal";
import useCustomMove from "../../hooks/useCustomMove";

const initState = {
  (...)
};

const AddComponent = () => {
  const [product, setProduct] = useState({ ...initState });
  const uploadRef = useRef();
  const [fetching, setFetching] = useState(false);
  const [result, setResult] = useState(null);
  const { moveToList } = useCustomMove(); //이동을 위한 함수

  const handleChangeProduct = (e) => {
    (...)
  };
  const handleClickAdd = (e) => {
    (...)
  };
  const closeModal = () => {
    //ResultModal 종료
    setResult(null);
    moveToList({ page: 1 }); //모달 창이 닫히면 페이지로 이동
  };

  return (
    (...)
  );
};

export default AddComponent;

* 최종적으로 브라우저를 통해서 확인해 보면 아래 화면들과 같이 등록된 상품의 번호가 보이고 ResultModal 창을 닫은 후에는 자동으로 /products/list 경로로 이동하는 것을 확인할 수 있다.