Package manager: pnpm 9+
Task runner: Turborepo
Apps: CRM (React 17), WMS (React 17), 학사(React 17), 테스트통합(React 19)
Backend: Spring Boot + iBatis(MyBatis) + MariaDB
Workspace style: apps/*, packages/*, services/*
이 문서는 즉시 복제 가능한 보일러플레이트와 현업용 운영 가이드를 함께 제공합니다. 아래 트리와 파일들을 그대로 생성하면 로컬에서 전부 동작합니다.
1) 디렉터리 구조
vinci-monorepo/
├─ apps/
│ ├─ crm-r17/ # React 17 (Vite)
│ ├─ wms-r17/ # React 17 (Vite)
│ ├─ haksa-r17/ # React 17 (Vite)
│ └─ test-suite-r19/ # React 19 (Vite)
│
├─ packages/
│ ├─ shared-utils/ # React 비의존 순수 TS 유틸
│ ├─ ui-r17/ # React17용 UI 컴포넌트 패키지
│ └─ ui-r19/ # React19용 UI 컴포넌트 패키지
│
├─ services/
│ └─ backend/ # Spring Boot + iBatis(MyBatis) + MariaDB
│ ├─ src/main/java/com/vinci/app
│ ├─ src/main/resources
│ └─ pom.xml
│
├─ infra/
│ └─ docker-compose.yml # MariaDB + Adminer
│
├─ turbo.json
├─ pnpm-workspace.yaml
├─ package.json
├─ tsconfig.base.json
├─ .npmrc
└─ .gitignore
2) 루트 설정 파일
2.1 .gitignore
node_modules
.pnpm-store
.vscode
.idea
.DS_Store
.env*
dist
coverage
*.log
**/.turbo
2.2 .npmrc
strict-peer-dependencies=false
prefer-workspace-packages=true
auto-install-peers=true
2.3 pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "services/*"
- "infra"
2.4 package.json (루트)
{
"name": "vinci-monorepo",
"private": true,
"packageManager": "pnpm@9.0.0",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"typecheck": "turbo run typecheck",
"db:up": "docker compose -f infra/docker-compose.yml up -d",
"db:down": "docker compose -f infra/docker-compose.yml down -v"
},
"devDependencies": {
"typescript": "5.5.4",
"turbo": "^2.1.3"
}
}
2.5 tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"baseUrl": ".",
"paths": {
"@utils/*": ["packages/shared-utils/src/*"],
"@ui-r17/*": ["packages/ui-r17/src/*"],
"@ui-r19/*": ["packages/ui-r19/src/*"]
}
}
}
2.6 turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"cache": true,
"dependsOn": ["^build"],
"outputs": ["dist/**", "build/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"cache": true
},
"test": {
"cache": true,
"outputs": ["coverage/**"]
},
"typecheck": {
"cache": true
}
}
}
3) 인프라 (MariaDB + Adminer)
3.1 infra/docker-compose.yml
services:
mariadb:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD: 9909
MARIADB_DATABASE: itf
MARIADB_USER: root
MARIADB_PASSWORD: 9909
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
adminer:
image: adminer
ports:
- "8081:8080"
depends_on:
- mariadb
volumes:
db_data:
4) 공유 패키지
4.1 packages/shared-utils/package.json
{
"name": "@vinci/shared-utils",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"typescript": "5.5.4"
}
}
packages/shared-utils/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
packages/shared-utils/src/index.ts
export const formatMoney = (n: number) => new Intl.NumberFormat().format(n);
export const sleep = (ms: number) => new Promise(res => setTimeout(res, ms));
4.2 React 버전 분리를 위한 UI 패키지
서로 다른 React 메이저(17 vs 19)가 공존하므로 UI 패키지를 버전별 분리합니다.
packages/ui-r17/package.json
{
"name": "@vinci/ui-r17",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"peerDependencies": {
"react": "^17",
"react-dom": "^17"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"typescript": "5.5.4"
}
}
packages/ui-r17/src/Button.tsx
import React from 'react';
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'ghost';
};
export const Button: React.FC<ButtonProps> = ({ variant = 'primary', ...props }) => (
<button {...props} className={`btn ${variant}`.trim()} />
);
packages/ui-r17/src/index.ts
export * from './Button';
packages/ui-r19/package.json
{
"name": "@vinci/ui-r19",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"peerDependencies": {
"react": ">=19 <20",
"react-dom": ">=19 <20"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"typescript": "5.5.4"
}
}
packages/ui-r19/src/Button.tsx
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'ghost';
};
export function Button({ variant = 'primary', ...props }: ButtonProps) {
return <button {...props} className={`btn ${variant}`.trim()} />;
}
packages/ui-r19/src/index.ts
export * from './Button';
5) React 앱들 (Vite 기반)
모든 앱은 Vite + React Router. React 17 앱은 react@17, react-dom@17, @types/react@17, @types/react-dom@17를 사용합니다. React 19 앱은 타입 내장으로 @types/* 불필요.
공통 Vite 헬퍼
각 앱 폴더에 동일한 구조를 사용하며 필요한 부분만 버전별로 다르게 지정합니다.
예시 파일 목록 (각 앱 공통)
apps/<app-name>/
├─ index.html
├─ package.json
├─ tsconfig.json
├─ vite.config.ts
└─ src/
├─ main.tsx
├─ App.tsx
├─ routes.tsx
└─ pages/
├─ Home.tsx
└─ About.tsx
5.1 CRM (React 17) — apps/crm-r17
package.json
{
"name": "@vinci/crm-r17",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "echo linting crm...",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"react": "17.0.2",
"react-dom": "17.0.2",
"react-router-dom": "6.26.2",
"@vinci/shared-utils": "workspace:*",
"@vinci/ui-r17": "workspace:*"
},
"devDependencies": {
"@types/react": "17.0.73",
"@types/react-dom": "17.0.25",
"@vitejs/plugin-react": "4.3.1",
"typescript": "5.5.4",
"vite": "5.4.8"
}
}
tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"outDir": "dist"
},
"include": ["src", "vite-env.d.ts"]
}
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { port: 5173 }
});
index.html
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CRM (React 17)</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import routes from './routes';
const router = createBrowserRouter(routes);
ReactDOM.render(<RouterProvider router={router} />, document.getElementById('root'));
src/routes.tsx
import React from 'react';
import App from './App';
import Home from './pages/Home';
import About from './pages/About';
const routes = [
{
path: '/',
element: <App />,
children: [
{ index: true, element: <Home /> },
{ path: 'about', element: <About /> }
]
}
];
export default routes;
src/App.tsx
import React from 'react';
import { Outlet, Link } from 'react-router-dom';
import { Button } from '@vinci/ui-r17';
export default function App() {
return (
<div>
<h1>CRM (React 17)</h1>
<nav>
<Link to="/">Home</Link> | <Link to="/about">About</Link>
</nav>
<Button onClick={() => alert('CRM!')}>Action</Button>
<Outlet />
</div>
);
}
src/pages/Home.tsx
import React from 'react';
export default () => <div>CRM Home</div>;
src/pages/About.tsx
import React from 'react';
export default () => <div>About CRM</div>;
5.2 WMS (React 17) — apps/wms-r17
CRM과 동일. 포트만 다르게.
차이점: vite.config.ts에서 server.port = 5174, 타이틀 변경, 버튼 메시지 변경 등.
5.3 학사(React 17) — apps/haksa-r17
동일 패턴. 포트 5175.
5.4 테스트통합 (React 19) — apps/test-suite-r19
package.json
{
"name": "@vinci/test-suite-r19",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "echo linting test-suite...",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"react-router-dom": "6.26.2",
"@vinci/shared-utils": "workspace:*",
"@vinci/ui-r19": "workspace:*"
},
"devDependencies": {
"@vitejs/plugin-react": "4.3.1",
"typescript": "5.5.4",
"vite": "5.4.8"
}
}
tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"outDir": "dist"
},
"include": ["src", "vite-env.d.ts"]
}
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { port: 5190 }
});
src/main.tsx
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import routes from './routes';
const router = createBrowserRouter(routes);
createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />);
src/App.tsx
import { Outlet, Link } from 'react-router-dom';
import { Button } from '@vinci/ui-r19';
export default function App() {
return (
<div>
<h1>Test Suite (React 19)</h1>
<nav>
<Link to="/">Home</Link> | <Link to="/about">About</Link>
</nav>
<Button onClick={() => alert('Test Suite!')}>Run</Button>
<Outlet />
</div>
);
}
나머지 routes.tsx, pages는 React 17 예제와 동일합니다.
6) Spring Boot + iBatis(MyBatis) + MariaDB (services/backend)
6.1 pom.xml
4.0.0
com.vinci
backend
0.1.0
17
3.3.2
org.springframework.boot
spring-boot-dependencies
${spring.boot.version}
pom
import
org.springframework.boot
spring-boot-starter-web
org.mybatis.spring.boot
mybatis-spring-boot-starter
3.0.3
org.mariadb.jdbc
mariadb-java-client
3.4.1
org.springframework.boot
spring-boot-starter-validation
org.springframework.boot
spring-boot-starter-test
test
org.springframework.boot
spring-boot-maven-plugin
6.2 src/main/resources/application.yml
server:
port: 8080
spring:
datasource:
url: jdbc:mariadb://127.0.0.1:3306/itf
username: root
password: 9909
driver-class-name: org.mariadb.jdbc.Driver
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
6.3 엔티티/매퍼/서비스/컨트롤러 (샘플)
src/main/java/com/vinci/app/domain/User.java
package com.vinci.app.domain;
public class User {
private Long id;
private String name;
private String role;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
}
src/main/resources/mapper/UserMapper.xml
http://mybatis.org/dtd/mybatis-3-mapper.dtd">
SELECT id, name, role FROM users ORDER BY id DESC
INSERT INTO users(name, role) VALUES(#{name}, #{role})
src/main/java/com/vinci/app/mapper/UserMapper.java
package com.vinci.app.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import com.vinci.app.domain.User;
@Mapper
public interface UserMapper {
List<User> findAll();
int insert(User user);
}
src/main/java/com/vinci/app/service/UserService.java
package com.vinci.app.service;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.vinci.app.domain.User;
import com.vinci.app.mapper.UserMapper;
@Service
public class UserService {
private final UserMapper userMapper;
public UserService(UserMapper userMapper) { this.userMapper = userMapper; }
public List<User> list() { return userMapper.findAll(); }
@Transactional
public Long create(String name, String role) {
User u = new User();
u.setName(name);
u.setRole(role);
userMapper.insert(u);
return u.getId();
}
}
src/main/java/com/vinci/app/web/UserController.java
package com.vinci.app.web;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import com.vinci.app.domain.User;
import com.vinci.app.service.UserService;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) { this.userService = userService; }
@GetMapping
public List<User> list() { return userService.list(); }
@PostMapping
public ResponseEntity<Long> create(@RequestBody User user) {
Long id = userService.create(user.getName(), user.getRole());
return ResponseEntity.ok(id);
}
}
src/main/java/com/vinci/app/VinciApplication.java
package com.vinci.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class VinciApplication {
public static void main(String[] args) {
SpringApplication.run(VinciApplication.class, args);
}
}
6.4 DB 초기 스키마
services/backend/src/main/resources/schema.sql
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
role VARCHAR(50) NOT NULL
);
Spring Boot는 schema.sql을 자동 실행합니다 (기본 설정).
7) Turborepo 파이프라인과 실행 예시
7.1 병렬 개발 서버 실행
# 1) 의존성 설치 (루트)
pnpm i
# 2) DB 기동
pnpm db:up # 3306(MariaDB), 8081(Adminer)
# 3) 애플리케이션 개발 서버 (여러 개 필터로 동시 실행)
# CRM + WMS + 학사 + TestSuite
pnpm dev --filter @vinci/crm-r17 --filter @vinci/wms-r17 --filter @vinci/haksa-r17 --filter @vinci/test-suite-r19
# 또는 개별 실행
pnpm -C apps/crm-r17 dev
pnpm -C apps/wms-r17 dev
pnpm -C apps/haksa-r17 dev
pnpm -C apps/test-suite-r19 dev
# 4) 백엔드 실행 (별 터미널)
cd services/backend && ./mvnw spring-boot:run
7.2 빌드/배포 아티팩트
# 전체 빌드 (의존 순서 자동 계산)
pnpm build
# 특정 앱만 빌드
pnpm --filter @vinci/test-suite-r19 build
7.3 타입체크/린트/테스트 (샘플)
pnpm typecheck
pnpm lint
pnpm test
8) 서로 다른 React 메이저 공존 전략
- 패키지 분리: UI 패키지(ui-r17, ui-r19)를 분리하여 peerDeps 충돌을 원천 차단.
- 공유 로직 분리: React 의존 없는 로직은 shared-utils로 이동.
- 라우팅/상태관리: 각 앱은 자체 상태/라우팅을 가짐. 교차 호환 필요 시 REST/GraphQL 또는 이벤트 브릿지(브라우저 postMessage)로 통신.
- 빌드 타깃 격리: Vite dev 서버 포트를 분리(5173/5174/5175/5190). 프록시를 통해 백엔드(8080)로 API 경로(/api)만 바인딩.
필요 시 vite.config.ts의 server.proxy에 다음을 추가:
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8080', changeOrigin: true }
}
}
9) 환경 변수(.env) 예시
루트 .env는 커밋 제외하세요.
VITE_API_BASE=http://localhost:8080
앱 코드에서 import.meta.env.VITE_API_BASE 사용.
10) 운영 팁
- pnpm별 격리 저장소: pnpm은 버전이 다른 React도 문제없이 공존시킵니다(virtual store).
- CI 캐시: Turborepo + pnpm store 캐시를 활용하여 빌드 시간 단축.
- 버전 고정: React 메이저 충돌 방지 위해 각 앱 package.json에 명확히 고정.
- 형상관리: apps/*는 독립 릴리스 가능하도록 CHANGELOG를 앱별 관리.
- DB 마이그레이션: 운영 단계에서는 Flyway/Liquibase 도입 추천.
11) 프런트엔드 ↔ 백엔드 연동 샘플
React(Home)에서 유저 목록 호출 — React 17/19 동일 코드
import React from 'react';
export default function Home() {
const [users, setUsers] = React.useState<{id:number;name:string;role:string}[]>([]);
React.useEffect(() => {
fetch(`${import.meta.env.VITE_API_BASE || ''}/api/users`).then(r => r.json()).then(setUsers);
}, []);
return (
<div>
<h2>Users</h2>
<ul>{users.map(u => <li key={u.id}>{u.name} — {u.role}</li>)}</ul>
</div>
);
}
12) 체크리스트
- pnpm i 완료
- pnpm db:up로 DB 기동
- services/backend에서 서버 실행
- 각 앱 pnpm dev로 페이지 접속 (5173/5174/5175/5190)
- /api/users POST로 샘플 데이터 입력 후 목록 확인
13) 확장 아이디어
- Ag-Grid 통합: apps/*에 ag-grid-community, ag-grid-react 추가 후 샘플 그리드 페이지 생성
- 인증: Keycloak/NextAuth 스타일의 OAuth2 프록시, Spring Security 연동
- 배포: 프런트는 S3+CloudFront/NGINX, 백엔드는 Docker 이미지로 K8s 배포. Turborepo에서 앱별 파이프라인 분기
부록 A) WMS/Haksa용 차이 파일 (요약)
- apps/wms-r17/vite.config.ts — port: 5174
- apps/haksa-r17/vite.config.ts — port: 5175
- 각 index.html의 <title>만 앱명으로 변경
부록 B) 간단한 cURL 테스트
# 사용자 생성
curl -X POST http://localhost:8080/api/users \
-H 'Content-Type: application/json' \
-d '{"name":"Alice","role":"admin"}'
# 목록 조회
curl http://localhost:8080/api/users
필요 시 Ag-Grid 페이지 템플릿과 React Router 메뉴(계층형/Collapsible Sidebar), 모달/팝업 샘플도 추가 제공 가능합니다.
'테스트 플렛폼' 카테고리의 다른 글
Vinci 모노레포: pnpm과 터보레포를 활용한 다중 스택, 다중 버전 개발 지침서 (3) | 2025.08.18 |
---|---|
환경 설정 (2) | 2025.08.18 |
모노레포: pnpm과 터보레포를 활용한 다중 스택, 다중 버전 개발 지침서 (1) | 2025.08.18 |
react 17, Typescript 환경에서 FilesInBucket 함수로 가져와서 해당 file 만큼 반복해서 deleteFileOfIndex(idx) 호출하여 삭제 (0) | 2025.08.12 |
LMMS Windows 개발 환경 설정 가이드 (0) | 2025.08.09 |