본문 바로가기

테스트 플렛폼

Vinci Monorepo: Multi‑App (React17 & React19) + TypeScript + Spring/iBatis/MariaDB

반응형

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 메이저 공존 전략

  1. 패키지 분리: UI 패키지(ui-r17, ui-r19)를 분리하여 peerDeps 충돌을 원천 차단.
  2. 공유 로직 분리: React 의존 없는 로직은 shared-utils로 이동.
  3. 라우팅/상태관리: 각 앱은 자체 상태/라우팅을 가짐. 교차 호환 필요 시 REST/GraphQL 또는 이벤트 브릿지(브라우저 postMessage)로 통신.
  4. 빌드 타깃 격리: 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), 모달/팝업 샘플도 추가 제공 가능합니다.

반응형