- monorepoturborepotsconfig
[블로그 소개] 프론트엔드 - 구조 및 설정
[블로그 소개] 프론트엔드 블로그를 만들기 위해 여러 번 세팅을 시도했고, 그 과정에서 포기도 여러 번 했습니다. 다양한 시도 끝에 monorepo 구조로 구성하는 방식이 가장 깔끔하게 프로젝트를 나누고 사용하기에도 편하다는 결론에 도달했습니다. 단순히 폴더 구조만으로 기능을 구분하려 했을 때는 폴더가 지나치게 세분화되었고, 그렇다고 패키지들을 repo를 나누려 하면 관리하고 다른 앱에 적용하기 짜증납니다. 특히 에디터 기능을 포함하려다 보니 코드 양이 많아지면서 도메인과 코드가 섞이기 쉬웠습니다. 반면, 하나의 레포에서 모듈별로 분리하는 방식은 자연스럽게 도메인과 코드가 분리되며, 재사용성 및 사용성(개발자 경험)까지 확보할 수 있다는 점에서 확실히 장점이 있다고 느꼈습니다. 아래는 블로그 만들 때 사용한 Monorepo 구조입니다. 과거 폴더 구조 (tsconfig Reference 기능 사용) 공통되는 설정들을 eslint.base.js, tsconfig.base.json에 담겨져 있어 각 앱과 패키지에서 이를 extends하는 방식으로 사용했습니다. 최적화를 위해서 references 속성을 이용해 의존성을 만들었습니다. 그런데 최신 버전(eslint v9.16)으로 설정을 하다보니 레퍼런스가 부족하여 일부분 뒤죽박죽 설정한 부분도 있습니다. ├── apps/ │ ├── storyBook/ # storybook │ ├── carrotBlogFront/ # Next.js 앱 (메인 프론트엔드) │ └── carrotMobileApp/ # React Native 앱 ├── packages/ │ ├── designSystem/ # tailwind 기반 디자인 시스템 │ ├── editor/ # 에디터 │ ├── domain/ # 도메인 [Entity, FetchApi, Util] │ └── utils/ # 공통 유틸 ├── preinstall.js # preinstall 스크립트 ├── pnpm-workspace.yaml # pnpm은 별도 파일로 워크스페이스 정의 ├── package.json ├── turbo.json # 빌드 파이프라인, task 설정 ├── eslint.config.js # root eslint ├── tsconfig.json # root tsconfig ├── eslint.config.base.js # 공통 eslint └── tsconfig.base.json # 공통 tsconfig 현재 폴더 구조 (Just in Time packages 적용) tsconfig와 eslint 설정을 공통된 부분은 각각 패키지로 더 직관적으로 찾아서 관리할 수 있도록 만들었습니다. 덤으로 기존의 tsconfig를 references 필드를 활용하여 최적화 했는데 혼자 개발하는 만큼 엄청 큰 프로젝트는 되기 힘들기 때문에 사용성 측면에서 더 좋은 just in time packages 방식으로 변경했습니다. (references를 삭제) 저가 처음 eslint를 설정했을때, 최신 버전의 eslint를 사용하여 사용법을 찾아보고 적용하느라 뒤죽박죽 진행한 점이 있습니다. 그래서 turboRepo dlx시 생성된느 기본 설정으로 상당부분 변경하였습니다. ├── apps/ │ ├── storyBook/ # storybook │ ├── carrotBlogFront/ # Next.js 앱 (메인 프론트엔드) │ └── carrotMobileApp/ # React Native 앱 ├── packages/ │ ├── designSystem/ # tailwind 기반 디자인 시스템 │ ├── editor/ # 에디터 │ ├── domain/ # 도메인 [Entity, FetchApi, Util] │ ├── utils/ # 공통 유틸 │ ├── eslintConfig/ # eslint 설정 │ └── tsConfig/ # 타입 스크립트 설정 ├── preinstall.js # preinstall 스크립트 ├── pnpm-workspace.yaml # pnpm은 별도 파일로 워크스페이스 정의 ├── package.json └── turbo.json # 빌드 파이프라인, task 설정 // apps/*/package.json // pacckages/*/package.json { "devDependencies": { "@yooncarrot/eslint-config": "workspace:*", "@yooncarrot/ts-config": "workspace:*", "eslint": "9.16.0" } } // packages/typescript-config/tsconfig.base.json { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "declaration": true, "declarationMap": true, "esModuleInterop": true, "isolatedModules": true, "composite": true, "lib": ["es2022", "DOM", "DOM.Iterable"], "module": "NodeNext", "moduleDetection": "force", "moduleResolution": "nodenext", "noUncheckedIndexedAccess": false, // index로 접근해도 undefined안나오게 "resolveJsonModule": true, "skipLibCheck": true, "strict": true, "forceConsistentCasingInFileNames": true, "allowImportingTsExtensions": true, "emitDeclarationOnly": true, "target": "ES2022" } } // packages/exlint-config/base.js import js from "@eslint/js"; import eslintConfigPrettier from "eslint-config-prettier"; import turboPlugin from "eslint-plugin-turbo"; import tseslint from "typescript-eslint"; import onlyWarn from "eslint-plugin-only-warn"; / / export const config = [ js.configs.recommended, eslintConfigPrettier, ...tseslint.configs.recommended, { plugins: { turbo: turboPlugin, }, rules: { "turbo/no-undeclared-env-vars": "off", "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": [ // 사용하지 않는 변수 underbar prefix 붙이면 에러안나게 설정 "error", { args: "all", argsIgnorePattern: "^_", caughtErrors: "all", caughtErrorsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_", varsIgnorePattern: "^_", ignoreRestSiblings: true, }, ], }, }, { plugins: { onlyWarn, }, }, { ignores: ["dist/"], }, ]; 1.1 App과 Package 블로그 프로젝트에서 App은 스토리북, React Web, React Native 3개 입니다. 그리고 패키지는 Design System, Editor, Domain, Utils 4개와 ts와 esLint 설정을 위한 tsConfig, eslintConfig 2개로 구성했습니다. App React Web React Native Storybook Package DesignSystem Editor Domain Utils tsConfig esLintConfig 여기서 Domain을 별도로 분리한 목적은 좀더 API를 체계적으로 사용하기 위함입니다.(도메인 주도 개발 느낌으로 생각한 것은 아닙니다.) 그리고 Domain 패키지의 역할은 3가지 입니다. 프론트에서 사용할 Article이나 Category같은 Entity를 정의하고, 백엔드에서 받는 DTO를 정의하여 DTO를 Entity로 가공하여 App에서는 바로 Entity로 사용할 수 있는 역할을 합니다. 또한 Restful한 API로 설계하였는데(성숙도 2레벨 정도), [Get, /articles]같은 api를 사용할 때 좀더 체계적으로 쿼리 파라미터를 활용할 수 있도록 App과 분리했습니다. 그래서 공통적인 쿼리 파라미터들은 QueryStringFactory같은 객체를 만들어 규칙적이게 관리할 수 있습니다. ex) [Get, /articles?q=블로그&sort=+createdAt-likes&category=Typescript&page=0&size=9] 이 api는 "블로그"를 검색하는데 생성일 오름차, 좋아요 내림차순으로 정렬된 데이터이고, 카테고리는 Typescript인 것들만 가져오는 api입니다. 이렇게 쿼리 스트링이 많기 때문에 여러 api에서 공통적으로 관리할 수 있다면 더 좋다고 생각했습니다. 마지막으로는 React Native로 앱으로도 개발해야 하는데, API 재사용을 가능하게 만듭니다. 1.2 tsconfig 공유 방법 Monorepo에서 모듈간 tsconfig를 공유하는 방법들에 대해서 설명드리겠습니다. 아래 예시들은 실제로는 Nx를 사용했을 때 예시들을 가져온 것입니다. 하지만 turboRepo를 사용해도 똑같이 설정하기 때문에 그냥 같은 예시로 사용했습니다. tsconfig 공유 방법별로 차이점들 정리했습니다. Monorepo 세팅을 하실 때 큰 도움이 될 것 같습니다. 구분 설명 타입 추론 속도 빌드 방식 타입 파일 배포 Project Reference ts가 패키지 간 의존성/빌드 순서를 추적 🟡 보통 (.tsbuildInfo 사용) tsc --build tsc --watch (실시간) .d.ts .tsbuildinfo ❌ Internal Package (Just in Time ) 빌드 없이 그대로 .ts 파일을 소비 🟡 보통 consumer 빌더 사용 .ts ❌ Compiled Package 소스 코드를 빌드해서 .js + .d.ts 출력 🟢 빠름 빌더 사용 .d.ts, .js ✅ Publish Package 외부 공개용으로 빌드된 패키지 🟢 빠름 빌더 사용 .d.ts, .js ✅ Relative Imports (가장 직관적인 방법) └─ . ├─ apps │ └─ exampleApp │ ├─ src │ │ └─ index.ts │ └─ tsconfig.json ├─ packages │ └─ exampleLib │ ├─ src │ │ └─ index.ts │ └─ tsconfig.json └─ tsconfig.base.json // apps/exampleApp/src/index.ts import { add } from '../../../packages/exampleLib/src/index.ts'; console.log(add(1,3)); // tsconfig.base.json { "compilerOptions": { "target": "ES2020", "module": "NodeNext", "strict": true, "moduleResolution": "NodeNext", "baseUrl": ".", "rootDir": "." } } // apps/exampleApp/tsconfig.json { "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "../../dist", "declaration": true }, "include": ["src//*"] } // packages/exampleLib/tsconfig.json { "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "../../dist", "declaration": true }, "include": ["src//*"] } 의존성 해결 상대 경로를 이용하여 패키지 간 연결을 가장 간단하게 할 수 있는 방법입니다. 하지만 코드베이스 구조를 변경하면, extends 경로를 일일이 수정해야 합니다. 큰 워크스페이스에서 이 단점이 더 부각될 수 있습니다. 모듈화 앱과 라이브러리들을 각각의 폴더로 분리하여 모듈화를 가능하게 해줍니다. 따라서 유지 보수성도 좋아지고 워크스페이스도 쉽게 탐색할 수 있습니다. 하지만 Typescript 관점에서는 전체가 하나의 통합된 프로젝트가 되어, 타입 검사에서 패키지 간에 경계가 존재하지 않게됩니다. 성능 Typescript 관점에서 전체가 하나의 통합된 프로젝트가 되어 워크스페이스가 크면 문제가 생길 수 있습니다. 타입 검사와 컴파일을 전체 모듈을 대상으로 이루어지기 때문에, 빌드 속도가 느려지고 에디터 반응성도 저하될 수 있습니다. Relative Imports를 Path Aliases로 전환. (가독성 개선) Relative Import와 차이점 tsconfig.base.json paths 필드 추가 결론 import path 축약하여 가독성 향상 └─ . ├─ apps │ └─ exampleApp │ ├─ src │ │ └─ index.ts │ └─ tsconfig.json ├─ packages │ └─ exampleLib │ ├─ src │ │ └─ index.ts │ └─ tsconfig.json └─ tsconfig.base.json // tsconfig.base.json { "compilerOptions": { "target": "ES2020", "module": "NodeNext", "strict": true, "moduleResolution": "NodeNext", "baseUrl": ".", "rootDir": "." "paths": { "@text-package/exampleLib": ["packages/exampleLib/src/index.ts"] } } } // apps/exampleApp/src/index.ts import { add } from '@test-package/exampleLib'; console.log(add(1,3)); 의존성 해결 Path alias를 사용하여 상대 경로를 사용할 필요가 없어져서 코드베이스 구조가 변경되어도 tsconfig.base.json의 paths만 변경해주면 됩니다. 모듈화와 성능은 달라지지 않습니다. tsconfig의 Reference 사용 요구 사항 : typescript v3.0 이상 Relative Import + Path Alias와 차이점 root scripts에 tsc --build로 tsconfig.base.json rootUrl 제거 compilerOptions에서 composite:true 설정 => incremental이 true가 되고 빌드 시 .tsbuildinfo 생성함 declaration 관련 설정들 추가 root 파일 추가 references 필드 추가 app declaration, outDir 필드 제거 references 필드 추가 packages declaration, outDir 필드 제거 결론 타입스크립트가 모듈별로 별도 관리 성능 향상 타입 의존성 생성 (빌드 의존성 생기며 ts가 알아서 계산) └─ . ├─ apps │ └─ exampleApp │ ├─ src │ │ └─ index.ts │ └─ tsconfig.json ├─ packages │ └─ exampleLib │ ├─ src │ │ └─ index.ts │ └─ tsconfig.json ├─ tsconfig.json └─ tsconfig.base.json // apps/exampleApp/src/index.ts import { add } from '@test-package/exampleLib'; console.log(add(1,3)); // tsconfig.base.json // 핵심은 rootUrl 삭제. { "compilerOptions": { "target": "ES2020", "module": "NodeNext", "strict": true, "moduleResolution": "NodeNext", "composite": true, "declaration": true, "declarationMap": true, "sourceMap": true, "baseUrl": ".", "paths": { "@text-package/exampleLib": ["packages/exampleLib/src/index.ts"] } } } // tsconfig.json { "files": [], "references": [{ "path": "./packages/exampleLib" }, { "path": "./apps/exampleApp" }] } // apps/exampleApp/tsconfig.json { "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", }, "references": [{ "path": "../../packages/exampleLib" }], "include": ["src//*"] } // packages/exampleLib/tsconfig.json { "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", }, "include": ["src//*"] } // packages/exampleLib/package.json { "name": "test-package", ... "scripts": { "dev": "tsx --tsconfig tsconfig.base.json apps/exampleApp/src/index.ts", "build": "tsc --build", "clean": "tsc --build --clean", "typecheck": "tsc --build --emitDeclarationOnly" } } 모듈화 프로젝트 레퍼런스를 사용하면 각 모듈을 독립적으로 만들 수 있습니다. 컴포넌트 사이의 논리적인 분리를 강제합니다. 이를 통해 패키지 간의 격리 수준이 향상되고, 의존성은 패키지 단위로 타입 검사를 수행합니다. 성능 증분 빌드(incremental build)를 사용합니다. 변경된 패키지만 다시 컴파일하기 때문에 전체 빌드를 반복할 필요가 없습니다. 타입스크립트는 .tsbuildinfo 파일을 생성하여 변경 사항을 추적하고 이를 통해 타입 검사와 컴파일 속도를 향상시킵니다. 규모가 큰 워크스페이스나 CI 파이프라인에서 유용합니다. (개발 편의성으로 --watch 옵션 사용하여 실시간 업데이트) Reference + package manager 요구사항 : Node.js v13.2.0 이상 이 방식은 app(consumer)에서 package의 ts파일을 직접 참조하는 방식입니다. 그래서 transpiling의 책임이 consumer에게 있습니다. 만약 consumer가 Typescript를 컴파일하여 사용할 수 없으면 package를 사용할 수 없게 됩니다. tsconfig Reference 사용과 차이점 tsconfig.base.json baseUrl 삭제 paths 삭제 app private 필드 추가 package를 dependency에 추가 app baseUrl 필드 추가 package private 필드 추가 (단독으로 사용 가능) exports 필드 추가 package composite 필드 추가 declaration 필드 추가 main, type 추가 workspace 추가 pnpm은 pnpm-workspace.yaml 파일 npm, yarn은 root 결론 typescript path alias가 아닌 package manager로 app과 패키지간 연결됨 모듈들을 별도로 패키징 관리 더 엄격한 환경 분리 app의 별도 트랜스파일 + 번들링 (app 빌드시 packages까지 해줘야함) └─ . ├─ apps │ └─ exampleApp │ ├─ src │ │ └─ index.ts │ └─ tsconfig.json ├─ packages │ └─ exampleLib │ ├─ src │ │ └─ index.ts │ └─ tsconfig.json ├─ pnpm-workspace.yaml ├─ tsconfig.json └─ tsconfig.base.json // pnpm-workspace.yaml packages: // apps/exampleApp/tsconfig.json { "compilerOptions": { "target": "ES2020", "module": "NodeNext", "strict": true, "moduleResolution": "NodeNext", "composite": true, "declaration": true, "declarationMap": true, "sourceMap": true, } } // apps/exampleApp/tsconfig.json { "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "baseUrl": ".", "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" }, "references": [{ "path": "../../packages/exampleLib" }], "include": ["src//*"] } // packages/exampleLib/tsconfig.json { "extends": "../../tsconfig.base.json", "compilerOptions": { "baseUrl": ".", "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" }, "include": ["src//*"] } // packages/exampleLib/package.json { "name": "@text-package/exampleLib", ... "type": "module", "exports": { ".": { "types": "./src/index.ts", "import": "./src/index.ts", "default": "./src/index.ts" }, "./package.json": "./package.json" }, "main": "./src/index.ts", "types": "./src/index.ts", "module": "./src/index.ts" } // apps/exampleApp/package.json { "name": "@yooncarrot/exampleApp", ... "dependencies": { "@test-package/exampleLib": "workspace:*" // NPM을 쓰면 선택사항 이지만, pnpm에서는 반드시 명시해줘야합니다. } } 의존성 해결 패키지 매니저의 workspace를 사용하여 패키지 해석을 패키지 매니저에게 위임하여 typescript에 독립적으로 동작합니다. 모듈화 각 패키지의 의존성이 package.json에 의해 명확하게 정의되어 직관적이고 이해하기 쉬워집니다. 그리고 export할 파일을 명시하기 때문에 해당 파일을 import하는 패키지에서 트랜스파일하고 번들링하는 책임을 지게 되어 더 확실히 모듈화가 됩니다. 성능은 이전 Reference방식과 같습니다. Reference + package manager + Pre-building 사용 (Compiled Packages) Reference + Package Manager 방법과 차이점 package exports 경로들을 빌드 경로로 수정 (src/index.ts => dist/index.d.js..) package.json혹은 exampleLib의 package.json 빌드 스크립트 적절히 수정. nx나 turboRepo쓰면 해당 monorepo 라이브러리 맞게 파이프라인 수정. 결론 번들링 책임이 각 모듈별로 가질 수 있음. (미리 빌드하고 제공하기 때문) Compiled Packages는 빌드를 해야 타입 업데이트가 되기 때문에 번거로울 수 있음.(개발 편의성 저하 가능성) 대규모 프로젝트에서는 pre-compile 방식이 개발 편의성이 좋을 수 있음 (IDE 타입 추론 딜레이 적어짐) └─ . ├─ apps │ └─ exampleApp │ ├─ ... │ ├─ package.json │ └─ tsconfig.json ├─ package.json ├─ packages │ └─ exampleLib │ ├─ dist │ │ ├─ index.d.ts │ │ ├─ index.d.ts.map │ │ ├─ index.js │ │ ├─ index.js.map │ │ └─ tsconfig.tsbuildinfo │ ├─ package.json │ ├─ src │ │ └─ index.ts │ └─ tsconfig.json ├─ pnpm-workspace.yaml ├─ tsconfig.base.json └─ tsconfig.json // packages/exampleApp/package.json { "name": "@test-package/exampleLib", "version": "0.0.0", // 추가된 필드 "private": true, // 추가된 필드 "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", // 모든 ./src/로 변경(빌드된 파일) "import": "./dist/index.js", "default": "./dist/index.js" }, "./package.json": "./package.json" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", "module": "./dist/index.js" } 의존성 해결 의존성 패키지들을 미리 컴파일하여, app 번들러가 미리 빌드된 출력물을 사용할 수 있게 됩니다. 또한 애플리케이션 번들링 중에 패키지 의존성을 컴파일할 필요도 없어집니다. 작업 파이프라인 작성시 패키지들이 미리 컴파일 되도록 보장하여 워크플로우를 더 효율적으로 만들 수 있습니다. 모듈화 타입스크립트 소스 파일을 직접 참조하는 이전 방식에 비해 모듈성이 향상됩니다. 패키지를 미리 컴파일하고 패키징하면, 모노레포 외부에 배포도 가능합니다. 성능 타입 검사 성능을 향상시킬 수 있습니다. 미리 컴파일된 .d.ts 파일을 통해 타입 정보가 미리 생성되어 프로젝트 참조를 통한 타입스크립트 소스 파일을 처리하는 대신 이 타입 정보 파일을 직접 활용할 수 있습니다. Reference + package manager + package use path alias + (Just-in-TIme Packages) 요구 사항 : typescript 5.4 이상, NodeJs 14.6 이상 package(패키지)의 tsconfig 옵션중에 compilerOptions.paths가 consumer에게는 적용되지 않습니다. 왜냐하면 app의 타입스크립트로 트랜스파일 될 것으로 예상하고 작동되기 때문입니다. 그래서 패키지에서 path alias를 사용하면 문제가 생기는데, 타입스크립트 5.0부터는 nodejs의 14.6버전에 추가된 subPath 기능으로 이를 해결할 수 있습니다. monorepo에서 Just-in-Time 패키지는 app에서 패키지의 ts 파일을 직접 참조하는 방식입니다. 즉, 타입 정의 파일(.d.ts)나 .tsbuildInfo파일을 참조하지 않는 방식입니다. 장점으로는 지속적인 build가 필요 없이 실시간으로 반영된다는 점입니다. └─ . ├─ apps │ └─ exampleApp │ ├─ ... │ ├─ package.json │ └─ tsconfig.json ├─ package.json ├─ packages │ └─ exampleLib │ ├─ package.json │ ├─ src │ │ ├─ add.ts │ │ ├─ sub.ts │ │ └─ index.ts │ └─ tsconfig.json ├─ pnpm-workspace.yaml ├─ tsconfig.base.json └─ tsconfig.json // packages/exampleLib/package.json { "name": "@test-package/exampleLib", "version": "0.0.0", // 추가된 필드 "type": "module", "imports": { // 추가된 필드 "#" // package 내부에서 path alias 사용시.(node 설정에서는 @ 불가) }, "exports": { // 추가된 필드 ".": "./src/index.ts", "./add": "./src/add.ts", "./sub": "./src/sub.ts", }, ... } // packages/exampleLib/tsconfig.json { "extends": ["../../tsconfig.base.json"], "compilerOptions": { "baseUrl": ".", "paths": { "#"] // packge 내부에서 path alias 사용시 Just-in-Time 패키지에서는 @불가. } }, "include": ["src//*"] ... } 의존성 해결 Package 모듈들도 path alias를 사용하여도 타입 참조가 가능하게 됩니다. (path alias로 인한 의존성 문제가 해결) 성능 compiled packages로 패키지를 app에서 사용하는 것 보다 성능은 좋지 않습니다. 하지만 ide에 타입을 보여주는 기능에 많은 리소스가 필요한 대규모 프로젝트가 아니리면 개발자의 개발 경험은 더 좋아질 수 있습니다. 1.3 eslint 공유 방법 EsLint 설정은 tsConfig설정 보다는 훨씬 관리하기 쉽습니다. 컴파일이나 번들링과 큰 연관은 없기 때문입니다. 단순히 eslintConfig 패키지에서 공통된 eslint설정을 만들고, App과 Package에서 import해서 사용했습니다. // NextJs App의 eslint.config.js import {nextJsConfig} from "@yooncarrot/eslintConfig/next"; / @type {import("eslint").Linter.Config} */ export default nextJsConfig; // esLintPackage 패키지의 base.js export const config = [ js.configs.recommended, eslintConfigPrettier, ...tseslint.configs.recommended, { plugins: { turbo: turboPlugin, }, rules: { "turbo/no-undeclared-env-vars": "off", "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": [ "error", { args: "all", argsIgnorePattern: "^_", caughtErrors: "all", caughtErrorsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_", varsIgnorePattern: "^_", ignoreRestSiblings: true, }, ], }, }, { plugins: { onlyWarn, }, }, { ignores: ["dist/"], }, ]; // esLintConfig 패키지의 nextjs.eslint.js export const nextJsConfig = [ ...baseConfig, js.configs.recommended, eslintConfigPrettier, ...tseslint.configs.recommended, { ...pluginReact.configs.flat.recommended, languageOptions: { ...pluginReact.configs.flat.recommended.languageOptions, globals: { ...globals.serviceworker, }, }, }, { plugins: { "@next/next": pluginNext, }, rules: { ...pluginNext.configs.recommended.rules, ...pluginNext.configs["core-web-vitals"].rules, // 커스텀 폰트 허용 "@next/next/no-page-custom-font": "off", }, }, { plugins: { "react-hooks": pluginReactHooks, }, settings: {react: {version: "detect"}}, rules: { ...pluginReactHooks.configs.recommended.rules, // React scope no longer necessary with new JSX transform. "react/react-in-jsx-scope": "off", }, }, ]; turborepo에서는 internal packages의 컴파일 전략을 크게 3가지로 나눴습니다. 컴파일하지않는 Just-in-Time Packages와, ts compile하는 Compiled Packages, NPM에 올리는 Publishable Packages입니다. 2.1 Just-in-Time Packages Just-in-Time 패키지는 최소한의 설정만 필요한 방식입니다. 왜냐하면 패키지에서 따로 빌드를 하지 않고 App의 컴파일러와 번들러로 함께 빌드되기 때문입니다. 즉 컴파일과 번들링 설정이 App 설정에 영향을 받습니다. 그리고 따로 컴파일 하지 않아도, ts 파일을 보고 바로 타입 추론이 됩니다. 장점 바로 Typescript를 export하여 사용하기 때문에, app에서는 패키지의 ts파일을 바로 참조하여 사용합니다. 그래서 패키지 파일을 수정하면 변경 사항이 바로 반영되어 타입 추론이 됩니다. app에서 패키지를 바로 직접 참조하기 때문에 별도의 build 스크립트나 설정이 필요없습니다. 단점/한계 ts파일 직접 참조 방식이기 때문에 consumer(주로 app)가 ts를 이해할 수 있어야 하고, 빌드할 수 있어야 합니다. 만약 app이 js만 사용할 수 있다면, complied packages 방식으로 바꿔야합니다. 패키지는 tsconfig에서 path alias를 사용할 수 없습니다. 왜냐하면 typescript는 코드를 작성한 패키지 안에서 트랜스파일할 것으로 생각하기 때문에 path alias를 별도로 인식할 수 없습니다. 그래서 path alias를 사용하고 싶으면 nodeJs의 subPath 기능을 사용해야 합니다. turborepo에서 package의 빌드 결과를 캐싱하지 못하는 단점이 있습니다. 왜냐하면 package를 따로 빌드를 하지 않기 때문입니다. 그래서 package가 바뀌면, app도 다시 빌드해야 합니다. 패키지를 ts 그대로 쓰기 때문에 타입 에러가 있으면, app에서는 굳이 몰라도 될 타입 에러를 알아야 하는 경우가 생길 수 있습니다. 2.2 Compiled Packages Compiled 패키지는 ts파일을 js와 d.ts 파일로 컴파일하여 사용하는 패키지입니다. 보통 /dist 폴더에 컴파일 결과를 출력하여, 진입점(entry point)로 설정하여 사용합니다. 컴파일하기 때문에 turborepo의 빌드 캐싱 기능을 활용할 수 있습니다. 그래서 app이나 package 모두 빌드 시 더 빠르게 빌드가 가능합니다. 장점 turborepo의 빌드 캐싱 기능을 활용할 수 있습니다. 단점/한계 보통 tsc로 컴파일 처리만 합니다. 왜냐하면 app의 bundler를 사용하기 때문입니다. 하지만 가끔 정적 파일을 package에서 사용한다면 별도의 번들링 작업이 필요할 수 있습니다. Just-in-Time 패키지보다 설정이 더 필요합니다. 예를 들면 폴리필 작업이 필요한 패키지라면 package.json의 sideEffect 속성을 추가해줘야 하기 때문입니다. 2.3 Publishable Packages Publishable 패키지는 NPM 저장소에 올려서 사용하는 패키지 입니다. 그래서 별도의 builder로 빌드할 수 있어야 합니다. 그래서 compile과 bundle의 책임이 패키지에게 있습니다. 가장 견고하게 패키지를 만드는 방식입니다. app은 npm registry를 통해 패키지를 다운받아 사용하는 방식이 됩니다. 폴더구조는 일반적으로 많이 사용하는 구조(Layered Architecture)를 사용했습니다. NextJS 앱은 FSD와도 어울릴 것 같아 FSD 아키텍처를 생각해 봤는데 새로 도전하는 것이 너무 많아서 고이 접어뒀습니다. 일부 앱과 패키지의 폴더 구조를 만들어 봤습니다. 3.1 NextJS (앱 라우터) └─ blog ├─ src │ ├─ app │ │ ... │ │ ├─ layout.ts │ │ ├─ page.ts │ │ ├─ action.ts │ │ ├─ error.ts │ │ ├─ loading.ts │ │ ├─ robots.ts │ │ └─ sitemap.ts │ ├─ components │ │ ├─ client │ │ └─ server │ └─ provider ├─ after_build.js ├─ eslint.config.js ├─ tsconfig.json ├─ postcss.config.js ├─ next.config.ts └─ package.json 3.2 design-system 패키지 폴더 구조 └─ design-system ├─ src │ ├─ globals.css │ ├─ palette.css │ ├─ components │ │ ... │ │ ├─ Backdrop │ │ └─ BentoCard │ └─ motion ├─ eslint.config.js ├─ tsconfig.json ├─ postcss.config.js ├─ vite.config.ts └─ package.json 3.3 editor 패키지 폴더 구조 └─ editor ├─ src │ ├─ extensions │ ├─ hooks │ ├─ plugins │ └─ utils ├─ eslint.config.js ├─ tsconfig.json ├─ vite.config.ts └─ package.json 참고자료 https://turbo.build/blog/you-might-not-need-typescript-project-references https://nx.dev/blog/typescript-project-references https://nx.dev/blog/managing-ts-packages-in-monorepos
2025-04-130035 - MySQLRedisSQL
[블로그 소개] DB - ERD
[블로그 소개] DB 이번 글에서는 블로그에 사용한 DB와 그 ERD를 그리는 과정을 소개해드리려고 합니다. DB는 MySQL을 사용했습니다. 사용한 이유는 혼자서 만들기엔 익숙한 DB를 사용하는 것이 낫다고 판단했습니다. ERD(DB설계)를 그리기 전에 이상적인 소프트웨어 공학 개발 순서에서는 만들 제품에 대한 정의와 검증을 합니다. 그리고 아래 나열한 과정들이 ERD를 그리기 전에 수행해야할 과정들입니다. 레퍼런스 조사, 시장 조사, 아이디어 구체화 및 경쟁력 분석 요구사항 정의 및 기능 목록 작성 와이어프레임/화면 설계 use Case 정의(누가 어떤 기능을 사용하는가, 어떤 데이터를 CRUD 하는지 확인) user Flow 정의 (기능이 언제 데이터를 생성/수정/조회 하는가, 데이터 CRUD 타이밍 확인) class Diagram 정의 (객체들의 관계 조회, ERD 구조의 뼈대) 유즈케이스, 사용자 흐름, 클래스 다이어그램을 합쳐서 검증. 데이터 모델링 (ERD) 하지만 실제 현업에서 개발을 할 때는 위 과정과 순서를 그대로 따르지 않습니다. 목표를 달성하기 위한 시간과 인력이 한정되어 있고 소프트웨어 개발 방법론(폭포수, 애자일..)에 따라서도 달라지기 때문입니다. 저는 혼자서 블로그를 다 만들어야하기 때문에 다음과 같은 간단한 과정으로 ERD 설계를 했습니다. 블로그 레퍼런스 조사 기능 정의 DB 설계 (ERD) 구현 중 추가하고 싶은 기능이 있으면 기능 정의 추가 => ERD 변경 혼자 개발하기 때문에 저에게 모든 결정권이 있고, 모든 변경 내역과 플로우를 알고 있습니다. 그래서 구현 중에 기능을 추가하거나 수정하여도 저가 모두 인지하며, 인력이 부족하기 때문에 위와 같이 간단한 과정으로 ERD를 설계한 것입니다. 아래는 저가 블로그를 만들때 정의한 기능 정의서 입니다. 이 기능정의서를 바탕으로 DB를 설계했습니다. 📄 기능 정의서 (축약) 기능 Google OAuth 로그인 JWT 방식 세션 검증 유저의 Id값은 절대 사용자에게 노출 하지 않음.(보안) 속성 개인 정보 보호를 위한 최소 정보 저장 (google_sub) 확장성을 위한 oauth provider 정보 역할 (RBAC) 기능 게시글 작성 게시글 수정/삭제 게시글 조회 게시글 검색 속성 제목 (SEO 영향) 내용 (SEO 영향) 작성자 카테고리 (SEO 영향) 라벨 (React, TailwindCss v4.0과 같은 티켓, SEO 영향) 이력 관리 게시글 변경 시 article_history 테이블에 이전 내용 백업 기능 댓글 작성 댓글 수정/삭제 댓글 조회 속성 내용 작성자 게시글 id 댓글 id(대댓글) 이력 관리 댓글 변경 시 comment_history 테이블에 이전 내용 백업 기능 게시글 분류 프론트엔드 라우팅에 사용 카테고리 CRUD 카테고리 출력 순서 변경 속성 카테고리 명 부모 카테고리 순서 기능 게시글에 여러 라벨 부여 가능 속성 라벨 명 기능 사용자별 좋아요 추가/취소 가능 게시글에 대한 좋아요 수 카운팅 속성 게시글 사용자 ERD는 바로 DDL문을 생성하여 사용하기 위해 MySQL 워크벤치를 사용하여 그렸습니다. 기능 확장을 위해 아직 쓰지 않는 Column들도 있고, 성능을 위해 비정규화한 테이블도 있고 isdeleted 를 1개의 status로 합치고 싶은 아쉬운 부분도 있는 테이블입니다. ERD를 만들때 특별히 신경쓴 부분들을 위주로 설명 드리겠습니다. 1.1 master 테이블과 history 테이블 분리 속성 값이 자주 안바뀌고 핵심이 되는 속성들을 포함하면 mst(master)테이블로 정의하였고, 변경 이력의 기록이 필요하다고 판단한 테이블은 history 테이블을 별도로 추가했습니다. article_mst는 실수로 내용을 다 지우거나 editor 기능 업데이트 시 문제가 생길 수 있기 때문에 history 테이블이 필수라고 생각했습니다. 그리고 댓글 기능은 악의적인 댓글을 쓰고 수정하는 경우가 발생하면 그 변경 이력이 필수라고 생각하여 history 테이블을 추가했습니다. 1.2 검색 엔진과 MySQL 블로그 검색 기능은 title과 content를 기준으로 작동하도록 하고싶었습니다. MYSQL 자체 기능만으로 검색 기능을 구현할 방법은 다음과 같습니다. Like 연산 REGEXP 정규식 연산 MATCH 연산 FULLTEXT Index 이 중 당연히 인덱싱하는 FULLTEXT index가 가장 빠르기 때문에 이 방법을 사용했습니다 그런데 이 방법은 띄어 쓰기를 기준으로 단어 단위로 인덱싱하여 한글 검색에서 정확도가 낮았습니다. 그래서 글자 수 단위로 인덱싱하는 N-gram parser를 이용하여 FULLTEXT index을 적용했여 더 빠르고 정확한 검색 기능이 구현됐습니다. 언젠가 DB가 많아지면 mySQL만 쓰기는 성능이 안나올 것입니다. 그 때는 Elasticsearch db를 연동하거나 다른 DB를 사용해야할 수 있지만, 그 정도로 많은 글을 쓸려면 오랜 시간이 걸릴 것 같습니다. 1.3 카테고리, 라벨 테이블의 자연키 categorymst 테이블은 pk로 자연키를 가집니다. 처음 화면 구상을 할 때, 카테고리별 게시글 목록은 URL을 카테고리 이름을 쓰도록 정의했습니다. 그래서 카테고리는 고유하기 때문에 그 이름 자체를 id로 써서 카테고리 이름으로 조회시 성능이 좋도록 설계했습니다. 라벨 테이블은 pk를 라벨명(name)으로 설정하여 특정 라벨을 찾을때 별도의 인조키(id)값을 찾을 필요가 없게 만들었습니다. 이렇게 만든 이유는 article 저장시 같은 label이 있으면 새롭게 id값을 할당한 같은 이름의 label이 생기면 기능 확장과 하여 부여하면 안되기 때문입니다. 추후 라벨로 게시글을 찾는 기능을 만든다면 라벨로 게시글 찾는 기능을 만들 때, 같은 이름의 다른 id값을 가진 라벨이 많아 성능상 문제가 생기고 데이터 중복 저장의 문제도 생기기 때문입니다. 1.4 연결 테이블(articlerel, articlelikes)의 식별관계 기능 추가가 많을 것으로 생각하여 유연하게 테이블을 만들고 싶어 비식별 관계로 대부분 구성했습니다. 하지만 연결 테이블은 식별 관계로 구성했습니다. article 수정할 때 label도 수정할 수 있습니다. 그래서 article 수정시 label을 삭제할 때 삭제되는 라벨을 사용하는 article이 없다면 그 라벨도 삭제되도록 기획했습니다. 이렇게 설계한 이유는 2가지가 있습니다. 쓸모없는 데이터가 많아지는 것을 원치 않는다. label 검색 시 자동완성 기능이 있을 때, 사용하지 않는 label을 필터하기 위해 필드 추가와 별도의 인덱싱은 원하지 않는다. 그래서 식별관계로 articlerel을 구현한다면 데이터 무결성을 지키며 쓸모 없는 데이터가 많아지는 것을 더 확실히 차단할 수 있고 그에 따라 조회 성능도 더 좋아질 것이라 판단했습니다. articlelikes(좋아요 기능) 연결테이블을 식별관계로 한 이유는 가장 강제 삭제되면 안되는 중요한 테이블 두 개를 연결하기 때문입니다. 비록 article이나 user 모두 soft delete를 하고 있지만 단순히 중요하기 때문에 식별 관계로 만들었습니다. 하지만 지금 와서 보니 설계 미스라고도 생각이 듭니다. 1.5 인덱싱(Indexing) 블로그 데이터베이스는 10개가 넘는 인덱스가 걸려있습니다. 특히 articleat기준 index, 검색을 위한 제목과 내용의 FULLTEXT index 등이 걸려있습니다. 지금은 article 개수가 적어서 오히려 index를 사용하는 것이 조회 성능이 안나올 수 있습니다. 하지만 추후 많은 글을 쓰게 되면 저가 생성한 index들이 빛날 것이라 생각합니다.
2025-04-100016✏️ 작성중... - Critical CSSNextJsSentry
[블로그 소개] 프론트 엔드 - 성능 최적화
[블로그 소개] 프론트엔드 방문해주셔서 감사합니다. 1번째 글 이후 블로그의 마음에 안드는 부분 수정을 하느라 2번째 글 쓰기까지 많은 시간이 걸린 것 같습니다. 이번 글에서는 저가 어떤 방식들로 블로그를 최적화를 했는지 소개하려고 합니다. 우선 글을 읽기 앞서 함께(?) 축하할 일은 최적화 결과 Lighthouse 4가지 카테고리에서 모두 100점을 달성했다는 것입니다. 하하 이번 글에서 소개할 것은 성능에 영향을 끼치는 원인을 어떻게 찾았는지, 그리고 어떻게 최적화 했는지 소개드릴려고 합니다. 여기에 더해 Lighthouse로 점수로 측정되지는 않지만, [네트워크가 오류날 정도로 느린 상황]이나 [새로고침 상황에서 하얀색 깜박임으로 인한 불편한 상황]을 개선한 과정도 소개드리겠습니다. 우선 아래 사진은 최적화를 완전히 마치고 홈화면을 Lighthouse로 성능, 접근성, 권장사항, 검색엔진 4가지 카테고리의 점수를 측정한 결과들입니다. Lighthouse는 크롬 개발자도구의 Lighthouse탭에서 누구나 성능측정을 쉽게 할 수 있도록 만든 툴입니다. Lighthouse 점수 측정시 주의할 점은 사용자 네트워크, 컴퓨터, 브라우저 환경에 따라 다른 결과를 보여줄 수 있다는 점입니다. 대표적인 예로 ThirdParty Cookie를 브라우저에서 차단하지 않았거나, 네트워크가 느리면 성능 측정 결과가 달라집니다. 성능 측정할 때 뿐만 아니라 평소에도 브라우저 설정으로 써드파티 Cookie는 차단하시길 추천드립니다. Home 화면(어드민 로그인) Home 화면(일반유저 로그인) Home 화면(비 로그인) 어드민 계정은 프로필 버튼 개수가 달라 리플로우가 발생하여 CLS 0.005의 수치를 갖음. 일반 계정은 의도한 버튼 개수가 출력되어 리플로우 발생없이 CLS 0의 수치를 갖음. 비로그인시 third party 라이브러리로 에러 메시지 출력되어 권장사항 점수가 낮아짐. 성능 저하 원인을 어떻게 파악했고 이를 바탕으로 LCP, FCP, CLS를 어떻게 개선했는지 소개드리겠습니다. 우선 각 용어에 대해 간단 설명과 최적화 전 YoonCarrot 사이트 성능 측정 결과를 보여드리겠습니다. LCP는 Largest Contentful Paint의 약자로 가장 용량이 큰 컨텐츠 표시 시점. FCP는 First Contentful Paint의 약자로 최초 화면에 렌더링 시작 할 때까지 시간. CLS는 Cumulative Layout Shift의 약자로 웹 페이지 출력 후 Layout Shift 발생 정도. 성능 측정 결과 진단된 원인 위 Lighthouse 성능 보고서를 확인하면 FCP, LCP, TBT, CLS같은 지표와 함께, 예상되는 원인들까지 진단해줍니다. 만약 지표의 의미를 안다면 LayoutShift가 일어나고 있거나 렌더링을 Blocking 시간이 존재한다는 것을 금방 알아낼 수 있습니다. 지표의 의미를 몰라도 진단된 원인들과 해결 방법을 확인하고 성능 개선이 가능합니다. 하지만 위 툴 만으로는 자세한 원인 파악하기 힘들 수 있습니다. 그래서 브라우저 성능 분석 툴을 쓰기도 하는데요. 다음 사진은 브라우저 개발자도구에 있는 툴을 이용하여 분석한 사진입니다. 프레임과 시간 단위로 어떠한 일들이 일어났는지 확인할 수 있습니다. 성능 분석 도구로 분석을 할때는 FCP 74ms, DCL106ms, LCP 171ms가 나왔습니다. Lighthouse와 결과가 많이 다르죠?? 네 정상입니다. Lighthouse 결과 사진은 사실 자료를 안 남겨둬 과거의 사진이라 점수가 낮습니다. 그래도 쭉 이어서 가보겠습니다. 성능 분석 도구로 분석된 자료를 간단하게 분석해 보겠습니다. 시각적으로 볼 수 있는 프레임 자료부터 보면 [빈 프레임 2번 => 이미지 렌더링 없는 프레임 2번 => LCP까지 완료된 프레임] 이 과정으로 5번째 프레임만에게 보여줄만한 화면이 나왔습니다. 여기서 확인이 필요해 보이는 부분은 FCP 결과가 빈화면이고, 왜 LCP까지 2번 프레임을 그렸을까 같습니다. (네트워크 상황 등에 따라 빈화면이나 이미지 없는 프레임이 1번으로 바뀔 수도 있습니다.) 그래프를 보면 중간쯤에 분홍색 선이 보입니다. 저 선은 보통 Layout Shift가 여러 번 일어나면 생깁니다. 확인해보니 CookirRunt Font가 적용되고 일부 컴포넌트의 크기가 바뀌는 것을 확인했습니다. 그리고 GoogleLogin 버튼이 생길때도 Layout Shift가 일어나는 것을 확인할 수 있었습니다. 1.1 FCP와 LCP 최적화 (with Critical CSS) 위 두 가지 분석을 통해 저가 내린 결론은 다음과 같습니다. 위 FCP의 빈 프레임은 HTML에 CSS가 씌어지지 않고 렌더링 되었거나, 서버 상태가 안좋아서다. 위와 같은 상황에도 내 블로그는 첫 프레임부터 안정적인 화면을 보여줄 수 있어야 한다. FCP와 LCP사이의 여러 번의 프레임 생성은 여러 번의 레이아웃 쉬프트의 결과다. (1.2 리플로우 최적화 참고) 위의 결론들 중 1번째와 2번째 결론으로 블로그를 개선하기 위해 Critical CSS를 적용했습니다. CSS 파일을 파일로 다운 받아 적용하면 첫 프레임 화면이 스타일 적용이 안된 채 보여질 것입니다. 그래서 중요(Critical)한 CSS를 Inline으로 HTML에 함께 보내주게 됩니다. 그리고 이를 Critical CSS라고 합니다. NextJs 환경에서 Critical CSS를 적용하기 위해서는 build후 HTML을 파싱하여 CSS를 불러오는 부분을 해당 Inline CSS로 바꾸는 방법을 사용합니다. 위 고슴도치는 Critical CSS 적용하는 라이브러리 중 하나인 Beasties(구 Critters)의 마스코트입니다. 그리고 아래 postbuild에 tailwind 처리 후 만들어진 CSS를 inline CSS로 바꿔주는 코드와 vercel 배포 시 출력 되는 로그입니다. Postbuild.js import Critters from "beasties";; import fs from "fs"; import path from "path"; const critters = new Critters({ path: ".next/static/css", publicPath: "/_next/static/css/", inlineFonts: true, preloadFonts: false, preload: "media", }); function fromDir(startPath, filter) { if (!fs.existsSync(startPath)) { throw new Error("빌드 폴더 위치 맞나요?") } else { const files = fs.readdirSync(startPath); Promise.all( files.map(async file => { const filename = path.join(startPath, file); const stat = fs.lstatSync(filename); if (stat?.isDirectory()) { fromDir(filename, filter); } else if (filename.endsWith(filter)) { const inlined = await critters.process( fs.readFileSync(filename, "utf8"), ); fs.writeFileSync(filename, inlined); } }), ); }} const promise = new Promise(() => fromDir(".next/server/app", ".html")); promise.then(() => console.log("완료")); 현재 이 방법은 Vercel 서버 PageRouter 사용자만 가능합니다. AppRouter 사용자는 Inline CSS 사용을 위해서 Custom Server를 사용해야 합니다.(tailwind 사용 기준. 스크립트는 실행하나 적용은 안됨.) 저 Vercel Server+ App Router 사용 중이라 위 Postbuild.js 스크립트로 Inline CSS를 적용할 수 없었습니다. InlineCSS기능은 현재 experiment 기능으로 존재하고 있으며 정식 기능이 될 때 적용할 예정입니다. 만약 사용하고 싶으시면 아래 next.config.ts 같이 사용하시면 됩니다. next.config.ts import type { NextConfig } from 'next' const nextConfig: NextConfig = { experimental: { inlineCss: true, }, } export default nextConfig 그래서 저는 직접 Layout에서 중요한 스타일들을 추출해서 style태그에 넣어줬습니다. 지금은 이렇게 수동으로 넣어놨지만, 추후 실험기능인 inline css 기능이 정식으로 출시되면 적용할 예정입니다. 아래 Layout에서 html에 바로 style태그를 넣어줄 수 있게 만든 컴포넌트 입니다. HtmlHeadRegistry.tsx "use client"; import {useServerInsertedHTML} from "next/navigation"; import {PropsWithChildren} from "react"; export default function HtmlHeadRegistry({children}: PropsWithChildren) { useServerInsertedHTML(() => { return ( <> {` @base {span, p { font-size: 1rem; line-height: 1.5; } html { font-size: 16px; @media (width ); }); return <>{children}; } 1.2 CLS 개선과 프레임 개선 위에서 내린 결론 중에 아직 3번째 결론을 해결하지 못했습니다. 3번째 결론은 리플로우가 어떠한 원인으로 일어나는지 성능 분석 도구로 파악하면 쉽습니다. 우선 리플로우에 대해 설명하자면 브라우저가 변경된 style을 화면에 반영할 때, 요소의 크기, 위치, 구조가 변하는 경우에 리페인트와 함께 일어납니다. 색깔만 바뀌는 경우 리페인트만 일어나는 경우와 다르게 1단계 더 일어나 자원 소모가 더 큽니다. 리플로우는 성능에도 영향을 주지만, 급작스러운 위치 변동으로 사용자 경험에도 악영향을 줍니다. 하지만 이를 해결하기 위한 방법은 간단합니다. 위치 변동이 예상되는 경우 미리 width, height를 적용해 주는 것입니다. image에 width height 정보를 넣어주는 이유에도 해당합니다. 아래 Flex 박스에서 CSS 자동계산으로 Reflow가 일어나는 경우와, 미리 예상하고 width/height를 걸어둔 경우를 비교해 봤습니다. LCP, CLS가 확실이 달라지는 것을 수치로 확인할 수 있습니다. 자동 CSS 계산의존 Static 하게 Height 설정 ㅇ 원인 분석을 하기 위해 CLS를 일으키는 지점 2부분에 ["By 글꼴"], ["자동 CSS 계산"] 이라고 라벨링한 모습을 가장 아래 사진에서 확인할 수 있다. 글꼴에 의한 CLS 증가만 존재한다. 왼쪽은 자동 CSS 계산으로 출력할 때 데이터고 오른쪽은 이를 해결하기 위해 Static하게 Width/height 설정하여 레이아웃 변화가 없게 만들었을 때 데이터 입니다. CLS를 일으키는 지점이 글꼴의 변경 1개로 줄어들었습니다. 이후 글꼴의 변경으로 인한 레이아웃 변화도 해결했습니다. 엄선한 쿠키런 글씨체를 썼는데 놓아줬습니다. CLS없이 쿠키런 글씨체를 쓰려면 글꼴 style을 디테일하게 설정 해야하는데 오래 걸릴 것 같아서 대중적인 구글 폰트로 바꿨습니다. 목록을 보여주는 화면같이 동적일 수 밖에 없는 화면에는 flex를 쓰겠지만, 고정적으로 사용한다면 width, height를 설정해달라고 디자이너에게 요청하는 것이 성능과 사용자 사용성에 더 좋을 수도 있다고 생각합니다. 마지막으로 최종 최적화 성능 결과 보여드리겠습니다. FCP와 LCP가 거의 항상 동시에 일어납니다. 위 결과에선 114.78ms에 일어납니다. 따라서 첫 프레임에 렌더링하는 화면은 블로그에서 보여줘야하는 화면과 같아졌습니다. 어쩔 수 없는 맨 처음 1개의 빈 프레임 제외하고는 사용자는 항상 완벽한 화면 만을 빠르게 볼 수 있게 되었습니다. 1.3 이미지 포멧 최적화 결론부터 말하면 92%의 퀄리티로 이미지를 webp로 압축 후 원본 파일과 비교하여 더 낮은 용량의 파일을 업로드 하는 방식으로 만들었습니다. 처음에는 이미지 포멧을 항상 webp로 변경했는데, 막상 용량 비교를 해보니 일부 색이 다양한 JPG나 투명한 부분이 많은 PNG는 오히려 webp보다 용량이 작은 경우가 많았기 때문입니다. AVIF가 압축률이 더 좋지만, webp를 사용한 이유는 그만큼 렌더링 하는데 연산량이 많아지고 아직 AVIF는 지원하지 않는 경우가 있어서 사용하지 않았습니다. 1.4 성능 툴로 원인 파악하여 개선한 다른 내용 사실 위에서 소개한 분석 전에도 성능을 개선했는데, 그때 자료를 안 남겨서 개선했던 내용 중 흥미로운 한 가지 요약해서 설명드리겠습니다. CSP 설정으로 Nonce 사용하여 스크립트를 실행하게 만들었었는데, Script마다 Nonce값 주입이 필요하니 다이나믹 렌더링으로 생성된다는 것을 생각하지 못했습니다. 그래서 엄청 사이트가 느려졌고, 그 원인을 성능 분석을 통해 알게 되어 빠른 CSP를 구현할 다른 방법을 찾고있습니다. 이번에 소개할 것은 느린 네트워크 화면에서도 화면 깨짐을 최소하하며 렌더링 할 수 있도록 개선한 방법입니다. 안정된 환경에서는 CSS까지 받아와서 화면을 그리겠지만, 공공장소에서는 순간적으로 네트워크가 불안정한 경우 잠깐 CSS가 적용 안된 화면이 보이거나 전체 레이아웃이 뒤틀리면서 피로감을 느끼는 경우를 경험한 적이 있으실 겁니다. 이 경험을 최소화하기 위해서는 Critical CSS을 사용하면 됩니다. 사실 1.1 내용과 같은 방법을 적용하면 됩니다. 아래 두 사진은 네트워크가 불안정하여 HTML만 받을 경우를 비교한 사진입니다. 순수 Tailwind 4 + Next15(App Router) Tailwind 4 + Next15(App Router) + Critical CSS 왼쪽은 CSS파일과 HTML은 따로 받는 방법, 오른쪽은 중요한 CSS는 Inline CSS로 HTML에 함께 받는 방법을 적용한 결과입니다. 왼쪽 화면은 배경이 CSS 적용이 안되어서 맨 위에 플레이어가 나오지만, 오른쪽은 일부 CSS가 바로 적용되어 GNB, SNB, LOGO가 출력되고 있습니다. 왼쪽 화면은 네트워크가 안좋아 잠깐 나타난다면 전체 화면이 리플로우를 일으키며 UX적으로 나쁜 경험을 줄 확률이 높지만 오른쪽은 이미 레이아웃이 어느 정도 잡혀있어 괜찮습니다. 그리고 우측 방법을 사용하면 네트워크가 나쁘더라도 화면이 뜰 경우 최소한 사용할 정도로 만들 수 있습니다. 즉, Critical CSS의 장점으로 inline CSS를 조절하여 최소한 보여줄 화면의 스펙을 결정할 수 있다는 것입니다. 그렇다면 항상 Critical CSS를 사용해야 할까 궁금하여 저도 비교한 자료를 찾아봤습니다. 2.1 Only CSS vs Inline CSS vs Inline Style HTML + CSS Sheet HTML+ Inline CSS HTML + Inline Style HTML에 바로 Style이 적용되게 하는 방법은 Inline CSS와 Inline Style이 있습니다. 위 가운데와 우측 사진이 그 예시 입니다. 그러면 "어떤 방법이 가장 좋을까?" 라고 물을때, 결론부터 말하면 환경(디바이스, 네트워크, 브라우저 등..)에 따라 다르고, 개발자가 어느 정도 Critical CSS나 Inline Style로 적용할지에 따라 달라집니다. HTML 사이즈비교 SSR HTML 렌더링 시간 브라우저 렌더링 시간 CSS 이제 로딩시 흰화면 출력을 제거했습니다. 다음으로 새로고침 시 스크롤 위치에 맞는 화면과 배율 출력 해결 방법을 소개드리겠습니다. 3.2 스크롤 위치에 맞는 화면과 맞는 배율 보여주기 위 3.1 방법으로 하얀 화면은 막았지만 새로고침시 여전히 배경이 야경이여야 하는데 아침을 출력하거나, Scroll 위치와 안 맞는 배율의 화면을 출력하는 문제가 발생합니다. 저의 화면 전환 방식은 Scroll 위치에 따라 배율이 계산되고, 특정 위치에서 src가 바뀌는 방식입니다. 이를 해결하기 위해서는 새로고침시 같은 스크롤 위치를 기억하여 사용할 수 있어야 합니다. 그래서 unload 이벤트와 sessionStorage를 활용하여 위치를 기억하고, useLayoutEffect를 사용하여 스크롤 위치가 DOM에 반영된 상태로 렌더링 되도록 의도하였습니다 아래가 그 예시 코드입니다. ScrollVideo.tsx 'use client' export const ScrollVideo = () => { const [videoIndex, setVideoIndex] = useState(0); useLayoutEffect(() => { // 첫 렌더링에만 실행되는 Effect // SessionStorage에 scrollY 저장하는 함수. function storeScrollY() { sessionStorage.setItem("last-scroll-y", window.scrollY.toString()); } // reload했다면, last-scroll-y가 존재함. const lastScrollY = sessionStorage.getItem("last-scroll-y"); => 첫 렌더링 시 lastScrollY 존재하면 scale값 계산후 반영. // scrollY 변화에 따라 video src를 교체 scrollY.on("change", y => { flushSync(() => (y >= 400 ? setVideoIndex(1) : setVideoIndex(0))); }); // unload시 scrollY를 Session Storage에 저장. window.addEventListener('unload',storeScrollY); return () => { window.removeEventListener('unload',storeScrollY); scrollY.destroy(); }; }, []); return ( ); }; 하지만 실제로 위 방식대로 코드를 실행해도 여전히 아주 잠깐 흰 화면이 출력됩니다. 왜냐하면 HTML 파일을 받으면 컴포넌트들은 서버에서 만들어진 HTML placeholder 값대로 출력하고 그 다음 hydration이 일어나기 때문입니다. 그러면 어떻게 반영해야할 까요?? 저는 고민 끝에 React DOM이 생성되는 것 보다 먼저 반영되어야 한다고 결론 내렸습니다. 하지만 저에게 생각나는 방법은 Hydration Rule을 어겨야만 합니다. 그래도 어쩔 수 없다고 생각했습니다. 더 좋은 방법이 있을 것 같지만.. 저로서는 생각이 나지 않았습니다. 그래서 html에 아에 script태그로 스크롤 위치를 저장하는 이벤트를 추가하고, hydration전에 DOM에 바로 src를 바꿔서 껴주는 방법을 선택했습니다. 아래는 그 예시 코드와 실제 scrollPosition이 저장되는 저장소 사진입니다. RootLayout.tsx 'use client' export const ScrollVideo = () => { return ( <> ); } 이렇게 하면 hydration 전에 video 소스와 포스터도 바꾸고, scale값도 바꿉니다. 첫 화면 그리기도 전에 반영시킵니다. 결론은 엄청 잘 작동했습니다. 하지만 이렇게 해도 충분하지만, 원래 작동하던 시간과, 그때의 화면을 poster로 그려보고 싶어집니다. 그래서 한번 시도해 봤는데요. 따로 코드로는 적지 않고 방법과 결론만 말씀드리겠습니다. unload시에 canvas 객체를 생성해서 마지막 배경화면을 데이터로 sessionStorage에 저장하고, 동영상 재생시간도 저장했습니다. 그리고 새로고침하면 그대로 화면에 보이게 만들었습니다. 잘 작동은 합니다. 다만, 컴퓨터의 한계로 살짝 멈칫 멈칫한 것은 없앨 수 없었습니다. 굳이 이렇게 구현해도 사용자 경험에 달라지는 것이 없으니 리소스 낭비라고 판단했습니다. 그래서 poster와 재생시간까지 저장하여 활용하는 코드는 삭제했습니다. 자세한 설명은 에디터 글에서 했었기 때문에 문제 원인과 해결 방법을 간단히 설명하고 넘어가겠습니다. Viewer로 사용자에게 보여줄 때, TOC를 생성하게 됩니다. 그런데 이미지 로딩이 있으니 처음에 레이아웃 계산이 자꾸 바뀝니다.(이미지 width, height 설정하면 덜합니다) 그래서 TOC 스크롤 위치 계산하는 동안 해당 Element 위치가 바뀌니 부정확한 TOC가 됩니다. 그래서 ResizeObserver와 Debouncing을 통해 100ms 이상의 시간 동안 Size가 바뀌지 않으면 그때 TOC를 출력할 수 있도록 최적화 하며 해결했습니다. 지금까지는 저의 컴퓨터 사용 환경에서만 성능 개선을 하였습니다. 하지만 실제 서비스 중일 때는 다양한 환경의 사용자들이 이용을 하고, 그 환경에 따라 다른 결과가 나올 것입니다. 심지어 그 환경에 따라 에러가 발생하기도 합니다. 그래서 실 사용자들의 성능 점수도 체크해보는 것이 좋습니다. 실 사용자들의 성능 점수를 확인하는 방법은 다양합니다. Vercel 서버에서 제공하는 Speed Insights를 사용할 수 있고 , Sentry같은 에러 로그 시스템에서 확인할 수도 있습니다. 저는 오류의 자세한 분석과 개선이 필요하여 Sentry를 사용하여 성능 분석도 함께 했습니다. Sentry의 성능 점수도 서버 네트워크나 트래픽 상황에 따라 성능 점수가 계속 바뀌고 그 변화를 시간대별로 확인할 수 있습니다.. 아래 YoonCarrot 블로그의 Sentry 성능 모니터링 화면 사진입니다. Next 프론트엔드의 FCP, LCP, CLS, INP, TTFB 같은 값을 1시간 동안 평균이 어느 정도 인지 알 수 있습니다. 그리고 페이지 별로 상세하게 확인할 수도 있습니다. 또한 Next 서버에서 캐싱한 데이터나 페이지를 언제 Revalidate했는지도 모니터링 가능합니다. 그리고 아래 사진은 페이지별 FCP, LCP 같은 값을 확인할 수 있는 사진입니다. Sentry에서는 [어떤 페이지]에서 [어떤 브라우저]로 [어떤 환경]에서도 항상 좋은 성능을 보여주는 지 확인할 수 있습니다. 이 데이터들로 저는 [어떤 페이지]에서 [어떤 브라우저]가 안 좋은 성능을 보여준다면 바로 해당 브라우저를 사용하여 더 빨리 문제점을 찾을 수 있습니다. 처음에는 Spring과 OAuth 2.0을 사용하여 로그인을 구현했습니다. 이미 레퍼런스가 많아서 구현하기 쉽지만 OAuth 2.0으로 코드를 전달하는 과정에서 리다이렉트가 필수적으로 일어날 수 밖에 없었습니다. 왜냐하면 구글 로그인 후 url path를 통해 리다이렉트로 인증 Code를 백엔드에서 받아서 Authorization 토큰을 발급하여 api로 사용자 정보를 받기 때문입니다. 그래서 리다이렉트가 사용자 경험을 해친다고 생각하여 Redirect없는 로그인을 위해서 OAuth2.0대신 Authorization과 Authentication이 분리되어 프론트단에서 인증만 하고 인증 데이터를 post로 넘겨 받을 수 있는 구글 sdk를 사용했습니다. 프론트엔드 구현은 쉬운데 백엔드는 완전히 커스텀한 로그인이 되어버려 자바 스프링시큐리티의 AbstractAuthenticationProcessingFilter를 써야해서 구현해야할 것들이 너무 많았습니다. 만약 협업으로 로그인을 이렇게 구현한다고 하면 반드시 백엔드에게 양해를 구해야겠다는 생각을 했습니다. 물론, 이 SDK도 OAuth2.0 로그인되게 리다이렉트 가능은 합니다. 아무튼 실제 적용해보니 redirect가 없어서 깜박임 없는 화면이 제공되어 더 편안하게 화면을 볼 수 있었습니다. 다만 깜박임이 없어 로그인 했는지 인지하지 못할 수 있어 로딩 표시 같은 기능을 추가할 예정입니다. 컴포넌트 단위 CSS 파일 적절한 분리 컴포넌트 스트리밍 목록 등 필요한 부분에 적용 디바이스에 따른 이미지 출력 최적화(저장 포함) SEO를 위한 subscription 길이 요약하여 줄이기 Client단 ReactQuery 데이터 캐싱 Next서버 컴포넌트 스트리밍 데이터 ReactQuery 캐싱 디자인 시스템 정비(Space, Font 등) 스크롤 이벤트 관리 중앙화(Provider 생성) Font Color와 배경 시인성 개선
2025-03-270290 - NextJsProseMirrorShiki
[블로그 소개] 프론트엔드 - 에디터
[블로그 소개] 프론트엔드 안녕하세요? 첫 번째 블로그 글입니다. 읽어주셔서 감사합니다. 저는 직접 홈페이지를 운영해보고 싶었고, 프로그래밍 하면서 고민하고 해결한 것들을 기록하고 공유하고 싶어 블로그를 만들었습니다. 화학공학과 찐 이과 출신이라 많이 글을 못씁니다. 쓴 글을 수정해가며 더 효율적으로 지식을 공유할 수 있도록 노력하겠습니다. 함께 더 쉽고 높게 성장할 수 있으면 좋겠습니다. 에디터를 만드는데 1달 가까이 사용할 정도로 공들였습니다. Toast UI, Editor.js, React-md-editor,Tiptap까지 커스텀 플러그인으로 원하는 기능을 추가 해보고 사용성과 확장성을 확인할 정도로 진심 이였습니다. 저에게 간택된 블로그 에디터는 ProseMirror 엔진 기반의 Tiptap 에디터입니다. 특징은 확장성이 엄청나지만, 그만큼 작은 기능도 많은 코드가 필요하다는 점입니다. 그리고 Viewer를 JSON과 Text 형식 모두 SSR로 렌더링할 수 있어서 NextJs, 백엔드와 찰떡궁합 에디터입니다! (※ 간단한 기능의 확장만 필요하면 플러그인이 많은 다른 에디터 강력 추천) 에디터 SSR 확장성 엔진 WYSIWYG Markdown RIch Text 기타 editor.js 🟡 中上 자체 ✅ 🟡 ✅ 노션, Json, 문서 좋음, 추천 react-md-editor ❌ 中 자체 ❌ ✅ ❌ 이지윅 때문에 사용성 떨어짐 toast UI ❌ 下 ProseMirror ✅ ✅ ✅ 문서화가 나쁨, 플러그인 적음. quill 🟡 中上 Delta ✅ 🟡 ✅ 문서 좋음, JSON, 추천. TipTap ✅ 中上 ProseMirror ✅ 🟡 ✅ 기능 잘 나뉨. 문서 보통. 추천. ProseMirror ✅ 上 자체 ✅ ❌ 🟡 문서 최고. 러닝 커브. 힘듬. Toast UI Editor, Editor.js, React-md-editor는 내가 생각한 블로그 설계와 적합한지 확인하기 위해 사용했고 quill은 회사에서 사용해봤습니다. 모두 나름의 장단점이 있고 상황에 따라 적절한 에디터를 고르면 된다고 느꼈습니다. 하지만 react-md-editor와 toast UI에디터는 개인적으로 비추천합니다. react-md-editor는 이지윅이 안 돼서 만약 고객에게 제공할 에디터라면 엄청 구식으로 보일 것입니다. 그리고 toast-ui v3.0은 다운로드 수에 비해 플러그인이 적었고 문서화도 커스텀 기능을 추가하기엔 매우 부족했습니다. 위 두 에디터 제외하고는 전부 좋았고 각자 강점과 특색이 있었습니다. editor.js는 노션으로 유명한 에디터입니다. 하지만 막상 사용해보면 노션과 달리 부자연스럽게 느껴졌습니다. 더 매끄럽게 작동하도록 만들려면 많은 플러그인을 추가해야할 것 같습니다. 문서화는 깔끔해서 제대로 만들면 엄청 좋은 에디터가 만들어 질 것 같습니다. quill 에디터는 탄탄한 느낌의 에디터입니다. 일반적으로 필요한 기능의 플러그인은 다 갖췄고, 탄탄하게 설계되었으니 만약 회사에서 고객에게 에디터를 제공해야 한다면 '이 에디터가 맞아!' 라고 말할 것 같은 에디터입니다. 문서화도 잘 되어있어서 커스텀 기능 추가도 수월해 보입니다. Tiptap 에디터는 문서화는 평범하지만 ProseMirror를 엔진으로 사용하며 그 구조를 거의 그대로 사용합니다. 그래서 문서가 부족해서 플러그인 추가에 어려움이 있을 수 있지만 ProseMirror가 자세히 문서화 되어있어서 그 문서를 참고할 수 있습니다. 하지만 ProseMirror는 기능 추가에 많은 노력이 필요합니다. 예를 들면 글자를 굵게 만드는 기능을 추가하고 싶으면 Schema, Command, HTML Render 방식, parse방식 등 모두 설정해야 합니다. 그래서 TipTap의 플러그인들을 사용한다면 기본 기능들은 굳이 어렵게 구현할 필요가 없기 때문에 서로 상호보완적인 면이 있습니다. 만약 회사에서 이 에디터를 사용한다면 그 이유는 최고의 제품을 위해 커스텀한 기능이 많이 필요하기 때문일 것입니다. 위 장단점은 모두 제 개인적인 의견이고, 실제 사람마다 생각은 다를 수 있으니 참고 용으로 봐주셨으면 좋겠습니다. 추가적으로 저가 TipTap을 이용한 또 다른 이유는 서버에서 TEXT, JSON 데이터 모두 HTML로 서버에서 바꿀 수 있다는 점입니다. 반면 quill과 editor.js는 window객체를 참조하고 있어 서버에서 JSON을 HTML로 파싱은 가능하지만 TEXT(원본)는 불가능합니다. 서버에서 데이터를 바꿀 수 있다는 것은, Sanitize(HTML 소독)을 백엔드에서 더 수월하게 할 수 있고, 저장 데이터 형식에서도 자유로워 집니다. 저의 에디터에 필요한 기능들을 정리해 봤습니다. 기본적인 글씨체 변경, 문단 정렬, 테이블 기능, 이미지 업로드 등은 필수였고, 프로그래밍 개발자 블로그로서 다양한 언어를 지원하는 Code Syntax Highlighter가 가장 중요합니다. 이 외에 평소 쓰는 마크다운 문법, S3에 업로드하여 URL을 받는 기능, 사용자를 위한 TOC 기능이 에디터에 들어갈 기능입니다. 기본 텍스트 편집 기능 글씨체 변경 (굵게, 밑줄, 기울임, 색깔, 배경색) 글머리 기호, 번호 기호, 체크 리스트 문단 좌, 우, 가운데 정렬 마크다운 문법 지원 (#적고 띄우면 h1 태그.. 언어" 라고 입력하면 해당 언어에 맞는 블록 자동 생성 VS Code에서 복붙시 클립보드 MIME 정보로 해당 언어에 맞는 코드블럭 자동 생성. 별도 CSS 추가 적용. CodeHighlighter의 파싱없이 그대로 출력 가능. (별도 데이터 가공 필요X) const a = 1; const b = 2; example error : b is not initialized. function add(num1, num2) { return num1 + num2; } const answer = add(1, 2); include int main() { int arr = new int*[100]; // 행 포인터 배열 동적 할당 for (int i = 0; i < 100; ++i) { arr[i] = new int[100](); // 각 행을 동적 할당 및 0으로 초기화 } arr[0][0] = 1; std::cout << arr[0][0] << std::endl; // 메모리 해제 for (int i = 0; i < 100; ++i) { delete[] arr[i]; } delete[] arr; return 0; } 코드 블럭을 보면 언어에 맞게 예약어와 변수들이 강조된 것과 별도로 형광팬이 칠해진 모습을 볼 수 있습니다. Syntax에 맞게 코드를 보여주는 기능과 theme에 맞는 UI를 보여주는 기능은 Shiki라는 Syntax Highlighter를 사용했습니다. Shiki를 사용한 이유는 다른 Syntax Highlighter와 달리 Line 단위로 묶어서 파싱하며, 100개 이상의 언어를 처리할 수 있기 때문입니다. Line단위로 묶어 파싱되는 것이 좋은 이유가 궁금할 수 있는데, 순수 CSS로 Line Number 출력이 가능하기 때문입니다. 이 기능을 Toast UI Editor 등 다양한 에디터에 녹여보았지만 에디터에 맞게 녹인 느낌이 아니라 따로 노는 느낌이였습니다. 왜냐하면 에디터와 한몸처럼 작동하도록 녹일 방법을 못 찾았기 때문입니다. 하지만 ProseMirror는 확장성이 좋아서 위 기능을 에디터와 한 몸처럼 작동하게 녹일 방법이 보였고, ProseMirror를 더 제대로 공부하게된 계기가 되었습니다. 저가 말하는 에디터와 한 몸처럼 작동한다는 것은 별도 데이터 처리 없이 Text 데이터 그대로 수정할 때 사용할 수 있다는 것을 의미합니다. 별도로 처리를 하면 에디터 형식이 에디터 형식과 달라져서 수정할 때 사용하지 못하기 때문에 이중으로 데이터 보관이 필요하게 됩니다. 이 기능을 에디터에 녹일 때 Tiptap으로 추가한 플러그인들과 얽히고 설켜 어디서 잘못된건지 파악하는 것이 가장 힘들었습니다. 그러다 보니 어떤 라이프 사이클로 돌아가는지 더 자세히 알게 되었고 다른 사람들이 다양한 플러그인 코드들을 보면서 공부하게 되었습니다. 이 과정으로 알아낸 것을 통해 Highlighter가 강조한 텍스트를 ProseMirror Mark(서식 객체)로 변환하여 적용했고, Schema 정의가 충돌하지 않도록 정의했습니다. 그리고 Line마다 Span 태그와 className을 추가하여 css로 line number를 더 수월하게 처리할 수 있도록 하여 Syntext Highlighter와 Editor가 한 몸처럼 동작하도록 만들었습니다. ProseMirror Mark로 실시간 변환해줬기 때문에, 다른 Mark(서식)도 추가할 수 있게 되어서 형광팬 등의 기능을 코드 블럭에 추가할 수 있게 만들 수 있었습니다. 이 기능을 정상 작동시킬때 까지 2주가 넘게 소요되어서 완성할 때 얼굴에 미소가 자동으로 생길 정도로 기분이 좋았고 앞으로 ProseMirror로 더 많은 것을 능숙하고 빠르게 만들 수 있다는 것에 설레었습니다. 코드블럭에 형광팬, 밑줄 같은 서식을 적용할 수 있는 텍스트 에디터는 찾기 힘듭니다. 그리고 코드 블럭 기능 만을 위해 2주 넘는시간을 투자하여 완성한 기능입니다. 이 기능의 구현은 저에겐 엄청 값진 경험이고 앞으로 더 잘할 수 있는 원동력이 된 것 같습니다. 2.2 Viewer의 서버 사이드 렌더링 Viewer의 서버 사이드 렌더링(SSR) 때문에 여러 에디터를 사용하며 많은 시행착오를 겪었습니다. SSR이 가능해야만 NextJs에서 캐싱하여 빠르게 사용자에게 보여줄 수 있기 때문입니다. 저가 생각한 Viewer의 SSR 구현은 아래와 같습니다. Client 단에서 HTML로 파싱하여 Editor데이터와 출력용 데이터 Server에 전달하기. Editor 데이터와 화면 출력용 데이터로 2중으로 보관된다. 에디터 기능 일부가 수정 되었을 때, HTML을 정적으로 별도 보관하기 때문에 자동 반영되지 않는다. 서버는 HTML 그대로 보내주면 된다. 서버는 클라이언트가 보낸 정적 HTML에 대한 Sanitize만 가능하다. Server에서 Editor의 데이터로 직접 Parsing후 출력하기. 에디터 일부 기능 변경 시, 해당 변경 사항에 맞게 HTML을 그리기 때문에 자동 반영된다. 데이터를 2중으로 보관할 필요가 없다. 서버에서 파싱하기 때문에 서버에 부담을 줄 수 있다. Server에서 변경된 에디터 기능으로 HTML 렌더링이 달라져도 Sanitize 할 수 있어 보안이 훨씬 강화된다. Server에서 Editor 데이터로 파싱하여 HTML을 만든 후, 2중으로 보관하며, 에디터 기능 업데이트 시 HTML 갱신하기. 에디터 일부 기능 변경 시, 해당 변경 사항에 맞게 HTML을 그리기 때문에 자동 반영된다. 서버는 HTML 그대로 보내주면 된다. 변경된 HTML에 대한 Sanitize가 가능하다. 데이터는 2중으로 보관된다. 1번 방법은 에디터가 SSR이 불가능할 때 사용할 방법이며, 클라이언트가 서버 Sanitize가 방어하지 못하는 방식으로 직접 HTML을 조작하여 보낼 수 있어 보안에 구멍이 발생할 수도 있고 , 에디터 업데이트로 변경된 파싱 방법으로 만들어진 HTML을 보여줄 수 없을 것입니다. 2번 방법은 더 안전하고, 최신 업데이트 사항을 반영할 수 있지만, 서버에 부담을 줄 수 있기 때문에 적절하게 캐싱하여 보여줄 수 있어야 합니다. 그래서 저는 위 두 가지 장점을 합친 3번째 방법을 채택했습니다. 빠른 속도와, 보안, 안정성을 가질 수 있는 방법이라고 생각합니다. 2.3 이미지 업로드 이미지는 크기 조절과 정렬이 가능하고, 비율 유지 여부도 설정할 수 있습니다. 여러 플러그인 코드들을 보고 좋은 기능들만 가져와 합쳐서 커스텀했습니다. 사진 파일을 직접 ctrl+v로 입력하면 webp로 압축하고 원본과 비교하여 용량이 작은 파일을 백엔드 서버로 업로드하여 URL을 받아오는 방식입니다. 기존 이미지의 URL을 통해 업로드도 가능합니다. 이미지도 블럭 단위로 복사가 가능하며, 외부 사이트의 이미지를 URL로 올릴 수 있지만, CSP 정책 설정으로 이 사이트에서는 해당 이미지를 로드할 수 없습니다. 2.4 기본 텍스트 편집 기능 우선 화질은 죄송합니다 GIF를 Webp로 만들어도 용량이 커 GIF들은 최대한 색상을 손실시켜 압축했습니다. 기본 텍스트 기능은 대부분 Tiptap 플러그인에서 가져왔고 일부 기능들은 커스텀 했습니다. 예를 들면 Color Palette 중 1개는 커스텀 기능입니다. 모든 기능을 직접 구현했다면 아직도 완성하지 못했을 거라 생각합니다. 많은 기본 텍스트 기능을 제공해준 Tiptap 에디터 관계자 분들에게 감사합니다. 기능 구현에 에너지를 다 써버려서 디자인이 엉망입니다. 디자인은 기능과 사용성 개선과 함께 천천히 개선할 예정입니다. 2.5 테이블 기능 사진1 MPTI 프로젝트 사진2 콘스텔링크 프로젝트 사진3 럭퀴즈 프로젝트 쉽게 온라인 PT를 받을 수 있는 플랫폼 WebRTC로 P2P 화상 채팅 기능 살아 생전 처음 했던 프로젝트라.. 아키텍처조차 없다 ㅜㅜ 누구도 기부 내역과 사용처를 조작하지 못하는 블록체인 기반 치료비 모금 플랫폼 누구나 만들고 하기 쉬운 실시간 대규모 퀴즈 게임 플랫폼 콘스텔링크 아키텍처 럭퀴즈 아키텍처 기본 브라우저의 테이블 태그를 이용하여 만들어졌습니다. 모바일에서는 table 태그가 Width나 Height, 정렬이 제대로 적용되지 않습니다. 그래서 넘치는 경우에는 좌우 스크롤로 볼 수 있도록 개선했습니다. 셀 추가/병합/분리가 가능하고. 1행에 2개의 열을 갖는 것도 가능합니다. 사진 1 아래의 셀이 합쳐진 셀입니다. 2.6 TOC 기존 Heading 플러그인을 수정하여 사용자 정의 Table of Contents(TOC) 기능을 만들기 위해 입력 중, h태그 생성 시 전체 h태그를 찾은 뒤, 계산하여 자동으로 번호가 html id와 data set으로 할당되도록 만들었습니다. 처음에는 간단할 것 같았지만, 실제로 구현해보니 head 태그가 문서 내에서 몇 번째 태그 인지를 전체 문서를 뒤지며 정확하게 계산하는 부분이 까다로웠고, Text 또는 JSON 형식으로 내용을 추출한 후 다시 에디터에서 불러왔을 때 동일한 구조로 나타나기 위해서 HTML로 렌더링하고 파싱하는 부분도 신경써서 수정해야 했습니다. 파싱에서 끝나는 것이 아니라 Client의 화면에서는 스크롤바 쪽에 위치한 TOC를 클릭 시 Head별 위치 값을 계산해 이동할 수 있어야 합니다. 단순히 scrollIntoView를 쓰면 된다고 생각했지만, Sticky 컴포넌트의 존재로 계산을 제대로 못하는 문제가 발생했습니다. 왜냐하면 브라우저 scrollIntoView 함수나 Anchor를 사용하여 컴포넌트로 이동할 때 브라우저 화면 기준 컴포넌트 Y좌표와 + scrollY로 계산하는데 Sticky한 컴포넌트는 이 계산식이 경우에 따라 틀린 계산이 되기 때문입니다. 또 다른 이슈는 이미지의 로딩 속도에 따라 이동할 위치와 의도한 위치가 달라지는 문제입니다. 이 문제는 Debouncing과 ResizeObserver를 사용하여 해결했습니다. Debouncing은 사이즈 변경이 자주 일어나도 1번의 위치 계산만 하기 위함이고, ResizeObserver는 레이아웃 변화 감지를 위해 사용했습니다. 아래는 완성된 TOC 기능 사용 예시입니다. 기타 기능은 지속적으로 업데이트해 나갈 예정입니다. 물론 지금 이 글을 쓰는 순간에도 소개했던 기능들의 디테일한 부분 업데이트를 많이 했습니다. 시간이 날 때마다 반영하겠습니다. 3.1 Iframe (CodePen) 블로그 개선을 위해 블로그를 탐방하다가 필요해보여 추가한 기능입니다. Iframe 때문에 CSP 설정이 조금 약화 됐습니다. Iframe 기능 구현이 어려운 것은 아닙니다. 다만 보안쪽 관련해서 고민이 많은 필요합니다. Iframe을 추가 하기 전에도 느낀거지만 third party 홈페이지를 사용할 때 어떻게 하면 안전하게 사용할 수 있을지 항상 고민이 됩니다. 예를 들면 CSP 설정에서 "반드시 안전하게 사용하려면 이것을 추가해라!!" 라고 하지만 만약 그 설정을 반영하면 사용할 수 있는 라이브러리도 적어집니다. 저의 홈페이지도 안전을 위해 COEP 설정을 안전하게 해뒀는데, third party 사이트를 Iframe에 띄우면서 이 설정을 없애야 했습니다. 저의 백엔드 서버라면 COEP 설정을 원하는대로 할 수 있겠지만 third party 사이트에서 헤더를 맞춰줄 이유가 없기 때문에 저가 맞춰야 했습니다. 이런 부분을 어떻게 해결해야 보안에 문제가 없는지 고민을 만들어준 기능입니다.
2025-03-1602190