[Team Project] With pet

Bin2·2022년 10월 24일
1
post-thumbnail
post-custom-banner

1. Project Intro

1-1. 프로젝트 소개

코드스테이츠 수료 전 5주간 진행한 메인 프로젝트 입니다.
이전의 프리 프로젝트와 동일한 팀원 (프론트3, 백2) 들과 함께 했고,
프로젝트의 완성도를 높히기 위해 디자이너 2분을 모집하여 함께 협업 했습니다.

해당 프로젝트는 현재 위치를 기반으로 반려견과 동반 가능한 장소들을 추천해주며
반려견 관련 커뮤니티를 제공하는 플랫폼 입니다.

1-2. 기간

2022.09.07 ~ 2022.10.12 (5주)

1-3. 기술 스택

  • React
  • Typescript
  • Redux-toolkit
  • React-Query
  • Styled-Components

1-5. 맡은 기능

  • 소셜 로그인
  • 회원가입
  • 사업자 등록 페이지
  • 펫플레이스 CRUD
  • 펫플레이스 상세 페이지
  • 리뷰 CRUD
  • 댕댕이숲 리스트
  • 댕댕이숲 상세 페이지
  • 댕댕이숲 댓글 CRUD

2. 소셜 로그인

소셜 로그인은 국내 사용자를 고려하여 접근성이 가장 뛰어나다고 판단한 네이버, 카카오, 구글 세가지를 구현했습니다.

Auth Server, Resource Server 에서 인증 코드, 토큰, 유저 정보를 받아오는 로직은 서버에서 담당하고 클라이언트에서는 서버에서 보내준 Refresh token, Access token을 search param으로 받아오는 방식으로 구현했습니다.

useEffect 훅과 React-Query의 useMutation을 조합하여 access token을 이용한 api를 통해 유저의 정보를 받아왔고,

유저에 관련된 정보들은 다수의 페이지, 컴포넌트에서 사용되기 때문에 Redux를 이용하여 전역 상태로 저장했습니다.

const Oauth = () => {
  const [searchParams] = useSearchParams();
  const access_token = searchParams.get("access_token") || "";
  const refresh_token = searchParams.get("refresh_token") || "";

  const dispatch = useAppDispatch();
  const navigate = useNavigate();
  const { mutate } = useMutation(getUserInfos, {
    onSuccess: (data) => {
      dispatch(
        logInUser({
          accessToken: access_token,
          refreshToken: refresh_token,
          keepLoggedIn: false,
        })
      );
      dispatch(initializeUserInfos(data));
      navigate("/");
    },
  });

  useEffect(() => {
    mutate(access_token);
  }, [access_token, mutate]);

  return (
    <SLoadingContainer>
      <LoadingSpinner />
    </SLoadingContainer>
  );
};

export default Oauth;

3. 회원가입

각각의 Input과 Label을 컴포넌트로 분리하여 다른 Form에도 재사용할 수 있도록 했습니다.

const Input = ({
  type = "text",
  label,
  id,
  value,
  isError,
  errorMsg,
  comment,
  placeholder,
  className,
  sideButton,
  readOnly,
  onChange,
}: Prop) => {
  return (
    <SInputContainer className={className}>
      {label && <label htmlFor={id}>{label}</label>}
      <SInput isError={isError} isLabel={label} isSideButton={sideButton}>
        <input
          type={type}
          id={id}
          placeholder={placeholder}
          value={value}
          readOnly={readOnly}
          onChange={(e) => onChange(e)}
        />
        {isError && <SError isError={isError}>{errorMsg}</SError>}
        {!isError && comment && (
          <SComment isError={isError}>{comment}</SComment>
        )}
        {sideButton}
      </SInput>
    </SInputContainer>
  );
};

export default Input;

중복되는 코드를 줄이고 재사용성을 높히기 위해 유효성을 검증하는 콜백 함수에 따라
error, value 상태를 리턴하는 로직을 커스텀 훅으로 분리했습니다.

// Custom hook
type ValidateCallback = (value: string, password?: string) => boolean;

type UseValidate = (
  callback: ValidateCallback
) => [
  string,
  boolean,
  (
    e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
    password?: string
  ) => void,
  (password?: string) => void,
  React.Dispatch<React.SetStateAction<string>>,
  React.Dispatch<React.SetStateAction<boolean>>
];

export const useValidate: UseValidate = (validateCallback) => {
  const [value, setValue] = useState("");
  const [error, setError] = useState(false);

  const handleChange = useCallback(
    (
      e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
      password?: string
    ) => {
      const { value } = e.target;

      if ((e as ChangeEvent<HTMLInputElement>).target.files) {
        const { name } = (e as ChangeEvent<HTMLInputElement>).target.files![0];

        setValue(name);

        if (validateCallback(name)) setError(false);
        if (!validateCallback(name)) setError(true);

        return;
      }

      setValue(value);

      if (validateCallback(value, password)) {
        setError(false);
      }

      if (!validateCallback(value, password)) {
        setError(true);
      }
    },
    [validateCallback]
  );

  const handleCheck = (password?: string) => {
    if (validateCallback(value, password)) {
      setError(false);
    }

    if (!validateCallback(value, password)) {
      setError(true);
    }
  };

  return [value, error, handleChange, handleCheck, setValue, setError];
};
//Utils
export const nickNameValidation = (value: string) => {
  return value.length > 1;
};

export const emailValidation = (value: string) => {
  const EMAIL_REGEX =
    /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$/i;

  return EMAIL_REGEX.test(value);
};

export const passwordValidation = (value: string) => {
  const ENG_NUM_REGEX = /^[a-zA-Z0-9]*$/;
  const { length } = value.trim();

  return ENG_NUM_REGEX.test(value) && length > 5 && length < 21;
};

4. 사업자 등록

펫플레이스를 등록하기 위해서는 실제 존재하는 장소인지, 매장일 경우 실제 존재하는 사업자인지의 여부를 판단해야 했습니다.

진위 여부를 판단하기 위해 다음과 같은 방법들을 고안했습니다.
1. 회원가입시 기업회원, 일반회원으로 분류하여 기업회원인 경우에만 등록할 수 있도록 설계
2. 어드민 페이지를 만들어 관리자를 통해 실제 존재하는 장소일 경우에만 등록

첫 번째 방법의 경우 회원가입이 복잡해짐에 따라 유저의 이탈률이 증가될 수 있다고 판단하였고, 기업 회원가입으로 인해 소셜로그인, 일반로그인의 유저 정보의 포맷이 달라짐으로써 서버쪽의 로직이 복잡해질 수 있었습니다.

두 번째 방법의 경우 어드민 페이지를 만들기 위해 그에 맞는 서버 api와 클라이언트 페이지를 개발해야 했고 그만한 리소스를 투입할 수 있을 만큼 마감 기한이 여유롭지 못했습니다.

이러한 조건들을 모두 충족하며 문제를 해결하기 위해 고민한 결과 사업자 등록 번호를 통해 진위 여부를 판단할 수 있는 api를 발견하게 되었습니다.
국세청 사업자등록정보 진위확인 및 상태조회 서비스

위의 api를 이용해 유저로부터 대표자명, 개업일자, 사업자 등록번호를 받으면 간단하게 진위 여부를 판단할 수 있었습니다.

백엔드 측에서는 따로 api를 만들지 않아도 되었고, 프론트 측에서도 회원가입 페이지에서 만들어 놓은 컴포넌트와 커스텀 훅을 재사용하여 빠르게 개발할 수 있었습니다.


// apis
const businessValidate = async (payload: Payload): Promise<Business> => {
  const { owner, openDate, businessNumber } = payload;
  const { data } = await axiosInstance.post(
    `https://api.odcloud.kr/api/nts-businessman/v1/validate?serviceKey=${BUSINESS_KEY}`,
    {
      businesses: [
        {
          b_no: businessNumber,
          start_dt: openDate,
          p_nm: owner,
        },
      ],
    }
  );

  return data;
};

const changeUserRole = async (): Promise<UserInfos> => {
  const { data } = await axiosInstance.patch(
    `/v1/user/update`,
    {
      userRole: "ROLE_OWNER",
    },
    {
      headers: { tokenNeeded: true },
    }
  );

  return data.data;
};

// mutate
  const { mutate: businessValidateMutate } = useMutation(businessValidate, {
    onSuccess: async (data) => {
      if (data.data[0].valid === "02") {
        openModal(<ErrorModal body="유효하지 않은 사업자 입니다." />);
        return;
      }
      changeUserRoleMutate();
    },
  });

  const { mutate: changeUserRoleMutate } = useMutation(changeUserRole, {
    onSuccess: (data) => {
      toast.success("사업자 등록이 완료되었습니다. 다시 로그인 해주세요.");
      navigate("/login");
    },
  });

유저로부터 입력받은 사업자 정보와 국세청 api를 이용하여 사업자 진위 여부를 판단하였고 사업자가 유효한 경우에만 유저의 Role을 변경하여 펫플레이스를 등록할 수 있도록 하였습니다.

5. 펫플레이스 등록

회원가입 페이지에서 만들었던 커스텀 훅, 컴포넌트를 재사용 했습니다.

주소의 경우 daum 주소 라이브러리를 사용하여 쉽게 주소를 검색할 수 있도록 했고,
입력 받은 주소를 kakao api를 이용하여 좌표로 변환 해주었습니다.

하나의 펫플레이스를 등록하기 위해 아래의 3가지 api를 조합해야 했습니다.
1. 이미지 파일 url 변환 api (AWS s3)
2. 카카오 좌표 변환 api
3. 펫플레이스 등록 / 수정 api

컴포넌트에서 모든 로직을 작성 할 경우 해당 컴포넌트의 복잡성이 커질 것이라 판단하여 커스텀 훅으로 분리했습니다.

const usePlace = (form: UsePlaceForm, isEditPage: boolean, storeId: string) => {
  const [isLoading, setIsLoading] = useState(false);

  const { mutate: fileMutate } = useMutation<
    string[],
    AxiosError<ErrorResponse>,
    UsePlaceForm
  >((payload) => uploadImages(payload.storeImages));

  const {
    data: placeData,
    mutate: addPlaceMutate,
    isSuccess: isAddSuccess,
  } = useMutation<Store, AxiosError<ErrorResponse>, AddPlacePayload>(
    (payload) => addPlace(payload)
  );

  const { mutate: editPlaceMutate, isSuccess: isEditSuccess } = useMutation<
    Store,
    AxiosError<ErrorResponse>,
    EditPlacePayload
  >((payload) => editPlace(payload));

  const { refetch } = useQuery<CoordinateResponse, AxiosError<ErrorResponse>>(
    ["coordinate", form.addressName],
    () => getCoordinate(form.addressName),
    {
      enabled: false,
      onSettled: () => setIsLoading(true),
      onSuccess: (coordinateData) => {
        if (!coordinateData.documents.length) {
          toast.error("주소를 상세하게 입력해주세요.");
          return;
        }
        fileMutate(form, {
          onSuccess: (fileData) => {
            const storeImages = fileData.map((file) => {
              return { storeImage: file };
            });

            isEditPage
              ? editPlaceMutate({
                  ...form,
                  storeId,
                  storeImages,
                  longitude: coordinateData.documents[0].x,
                  latitude: coordinateData.documents[0].y,
                })
              : addPlaceMutate({
                  ...form,
                  storeImages,
                  longitude: coordinateData.documents[0].x,
                  latitude: coordinateData.documents[0].y,
                });

            setIsLoading(false);
          },
        });
      },
    }
  );

  return { refetch, isAddSuccess, isEditSuccess, isLoading, placeData };
};

export default usePlace;

6. 펫플레이스 수정

등록, 수정 페이지의 경우 같은 UI를 공유하고 각각의 Input들의 값들만 바뀌도록 설계 했습니다.

하나의 컴포넌트에 isEditPage, state 두 가지의 prop을 넘겨 주었고,
isEditPage의 상태에 따라 각기 다른 로직이 수행되도록 구현 했습니다.

수정 페이지로 이동 시 prop으로 넘겨준 state가 각각의 Input에 렌더링 되어 쉽게 수정할 수 있도록 했습니다.

const EditPlace = () => {
  const { state } = useLocation() as LocationWithState;
  return (
    <PlaceForm
      isEditPage
      state={{ ...state, storeImages: storeDataTransfromer(state.storeImages) }}
    />
  );
};

export default EditPlace;

또한 수정 전 state와 수정 후 state를 비교하여 변경 사항이 없을 경우에는 모달 창을 띄워 주어 불필요한 서버 요청을 줄이도록 했습니다.

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (isEditPage) {
      if (
        prevState.storeName === nameValue &&
        prevState.category === checkboxValue &&
        prevState.addressName === addressValue &&
        prevState.phone === phoneNumberValue &&
        prevState.homepage === homePageValue &&
        prevState.body === descriptionValue &&
        compareImageList(prevState.storeImages, images)
      ) {
        openModal(<ErrorModal body="변경된 내용이 없습니다." />);
        return;
      }
    }
}

7. 펫플레이스 상세 페이지

7-1. 이미지 갤러리

grid를 이용하여 첫 화면에서는 5개의 이미지를 보여주었고 사진 모두 보기 버튼클릭 시 슬라이더를 통해 더 크고 상세하게 모든 이미지들을 볼 수 있도록 구현 했습니다.

이미지 리스트의 경우 추가, 삭제와 같은 기능이 없는 정적 데이터이기 때문에 index가 중복되거나 변경될 일이 없다고 판단하여 key 값에 index를 할당 해주었습니다.
(image url을 할당할 경우 중복 가능성 있음)

interface Prop {
  imageList: string[] | undefined;
}

export const ImageGrid = ({ imageList }: Prop) => {
  const { openModal } = useModal();
  const renderImageList = imageList ? imageList.slice(0, 5) : [];

  return (
    <SContainer>
      {renderImageList.map((image, index) => (
        <img key={index} src={image} alt="place" />
      ))}
      <SButton
        onClick={() =>
          openModal(
            <SSliderContainer>
              <Slider imageList={imageList} />
            </SSliderContainer>
          )
        }
      >
        사진 모두 보기
      </SButton>
    </SContainer>
  );
};

7-2. 최근 본 목록

마이페이지에서 최근 본 목록을 볼 수 있도록 로컬스토리지를 이용하여 해당 기능을 구현 했습니다.

마이페이지를 담당하는 팀원도 쉽게 로컬스토리지 키와 로직들을 공유할 수 있도록 Util 함수로 분리하였습니다.

아래와 같이 3가지 경우의 수 (첫 방문, 중복된 데이터 있을 경우, 최근 방문 기록이 10개 이상일 경우)를 고려하여 로직을 작성했고
useEffect 훅을 이용해 상세 페이지 접근 시 로컬스토리지에 해당 펫플레이스의 정보가 저장되도록 했습니다.

// placeLocalStorage.ts
export const removePlaceFromLocalStorage = () => {
  localStorage.removeItem("recentPlace");
};

export const getPlaceFromLocalStorage = (): null | Store[] => {
  const result = localStorage.getItem("recentPlace");
  const place = result ? JSON.parse(result) : null;
  return place;
};

export const addPlaceToLocalStorage = (data: Store) => {
  let placeLocalStorageData = getPlaceFromLocalStorage();

  // 로컬 스토리지 데이터가 없을 경우 (첫 방문)
  if (!placeLocalStorageData) {
    placeLocalStorageData = [data];
    localStorage.setItem("recentPlace", JSON.stringify(placeLocalStorageData));
    return;
  }

  // 로컬 스토리지 데이터가 있을 경우 중복 제거
  const currentId = data.storeId;
  const mappedStoreId = placeLocalStorageData.map(
    (storageData) => storageData.storeId
  );

  if (!mappedStoreId.includes(currentId)) {
    placeLocalStorageData.push(data);
  }

  // 최근 방문 기록이 10개 이상일 경우 가장 오랜된 목록 제거
  if (placeLocalStorageData.length > 10) {
    placeLocalStorageData.shift();
  }

  localStorage.setItem("recentPlace", JSON.stringify(placeLocalStorageData));
};

7-3. 좋아요

좋아요 기능은 백엔드 측에서 해당 펫플레이스의 좋아요를 누른 유저의 ID를 heartUserId 라는 배열에 담아 보내주었습니다.

isLike 이라는 상태를 만들었고 전역 상태로 저장되어있는 로그인 한 유저의 ID와 heartUserId에 포함되어 있는 ID 들을 비교하였고 그 결과에 맞추어 하트의 스타일을 변경해주었습니다.

또한 좋아요 클릭 시 로그인 하지 않았을 경우, 이미 좋아요를 눌렀을 경우, 좋아요를 누르지 않았을 경우 세 가지로 분기하여 각기 다른 로직을 수행하도록 했습니다.

  const { mutate: registerHeartMutate } = useMutation(registerHeart, {
    onSuccess: () => queryClient.invalidateQueries(["place", params.id]),
  });
  const { mutate: cancelHeartMutate } = useMutation(cancelHeart, {
    onSuccess: () => queryClient.invalidateQueries(["place", params.id]),
  });

  const handleHeartClick = () => {
    if (!loginStatus) {
      openModal(<LoginModal />);
      return;
    }
    if (isLike) {
      cancelHeartMutate(data.storeId);
    }
    if (!isLike) {
      registerHeartMutate(data.storeId);
    }
  };

  useEffect(() => {
    if (data.heartUserId.includes(userInfos.userId)) {
      setIsLike(true);
    } else {
      setIsLike(false);
    }
  }, [userInfos, data]);

7-5. 리뷰

유저가 리뷰를 작성하기 위해 리뷰 Form에 Focus 하면 로그인 하지 않았을 경우, 자신이 등록한 매장일 경우, 이미 작성한 리뷰가 있을 경우에는 리뷰를 등록할 수 없도록 하여 리뷰의 신빙성을 높히도록 했습니다.

  const handleFocus = () => {
    if (!isEdit) {
      if (!loginStatus) {
        openModal(<LoginModal />);
        return;
      }

      if (userInfos.userId === data.user.userId) {
        openModal(
          <ErrorModal body="자신이 등록한 매장에는 리뷰를 등록할 수 없습니다." />
        );
        return;
      }

      if (registerReviewUserList.includes(userInfos.userId)) {
        openModal(<ErrorModal body="이미 작성한 리뷰가 존재합니다." />);
        return;
      }
    }
  };

리뷰의 경우 별점도 같이 등록할 수 있습니다.
클릭한 별점의 indexStar 아이콘 배열의 index를 비교하여 className을 변경시켜 주었고 그에 맞게 스타일을 바꿔주었습니다.

const RatingStar = ({ ratingIndex, setRatingIndex }: Prop) => {

  const handleStarClick = (index: number) => {
    setRatingIndex(index);
  };

  return (
    <SRatingContainer>
      {[1, 2, 3, 4, 5].map((arrayIndex, index) => (
        <SStar
          size={20}
          key={`rating_${index}`}
          className={arrayIndex <= ratingIndex ? "active" : "inactive"}
          onClick={() => handleStarClick(arrayIndex)}
        />
      ))}
      <p>{convertScoreToComment(ratingIndex)}</p>
    </SRatingContainer>
  );
};

React QueryuseInfiniteQuery 를 이용하여 더 보기 기능을 구현했습니다.

리뷰 더 보기 버튼 클릭 시 fetchNextPage 함수가 실행 되며 pageParam을 1씩 증가시키며 다음 리뷰 페이지를 불러오는 방식입니다.

그렇게 불러온 리뷰 리스트를 flatMap을 이용하여 평탄화 작업을 해주었고,
useMemo 훅을 이용하여 컴포넌트가 리렌더링 되더라도 복잡한 연산을 반복 하지 않도록 메모이제이션 해주었습니다.

  const {
    data: reviewData,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery(
    ["review", storeId, sortOption],
    ({ pageParam = 1 }) => getInfiniteReview(storeId, sortOption, pageParam),
    {
      getNextPageParam: (lastPage) => {
        const { totalPages } = lastPage.data.reviews.pageInfo;
        if (lastPage.nextPage <= totalPages) {
          return lastPage.nextPage;
        }
        return undefined;
      },
    }
  );

// ...jsx
  const reviewList = useMemo(
    () =>
      reviewData
        ? reviewData.pages.flatMap(({ data }) => data.reviews.data)
        : [],
    [reviewData]
  );

  return (
    {reviewList.map((data) => (
      <ReviewCard
          key={data.reviewId}
          reviewId={data.reviewId}
          user={data.user}
          updatedAt={data.updatedAt}
          body={data.body}
          score={data.score}
      />
      ))}
    {hasNextPage && (
       <ButtonWhite
          onClick={() => fetchNextPage()}
          isPending={isFetchingNextPage}
       >
         리뷰 더 보기
       </ButtonWhite>
      )}
  );

8. 댕댕이숲 리스트

댕댕이숲은 유저들끼리 자유롭게 의견을 나눌 수 있는 커뮤니티 입니다.
IntersectionObserver api 를 이용하여 참조하고 있는 div 태그가 뷰포트에 나타나면 useInfiniteQuery 를 이용하여 다음 리스트를 불러오는 방식으로 구현했습니다.

컴포넌트가 비대해지는 것을 방지하고 유지 보수성을 높히기 위해 각각 커스텀 훅으로 분리했습니다.

// useIntersect.tsx
export type IntersectHandler = (
  entry: IntersectionObserverEntry,
  observer: IntersectionObserver
) => void;

export const useIntersect = (
  onIntersect: IntersectHandler,
  options?: IntersectionObserverInit
) => {
  const ref = useRef<HTMLDivElement>(null);
  const callback = useCallback(
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) onIntersect(entry, observer);
      });
    },
    [onIntersect]
  );

  useEffect(() => {
    if (!ref.current) return;

    const observer = new IntersectionObserver(callback, options);
    observer.observe(ref.current);

    return () => observer.disconnect();
  }, [ref, options, callback]);

  return ref;
};

// usePostList.tsx
export const usePostList = () => {
  const {
    data,
    hasNextPage,
    fetchNextPage,
    isLoading,
    isFetching,
    isFetchingNextPage,
  } = useInfiniteQuery("post", ({ pageParam = 1 }) => getPostList(pageParam), {
    getNextPageParam: (lastPage) => {
      const { totalPages } = lastPage.pageInfo;
      if (lastPage.nextPage <= totalPages) {
        return lastPage.nextPage;
      }
      return undefined;
    },
  });

  return {
    data,
    hasNextPage,
    fetchNextPage,
    isLoading,
    isFetching,
    isFetchingNextPage,
  };
};

9. 회고

9-1. 비개발 직군과의 협업

처음으로 디자이너 분들과 함께 협업을 했습니다.
매일 디자이너, 개발자 분들이 함께 모여 데일리 스크럼 시간을 가졌는데
개발하며 사용되는 전문용어들을 쓰면서 디자이너 분들이 이해하기 힘든 상황이 빈번히 발생했고 그로 인해 불필요하게 회의시간이 길어지는 문제가 생겼습니다.

이러한 문제를 해결하기 위해 스크럼을 진행 할 때 마다 전문 용어를 쓰기 보다는 디자이너 분들도 이해할 수 있도록 최대한 쉽게 풀어서 설명하는 노력을 하게 되었습니다.

서로 다른 직군끼리 협업하며 서로의 입장을 들어 볼 수 있는 귀중한 경험을 할 수 있었습니다.

9-2. 서버 상태 관리

이전의 프로젝트에서는 Redux Redux-thunk를 이용하여 클라이언트 상태, 서버 상태 모두 리덕스로 관리했었습니다.

그로 인해 전역으로 관리 할 필요가 없는 데이터들도 전역 상태로 관리하게 되었고,
전역으로 상태를 관리하기 위한 라이브러리를 이렇게 사용하는게 맞을까 ? 라는 생각을 하게 되었습니다.

따라서 이번 프로젝트에서는 클라이언트 상태와 서버 상태를 분리해서 관리해보고 그 차이점을 직접 체감해보기 위해 React-Query를 도입했습니다.

React-Query를 사용해보고 느낀 장점은 다음과 같습니다.

  1. Redux에 비해 보일러플레이트 감소, 직관적인 코드
  2. API 처리에 대한 여러개의 인터페이스, 옵션 제공
  3. Cache 기능 제공

Redux로 서버 상태를 관리 할 때는 하나의 요청을 처리하기 위해
isLoading, isSuccess, isError 와 같은 상태들과 이에 맞춰 action들까지 직접 만들어야 했습니다.

React Query 에서는 이러한 상태들을 직접 만들지 않아도 되어 보일러플레이트가 획기적으로 감소하였습니다.

또한 useInfiniteQuery invalidateQuery 등과 같은 각종 메서드들을 제공하여 핵심 기능들을 더 편하고 빠르게 구현할 수 있었습니다.

반대로 사용하며 느낀 단점은 API 관련 로직들을 컴포넌트에서 작성하게 되며 컴포넌트가 상대적으로 비대해진다는 문제가 있었고, 프로젝트 볼륨이 커진다면 Query key를 관리하기가 어렵겠다는 생각이 들었습니다.

이러한 단점을 보완하기 위해 apis 폴더를 따로 만들어 컴포넌트와 API 로직의 유착을 최소화 하였고, 공통적으로 사용되는 Query들은 Custom hook으로 만들어 재사용성을 높히도록 했습니다.

또한 이번 프로젝트에서는 반영하지 못했지만 Query key를 공통으로 관리하기 위해 특정 파일에서 상수로 관리해도 좋겠다는 생각이 들었습니다.

9-3. 테스트

이전 프로젝트에서는 Jest React-Testing-Library를 이용하여 단위테스트, 통합테스트를 작성 했었습니다.

하지만 이번 프로젝트에서는 일정이 여유롭지 못했고, 혼자 테스트 코드를 작성하기 보다는 다른 팀원분들을 도와 프로젝트의 완성도를 높히는 것이 더 좋겠다고 판단하여 테스트 코드를 작성하지 못했습니다.

그러다 보니 QA를 진행하며 생각보다 많은 버그가 발생했고,
다른 팀원의 코드를 수정하거나, 내가 작성한 코드를 리팩토링 할 때 연관된 기능에 영향을 미치지 않는지, 정상적으로 작동하는지를 일일히 수작업으로 확인해야 했습니다.

앞으로 진행하게 될 프로젝트에서는 체계적으로 테스트 코드를 작성하며 커버리지를 최대한 높혀보고 싶고, 테스트 코드를 작성함으로써 얻을 수 있는 이점을 직접 체감해보고 싶다는 생각이 들었습니다.

profile
Developer
post-custom-banner

0개의 댓글