본문 바로가기

사이드 프로젝트

[React 프로젝트] 코드 리팩토링 과정, 퍼블리싱 변경

현재 진행 하고 있는 사이드 프로젝트의 퍼블리싱 변경 및 코드 리팩토링을 진행해보았다. 

 

AS-IS 화면

 

TO-BE 화면

 

여기서 내가 변경한건

 

1. Alert Modal 변경 > 기존에 공통으로 사용하던 Alert 모달을 버리고, 새로 만든 Alert 모달로 통일. 

2. 도감 이동을 클릭 하고 요청이 진행 중인 경우 로딩 스피너 띄우는 기능 추가

3. 변경된 퍼블리싱 적용

4. 코드 리팩토링

 

크게 이 네 가지이다.

 

1. Alert Modal 변경

Alert에서 사용하던 상태값을 Recoil에서 Usestate로 변경하고 버튼 퍼블리싱을 변경했다.

Recoil을 사용하니, 의도 하지 않은 리 렌더링이 일어날 때가 있었다. 

그래서 Usestate를 사용하는 Alert 컴포넌트로 바꾸고 해당 컴포넌트는 ButtonComps 와 분리하여 작성했다.

 

변경 전 코드

export function ButtonComps({ onSuccess }: { onSuccess: () => void }) {
  const [selectedPlants, setSelectedPlants] = useRecoilState<{ params: string[] }>(selectCol)
  const [drawerOpen, setDrawerOpen] = useState(false)
  const [closeInner, setCloseInner] = useState(false)
  const { deleteForm, isPending: isPendingDelete, isError: isErrorDelete, isSuccess } = useDeleteItemInToCollection()
  const { collectionId } = useParams()
  const [alert, setAlert] = useRecoilState(alertState)

  useEffect(() => {
    if (isSuccess === true) {
      onSuccess()
    }
  }, [isSuccess])

  const deletePlant = () => {
    if (isPendingDelete || !collectionId) return
    deleteForm(collectionId, selectedPlants.params)
  }

  return (
    <>
      <Alert afterCloseAlert={deletePlant} />
      <Box sx={{ p: CONTENT_PADDING, pb: 0, pt: CONTENT_PADDING_TOP }}>
        {selectedPlants.params.length > 0 && (
          <FixedButtonLayout bottom="100px">
            <DualButtonLayout styleID="div_dual_btn">
              <CustomButton
                onClick={() => {
                  if (selectedPlants.params && selectedPlants.params.length > 0) {
                    setDrawerOpen(true)
                  }
                }}
                styleID="btn_dual_01"
                type="button"
                height=""
                width=""
              >
                다른 도감으로 옮기기
              </CustomButton>
              <CustomButton
                onClick={() => {
                  setAlert({
                    showAlert: true,
                    title: '도감을 삭제하시겠어요?',
                    discription: '도감 내 모든 식물도 함께 삭제됩니다.',
                  })
                }}
                styleID="btn_dual_02"
                type="button"
                height=""
                width=""
              >
                삭제하기
              </CustomButton>
            </DualButtonLayout>
          </FixedButtonLayout>
        )}
      </Box>
      <DrawerLayout
        title="내 도감 목록"
        height="60%"
        pb={0}
        pt={0}
        open={drawerOpen}
        onClose={() => {
          setDrawerOpen(false)
          setCloseInner(false)
        }}
        closeInner={closeInner}
        children={
          <>
            <CollectionItemMoveListMain
              type="MOVE" // MOVE, ADD
              onFinish={() => {
                setCloseInner(true)
                onSuccess()
              }}
            />
          </>
        }
      />
    </>
  )
}

 

변경 후 코드

export function ButtonComps({ onSuccess }: { onSuccess: () => void }) {
  const [selectedPlants, setSelectedPlants] = useRecoilState<{ params: string[] }>(selectCol)
  const [drawerOpen, setDrawerOpen] = useState(false)
  const [closeInner, setCloseInner] = useState(false)
  const { deleteForm, isPending: isPendingDelete, isError: isErrorDelete, isSuccess } = useDeleteItemInToCollection()
  const { collectionId } = useParams()

  const [openAlert, setOpenAlert] = useState(false)

  useEffect(() => {
    if (isSuccess === true) {
      onSuccess()
    }
  }, [isSuccess])

  const deletePlant = () => {
    if (isPendingDelete || !collectionId) return
    deleteForm(collectionId, selectedPlants.params)
  }

  return (
    <>
      <PlazaDeleteAlert
        closeAlert={() => {
          setOpenAlert(false)
        }}
        deletePlant={deletePlant}
        openProp={openAlert}
      />
      <Box sx={{ p: CONTENT_PADDING, pb: 0, pt: CONTENT_PADDING_TOP }}>
        {selectedPlants.params.length > 0 && (
          <FixedButtonLayout zi="1000" bottom="0px">
            <ButtonLayout>
              <ButtonDiv
                onClick={() => {
                  if (selectedPlants.params && selectedPlants.params.length > 0) {
                    setDrawerOpen(true)
                  }
                }}
              >
                <ItemMove alt="도감 이동" src={itemMoveIcon} />
                <ButtonLabel>도감 이동</ButtonLabel>
              </ButtonDiv>
              <ButtonDiv
                onClick={() => {
                  setOpenAlert(true)
                }}
              >
                <ItemDelete alt="삭제" src={itemDeleteIcon} />
                <ButtonLabel>삭제</ButtonLabel>
              </ButtonDiv>
            </ButtonLayout>
          </FixedButtonLayout>
        )}
      </Box>
      <DrawerLayout
        title="내 도감 목록"
        height="60%"
        pb={0}
        pt={0}
        open={drawerOpen}
        onClose={() => {
          setDrawerOpen(false)
          setCloseInner(false)
        }}
        closeInner={closeInner}
        children={
          <>
            <CollectionItemMoveListMain
              type="MOVE" // MOVE, ADD
              onFinish={() => {
                setCloseInner(true)
                onSuccess()
              }}
            />
          </>
        }
      />
    </>
  )
}
function PlazaDeleteAlert({
  openProp,
  deletePlant,
  closeAlert,
}: {
  openProp: boolean
  deletePlant: () => void
  closeAlert: () => void
}) {
  return (
    <>
      <Alert2Modal
        title="식물을 삭제하시겠어요?"
        description="선택한 식물들이 삭제됩니다."
        open={openProp}
        afterCloseAlert={() => {
          deletePlant()
          closeAlert()
        }}
        handleClose={() => {
          closeAlert()
        }}
      />
    </>
  )
}

 

2. 도감 이동을 클릭 하고 요청이 진행 중인 경우 로딩 스피너 띄우는 기능 추가

변경 전 코드를 보면 알겠지만, 도감 이동 시에 요청이 돌아가고 있을 때, 

버튼의 label을 통해서 로딩중인지 아닌지를 구분했다.

그런데, 버튼의 텍스트가 잘 안보이기도 하고 

사용자가 버튼을 클릭하는 행위 자체를 막는게 나을 것 같아서 로딩스피너로 바꾸게 되었다. 

 

이 컴포넌트를 도감에 새로 등록할 때, 이전 도감에서 새로운 도감으로 이동할 때 

두 가지 상황에서 같이 이용하고 있는데 

변경 전 코드를 보니 등록하는 요청 값만 구분하고 있고 

이동 할 때의 요청 상태는 확인을 안하고있더라.. 그래서 그 부분 또한 추가했다.

 

변경 전 코드

import FormMain from 'src/components/common/form/FormMain'
import CollectionItemMoveList from './CollectionItemMoveList'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useAddItemInToCollection, useMoveItem } from '../hook/CollectionListCustomHook'
import { selectCol } from 'src/utils/common/state/state'
import { useRecoilState } from 'recoil'

export default function CollectionItemMoveListMain({
  onFinish,
  type,
  plantId,
}: {
  plantId?: number | string | null
  type: string
  onFinish: () => void
}) {
  const [selected, setSelected] = useState<null | number>(null)
  const [selectedPlants, setSelectedPlants] = useRecoilState<{ params: string[] }>(selectCol)
  const { itemId } = useParams()
  const { createForm, isSuccess, isPending } = useAddItemInToCollection()
  const { moveError, moveSuccess, moveItem, moving } = useMoveItem()

  useEffect(() => {
    if (isSuccess) {
      onFinish()
    }
  }, [isSuccess])
  useEffect(() => {
    if (moveError) {
      onFinish()
    }
  }, [moveError])
  useEffect(() => {
    if (moveSuccess) {
      onFinish()
    }
  }, [moveSuccess])
  return (
    <>
      <CollectionItemMoveList
        onSelect={(id: number) => {
          setSelected(id)
        }}
      />
      <FormMain.Button
        onClick={() => {
          if (isPending || moving) return
          if (selected) {
            // type="ADD"
            if (type === 'ADD' && (itemId || plantId)) {
              if (itemId) createForm(selected, [itemId])
              else if (plantId) createForm(selected, [plantId])
            } else {
              moveItem(selected, selectedPlants.params)
            }
          }
        }}
        styleID={selected ? 'btn_submit_01_active' : 'btn_submit_01'}
        width="100%"
        type="submit"
      >
        {isPending ? '...' : '선택 완료'}
      </FormMain.Button>
    </>
  )
}

 

변경 후 코드

 

import CollectionItemMoveList from './CollectionItemMoveList'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useAddItemInToCollection, useMoveItem } from '../hook/CollectionListCustomHook'
import { selectCol } from 'src/utils/common/state/state'
import { useRecoilState } from 'recoil'
import FullLoadingSpinner from 'src/components/common/container/FullLoadingSpinner'

export default function CollectionItemMoveListMain({
  onFinish,
  type,
  plantId,
}: {
  plantId?: number | string | null
  type: string
  onFinish: () => void
}) {
  const [selected, setSelected] = useState<null | number>(null)
  const [selectedPlants, setSelectedPlants] = useRecoilState<{ params: string[] }>(selectCol)
  const { itemId } = useParams()
  const { createForm, isSuccess, isPending } = useAddItemInToCollection()
  const { moveError, moveSuccess, moveItem, moving } = useMoveItem()

  useEffect(() => {
    if (isSuccess) {
      onFinish()
    }
  }, [isSuccess])

  useEffect(() => {
    if (moveError) {
      onFinish()
    }
  }, [moveError])

  useEffect(() => {
    if (moveSuccess) {
      onFinish()
    }
  }, [moveSuccess])

  return (
    <>
      <CollectionItemMoveList
        onSelect={(id: number) => {
          if (isPending || moving) return
          if (id) {
            if (type === 'ADD' && (itemId || plantId)) {
              if (itemId) createForm(id, [itemId])
              else if (plantId) createForm(id, [plantId])
            } else {
              moveItem(id, selectedPlants.params)
            }
          }
          setSelected(id)
        }}
      />
      {(isPending || moving) && <FullLoadingSpinner isShow={true} />}
    </>
  )
}

 

추가한 로딩스피너..

 

3. 변경된 퍼블리싱 적용 및 코드 리팩토링

퍼블리싱은 코드 리팩토링을 하면서 동시에 진행을 했다.

 

3 - 1 공통된 이동 버튼에서 > 아이템 별 이동 버튼으로 변경

위 아이템을 띄워주는 컴포넌트에 버튼을 추가 해주면 되는건데, 

위 컴포넌트를 여러 곳에서 재 사용을 하기 때문에 

버튼이 필요한 상황인지 아닌지를 담는 prop이 필요하다.

 

코드를 보면 type prop을 가져왔다. 

type 은 도감 이동 상태가 "ADD" (새로운 등록), "MOVE"(이동)

이냐 이 두 가지 상태값을 받는건데 이 프롭을 새로 추가했다. 

 

그리고 받은 type에 따라 button의 label 이 이동인지 저장인지를 prop을 통해 내려주는 방식을 택했다.

 

3 -2 또 컴포넌트를 반환 할 때, 중복되는 if문을 없앴다. 

기존에는 

로딩인 경우 => 로딩 스피너를 리턴 

리스트가 없는 경우 => 도감이 없어요 리턴

리스트가 있는 경우 => 도감 리턴

그 외 => 도감이 없어요 리턴

 

이었는데, 그외의 조건문이랑 리스트가 없는 경우랑 같은 컴포넌트를 중복해서 작성했기 때문에

 

로딩인 경우 => 로딩 스피너를 리턴 

리스트가 있는 경우 => 도감 리턴

그 외 => 도감이 없어요 리턴

로 변경을 진행했다. 

 

3 - 3 style 코드는 styled compent로 뺐다. 

자세히 보면 img 태그를 작성할 때 style코드를 같이 작성하였는데, 

코드가 좀더 간결하게 읽힐려면 style코드는 따로 분리해서 작성하는게 좋겠다고 판단 되었다. 

 

 

변경전 코드

import PlantItemLayout from 'src/components/common/layout/PlantItemLayout'
import PlantListLayout from 'src/components/common/layout/PlantListLayout'
import { SAMPLE_COLLECTION } from 'src/utils/common/constants/constants'
import { CollectionType, ItemObjType, plantTypeType } from 'src/utils/common/type/type'
import MyPlantSample from '../../../../assets/images/plant/MyPlantSample.png'
import { useState } from 'react'
import { useCollectionInfo } from '../hook/CollectionListCustomHook'
import { generateRandomKey } from 'src/utils/common/scripts/common'
import LoadingSpinner from 'src/components/common/container/LoadingSpinner'
import NoData from 'src/components/common/content/Nodata'
import sample from '../../../../assets/images/collection/collectionHeaderImg.png'
import NoDataContainer from 'src/components/common/container/NoDataContainer'

export default function CollectionItemMoveList({
  onSelect,
  padding = '16px 0px 16px 14px',
}: {
  padding?: string
  onSelect?: (id: number) => void
}) {
  const [selectedItem, setSelectedItem] = useState<string | number>('')
  const { isSuccess, collectionList, isPending } = useCollectionInfo()

  if (isPending) {
    return (
      <>
        <LoadingSpinner />
      </>
    )
  }
  if (collectionList && collectionList?.data && collectionList.data.myEncyclopediaList) {
    if (collectionList.data.myEncyclopediaList.length === 0) {
      return (
        <PlantListLayout height="339px">
          <NoDataContainer title="아직 등록된 도감이 없어요!" discription="관심있는 식물을 도감에 저장해 보세요!" />
        </PlantListLayout>
      )
    } else {
      return (
        <PlantListLayout height="339px">
          {collectionList?.data.myEncyclopediaList.map((item: CollectionType, index: number) => (
            <PlantItemLayout
              height="92px"
              padding={padding}
              onClick={() => {
                setSelectedItem(item.id)
                onSelect?.(item.id)
              }}
              eng={`${item?.plantCount.toString()} 개의 식물`}
              name={item.title}
              key={generateRandomKey()}
              className={
                selectedItem === item.id ? (index === 0 ? 'selected first' : 'selected') : index === 0 ? 'first' : ''
              }
            >
              <img
                src={item.coverImageUrl ? item.coverImageUrl : sample}
                alt="이미지태그"
                style={{
                  width: '60px',
                  height: '60px',
                }}
              />
            </PlantItemLayout>
          ))}
        </PlantListLayout>
      )
    }
  } else {
    return (
      <PlantListLayout height="339px">
        <NoDataContainer title="아직 등록된 도감이 없어요!" discription="관심있는 식물을 도감에 저장해 보세요!" />
      </PlantListLayout>
    )
  }
}

 

변경 된 코드

import PlantItemLayout from 'src/components/common/layout/PlantItemLayout'
import PlantListLayout from 'src/components/common/layout/PlantListLayout'
import { CollectionType } from 'src/utils/common/type/type'
import { useState } from 'react'
import { useCollectionInfo } from '../hook/CollectionListCustomHook'
import { generateRandomKey } from 'src/utils/common/scripts/common'
import LoadingSpinner from 'src/components/common/container/LoadingSpinner'
import sample from '../../../../assets/images/collection/collectionHeaderImg.png'
import styled from 'styled-components'
import NoDataContainer2 from 'src/components/common/container/NoDataContainer2'

export default function CollectionItemMoveList({
  onSelect,
  padding = '16px 0px 16px 14px',
  type,
}: {
  padding?: string
  onSelect?: (id: number) => void
  type: string
}) {
  const [selectedItem, setSelectedItem] = useState<string | number>('')
  const { isSuccess, collectionList, isPending } = useCollectionInfo()

  if (isPending) {
    return (
      <>
        <LoadingSpinner />
      </>
    )
  }
  if (
    collectionList &&
    collectionList?.data &&
    collectionList.data.myEncyclopediaList &&
    collectionList.data.myEncyclopediaList.length !== 0
  ) {
    return (
      <PlantListLayout height="339px">
        {collectionList?.data.myEncyclopediaList.map((item: CollectionType, index: number) => (
          <PlantItemLayout
            key={generateRandomKey()}
            buttonTitle={type === 'ADD' ? '저장' : '이동'}
            height="92px"
            padding={padding}
            onClick={() => {
              setSelectedItem(item.id)
              onSelect?.(item.id)
            }}
            name={item.title}
            eng={`${item?.plantCount.toString()} 개의 식물`}
            className={
              selectedItem === item.id ? (index === 0 ? 'selected first' : 'selected') : index === 0 ? 'first' : ''
            }
          >
            <Img src={item.coverImageUrl ? item.coverImageUrl : sample} alt="이미지태그" />
          </PlantItemLayout>
        ))}
      </PlantListLayout>
    )
  } else {
    return (
      <PlantListLayout height="339px">
        <NoDataContainer2
          ac="center"
          title="아직 등록된 도감이 없어요!"
          discription="관심있는 식물을 도감에 저장해 보세요!"
        />
      </PlantListLayout>
    )
  }
}

const Img = styled.img`
  width: 60px;
  height: 60px;
`

 

 

하위 컴포넌트

import styled from 'styled-components'
import { CustomButton } from '../button/CustomButton'

type PlantItemLayoutType = {
  children: React.ReactNode
  buttonTitle?: string
  name: string
  eng?: string
  onClick: () => void
  className: string
  firstmt?: string
  padding?: string
  height?: string
}

export default function PlantItemLayout({
  buttonTitle,
  firstmt = '17px',
  padding = '10px 0px 10px 14px',
  children,
  name,
  eng = '',
  onClick,
  className,
  height = '80px',
}: PlantItemLayoutType) {
  return (
    <PlantItemContainer
      $height={height}
      $firstmt={firstmt}
      $pd={padding}
      onClick={() => {
        onClick()
      }}
      className={className}
    >
      {children}
      <PlantText name={name} eng={eng} />
      {buttonTitle && (
        <CustomButton
          onClick={() => {
            onClick()
          }}
          styleID="btn_collection_move"
          type="button"
        >
          {buttonTitle}
        </CustomButton>
      )}
    </PlantItemContainer>
  )
}

export function PlantText({ name, eng }: { name: string; eng?: string }) {
  return (
    <TextContainer>
      <PlantTitle>{name}</PlantTitle>
      {eng && <PlantEngTitle>{eng}</PlantEngTitle>}
    </TextContainer>
  )
}