반응형

사내 디자인 시스템을 만들고 있었는데

배포 및 개발 환경 관련해서 여러가지 피드백 및 요청이 왔습니다..!

 

1. 배포 환경을 갖추기 어려운데 자동화 스크립트가 있으면 좋을거 같다.

2. 초심자도 쉽게 배포하고 빌드하면 좋을 것 같다.

 

특히, 각 컴포넌트를 하나의 모노 레포지토리로 관리하고 있고 내부에서 
turbo repo, rollup, jest 등 많은 개발 라이브러리를 사용하고 있는데 시행착오를 많이 겪으시는거 같아서 

boilerplate 생성 스크립트를 하나 작성했고, 블로그에도 공유해 봅니다. 

 

생성 스크립트

 

더보기
const fs = require('fs')
const path = require('path')
const readline = require('readline')
const { exec } = require('child_process')

const validatePackageName = (name) => {
  const regex = /^@TDS\/[a-zA-Z0-9-_]+$/
  if (!regex.test(name)) {
    throw new Error(`Invalid package name format. It should be in the format @TDS/package's name`)
  }

  const hasUppercase = /[A-Z]/.test(name.split('/')[1])
  if (hasUppercase) {
    console.warn('Warning: Package name should not contain uppercase letters.')
  }
}

const generatePackageJson = (packageName, sourcePath) => {
  // 무엇인지 인지한 상태에서 바꿀것
  const [namespace, name] = packageName.split('/')
  
  const packageJson = {
    name: `${namespace}/${name.toLowerCase()}`,
    version: '0.0.0',
    sideEffects: ['./index.scss'],
    license: 'MIT',
    exports: {
      '.': {
        require: sourcePath,
        import: sourcePath,
      },
      './index.scss': './index.scss',
      './package.json': './package.json',
    },
    source: sourcePath,
    main: sourcePath,
    module: sourcePath,
    types: sourcePath,
    files: ['dist/**'],
    scripts: {
      test: 'jest --passWithNoTests',
      build: 'rollup -c && tsc --emitDeclarationOnly --declarationMap false --declaration --declarationDir dist/types',
      lint: `eslint "src/**/*.ts*"`,
      clean: `rm -rf .turbo && rm -rf node_modules && rm -rf dist`,
      'manual-release': 'pnpm run build && pnpm publish --no-git-checks',
    },
    devDependencies: {
      '@types/node': '^20.12.7',
      autoprefixer: '^10.4.19',
      'babel-jest': '29.5.0',
      eslint: '^8.57.0',
      'jest-config': 'workspace:*',
      'rollup-config': 'workspace:*',
      'eslint-config-acme': 'workspace:*',
      postcss: '^8.4.38',
      sass: '^1.75.0',
      'ts-jest': '29.0.5',
      tsconfig: 'workspace:*',
    },
    dependencies: {},
    peerDependencies: {
      '@types/react': '^18.2.37',
      '@types/react-dom': '^18.2.25',
      react: '^16.8 || ^17.0 || ^18.0',
    },
    peerDependenciesMeta: {
      '@types/react': {
        optional: true,
      },
      '@types/react-dom': {
        optional: true,
      },
    },

    publishConfig: {
      access: 'restricted',
      registry: 'http://##addressissecret##/api/v4/projects/311/packages/npm/',
      exports: {
        '.': {
          import: {
            types: `./dist/types/index.d.ts`,
            default: `./dist/es/client/${path.basename(sourcePath).replace('tsx', 'mjs')}`,
          },
          require: {
            types: `./dist/types/index.d.ts`,
            default: `./dist/cjs/client/${path.basename(sourcePath).replace('tsx', 'cjs')}`,
          },
        },
        './index.css': `./dist/es/client/index.css`,
      },
      source: `./src/index.ts`,
      main: `./dist/cjs/client/${path.basename(sourcePath).replace('tsx', 'cjs')}`,
      module: `./dist/es/client/${path.basename(sourcePath).replace('tsx', 'mjs')}`,
      types: `./dist/types/index.d.ts`,
    },
  }

  return JSON.stringify(packageJson, null, 2)
}
const createSrcIndexTsx = (srcDir) => {
  const srcIndexTsxContent = `export {};`
  fs.writeFileSync(path.join(srcDir, 'index.tsx'), srcIndexTsxContent)
}

const createRootIndexTsx = (projectDir) => {
  const rootIndexTsxContent = `import './index.scss';

// export * from './src';
`
  fs.writeFileSync(path.join(projectDir, 'index.tsx'), rootIndexTsxContent)
}

const createEslintConfig = (projectDir) => {
  const rootIndexTsxContent = `module.exports = {
    root: true,
    extends: ["acme"],
  };  
`
  fs.writeFileSync(path.join(projectDir, '.eslintrc.js'), rootIndexTsxContent)
}

const createIndexScss = (projectDir) => {
  const indexScssContent = ` 
`
  fs.writeFileSync(path.join(projectDir, 'index.scss'), indexScssContent)
}

const createJestConfig = (projectDir) => {
  const jestConfigContent = `const jestConfig = require('jest-config/jest.config.js')

const customJestConfig = {
  ...jestConfig,
  // 패키지별 설정을 여기에 추가
}

module.exports = customJestConfig
`
  fs.writeFileSync(path.join(projectDir, 'jest.config.js'), jestConfigContent)
}

const createPostcssConfig = (projectDir) => {
  const postcssConfigContent = `module.exports = {
  plugins: [require('autoprefixer')()],
}
`
  fs.writeFileSync(path.join(projectDir, 'postcss.config.js'), postcssConfigContent)
}

const createRollupConfig = (projectDir) => {
  const rollupConfigContent = `import { defaultConfig } from 'rollup-config/rollup.config.mjs'

const config = defaultConfig()
export default config
`
  fs.writeFileSync(path.join(projectDir, 'rollup.config.mjs'), rollupConfigContent)
}

const createSetupTests = (projectDir) => {
  const setupTestsContent = `import '@testing-library/jest-dom'
`
  fs.writeFileSync(path.join(projectDir, 'setupTests.ts'), setupTestsContent)
}

const createTsconfig = (projectDir) => {
  const tsconfigContent = `{
  "extends": "tsconfig/react-library.json",
  "compilerOptions": {
    "baseUrl": "./",

    "paths": {}
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["dist", "build", "node_modules", "**/*.test.*"]
}
`
  fs.writeFileSync(path.join(projectDir, 'tsconfig.json'), tsconfigContent)
}

const createProjectStructure = (packageName, projectDir, sourcePath) => {
  if (!fs.existsSync(projectDir)) {
    fs.mkdirSync(projectDir, { recursive: true })
  }

  const srcDir = path.join(projectDir, 'src')
  if (!fs.existsSync(srcDir)) {
    fs.mkdirSync(srcDir)
  }

  // Write package.json
  fs.writeFileSync(path.join(projectDir, 'package.json'), generatePackageJson(packageName, sourcePath))

  // Create other files
  createSrcIndexTsx(srcDir)
  createRootIndexTsx(projectDir)
  createIndexScss(projectDir)
  createEslintConfig(projectDir)
  createJestConfig(projectDir)
  createPostcssConfig(projectDir)
  createRollupConfig(projectDir)
  createSetupTests(projectDir)
  createTsconfig(projectDir)

  console.log(`Project structure for ${path.basename(projectDir)} created successfully.`)
}

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
})

const question = (text, defaultValue = '') => {
  return new Promise((resolve) => {
    rl.question(`${text}${defaultValue ? ` (${defaultValue})` : ''}: `, (answer) => resolve(answer || defaultValue))
  })
}

const runPnpmInstall = (projectDir) => {
  return new Promise((resolve, reject) => {
    exec('pnpm install', { cwd: './' }, (error, stdout, stderr) => {
      if (error) {
        console.error(`Error running pnpm install: ${error}`)
        reject(error)
        return
      }
      console.log(stdout)
      console.error(stderr)
      resolve()
    })
  })
}

;(async () => {
  const packageName = await question('Enter the package name (ex- @TDS/button)')
  validatePackageName(packageName)

  const folderPath = path.join('./apps/', packageName.replace(/\//g, '-'))

  console.log(`folders create in ${folderPath}`)
  const sourcePath = './index.tsx'
  const projectDir = path.join(folderPath)

  console.log(`entryPoint is ${sourcePath}`)

  createProjectStructure(packageName, projectDir, sourcePath)

  rl.close()
  console.log(`package.json generated and project folder created at ${projectDir}.`)

  console.log('start installing packages....')

  await runPnpmInstall(projectDir)

  console.log('done.')
})()

 

코드 전체는 아래에서 구경하실 수 있습니다. 


https://gist.github.com/lodado/4beb14f44d304fe65772809778e0fb35

 

generatePacakges.js

GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 

사용 예시 스크린샷)

 

 

기본 사용 방법은

라이브러리의 이름을 입력하면 지정된 위치에 폴더 +  라이브러리 명으로 하나의 레포지토리 및 설정 파일이 생깁니다.

혹시 쓰실 분은 config 관련 함수에 자기가 원하는 코드를 작성하는 식으로 커스텀하면 되실 것 같습니다. 

 

반응형
반응형
{
   // 예시 코드!
  "exports": {
    ".": {
      "require": "./src/index.tsx",
      "import": "./src/index.tsx"
    },
    "./index.scss": "./src/index.scss",
  },
  "source": "src/index.tsx",
  "main": "dist/index.js",
  "files": [
    "dist/**"
  ],
  "devDependencies": {
...
  },
  "dependencies": {
....
  },
  "publishConfig": {
    "access": "restricted",
    "registry": "http://####/api/v4/projects/311/packages/npm/",
    "exports": {
      ".": {
        "require": "./dist/index.js",
        "import": "./dist/index.mjs",
        "types": "./dist/index.d.ts"
      },
      "./index.css": "./dist/index.css"
    },
    "import": "./dist/index.mjs",
    "main": "./dist/index.js",
    "module": "./dist/index.mjs",
    "types": "./dist/index.d.ts"
  }
}

 

모노레포에서 개발 경험 향상을 위해서 레포지토리의 package.json의 exports에 원본 코드 파일을(./src/index.ts) 명시하고, 배포할때는 publishConfig 옵션의 exports에 빌드 결과물 파일을 명시해서 사용하고 있다.

 

*export에 빌드 결과물(./dist/index.js)을 적으면 테스트코드를 돌리기 위해서 반드시 모든 레포지토리를 빌드해줘야해서 위 방법을 사용했음

 

RootDir 문제점..?

이랬더니 빌드하면 의존성 있는 레포지토리 내용이 번들링 결과에 포함되는 결과물이 생겼었는데..

 

dist (번들링된 결과물)
└── 기타 파일 etc...
└── packages (다른 레포지토리 결과물)
    ├── @다른 모노레포 1 
    │   ├── designFiles
    │   └── src
    │       ├── index.css
    │       └── index.js
    ├── @다른 모노레포 2
    │   └── src
    │       ├── .eslintrc.js
    │       ├── .eslintcache
    │       ├── .gitignore
    │       └── .npmrc

 

빌드 과정에서 tsconfig.jsoncompilerOptions.rootDir 설정이 다른 타입스크립트 파일을 포함하기 위해서 모노레포의 최상위 레포지토리 루트(./)로 자동 확장되고, 빌드 결과물에 다른 레포지토리의 내용이 함께 번들링되는 문제가 발생한걸로 추측됐다. 

 

이 타입 관련 문제는 typescript 자체적인 문제로 tsup, rollup등 다른 번들러를 써도 동일한 결과가 나올것이라 추측되고 실제로 tsup, rollup 동일하게 발생했다.

 

해결 방법  

해결방법은 생각보다 간단했는데.. 

 

1. peer dependencies

 

이미 다른 레포지토리를 배포했다면 해당 라이브러리를 참조해서 사용하면 된다.

그래서, peerDependencies에 다른 모노레포 레포지토리 명을 적어주면 개발할때는 export에 적어놓은 타입스크립트 원본 파일을 참조하고, 빌드할때는 peerdependencies이니까 자동적으로 다른 레포지토리의 내용이 빌드 결과물에 빠지게 된다.

 

참고로 rollup external 등 여러 옵션을 써봐도 다른 레포지토리의 빌드 결과는 자동적으로 빠지지 않았다. 번들러 입장에서 다른 레포지토리가 정말로 배포되어 있어서 내가 빌드 안해도 되는지 알 방법이 없으니 peerDependendicies로 명시해줘야한다.

 

2. 빌드할때 내 폴더의 타입은 tsc로 따로 빌드함

 

다른 사람들이 쓴 rollup 코드들을 보면 타입은 tsc 등으로 따로 빌드하던데 왜 그런가 했더니.. 이 rootDir문제 때문이 아닐까?

typescript({
          tsconfig: './tsconfig.json',
          tsconfigOverride: {},
          useTsconfigDeclarationDir,
        }),

 

나는 rollup과 rolllup-plugin-typescript2를 사용하고 있는데 useTsconfigDeclarationDir로 타입만 빼고 자바스크립트 파일만 따로 빌드하게 만들었다. 

해당 옵션을 빼면 peerDependencies를 사용해도 동일한 문제가 발생했다.

 

  "scripts": {
    "build": "rollup -c ./rollup.config.mjs",
    "build:types": "tsc --emitDeclarationOnly --declarationMap false --declaration --declarationDir dist/types/client",
  }

 

그리고 tsc로 레포지토리의 타입만 따로 추출한다.


해당 방법이 완벽한 정답이 아닐수도 있긴 하지만.. 일단 pnpm, turbo repo, typescript, rollup을 사용하는 환경에서는 잘 동작하는것 같다. 

반응형
반응형

사내에서 프로젝트 별 코드의 파편화를 줄이고 디자인 시스템을 만들려고 하고 있다.

인수인계 겸 정리한 문서를 공개해도 상관 없을꺼 같아서 블로그에 간단 정리해본다.

 

사용 툴

  • 🏎 Turborepo — 모노레포 라이브러리로 여러 패키지나 프로젝트 간의 의존성 관리를 효율화하고 Monorepo 내에서 빌드 프로세스를 간소화
  • 🚀 React — 프론트엔드 사용 라이브러리
  • 🛠 Parcel — 번들러로 설정 없이 간편하게 쓸 수 있어서 사용, 추후 rollup으로 변경될 가능성도 있음
  • 📖 Storybook — UI 컴포넌트 문서화 및 공유 & 협업용
  • TypeScript는 정적 타입 검사를 위한 도구
  • ESLint는 코드 린팅을 위한 도구
  • Prettier는 코드 포맷팅을 위한 도구
  • Changesets는 버전 관리와 변경 로그를 관리하는 도구
  •  

라이브러리를 구현할때는 radix-ui를 사용합니다.

자체 구현은 최대한 지양합니다. (개발자 나가면 유지보수가 안됨 ㅠㅠ)

 

커밋 컨벤션

build: 빌드 시스템이나 외부 의존성에 관련된 변경사항 (예: gulp, broccoli, npm의 패키지 변경 등)
chore: 기타 변경사항 (코드 수정 없이 설정을 변경하는 경우)
ci: 지속적 통합(CI) 설정과 스크립트 변경사항 (예: Travis, Circle, BrowserStack, SauceLabs의 설정 변경 등)
docs: 문서에만 영향을 미치는 변경사항
feat: 새로운 기능에 대한 커밋
fix: 버그 수정에 대한 커밋
perf: 성능을 개선하는 코드 변경사항
refactor: 버그를 수정하거나 기능을 추가하지 않는 코드 변경사항
revert: 이전 커밋을 되돌리는 작업
style: 코드 의미에 영향을 주지 않는 변경사항 (공백, 포맷팅, 누락된 세미콜론 등)
test: 테스트 추가, 리팩토링 테스트 코드, 누락된 테스트 추가 등
echo "foo: some message" # fails
echo "fix: some message" # passes

 

conventional commit을 사용합니다. 허용되지 않은 양식은 husky와 commitlint library가 막습니다.

https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional

 

모노 레포 구성

 

모노레포는 여러 개의 프로젝트나 라이브러리를 단일 저장소에서 관리하는 방식을 말합니다. 이러한 접근 방식은 큰 프로젝트나 여러 팀이 협업할 때 많은 이점을 제공합니다. 주요 이점으로는 코드의 재사용성 증가, 의존성 관리의 단순화, 통합된 버전 관리 등이 있습니다.

turbo repo를 이용한 모노 레포 구조를 가집니다. 각 폴더는 하나의 레포지토리를 구성합니다.

 

- apps
    ㄴ docs -> 문서용 storybook
    ㄴ RadixComponents -> 사용하는 공통 컴포넌트 (현재는 사용하고 있지 않음)
    ㄴ TTTable  -> 테이블 구현체

- packages
    ㄴ shared  -> 공통 변수
    ㄴ @TDS/utils -> 공통 함수
    ㄴ designFiles -> designTokens, icons들 저장소

- configs
    ㄴ eslint -> eslint 설정
    ㄴ tsconifg -> typescript 설정
    ㄴ jestconfig -> jest 설정
  • apps: 실제 사용자에게 제공되는 애플리케이션들이 위치합니다. 여기에는 문서를 위한 Storybook(docs), 공통 컴포넌트(RadixComponents), 테이블 구현체(TTTable) 등이 포함될 수 있습니다.
  • packages: 재사용 가능한 코드, 공통 함수, 디자인 토큰, 아이콘 등을 포함하는 패키지들이 위치합니다. 이러한 패키지들은 apps 내의 프로젝트들 및 배포 후 자사 프로젝트에 의해 사용될 수 있습니다.
  • configs: 프로젝트 전체에 걸쳐 공유되는 설정 파일들이 위치합니다. ESLint, TypeScript, Jest 등의 설정이 포함될 수 있습니다.

script 실행 방법

최상위 package.json에 정의된 스크립트는 모노레포 내의 여러 프로젝트나 패키지에 대한 작업을 조정합니다. 예를 들어, 다음과 같은 스크립트가 있을 수 있습니다:

  • build: 애플리케이션과 패키지를 빌드합니다.
  • test: 애플리케이션과 패키지의 테스트를 실행합니다.
  • lint: 코드 스타일과 문법 검사를 전체 프로젝트에 걸쳐 실행합니다.
  • start: 개발 모드에서 특정 애플리케이션을 실행합니다.

최상위 폴더에서 pnpm run build시 하위 레포지토리의 build 명령어가 있는 레포지토리는 병렬적으로 해당 명령어를 전부 실행합니다.

다른 자세한 기능들은 turbo repo docs를 참고하시기 바랍니다.

레포지토리 배포 프로세스

1. pnpm run changeset
2. 패치 버젼, summary 등 작성
3. pnpm run changeset version (version up)
4. commit
5. PR 및 리뷰
6. master branch에 반영
7. gitlab runner에서 자동 배포

1. pnpm run changeset

  • 이 명령은 변경 사항을 추가하기 위해 Changesets CLI를 실행합니다.

2. 패치 버전, 요약 등 작성

  • 선택한 패키지와 업데이트 유형(예: 패치)에 대한 설명을 작성합니다. 이 설명은 추후 changelog에 반영됩니다.
  • 이 과정은 변경 사항에 대한 명확한 기록을 남기며, 팀원들이나 사용자들이 어떤 변경 사항이 있었는지 쉽게 파악할 수 있도록 합니다.

3. pnpm run changeset version (버전 업)

  • 이 명령은 Changesets가 생성한 변경 사항 파일을 기반으로 패키지의 버전을 실제로 업데이트하고, 각 패키지의 changelog를 업데이트합니다.
  • 변경 사항에 대한 모든 정보는 패키지별로 관리되며, 이 단계에서 자동으로 패키지 버전이 업데이트됩니다.

4. 커밋

  • 버전 업데이트와 changelog 변경 사항을 Git 저장소에 커밋합니다. 이 커밋에는 버전이 업데이트된 패키지와 변경된 changelog 내용이 포함됩니다.

5. PR(풀 리퀘스트) 및 리뷰

  • 변경 사항을 메인 브랜치(예: master)에 병합하기 전에, 풀 리퀘스트를 생성합니다. 이는 코드 리뷰 과정을 통해 변경 사항을 검토하고 팀 내에서 합의를 이루기 위함입니다.
  • 팀원들이 변경 사항을 리뷰하고, 필요한 피드백을 제공합니다. 리뷰 과정을 통해 코드 품질을 유지하고, 오류를 사전에 방지할 수 있습니다.

6. master 브랜치에 반영

  • 리뷰를 통과한 후, 풀 리퀘스트를 메인 브랜치에 병합합니다. 이는 변경 사항이 공식적으로 프로젝트에 반영되었음을 의미합니다.

7. GitLab Runner에서 자동 배포

  • 메인 브랜치에 병합된 후, GitLab CI/CD 파이프라인이 실행됩니다. GitLab Runner는 이 파이프라인을 통해 정의된 배포 작업을 자동으로 수행합니다.
  • 배포 과정은 테스트, 빌드, 배포 단계를 포함할 수 있으며, 이 모든 과정은 자동으로 실행됩니다. 성공적으로 배포가 완료되면, 변경된 사항이 실제 환경에 적용됩니다.

** package.json에 명시한 build 결과물 및 publish config의 exports 파일만 배포하므로 꼼꼼히 확인해주세요.

private(사내망) 배포 방법

 

1. 최상위 .npmrc 에 registry를 설정합니다. (구현되어 있음)

@TDS:registry=http://#######/api/v4/projects/311/packages/npm/

위 설정의 뜻은 @TDS 라는 이름을 앞에 붙인 라이브러리를 사내에(private registry) 배포 및 사용하겠단 뜻입니다.

사용하는 프로젝트의 .npmrc에도 동일하게 작성합니다

 

2. 배포하고 싶은 라이브러리의 package.json을 설정합니다. (중요!)

{
  "name": "@TDS/radix_components",
  "version": "0.0.0",
  "sideEffects": ["./src/index.scss"],
  "license": "MIT",
  "exports": {
    ".": {
      "require": "./src/index.tsx",
      "import": "./src/index.tsx"
    },
    "./index.scss": "./src/index.scss",
    "./package.json": "./package.json"
  },
  "source": "src/index.tsx",
  "main": "dist/index.js",
  "files": [
    "dist/**"
  ],
  "scripts": {
    "test": "jest --passWithNoTests",
    "build": "tsup src/index.tsx --format esm,cjs --dts --external react",
    "parcel:build": "parcel build",
       .... 
  },
  "devDependencies": {
...
  },
  "dependencies": {
....
  },
  "resolutions": {
   ....
  },
  "publishConfig": {
    "access": "restricted",
    "registry": "http://####/api/v4/projects/311/packages/npm/",
    "exports": {
      ".": {
        "require": "./dist/index.js",
        "import": "./dist/index.mjs",
        "types": "./dist/index.d.ts"
      },
      "./package.json": "./package.json",
      "./index.css": "./dist/index.css"
    },
    "import": "./dist/index.mjs",
    "main": "./dist/index.js",
    "module": "./dist/index.mjs",
    "types": "./dist/index.d.ts"
  }
}

 

여기서 중요하게 볼 것은 exports와 publish config, private, sideEffects, name, version 입니다.

name

  • 라이브러리의 고유한 이름을 설정합니다. 여기서는 @TDS/radix_components로, @TDS는 범위(scope)를 나타내며, 같은 범위 내에서 유니크한 이름을 가지게 합니다. npm 등의 패키지 매니저를 통해 이 이름으로 라이브러리를 찾고 설치할 수 있습니다.

version

  • 라이브러리의 현재 버전을 나타냅니다. 세마틱 버저닝(semantic versioning) 원칙에 따라 major.minor.patch 형식으로 관리됩니다. 초기 상태인 0.0.0에서 시작하여, 라이브러리가 업데이트될 때마다 적절한 버전 업을 합니다.

sideEffects

  • 패키지가 부수 효과(side effects)를 가지는지 여부를 웹팩(Webpack) 같은 모듈 번들러에 알립니다. 여기서는 ./src/index.scss 파일이 부수 효과를 가진다고 명시되어 있어, 이 파일을 제외하고 트리 쉐이킹(tree shaking)을 적용할 수 있습니다.

exports

  • 패키지의 내보내기(entry points)를 정의합니다. 이는 패키지를 사용하는 소비자가 접근할 수 있는 모듈의 경로를 명시합니다. 예를 들어, require 또는 import를 통해 메인 모듈을 가져오거나, 추가적인 파일(index.scss)에 대한 접근 방법을 제공합니다.
  • 개발자 경험 향상을 위해서 모노레포 내에서는 코드를 참조합니다. 해당 방식을 사용하지 않으면 merge 및 테스트때마다 강제로 빌드를 한번씩 실행해줘야 합니다.

publishConfig

  • 패키지를 배포할 때 사용할 설정을 정의합니다. access 필드로 접근성을 설정하며, registry 필드는 패키지가 배포될 npm 레지스트리의 URL을 지정합니다. 또한, exports 섹션은 배포된 패키지의 내보내기 설정을 다시 정의하여, 사용자가 패키지의 빌드된(dist) 버전을 사용할 수 있게 합니다.
  • 개발자 경험을 향상하기 위해서 모노레포 빌드시 해당 옵션을 사용하도록 합니다.

private

  • (이 설정은 예시에 명시되어 있지 않지만 중요합니다) 이 필드가 true로 설정되면, 패키지가 비공개로 설정되어 npm 등의 공개 레지스트리에 배포되지 않습니다. 주로 개인 프로젝트나 팀 내부에서 사용할 패키지에 적용됩니다.
  • configs 레포지토리들은 private로 적용합니다.

 

gitlab의 package registry에서 현재 배포된 파일들을 확인 가능합니다

 

사내에 배포시 gitlab의 package registry에서 현재 배포된 파일들을 확인 가능합니다.

라이브러리 사용 방법

  "dependencies": {
    "@DB3/designFiles": "^0.0.0",
    "@DB3/tttable": "1.0.2",
       ...
   }

“내부망에 접속된 컴퓨터”에서 package.json에 명시해서 사용하면 됩니다.

 

CSS 관리법 - Design Variable

Figma Variable은 Figma 디자인 도구에서 사용되는 기능으로, 사용자가 디자인 내에서 텍스트나 색상과

같은 속성을 변수로 설정하여 재사용할 수 있게 해주는 기능입니다.

 

design variable에 대한 간단한 설명

 

개발의 "variable(변수)"처럼 dark mode, theme 등 여러 상황에 알맞게 값을 바꿔주는 기능
color, text 등 여러 값을 유연하게 변경 가능

반영 방법

enterprise 계정에서만 figma web api를 통한 auto import 가능 (우리 회사는 해당 없음)

그래서, figma plugin을 활용해서 design variable은 variable2CSS plugin으로 받아오고

typography 관련은 global style to code plugin으로 수동으로 받아오는 식으로 구현해야합니다.

피그마 기준으로 plugin을 통해 컬러를 뽑아내어 사용합니다.

절대로 임의의 컬러를 추가해서 사용하면 안됩니다.

해당 파일은 packages/designFiles 라이브러리 내부에 구현하고, 공유해서 사용합니다.

ex)

[data-theme="light"] {
  /* colors */
  --adaptive-blanket-layout-split: var(--adaptive-grey-opacity-grey-opacity300, #001d3a2e);
  --adaptive-blanket-modal-background: var(--adaptive-grey-opacity-grey-opacity500, #03183275);
  --adaptive-blue-blue100: #cae8ff;
  --adaptive-blue-blue1000: #004491;
  --adaptive-blue-blue1100: #003571;
  ....(생략)
  }

  [data-theme="dark"] {
  /* colors */
  --adaptive-blanket-layout-split: var(--adaptive-grey-opacity-grey-opacity300, #001d3a2e);
  --adaptive-blanket-modal-background: #13151799;
  --adaptive-blue-blue100: #00326a;
  --adaptive-blue-blue1000: #98cefd;
  --adaptive-blue-blue1100: #b3defe;
  ...(생략)
  }

darkMode, lightMode 두가지 테마를 variable을 토글되게 해서 사용하고, semantic한 네임을 사용하도록 ux연구원분들께 요청합니다.

어떻게 모드를 바꾸는지는 designFiles/setDarkMode.ts 코드를 참고합니다.

다크모드를 적용할 생각이 없어도 해당 방식으로 구현해야 편할껍니다...

*프로젝트에서 실제로 어떻게 사용할껀지는 논의가 필요함.

가장 쉬운 방법은 import ‘designFiles/index.css’ 이다.

테스트(JEST) 사용법

예시 config 파일

const { dirname, join } = require('path')
const path = require('path')

const jestConfig = require('jest-config/jest.config.js')

const customJestConfig = {
  ...jestConfig,

  testEnvironment: 'jsdom',

  moduleNameMapper: {
    ...jestConfig.moduleNameMapper,

    ...{ '@table/(.*)$': `${__dirname}/../@TDS-TTTable/src/$1` },
  },
}

module.exports = customJestConfig

jest-config 레포지토리에서 jest.config.js와 setupTests.ts를 가져옵니다.

describe('Circle class', function() {
  describe('when area is calculated', function() {
    it('it should sets the radius', function() { ... });
    it('it should sets the diameter', function() { ... });
    it('it should sets the circumference', function() { ... });
  });
});

 

테스트는 describe-it pattern 으로 가독성 있게 작성합니다.

https://woonjangahn.gitbook.io/logs/typescript/testing/jest-describe-it

내부는 when then given pattern에 맞게 작성합니다.

https://brunch.co.kr/@springboot/292

프론트엔드 특성상 스타일과 사용 라이브러리가 자주 바뀔 일이 많습니다.

스타일은 테스트 하지 말고 로직(hook)과 컴포넌트 단위로 테스트를 작성합니다.

코드 한줄 한줄을 검증하는 화이트박스가 아니라

블랙박스 테스트, 유저의 동작을 end-to-end로 검증하는 테스트 위주로 작성합니다.

유용한 명령어들

최상위 package.json 참고

  • pnpm build - Storybook 를 포함한 모든 패키지 빌드
  • pnpm dev - 모든 패키지를 로컬에서 실행하고 Storybook으로 미리보기
  • pnpm lint - 모든 패키지에 대해 lint 진행
  • pnpm changeset - changeset 생성
  • pnpm clean - 모든 node_modulesdist 폴더 정리 (각 패키지의 clean 스크립트 실행)
반응형
반응형

 

함수나 react의 props로 가끔 피치 못할 사정으로 nullable한 값을 내려보낼 때가 있다.

 

function example(a?: number, b?:number) {
	if(isNil(a) || isNil(b)) ... // null 방어 코드
	.. 
}

 

null값이면 해당 parameter를 사용 못하니 Nullish coalescing operator(??) 나 Optional chaining(?.)를 사용해서 방어 로직을 짜게 되는데,

그러다보면 nullable한 변수를 쓰는 곳마다 방어로직을 넣게 되고 다른 함수의 정보가 넘어오다보니  코드의 유연성과 안정성이 떨어지는 문제가 발생한다.

 

그래서 Null 전파를 막지는 못해도 피하는 방법에 대해 써보려고 한다.

 

실제 예시

 

지금 테이블 라이브러리를 만들고 있는데,

보통 라이브러리들에는 option을 넣어서 필요한 기능을 추가하거나 뺄 수 있다.

 

function App() {

	return <Table data={data} columns={columns} option={{ isServerSide: true }}/> // Table 예시 코드 
}

 

당연히 테이블 라이브러리를 만들때도 해당 기능을 구현하려고 했는데 option props에는 유저가 필요한 정보만 명시하고 나머지는 default 값으로 실행되야 하므로 nullable한 값이 들어올 수 밖에 없을것이다.

 

그리고 option으로 주어진 값은 테이블 내부에서 여러 곳에 전파되어 option이 지정되었다면 해당 옵션을 지원하는 로직을 실행시킬것이고, 아니라면 기본 default 로직이 실행될것이다.

 

그럼 option의 값이 nullable이니까 option이 전파된 곳마다 null check를 해야할까?

물론 그럴수도 있지만, 그럼 너무 힘들어지고 디버깅과 코드 수정이 어려워질 것이다. 

 

만약 홍수가 난다면 댐이 넘치기 전에 댐에서 조치를 취하는것이 가장 쉬운 방법일 것이다.

마찬가지로 Nullable한 값이 넘어온다면, 그 전에 Nullable한 값이 아니도록 조치를 취하는것이 가장 쉬운 방법일 것이다.

 

Nullable Object -> (전처리) -> Not Nullable Object -> 다른 모듈로 전파

 

그 해답은, props나 parameter로 넘어오는 Nullable paramter를 바로 전처리해서 Nullable하지 않는 object로 만들어 사용하면 된다.

보통 default Parameter를 주입하는 방법을 많이 쓰고, 혹은 Null object pattern(https://johngrib.github.io/wiki/pattern/null-object/)을 이용한다. 

 

Null을 넘길 수 밖에 없어서 Null을 넘긴건데 이게 뭔소리냐..? 싶을수도 있긴 하지만 default Parameter나 Null Object 같은 패턴을 이용해서 null 체크를 하는 상황 자체를 회피하는 방법이다. 

 

 

https://github.com/vercel/swr/blob/main/_internal/src/utils/resolve-args.ts#L8

 

 

테이블 라이브러리의 케이스에서는 Option이 넘어올때 중간에서 Nullable한 값이 있다면 default 값으로 overwrite하면 됐었다. 실제로 swr 라이브러리도 config로 넘긴 값을 defaultConfig과 merge해서 overwriting 하는 방법을 쓰고 있다. (17번째 줄)

 

옛날에 부스트캠프를 했을때 모든 코드에 null체크를 하는 게 안좋다는 피드백을 멘토님께 받았었는데

그 당시에는 왜 그런가 잘 몰랐었는데 오늘 날 와서 이해하게 된 것 같다..!

 

reference

 

SWR

https://github.com/vercel/swr/blob/main/_internal/src/utils/resolve-args.ts#L8

 

https://jojoldu.tistory.com/721

 

3. 좋은 함수 만들기 - Null 을 다루는 방법

여기서는 null 과 undefined 를 구분하지 않고 null 로 통일해서 표현한다. 정적 분석 서비스 rollbar 에서 1000개 이상의 JS 프로젝트에서의 소프트웨어 결함 통계를 공개했다. (출처: top-10-javascript-errors-f

jojoldu.tistory.com

 

반응형
반응형

 

전 글(https://lodado.tistory.com/83)에서 table 라이브러리를 구현해야 하는데

초기에는 headless library인 Tanstack Table을 사용하려 했으나, 대용량을 처리하지 못하는 문제로 내부 구조도 자체 구현하는 방향으로 방향을 바꿨다는 글을 썼다.

 

즉, 초기 렌더링 같은 tanstack table 자체의 성능에는 만족했지만 메모리 사용량이 너무  많아서(원본 데이터의 20배 수준) tanstack table을 사용하지 않는다는 결론을 내렸었는데

 

이 글에서는

 

1. tanstack Table의 데이터 구조

2. 메모리를 많이 사용하는 이유

 

두가지를 github를 들어가서 훑어 볼 것이다. 

 

깃허브 주소는 아래와 같다.

https://github.com/TanStack/table

 

GitHub - TanStack/table: 🤖 Headless UI for building powerful tables & datagrids for TS/JS - React-Table, Vue-Table, Solid-Ta

🤖 Headless UI for building powerful tables & datagrids for TS/JS - React-Table, Vue-Table, Solid-Table, Svelte-Table - GitHub - TanStack/table: 🤖 Headless UI for building powerful tables &...

github.com

 

사용 예시 

 

우선 tanstack table에서 제공하는 예시 코드(kitchen sink)를 보고, 내부 동작을 추론해보자.

 

 

 

위 예제 코드에서 src/App.tsx를 보면 tanstack table을 사용하는 코드 예시를 볼 수 있다.

 

export const App = () => {
  const rerender = React.useReducer(() => ({}), {})[1]

  const [data, setData] = React.useState(makeData(1000))
  const refreshData = () => setData(makeData(1000))

  const [columnVisibility, setColumnVisibility] = React.useState({})
  const [grouping, setGrouping] = React.useState<GroupingState>([])
  const [isSplit, setIsSplit] = React.useState(false)
  const [rowSelection, setRowSelection] = React.useState({})
  const [columnPinning, setColumnPinning] = React.useState({})
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
    []
  )
  const [globalFilter, setGlobalFilter] = React.useState('')

  const [autoResetPageIndex, skipAutoResetPageIndex] = useSkipper()

  const table = useReactTable({
    data,
    columns,
    defaultColumn,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getGroupedRowModel: getGroupedRowModel(),
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: getFacetedUniqueValues(),
    getFacetedMinMaxValues: getFacetedMinMaxValues(),
... 생략

 

위 코드를 보면 useReactTable이 tanstack Table을 사용하는 방식(hook)이고, 

data와 column을 내부에 넣어서 table이란 object로 한번 감싸서 export하는것을 볼 수 있다.

 

그리고 getCoreRowModel, getFilterRowModel 등이 있는데 

해당 model들이 table에서 각 기능 (sort, filter)등에 사용하는 것이라고 추론할 수 있다.

 

그래서 일단 tanstack Table의 데이터 구조 를 github에 들어가서 어떻게 각 Model들이 어떻게 동작하고 연계되는지 찾아볼것이다.

 

힌트를 주자면 SQL에서 select문으로 데이터를 조회하는 방식과 비슷하다.

 

https://github.com/TanStack/table

tanstack table 최상단 폴더로 가보자.

lerna, packages 등의 폴더가 보이는데 이 프로젝트가 모노레포로 되어 있다는 사실을 알 수 있다.

 

그럼 packages 안에 core란 폴더가 있을거 같은데 그 폴더에 우리가 찾고 싶은 내용이 있을것 같다.

 

 

https://github.com/TanStack/table/blob/main/packages/table-core/src/utils/getCoreRowModel.ts

 

core 폴더를 찾아서 들어가다보니 아까 본 getCoreRowModel.ts 이란 파일이 있다.

보통 라이브러리들은 리엑트, 스벨트 등 다른 라이브러리에 종속성을 없에기 위해서 내부 코어 로직을 js로 구현하고,

각 라이브러리에 포팅 과정을 거치는데 아래 예시 코드를 보니 이 라이브러리도 똑같은 과정을 거치는것을 확인할 수 있다.

 

import { createRow } from '../core/row'
import { Table, Row, RowModel, RowData } from '../types'
import { memo } from '../utils'

export function getCoreRowModel<TData extends RowData>(): (
  table: Table<TData>
) => () => RowModel<TData> {
  return table =>
    memo(
      () => [table.options.data],
      (
        data
      ): {
        rows: Row<TData>[]
        flatRows: Row<TData>[]
        rowsById: Record<string, Row<TData>>
      } => {
        const rowModel: RowModel<TData> = {
          rows: [],
          flatRows: [],
          rowsById: {},
        }
			.... (생략) 

          for (let i = 0; i < originalRows.length; i++) {
            // Make the row
            const row = createRow(
              table,
              table._getRowId(originalRows[i]!, i, parentRow),
              originalRows[i]!,
              i,
              depth,
              undefined,
              parentRow?.id
            )
			
            .... (생략) 

            rowModel.flatRows.push(row)
            rowModel.rowsById[row.id] = row
            rows.push(row)

          return rows
        }

        rowModel.rows = accessRows(data)
        return rowModel
      },
      {
        key: process.env.NODE_ENV === 'development' && 'getRowModel',
        debug: () => table.options.debugAll ?? table.options.debugTable,
        onChange: () => {
          table._autoResetPageIndex()
        },
      }
    )
}

https://github.com/TanStack/table/blob/main/packages/table-core/src/utils/getCoreRowModel.ts

 

해당 model는 테이블에서 모든 row를 꺼내는 로직을 구현한 함수로 보인다. 

해당 코드에서 중요하다고 생각한 점을 추려봤는데 

 

1. memo를 사용해서 메모라이징을 통해 최적화를 시도한점 (성능 향상 but 메모리 사용)
2. flawRow를 사용해서 차원 depth가 있는 배열을 1차원 배열로 처리해 저장한 점 (성능 향상 but 메모리 사용)

3. rowById로 row의 id를 key로 해서 BigO(1)에 찾는 함수를 JSON 형태로 구현한 점 (성능 향상 but 메모리 사용)

 

음.. 위 코드를 보면 일단 성능을 위해 메모리를 희생한 코드로 보이고 해당 부분이 대용량 데이터 처리에서 약점이 될 것으로 추측될것 같다.

그래도 위 코드에서 늘어나는 메모리가 고작 3~4배일꺼 같은데..? 일단 메모리 부분은 추후에 보기로 하고 넘어가자

 

4. 독립적인 기능 (모든 row만을 꺼내는 로직을 구현한 함수)을 담당하는 하나의 함수

 

/packages/table-core/src/utils 에서 다른 model들을 살펴보면 각 model 함수들이 filter, sorting, pagination등 

독립된 로직을 동작하는 함수임을 알 수 있다. 

 

그럼 어디서 해당 함수들을 연결해줄까?

 

타고 타고 들어가다보면... table( table-core/src/core/table.ts#L125 )이란 파일에서 model끼리 연결해주는 부분을 찾을수가 있다.

 

const features = [
  Headers,
  Visibility,
  Ordering,
  Pinning,
  Filters,
  Sorting,
  Grouping,
  Expanding,
  Pagination,
  RowSelection,
  ColumnSizing,
] as const  // 중요 !!! 아까 본 model들이 converting되어 내부에 들어 있는 object임

... 생략

export function createTable<TData extends RowData>(
  options: TableOptionsResolved<TData>
): Table<TData> {
  ... 생략
  const coreInitialState: CoreTableState = {}

  let initialState = {
    ...coreInitialState,
    ...(options.initialState ?? {}),
  } as TableState

  table._features.forEach(feature => {
    initialState = feature.getInitialState?.(initialState) ?? initialState
  })

  const queued: (() => void)[] = []
  let queuedTimeout = false

  const coreInstance: CoreInstance<TData> = {
    _features: features,
    options: {
      ...defaultOptions,
      ...options,
    },
    initialState,
    _queue: cb => {
      queued.push(cb)

      if (!queuedTimeout) {
        queuedTimeout = true

        // Schedule a microtask to run the queued callbacks after
        // the current call stack (render, etc) has finished.
        Promise.resolve()
          .then(() => {
            while (queued.length) {
              queued.shift()!()
            }
            queuedTimeout = false
          })
          .catch(error =>
            setTimeout(() => {
              throw error
            })
          )
      }
    },
 
   ... 생략 
   Object.assign(table, coreInstance)

  for (let index = 0; index < table._features.length; index++) {
    const feature = table._features[index]
    feature?.createTable?.(table)
  }

  return table
}

https://github.com/TanStack/table/blob/main/packages/table-core/src/core/table.ts#L125

 

보고 싶은 부분만 추려봤다

 

  table._features.forEach(feature => {
    initialState = feature.getInitialState?.(initialState) ?? initialState
  })

위 코드가 model들이 연계되는 부분인데 함수형 프로그래밍의 pipe를 구현한 형태로 볼 수 있다

즉, input을 넣고 각 model들의 out을 바로 다음 model의 input으로 넣고... pipe 형태로 모델들을 연계한 것으로 볼 수 있다.

이 부분은 SQL의 select 문의 동작 방식과 유사하다.

 

query의 동작 순서

 

tanstack table의 핵심 데이터 구조는 생각보다 간단한 형태로 되어있다.

실제로 사내에서 만든 테이블 라이브러리의 내부 데이터 구조는 SQL의 select문을 참고하여 1~2주만에 구현했고,

tanstack table 와 유사한 성능을 낼 수 있었다.

 

 

그럼 tanstack table이 메모리를 과다 사용하는 이유는 무엇일까?

복합적인 요인이라서 꼭 찝어 말할 수는 없지만, 아까 보았듯이 row에 getRowById같은 편의 기능을 넣어놓았는데 만약 50~100만 row가 되면 row에 있는 편의기능 함수가 row 갯수만큼 추가되서 메모리를 과다 사용되는것으로 추측된다.

 

실제로 fork해서 50만개 row에 있는 "편의기능 함수" 만을 지워보는 실험을 했더니  1.6GB 정도에서 몇백MB를 감소 시킬 수 있었던 기억이 난다. 

 

사내에서 라이브러리를 구현했을때는 row를 인자로 받아서 실행하는 함수를 독립적으로 빼서 구현해서

tanstack table에 비해 메모리 사용량을 획기적으로 줄였다.

 

사실 tanstack table은 대용량 데이터 처리는 server pagination으로 구현한다고 생각해서

메모리 부분은 생각 안했을꺼라 추론 되지만..?

 

SQL Editor같은 제품을 브라우저에 구현하는 경우에는 브라우저에 수십~수백만 데이터가 있는 경우가 있고, 

해당 케이스를 서버 페이지네이션으로만 구현하려면 오히려 번거로워 SQL select문을 벤치마킹해서

테이블에서 1000만 이하의 대용량 데이터를 문제 없이 핸들링 할 수 있도록 내부 데이터 구조를 구현했다.

 

끟. 

반응형
반응형

react datatable (https://github.com/gregnb/mui-datatables)

사내에서 react-datatable를 브라우저 테이블으로 사용하고 있는데

렌더링 성능 이슈로 40~50만 건의 row 데이터 처리가 불가능해서 아예 테이블을 자체 구현하라는 지시가 떨어졌다.

이 글은 고분군투하면서 그 과정을 기록한 눈물겨운 글이다..

 

요구 스펙

 

1. 최소 column 10개, 100만 row를 브라우저에 렌더링 가능할 것 

*맨 처음 렌더링 성능 이슈를 해결하기 위해서 서버 페이지네이션을 생각하고 있었는데

이 요구사항으로 인해 최소 100만개는 클라이언트에 렌더링 되도록 구현해야 했다.

 

+ 가상화 라이브러리 (react-virtualized등) 적용

 

2. 현존하는 테이블 라이브러리보다 성능이 좋거나 대등할것 (!?)

특히, 1번 케이스에서 다른 테이블 라이브러리처럼 초기 렌더링이 3~5초 이내여야 한다.

 

3. 기존 테이블 라이브러리의 기능을 모두 갖출 것 (sorting, searching, filtering, pagination, inifinity scroll 등)

 

처음엔 tanstack table headless 라이브러리를 사용하려고 했는데 

메모리 사용량이 1번 스펙을 충족하지 못해서 할 수 없이 데이터구조도 자체구현 하는쪽으로 하기로 결정했다.

 

(tanstack table은 메모라이징 및 각종 지표를 제공하면서 메모리를 과다 사용해서

13개 column, 50만 row가 주어질때 다른 테이블이 200MB를 쓴다면,

tanstack table은 1.6 GB를 사용해서 대용량 처리엔 알맞지 않다는 결론을 내림)

 

초기 데이터 구조

sql select문 순서!

DB의 Select문에서 영감을 받아서 설계했는데 

원본 데이터 → ( execution ) → 현재 필요한 데이터만 UI에 전달하는 식으로 구현했다.

초기 데이터 구조 예시

UI 데이터의 관계를 분리했다.

 

원본 데이터 상태(originalData)가 있고,

sortingState, filteringState, paginationState 등등 여러 조건들을 상태로 observing하여

상태가 바뀔때마다 필터링해서 현재 UI에 필요한 데이터만 전달한다. 

 

즉, 각 sorting, filtering 상태는 Finite State Machine처럼 어떤 특정 상태를 가지고 있고, 그 상태에 맞추어서

originalData를 필터링해 최종적으로 UI에서 현재 원하는 데이터만 필터링해서 볼 수 있다.

 

더보기

* 초기 데이터 구조라고 써놨는데 

sorting, search같이 반드시 모든 데이터를 한번 순회 해야하는 경우도 있을 것이고

화면에 현재 보이는 row들에게만 적용해야하는 경우도 있을 것이다.

 

그래서 실 코드 구현 예시는

가상화 라이브러리를 이용해서 lazy하게 적용되는 파트와

위 예시처럼 한번 모든 row를 순회하며 적용하는 2가지 케이스로 나뉘어져 있다.

 

 

코드 예시

위 설계 부분을 그대로 코드로 구현한 것이다.

 

예시 코드이고 실제 구현된 코드와는 다름

*flow는 함수형 프로그래밍의 pipe 함수(관련 링크)이며, lodash에 구현되어 있다.

 

예시 코드에 있는 id를 +1 하는 의미없는 함수들은 성능을 측정하기 위해 집어넣은 sorting, filtering, pagination등의 mocking 구현체를 뜻한다. 

 

테이블 UI 예시

 

Body

블로그에 올리기 위한 예시 코드이고 실제 구현된 코드와는 다름

필터링된 최종 결과물을 화면에 렌더링한다.

sorting 되었다면 visibleRowData는 오름차순으로 정렬되어 제공될 것이다.

특정 열이 숨김 처리되었다면 visibleRowData는 특정 행들이 제거되어 제공될 것이다.

 

UI 입장에서는 데이터를 가공하는 중간 과정을 알 필요가 없이 자신이 요청한 데이터를 그대로 쓸 수 있다.

 

코드 예시에는 가상화 라이브러리가 적용되어 있지 않는데, 

120만개의 row가 테이블에 제공된다면 row 모두를 dom에 그릴 필요는 없을 것이니

보이는 부분만 렌더링 시키는 가상화 라이브러리를 적용하면 된다. 

 

테이블 header 예시 

블로그에 올리기 위한 예시 코드이고 실제 구현된 코드와는 다름

handleSort로 sortingState 상태를 변경한다. 

그럼 rowData들이 sorting되어 body에 보여지게 된다.

 

구현 결과

현존하는 tanstack table, tui-grid, mui datatable등과 비교해봤는데 직접적인 수치를 제공할 수는 없지만

초기 요구사항을 모두 만족했고 대등하거나 더 나은 성능을 보여줬다.

 

특히, 메모리 부분에 신경써서 10개의 column, 1000만 row까지는 무리없이 몇초내에 렌더링하는 성능을 보여줬다.

다만 1000만개정도 무지막지한 데이터양이라면 row를 브라우저에 가져오지 않고 서버 페이지네이션을 구현하는 절충안으로 나아갈 것이다..

 

반응형
반응형

개발을 진행하면서 가끔 Icon 추가나 변경이 이루어질때가 있는데

그럴때마다 아래 과정을 거쳤다.

1. 디자이너가 피그마에서 업데이트한 아이콘을 내려받은 후, 이를 압축합니다.
2. 사내 메신저에 업로드하고, 엔지니어에게 공유합니다.
3. 엔지니어는 이를 내려받아 압축을 풀고, 소스 코드에 적절히 추가합니다.
4. PR을 올려 코드 리뷰 후 머지합니다.
// (https://channel.io/ko/blog/figma-icon-plugin) 에서 발췌

 

그래서 피그마에서 아이콘 업데이트가 이뤄진다면 코드에서 바로 다운받을 수는 없을까? 생각했고,

찾아본 결과 피그마 plugin을 발견하여 직접 써본 후 사용 방식을 공유해본다.

 

사용방법

 

1. 피그마 access token 추가

 

 

figma setting > personal access token에서 토큰을 생성한다. 

피그마 web api를 사용하려면 access token을 같이 보내줘야 한다.

 

2. node-fetch 설치

pnpm add -D node-fetch //pnpm, yarn, npm 등 사용하는걸로 설치

https://www.npmjs.com/package/node-fetch

 

node-fetch 라이브러리를 devDependency에 추가한다.

프론트 환경이든 백엔드 환경이든 dev 환경에서만 돌릴꺼이므로(node를 사용하므로) 크게 상관은 없다.

 

설치 후 

   "icon": "node ./src/icons/generator.mjs"

 

packages.json에서 아래 3번의 파일을 실행하면 피그마 icon들이 자동생성되게 구현할 것이다.

 

3. Icon들을 다운받는 generator.mjs 파일 생성

 

여담으로  나는 import를 쓰고 싶은데 ESM 환경이 아니라서 mjs로 명시했는데

뭐 굳이 esm을 사용하고 싶지 않으면 .mjs 확장자를 쓰지 않아도 되긴 한다. 

 

코드는 https://medium.com/iadvize-engineering/using-figma-api-to-extract-illustrations-and-icons-34e0c7c230fa 에서

약간의 수정을 거친 코드이다.

 

전체 코드 보기

더보기
/*
 reference:
  https://medium.com/iadvize-engineering/using-figma-api-to-extract-illustrations-and-icons-34e0c7c230fa
*/


import dotenv from 'dotenv'
import { appendFileSync, writeFileSync } from 'fs'
import fetch from 'node-fetch'


dotenv.config()


const TOKEN = process.env.FIGMA_WEBHOOK
const FILE_KEY = 'v2LAIwRuECBSb24aIFDKwB'


const fetchFigmaFile = (key) => {
  return fetch(`https://api.figma.com/v1/files/${key}`, { headers: { 'X-Figma-Token': TOKEN } }).then((response) =>
    response.json(),
  )
}


const flatten = (acc, cur) => [...acc, ...cur]


const getComponentsFromNode = (node) => {
  if (node.type === 'COMPONENT') {
    return [node]
  }
  if ('children' in node) {
    return node.children.map(getComponentsFromNode).reduce(flatten, [])
  }
  return []
}


const formatIconsSVG = (svg) => svg.replace(/fill="(?:#[a-fA-F0-9]{6}|none)"/gm, 'fill="currentColor"')


const formatName = (name) => name?.toUpperCase().replace(/-/g, '_') // replaces '/' by '_'


const hash = (path) => path.replace(/^.*\/img\//g, '').replace(/\//g, '_')


const generateFiles = (ele) => {
  if (!ele) return ''


  const { name, fileName, svg } = ele
  const component = `
  import * as React from "react";


  const ${name} = (props: React.SVGProps<SVGSVGElement>) => {
    return (${svg.replace(/<svg /, '<svg {...props} ')});
  }


  export default ${name};
  `


  writeFileSync(`./src/icons/${name}.tsx`, component)
  return `${name}`
}


const getSVGsFromComponents = (components) => {
  const key = FILE_KEY
  const filteredComponent = components.filter(({ name }) => name?.toUpperCase().startsWith('ICON'))
  const ids = filteredComponent.map(({ id }) => id)


  return fetch(`https://api.figma.com/v1/images/${key}?ids=${ids.join()}&format=svg`, {
    headers: { 'X-Figma-Token': TOKEN },
  })
    .then((response) => response.json())
    .then(({ images }) =>
      Promise.all(
        filteredComponent.map(
          ({ id, name, type }) =>
            images[id] &&
            fetch(images[id])
              .then((response) => response.text())
              .then((svg) => ({
                name: formatName(name),
                fileName: hash(images[id]),
                svg: formatIconsSVG(svg),
              })),
        ),
      ),
    )
}


async function run() {
  if (!TOKEN) {
    console.error(
      'The Figma API token is not defined, you need to set an environment variable `FIGMA_API_TOKEN` to run the script',
    )
    return
  }
  fetchFigmaFile(FILE_KEY)
    .then((data) => getComponentsFromNode(data.document))
    .then(getSVGsFromComponents)
    .then((dataArray) => dataArray.map(generateFiles))
    .then((ele) => Array.from(new Set(ele)))
    .then((texts) => {
      writeFileSync(
        './src/icons/index.ts',
        texts.reduce((t, v) => `${t}\n import ${v} from './${v}'`, ''),
      )


      appendFileSync('./src/icons/index.ts', texts.reduce((t, v) => `${t} ${v},`, '\n\n export {').slice(0, -1))


      appendFileSync('./src/icons/index.ts', '}')
    })
}


run()

 

혹시 copy가 필요한 경우는 아래 gist에서 전체 코드를 볼 수 있다.

https://gist.github.com/lodado/24da180db5042ff1bb5b20b1527d5e33

 

icon 자동 설치

icon 자동 설치. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 

전체 코드는 아래와 같은데

async function run() {
  if (!TOKEN) {
    console.error(
      'The Figma API token is not defined, you need to set an environment variable `FIGMA_API_TOKEN` to run the script',
    )
    return
  }
  fetchFigmaFile(FILE_KEY)
    .then((data) => getComponentsFromNode(data.document))
    .then(getSVGsFromComponents)
    .then((dataArray) => dataArray.map(generateFiles))
    .then((ele) => Array.from(new Set(ele)))
    .then((texts) => {
      writeFileSync(
        './src/icons/index.ts',
        texts.reduce((t, v) => `${t}\n import ${v} from './${v}'`, ''),
      )

      appendFileSync('./src/icons/index.ts', texts.reduce((t, v) => `${t} ${v},`, '\n\n export {').slice(0, -1))

      appendFileSync('./src/icons/index.ts', '}')
    })
}

run()

 

진행 과정을 한줄 한줄 차근차근 보자.

 

 

1. 피그마 api 접근

import dotenv from 'dotenv'
import { appendFileSync, writeFileSync } from 'fs'
import fetch from 'node-fetch'

dotenv.config()

const TOKEN = process.env.FIGMA_WEBHOOK
const FILE_KEY = 'v2LAIwRuECBSb24aIFDKwB'

const fetchFigmaFile = (key) => {
  return fetch(`https://api.figma.com/v1/files/${key}`, { headers: { 'X-Figma-Token': TOKEN } }).then((response) =>
    response.json(),
  )
}

 

나는 dotenv를 써서 아까 받은 피그마 access token을 .env에 저장하고, api 요청을 할때 token을 담아 보내게 작성했다.

FILE_KEY는 피그마 프로젝트에 들어가서 내가 받아오고 싶은 프로젝트의 file key를 쓰면 된다. 

 

figma.com/file/${file_key}&nbsp; <- 이부분!

 

2. 받아온 api 전처리

const flatten = (acc, cur) => [...acc, ...cur]

const getComponentsFromNode = (node) => {
  if (node.type === 'COMPONENT') {
    return [node]
  }
  if ('children' in node) {
    return node.children.map(getComponentsFromNode).reduce(flatten, [])
  }
  return []
}

피그마엔 Component와 frame?등 컴포넌트를 분류하는 기준이 있는데,

개발과는 크게 상관이 없는 부분이니 사용하기 쉽게 모두 전처리해서 다음 부분으로 넘긴다. 

 

3. SVG 파일(Icon들만) 받아오기

const formatIconsSVG = (svg) => svg.replace(/fill="(?:#[a-fA-F0-9]{6}|none)"/gm, 'fill="currentColor"')

const formatName = (name) => name?.toUpperCase().replace(/-/g, '_') // replaces '/' by '_'

const hash = (path) => path.replace(/^.*\/img\//g, '').replace(/\//g, '_')

const getSVGsFromComponents = (components) => {
  const key = FILE_KEY
  const filteredComponent = components.filter(({ name }) => name?.toUpperCase().startsWith('ICON'))
  const ids = filteredComponent.map(({ id }) => id)

  return fetch(`https://api.figma.com/v1/images/${key}?ids=${ids.join()}&format=svg`, {
    headers: { 'X-Figma-Token': TOKEN },
  })
    .then((response) => response.json())
    .then(({ images }) =>
      Promise.all(
        filteredComponent.map(
          ({ id, name, type }) =>
            images[id] &&
            fetch(images[id])
              .then((response) => response.text())
              .then((svg) => ({
                name: formatName(name),
                fileName: hash(images[id]),
                svg: formatIconsSVG(svg),
              })),
        ),
      ),
    )
}

 

피그마 프로젝트의 img 중에서 format이 svg인 이미지들만 전부 불러온다.

(당연히 필요한 icon이 다른 포멧이라면 해당 포멧도 추가해야한다.)

 

이때 모든 svg를 불러오게 되는데, Icon만 선별하기 위해서 디자이너 분에게 Icon은 모두 이름앞에 Icon prefix를 붙여달라고 요청했다.

아마 피그마에서 분류하는 다른 좋은방법이 있을거 같은데.. 

일정이 급해서 시간상 해당 방법으로 구현해달라고 요청했다.

 

여담으로 피그마에서 component 지정을 하지 않으면 위 api에서 나타나지 않는듯 싶다.

 

 

4. React Component로 변환

const generateFiles = (ele) => {
  if (!ele) return ''

  const { name, fileName, svg } = ele
  const component = `
  import * as React from "react";

  const ${name} = (props: React.SVGProps<SVGSVGElement>) => {
    return (${svg.replace(/<svg /, '<svg {...props} ')});
  }

  export default ${name};
  `

  writeFileSync(`./src/icons/${name}.tsx`, component)
  return `${name}`
}

그냥 ${name}.svg로 저장하는 방법도 있긴 한데 어차피 react component로 mapping 하는 과정이 필요해서 

아예 react component로 아이콘을 저장하게 생성한다. 

 

생성된 아이콘들 예시

5. 자동 export 

 

// run function의 마지막 부분)

.then((texts) => {
      writeFileSync(
        './src/icons/index.ts',
        texts.reduce((t, v) => `${t}\n import ${v} from './${v}'`, ''),
      )

      appendFileSync('./src/icons/index.ts', texts.reduce((t, v) => `${t} ${v},`, '\n\n export {').slice(0, -1))

      appendFileSync('./src/icons/index.ts', '}')
 })

 

import & export 하기 쉽도록 index.tsx에 import 후 export하는 파일을 작성한다.

자동생성 index.tsx 파일

해당 과정을 거치면 위와 같은 파일이 생성된다.

 

6. 끝!

 

혹시 storybook에서 보고 싶다면 추가 icon도 자동으로 import하게 

import * as Icons from 'icon 폴더 주소' 로 구현한다.

 

import type { Meta, StoryObj } from '@storybook/react'
import { FooterNav } from 'myll-ui'
import Image from 'next/image'
import * as Icons from 'shared'

// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
const meta: Meta<typeof FooterNav> = {
  title: 'Example/Icon',
  argTypes: {},
}

export default meta

const ICONS = Object.entries(Icons)
  .filter(([key, value]) => key.startsWith('ICON'))
  .map(([key, value]) => {
    return { key, IconComponent: value }
  })

export const IconExamples = () => {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
      색깔은 fill, color를 통해 맞추세요
      {ICONS.map(({ key, IconComponent }) => {
        return (
          <div style={{ display: 'flex', flexDirection: 'row', width: '100%' }}>
            <div style={{ width: '280px' }}>{key} :</div> <IconComponent />
          </div>
        )
      })}
    </div>
  )
}

 

storybook 예시

 

위 icon들은 storybook이 연동된 사이드 프로젝트의 github-pages에서 볼 수 있다.

https://myll-github.github.io/myll-frontend/?path=/story/example-icon--icon-examples 

 

@storybook/cli - Storybook

 

myll-github.github.io

 

자동 업데이트를 통해서 끔찍한 파일 다운 & 저장 노가다에서 해방된 듯 싶다..?

webhook을 통하여 아이콘 업데이트마다 자동 PR을 날리거나 다른 설정도 더 해줄 수 있는것 같지만

토의 결과 아이콘이 한번 업데이트 된 이후에는 수정이 없을것으로 생각되어 시도하진 않았다. 

 

reference 

 

https://channel.io/ko/blog/figma-icon-plugin

 

피그마 플러그인으로 아이콘 업데이트 자동화하기

안녕하세요 👋, 채널톡 웹팀의 에드입니다. 채널톡엔 베지어(Bezier)라는 디자인 시스템이 있습니다. 저희 웹팀에서는 이 디자인 시스템의 React 구현체인 bezier-react 라는 오픈소스 라이브러리를

channel.io

위글에 영감을 받아서 제작했는데 webhook을 통한 자동 PR까진 만들지는 않았다.

 

https://medium.com/iadvize-engineering/using-figma-api-to-extract-illustrations-and-icons-34e0c7c230fa

 

반응형
반응형

최근 취직한 직장에서 실시간 모니터링 툴을 만들고 있다. 

결론부터 말하자면 월부터 3월까지 일하면서

(실시간 데이터 업데이트시) 렌더링 시간을 700~600ms에서 390~290ms로 대략 100% 개선하였다.

 

이때 사용한 기법을 간단하게 적어보고자 한다.

 

비슷한 모니터링 툴 예시 - grafana (https://grafana.com/)

실제 제품과 코드를 보여줄순 없고 비슷한 제품으로는 그라파나(https://grafana.com/) 라는 제품이 있는데 거의 80%쯤 비슷해서..? 이 제품을 보여주고 예시로 설명하고자 한다.

react-grid-layout(https://github.com/react-grid-layout/react-grid-layout)이라고 레이아웃 배치때 사용한 라이브러리도 똑같다.

 

배경 지식

 

어떻게 개선했나 리펙토링을 설명하기에 앞서

어떤 구조로 되어있나를 간략하게 설명하고자 한다.

 

화면에는 두가지 api 정보가 있다.

 

1. layout 정보 (빨간색) : 전체 레이아웃 정보와 레이아웃과 관련된 차트 정보를 담고 있다.

레이아웃 좌표(x, y축), 차트 종류(area, grid, bar 등) 차트를 각각 '어디'에 배치할지 정보를 담고 있다.

 

2. chart 정보(파란색) : 차트 내부의 실제 차트 정보를 api를 통해 불러온다.

 

프로젝트는 CRA로 구현되었는데 해당 화면을 들어가게 되면 1차적으로 layout정보를 api를 통해 불러오고,

레이아웃을 불러오면 2차적으로 각각 차트 데이터를 api를 통해 불러오게 되는 구조다.

 

문제 원인

렌더링에 걸리는 시간 - 대략 600~700ms

react profiler를 돌려본결과 느린 이유를 크게 아래의 3가지로 압축가능한듯하다.

설명하고 추후 해결방안을 제시할 예정

 

1. presentational and container pattern 이용

 

전체 페이지가 presentational and container pattern으로 구현되어 있고 로직이 최상위에 몰려있었다.

 

최상층에서 layout 정보를 받아 상태로 가지고 있고 (mobx + useState 혼합) 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 자동적으로 전부 리렌더링되는데 하나만 바뀌어도 화면 전체 리렌더링을 유발했다. 

전임자가 Memo등을 사용해서 최적화를 시도한듯한데 신통치는 않았던듯..

 

끔찍한 가독성은 덤이고 이 구조는 재앙(?)을 불러오게 된다. 2번에서 후술

 

2. derived State 사용

class EmailInput extends Component {
  state = { email: this.props.email };

  render() {
    return <input onChange={this.handleChange} value={this.state.email} />;
  }

  handleChange = event => {
    this.setState({ email: event.target.value });
  };

  componentWillReceiveProps(nextProps) {
    // This will erase any local state updates!
    // Do not do this.
    this.setState({ email: nextProps.email });
  }
}

 

코드 예시 - 리엑트 공식 링크 참고 (https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html)

 

derivedState란 말 그대로 상태로부터 파생되는 State이다.

위 코드 예시처럼 prop으로 어떤 상태를 내려받아 전처리나 필요한 데이터만 따로 빼내어 상태로 관리하는 형태이다.

 

기존 코드에서는 presentational-container 패턴으로 layout api 정보를 최상층에서 prop으로 주입하고,

차트 내부에서 derived state를 사용해서 chart api 데이터와 혼합해 관리했다.

 

아까 캡처 사진을 보면서 다시 이해해보자.

layout api state(빨간색)에는 모든 레이아웃의 정보가 담겨 있다.

chart 레이아웃 정보는 array 형식으로 담겨 있는 형태였는데

chart 내부에서 layout의 정보를 사용하려고 chart api state(파란색)과 혼합해 전처리하고

derivedState를 만들게 된다.

 

위 렌더링 profiler를 보면 Run MicroTask란 작업이 엄청 많은것을 볼 수 있는데

어떤 상태가 바뀌면 derivedState도 재생생하면서 rerendering을 무수히 많이 발생시켜 렌더링 속도가 매우 느려지게 된다.

예를 들어, 파란색 chart를 오른쪽으로 약간 이동시키기만 해도 최상층부터 맨 아래까지 수많은 렌더링이 발생하는것...

 

처음엔 Run MicroTasks라길래 네트워크 중복 호출인줄 알았는데 아니였고

수많은 useEffect와 derivedState 문제인것으로 추측된다.

 

3. api 호출때 Batching 처리 X 

 

각 레이아웃에서 chartData를 api로 호출할때 한번에 묶어 호출하는게 아니라

차트 하나하나가 api콜을 해서 렌더링시키게 된다.

 

30개의 차트면 30개의 api 콜을 하는 방식인데

한두개가 네트워크 지연으로 느리게 오게 되면 다시 리렌더링을 발생시키게 된다.

 

해결 방안

리펙토링에서는 위 3가지 문제점을 제거하는 방안으로 일단 리펙토링을 진행했다.

600ms -> 300ms로 100% 빨라진 모습

 

그래서 이후 점진적인 리펙토링이 필요함에도 일단 상당히 빨라진것을 볼 수 있다.

 

1. presentational and container pattern 이용 -> Hook + 전역 State 방식으로 변경 

 

전역 state를 사용하면 변경된 상태들과 관련된 react component들만 리렌더링 시킬 수 있다.

1번은 사실 pattern 문제라기보다는 가독성 + 2번 문제 해결을 위해 리펙토링했다.

 

2. derived State 제거

 

정확히는 불필요한 rerendering을 줄이기 위해서 UseEffect를 제거했고,

UseEffect가 필요한 derived state를 제거했다. 

 

derived State를 사용한 이유를 보니

복잡한 3~4 depth의 api를 전처리해 chart 내부에서 사용하려는 이유도 크길래 

 

'전처리'해야하는 상태를 normalizr로 따로 분리하여 1 depth로 전처리후 전역 state로 관리하고

chart에 각각 unique한 id를 발급하여 전역 state를 id로 접근할 수 있게 리펙토링했다.

 

setState({
    locateX : props.layoutData.properties.x;
    locateY : props.layoutData.properties.y;
    threshold: props.layoutData.properties.y;
    chartData : chartData.data.chartData;
    chartAxisX : chartData.data.axisX,
    chartAxisY : chartData.data.axisY,
	....
})

이렇게 사용하던 코드를 (실제 코드와는 다른 예시 코드임)

const {locateX, locateY, threshold, chartData, chartAxis, chartAxisY } = useLayoutData(props.chartId);

normalizr를 사용해 위와 같이 리펙토링했다.

 

3. Batching처리 로직 추가 

 

const promise1 = Promise.resolve(3);
const promise2 = new Promise((resolve, reject) => setTimeout(reject, 100, 'foo'));
const promises = [promise1, promise2];

Promise.allSettled(promises).
  then((results) => results.forEach((result) => console.log(result.status)));

// Expected output:
// "fulfilled"
// "rejected"

promise.allSettled()를 사용하면 위와 같이 여러개의 api콜이 다 올때까지 await를 걸 수 있고, 에러처리도 해준다.

 

각 차트에서 api 콜을 하는게 아니라, 배칭처리를 통해 모든 api콜이 올때까지 기다린후

한번에 chart들을 업데이트해주는 방식으로 변경했다.

 

해당 방식만을 사용하면 chart가 하나 추가되었을때 다시 모든 api 콜을 하는 단점이 있었는데

이것은 requestTime을 기록해서

 

현재시간-refreshTime >= refresh로 

 

필요한 데이터만 불러오도록 예외처리시켰다. 

 

다음 분기에는 모든 api를 하나로 통합하고 마치 GraphQL처럼(혹은 graphQL로)

프론트에서 질의문을 보내 필요한 데이터만 받아오는 형식으로 변경하지 싶은데 일단은 배칭처리로 묶는거까지 마무리했다.

 

여담으로 이 페이지를 만든 전임자가 내가 입사하기 전에 나간 상태여서 

걍 맨땅에 헤딩하는식으로 했기 때문에 코드 이해에 애좀 먹었다;;

 

지금 생각해도 이걸 신입이 어떻게 했지? 싶은데

못했으면 짤렸으려나? 

반응형
반응형

지난 2022년 12월 ~ 2023년 2월 1일간 했던 일을 간단하게 메모해두려고 한다

아는사람?의 추천을 받고 작년 10월에 새로운 회사에 입사했다.
입사하고 기존 레거시 프로젝트의 react 개발자로 일하게 되었는데,
코드를 받아보고 정말 충격을 받았었다.

가만히 놔두면 렌더링이 무한 발생하는 실시간 차트 페이지..; 이 페이지는 결국 버리고 새로 만들었다

정말 개판으로 짜였다고 느껴진 스파게티 코드와
!important와 inline으로 갈겨놓은 css들을 보고 경악을 했어서 처음엔 나갈려다가
채용 한파에 1년만 버티자 하고 울며 겨자먹기로 ㅠㅠ 일하게 되었는데

하다보니 애증?이 생겨서 열심히 하고 있는 중이다.

1. ground rule 다시 만들기

(아래 링크는 입사 이후 회의한 내용)
https://www.notion.so/10-13-e2521a2a8b5944ddb4e873da3f00cd26

 

10/13 회의 내용

1. import alias - 반영 완료

www.notion.so


프론트 개발은 중간에 한분이 나가시고 사실상 사수분 한명이 다 하고 있던 상태였는데 (어떻게 하셨는지 지금도 의문..)
협업을 위해 간단한 ground rule을 다시 세우고 작업하기로 했다.

프로젝트가 몇년간 여러 사람을 거쳐가면서 누구는 presentational-container pattern을 사용하고 누구는 그걸 따라하겠다고 굳이 mobx에서 전역 데이터를 꺼내서 최상단에서 최하단 7층까지 내려보내고 있고;; 통일성이 필요했다.

2. 디자인 시스템 - 공통 컴포넌트 분리하기

디자인 시스템 예시(출처 : https://coyleandrew.medium.com/a-quick-guide-to-creating-a-design-system-7888e267171f)

기존에는 같은 컴포넌트를 ctrl c + ctrl v 해 사용하거나, material UI를 그대로 박아넣어서
수정 사항이 생겼으면 모든 컴포넌트를 바꾸는 노가다를 해야했다.
(공통 컴포넌트가 있긴 했지만 사람이 계속 바뀌면서 쓰이지 않았다.. 이유는 3번 storybook 문단에서 후술함)

실제로 입사 초기에 dialog에 어떤 기능을 추가하기 위해서 46개의 dialog가 포함된 파일을 수정했었다. ....

그래서 디자인팀에 요청해서 재사용성을 높이기 위해서 같은 컴포넌트를 묶어주고
컴포넌트화 시키는 작업을 하자고 제안했는데, 마침 디자인팀도 일관된 사용자 경험을 위해서 비슷한 고민을 하고 있던 참이라서 공통 컴포넌트 작업을 시작하게 되었다.

디자인 템플릿은 구글의 Material UI를 참고했고, 코드 형식은 Headless UIcompound Pattern를 참고했다.

공통 컴포넌트인 accordion 예시

1. 우선 많이 쓰이는 컴포넌트 (Dropdown, button 등)등을 분리했고,
2. (button 같이 여러 style 바리에이션이 있는 경우) 디자인에 따라서 primary, secondary 등 여러 css 템플릿을 만들었다. props로 전달해서 css 형식을 정하게 된다.
3. 그리고 공통 컴포넌트 내부에서 상태를 Depth에 상관없이 공유하기 위해서 Compound pattern과 Context API를 사용해
작성했다.

 

기능과 스타일의 분리

 

구현시 재사용성을 위해서 기능(logic) 부분과 style이 분리되게 설계했다.

한 기능(ex- dropdown)에 여러 style을 갈아끼울 수 있으며,

기능과 style이 서로 독립적이니 객체지향의 OCP처럼 점점 필요한 기능을 확장해나갈수 있게 설계했다.

 

위 예시 사진인 accordion도 dropdown의 logic 부분 코드을 재활용하고 style을 따로 입힌 예시이다.

 

참고) compound pattern + context api - https://leon-dunamu.github.io/2021/07/21/react-compound-component/

2-1) 라이브러리 모듈화

이때 라이브러리를 사용했다면(ex - table 이나 Map API) 반드시 공통 컴포넌트로 한번 감싸고
외부에 인터페이스를 공유하는 식으로 작성했다.

왜냐하면 이 공통 컴포넌트를 사용하는 입장에서는 내부 구현을 알 필요가 없다.

지도로 비유하자면

컴포넌트를 사용하는 입장에서 Kakao Map인지 Naver Map인지는 관심이 없고 '지도 자체'만 필요하기 떄문이다.

또한, 모듈화가 이뤄져야 만약 라이브러리에 문제가 생겼거나 교체가 필요하다면 인터페이스는 놔두고 공통 컴포넌트 내부만 바꾸는 식으로 빠르게 교체할 수 있다.

+ 모듈화를 통해 '경계'를 만들고 라이브러리 전용 테스트를 작성해서 만약 라이브러리 버전업시 문제가 발생하면 빠르게 캐치할수 있도록 jest로 test code를 작성했다.

추가적으로 기본적인 dropdown 컴포넌트 등을 만들때엔 기존에는 mui4를 사용했는데 점차 deprecated 시킬 예정이라서 외부 라이브러리를 사용하지 않고 직접 작성했다.
(지금 생각해보니 아이콘은 mui자너..?)

3. Storybook 도입

https://storybook.js.org/

사람이 자주 바뀌는데 문서화가 안되있으니
전임자들 코드를 분석해보니 비슷한 코드를 2~3번씩 작성하거나
1회용이라 생각해서 그냥 작성해 때려박는 문제점이 있길래 인수인계 + 공통 컴포넌트 테스트 용으로 storybook을 도입했다.

1. 공통 컴포넌트를 작업한 뒤

2. storybook을 작성하고

3. 그 storybook을 jest에서 import해서 테스트를 돌린다 (mocking - MSW)
이러면 테스트 대상이 어떻게 렌더링되나 실제로 브라우저에서 볼 수 있는 장점이 있다.

테스트 방식은 전에 글을 한번 썼었다.
https://lodado.tistory.com/73

4. visual regression test 용으로 chromatic 에 연동해 확인한다.

(그런데 모든 snapshot을 체크해보기에는 사람이 부족하기도 하고 chromatic은 단순 참고 용도로 사용하고 있다..
확인하는 부분은 나중 QA팀에 요청할 생각이다)

4. 데이터 정규화 - normalizr 도입

(normalizr 소개는 quick start 참고 - https://github.com/paularmstrong/normalizr/blob/master/docs/quickstart.md)

{
  "id": "123",
  "author": {
    "id": "1",
    "name": "Paul"
  },
  "title": "My awesome blog post",
  "comments": [
    {
      "id": "324",
      "commenter": {
        "id": "2",
        "name": "Nicole"
      }
    }
  ]
}


normalizr를 간단하게 설명하자면 위와 같이 깊은 depth를 가진 JSON 파일에서 data.comments.commenter.id 만 3으로 수정한다고 생각해보자. 매우매우매우 끔찍하다.
그래서 정규화 과정을 통해 데이터를 id를 통해 참조하는 쓰기 좋은 포맷으로 바꿔준다.

{
  result: "123",
  entities: {
    "articles": {
      "123": {
        id: "123",
        author: "1",
        title: "My awesome blog post",
        comments: [ "324" ]
      }
    },
    "users": {
      "1": { "id": "1", "name": "Paul" },
      "2": { "id": "2", "name": "Nicole" }
    },
    "comments": {
      "324": { id: "324", "commenter": "2" }
    }
  }
}


위와 같이 id를 통해 참조 가능한 1 depth로 줄여주는 좋은 라이브러리다.

내가 맡은 파트는 비유하자면
"레이아웃에 drag and drop으로 자유롭게 배치 가능한 실시간 주식 차트"인데, API 구조가 상당히 복잡하다.

주식 차트들을 보기 위해서 다음과 같은 과정을 거친다.

1. 유저의 LayoutList 목록을 API 콜 A로 가져온다.
2. 유저가 LayoutList 중 하나의 Layout을 선택한다.
3. 해당 선택된 Layout에는 Chart들의 정보를 담는 ChartList가 있다. ChartList 에 있는 chart 들의 key들을 API 콜 B로 보내서 chart를 그릴 데이터를 API로 받아온다.

기존 코드는 해당 API를 사용하기 위해서 매우 복잡하고 파악이 힘든 전처리 과정을 거치고 있었는데

normalizr를 사용해 id 값만 있다면 어느 데이터나 (전역 state를 통해) 접속하고 , 수정 가능하게 리펙토링하니
가독성이 높아지고 유지보수성도 확실히 향상되었다.

나도 써보기 전까진 긴가민가 했는데 이젠 안쓰고는 못살꺼 같다.
API 구조가 복잡하면 꼭 써보자.

 

2023-02-18 추가 //

비슷한 내용 찾아서 추가..

 

https://www.youtube.com/watch?v=HYgKBvLr49c 

 

 

5. Repository 패턴 도입

안드로이드의 레포지토리 패턴 예제


프론트에서 필요한 데이터의 접근과 비즈니스 로직을 분리하기 위해서 API 접근에서 레포지토리 패턴을 추가했다.
추가적으로 필요한 데이터 전처리, 데이터 정규화(normalizr), 데이터 캐싱 처리를 레포지토리에서 관리하도록 layer를 분리했다.

기존에는

component <-> (state library(mobx, redux 등등..)) <->  API Call

위와 같은 구조로 되어 있었는데 필요한 데이터를 뽑아내는 전처리 로직과 비즈니스 로직이 뭉쳐있어서
API 수정이나 프론트 코드 수정에 유연하지 못했다.

repository를 추가한다면

component <-> (state library(mobx, redux 등등..)) <-> repository  <->  API Call

위와 같은 레이어를 거치는셈이다.
repository가 데이터 관리를 수행하고 사용하는 입장에서는 그냥 데이터 요청만 해서 써도 된다.

여담으로 repository가 데이터 전처리까지 해도 좋은가(=프론트에서 전처리해도 좋은가) 조사해본 결과 데이터 전처리는 가능하면 백엔드에서 하는게 좋은 편인거 같다. 프론트엔드는 싱글 쓰레드로 상태관리와 렌더링하는거만 해도 바쁘다.
그래서 일부 기업은 프론트가 백엔드인 API gateway server까지 전담하는 경우도 많다고 한다.

우리팀도 점진적으로 리펙토링 해가며 전처리 로직을 백엔드로 넘길 예정이다. graphql을 도입하여 프론트에서는 질의문만 백엔드에 날릴까 검토중인데 큰 작업이라 진짜 할지는 모르겠다.

추가적으로 백엔드 API가 기존에는 페이지 별로 있었는데 컴포넌트 단위로 통합시킬 예정이다. 그래서 1차로 동일한 인터페이스를 가진 레포지토리로 api를 통합하고 2차로 레포지토리를 바꾸는 식으로 점진적으로 api를 통합해나갈 계획이다.


하다보니 느낀점이
내가 아직 신입인데 올바른 방향으로 가고 있나 걱정되기도 하고 이상한 코드는 이상하게 짜인 이유가 있었고 다시 만들어도 비슷한 레일을 따라가는거 같긴 하다..?
혹시 예상치 못한 버그가 생길까봐 조심스레 개선하고 있는 중이다.

그래도 컨택스트 파악 안된 코드를 다음 사람에게 물려주고 싶진 않아서 노력하고 있다..🔥

반응형

+ Recent posts