스터디 레포트 202403 #6

Table of Contents

[TOC]

[ ←전편보기 / 1편, 2편, 3편, 4편, 5편 ]

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

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

테스트 실행 : https://devlog.ntiple.com/samples/sample-1088

1. 자! 드가자~!

아마 이번이 3월에 진행했던 스터디 편의 마지막 포스팅이 될 거 같다.
(딸랑 4일치 스터디 내용을 무려 6개월동안 레포팅 하고 있다....)

중요한건 이제껏 만든게 고작 게시판 이라는거다!
(고작 게시판 이지만 구축이 만만하지만은 않다는걸 깨달았다)

이번편은 남은 내용 전부를 리뷰할 거기 때문에 꽤 길어질 것이 예상되지만

....

뭐..

...

어쨌든 마무리 해 보자!

이번 편에서 작성 및 수정 할 파일들은 아래와 같다.

└── src
    ├── components
    │   ├── footer.tsx (꼬리말 컴포넌트)
    │   └── header.tsx (머리말 컴포넌트)
    ├── libs
    │   └── app-context.ts (통합기능 라이브러리)
    └── pages
        ├── atc
        │   ├── atc01001s01.jsx (게시물 작성 페이지)
        │   ├── atc01001s02
        │   │   ├── [articleid].jsx (게시물 조회 페이지)
        │   ├── atc01001s03
        │   │   ├── [articleid].jsx (게시물 수정 페이지)
        │   └── atc01001s04
        │       ├── [pagenum].jsx (게시물 목록 페이지)
        │       └── index.jsx (게시물 목록 페이지)
        ├── lgn
        │   └── lgn01001s01.jsx (로그인 페이지)
        ├── mai
        │   └── mai01001s01.jsx (메인 페이지)
        ├── usr
        │   ├── usr01001s01.jsx (회원가입 페이지)
        │   ├── usr01001s02.jsx (가입완료 페이지)
        │   └── usr01001s03.jsx (마이페이지)
        ├── index.jsx (메인 페이지)
        └── global.scss (전역 스타일시트)

2. 추가 모듈 (컴포넌트 / 라이브러리)

2-1. global.scss (전역 스타일시트)

우선, 이번 포스팅에 추가할 컴포넌트 및 페이지에 대한 스타일을 추가 및 수정해 준다

html {
  body {
    font-family: Arial, Helvetica, sans-serif;
    margin: 0;

    header {
      position: fixed;
      top: 0;
      width: calc(100%);
      max-width: 100vw;
      height: 1rem;
      z-index: 999;
      > div {
        background-color: #fff;
        text-align: center;
        margin-bottom: 1rem;
        padding-top: 1rem;
        padding-bottom: 1rem;
        border-bottom: 1px solid #ccc;
        align-items: center;

        > div:nth-child(1) {
          width: 64px;
        }
        > div:nth-child(2) {
          width: calc(100% - 4rem)
        }
        > div:nth-child(3) {
          width: 64px;
        }
      }
    }

    .header-aside > .MuiPaper-root {
      width: 30%;
      max-width: 10rem;
      padding-top: 1rem;
      > div {
        > .MuiButtonBase-root {
          width: 100%;
        }
      }
    }

    main {
      margin-top: calc(3rem + 1.2rem + 2px + 1rem);
      margin-bottom: calc(6rem + 1.2rem + 2px);

      > div > section {
        margin-left: auto;
        margin-right: auto;
      }
    }

    footer {
      transition: opacity 0.3s, transform 0.2s;
      position: fixed;
      opacity: 1;
      top: calc(100dvh - 3rem - 1.2rem - 2px);
      width: calc(100%);
      max-width: 100vw;
      z-index: 999;
      > div {
        background-color: #fff;
        text-align: center;
        margin-top: 1rem;
        padding-top: 1rem;
        padding-bottom: 1rem;
        border-top: 1px solid #ccc;
      }
    }

    /** sticky 기능을 위해 css변수를 사용 */
    footer :root {
      --screen-height: 100dvh;
      --scroll-top: 0;
      --footer-height: calc(3rem + 1.2rem + 2px)
    }

    footer.sticky {
      position: absolute;
      top: calc(var(--screen-height) + var(--scroll-top) - var(--footer-height))
    }

    footer.hide {
      opacity: 0;
      transform: translateY(0.5rem) 
    }
  }
}

section.title {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

section.flex-form {
  display: flex;
  justify-content: center;
  form {
    width: calc(60vw + 1rem + 4rem + 32px);
    article {
      hr {
        margin-top: 2rem;
        margin-bottom: 2rem;
      }
      div.form-block {
        margin-bottom: 0.5rem;
        > label[for] {
          display: flex;
          align-items: center;
          justify-content: left;
          width: 7rem;
          margin-top: 0.25rem;
          margin-bottom: 0.25rem;
        }

        > label[for] + div.form-element {
          // width: calc(60vw);
          width: 100%;
          margin-right: 0.5rem;
        }

        > label[for] + div.form-element.user-id {
          > div:nth-child(1) {
            width: calc(100% - 5rem - 0.5rem - 10px);
            margin-right: 0.5rem;
          }
          > div:nth-child(2) {
            width: calc(5rem);
          }
        }

        > label[for] + div.form-element.email {
          > div:nth-child(1) {
            width: calc(50% - 1rem);
          }
          > span:nth-child(2) {
            display: inline-block;
            width: 2rem;
            padding-top: 0.5rem;
            text-align: center;
          }
          > div:nth-child(3) {
            width: calc(50% - 1rem);
          }
        }

        > label[for] + div.form-element.editor {
          > div > div[contenteditable] {
            border: 1px solid #ccc;
            border-radius: 0.2rem;
            padding: 0.5rem;
            height: 10rem;
            overflow-y: auto;
          }
        }

        > label[for] + div.form-element + button {
          width: calc(4rem + 32px);
        }
      }
      > .buttons {
        display: flex;
        justify-content: center;
      }
    }
  }
}

.tiptap > p:first-child {
  margin-top: 0;
}

.MuiInputBase-root > input + fieldset > legend {
  display: none;
}

hr {
  border-top: 1px solid #ccc;
  border-bottom: 0;
}

table.articles {
  > thead {
    > tr {
      > th {
        border-left: 1px solid #ccc;
      }
      > th:first-child {
        border-left: none;
      }
    }
  }
  > tbody {
    > tr {
      > td {
        padding: 0.5rem;
      }
      > td.nopad {
        padding: 0;
      }
    }
  }
}

/** 좌우 폭이 너무 넓어지는것을 방지 */
@media (min-width:800px) {
  section, hr {
    max-width: 800px;
  }
}

/** 이하 tailwind 에서 일부 내용을 차용해서 util css 로 사용 */
.mx-1 {
  margin-left: 0.25rem;
  margin-right: 0.25rem;
}

.my-1 {
  margin-top: 0.25rem;
  margin-bottom: 0.25rem;
}

.text-center {
  text-align: center;
}

.text-right {
  text-align: right;
}

.overflow-hidden {
  overflow: hidden;
}

.overflow-x-auto {
  overflow-x: auto;
}

.flex {
  display: -webkit-box;
  display: -ms-flexbox;
  display: -webkit-flex;
  display: flex;
}

.justify-center {
  -webkit-box-pack: center;
  -ms-flex-pack: center;
  -webkit-justify-content: center;
  justify-content: center;
}

.w-screen {
  width: 100vw;
}

.w-full {
  width: 100%;
}

.max-w-1\/2 {
  max-width: calc(50%);
}

.max-w-2\/3 {
  max-width: calc(100% * 2 / 3);
}

2-2. app-context.ts (공통컨텍스트 모듈)

app-context.ts 파일 중 onload 메소드를 찾아 아래와 같이 수정한다

기존 주석 내용 (TODO: AES 암호화 정보 초기화, TODO: 사용자 정보 초기화) 에 대한 내용 추가

암호 교환 및 검증 절차, 공통서비스 내용 참고


... 중략 ...

  /** APP 최초 구동시 수행되는 프로세스 */
  async onload(props: AppProps) {
    const $body = $(document.body)
    appvars.router = props.router
    if (appvars.astate == C.APPSTATE_INIT) {
      appvars.astate = C.APPSTATE_START
      try {
        const api = (await import('@/libs/api')).default
        const crypto = (await import('@/libs/crypto')).default
        const userContext = (await import('@/libs/user-context')).default
        const conf = decryptAES(encrypted(), C.CRYPTO_KEY)
        app.putAll(appvars.config, conf)
        log.setLevel(conf.log.level)
        log.debug('CONF:', conf)
        const cres = await api.get(`cmn01001`, {})
        await crypto.rsa.init(app.getConfig().security.key.rsa, C.PRIVATE_KEY)
        const aeskey = crypto.rsa.decrypt(cres?.check || '')
        await crypto.aes.init(aeskey)
        appvars.astate = C.APPSTATE_ENV
        const userInfo = userContext.getUserInfo()
        if (userInfo?.userId && (userInfo.accessToken?.expireTime || 0) > new Date().getTime()) { userContext.checkExpire() }
        appvars.astate = C.APPSTATE_USER
      } catch (e) {
        appvars.astate = C.APPSTATE_ERROR
        log.debug('E:', e)
      }
      const fnunload = async () => {
        window.removeEventListener('beforeunload', fnunload)
        $body.addClass('hide-onload')
      }
      const fnload = async () => {
        window.addEventListener('beforeunload', fnunload)
        document.removeEventListener('DOMContentLoaded', fnload)
        $body.removeClass('hide-onload')
        /** 트랜지션시간 300ms */
        setTimeout(() => appvars.astate = C.APPSTATE_READY, 300)
      }
      if (document.readyState !== 'complete') {
        document.addEventListener('DOMContentLoaded', fnload)
      } else {
        fnload()
      }
    }
  },

... 중략 ...

2-3. header.tsx (머리말 컴포넌트)

헤더 컴포넌트를 다음과 같이 수정해 준다. (Aside 형태 메뉴 기능 추가)

로그인연장, 로그인서비스 내용참고

import app from '@/libs/app-context'
import userContext from '@/libs/user-context'
import * as C from '@/libs/constants'
import { Container, Block, Button, Link } from '@/components'
import { Drawer } from '@mui/material'
import { Menu as MenuIcon, ArrowBackIos as ArrowBackIcon } from '@mui/icons-material';
const { defineComponent, useSetup, goPage } = app
export default defineComponent(() => {
  const self = useSetup({
    vars: {
      aside: false
    },
    async mounted({ releaser }) {
      /** 사용자 로그인 만료시간 모니터링 */
      releaser(userContext.subscribe(() => update(C.UPDATE_SELF)))
    }
  })
  const { vars, update, ready } = self()
  const userInfo = userContext.getUserInfo()
  /** aside 메뉴 오픈 */
  const openAside = (visible: boolean) => {
    vars.aside = visible
    update(C.UPDATE_FULL)
  }
  const logout = async () => {
    await userContext.logout()
    alert('로그아웃 되었어요')
    goPage('/')
  }
  return (
    <header>
    <Container className='flex'>
      <Block>
        <Button
          size='small'
          onClick={() => goPage(-1)}
          >
          <ArrowBackIcon />
        </Button>
      </Block>
      <Block>
        <Link
          href={ '/' }
          >
          HEADER
        </Link>
      </Block>
      <Block>
        <Button
          size='small'
          onClick={ () => openAside(true) }
          >
          <MenuIcon />
        </Button>
      </Block>
    </Container>
    <Drawer
      className='header-aside'
      anchor='right'
      open={ vars.aside }
      onClick={ () => openAside(false) }
      >
      { ready() && (userInfo?.userId) && (
        <>
          <Block className='text-center my-1'>
            { userInfo.userNm }
          </Block>
          <Block className='text-center my-1'>
            { userInfo.timelabel }
          </Block>
        </>
      ) }
      <Block>
        <Button
          href={'/'}
          >
          홈
        </Button>
        { ready() && !(userInfo?.userId) ? (
        <>
          <Button
            href={'/lgn/lgn01001s01'}
            >
            로그인
          </Button>
          <Button
            href={'/usr/usr01001s01'}
            >
            회원가입
          </Button>
        </>
        ) : (
        <>
          <Button
            onClick={ userContext.tokenRefresh }
            >
            로그인연장
          </Button>
          <Button
            onClick={ logout }
            >
            로그아웃
          </Button>
          <Button
            href='/usr/usr01001s03'
            >
            마이페이지
          </Button>
        </>
        ) }
        <Button
          href='/atc/atc01001s04/1'
          >
          게시판
        </Button>
      </Block>
    </Drawer>
    </header>
  )
})

2-4. footer.tsx (꼬리말 컴포넌트)

꼬리말 컴포넌트를 다음과 같이 수정해 준다. (꼬리말 따라다니기 기능)

import $ from 'jquery'
import lodash from 'lodash'
import app from '@/libs/app-context'
import { Container } from '@/components'

const { defineComponent, useSetup, useRef } = app
const { debounce } = lodash
const evtlst = ['scroll', 'resize']
export default defineComponent(() => {
  const eref = useRef({} as HTMLDivElement)
  useSetup({
    async mounted({ releaser }) {
      evtlst.map(v => window.addEventListener(v, fncResize))
      fncResize()
    },
    async unmount() {
      evtlst.map(v => window.removeEventListener(v, fncResize))
    }
  })
  const fncResizePost = debounce(() => {
    const page = $('html,body')
    /** footer sticky 기능을 위해 css 변수를 수정한다 */
    const footer = eref.current || {}
    {[
      '--screen-height', `${window.innerHeight || 0}px`,
      '--scroll-top', `${page.scrollTop()}px`,
      '--footer-height', `${footer?.offsetHeight || 0}px`
    ].map((v, i, l) => (i % 2) &&
      footer?.style?.setProperty(l[i - 1], v))}
    footer?.classList?.remove('hide')
  }, 10)
  const fncResize = () => {
    eref?.current?.classList?.add('sticky', 'hide')
    fncResizePost()
  }
  return (
    <footer ref={ eref }>
    <Container>
      FOOTER
    </Container>
    </footer>
  )
})

3. 페이지 구축

3-1. /mai/mai01001s01.jsx (메인 페이지)

먼저 몸풀기용으로 간단한 링크 페이지부터 만들어 보자

import app from '@/libs/app-context'
import userContext from '@/libs/user-context'
import { Button, Block, Container } from '@/components'

const { definePage, useSetup } = app

export default definePage(() => {

  const { ready } = useSetup()()
  const userInfo = userContext.getUserInfo()

  return (
  <Container>
    <section className='title'>
      <h2>메인페이지</h2>
    </section>
    <hr/>
    <section>
      <p> 샘플 게시판 어플리케이션 입니다. </p>
      <p> 현재 페이지는 메인페이지 입니다. </p>
      <article>
        { ready() && !(userInfo?.userId) && (
        <Block>
          <Button
            href='/lgn/lgn01001s01'
            >
            로그인
          </Button>
          <Button
            href='/usr/usr01001s01'
            >
            회원가입
          </Button>
        </Block>
        ) }
        <Block>
          <Button
            href='/atc/atc01001s04/1'
            >
            게시판으로 이동
          </Button>
        </Block>
        <Block>
          <Button
            href='/smp/smp01001s01'
            >
            샘플1
          </Button>
        </Block>
        <Block>
          <Button
            href='/smp/smp01001s02'
            >
            샘플2
          </Button>
        </Block>
      </article>
    </section>
  </Container>
  )
})

3-2. /index.jsx (메인 페이지)

/ URL로 접근시 단순히 만들어진 페이지(mai01001s01)를 호출해 주도록 수정한다.

import app from '@/libs/app-context'
import Mai01001s01 from '@/pages/mai/mai01001s01'
export default app.definePage(() => {
  return ( <Mai01001s01 />)
})

3-3. /usr/usr01001s01.jsx (회원가입 페이지)

회원가입, 사용자서비스 내용참고

import app from '@/libs/app-context'
import api from '@/libs/api'
import values from '@/libs/values'
import crypto from '@/libs/crypto'
import * as C from '@/libs/constants'
import { Block, Form, Button, Input, Select, Container } from '@/components'

const { definePage, useSetup, log, goPage, clone, sleep } = app
const { matcher } = values

export default definePage(() => {
  const PTN_EMAIL = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i
  const self = useSetup({
    vars: {
      formdata: {
        userNm: '',
        userId: '',
        passwd: '',
        passwd2: '',
        emailId: '',
        emailHost: '',
        email: ''
      },
      iddupchk: false,
      emailSelector: 'select',
      emailHosts: [
        { name: '선택해 주세요', value: '' },
        'gmail.com',
        'naver.com',
        'daum.net',
        'kakao.com',
        'hotmail.com',
        'icloud.com',
        { name: '직접입력', value: '_' },
      ]
    },
    async mounted() {
      log.debug('USR01001S01 - MOUNTED!')
    }
  })
  const { update, vars } = self()
  const emailHostChanged = async () => {
    /** 직접입력인 경우 입력컴포넌트 전환 */
    if (vars.formdata.emailHost == '_') {
      vars.emailSelector = 'input'
      vars.formdata.emailHost = ''
      update(C.UPDATE_SELF)
    }
  }
  const checkUserId = async () => {
    const model = vars.formdata
    vars.iddupchk = false
    if (model.userId) {
      model.userId = String(model.userId)
        .toLowerCase().replace(/[^a-zA-Z0-9]+/, '')
      const res = await api.get(`usr01001/${model.userId}`)
      if (res?.rescd !== C.RESCD_OK) {
        alert(`"${model.userId}" 는 이미 사용중이거나 사용할수 없어요.`)
      } else {
        vars.iddupchk = true
        alert(`"${model.userId}" 는 사용 가능해요.`)
      }
      update(C.UPDATE_FULL)
    } else {
      alert('아이디를 입력해 주세요')
    }
  }
  /** 회원가입, was 에 전달하기 전에 validation 부터 수행한다. */
  const submit = async () => {
    let msg = ''
    let model = clone(vars.formdata)
    model.email = `${model.emailId}@${model.emailHost}`
    if (!msg && !model.userNm) { msg = '이름을 입력해 주세요' }
    if (!msg && model.userNm.length < 2) { msg = '이름을 2글자 이상 입력해 주세요' }
    if (!msg && !model.userId) { msg = '아이디를 입력해 주세요' }
    if (!msg && model.userId.length < 4) { msg = '아이디를 4글자 이상 입력해 주세요' }
    if (!msg && !vars.iddupchk) { msg = '아이디 중복확인을 수행해 주세요' }
    if (!msg && !model.passwd) { msg = '비밀번호를 입력해 주세요' }
    if (!msg && model.passwd.length < 4) { msg = '비밀번호를 4글자 이상 입력해 주세요' }
    if (!msg && !model.passwd2) { msg = '비밀번호 확인을 입력해 주세요' }
    if (!msg && model.passwd !== model.passwd2) { msg = '비밀번호 확인이 맞지 않아요' }
    if (!msg && !model.emailId) { msg = '이메일을 입력해 주세요' }
    if (!msg && !PTN_EMAIL.test(model.email)) { msg = `"${model.email}" 는 올바른 이메일 형식이 아니예요` }
    if (msg) {
      alert(msg)
    } else {
      let result = false
      // dialog.progress(true)
      try {
        /** 필요한 파라메터만 복사한다. */
        Object.keys(model).map((k) => ['userNm', 'userId', 'passwd', 'email'].indexOf(k) == -1 && delete model[k])
        model.passwd = crypto.aes.encrypt(model.passwd)
        let res = await api.put(`usr01001`, model)
        log.debug('RES:', res)
        if (res.rescd === C.RESCD_OK) {
          result = true
          await goPage(-1)
          await goPage(`/usr/usr01001s02`)
        }
      } catch (e) {
        log.debug('E:', e)
      }
      // thread(() => { dialog.progress(false) }, 500)
      if (!result) {
        alert('회원 등록에 실패했어요 잠시후 다시 시도해 주세요')
      }
    }
  }
  return (
  <Container>
    <section className='title'>
      <h2>회원가입</h2>
    </section>
    <hr/>
    <section className='flex-form'>
      <Form>
        <article>
          <Block className='form-block'>
            <label htmlFor='frm-user-nm'> 이름 </label>
            <Block className='form-element'>
            <Input
              id='frm-user-nm'
              model={ vars.formdata }
              name='userNm'
              placeholder='이름'
              maxLength={ 20 }
              className='w-full'
              size='small'
              />
            </Block>
          </Block>
          <Block className='form-block'>
            <label htmlFor='frm-user-id'>아이디</label>
            <Block className='form-element user-id'>
            <Input
              id='frm-user-id'
              model={ vars.formdata }
              name='userId'
              placeholder='영문자로 시작, 12자 이내'
              maxLength={ 12 }
              className='w-full'
              size='small'
              />
            <Button
              variant='contained'
              color='inherit'
              onClick={ checkUserId }
              >
              중복확인
            </Button>
            </Block>
          </Block>
          <Block className='form-block'>
            <label htmlFor='frm-passwd'>비밀번호</label>
            <Block className='form-element'>
            <Input
              type='password'
              id='frm-passwd'
              model={ vars.formdata }
              name='passwd'
              placeholder='영문자, 숫자, 특수기호 각 1개이상'
              maxLength={ 30 }
              className='w-full'
              size='small'
              />
            </Block>
          </Block>
          <Block className='form-block'>
            <label htmlFor='frm-passwd2'>비밀번호확인</label>
            <Block className='form-element'>
            <Input
              type='password'
              id='frm-passwd2'
              model={ vars.formdata }
              name='passwd2'
              placeholder='비밀번호확인'
              maxLength={ 30 }
              className='w-full'
              size='small'
              />
            </Block>
          </Block>
          <Block className='form-block'>
            <label htmlFor='frm-email'>이메일</label>
            <Block className='form-element email'>
            <Input
              id='frm-email'
              model={ vars.formdata }
              name='emailId'
              placeholder='이메일 아이디'
              maxLength={ 30 }
              size='small'
              />
            <span>@</span>
            { matcher(vars?.emailSelector, 'select', 
              'select', (
                <Select
                  model={ vars.formdata }
                  name='emailHost'
                  options={ vars.emailHosts }
                  onChange={ emailHostChanged }
                  size='small'
                  />
              ),
              'input', (
                <Input
                  model={ vars.formdata }
                  name='emailHost'
                  maxLength={ 30 }
                  size='small'
                  />
              )
            ) }
            </Block>
          </Block>
          <hr/>
          <Block className='buttons'>
            <Button
              className='mx-1'
              variant='contained'
              size='large'
              onClick={ submit }
              >
              완료
            </Button>
            <Button
              className='mx-1'
              variant='outlined'
              size='large'
              >
              취소
            </Button>
          </Block>
        </article>
      </Form>
    </section>
  </Container>
  )
})

3-4. /usr/usr01001s02.jsx (가입완료 페이지)

가입완료 환영페이지, lottie 를 사용한 애니메이션을 추가한다.

import app from '@/libs/app-context'
import { Block, Form, Button, Container, Lottie } from '@/components'

const { definePage, goPage } = app

export default definePage(() => {
  return (
  <Container>
    <section className='title'>
      <h2>회원가입 완료</h2>
    </section>
    <hr/>
    <section className='flex-form'>
      <Form>
        <article className='text-center'>
          <Block>
            <p>
              가입이 완료되었어요
            </p>
            <Lottie
              src='/assets/lottie/hello.json'
              />
          </Block>
          <Block>
            <Button
              variant='contained'
              onClick={() => goPage(-1) }
              >
              이전 페이지로 이동
            </Button>
          </Block>
        </article>
      </Form>
    </section>
  </Container>
  )
})

3-5. /usr/usr01001s03.jsx (마이페이지)

회원정보 수정페이지, 회원가입 페이지에서 많은 부분 복사 작성했다.

마이페이지, 사용자서비스 내용참고

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

const { definePage, useSetup, log, goPage, clone } = app
const { matcher } = values
const userInfo = userContext.getUserInfo()

export default definePage(() => {
  const PTN_EMAIL = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i
  const self = useSetup({
    vars: {
      formdata: {
        id: '',
        userNm: '',
        userId: '',
        passwd: '',
        passwd2: '',
        emailId: '',
        emailHost: '',
        email: '',
      },
      iddupchk: false,
      emailSelector: 'select',
      emailHosts: [
        { name: '선택해 주세요', value: '' },
        'gmail.com',
        'naver.com',
        'daum.net',
        'kakao.com',
        'hotmail.com',
        'icloud.com',
        { name: '직접입력', value: '_' },
      ]
    },
    /** email 등 저장하고 있지 않은 개인정보를 표시하기 위해 불러들임 */
    async mounted() {
      const res = await api.get(`usr01002/${userInfo.userId}`)
      vars.formdata = res
      const email = PTN_EMAIL.exec(vars.formdata.email)
      if (email) {
        vars.formdata.emailId = email[1]
        vars.formdata.emailHost = email[5]
      }
      update(C.UPDATE_ENTIRE)
    }
  })
  const { update, vars, ready } = self()
  const emailHostChanged = async () => {
    if (vars.formdata.emailHost == '_') {
      vars.emailSelector = 'input'
      vars.formdata.emailHost = ''
      update(C.UPDATE_SELF)
    }
  }
  /** validation 진행 후 submit */
  const submit = async () => {
    let msg = ''
    let model = clone(vars.formdata)
    model.email = `${model.emailId}@${model.emailHost}`
    if (!msg && model.passwd && model.passwd.length < 4) { msg = '비밀번호를 4글자 이상 입력해 주세요' }
    if (!msg && model.passwd && !model.passwd2) { msg = '비밀번호 확인을 입력해 주세요' }
    if (!msg && model.passwd && model.passwd !== model.passwd2) { msg = '비밀번호 확인이 맞지 않아요' }
    if (!msg && !model.emailId) { msg = '이메일을 입력해 주세요' }
    if (!msg && !PTN_EMAIL.test(model.email)) { msg = `"${model.email}" 는 올바른 이메일 형식이 아니예요` }
    if (msg) {
      alert(msg)
    } else {
      let result = false
      try {
        /** 필요한 파라메터만 복사한다. */
        Object.keys(model).map((k) => ['id', 'userId', 'passwd', 'email'].indexOf(k) == -1 && delete model[k])
        if (model.passwd) { model.passwd = crypto.aes.encrypt(model.passwd) }
        let res = await api.put(`usr01002`, model)
        log.debug('RES:', res)
        if (res.rescd === C.RESCD_OK) {
          result = true
          goPage(-1)
        }
      } catch (e) {
        log.debug('E:', e)
      }
      if (!result) {
        alert('회원 정보 수정에 실패했어요 잠시후 다시 시도해 주세요')
      }
    }
  }
  return (
  <Container>
    <section className='title'>
      <h2>회원정보 수정</h2>
    </section>
    <hr/>
    <section className='flex-form'>
      <Form>
        <article>
          { ready() && (
          <>
          <Block className='form-block'>
            <p> 이름 : { userInfo.userNm } </p>
          </Block>
          <Block className='form-block'>
            <p> 아이디 : { userInfo.userId } </p>
          </Block>
          </>
          ) }
          <Block className='form-block'>
            <label htmlFor='frm-passwd'>비밀번호</label>
            <Block className='form-element'>
            <Input
              type='password'
              id='frm-passwd'
              model={ vars.formdata }
              name='passwd'
              placeholder='변경시에만 입력해 주세요'
              maxLength={ 30 }
              className='w-full'
              size='small'
              />
            </Block>
          </Block>
          <Block className='form-block'>
            <label htmlFor='frm-passwd2'>비밀번호확인</label>
            <Block className='form-element'>
            <Input
              type='password'
              id='frm-passwd2'
              model={ vars.formdata }
              name='passwd2'
              placeholder='비밀번호확인'
              maxLength={ 30 }
              className='w-full'
              size='small'
              />
            </Block>
          </Block>
          <Block className='form-block'>
            <label htmlFor='frm-email'>이메일</label>
            <Block className='form-element email'>
            <Input
              id='frm-email'
              model={ vars.formdata }
              name='emailId'
              placeholder='이메일 아이디'
              maxLength={ 30 }
              size='small'
              />
            <span>@</span>
            { matcher(vars?.emailSelector, 'select', 
              'select', (
                <Select
                  model={ vars.formdata }
                  name='emailHost'
                  options={ vars.emailHosts }
                  onChange={ emailHostChanged }
                  size='small'
                  />
              ),
              'input', (
                <Input
                  model={ vars.formdata }
                  name='emailHost'
                  maxLength={ 30 }
                  size='small'
                  />
              )
            ) }
            </Block>
          </Block>
          <hr/>
          <Block className='buttons'>
            <Button
              className='mx-1'
              variant='contained'
              size='large'
              onClick={ submit }
              >
              완료
            </Button>
            <Button
              className='mx-1'
              variant='outlined'
              size='large'
              onClick={ () => goPage(-1) }
              >
              취소
            </Button>
          </Block>
        </article>
      </Form>
    </section>
  </Container>
  )
})

3-5. /lgn/lgn01001s01.jsx (로그인 페이지)

자! 회원가입 까지 되었으니 이제 로그인 페이지를 작성해 보자

실제 토큰을 받아와서 영속저장 하는 부분은 api.tsuser-context.ts 에 구현되어 있다

로그인, 로그인서비스, 통신모듈, 사용자정보 모듈 내용참고

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

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

export default definePage(() => {

  const self = useSetup({
    vars: {
      formdata: {
        userId: '',
        passwd: '',
      }
    }
  })

  const { vars } = self()

  const submit = async () => {
    const formdata = clone (vars.formdata)
    formdata.passwd = crypto.aes.encrypt(formdata.passwd)
    try {
      const res = await api.post(`lgn01001`, formdata)
      log.debug('RES:', res)
      if (res.rescd === C.RESCD_OK) {
        goPage(-1)
      } else {
        alert('로그인이 실패했습니다')
      }
    } catch (e) {
      log.debug('E:', e)
      if (e?.msgcode == 'USER_NOT_FOUND') {
        alert('사용자 아이디 혹은 비밀번호가 잘못되었어요')
      } else {
        alert(e?.message || '오류가 발생했어요')
      }
    }
  }

  return (
  <Container>
    <section className='title'>
      <h2>로그인</h2>
    </section>
    <hr/>
    <section className='flex-form'>
      <Form>
        <article className='text-center'>
          <Block className='form-block'>
            <label htmlFor='frm-user-id'> 아이디 </label>
            <Block className='form-element'>
            <Input
              id='frm-user-id'
              model={ vars.formdata }
              name='userId'
              placeholder='로그인 아이디'
              maxLength={ 20 }
              className='w-full'
              size='small'
              onEnter={ submit }
              />
            </Block>
          </Block>
          <Block className='form-block'>
            <label htmlFor='frm-passwd'> 비밀번호 </label>
            <Block className='form-element'>
            <Input
              id='frm-passwd'
              type='password'
              model={ vars.formdata }
              name='passwd'
              placeholder='비밀번호'
              maxLength={ 20 }
              className='w-full'
              size='small'
              onEnter={ submit }
              />
            </Block>
          </Block>
          <hr/>
          <Block className='buttons'>
            <Button
              className='mx-1'
              variant='contained'
              size='large'
              onClick={ submit }
              >
              로그인
            </Button>
            <Button
              className='mx-1'
              variant='outlined'
              size='large'
              >
              취소
            </Button>
          </Block>
        </article>
      </Form>
    </section>
  </Container>
  )
})

3-6. /atc/atc01001s01.jsx (게시물 작성 페이지)

게시물작성, 게시물서비스 내용참고

import app from '@/libs/app-context'
import api from '@/libs/api'
import * as C from '@/libs/constants'
import { Block, Button, Container, Form, Editor } from '@/components'
import Input from '@/components/input'
import $ from 'jquery'

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

export default definePage(() => {
  const self = useSetup({
    vars: {
      formdata: {
        title: '',
        contents: '',
      }
    },
    async mounted() {
    }
  })

  const { vars, update } = self()

  const submit = async () => {
    let msg = ''
    let model = clone(vars.formdata)
    const contents = String($(model.contents).text()).trim()
    if (!msg && !model.title) { msg = '제목을 입력해 주세요' }
    if (!msg && model.title.length < 2) { msg = '제목을 2글자 이상 입력해 주세요' }
    if (!msg && !contents) { msg = '내용을 입력해 주세요' }
    if (!msg && contents.length < 2) { msg = '내용을 2글자 이상 입력해 주세요' }
    if (msg) {
      alert(msg)
    } else {
      let result = false
      try {
        /** 필요한 파라메터만 복사한다. */
        Object.keys(model).map((k) => ['title', 'contents'].indexOf(k) == -1 && delete model[k])
        log.debug('CHECK:', model)
        const res = await api.put(`atc01001`, model)
        log.debug('RES:', res)
        if (res.rescd === C.RESCD_OK) {
          result = true
          goPage(-1)
        }
      } catch (e) {
        msg = e?.message
        log.debug('E:', e)
      }
      if (!result) {
        alert(msg || '새글 쓰기에 실패했어요 잠시후 다시 시도해 주세요')
      }
    }
  }

  return (
  <Container>
    <section className='title'>
      <h2>새글 작성</h2>
    </section>
    <hr/>
    <section className='flex-form'>
      <Form>
        <article>
          <Block className='form-block'>
            <label htmlFor='frm-title'> 제목 </label>
            <Block className='form-element'>
              <Input
                id='frm-title'
                model={ vars.formdata }
                name='title'
                placeholder='제목'
                maxLength={ 20 }
                className='w-full'
                size='small'
                />
            </Block>
          </Block>
          <Block className='form-block'>
            <label htmlFor='frm-contents'> 내용 </label>
            <Block className='form-element editor'>
              <Editor
                id='frm-contents'
                model={ vars.formdata }
                name='contents'
                className='w-full'
                size='small'
                />
            </Block>
          </Block>
          <hr/>
          <Block className='buttons'>
            <Button
              className='mx-1'
              variant='contained'
              size='large'
              onClick={ submit }
              >
              완료
            </Button>
            <Button
              className='mx-1'
              variant='outlined'
              size='large'
              onClick={ () => goPage(-1) }
              >
              취소
            </Button>
          </Block>
        </article>
      </Form>
    </section>
  </Container>
  )
})

3-7. /atc/atc01001s02/[articleid].jsx (게시물 수정 페이지)

파일명이 '[ ]' 로 둘러쌓인 이유는 Java 의 path-variable 과 같이 사용하기 위함이다.

가령 브라우저에서 /atc/atc01001s02/132 로 호출한 경우, 페이지 내부에서 getParameter('articleid') 를 실행하면 132 값을 얻을 수 있다.

import app from '@/libs/app-context'
import api from '@/libs/api'
import * as C from '@/libs/constants'
import userContext from '@/libs/user-context'
import { Container, Block, Input, Button, Link, Content, Form, Editor } from '@/components'
import moment from 'moment'
import $ from 'jquery'

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

export default definePage(() => {
  const self = useSetup({
    vars: {
      formdata: {
        id: '',
        title: '',
        contents: '',
        userId: '',
        userNm: '',
        ctime: '',
        utime: '',
        boardId: '',
        num: ''
      },
    },
    async mounted() {
      /** getParameter 로 path-variable (articleid)을 읽어온다 */
      loadData(getParameter('articleid'))
    }
  })
  const userInfo = userContext.getUserInfo()
  const { vars, update, ready } = self()

  const loadData = async (articleId) => {
    const res = await api.get(`atc01001/${articleId}`)
    vars.formdata = clone(res)
    log.debug('RES:', res)
    setTimeout(() => update(C.UPDATE_ENTIRE), 200)
  }

  const submit = async () => {
    let msg = ''
    let model = clone(vars.formdata)
    const contents = String($(model.contents).text()).trim()
    if (!msg && !model.title) { msg = '제목을 입력해 주세요' }
    if (!msg && model.title.length < 2) { msg = '제목을 2글자 이상 입력해 주세요' }
    if (!msg && !contents) { msg = '내용을 입력해 주세요' }
    if (!msg && contents.length < 2) { msg = '내용을 2글자 이상 입력해 주세요' }
    if (msg) {
      alert(msg)
    } else {
      let result = false
      try {
        /** 필요한 파라메터만 복사한다. */
        Object.keys(model).map((k) => ['id', 'title', 'contents'].indexOf(k) == -1 && delete model[k])
        log.debug('CHECK:', model)
        const res = await api.put(`atc01001`, model)
        log.debug('RES:', res)
        if (res.rescd === C.RESCD_OK) {
          result = true
          goPage(-1)
        }
      } catch (e) {
        msg = e?.message
        log.debug('E:', e)
      }
      if (!result) {
        alert(msg || '글 수정에 실패했어요 잠시후 다시 시도해 주세요')
      }
    }
  }

  const print = {
    cdate: (date) => date && moment(date).format('YYYY-MM-DD'),
  }
  return (
  <Container>
    <section className='title'>
      <h2>글수정</h2>
    </section>
    <hr/>
    <section className='flex-form'>
      <Form>
        <article>
          <Block className='form-block'>
            <label htmlFor='frm-title'> 제목 </label>
            <Block className='form-element'>
              <Input
                id='frm-title'
                model={ vars.formdata }
                name='title'
                placeholder='제목'
                maxLength={ 20 }
                className='w-full'
                size='small'
                />
            </Block>
          </Block>
          <Block className='form-block'>
            <label htmlFor='frm-contents'> 내용 </label>
            <Block className='form-element editor'>
              <Editor
                id='frm-contents'
                model={ vars.formdata }
                name='contents'
                className='w-full'
                size='small'
                />
            </Block>
          </Block>
          <hr/>
          <Block className='buttons'>
            <Button
              className='mx-1'
              variant='contained'
              size='large'
              onClick={ submit }
              >
              완료
            </Button>
            <Button
              className='mx-1'
              variant='outlined'
              size='large'
              onClick={ () => goPage(-1) }
              >
              취소
            </Button>
          </Block>
        </article>
      </Form>
    </section>
  </Container>
  )
})

3-8. /atc/atc01001s03/[articleid].jsx (게시물 조회 페이지)

게시물 수정 페이지와 같이 path-variable 을 사용한다.

게시물상세조회, 게시물서비스 내용참고

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

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

export default definePage(() => {
  const self = useSetup({
    vars: {
      data: {
        id: '',
        title: '',
        contents: '',
        userId: '',
        userNm: '',
        ctime: ''
      },
    },
    async mounted() {
      loadData(getParameter('articleid'))
    }
  })
  const userInfo = userContext.getUserInfo()
  const { vars, update, ready } = self()

  const loadData = async (articleId) => {
    const res = await api.get(`atc01001/${articleId}`)
    vars.data = clone(res)
    log.debug('RES:', res)
    update(C.UPDATE_FULL)
  }

  const print = {
    cdate: (date) => date && moment(date).format('YYYY-MM-DD'),
  }
  return (
  <Container>
    <section className='title'>
      <h2>{ vars.data?.title || '게시물 조회' }</h2>
      { ready() && (userInfo?.userId || '') == vars.data?.userId && (
        <Button
          variant='contained'
          href={`/atc/atc01001s02/${vars.data?.id}`}
          >
          글수정
        </Button>
      ) }
    </section>
    <hr/>
    <section>
      <article>
        <Block> 
          <p> 작성자 : { vars.data.userNm } </p>
          <p> 작성일시 : { print.cdate(vars.data.ctime) } </p>
        </Block>
        <hr/>
        <Block className='w-full overflow-x-auto'>
          <Content html={ vars.data?.contents || '' } />
        </Block>
      </article>
    </section>
  </Container>
  )
})

3-9. /atc/atc01001s04/[pagenum].jsx (게시물 목록 페이지)

게시물검색, 게시물서비스 내용참고

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

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

export default definePage(() => {
  const self = useSetup({
    vars: {
      data: {
        list: [],
      },
      /** pagination 을위한 데이터 */
      pdata: {
        currentPage: 1,
        rowCount: 10,
        rowStart: 0,
        rowTotal: 0,
        keyword: '',
        searchType: '',
        orderType: ''
      },
      state: 0,
    },
    async mounted() {
      loadData(getParameter('pagenum') || 1)
    },
  })

  const { vars, update, ready } = self()
  const userInfo = userContext.getUserInfo()

  const print = {
    num: (inx) => (vars.pdata?.rowTotal || 0) - (vars.pdata?.rowStart || 0) - inx,
    cdate: (date) => date && moment(date).format('YYYY-MM-DD'),
    /** 상태에 따라 다른 메시지가 출력된다 */
    state(state) {
      switch (state) {
      case 0: return '잠시만 기다려 주세요.'
      case 2: return '검색된 게시물이 없습니다.'
      default: return state
      }
    }
  }

  const loadData = async (pagenum = C.UNDEFINED) => {
    vars.state = 0
    update(C.UPDATE_FULL)
    try {
      if (pagenum !== C.UNDEFINED) {
        vars.pdata.rowStart = ((pagenum - 1) || 0) * vars.pdata.rowCount
      }
      const pdata = clone(vars.pdata)
      const res = await api.post(`atc01001`, pdata)
      log.debug('RES:', res)
      vars.data = res
      if (vars.data.list.length != 0) {
        vars.state = 1
        vars.pdata.currentPage = pagenum
      } else {
        vars.state = 2
      }
      for (const k in vars.pdata) { Object.hasOwn(res, k) && (vars.pdata[k] = res[k]) }
    } catch (e) {
      vars.data.list = []
      vars.state = e?.message || '오류가 발생했습니다.'
      log.debug('E:', e)
    }
    update(C.UPDATE_FULL)
  }

  const pageChanged = async () => {
    goPage(`/atc/atc01001s04/${vars.pdata.currentPage}`)
  }
  return (
  <Container>
    <section className='title'>
      <h2>게시물 목록</h2>
      { ready() && userInfo.userId && (
      <Button
        variant='contained'
        href='/atc/atc01001s01'
        >
        새글작성
      </Button>
      ) }
    </section>
    <hr/>
    <section>
      <article>
        <Block>
          <table className='w-full articles'>
            <colgroup>
              <col width={'10%'} />
              <col width={'50%'} />
              <col width={'20%'} />
              <col width={'20%'} />
            </colgroup>
            <thead>
              <tr>
                <th> 순번 </th>
                <th> 제목 </th>
                <th> 작성자 </th>
                <th> 작성일 </th>
              </tr>
            </thead>
            <tbody>
            <tr><td colSpan={4} className='nopad'><hr/></td></tr>
            { (vars.data?.list?.length || 0) === 0 && (
              <tr><td colSpan={4} className='text-center'>{
                print.state(vars.state)
              }</td></tr>
            ) }
            { vars.data?.list?.map((itm, inx) => (
              <tr key={ inx }>
                <td className='text-right'> { print.num(inx) } </td>
                <td>
                  <Link
                    href={ `/atc/atc01001s03/${itm.id}` }
                    >
                    { itm?.title }
                  </Link>
                </td>
                <td> { itm?.userNm } </td>
                <td> { print.cdate(itm?.ctime) } </td>
              </tr>
            )) }
            </tbody>
          </table>
        </Block>
        <Block className='flex justify-center'>
          <Pagination
            model={ vars.pdata }
            onChange={ pageChanged }
            />
        </Block>
      </article>
    </section>
  </Container>
  )
})

3-10. /atc/atc01001s04/index.jsx (게시물 목록 페이지)

실제 기능은 위 페이지에서 수행하므로 단순히 import-export 처리한다.

import Atc01001s04 from './[pagenum]'
export default Atc01001s04

4. 결과물

뭐.... 일단은 완성했다.

최종 결과물의 전체 파일 목록은 아래와 같다. (필수 파일들)

/public/assets/lottie/hello.json 파일은 lottie 애니메이션을 다운로드 한 것이다

├── public
│   ├── assets
│   │   └── lottie
│   │       └── hello.json
│   └── favicon.ico
├── env
│   ├── env-local.yml
│   └── replace-loader.js
├── src
│   ├── components
│   │   ├── block.tsx
│   │   ├── button.tsx
│   │   ├── checkbox.tsx
│   │   ├── container.tsx
│   │   ├── content.tsx
│   │   ├── editor.tsx
│   │   ├── footer.tsx
│   │   ├── form.tsx
│   │   ├── header.tsx
│   │   ├── index.ts
│   │   ├── input.tsx
│   │   ├── layout.tsx
│   │   ├── link.tsx
│   │   ├── lottie.tsx
│   │   ├── pagination.tsx
│   │   └── select.tsx
│   ├── libs
│   │   ├── api.ts
│   │   ├── app-context.ts
│   │   ├── constants.ts
│   │   ├── crypto.ts
│   │   ├── log.ts
│   │   ├── user-context.ts
│   │   └── values.ts
│   └── pages
│       ├── atc
│       │   ├── atc01001s01.jsx
│       │   ├── atc01001s02
│       │   │   └── [articleid].jsx
│       │   ├── atc01001s03
│       │   │   └── [articleid].jsx
│       │   └── atc01001s04
│       │       ├── [pagenum].jsx
│       │       └── index.jsx
│       ├── lgn
│       │   └── lgn01001s01.jsx
│       ├── mai
│       │   └── mai01001s01.jsx
│       ├── smp
│       │   ├── smp01001s01.jsx
│       │   └── smp01001s02.jsx
│       ├── usr
│       │   ├── usr01001s01.jsx
│       │   ├── usr01001s02.jsx
│       │   └── usr01001s03.jsx
│       ├── _app.jsx
│       ├── _document.jsx
│       ├── global.scss
│       └── index.jsx
├── .eslintrc.json
├── next.config.mjs
├── package.json
├── package-lock.json
└── tsconfig.json

실제 실행되는 모습이다.



5. 후~~~우기

처음에는 정말 혼자서 할수있는 간단하고 쉬운 코딩 교육용 게시판 프로젝트 하나 작성하려고 했는데

중간중간 살붙이기도 많이 했고 하는김에 제대로 하자 라는 욕심도 붙어서

초보자가 이해하기에는 살짝 어렵고, 경력자에게는 불필요한 좀 어중간한 프로젝트가 되고 말았다.

뭐 개인적으로는 이거 통째로 가져다가 실제 프로젝트에 써먹긴 했으니 손해본거 같지는 않지만..

실제 스터디 할 때 까지만 해도 대충 만든거라 좀 불안하게 작동했는데

블로그로 정리하면서 꽤 많이 안정화 시킨거 같다 (내가 보기에는......).

일단 (내가 사용할 목적으로) 본 프로젝트를 바탕으로 많은 기능들을 추가시켜 볼 생각이다.

(진행 사항은 gitlab 또는 github 를 통해 지속적으로 배포할 계획이다)

급하게 달려오다 보니 설명되지 않았던 핵심 기술들에 대한 상세 설명들은

하나씩 기술팁 등의 짧은글로 남기도록 하겠다.

끝!!

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

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

테스트 실행 : https://devlog.ntiple.com/samples/sample-1088

[ ←전편보기 / 1편, 2편, 3편, 4편, 5편 ]

답글 남기기

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