사용 프레임워크 및 라이브러리
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
//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처리가 중점적이였다.