스터디 레포트 202403 #5

Table of Contents

[TOC]

[ ←전편보기 / 1편, 2편, 3편, 4편, 6편 / 다음편보기→ ]

결과물 git주소 : https://gitlab.ntiple.com/developers/study-202403

github : https://github.com/lupfeliz/study-202403

1. 전편에 이어.....

이러저러한 일로 포스팅을 미뤄 왔더니 1달이 지나버렸다.. ㄷㄷㄷ

그동안 맘에 안드는 부분이 발견되어 기존 소스를 변경한 부분도 생겨버렸고....

그래서 이전 포스팅 수정도 해야 했고.....

크흠. 여튼 그래서 이전 내용에 이어, 이번에는 화면 구성요소 컴포넌트 와 샘플 그리고 간단한 통신 예제까지 작성해 보겠다.

내용이 아주초큼 길것으로 예상되니 이번에도 부가적인 설명은 소스 내 주석으로 처리하겠다.

이번 포스팅에서 작성/수정되는 파일들은 다음과 같다

└── src
    ├── components
    │   ├── checkbox.tsx (체크박스 컴포넌트)
    │   ├── editor.tsx (편집기 컴포넌트)
    │   ├── index.ts (컴포넌트 barrel, 내용추가)
    │   ├── input.tsx (입력 컴포넌트)
    │   ├── link.tsx (링크 컴포넌트)
    │   ├── lottie.tsx (Lottie 애니메이션 컴포넌트)
    │   ├── pagination.tsx (데이터 분할 페이징 컴포넌트)
    │   └── select.tsx (선택기 컴포넌트)
    ├── libs
    │   ├── api.ts (통신 라이브러리)
    │   └── user-context.ts (사용자정보 라이브러리)
    └── pages
        └── smp
            ├── index.jsx (메인화면)
            ├── smp01001s01.jsx (화면 컴포넌트 샘플)
            └── smp01001s02.jsx (통신 예제 샘플)

2. 화면 구성 컴포넌트

2-1. checkbox.tsx (체크박스 컴포넌트)

import _Checkbox, { CheckboxProps as _CheckboxProps } from '@mui/material/Checkbox'
import _Radio, { RadioProps as _RadioProps } from '@mui/material/Radio'
import app from '@/libs/app-context'
import * as C from '@/libs/constants'

/** mui 기본 체크박스(라디오버튼) 속성 타입 상속  */
type CheckboxProps = _CheckboxProps & _RadioProps & {
  model?: any
  type?: 'checkbox' | 'radio'
}

const { useRef, copyExclude, copyRef, useSetup, defineComponent, modelValue } = app

export default defineComponent((props: CheckboxProps, ref: CheckboxProps['ref'] & any) => {
  const pprops = copyExclude(props, ['model'])
  const elem: any = useRef()
  const self = useSetup({
    props,
    vars: {
      checked: false,
    },
    async mounted() {
      copyRef(ref, elem)
      /** 초기 데이터 화면반영 */
      const { props, value } = modelValue(self())
      vars.checked = props?.value == value
    },
    async updated() {
      /** 데이터가 변경되면 실제 화면에 즉시 반영 */
      const { props, value } = modelValue(self())
      vars.checked = props?.value == value
    }
  })
  const { vars, update } = self()
  /** 체크박스 변경 이벤트 발생시 처리 */
  const onChange = async (e: any, v: any) => {
    const { props, setValue } = modelValue(self())
    setValue(v ? props?.value : '')
    vars.checked = v
    update(C.UPDATE_FULL)
  }
  return (
  <>
  {/* 표현타입에 따라 체크박스 또는 라디오버튼 으로 표현된다 */}
  { props.type === 'radio' ? (
    <_Radio ref={ elem } checked={ vars.checked || false } onChange={ onChange } { ...pprops } />
  ) : (
    <_Checkbox ref={ elem } checked={ vars.checked || false } onChange={ onChange } { ...pprops } />
  ) }
  </>
  )
})

2-2. editor.tsx (편집기 컴포넌트)

import { ComponentPropsWithRef } from 'react'
import { useEditor, EditorContent, EditorContentProps } from '@tiptap/react'
import { Mark, mergeAttributes } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import lodash from 'lodash'
import app from '@/libs/app-context'
import * as C from '@/libs/constants'

/** 편집기 내부에서 span 태그 사용이 가능하도록 편집기(tiptap) 플러그인 작성 */
const Span = Mark.create({
  name: 'span',
  group: 'inline',
  inline: true,
  selectable: true,
  atom: false,
  parseHTML() { return [ { tag: 'span' }, ] },
  renderHTML({ HTMLAttributes }) { return ['span', mergeAttributes(HTMLAttributes), 0] },
  addAttributes() { return { class: { default: null }, style: { default: null } } },
})

/** 편집기 속성타입 상속 */
type EditorProps = ComponentPropsWithRef<'div'> & EditorContentProps & {
  model?: any
  name?: string
}

const { useRef, copyExclude, copyRef, useSetup, defineComponent, modelValue } = app
const { debounce } = lodash

export default defineComponent((props: EditorProps, ref: EditorProps['ref'] & any) => {
  const pprops = copyExclude(props, ['model', 'editor'])
  const elem: any = useRef()
  const editor = useEditor({
    /** 위에서 작성한 Span 플러그인을 사용한다. (이 선언이 없으면 'span' 태그가 불가하어 컬러등 스타일링이 불가능) */
    extensions: [StarterKit, Span],
    content: '',
  })

  const self = useSetup({
    name: 'editor',
    props,
    vars: { editor },
    async mounted() {
      copyRef(ref, elem)
    },
    updated: debounce(async (mode: number) => {
      /** 외부에서 강제 업데이트 신호를 받아도 100ms 정도 debounce 를 걸어준다 */
      if (mode === C.UPDATE_ENTIRE && vars) {
        const { value } = modelValue(self())
        self()?.vars?.editor?.commands.setContent(value)
      }
    }, 100)
  })
  const { vars, update } = self({ editor })
  vars.editor = editor
  /** 편집기 편집 이벤트는 자주 발생하기 때문에 debounce 로 이벤트 발생빈도를 낮춘다 */
  const onChange = debounce(async (v) => {
    const { setValue } = modelValue(self())
    setValue(v, () => update(C.UPDATE_FULL))
    if (props?.onChange) { props.onChange(v) }
  }, 100)
  editor && editor.on('transaction', ({ editor }) => {
    onChange(editor.getHTML())
  })
  return (
  <EditorContent ref={ elem }
    /* @ts-ignore */
    editor={ editor }
    { ...pprops }
    />
  )
})

2-3. input.tsx (입력 컴포넌트)

import _TextField, { TextFieldProps as _TextFieldProps } from '@mui/material/TextField'
import $ from 'jquery'
import app from '@/libs/app-context'
import * as C from '@/libs/constants'

type InputProps = _TextFieldProps & {
  model?: any
  onEnter?: Function
  maxLength?: number
  inputMode?: string
  pattern?: string
}

const { copyExclude, useRef, copyRef, useSetup, defineComponent, modelValue } = app
export default defineComponent((props: InputProps, ref: InputProps['ref'] & any) => {
  const pprops = copyExclude(props, ['model', 'onEnter', 'maxLength', 'inputMode', 'pattern'])
  const iprops = props?.inputProps || {}
  const elem: any = useRef()
  const self = useSetup({
    name: 'input',
    props,
    vars: { },
    async mounted() {
      copyRef(ref, elem)
      /** 최초상태 화면반영 */
      $(elem?.current).find('input').val(modelValue(self())?.value || '')
    },
    async updated(mode) {
      if (mode && vars) {
      /** 화면 강제 업데이트 발생시 화면반영 */
        $(elem?.current).find('input').val(modelValue(self())?.value || '')
      }
    }
  })
  const { vars, update } = self()
  /** 입력컴포넌트 변경이벤트 처리 */
  const onChange = async (e: any) => {
    const { setValue } = modelValue(self())
    const v = $(elem?.current).find('input').val()
    /** 변경시 데이터모델에 값전달 */
    setValue(v, () => update(C.UPDATE_FULL))
    if (props.onChange) { props.onChange(e as any) }
  }
  /** 입력컴포넌트 키입력 이벤트 처리 */
  const onKeyDown = async (e: any) => {
    const keycode = e?.keyCode || 0
    switch (keycode) {
    case 13: {
      if (props.onEnter) { props.onEnter(e) }
    } break
    }
  }
  return (
  <_TextField ref={ elem }
    onChange={ onChange }
    onKeyDown={ onKeyDown }
    hiddenLabel
    inputProps={{
      maxLength: props?.maxLength || iprops?.maxLength,
      inputMode: props?.inputMode || iprops?.inputMode,
      pattern: props?.pattern || iprops?.pattern
    }}
    { ...pprops }
    />
  )
})

2-4. link.tsx (링크 컴포넌트)

import { ComponentPropsWithRef, createElement, MouseEvent } from 'react'
import app from '@/libs/app-context'
import * as C from '@/libs/constants'
type LinkProps = ComponentPropsWithRef<'a'> & {
  href?: any
  param?: any
}
/** 기본적으로는 일반 a 태그와 같지만 SPA 방식으로 화면을 전환한다 */
export default app.defineComponent((props: LinkProps, ref: LinkProps['ref']) => {
  const pprops = app.copyExclude(props, ['param'])
  const onClick = async (e: MouseEvent) => {
    if (props.href !== C.UNDEFINED) {
      /** 입력된 이벤트 전달을 취소하고 */
      e && e.preventDefault()
      e && e.stopPropagation()
      /** SPA 방식(router.push) 화면이동을 시도한다 */
      app.goPage(props.href, props.param)
    }
    if (props?.onClick) { props.onClick(e as any) }
  }
  return createElement('a', { ref: ref, onClick: onClick, ...pprops })
})

2-5. lottie.tsx (Lottie 애니메이션 컴포넌트)

import { ComponentPropsWithRef } from 'react'
import _Lottie from 'lottie-web'
import app from '@/libs/app-context'
type LottieProps = ComponentPropsWithRef<'div'> & {
  src?: string,
  loop?: boolean
  autoplay?: boolean
  renderer?: any,
}
const { defineComponent, useSetup, log, copyExclude, copyRef, basepath, useRef } = app
export default defineComponent((props: LottieProps, ref: LottieProps['ref']) => {
  const pprops = copyExclude(props, [])
  const eref = useRef() as any
  useSetup({
    async mounted() {
      let src = await basepath(props?.src || '')
      log.debug('LOTTIE-PATH:', src, app.getConfig().app.basePath)
      const element = eref?.current
      copyRef(ref, eref)
      if (!element?.getAttribute('data-loaded')) {
        log.debug('CHECK:', src, element?.getAttribute('data-loaded'))
        element?.setAttribute('data-loaded', true)
        _Lottie.loadAnimation({
          container: element,
          path: src,
          loop: props?.loop,
          autoplay: props?.autoplay || true,
          renderer: props?.renderer,
        })
      }
    }
  })
  return (
    <div
      ref={ eref }
      {...pprops}
      />
  )
})

2-6. pagination.tsx (데이터 분할 페이징 컴포넌트)

import _Pagination, { PaginationProps as _PaginationProps } from '@mui/material/Pagination';
import _Stack from '@mui/material/Stack';
import * as C from '@/libs/constants'
import app from '@/libs/app-context'

const { defineComponent, copyExclude, useSetup, copyRef, useRef } = app

type PaginationProps = _PaginationProps & {
  onChange?: Function
  model?: {
    currentPage?: number
    pageCount?: number
    rowCount?: number
    rowStart?: number
    rowTotal?: number
  },
}
export default defineComponent((props: PaginationProps, ref: PaginationProps['ref']) => {
  const pprops = copyExclude(props, ['onChange', 'model'])
  const elem = useRef({} as HTMLDivElement)
  const self = useSetup({
    vars: props.model || { },
    async mounted() {
      copyRef(ref, elem)
      update(C.UPDATE_FULL)
    },
    async updated() {
      if (!vars.currentPage) { vars.currentPage = 1 }
      if (!vars.rowCount) { vars.rowCount = 10 }
      if (!vars.rowTotal) { vars.rowTotal = 0 }
      if (!vars.pageCount) { vars.pageCount = Math.ceil(1.0 * vars.rowTotal / vars.rowCount) }
      if (vars.currentPage > vars.pageCount) { vars.currentPage = vars.pageCount }
      vars.rowStart = (vars.currentPage - 1) * vars.rowCount
      update(C.UPDATE_SELF)
    }
  })
  const { vars, update } = self()
  const onChange = (e: any, n: number) => {
    if (!n) { n = 1 }
    if (!vars.pageCount) { vars.pageCount = 0 }
    if (n > vars.pageCount) { n = vars.pageCount || 1 }
    if (n < 1) { n = 1 }
    vars.rowStart = (n - 1) * (vars.rowCount || 10)
    vars.currentPage = n
    if (props?.onChange && props.onChange instanceof Function) {
      (props as any).onChange()
    }
  }
  return (
    <>
    { (vars.pageCount || 0) > 0 && (
      <_Stack
        spacing={2}
        >
        <_Pagination
          ref={ elem }
          count={ vars.pageCount }
          page={ Number(vars?.currentPage || 1) }
          siblingCount={ props.siblingCount || 3 }
          boundaryCount={ props.boundaryCount || 2 }
          onChange={ onChange }
          showFirstButton
          showLastButton
          size='small'
          { ...pprops }
          />
      </_Stack>
    ) }
    </>
  )
})

2-7. select.tsx (선택기 컴포넌트)

import _Select, { SelectProps as _SelectProps } from '@mui/material/Select'
import _MenuItem from '@mui/material/MenuItem'
import * as C from '@/libs/constants'
import app from '@/libs/app-context'

/** 선택목록 타입 */
type OptionType = {
  name?: string
  value?: any
  selected?: boolean
}
/** mui 선택기 타입 상속 */
type InputProps = _SelectProps & {
  model?: any
  options?: OptionType[]
}

const { useRef, copyExclude, clear, copyRef, useSetup, defineComponent, modelValue } = app

export default defineComponent((props: InputProps, ref: InputProps['ref'] & any) => {
  const pprops = copyExclude(props, ['model', 'options', 'onChange'])
  const elem: any = useRef()
  const self = useSetup({
    name: 'select',
    props,
    vars: {
      index: 0,
      value: '',
      options: [] as OptionType[],
    },
    async mounted() {
      copyRef(ref, elem)
    }
  })
  const { vars, update } = self()

  /** 렌더링 시 필요한 선택목록 정보 조합 */
  if (props?.options && props?.options instanceof Array && vars?.options) {
    clear(vars.options)
    const { value: mvalue } = modelValue(self())
    for (let inx = 0; inx < props.options.length; inx++) {
      const item: any = props.options[inx]
      let value = typeof item === C.STRING ? item : item?.value || ''
      let name = item?.name || value
      if (value == mvalue) {
        vars.index = inx
        vars.value = value
      }
      vars.options.push({ name: name, value: value, selected: value == mvalue })
    }
    if (mvalue === undefined) { vars.index = 0 }
  }

  const onChange = async (e: any, v: any) => {
    const { setValue } = modelValue(self())
    let options: OptionType[] = vars.options as any
    const inx = v?.props?.value || 0
    setValue(options[inx].value, () => update(C.UPDATE_FULL))
    /** 변경시 데이터모델에 값전달 */
    if (props.onChange) { props.onChange(e as any, v) }
  }
  return (
  <_Select
    ref={ elem }
    onChange={ onChange }
    value={vars?.index || (vars?.options?.length > 0 ? 0 : '')}
    { ...pprops }
    >
    {/* 선택목록 생성*/}
    { vars?.options?.length > 0 && vars.options.map((itm, inx) => (
    <_MenuItem
      key={ inx }
      value={ inx }
      selected={ itm?.selected || false }
      >
      { `${itm?.name}` }
    </_MenuItem>
    ))}
  </_Select>
  )
})

2-8. index.ts (컴포넌트 barrel) / 기존내용편집

/** 각종 페이지에서 import 를 편하게 하기 위한 barrel 파일 */
export { default as Block } from './block'
export { default as Button } from './button'
export { default as Checkbox } from './checkbox'
export { default as Container } from './container'
export { default as Content } from './content'
export { default as Editor } from './editor'
export { default as Form } from './form'
export { default as Input } from './input'
export { default as Link } from './link'
export { default as Lottie } from './lottie'
export { default as Pagination } from './pagination'
export { default as Select } from './select'
export { Fragment } from 'react'

3. 공통 라이브러리

3-1. api.ts (통신 라이브러리)

이미 이전부터 3월달 스터디 내용과 달라진지 오래지만 본 부분은 거의 완전 새로 구성해서 기존의 axios 코드를 모두 걷어내고 새로 fetch 를 사용하여 재구성 했다.

....

따라서 아직은 미완성이다... (쿨러-엌!)

import * as C from '@/libs/constants'
import app from './app-context'
import userContext from './user-context'
import crypto from './crypto'

type OptType = {
  method?: String
  apicd?: String
  resolve?: Function
  reject?: Function
}

const { putAll, getConfig, log } = app
const keepalive = true

/** 초기화, 기본적으로 사용되는 통신헤더 등을 만들어 준다 */
const init = async (method: string, apicd: string, data?: any, opt?: any) => {
  const headers = putAll({}, opt?.headers || {})
  const timeout = opt?.timeout || getConfig()?.api?.timeout || 10000
  const signal = AbortSignal.timeout(timeout)
  const url = api.mkuri(apicd)

  let body: any = ''
  if (!headers[C.CONTENT_TYPE]) {
    headers[C.CONTENT_TYPE] = C.CTYPE_JSON
  }
  if (String(headers[C.CONTENT_TYPE]).startsWith(C.CTYPE_JSON)) {
    body = JSON.stringify(data || {})
  } 
  /** JWT 토큰이 저장되어있는 경우 헤더에 Bearer 추가 */
  const user = userContext.getUserInfo()
  if (user && user?.accessToken?.value) {
    switch (opt?.authtype) {
    case undefined: {
      /** 일반적인 경우 */
      headers[C.AUTHORIZATION] = `${C.BEARER} ${user.accessToken?.value}`
    } break
    case C.TOKEN_REFRESH: {
      /** 추가 헤더가 필요한 경우 */
      headers[C.AUTHORIZATION] = `${C.BEARER} ${user.refreshToken?.value}`
    } break
    }
  }
  return { method, url, body, headers, signal }
}

/** 통신결과 처리 */
const mkres = async (r: Promise<Response>, opt?: OptType) => {
  let ret = { }
  let t: any = ''
  const resp = await r
  const hdrs = resp?.headers || { get: (v: any) => {} }
  /** 통신결과 헤더에서 로그인 JWT 토큰이 발견된 경우 토큰저장소에 저장 */
  if ((t = hdrs.get(C.AUTHORIZATION.toLowerCase()))) {
    const auth: string[] = String(t).split(' ')
    if (auth.length > 1 && auth[0] === C.BEARER) {
      try {
        const current = new Date().getTime()
        const decval = String(crypto.aes.decrypt(auth[1]) || '').split(' ')
        log.debug('AUTH:', decval)
        if (decval && decval.length > 5) {
          /** 로그인 인경우 */
          userContext.setUserInfo({
            userId: decval[0],
            userNm: decval[1],
            accessToken: {
              value: decval[2],
              expireTime: current + Number(decval[4])
            },
            refreshToken: {
              value: (decval[3] !== '_' ? decval[3] : C.UNDEFINED),
              expireTime: current + Number(decval[3] !== '_' ? decval[5] : 0),
            },
            notifyExpire: false
          })
          log.debug('CHECK:', decval[3].length, decval[3])
          userContext.checkExpire()
        } else if (decval && decval.length > 3) {
          /** 로그인 연장 인경우 */
          userContext.setUserInfo({
            userId: decval[0],
            userNm: decval[1],
            accessToken: {
              value: decval[2],
              expireTime: current + Number(decval[3])
            }
          })
          /** 토큰 만료시간을 모니터링 한다 */
          userContext.checkExpire()
        }
      } catch (e) {
        log.debug('E:', e)
      }
    }
  }
  /** 상태값에 따른 오류처리 */
  const state = { error: false, message: '' }
  let msgcode = async () => (await (resp?.json && resp.json()))?.message
  switch (resp.status) {
  case C.SC_BAD_GATEWAY:
  case C.SC_GATEWAY_TIMEOUT:
  case C.SC_INTERNAL_SERVER_ERROR:
  case C.SC_RESOURCE_LIMIT_IS_REACHED:
  case C.SC_SERVICE_UNAVAILABLE: {
    putAll(state, { error: true, message: `처리 중 오류가 발생했어요`, msgcode: await msgcode() })
  } break
  case C.SC_UNAUTHORIZED: {
    putAll(state, { error: true, message: `로그인을 해 주세요`, msgcode: await msgcode() })
    await userContext.logout(false)
  } break
  case C.SC_FORBIDDEN: {
    putAll(state, { error: true, message: `접근 권한이 없어요`, msgcode: await msgcode() })
  } break
  case C.SC_NOT_FOUND:
  case C.SC_BAD_REQUEST: {
    putAll(state, { error: true, message: `처리할 수 없는 요청이예요`, msgcode: await msgcode() })
  } break
  case C.SC_OK: {
  } break
  default: }

  /** 정상인경우 결과값 리턴처리 */
  if (!state.error) {
    switch (hdrs.get('content-type')) {
    /** 결과 타입이 JSON 인경우 */
    case 'application/json': {
      ret = await resp.json()
    } break
    /** 결과 타입이 OCTET-STREAM (다운로드) 인경우 */
    case 'application/octet-stream': {
      ret = await resp.blob()
    } break
    default: }
    if (opt?.resolve) { opt.resolve(ret) }
  } else {
    return opt?.reject && opt.reject(state) || {}
  }
  return ret
}

const api = {
  nextping: 0,
  /** PING, 백엔드가 정상인지 체크하는 용도 */
  async ping(opt?: any) {
    return new Promise<any>(async (resolve, reject) => {
      const apicd = `cmn00000`
      const curtime = new Date().getTime()
      if (opt?.noping) { return resolve(true) }
      if (curtime < api.nextping) { return resolve(true) }
      const { method, headers, signal, url } = await init(C.GET, apicd, opt)
      const r = fetch(url, { method, headers, signal, keepalive })
      const res: any = await mkres(r, putAll(opt || {}, { apicd, method, resolve, reject }))
      /** 다음 ping 은 10초 이후 */
      api.nextping = curtime + (1000 * 10)
      return res
    })
  },
  /** POST 메소드 처리 */
  async post(apicd: string, data?: any, opt?: any) {
    return new Promise<any>(async (resolve, reject) => {
      await api.ping(opt)
      const { method, url, body, headers, signal } = await init(C.POST, apicd, data, opt)
      const r = fetch(url, { method, body, headers, signal, keepalive })
      return await mkres(r, putAll(opt || {}, { apicd, method, resolve, reject }))
    })
  },
  /** GET 메소드 처리 */
  async get(apicd: string, data?: any, opt?: any) {
    return new Promise<any>(async (resolve, reject) => {
      await api.ping(opt)
      const { method, url, headers, signal } = await init(C.GET, apicd, data, opt)
      const r = fetch(url, { method, headers, signal, keepalive })
      return await mkres(r, putAll(opt || {}, { apicd, method, resolve, reject }))
    })
  },
  /** PUT 메소드 처리 */
  async put(apicd: string, data?: any, opt?: any) {
    return new Promise<any>(async (resolve, reject) => {
      await api.ping(opt)
      const { method, url, body, headers, signal } = await init(C.PUT, apicd, data, opt)
      const r = fetch(url, { method, body, headers, signal, keepalive })
      return await mkres(r, putAll(opt || {}, { apicd, method, resolve, reject }))
    })
  },
  /** DELETE 메소드 처리 */
  async delete(apicd: string, data?: any, opt?: any) {
    return new Promise<any>(async (resolve, reject) => {
      await api.ping(opt)
      const { method, headers, signal, url } = await init(C.DELETE, apicd, data, opt)
      const r = fetch(url, { method, headers, signal, keepalive })
      return await mkres(r, putAll(opt || {}, { apicd, method, resolve, reject }))
    })
  },
  /** URL 을 형태에 맞게 조립해 준다 */
  mkuri(apicd: string) {
    const mat: any = apicd && /^([a-z]+)([0-9a-zA-Z]+)([/].*){0,1}$/g.exec(apicd) || {}
    if (mat && mat[1]) {
      return `${app.getConfig()?.api?.base || '/api'}/${mat[1]}/${mat[0]}`
    } else {
      return apicd
    }
  }
}

export default api

3-2. user-context.ts (사용자정보 라이브러리)

본 부분도 아직 대화창 컴포넌트가 미정이어서 ........ 아직은 미완성 이다...

일단은 native dialog(window.alert / window.confirm) 를 사용해 일단락 했다... 보기는 안좋지만....

import { createSlice, configureStore, combineReducers } from '@reduxjs/toolkit'
import { persistStore, persistReducer } from 'redux-persist'
import { getPersistConfig } from 'redux-deep-persist'
/** 세션스토리지 사용선언, 탭별로 영속저장이 유지된다 */
import storage from 'redux-persist/lib/storage/session'

import * as C from '@/libs/constants'
import app from '@/libs/app-context'
import api from '@/libs/api'

const { log, clone } = app

/** 사용자 정보 */
const schema = {
  userInfo: {
    userId: '',
    userNm: '',
    /** 접근제어용 토큰(access-token) 저장소 */
    accessToken: { value: '', expireTime: 0 },
    /** 접근유지용 토큰(refresh-token) 저장소 */
    refreshToken: { value: '', expireTime: 0 },
    lastAccess: 0,
    notifyExpire: false,
    timelabel: ''
  }
}

const slice = createSlice({
  name: 'user',
  initialState: schema,
  reducers: {
    setUserInfo: (state, action) => {
      const p: any = action.payload
      for (const k in p) { (state.userInfo as any)[k] = p[k] }
    }
  }
})

/** 영속저장 설정을 해 준다 */
const persistConfig = getPersistConfig({
  key: 'user',
  version: 1,
  storage,
  blacklist: [ ],
  rootReducer: slice.reducer
})

/** redux store 를 만들어 준다 */
const userStore = configureStore({
  reducer: persistReducer(persistConfig, slice.reducer),
  /** serialize 경고 방지용 */
  middleware: (middleware) => middleware({ serializableCheck: false })
})

/** 영속저장 선언, 브라우저 리프레시 해도 정보가 남아 있다 */
const persistor = persistStore(userStore)

const userVars = {
  timerHandle: C.UNDEFINED as any
}

const userContext = {
  /** 사용자정보 입력 */
  setUserInfo(info: any) {
    userStore.dispatch(slice.actions.setUserInfo(info))
  },
  /** 사용자정보 출력 */
  getUserInfo() {
    return (userStore.getState().userInfo) as typeof schema.userInfo
  },
  /** 사용자 정보 만료시간 체크 */
  async checkExpire() {
    if (userVars.timerHandle) { clearTimeout(userVars.timerHandle) }
    const current = new Date().getTime()
    const userInfo = userContext.getUserInfo()
    let accessToken = userInfo.accessToken
    let refreshToken = userInfo.refreshToken
    let expired = false
    /** 액세스토큰 조회 */
    if ((accessToken?.expireTime || 0) > current) {
      const diff = Math.floor((accessToken.expireTime - current) / 1000)
      const minute = Math.floor(diff / 60)
      const mod = diff - (minute * 60)
      userContext.setUserInfo({ timelabel: `${String(minute).padStart(2, '0')}:${String(mod).padStart(2, '0')}` })
      if (mod % 10 == 0) { log.debug(`DIFF: ${minute} min ${mod} sec / ${diff} / ${current} / ${app.getConfig().auth?.expiry}`) }
    }
    if (refreshToken?.value && refreshToken?.expireTime < (current - C.EXTRA_TIME)) {
      log.debug('REFRESH-TOKEN EXPIRED')
      /** TODO: REFERESH 토큰 만료처리 */
    }
    // log.debug('CHECK:', Math.floor((accessToken.expireTime - (current + C.EXPIRE_NOTIFY_TIME - C.EXTRA_TIME)) / 1000))
    if ((accessToken?.value && accessToken?.expireTime <
      (current + C.EXPIRE_NOTIFY_TIME - C.EXTRA_TIME)) && !userInfo?.notifyExpire
      ) {
      if (confirm(`인증이 ${Math.ceil((accessToken?.expireTime - current) / 1000 / 60)}분 안에 만료됩니다. 연장하시겠어요?`)) {
        userContext.tokenRefresh()
      } else {
        userContext.setUserInfo({ notifyExpire: true })
      }
    }
    if (accessToken?.value && accessToken?.expireTime < (current - C.EXTRA_TIME)) {
      log.debug('ACCESS-TOKEN EXPIRED')
      if (accessToken.value && accessToken?.expireTime >= 0) {
        expired = true
        userContext.logout()
        alert('로그아웃 되었어요')
        app.goPage('/')
      }
    }
    accessToken = clone(userInfo.accessToken)
    userStore.dispatch(slice.actions.setUserInfo({ accessToken }))
    if (!expired) {
      userVars.timerHandle = setTimeout(userContext.checkExpire, 1000)
    }
  },
  async tokenRefresh() {
    api.post(`lgn01002`, {}, { authtype: C.TOKEN_REFRESH })
  },
  /** 강제 로그아웃 */
  async logout(notify: boolean = true) {
    userStore.dispatch(slice.actions.setUserInfo(
      clone(schema.userInfo)))
      /** TODO: 로그아웃 알림처리 */
  },
  /** 사용자정보 변경 모니터링 */
  subscribe(fnc: any) {
    userStore.subscribe(fnc)
  },
}

export { userStore, persistor }
export default userContext

4. 샘플 페이지

4-1. smp01001s01.jsx (화면 컴포넌트 샘플)

import app from '@/libs/app-context'
import * as C from '@/libs/constants'
import { Block, Button, Checkbox, Input, Select, Editor, Lottie, Container } from '@/components'

const { log, definePage, goPage, useSetup, clear } = app

/**
 * 페이지 컴포넌트 샘플
 * 컴포넌트 변경이 감지되면 자동으로 데이터를 갱신할 수 있고
 * 반대로 데이터 갱신 후 update(C.UPDATE_ENTIRE) 를 수행하여
 * 컴포넌트를 업데이트 시킬 수 있다.
 **/
export default definePage((props) => {
  const self = useSetup({
    vars: {
    /** 3초 타이머 */
    timer: 3,
    /** 폼데이터 */
    formdata: {
      input01: '',
      input02: 'AAAA',
      checkbox1: 'Y',
      checkbox2: '',
      checklist: ['', '', '', ''],
      select1: '',
      content: '',
    },
    /** 선택기 목록 설정 */
    options1: [
      { name: '선택해주세요', value: '' },
      'hotmail.com',
      'naver.com',
      'kakao.com',
      'gmail.com',
    ],
    /** 난수테스트용 */
    idgen: ['', '', '', ''],
    },
    mounted, unmount, updated
  })
  const { update, vars } = self()
  /** 페이지 시작 이벤트처리 */
  async function mounted() {
    log.debug('MOUNTED! SMP01001S01', props)
    const fdata = vars.formdata
    /** 3초가 지나면 데이터 강제 업데이트를 수행한다 */
    const fnctime = async () => {
      if (vars.timer == 1) {
        fdata.input02 = 'BBBB'
        fdata.checkbox2 = 'C'
        fdata.select1 = 'kakao.com'
        fdata.content = `<p><span style="color:#f00">CONTENT</span></p>`
        vars.idgen.map((v, i, l) => l[i] = app.genId())
        /** 전체 데이터 갱신으로 화면 데이터가 자동으로 바뀐다 */
        update(C.UPDATE_ENTIRE)
      } else if (vars.timer > 0) {
        setTimeout(fnctime, 1000)
        update(C.UPDATE_FULL)
      }
      vars.timer--
    }
    setTimeout(fnctime, 1000)
    /** 난수 생성 테스트 */
    vars.idgen.map((v, i, l) => l[i] = app.genId())
  }
  /** 페이지 종료 이벤트 처리 */
  async function unmount() {
    log.debug('UNMOUNT! SMP01001S01')
  }
  /** 페이지 업데이트 이벤트 처리 */
  async function updated() {
    log.debug('UPDATED! SMP01001S01')
  }
  return (
  <Container>
    <section className='title'>
      <h2>컴포넌트샘플</h2>
    </section>
    <hr/>
    <section>
      <article>
        <Block className='my-1'>
        <p> GLOBAL-STATE-VALUE: { app.state() } </p>
        </Block>
      </article>
      <article>
        <h3> 버튼 컴포넌트 </h3>
        <hr />
        <Block className='my-1'>
          <Button
            className='mx-1'
            onClick={ () => goPage(-1) }
            >
            뒤로가기
          </Button>
          <Button
            className='mx-1'
            variant='contained'
            onClick={ () => {
              clear(vars?.formdata)
              update(C.UPDATE_SELF)
            } }
            >
            데이터삭제
          </Button>
          <Button
            className='mx-1'
            variant='contained'
            color='warning'
            >
            경고버튼
          </Button>
          <Button
            className='mx-1'
            variant='outlined'
            color='info'
            >
            기본버튼
          </Button>
          <Button
            className='mx-1'
            variant='outlined'
            color='info'
            href={'/smp/smp01001s02'}
            param={ { key: 'a', val: 'b' } }
            >
            페이지링크
          </Button>
        </Block>
      </article>
      <article>
        <h3>입력컴포넌트</h3>
        <hr />
        <Block className='my-1'>
          <Input
            model={ vars?.formdata }
            name='input01'
            size='small'
            />
          <span className='mx-1 my-1'>
          [VALUE: { vars?.formdata?.input01 }]
          </span>
        </Block>
        <Block className='my-1'>
          <Input
            model={ vars?.formdata }
            name='input02'
            size='small'
            />
          <span className='mx-1 my-1'>
            { vars?.timer > 0 ? (
              <> [change after { vars?.timer }sec: { vars?.formdata?.input02 }] </>
            ) : '' }
          </span>
        </Block>
      </article>
      <article>
        <h3>체크박스</h3>
        <hr />
        <Block className='my-1'>
          <Checkbox
            model={ vars?.formdata }
            name='checkbox1'
            value='Y'
            />
          <Checkbox
            model={ vars?.formdata }
            name='checkbox2'
            value='A'
            />
          <Checkbox
            type='radio'
            model={ vars?.formdata }
            name='checkbox2'
            value='B'
            />
          <Checkbox
            type='radio'
            model={ vars?.formdata }
            name='checkbox2'
            value='C'
            />
        </Block>
        <Block className='my-1'>
          { vars?.formdata?.checklist && vars?.formdata?.checklist.map((itm, inx) => (
            <Checkbox
              key={ inx }
              model={ vars?.formdata }
              name={ `checklist.${inx}` }
              value='Y'
              />
          )) }
        </Block>
      </article>
      <article>
        <h3>선택기</h3>
        <hr />
        <Block className='my-1'>
          <Select
            size='small'
            model={ vars?.formdata }
            name='select1'
            options={ vars?.options1 }
            />
        </Block>
      </article>
      <article>
        <h3>편집기</h3>
        <hr />
        <Block className='my-1'>
          <Editor
            model={ vars?.formdata }
            name='content'
            />
        </Block>
      </article>
      <article>
        <h3>전체 폼데이터</h3>
        <hr />
        <Block className='my-1 w-full overflow-x-auto'>
          FORMDATA: [{ JSON.stringify(vars?.formdata) }]
        </Block>
      </article>
      <article>
        <h3>LOTTIE</h3>
        <hr />
        <Block className='my-1 flex justify-center'>
          <Lottie 
            className='max-w-2/3'
            src='/assets/lottie/hello.json'
            />
        </Block>
      </article>
      <article>
        <h3>전역ID 생성 테스트</h3>
        <hr />
        <Block className='my-1'>
        { (vars?.idgen || []).map((v, i) => (
        <p key={ i }>
          { i } : { v }
        </p>
        )) }
        </Block>
      </article>
    </section>
  </Container>
  )
})

4-2. smp01001s02.jsx (통신 예제 샘플)

본 샘플은 통신예제 샘플이라 이전 포스팅에 있는 was 가 구동되어 있어야 정상 작동한다

import app from '@/libs/app-context'
import api from '@/libs/api'
import * as C from '@/libs/constants'
import crypto from '@/libs/crypto'
import { Button, Block, Container } from '@/components'
import userContext from '@/libs/user-context'

const { log, definePage, useSetup, goPage, getParameter } = app

export default definePage((props) => {
  const self = useSetup({
    vars: {
      aeskey: '',
      userInfo: { },
      articles: [ ],
      message: ''
    },
    async mounted() {
      log.debug('CHECKPARAM:', getParameter())
    }
  })
  const { update, vars } = self()
  const onClick = async (v) => {
    try {
      vars.message = ''
      switch (v) {
      case 0: {
        /** 통신암호 초기화 */
        const res = await api.get(`cmn01001`, {})
        const check = res?.check || ''
        log.debug('RES:', res?.check, app.getConfig().security.key.rsa)
        await crypto.rsa.init(app.getConfig().security.key.rsa, C.PRIVATE_KEY)
        vars.aeskey = crypto.rsa.decrypt(check)
        update(C.UPDATE_SELF)
      } break
      case 1: {
        /** 로그인 (테스트계정) */
        await crypto.aes.init(vars.aeskey)
        const res = await api.post(`lgn01001`, {
          userId: `tester`,
          passwd: `${crypto.aes.encrypt('test12#')}`
        })
        log.debug('RES:', res)
        vars.userInfo = userContext.getUserInfo()
        log.debug('USER:', vars.userInfo)
        update(C.UPDATE_FULL)
      } break
      case 2: {
        /** 로그아웃 */
        userContext.logout()
        vars.userInfo = userContext.getUserInfo()
        update(C.UPDATE_FULL)
      } break
      case 3: {
        /** 게시물 조회 */
        const res = await api.post(`atc01001`, {
          searchType: '',
          keyword: '',
          rowStart: 0,
          rowCount: 10,
        })
        log.debug('RES:', res)
        vars.articles = res?.list || []
        update(C.UPDATE_FULL)
      } break
      default: }
    } catch (e) {
      log.debug('E:', e)
      vars.message = e.message
      update(C.UPDATE_FULL)
    }
  }
  return (
  <Container>
    <section className='title'>
      <h2>통신샘플</h2>
    </section>
    <hr/>
    <section>
      <Block>
        <Button
          className='mx-1'
          onClick={ () => goPage(-1) }
          >
          뒤로가기
        </Button>
        <Button
          className='mx-1'
          onClick={ () => onClick(0) }
          >
          통신암호초기화
        </Button>
        <Button
          className='mx-1'
          onClick={ () => onClick(1) }
          >
          로그인
        </Button>
        <Button
          className='mx-1'
          onClick={ () => onClick(2) }
          >
          로그아웃
        </Button>
        <Button
          className='mx-1'
          onClick={ () => onClick(3) }
          >
          게시물조회
        </Button>
      </Block>
      <Block>
        AES 암호화 키 : { vars.aeskey }
      </Block>
      <Block>
        로그인정보 : { JSON.stringify(vars.userInfo) }
      </Block>
      <Block>
        게시물목록 : { JSON.stringify(vars.articles) }
      </Block>
      <Block>
        메시지 : <span style={{ color: '#f00' }}>{vars.message}</span>
      </Block>
    </section>
  </Container>
  )
})

4-3. index.jsx (메인)

컴포넌트샘플통신샘플 로 갈수있는 링크 버튼을 만든다

/** 최초 페이지 이므로 기능없이 샘플페이지 이동용 버튼만 작성한다. */
import app from '@/libs/app-context'
import { Button, Container } from '@/components'

export default app.definePage((props) => {
  return (
  <Container>
    <section className='title'>
      <h2>INDEX PAGE</h2>
    </section>
    <hr/>
    <section>
      <Button
        href='/smp/smp01001s01'
        >
        컴포넌트샘플
      </Button>
      <Button
        href='/smp/smp01001s02'
        >
        통신샘플
      </Button>
    </section>
  </Container>
  )
})

5. 실행결과

이후 프로젝트를 실행하고 (npm run dev) 브라우저로 띄워보면 아래와 같은 화면이 뜬다.

직접 작동하는 실제 페이지 이다



6. 마무~~ 으리!

우여곡절 끝에 일단 컴포넌트 샘플까지는 만들었다.

다음편은 아마도 3월 스터디 레포트 편의 마지막이 될 듯 하다.
한참 스터디 때 본편의 NextJS 기틀은 다 잡아 놓은거지만
무려 5개월이나 숙성 시키는 동안 소스가 꽤 많은 부분 첨삭 되었다.

원래는 욕심 때문에 더 다양한 기능이 있었는데 스터디 라는 목적에 맞지 않아
과감히 쳐냈고 본래 예전에 쓰던 axios 로 만들어진 통신모듈을
fetch 로 리팩토링 한 부분도 있고... 네이밍이 마음에 안들어서 갈아버린것도 있고...

아마 번외편으로 본 스터디 에 쓰인 기법들을 설명하는 포스팅이 필요할 듯 하지만...

뭐 여튼 다음 편은 직접 회원가입, 로그인, 게시물작성, 조회 까지 다 해 볼거다

결과물 git주소 : https://gitlab.ntiple.com/developers/study-202403

github : https://github.com/lupfeliz/study-202403

[ ←전편보기 / 1편, 2편, 3편, 4편, 6편 / 다음편보기→ ]

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다