[ Introduction > Blog ]
[블로그 소개] 프론트엔드 - 구조 및 설정
[블로그 소개] 프론트엔드 - 아키텍처
블로그를 만들기 위해 여러 번 세팅을 시도했고, 그 과정에서 포기도 여러 번 했습니다. 다양한 시도 끝에 monorepo 구조로 구성하는 방식이 가장 깔끔하게 프로젝트를 나누고 사용하기에도 편하다는 결론에 도달했습니다. 단순히 폴더 구조만으로 기능을 구분하려 했을 때는 폴더가 지나치게 세분화되었고, 그렇다고 패키지들을 repo를 나누려 하면 관리하고 다른 앱에 적용하기 짜증납니다. 특히 에디터 기능을 포함하려다 보니 코드 양이 많아지면서 도메인과 코드가 섞이기 쉬웠습니다. 반면, 하나의 레포에서 모듈별로 분리하는 방식은 자연스럽게 도메인과 코드가 분리되며, 재사용성 및 사용성(개발자 경험)까지 확보할 수 있다는 점에서 확실히 장점이 있다고 느꼈습니다.
1. Micro Frontend 아키텍처와 설정
아래는 블로그 만들 때 사용한 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.jsimport 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";/** * A shared ESLint configuration for the repository. * * @type {import("eslint").Linter.Config[]} * */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 | 빌드 없이 그대로 .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.tsimport { 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.tsimport { 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 - package.json
scripts에 tsc --build로
tsconfig.base.json
rootUrl 제거
compilerOptions에서 composite:true 설정 => incremental이 true가 되고 빌드 시 .tsbuildinfo 생성함
declaration 관련 설정들 추가
root - tsconfig.json
파일 추가
references 필드 추가
app - tsconfig.json
declaration, outDir 필드 제거
references 필드 추가
packages - tsconfig.json
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.tsimport { 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 - package.json
private 필드 추가
package를 dependency에 추가
app - tsconfig.json
baseUrl 필드 추가
package - package.json
private 필드 추가 (단독으로 사용 가능)
exports 필드 추가
package - tsconfig.json
composite 필드 추가
declaration 필드 추가
main, type 추가
workspace 추가
pnpm은 pnpm-workspace.yaml 파일
npm, yarn은 root - package.json의 workspaces 필드 추가.
결론
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.yamlpackages: - 'apps/*' - '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 - package.json
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/* => ./dist/*로 변경(빌드된 파일) "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": { // 추가된 필드 "#*": "./src/*" // 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": { "#*": ["./src/*"] // 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.jsimport {nextJsConfig} from "@yooncarrot/eslintConfig/next";/** @type {import("eslint").Linter.Config} */export default nextJsConfig;// esLintPackage 패키지의 base.jsexport 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.jsexport 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", }, },];
2. 내부 패키지 컴파일 전략
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를 통해 패키지를 다운받아 사용하는 방식이 됩니다.
3. 폴더 구조
폴더구조는 일반적으로 많이 사용하는 구조(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