React Message Service(리펙토링)

김지환·2022년 10월 16일
0
post-thumbnail

사용 프레임워크 및 라이브러리

  • React
  • Recoil
  • MUI-datagrid
  • chartJS
  • lodash / moment / ckeditor4 / idleSession / qs / xlsx
  • google analytics

Project 구조

  • main => App => layout / sideMenu => views / components

  • main에서 라우터와 상태를 설정하고 관리한다.
    그리고 전체적인 구성을 layout과 recoil 설정 등등 pages들로 UI를 나누어 구성한다.

  • App.jsx는 라우터와 토큰(JWT) 부분을 설정해준다.

  • layout 컴포넌트를 따로 빼서 sideMenu 및 예하 컴포넌트로 나뉘게 설정해준다.

  • 페이지들은 크게 메인과 관리메뉴들 및 통계로 나뉜다.

  • 메인과 통계 페이지에서는 chartJS를 사용하였는데 라이브러리를 사용하여 편리하게 적용시켰다.

Recoil (JWT / login / IdleSession)

  • 전체적인 전역 상태를 관리해주는 라이브러리이다.
    atoms.js라는 파일을 만들어 설정해주고 사용한다.
    로그인 정보와 사이드메뉴 및 회원넘버 및 권한을 사용하였다.
    또한, recoilPersist라는 비동기 작업을 위해 사용해주었고 IdleSession을 사용하여 자동 로그아웃을 적용하였다.

     //atoms.js
     import { atom } from "recoil"
     import { recoilPersist } from "recoil-persist"
     import { IdleSessionTimeout } from "idle-session-timeout"
    
     const { persistAtom } = recoilPersist()
     export const logoutDeadLineConstructer = new IdleSessionTimeout(60 * 60 * 1000)
    
     // Set Login Info
     export const loginInfoState = atom({
       key: "loginInfoState",
       default: {},
       effects: [persistAtom],
     })
    
     // LoginCheck Info
     export const isLoginCheckState = atom({
       key: "isLoginCheckState",
       default: {
         isLogin : false
       },
       effects: [persistAtom],
     })
    
     // 로그인 기한 만료
     export const isLoginDeadLineState = atom({
       key: "isLoginDeadLineState",
       default: {
         isDead : false
       },
       effects: [persistAtom],
     })
    
     // Set Sidebar
     export const sideBarState = atom({
       key: "sideBarState",
       default: {
         currentSeqNo: 0,
       },
     })
    
     export const sideSubBarState = atom({
       key: "sideSubBarState",
       default: {
         currentSeqNo: 0,
       },
     })
    
     // Set MemberNo
     export const memberNumberState = atom({
       key: "memberNumberState",
       default: {
         currentMemberNo: "",
       },
     })

페이지에서 recoil 사용법이다.

  • /header.jsx
    import { useRecoilState, useSetRecoilState } from "recoil"
    import {
      loginInfoState,
      logoutDeadLineConstructer,
      sideBarState,
      sideSubBarState,
      memberNumberState,
      isLoginDeadLineState,
      isLoginCheckState
    } from "@/assets/script/atoms"
    
    const [loginInfo, setLoginInfo] = useRecoilState(loginInfoState)
    
      useEffect(() => {
      const accessToken = window.localStorage.getItem("accessTokenState")
      ajax.setAccessToken(accessToken)
      logoutSessionStart()
    }, [])
    
    
    if (result.data.code === "0000") {
      setIsLoginCheck({ isLogin: false })
      setLoginInfo({})
      setSideMenuState(0)
      setSideSubMenuState(0)
      setMemberNoState("")
      window.localStorage.setItem("accessTokenState", "")
      window.localStorage.setItem("refreshTokenState", "")
      navigate("/login")
    }
    
    logoutDeadLineConstructer.onTimeOut = () => {
      // 여기에서 서버를 호출하여 사용자를 로그아웃할 수 있습니다.
      handleLogOut("deadlineExpired")
    }
     	<p>
            관리자 <span>[{loginInfo.mngrNm}]</span>
          </p>

Method / Library

  • qs

      axios.defaults.baseURL = import.meta.env.VITE_BASE_API_URL
      axios.defaults.paramsSerializer = params => {
        const result = qs.stringify(params, { arrayFormat: "repeat" })
        return result
      }

    => api와 통신할때 url에 배열 query문을 보내준다.

  • lodash

    _.uniqBy(배열변수, '고유속성이름') => 중복값을 제거 (데이터 한개일때)
    _.unionBy(변수1,변수2, '') => 배열 데이터를 합쳐 중복을 제거한다.
    _.find(배열, {찾을 객체 데이터})
    _.findIndex(배열, {찾을 객체 데이터})
    _.remove(배열,{})
    _.cloneDeep(value)

=> lodash의 문법 및 속성에 맞게 array, collection, date 등 데이터의 필수적인 구조를 쉽게 다룰수있게 해주며 배열 안의 객체들을 handling할때 유용하여 코드를 줄여주며, 빠른 작업에 도움을 준다.

  • moment

    import moment from "moment"
    
    const moment = require("moment");
    var date = moment("2021-10-09");
    date.format();	// 2021-10-09T00:00:00+09:00
    var now = moment();
    now.format();	// 2021-10-09T00:09:45+09:00
    now.format("YY-MM-DD");	// 21-10-09

=> javascript 날짜 라이브러리로 형태에 따라서 포멧해준다.

  • CkEditor4

    "ckeditor4-react": "^4.0.0",
    import { CKEditor } from "ckeditor4-react"
    import CKeditorComponent from "@/components/common/CKeditorComponent"
    
        function CKeditorComponent({ onChange = () => {}, data, isEditorplaceholder = false, validatorTitle = "내용", isValidators = false }) {
    const [loading, setLoading] = useState(true)
    
    return (
      <div className="detailEditor" style={{ minHeight: 250, position: "relative" }}>
        {loading && (
          <Box sx={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", width: "100%", justifyContent: "center" }}>
            <CircularProgress thickness={3} size={30} />
          </Box>
        )}
        <CKEditor
          config={{
            filebrowserUploadUrl: `${import.meta.env.VITE_BASE_API_URL}/api/common/file/ckupload`,
            imageUploadUrl: `${import.meta.env.VITE_BASE_API_URL}/api/common/file/ckupload`,
            filebrowserUploadMethod: "xhr",
            extraPlugins: ["font", "colorbutton", "justify", "find", "emoji", "editorplaceholder"],
            allowedContent: true,
            height: 600,
            editorplaceholder: isEditorplaceholder === true ? "※ 표 등록 시, [표 속성]의 [너비] 항목을 450으로 지정해 주시기 바랍니다." : ""
          }}
          onChange={event => onChange(event)}
          onInstanceReady={() => setLoading(false)}
          initData={data}
        />
        {isValidators && (
          <TextValidator
            className="editorValidators"
            sx={{ width: "100%", border: 0 }}
            validators={["required"]}
            value={data}
            errorMessages={[isEmptyErrorMessage(validatorTitle)]}
          />
        )}
      </div>
    )
    }
    
    export default CKeditorComponent

=> 컴포넌트화 시켜서 실제로 이렇게 한줄로 사용하고, confing 설정과 상태관리를 해줄수있다.
<CKeditorComponent name={"evtCotn"} onChange={handleChangeCKEditor} data={evtCotn_v} isValidators={true} />

  • xlsx

      export default defineConfig({
    plugins: [react()],
    resolve: {
      alias: {
        "@": path.resolve(__dirname, "./src"),
      },
    },
    build: {
      rollupOptions: {
        external: ['xlsx'],
      },
    },

    })
    => vite build에서 xlsx파일을 json 변환해주어 빌드해준다.

  • MUI datagrid

      <CommonDatagrid
                  columns={columns}
                  rows={datagridList}
                  datagridSelectedChangeEvent={datagridSelectedChangeEvent}
                  isVisibleCheckbox={false}
                />

    => vite build에서 xlsx파일을 json 변환해주어 빌드해준다.

API

  • axios를 사용하여 서버와의 통신을 통해 데이터를 받아온다.

    **get, put, post, delete 총 4가지를 사용하였다.

    **ajax.js파일에서 http axios 함수들을 셋팅해주고,
    따로 service라는 폴더를 만들어 api가 필요한 페이지마다 컴포넌트화 시켜준다.

//ajax.js
// get function
export async function get(url, params = {}, isDisplayLoadingGauge = false) {
  visibleLoadingGauge(isDisplayLoadingGauge)
  try {
    const config = {
      params: params
    }
    removePending(config, url)
    addPending(config, url)

    const response = await axios.get(url, config)
    hiddeLoadingGauge()

    removePending(response?.config, url)
    // custom error
    if (response?.data?.code !== "0000") {
      if (response?.data?.code === undefined) return

      bizError(response?.data)
    } else {
      console.log(response)
      return response
    }
  } catch (error) {
    httpError(error)
  }
}
  • service / apis

    **기본적인 4가지 함수의 사용법들은 밑에 notice api와 같다.

import * as ajax from "@/assets/script/ajax.js"

// Notice 목록 조회
export const fetchNoticeList = async (params = {}, isDisplayLoadingGauge = false) => {
  return await ajax.get("/api/bo/board/notice", params, isDisplayLoadingGauge)
}

// Notice 조회
export const fetchNotice = async (path = "", params = {}, isDisplayLoadingGauge = false) => {
  return await ajax.get("/api/bo/board/notice" + path, params, isDisplayLoadingGauge)
}

// Notice 등록
export const registNotice = async (params, isDisplayLoadingGauge = false) => {
  return await ajax.post("/api/bo/board/notice", params, isDisplayLoadingGauge)
}

// Notice 수정
export const modifyNotice = async (params, isDisplayLoadingGauge = false) => {
  return await ajax.put("/api/bo/board/notice", params, isDisplayLoadingGauge)
}

// Notice 삭제
export const removeNotice = async (path = "", params = {}, isDisplayLoadingGauge = false) => {
  return await ajax.del("/api/bo/board/notice?ntcNoList=" + path, params, isDisplayLoadingGauge)
}
  • swagger에서 param 대신 path가 필요하거나 임의로 정해진 형태로 써줘야 하는 경우도 있다.

    // 환불신청 목록
    export const fetchRefundList = async (params = {}, isDisplayLoadingGauge = false) => {
      return await ajax.get("/api/bo/refund", params, isDisplayLoadingGauge)
    }
    
    // 환불신청 접수
    export const modifyStateStandbyToReceipt = async (rfdNo, mbId, params = {}, isDisplayLoadingGauge = false) => {
      return await ajax.post(`/api/bo/refund/${rfdNo}/${mbId}/confirm`, params, isDisplayLoadingGauge)
    }
    
    // Policy 목록 조회
    export const fetchPolicyList = async (polcTermsCd, params = {}, isDisplayLoadingGauge = false) => {
      return await ajax.get(`/api/bo/policy/terms/${polcTermsCd}`, params, isDisplayLoadingGauge)
    }
    
    // Policy 조회
    export const fetchPolicy = async (polcTermsCd, params = {}, isDisplayLoadingGauge = false) => {
      return await ajax.get(`/api/bo/policy/terms/detail/${polcTermsCd}`, params, isDisplayLoadingGauge)
    }
     try {
    const params = {
      aplStartDt: aplStartDt,
      verNm: verNm,
    }
    const result = await fetchPolicy(polcTermsCd, params)

LifeCycle

Project Pages / Components

  • Login
  • Header
  • DM발송건수관리
  • 운영권한관리
  • 약관/이력관리
  • 포인트관리
  • 전시관리
  • 표준단가관리
  • ) Login
    유저의 로그인과 회원가입을 도와주는 페이지이다.
    쿠키와 로컬스토리지, idleSession을 사용하여 아이디 저장과 로그인, 기한만료 기능을 만들었고, recoil을 사용해서 글로벌로 상태관리를 해주었다.
    그리고 로그인시 JWT토큰을 받아 이용하였고 만료시 refresh토큰을 다시 받아 사용하였으며,
    로그인, 회원가입, 로그아웃, 초기화, 인증을 api로 통신해주었다.
  	//ajax.js
    
    // axios default config
    axios.defaults.baseURL = import.meta.env.VITE_BASE_API_URL
    axios.defaults.paramsSerializer = params => {
      const result = qs.stringify(params, { arrayFormat: "repeat" })
      return result
    }

    // setAccessToken function
    export async function setAccessToken(accessToken) {
      axios.defaults.headers.common["Authorization"] = accessToken
    }

    // axios cancelToken
    const pending = new Map()
    const addPending = (config, requestUrl) => {
      const url = [config, requestUrl].join("&")
      config.cancelToken =
        config.cancelToken ||
        new axios.CancelToken(cancel => {
          if (!pending.has(url)) {
            pending.set(url, cancel)
          }
        })
    }
    const removePending = (config, requestUrl) => {
      const url = [config, requestUrl].join("&")
      if (pending.has(url)) {
        const cancel = pending.get(url)
        cancel(url)
        pending.delete(url)
      }
    }

    // axios tokenRefreshing
    let isTokenRefreshing = false
    let refreshSubscribers = []
    const onTokenRefreshed = accessToken => {
      refreshSubscribers.map(callback => callback(accessToken))
    }
    const addRefreshSubscriber = callback => {
      refreshSubscribers.push(callback)
    }

    axios.interceptors.response.use(
      response => {
        return response
      },
      async error => {
        if (error?.code === "ERR_CANCELED") return

        const {
          config,
          response: { status }
        } = error
        const originalRequest = config
        if (status === 401) {
          const retryOriginalRequest = new Promise(resolve => {
            addRefreshSubscriber(accessToken => {
              originalRequest.headers.Authorization = accessToken
              resolve(axios(originalRequest))
            })
          })
          if (!isTokenRefreshing) {
            // isTokenRefreshing이 false인 경우에만 token refresh 요청
            isTokenRefreshing = true

            const localStorageItems = JSON.parse(window.localStorage.getItem("recoil-persist"))
            const refreshToken = window.localStorage.getItem("refreshTokenState")
            const params = {
              memberId: localStorageItems.loginInfoState.lgnId,
              managerId: localStorageItems.loginInfoState.mngrId,
              refreshToken: refreshToken
            }

            const result = await axios.post("api/common/refresh/token", params)
            const newAccessToken = result.data.result

            isTokenRefreshing = false

            window.localStorage.setItem("accessTokenState", newAccessToken)
            axios.defaults.headers.common["Authorization"] = newAccessToken

            onTokenRefreshed(newAccessToken)
          }

          return retryOriginalRequest
        } else if (status === 403) {
          window.localStorage.removeItem("refreshTokenState")
          window.localStorage.removeItem("accessTokenState")
          window.localStorage.removeItem("recoil-persist")

          window.location = "/login"
        }
        return Promise.reject(error)
        }
        )
        
        
    //atom.js
    
    import { atom } from "recoil"
    import { recoilPersist } from "recoil-persist"
    import { IdleSessionTimeout } from "idle-session-timeout"

    const { persistAtom } = recoilPersist()
    export const logoutDeadLineConstructer = new IdleSessionTimeout(60 * 60 * 1000)

    // Set Login Info
    export const loginInfoState = atom({
      key: "loginInfoState",
      default: {},
      effects: [persistAtom]
    })

    // LoginCheck Info
    export const isLoginCheckState = atom({
      key: "isLoginCheckState",
      default: {
        isLogin: false
      },
      effects: [persistAtom]
    })
  • 1) Header.jsx
    로그인 후 메인 페이지로 들어와서 사이드메뉴와 로그아웃등 전반적인 글로벌 상태관리를 같이해주는 페이지이다.

        import * as React from "react"
        import { useEffect } from "react"
        import { useNavigate } from "react-router-dom"
        import Stack from "@mui/material/Stack"
        import Button from "@mui/material/Button"
        import { convertDateYYYYMMDDHH24MISS, isEmpty, getTodayHHMM } from "@/assets/script/utils"
        import * as ajax from "@/assets/script/ajax.js"
        //api
        import { fetchLogout } from "@/services/login"
        // 커스텀훅
        import useCommonPop from "@/hooks/useCommonPop"
        // 공통상수
        import { useRecoilState, useSetRecoilState } from "recoil"
        import { loginInfoState, logoutDeadLineConstructer, sideBarState, sideSubBarState, memberNumberState, isLoginCheckState } from "@/assets/script/atoms"
    
        function Header() {
          const navigate = useNavigate()
          const [loginInfo, setLoginInfo] = useRecoilState(loginInfoState)
          const [sideMenuState, setSideMenuState] = useRecoilState(sideBarState)
          const [sideSubMenuState, setSideSubMenuState] = useRecoilState(sideSubBarState)
          const [memberNoState, setMemberNoState] = useRecoilState(memberNumberState)
          const setIsLoginCheck = useSetRecoilState(isLoginCheckState)
    
          // 공통팝업관련 커스텀훅
          const {
            isOpen,
            popTitle,
            multiBtnOpt,
            confirmFunc,
            cancelFunc,
            fnPopUpOpen: fnPopUpOpen,
            handleClickPopConfirmProc,
            OpenCloseBasicPopupComponent,
          } = useCommonPop(false, "로그인정보", false, null, null)
    
          const handleLogOut = async (order) => {
            const result = await fetchLogout()
            if (result.data.code === "0000" && order === "deadlineExpired") {
              // 기한만료로 로그아웃
              window.localStorage.setItem("isDead", true)
            }
    
            if (result.data.code === "0000") {
              setIsLoginCheck({ isLogin: false })
              setLoginInfo({})
              setSideMenuState(0)
              setSideSubMenuState(0)
              setMemberNoState("")
              window.localStorage.removeItem("accessTokenState")
              window.localStorage.removeItem("refreshTokenState")
              window.localStorage.removeItem("timeLimit")
              navigate("/login")
            }
          }
          const logoutSessionStart = () => {
            logoutDeadLineConstructer.dispose()
            logoutDeadLineConstructer.start()
          }
    
          logoutDeadLineConstructer.onTimeOut = () => {
            // 여기에서 서버를 호출하여 사용자를 로그아웃할 수 있습니다.
            handleLogOut("deadlineExpired")
          }
          // 로그아웃까지 남은시간 체크
          // logoutDeadLineConstructer.onTimeLeftChange = (timeLeft) => {
          // console.log(`${Math.round(timeLeft / 1000)} ms left`);
          // };
    
          const browserUnloadFn = () => {
            window.localStorage.removeItem("timeLimit")
            window.localStorage.setItem("timeLimit", getTodayHHMM())
          }
          const browserLoadFn = () => {
            const browserUnloadTime = new Date(convertDateYYYYMMDDHH24MISS(window?.localStorage?.getItem("timeLimit")))?.getTime()
            const browserloadTime = new Date(convertDateYYYYMMDDHH24MISS(getTodayHHMM()))?.getTime()
            const limitTime = 60 * 60 * 1000
    
            if (isNaN(browserUnloadTime) || isNaN(browserloadTime)) {
              return
            }
            if (browserloadTime - browserUnloadTime >= limitTime) {
              handleLogOut("deadlineExpired")
            }
    
            window.localStorage.removeItem("timeLimit")
          }
    
          useEffect(() => {
            browserLoadFn()
    
            const accessToken = window.localStorage.getItem("accessTokenState")
            ajax.setAccessToken(accessToken)
            logoutSessionStart()
    
            const recoilPersist = JSON.parse(window.localStorage.getItem("recoil-persist")).loginInfoState
            if (
              isEmpty(recoilPersist.authNo) ||
              isEmpty(recoilPersist.lastLoginRegDtt) ||
              isEmpty(recoilPersist.lgnId) ||
              isEmpty(recoilPersist.mngrId) ||
              isEmpty(recoilPersist.mngrNm) ||
              isEmpty(recoilPersist.mngrStsCd) ||
              isEmpty(recoilPersist.mngrStsCdNm)
            ) {
              handleLogOut()
            }
          }, [])
    
          window.addEventListener("unload", browserUnloadFn)
          return (
            <div className="wrap">
              <header>
                <div className="avatarBox">
                  <div>
                    <p>
                      관리자 <span>[{loginInfo.mngrNm}]</span>
                    </p>
                  </div>
                  <div>
                    {loginInfo.lastLoginRegDtt === null ? (
                      "최근접속 이력이 없습니다."
                    ) : (
                      <p>최근접속일시 : {convertDateYYYYMMDDHH24MISS(loginInfo.lastLoginRegDtt)}</p>
                    )}
                  </div>
                </div>
                <div className="logoutBox">
                  <Stack spacing={2} direction="row">
                    <Button size="small" variant="contained" color="primary" onClick={handleLogOut}>
                      LOGOUT
                    </Button>
                  </Stack>
                </div>
              </header>
    
              {
                <OpenCloseBasicPopupComponent
                  handleClickPopConfirmProc={handleClickPopConfirmProc}
                  confirmFunc={confirmFunc}
                  popCloseEvntGbn={false}
                  isOpen={typeof isOpen === "undefined" ? false : isOpen}
                  popTitle={popTitle}
                  multiBtnOpt={multiBtnOpt}
                />
              }
            </div>
          )
        }
    
        export default React.memo(Header)
  • 2) DM발송건수관리
    : 전반적인 발송건수를 관리하며 개인회원과 기업회원들의 SMS MMS 알림톡등의 발송상태와 발송일시등 이 프로젝트의 제일 핵심적인 부분을 관리해주는 페이지이다.

  • 3)운영/권한관리
    운영 계정에 대한 권한을 주는 페이지이다.
    대표 관리들이 있고 거기에 대한 체크박스들이 있는데, 그 체크박스를 처리하는 부분이 많았다.
    커스텀훅도 사용을 하였었는데 백엔드와 소통하여 어느부분에서 처리하는게 더 효율적인지를 판단해 api를 통해 받는 데이터를 활용했으면 더 좋은 클린코드를 짤 수 있을것같다.

  • 4) 약관/이력관리
    약관 이력관리는 useParams를 이용하여 API호출에 여러개의 params를 변형해서 던지는부분이 흥미로웠다. 그리고 FO쪽과 관련하여 바로 보여지는 부분이 많았고,
    기간에 대한 예외처리와 CKEDITOR를 활용한 마크업 스타일을 커스텀해줘야 하는 부분들이 많았다.

  • 5) 포인트관리
    포인트관리는 회원마다 부여해주는 포인트에 대한 기준을 나누는게 많았고, 예외처리와 금액적인 validation처리가 중점적이였다.

  • 6) 전시관리
    전시관리는 배너와 팝업부분이 있었는데 노출순서를 정해주는부분과 전시기간에 따른 이벤트를 걸어주는 부분들이 중점적이였다.
  • 7) 표준단가관리
    DM발송에 대한 전반적인 건수를 관리해주는 페이지로 SMS MMS 알림톡등이 있다.
profile
Web Developer

0개의 댓글