[TOC]
[ ←전편보기 / 1편, 2편, 3편, 5편, 6편 / 다음편보기→ ]
결과물 git주소 : https://gitlab.ntiple.com/developers/study-202403
1. 잡설....
이전 포스팅까지는 백엔드 JAVA 서버를 구축해 보았다.
이번부터는 본격적으로 프론트엔드를 넘어가는데...
마찬가지로 바닥부터 구축해 볼거다.
그런데 포스팅 하려고 이것 저것 정리 하다보니...
분량이 상당히 늘어나 버렸다...
(애초에 스터디 했던 영역을 벗어나 버린것도 있고...)
프론트엔드 편을 3편으로 기획 했는데 아마도 1편 정도 더 해야 할 듯......
(뭐.. 항상 그랬듯이 내가 계획한 것과는 다르게 흘러가는 법이지..머....)
일단 이번편에서는 뼈대 까지만 만들거라 일단 재미 없는 내용이 연속될거다.
그리고 이번 스터디 하면서 정한 규칙이 생겼는데.
-
공통 라이브러리 및 컴포넌트는 타입스크립트 를 사용해 구축하고
-
재활용 할 일이 별로 없는 페이지 같은 파일들은 jsx 를 사용해 구축하는것을 원칙으로 한다.
이렇게 해 두면 자동완성 등 타입스크립트에서 누릴수 있는 견고함과 일반 자바스크립트를 사용함으로서
누릴수 있는 자유로움(유연성) 을 둘다 느낄 수 있을 것이다.
2. next-app 초기 구축
자 그럼 본격적으로 next-app 을 시작해 보자, cli 환경으로 구축 할거고
기본적으로 nodejs 는 설치 되어 있어야 한다 (테스트 해 본 결과 v18.17 정도면 무리 없이 작동했다.)
이하 중요한 설명이 아닌 경우 주석으로 대체했다.
(분량 압박이 심하므로 실행해 보실분들은 그냥 복사/붙여넣기 하는걸 권장합니다......)
2-1. npx create-next-app
수행
$ npx create-next-app my-next-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
... 중략 ...
129 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
Initialized a git repository.
- 이후
/src/app
폴더 삭제하고/env
,/src/libs
,/src/components
,/src/pages
,/src/assets
폴더들을 추가한다
2-2. 기초 설정 파일들 수정
.eslintrc.json
파일을 수정한다 (불필요한 오류 레포팅을 줄여준다)
{
"extends": "next/core-web-vitals",
"rules": {
"react/display-name": "off",
"import/no-anonymous-default-export": "off"
}
}
tsconfig.json
파일을 수정한다. ('js', 'jsx' 파일에서 자동완성 지원)
... 중략 ...
"include": [
"next-env.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.js",
"src/**/*.jsx",
".next/types/**/*.ts"
],
... 중략 ...
2-3. 의존 패키지들 설치
## 코어패키지 / nextjs, react 기본 및 redux 등
$ npm install crypto-js jquery jsencrypt lodash moment @reduxjs/toolkit \
next-transpile-modules react-redux react-transition-group \
redux redux-deep-persist redux-persist html-react-parser
## UI패키지 / material-ui, framer, lottie 등
$ npm install @mui/base @mui/icons-material @mui/material @emotion/styled framer-motion lottie-web
## 에디터 / nextjs 에서는 tiptap 이 비교적 호환성이 좋은듯 하다.
$ npm install @tiptap/react @tiptap/pm @tiptap/starter-kit
## 개발환경 / 나머지 개발환경에 필요한것들..
$ npm install -D @types/crypto-js @types/jquery @types/lodash @types/react-transition-group \
js-yaml sass sass-loader
2-4. 소스 변조기 플러그인 작성
-
포스팅을 준비하다가 간단하게 빌드 중 소스 변조작업이 필요해서 만든 웹팩용 플러그인 이다.
-
아래와 같이
env/replace-loader.js
파일을 작성 한다
/** 빌드시 소스코드를 가로채어 변화시켜주는 웹팩 플러그인 */
module.exports = function(source) {
let result = String(source || '')
let mat
/** 치환데이터 저장소를 초기화 한다 */
if (BUILD_STORE.$INITIALIZED === false) {
const o = JSON.parse(process?.env?.BUILD_STORE)
for (const k in o) { BUILD_STORE[k] = o[k] }
delete BUILD_STORE.$INITIALIZED
}
/** 소스코드에서 {$$문자열$$} 이 발견되면 BUILD_STORE 에서 내용을 찾아 치환한다 */
while (result != null && (mat = /\{\$\$([a-zA-Z0-9_-]+)\$\$\}/g.exec(result))) {
const prev = result.substring(0, mat.index)
const next = result.substring(mat.index + mat[0].length, result.length)
result = `${prev}${BUILD_STORE[mat[1]]}${next}`
}
return result
}
/** 치환데이터 저장소 */
const BUILD_STORE = { $INITIALIZED: false }
2-5. 환경변수 파일 작성
/env/env-local.yml
,/env/env-prod.yml
등 프로파일별 환경변수를 작성한다.
app:
## 프로파일 local / dev /prod 등 상황에 따라 작성한다
profile: 'local'
basePath: ''
api:
## API 자바서버 주소 reverse-proxy 로 작동하므로 localhost 로 작성해도 된다.
base: '/api'
alter: '/api'
server: 'http://localhost:8080'
timeout: 10000
security:
key:
## 페이지 적재후 API 와 키교환을 위한 RSA 암호화 키
rsa: 'MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMhfVP50PfMYry6YsMYpEdTPjWRHflvUNDhzhrgyO5gEiTIj2lK6S95XRggsU/NGTbRqRouBmFlV4+KHL9LLKhXszqlDRDWf1jZJBNdI4/GpRlQU/MkByji+2lv9894gJPXpExDLO/bM+PTZ16HMAMyZJ6DNw5hD/GMEudZk6U1lAgMBAAECgYAD/17nOrN3s6DfGZ3BPlWEPOXRv9lmBJxMGgXwi9QDiuefz/ZNmzjjRTN4+0Vrf5YSSOKCawH6mkuTG+ZY2sPKpiIBSX4SXewMIKRhbxOj19iwrNp1gBDK3s/kpHy9+x8b7tuEIITNYreuadYAvVgSMlaJVPdh7uUm39sMnn/aqwJBANRWQBpaOfw3iQ+g7jBrBJz+e5QbmsNZeNUPrk9WbMuhKQkLUP2E4U5noFKV8SZFGo48YPKsC32DxFF3e8kw2fsCQQDxk0ALLlepzdpqVkm6YQ4KtTEweug+L/RdjU5apGZLZI+AGfYIE1JIzyzgdPke92ePnw1wPqFgi0PyzyJn8DgfAkAq5M2IRUfHapSWgqT7RPMmn8XpEnZ+Ffnx2HwW7NeHfyPh/tY6kHhPNWHOrRmM6JLHvuy6uQSNM2waJO/toZ+3AkEAo4WzUl46RNztPhHOsnTEFod0Fob78ixv02u1YDHsdJhLcsEgA3NgvZxPmlhT0ZxS46scY6BhiIJ8qj1/4q9+rQJAbZj4vsQB5SrKcYNR8VVYMP9EYXnFax66QgSjGI3zPJhjAcbdyhDVshFKHcsI3R1dxegLFTbvjYKejzWfHBcBUQ=='
log:
## 기본 로그레벨, info, warn 등 상위레벨로 수정시 콘솔 출력이 사라진다
level: 'debug'
2-6. nextjs 구동파일 수정
/next.config.mjs
파일을 아래와 같이 수정한다.
import yaml from 'js-yaml'
import cryptojs from 'crypto-js'
import fs from 'fs'
const nextConfig = () => {
/** 커맨드 : npm run dev 했을경우 : dev */
const cmd = process.env.npm_lifecycle_event
/** 개발모드로 실행중인지 여부 */
const prod = process.env.NODE_ENV === 'production'
/** 프로파일별 환경변수를 읽어온다 */
const PROFILE = process.env.PROFILE || 'local'
const yml = yaml.load(fs.readFileSync(`${process.cwd()}/env/env-${PROFILE}.yml`, 'utf8'))
if (!process.env?.PRINTED) {
console.log('================================================================================')
console.log(`샘플앱 / 프로파일 : ${PROFILE} / 구동모드 : ${cmd} / API프록시 : ${yml.api?.server}`)
console.log('================================================================================')
process.env.PRINTED = true
/** 설정정보 등을 암호화 하여 클라이언트로 보내기 위한 AES 키, replace-loader 에 의해 constants 에 입력된다 */
const cryptokey = btoa(Array(32).fill('0').map((v, i, l) => l[i] = Math.round(Math.random() * 255)))
process.env.BUILD_STORE = JSON.stringify({
CRYPTO_KEY: cryptokey,
ENCRYPTED: cryptojs.AES.encrypt(JSON.stringify(yml), cryptokey).toString()
})
}
/** API 프록시 설정 */
const apiproxy = [ ]
apiproxy.push({
source: `${yml?.api?.base || '/api'}/:path*`,
destination: `${yml?.api?.server || 'http://localhost:8080'}${yml?.api?.alter || '/api'}/:path*`
})
return {
/** /api 경로로 요청이 들어올 경우 API 자바서버로 프록시 */
async rewrites() { return apiproxy },
/** npm run generate 로 빌드시 정적빌드 수행 하도록 */
output: /generate/.test(cmd) ? 'export' : undefined,
/** 빌드결과물 생성위치 : /dist */
distDir: 'dist',
basePath: yml?.app?.basePath || undefined,
/** 개발모드에서 페이지가 두번씩 접근되는 현상 방지 */
reactStrictMode: prod ? true : false,
/** 빌드타임에 사용되는 설정정보 */
serverRuntimeConfig: yml,
/** 브라우저에 전달할 설정정보 */
publicRuntimeConfig: { profile: PROFILE },
/** 웹팩 빌드중 소스코드를 가로채 변경한다 (replace-loader) */
webpack: (cfg, opt) => {
cfg?.module?.rules?.push && cfg.module.rules.push({
test: [ /\/libs\/(constants|app-context)\.[jt]s$/ ],
loader: `${process.cwd()}/env/replace-loader.js`,
})
return cfg
},
}}
export default nextConfig()
2-7. 각종 라이브러리 구축
2-7-1. 전역상수 (/src/libs/constants.ts
)
/** 자주 사용되는 상수 정의 */
export const ROOT = 'ROOT'
export const DEBUG = 'debug'
export const INFO = 'info'
export const TRACE = 'trace'
export const WARN = 'warn'
export const ERROR = 'error'
export const UTF8 = 'utf-8'
export const TEXT = 'text'
export const STRING = 'string'
export const ALPHA = 'alpha'
export const ALPHASPC = 'alphaspc'
export const ALPHANUM = 'alphanum'
export const ALPHASTART = 'alphastart'
export const ALPHANUMSPC = 'alphanumspc'
export const ASCII = 'ascii'
export const EMAIL = 'email'
export const PUBLIC_KEY = 'publickey'
export const PRIVATE_KEY = 'privatekey'
export const OBJECT = 'object'
export const BOOLEAN = 'boolean'
export const NUMBER = 'number'
export const NUMERIC = 'numeric'
export const CONTENT_TYPE = 'content-type'
export const CHARSET = 'charset'
export const CTYPE_JSON = 'application/json'
export const CTYPE_GRAPHQL = 'application/graphql'
export const CTYPE_FORM = 'application/x-www-form-urlencoded'
export const CTYPE_XML = 'text/xml'
export const CTYPE_HTML = 'text/html'
export const CTYPE_TEXT = 'plain/text'
export const CTYPE_CSS = 'text/css'
export const CTYPE_OCTET = 'application/octet-stream'
export const CTYPE_MULTIPART = 'multipart/form-data'
export const APPSTATE_INIT = 0
export const APPSTATE_START = 1
export const APPSTATE_ENV = 2
export const APPSTATE_USER = 3
export const APPSTATE_READY = 4
export const APPSTATE_ERROR = 5
export const UNDEFINED = undefined as any
export const HIDE_PRELOAD = 'hide-preload'
export const FN_NIL = (..._: any[]) => { }
export const BEARER = 'Bearer'
export const AUTHORIZATION = 'Authorization'
export const POST = 'post'
export const GET = 'get'
export const PUT = 'put'
export const DELETE = 'delete'
export const TOKEN_REFRESH = 'tokenRefresh'
export const EXTRA_TIME = 1000 * 5
export const EXPIRE_NOTIFY_TIME = 1000 * 60 * 2
export const UPDATE_ENTIRE = 3
export const UPDATE_FULL = 2
export const UPDATE_SELF = 1
export const UPDATE_IF_NOT = 0
export const SC_OK = 200
export const SC_MOVED_PERMANENTLY = 301
export const SC_MOVED_TEMPORARILY = 302
export const SC_UNAUTHORIZED = 401
export const SC_FORBIDDEN = 403
export const SC_NOT_FOUND = 404
export const SC_METHOD_NOT_ALLOWD = 405
export const SC_BAD_REQUEST = 400
export const SC_INTERNAL_SERVER_ERROR = 500
export const SC_BAD_GATEWAY = 502
export const SC_SERVICE_UNAVAILABLE = 503
export const SC_GATEWAY_TIMEOUT = 504
export const SC_RESOURCE_LIMIT_IS_REACHED = 508
export const RESCD_OK = '0000'
/** 이 부분은 웹팩 플러그인(replace-loader)에 의해 자동으로 채워진다 */
export const CRYPTO_KEY = '{$$CRYPTO_KEY$$}'
2-7-2. 로그 유틸 (/src/libs/log.ts
)
/** Java 의 Logger 와 비슷하게 작성된 로거, setLevel 에 의해 동적으로 레벨링 가능하다 */
import * as C from './constants'
const { FN_NIL, ROOT, TRACE, DEBUG, INFO, WARN, ERROR, UNDEFINED } = C
const fnclog = console.log
const fncinfo = console.info
const fnwarn = console.warn
const fnerrr = console.error
type LogCtxType = { [name: string]: Log }
type Levels = 'trace' | 'debug' | 'info' | 'warn' | 'error'
type AppenderType = typeof console | {
log?: typeof FN_NIL,
info?: typeof FN_NIL,
warn?: typeof FN_NIL,
error?: typeof FN_NIL
}
const logctx: LogCtxType = {
ROOT: UNDEFINED as Log
}
class Log {
constructor(namespace: string = ROOT, appender?: AppenderType) {
if (namespace === undefined) { namespace = ROOT }
logctx[namespace] = this
let level = logctx[ROOT]?.level || DEBUG
this.inst = this
this.namespace = namespace
this.setLevel(level, appender)
}
/** proxy setter 용 */
private inst = UNDEFINED as any as Log
private level: Levels = DEBUG
private namespace = ROOT
private appender: AppenderType = console
trace = FN_NIL
debug = fnclog
info = fncinfo
warn = fnwarn
error = fnerrr
getLogger(namespace: string = ROOT, appender?: AppenderType) {
let ret: Log = UNDEFINED
let inst: Log = UNDEFINED
let level = logctx[namespace]?.level || logctx[ROOT]?.level || DEBUG
if (namespace) { inst = logctx[namespace] }
if (!inst && namespace) { inst = new Log(namespace, appender) }
if (!inst) { inst = logctx[ROOT] }
if (inst) {
inst.setLevel(level)
ret = makeProxy(inst)
}
return ret
}
getLevel() { return this.level }
getNamespace() { return this.namespace }
/** 로그 레벨링, log.setLevel('info') 실행 후 log.debug('log') 호출시 콘솔에 출력되지 않음 */
setLevel(level: Levels, appender?: AppenderType) {
let inst = this.inst
if (appender) { inst.appender = appender }
switch (level) {
case TRACE: {
inst.trace = inst?.appender?.log || fnclog
inst.debug = inst?.appender?.log || fnclog
inst.info = inst?.appender?.info || inst?.appender?.log || fncinfo
inst.warn = inst?.appender?.warn || inst?.appender?.log || fnwarn
inst.error = inst?.appender?.error || inst?.appender?.log || fnerrr
inst.level = level
} break
case DEBUG: {
inst.trace = FN_NIL
inst.debug = inst?.appender?.log || fnclog
inst.info = inst?.appender?.info || inst?.appender?.log || fncinfo
inst.warn = inst?.appender?.warn || inst?.appender?.log || fnwarn
inst.error = inst?.appender?.error || inst?.appender?.log || fnerrr
inst.level = level
} break
case INFO: {
inst.trace = FN_NIL
inst.debug = FN_NIL
inst.info = inst?.appender?.info || inst?.appender?.log || fncinfo
inst.warn = inst?.appender?.warn || inst?.appender?.log || fnwarn
inst.error = inst?.appender?.error || inst?.appender?.log || fnerrr
inst.level = level
} break
case WARN: {
inst.trace = FN_NIL
inst.debug = FN_NIL
inst.info = FN_NIL
inst.warn = inst?.appender?.warn || inst?.appender?.log || fnwarn
inst.error = inst?.appender?.error || inst?.appender?.log || fnerrr
inst.level = level
} break
case ERROR: {
inst.trace = FN_NIL
inst.debug = FN_NIL
inst.info = FN_NIL
inst.warn = FN_NIL
inst.error = inst?.appender?.error || inst?.appender?.log || fnerrr
inst.level = level
} break
default: { inst.setLevel(DEBUG, appender) }}
return this
}
/** window.console 이외의 appender 를 사용하고자 할 때 사용 */
setAppender(appender: AppenderType = console) {
this.setLevel(this.level, appender)
}
setGlobalLevel(level: Levels, namespace?: string, appender?: AppenderType) {
if (namespace && namespace !== 'all') {
logctx[namespace].setLevel(level, appender)
} else {
for (const key in logctx) {
logctx[key].setLevel(level, appender)
}
}
}
setGlobalAppender(namespace?: string, appender?: AppenderType) {
if (namespace && namespace !== 'all') {
logctx[namespace].setAppender(appender)
} else {
for (const key in logctx) {
logctx[key].setAppender(appender)
}
}
}
}
/** Proxy 객체화 하여 내부 멤버를 조작할수 없도록 한다 */
const makeProxy = (log: Log) => {
return new Proxy(log, {
set(o: any, k: any, v: any) {
throw Error(`NOT ALLOWED MODIFY! log.${k}`)
}
}) as Log
}
const log = makeProxy(new Log(ROOT, console)).setLevel(DEBUG)
const getLogger = log.getLogger
export default log
export { getLogger, type Levels, type AppenderType }
2-7-3. 데이터 유틸 (/src/libs/values.ts
)
/** 각종 데이터 유틸 모음 */
import * as C from './constants'
import log from './log'
type PutAllOptType = {
deep?: boolean
root?: any
}
const values = {
clone<T>(v: T) {
let ret:T = C.UNDEFINED
try {
ret = JSON.parse(JSON.stringify(v))
} catch(e) { log.debug('E:', e) }
return ret
},
/** source객체의 모든 속성을 target 객체에 입력한다 (첫번째 인자가 target), deep 옵션을 줄경우 요소마다 재귀호출한다 */
putAll<T>(_target: T, source: any, opt: PutAllOptType = C.UNDEFINED) {
let target: any = _target
if (target == null || source == null || target === source ) { return target }
if (!opt) { opt = { root: target } }
for (const k in source) {
const titem = target[k]
const sitem = source[k]
if (titem !== undefined && titem !== null) {
if (typeof (titem) === C.STRING) {
target[k] = source[k]
} else if ((opt?.deep) && titem instanceof Array && sitem instanceof Array) {
values.putAll(titem, sitem, opt)
} else if ((opt?.deep) && typeof(titem) === C.OBJECT && typeof(sitem) === C.OBJECT) {
values.putAll(titem, sitem, opt)
} else {
/** 타입이 다르다면 무조건 치환. */
target[k] = source[k]
}
} else {
target[k] = source[k]
}
}
return target
},
/** target 에서 exclude 나열된 것들을 제외한 모든 요소를 복제한 객체 생성 */
copyExclude<T>(target: T, excludes: any[] = []) {
let ret: any = { }
const keys = Object.keys(target as any)
for (const key of keys) {
if (excludes.indexOf(key) !== -1) { continue }
ret[key] = (target as any)[key]
}
return ret as T
},
/** react의 ref 객체끼리 복제 할 때 사용 (useRef), 기본적으로는 putAll 과 동일 */
copyRef(target: any, source: any, opt?: any) {
if (target && source && target.hasOwnProperty('current')) {
values.putAll(target, source)
if (opt) { values.putAll(target, opt) }
}
return target
},
/** target 객체 내부의 모든 요소 삭제 */
clear<T>(target: T) {
if (target instanceof Array) {
target.splice(0, target.length)
} else if (target) {
for (const k in target) {
const v = target[k]
if (v instanceof Array) {
values.clear(v)
} else if (typeof v === C.OBJECT) {
values.clear(v)
} else {
delete target[k]
}
}
}
return target
},
/** 최소(min)~최대(max)값 사이의 난수 생성, 최소값을 입력하지 않을경우 자동으로 0 으로 지정됨 */
getRandom(max: number, min: number = C.UNDEFINED) {
if (min == C.UNDEFINED) { min = 0 }
if (max < 0) { max = max * -1 }
const ret = min + Math.floor(Math.random() * max)
return ret
},
/** 단일문자 난수 */
randomChar(c = 'a', n = 26) {
return String.fromCharCode(Number(c.charCodeAt(0)) + values.getRandom(n))
},
/** 난수로 이루어진 문자열, number / alpha / alphanum 의 3가지 타입으로 생성가능 */
randomStr (length: number, type: 'number' | 'alpha' | 'alphanum' = C.UNDEFINED) {
let ret = ''
switch (type) {
case undefined:
/** 숫자 */
case 'number': {
for (let inx = 0; inx < length; inx++) {
ret += String(values.getRandom(10))
}
} break
/** 문자 */
case 'alpha': {
for (let inx = 0; inx < length; inx++) {
switch(values.getRandom(2)) {
case 0: /** 소문자 */ { ret += values.randomChar('a', 26) } break
case 1: /** 대문자 */ { ret += values.randomChar('A', 26) } break
}
}
} break
/** 영숫자 */
case 'alphanum': {
for (let inx = 0; inx < length; inx++) {
switch(values.getRandom(3)) {
case 0: /** 숫자 */ { ret += String(values.getRandom(10)) } break
case 1: /** 소문자 */ { ret += values.randomChar('a', 26) } break
case 2: /** 대문자 */ { ret += values.randomChar('A', 26) } break
}
}
break
} }
return ret
},
matcher(v: any, def: any, ...arg: any[]) {
let ret = C.UNDEFINED
let defval = C.UNDEFINED
let vlst = v
if (!arg) { return ret }
if (!(vlst instanceof Array)) { vlst = [vlst] }
for (let inx = 0; inx < arg.length; inx += 2) {
let alst = arg[inx]
if (!(alst instanceof Array)) { alst = [alst] }
for (const aitm of alst) {
for (const vitm of vlst) {
if (vitm === aitm) {
ret = arg[inx + 1]
break
}
if (aitm === def) { defval = arg[inx + 1] }
}
}
}
if (ret === C.UNDEFINED) { ret = defval }
return ret
},
}
export default values
2-7-4. 암호화 유틸 (/src/libs/crypto.ts
)
/** app 내에서 aes 와 rsa 방식 암복호화를 간편하게 다룰 목적으로 작성 */
import cryptojs from 'crypto-js'
import * as C from './constants'
import log from './log'
type WordArray = cryptojs.lib.WordArray
/** 암호화 기본키 저장소 */
const context = {
aes: {
defbit: 256,
defkey: undefined as any as WordArray,
opt: {
iv: undefined as any as WordArray,
mode: cryptojs.mode.CBC,
padding: cryptojs.pad.Pkcs7
}
},
rsa: {
cryptor: undefined as any,
defkey: ''
}
}
context.aes.defkey = cryptojs.enc.Utf8.parse(''.padEnd(context.aes.defbit / 8, '\0'))
context.aes.opt.iv = cryptojs.enc.Hex.parse(''.padEnd(16, '0'))
const NIL_ARR = cryptojs.enc.Hex.parse('00')
const crypto = {
/** AES 모듈 */
aes: {
init: async (key?: any) => {
if (key) { context.aes.defkey = crypto.aes.key(key) }
},
decrypt: (msg: string, key?: any) => {
let hkey: WordArray = key ? crypto.aes.key(key) : context.aes.defkey
return cryptojs.AES.decrypt(msg, hkey, context.aes.opt).toString(cryptojs.enc.Utf8)
},
encrypt: (msg: string, key?: any) => {
let hkey: WordArray = key ? crypto.aes.key(key) : context.aes.defkey
return cryptojs.AES.encrypt(msg, hkey, context.aes.opt).toString()
},
key(key: any, bit: number = context.aes.defbit) {
let ret: any = undefined
if (key) {
if (typeof key === C.STRING || typeof key === C.NUMBER) {
key = String(key)
const b64len = Math.round(bit * 3 / 2 / 8)
if (key.length > (b64len)) { key = String(key).substring(0, b64len) }
if (key.length < (b64len)) { key = String(key).padEnd(b64len, '\0') }
if (ret === undefined) { try { ret = crypto.b64dec(key) } catch (e) { log.debug('E:', e) } }
if (ret === undefined) { try { ret = crypto.hexdec(key) } catch (e) { log.debug('E:', e) } }
} else {
if (key.__proto__ === cryptojs.lib.WordArray) { ret = key }
}
}
return ret as any as WordArray
},
setDefaultKey(key: any, bit: number = context.aes.defbit) {
if (bit && bit !== context.aes.defbit) { context.aes.defbit = bit }
context.aes.defkey = this.key(key, bit)
}
},
/** RSA 모듈 / JSEncrypt 에서는 private key 를 사용해야만 암/복호화가 모두 지원된다 */
rsa: {
init: async (keyval?: string, keytype?: string) => {
if (!context?.rsa?.cryptor) {
const JSEncrypt = (await import('jsencrypt')).default
context.rsa.cryptor = new JSEncrypt()
switch (keytype) {
case C.PRIVATE_KEY: case C.UNDEFINED: { context.rsa.cryptor.setPrivateKey(keyval) } break
case C.PUBLIC_KEY: { context.rsa.cryptor.setPublicKey(keyval) } break
}
}
},
decrypt: (msg: string, key?: any) => {
if (key) { context.rsa.cryptor.setPrivateKey(key) }
return context.rsa.cryptor.decrypt(msg)
},
encrypt: (msg: string, key?: any) => {
if (key) { context.rsa.cryptor.setPrivateKey(key) }
return context.rsa.cryptor.encrypt(msg)
}
},
b64dec(key: string) {
let ret = NIL_ARR
try { ret = cryptojs.enc.Base64.parse(key) } catch (e) { log.debug('E:', e) }
return ret
},
hexdec(key: string) {
let ret = NIL_ARR
try { ret = cryptojs.enc.Hex.parse(key) } catch (e) { log.debug('E:', e) }
return ret
},
}
export default crypto
2-7-5. 공통컨텍스트 (/src/libs/app-context.ts
)
- 여러 기능들의 복합체, 소스가.... 길다....
/** APP 구동시 빈번하게 사용되는 기능들의 복합체, values 등 유틸들이 mixin 되어 있다 */
/* eslint-disable react-hooks/exhaustive-deps */
import { Function1, Function2, debounce } from 'lodash'
import $ from 'jquery'
import getConfig from 'next/config'
import { createSlice } from '@reduxjs/toolkit'
import { configureStore } from '@reduxjs/toolkit'
import React, { useRef, forwardRef } from 'react'
import { useRouter } from 'next/navigation'
import { NextRouter } from 'next/router'
import dynamic from 'next/dynamic'
import { AppProps } from 'next/app'
import { AES as cjaes, enc as cjenc } from 'crypto-js'
import * as C from '@/libs/constants'
import values from '@/libs/values'
import log, { getLogger } from '@/libs/log'
type UpdateFunction = (mode?: number) => void
type LauncherProps<V, P> = {
name?: string
mounted?: Function1<{ releaser: Function1<any, void> }, void>
unmount?: Function
updated?: Function2<number, string, void>
releaselist?: Function[]
vars?: V
props?: P
}
type SetupType<V, P> = {
uid: string
update: UpdateFunction
ready: Function
vars: V
props?: P
}
type ContextType<T> = {
[name: string]: T
}
/** 이 부분은 웹팩 플러그인(replace-loader)에 의해 자동으로 채워진다 */
const encrypted = () => '{$$ENCRYPTED$$}'
const ctx: ContextType<LauncherProps<any, any>> = { }
const { serverRuntimeConfig, publicRuntimeConfig } = getConfig()
/** 메소드 별도선언시 WEBPACK 난독화에 도움이 된다 */
const decryptAES = (v: string, k: string) => JSON.parse(cjaes.decrypt(v, k).toString(cjenc.Utf8))
/** 전역 일반객체 저장소 (non-serializable 객체) */
const appvars = {
astate: 0,
gstate: 0,
uidseq: 0,
router: {} as NextRouter,
config: {
app: { profile: '', basePath: '' },
api: { base: '', alter: '', server: '', timeout: 0 },
auth: { expiry: 0 },
security: {
key: { rsa: '' }
}
}
}
/** 전역 STORE 저장소 (serializable 객체) */
const appContextSlice = createSlice({
name: 'appContext',
initialState: {
state: 0,
mode: 0,
sendid: ''
},
reducers: {
setAppInfo: (state, { payload }) => {
if (payload?.state || 0) { state.state = payload.state }
if (payload?.mode !== C.UNDEFINED) { state.mode = payload.mode }
state.sendid = payload?.sendid || ''
}
}
})
/** 전역 STORE 선언 */
const appContextStore = configureStore({ reducer: appContextSlice.reducer })
const app = {
/** values, log, getLogger mixin */
...values, log, getLogger, useRef,
/** 앱 내 유일키 생성 */
genId() { return `${new Date().getTime()}${String((appvars.uidseq = (appvars.uidseq + 1) % 1000) + 1000).substring(1, 4)}` },
/**
* react 에서 vue 생명주기 메소드인 mounted / unmount 를 흉내낸 방법
* 다음과 같이 사용한다
* useSetup({
* async mounted() {
* {초기화 프로세스}
* },
* async unmount() {
* {종료 프로세스}
* }
* })
**/
useSetup<V, P>(prm: LauncherProps<V, P>) {
const router = useRouter()
const [uid] = React.useState(app.genId())
const [phase, setPhase] = React.useState(0)
const [, setState] = React.useState(0)
ctx[uid] = app.putAll(ctx[uid] || { name: prm?.name, vars: prm?.vars || {}, releaselist: [] }, { props: prm?.props || {} })
const self = (vars?: any, props?: any) => {
let ret = {
uid,
update: (mode: any) => setState(app.state(mode, uid)),
ready: () => !!(appvars.astate && phase),
vars: ctx[uid]?.vars || {} as V,
props: (props || ctx[uid]?.props || {}) as P,
}
if (vars) { for (const k in vars) { (ret.vars as any)[k] = vars[k] } }
return ret as SetupType<V, P>
}
app.putAll(self, self())
if (!appvars?.router) { appvars.router = router as any }
React.useEffect(() => {
let retproc: any = () => { }
let res = C.UNDEFINED
switch (phase) {
case 0: {
setPhase(1)
if (prm?.mounted) {
setTimeout(async () => {
try {
const releaser = (v: Function) => (ctx[uid]?.releaselist || []).push(v)
res = prm?.mounted && prm.mounted({ releaser })
if (res && res instanceof Promise) { res = await res }
const unsubscribe = app.subscribe(async (mode, sendid) => {
const sender = ctx[sendid] || {}
log.trace(`SUBSCRIBE : ${sender?.name || ''} → ${prm?.name || ''}`)
let res = prm?.updated ? prm.updated(mode, sendid) : {}
if (res && res instanceof Promise) { res = await res }
setState(app.state(0, uid))
})
releaser(() => {
log.trace('UNSUBSCRIBE...', ctx[uid]?.name || uid)
unsubscribe()
})
} catch (e) { log.trace('E:', e) }
setPhase(2)
}, 0)
} else {
setPhase(2)
}
} break
case 1: { } break
case 2: {
retproc = () => {
/** 종료스크립트 */
try {
if (prm?.unmount) { prm?.unmount && prm.unmount() }
} catch (e) { log.trace('E:', e) }
for (const releaser of (ctx[uid]?.releaselist || [])) {
if (releaser && releaser instanceof Function) { releaser() }
}
delete ctx[uid]
log.trace('CTX-QTY:', Object.keys(ctx).length)
}
} break }
return retproc
}, [phase])
return self
},
/**
* 페이지 이동함수, /pages 폴더 를 기준으로 한 uri 절대경로를 입력해 주면 된다
* useRouter 를 사용했으므로 경로파라메터도 인식하며, -1 입력시 뒤로가기 기능을 수행한다.
**/
goPage(uri: string | number, param?: any) {
// log.debug('GO-PAGE:', uri, appvars.router)
return new Promise<any>(async (resolve, reject) => {
let prevuri = location.pathname
const callback = async () => {
if (location.pathname !== prevuri) {
resolve(prevuri = location.pathname)
log.debug('URI-CHANGED:', location.pathname)
observer.disconnect()
window.removeEventListener('beforeunload', callback)
}
}
const observer = new MutationObserver(callback)
observer.observe(document, { subtree: true, childList: true })
window.addEventListener('beforeunload', callback)
if (typeof uri === C.STRING) {
try {
await appvars.router.push(String(uri), String(uri), param)
} catch (e) {
log.debug('E:', e)
reject(e)
}
} else if (typeof uri === C.NUMBER) {
history.go(Number(uri))
}
})
},
sleep(time: number) {
return new Promise(r => setTimeout(r, time))
},
/** react 페이지 선언 */
definePage<A, B, C extends A & B>(compo?: A, _opts?: B) {
let ret = C.UNDEFINED
let opts: any = _opts
if (compo) {
ret = compo
if (opts) {
app.putAll(ret, opts)
if (opts.nossr) {
ret = dynamic(() => Promise.resolve(compo as any), { ssr: false }) as any
}
}
}
return ret as any as C
},
/** react 컴포넌트 선언 */
defineComponent<A, B, C extends A & B>(compo?: A, _opts?: B) {
let ret: any = compo
let opts: any = app.copyExclude(_opts || {})
if (compo && compo instanceof Function) {
if (compo.length >= 2) {
if (opts?.nossr) {
ret = dynamic(() => Promise.resolve(forwardRef(compo as any)), { ssr: false }) as any
} else {
ret = forwardRef(compo as any)
}
} else {
if (opts?.nossr) {
ret = dynamic(() => Promise.resolve(compo as any), { ssr: false }) as any
}
}
if (opts) {
delete opts['nossr']
app.putAll(ret, opts)
}
}
return ret as any as C
},
/** 전역상태변수, 인자로 1이상의 값이 입력되면 subscribe 하고 있는 모든 객체에 전파된다 */
state: (mode?: number, sendid = '') => {
mode = Number(mode) || C.UPDATE_IF_NOT
const state = appvars.gstate = (appvars.gstate) % (Number.MAX_SAFE_INTEGER / 2) + (mode ? 1 : 0)
if (mode > C.UPDATE_SELF) {
app._dispatchState(state, mode > C.UPDATE_FULL ? mode : C.UNDEFINED, sendid)
}
return state
},
/** 너무 자주 수행되지 않도록 debounce 를 걸어준다 */
_dispatchState: debounce((state = 0, mode = 0, sendid= '') => {
appContextStore.dispatch(appContextSlice.actions.setAppInfo({ state, mode, sendid }))
}, 10),
/** 전역상태변수 상태를 모니터링(subscribe) 하도록 구독한다. */
subscribe(fnc: Function2<number, string, void>, delay = 0) {
const pass = () => {
const state = appContextStore.getState()
fnc(state?.mode || 0, state?.sendid)
}
const debounced: any = debounce(pass, delay)
return appContextStore.subscribe(debounced)
},
/** 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 crypto = (await import('@/libs/crypto')).default
const conf = decryptAES(encrypted(), C.CRYPTO_KEY)
app.putAll(appvars.config, conf)
log.setLevel(conf.log.level)
log.debug('CONF:', conf)
await crypto.rsa.init(app.getConfig().security.key.rsa, C.PRIVATE_KEY)
/** TODO: AES 암호화 정보 초기화 */
appvars.astate = C.APPSTATE_ENV
/** TODO: 사용자 정보 초기화 */
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()
}
}
},
/** 입력성 컴포넌트 (input 등)에서 자동으로 값을 입력하도록 수행하는 메소드 */
modelValue<V, P>(self: SetupType<V, P>) {
const props = self?.props || {} as any
const model = props?.model
const name = props?.name ? props.name.split(/[.]/)[0] : undefined
const inx = props?.name ? props.name.split(/[.]/)[1] : -1
let value = (model && name) ? model[name] : undefined
if (value && typeof value == C.OBJECT) { value = value[inx] }
const setValue = (v: any, callback?: Function) => {
if (model && name) {
if (model[name] && typeof model[name] == C.OBJECT && inx != -1) {
model[name][inx] = v
} else {
model[name] = v
}
if (callback) { callback(model, name, inx, value) }
}
return v
}
return { props: self?.props, vars: self?.vars, model, name, inx, value, setValue }
},
getParameter: (key?: string) => {
let ret: any = C.UNDEFINED
const prm: any = { }
let o: any
try {
const d1 = String(history?.state?.url || '').split(/[/]/)
const d2 = String(history?.state?.as || '').split(/[/]/)
let len = d1.length > d2.length ? d1.length : d2.length
for (let inx = 0; inx < len; inx++) {
if (/[\[]([a-zA-Z0-9_-]+)[\]]/.test(d1[inx] || '')) {
prm[d1[inx].substring(1, d1[inx].length - 1)] = d2[inx]
}
}
} catch (e) {
log.debug('E:', e)
}
if ((o = history?.state?.options)) {
for (const k of Object.keys(o)) { prm[k] = o[k] }
}
if (o = new URLSearchParams(location.search)) {
for (const k of o.keys()) { prm[k] = o.get(k) }
}
if (Object.keys(prm).length > 0) { log.debug('PRM:', prm, history) }
ret = key ? prm[key] : prm
return ret
},
async basepath(uri: string) {
await app.waitmon(() => appvars.astate === C.APPSTATE_READY)
if (uri.startsWith('/')) { uri = `${String(app.getConfig()?.app?.basePath || '').replace(/[\/]+/g, '/')}${uri}` }
return uri
},
waitmon(check: () => any, opt?: any) {
if (opt === undefined) { opt = { } }
const ctx = {
__max_check: opt.maxcheck || 100,
__interval: opt.interval || 100
}
return new Promise<any>((resolve, _reject) => {
const fnexec = function() {
/** 조건을 만족시키면 */
if (check()) {
resolve(true)
} else if (ctx.__max_check > 0) {
ctx.__max_check--
setTimeout(fnexec, ctx.__interval)
}
}
fnexec()
})
},
astate: () => appvars.astate,
publicRuntimeConfig,
serverRuntimeConfig,
getConfig: () => appvars.config,
}
export default app
export { type ContextType }
2-8. 공통 컴포넌트 및 레이아웃
2-8-1. block.tsx
(단순 블럭 컴포넌트)
/**
* 아무런 역할을 하지 않는 일반 div 블럭과 같지만
* jsx 태그 영역에서 영역구분을 위한 용도로 작성 (단순하게 태그 이름 구분을 위한 컴포넌트)
**/
import { ComponentPropsWithRef } from 'react'
import app from '@/libs/app-context'
type BlockProps = ComponentPropsWithRef<'div'> & { }
export default app.defineComponent((props: BlockProps, ref: BlockProps['ref']) => {
return ( <div ref={ ref } { ...props }> { props.children } </div> )
})
2-8-2. container.tsx
(단순 컨테이너 컴포넌트)
import _Container, { ContainerProps as _ContainerProps } from '@mui/material/Container'
import app from '@/libs/app-context'
type ContainerProps = _ContainerProps & { }
export default app.defineComponent((props: ContainerProps, ref: ContainerProps['ref']) => {
return ( <_Container ref={ ref } { ...props }> { props.children } </_Container> )
})
2-8-3. form.tsx
(단순 폼 컴포넌트)
import { ComponentPropsWithRef } from 'react'
import app from '@/libs/app-context'
type FormProps = ComponentPropsWithRef<'form'> & { }
export default app.defineComponent((props: FormProps, ref: FormProps['ref']) => {
return ( <form ref={ ref } { ...props }> { props.children } </form> )
})
2-8-4. html 태그 렌더링 컴포넌트 (/src/components/content.tsx
)
- jsx 파일에서 html 태그를 다루기 위해 작성한 컴포넌트.
/**
* react(jsx표현식) 에서 순수 html 태그를 사용할 수 없으므로 이를 해결하기 위한 컴포넌트.
* <Content html={`<span style="color:#f00">HTML</span>`} /> 처럼 html 태그를 content 속성으로 내포하거나
* <Content html={`<script> console.log('OK') </script>`} /> 와 같이 스크립트를 입력하는 용도로도 사용할 수 있다.
**/
import { ComponentPropsWithRef } from 'react'
import parse from 'html-react-parser'
import app from '@/libs/app-context'
type ContentProps = ComponentPropsWithRef<'div'> & { html?: string }
export default app.defineComponent((props: ContentProps) => {
const content = parse(String(props.children || props?.html || ''))
return (<> {content} </>)
})
2-8-5. 버튼 컴포넌트 (/src/components/button.tsx
)
import _Button, { ButtonProps as _ButtonProps } from '@mui/material/Button'
import * as C from '@/libs/constants'
import app from '@/libs/app-context'
type ButtonProps = _ButtonProps & {
href?: any
param?: any
}
export default app.defineComponent((props: ButtonProps, ref: ButtonProps['ref']) => {
const onClick = async (e: any) => {
/** 버튼이지만 href 속성이 있다면 a 태그처럼 작동한다 */
if (props.href !== C.UNDEFINED) {
e && e.preventDefault()
e && e.stopPropagation()
app.goPage(props.href, props.param)
}
if (props?.onClick) { props.onClick(e as any) }
}
return (
<_Button ref={ ref } onClick={ onClick } { ...props }>
{ props.children }
</_Button>
)
})
2-8-6. 컴포넌트 임포트용 배럴 (/src/components/index.ts
)
/** 각종 페이지에서 import 를 편하게 하기 위한 barrel 파일 */
export { default as Button } from './button'
export { default as Container } from './container'
export { default as Content } from './content'
export { default as Form } from './form'
export { Fragment } from 'react'
2-8-7. 기본 머리말 (/src/components/header.tsx
)
import app from '@/libs/app-context'
import { Container } from '@/components'
export default app.defineComponent(() => {
return (
<header>
<Container>
HEADER
</Container>
</header>
)
})
2-8-8. 기본 꼬리말 (/src/components/footer.tsx
)
import app from '@/libs/app-context'
import { Container } from '@/components'
export default app.defineComponent(() => {
return (
<footer>
<Container>
FOOTER
</Container>
</footer>
)
})
2-8-9. 기본 레이아웃 컴포넌트 (/src/components/layout.tsx
)
/** 페이지 트랜지션이 적용된 기본 레이아웃, Header 와 Footer 요소가 존재함. */
import { motion, HTMLMotionProps } from 'framer-motion'
import app from '@/libs/app-context'
import Header from './header'
import Footer from './footer'
type LayoutProps = HTMLMotionProps<'div'>
export default app.defineComponent((props: LayoutProps, ref: any) => {
const { children, ...rest } = props
return (
<>
{/* 머리말(HEADER) 영역 */}
<Header />
{/* 페이징 트랜지션, goPage 로 이동시 트랜지션이 걸린다 */}
<motion.main
ref={ ref }
initial={ { x: '5%', opacity: 0 } }
animate={ { x: 0, opacity: 1 } }
exit={ { x: '-5%', opacity: 0 } }
transition={ { duration: 0.25, ease: 'easeInOut' } }
{ ...rest }
>
{/* 페이지 내용 */}
{ children as any }
</motion.main>
{/* 꼬리말(FOOTER) 영역 */}
<Footer />
</>
)
})
2-8-10. 클라이언트 최초 진입 컴포넌트 (/src/pages/_app.jsx
)
import Head from 'next/head'
import { AnimatePresence } from 'framer-motion'
import LayoutDefault from '@/components/layout'
import app from '@/libs/app-context'
/** 전역 스타일시트 */
import '@/pages/global.scss'
const { useSetup, definePage } = app
export default definePage((props) => {
const { Component, pageProps, router } = props
useSetup({
async mounted() {
/** APP 최초구동을 수행한다 */
app.onload(props)
}
})
/** 페이지 선언시 다른 layout 속성이 발견되면 해당 레이아웃으로 전환한다 */
const applyLayout = Component.layout || ((page, router) => (
<LayoutDefault key={ router.route }> { page } </LayoutDefault>
))
return (
<>
<Head>
{/* meta head 선언은 _app 에서 선언한다, script 등 리소스성 head 선언은 _document.jsx 에서 선언 */}
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
</Head>
{/* 트랜지션감지 */}
<AnimatePresence mode='wait' initial={ false }>
{/* 실제 경로에 맞는 페이지 컴포넌트 */}
{ applyLayout(<Component key={router.asPath} {...pageProps} />, router) }
</AnimatePresence>
</>
)
})
2-8-11. 페이지 정적 요소 컴포넌트 (/src/pages/_document.jsx
)
import { Html, Head, Main, NextScript } from 'next/document'
import { Content } from '@/components'
import app from '@/libs/app-context'
export default app.definePage(() => {
return (
<Html>
<Head>
{/* 페이지 hard-loading 시 적용할 기본 transition */}
<Content html={`
<style type="text/css">
body { transition: opacity 0.4s 0.2s ease }
.hide-onload { opacity: 0; }
</style>`}
/>
</Head>
{/* hide-onload 클래스가 사라지면 트랜지션이 시작된다. */}
<body className='hide-onload'>
<Main />
<NextScript />
</body>
</Html>
)
})
2-9. 페이지 및 스타일 작성
2-9-1. '/' 페이지 (/src/pages/index.jsx
)
- 이제 본격적으로 페이지 작업이다.
/** 최초 페이지 이므로 기능없이 샘플페이지 이동용 버튼만 작성한다. */
import app from '@/libs/app-context'
import { Button } from '@/components'
export default app.definePage((props) => {
return (
<>
<div>
<h1>
INDEX PAGE
</h1>
<section>
<Button
href='/smp/smp01001s01'
>
SAMPLE
</Button>
</section>
</div>
</>
)
})
2-9-2. 전역스타일 (/src/pages/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;
}
}
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;
}
}
}
}
section.title {
display: flex;
justify-content: space-between;
align-items: center;
}
.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);
}
3. 결과
- 이번 포스트에서 만들거나 수정한 파일들은 아래와 같다.
├── env
│ ├── env-local.yml (환경설정)
│ └── replace-loader.js (소스코드 변조기)
├── src
│ ├── components
│ │ ├── block.tsx (단순 블럭 컴포넌트)
│ │ ├── button.tsx (버튼 컴포넌트)
│ │ ├── container.tsx (단순 컨테이너 컴포넌트)
│ │ ├── content.tsx (html 태그 컴포넌트)
│ │ ├── form.tsx (단순 폼 컴포넌트)
│ │ ├── footer.tsx (꼬리말)
│ │ ├── header.tsx (머리말)
│ │ ├── index.ts (컴포넌트 barrel)
│ │ └── layout.tsx (기본 레이아웃)
│ ├── libs
│ │ ├── app-context.ts (공통 컨텍스트)
│ │ ├── constants.ts (전역상수)
│ │ ├── crypto.ts (암호화 유틸)
│ │ ├── log.ts (로그 유틸)
│ │ └── values.ts (데이터 유틸)
│ └── pages
│ ├── _app.jsx (페이지 진입점)
│ ├── _document.jsx (정적요소)
│ ├── global.scss (전역 스타일)
│ └── index.jsx ('/' 페이지)
├── .eslintrc.json (소스유형및 오류제어)
├── next.config.mjs (next-js 구동)
└── tsconfig.json (타입스크립트 설정)
- 자 이제
npm run dev
수행하고
$ npm run dev
> my-next-app@0.1.0 dev
> next dev
================================================================================
샘플앱 / 프로파일 : local / 구동모드 : dev / API프록시 : http://localhost:8080
================================================================================
▲ Next.js 14.2.4
- Local: http://localhost:3000
✓ Starting...
✓ Ready in 2.3s
○ Compiling /_error ...
✓ Compiled / in 5.7s (1032 modules)
GET / 200 in 5598ms
- 브라우저 열어서
http://localhost:3000
을 쳐서 결과를 확인해 보면
.........
거 참 휑하네.... (이거 하나 보려고 지금까지 이런짓거리 했나.... 싶다...)
.........
원래는 이번편에서 입력컴포넌트와 샘플페이지 까지 나가려 했는데. 분량 압박이 심해서
이정도 까지 하고
다음번에는 컴포넌트와 화면샘플, 그리고 백엔드와 통신까지 다루어 보겠다.
결과물 git주소 : https://gitlab.ntiple.com/developers/study-202403