Jace Docs

MonoRepo

MonoRepo 만드는 쉘스크립트, 설정

실행

> mkdir monorepo
> cd monorepo
> vi monorepo.sh
> chmod +x monorepo.sh
> ./monorepo.sh

monorepo.sh

#!/bin/bash

# 색상 및 스타일
GREEN='\033[0;32m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
NC='\033[0m'

echo -e "${PURPLE}====================================================${NC}"
echo -e "${PURPLE}   JACE ULID-Driven Monorepo Scaffolding (2026)     ${NC}"
echo -e "${PURPLE}====================================================${NC}"

# 1. 루트 초기화
echo -e "${BLUE}📦 [1/6] 루트 워크스페이스 및 .gitignore 설정 중...${NC}"
mkdir -p apps packages
pnpm init

cat <<EOF >.gitignore
node_modules/
.pnpm-store/
.turbo
dist/
build/
.react-router/
.cache/
.env
*.env.local
.DS_Store
EOF

cat <<EOF >pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
EOF

pnpm add -D turbo typescript tsx vite-tsconfig-paths -w

cat <<EOF >turbo.json
{
  "\$schema": "https://turbo.build/schema.json",
  "globalPassThroughEnv": [
    "DATABASE_URL",
    "DIRECT_URL",
    "BETTER_AUTH_SECRET",
    "BETTER_AUTH_URL",
    "GOOGLE_CLIENT_ID",
    "GOOGLE_CLIENT_SECRET",
    "KAKAO_CLIENT_ID",
    "KAKAO_CLIENT_SECRET",
    "NAVER_CLIENT_ID",
    "NAVER_CLIENT_SECRET"
  ],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".react-router/**", "build/**"]
    },
    "db:generate": { "cache": false },
    "db:push": { "cache": false },
    "dev": { "cache": false, "persistent": true }
  }
}

EOF

# 2. packages/database (ULID Default 적용)
echo -e "${BLUE}🗄️  [2/6] DB 패키지 구축 (@gnss/db)...${NC}"
mkdir -p packages/database/src/common
cd packages/database
pnpm init
sed -i '' 's/"name": "database"/"name": "@gnss\/db"/' package.json
# ulidx를 DB 패키지에도 추가하여 스키마에서 직접 사용
pnpm add drizzle-orm postgres ulidx
pnpm add -D drizzle-kit

cat <<EOF >drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
  schema: "./src/common/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: { url: process.env.DIRECT_URL! },
});
EOF

cat <<EOF >src/common/schema.ts
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
import { ulid } from "ulidx";

// 1. User 테이블
export const user = pgTable("user", {
  id: text("id").primaryKey().\$defaultFn(() => ulid()),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  emailVerified: boolean("email_verified").notNull().default(false),
  image: text("image"),
  isPremium: boolean("is_premium").default(false).notNull(),
  createdAt: timestamp("created_at", { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
  updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
});

// 2. Session 테이블
export const session = pgTable("session", {
  id: text("id").primaryKey().\$defaultFn(() => ulid()),
  expiresAt: timestamp("expires_at").notNull(),
  token: text("token").notNull().unique(),
  createdAt: timestamp("created_at").notNull(),
  updatedAt: timestamp("updated_at").notNull(),
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  userId: text("user_id").notNull().references(() => user.id),
});

// 3. Account 테이블 (소셜 연동)
export const account = pgTable("account", {
  id: text("id").primaryKey().\$defaultFn(() => ulid()),
  accountId: text("account_id").notNull(),
  providerId: text("provider_id").notNull(),
  userId: text("user_id").notNull().references(() => user.id),
  accessToken: text("access_token"),
  refreshToken: text("refresh_token"),
  idToken: text("id_token"),
  accessTokenExpiresAt: timestamp("access_token_expires_at"),
  refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
  scope: text("scope"),
  password: text("password"),
  createdAt: timestamp("created_at").notNull(),
  updatedAt: timestamp("updated_at").notNull(),
});

// 4. Verification 테이블
export const verification = pgTable("verification", {
  id: text("id").primaryKey().\$defaultFn(() => ulid()),
  identifier: text("identifier").notNull(),
  value: text("value").notNull(),
  expiresAt: timestamp("expires_at").notNull(),
  createdAt: timestamp("created_at"),
  updatedAt: timestamp("updated_at"),
});
EOF

cat <<EOF >src/common/relations.ts
import { relations } from "drizzle-orm";
import { user, session, account } from "./schema";

export const userRelations = relations(user, ({ many }) => ({
  sessions: many(session),
  accounts: many(account),
}));

export const sessionRelations = relations(session, ({ one }) => ({
  user: one(user, { fields: [session.userId], references: [user.id] }),
}));

export const accountRelations = relations(account, ({ one }) => ({
  user: one(user, { fields: [account.userId], references: [user.id] }),
}));
EOF

cat <<EOF >src/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as commonSchema from "./common/schema";
import * as commonRelations from "./common/relations";

export const schema = { ...commonSchema, ...commonRelations };

if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is missing");

const client = postgres(process.env.DATABASE_URL!, { max: 1 });
export const db = drizzle(client, { schema });

export * from "drizzle-orm";
export * from "./common/schema";
export * from "./common/relations";
EOF
cd ../..

# 3. packages/auth 구축
echo -e "${BLUE}🔐 [3/6] Auth 패키지 구축 (@gnss/auth)...${NC}"
mkdir -p packages/auth/src
cd packages/auth
pnpm init
sed -i '' 's/"name": "auth"/"name": "@gnss\/auth"/' package.json
pnpm add better-auth ulidx @gnss/db@workspace:*

cat <<EOF >src/index.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db, schema } from "@gnss/db";
import { ulid } from "ulidx";

export const getAuth = (env: any) => {
  return betterAuth({
    database: drizzleAdapter(db, {
      provider: "pg",
      schema: schema,
      generateId: () => ulid(),
    }),
    socialProviders: {
      google: {
        prompt: "select_account",
        clientId: env.GOOGLE_CLIENT_ID,
        clientSecret: env.GOOGLE_CLIENT_SECRET,
      },
      kakao: { clientId: env.KAKAO_CLIENT_ID, clientSecret: env.KAKAO_CLIENT_SECRET },
      naver: { clientId: env.NAVER_CLIENT_ID, clientSecret: env.NAVER_CLIENT_SECRET },
    },
    account: { accountLinking: { enabled: true } },
    emailAndPassword: {
      enabled: true,
      password: { hashOptions: { logN: 10, r: 8, p: 1 } }
    },
    secret: env.BETTER_AUTH_SECRET,
    baseURL: env.BETTER_AUTH_URL,
    session: {
      expiresIn: 60 * 60 * 24 * 7,
      cookieCache: { enabled: true, strategy: "jwt" },
    },
    cookie: {
      domain: process.env.NODE_ENV === "production" ? ".gnss.com" : ".local.com",
    },
    trustedOrigins: [
      "http://web.local.com:3000",
      "http://admin.local.com:3001",
      "https://calen.thejace.workers.dev",
    ],
  });
};
EOF
cd ../..

# 4. Apps 생성 (Web, Admin)
echo -e "${BLUE}🌐 [4/6] 서비스 앱 초기화 중...${NC}"
for app in web admin; do
	mkdir -p apps/$app/app/routes
	cd apps/$app
	pnpm init
	sed -i '' "s/\"name\": \"$app\"/\"name\": \"@gnss\/$app\"/" package.json
	pnpm add @gnss/db@workspace:* @gnss/auth@workspace:* better-auth

	PORT=$([ "$app" == "web" ] && echo "3000" || echo "3001")
	cat <<EOF >vite.config.ts
import { defineConfig } from "vite";
import { reactRouter } from "@react-router/dev/vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
  envDir: "../../",
  server: { port: $PORT, host: "$app.local.com" },
  plugins: [reactRouter(), tsconfigPaths()],
});
EOF
	cd ../..
done

# 5. 환경 변수 및 마무리
echo -e "${BLUE}📝 [5/6] 환경 변수 설정...${NC}"
cat <<EOF >.env
DATABASE_URL="postgres://user:pass@host:6543/db?pgbouncer=true"
DIRECT_URL="postgres://user:pass@host:5432/db"
BETTER_AUTH_SECRET="$(openssl rand -hex 32)"
BETTER_AUTH_URL="http://web.local.com:3000"
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
KAKAO_CLIENT_ID=""
KAKAO_CLIENT_SECRET=""
NAVER_CLIENT_ID=""
NAVER_CLIENT_SECRET=""
EOF

# 6. 최종 설치
echo -e "${BLUE}🚚 [6/6] 최종 의존성 설치 중...${NC}"
pnpm install

echo -e "${GREEN}====================================================${NC}"
echo -e "${GREEN}✅ ULID 스키마가 포함된 설정이 완료되었습니다!${NC}"
echo -e "${BLUE}Mac Studio에서 pnpm dev로 바로 시작하세요.${NC}"
echo -e "${GREEN}====================================================${NC}"

packages의 라이브러리 사용법

1. 라이브러리의 package.json 설정

먼저 공유할 라이브러리(packages/my-lib)의 이름과 진입점을 확인해야 합니다.

  • name: 다른 앱에서 불러올 때 사용할 이름 (예: @my-project/ui)
  • main/exports: 빌드된 파일이나 소스 코드의 위치
{
  "name": "@my-project/ui",
  "version": "0.0.0",
  "main": "./index.ts",  // 혹은 빌드된 경로 ./dist/index.js
  "types": "./index.ts"
}

2. 앱의 package.json에 등록

이제 라이브러리를 사용할 앱(apps/web)의 설정 파일에 해당 라이브러리를 의존성으로 추가합니다.

이때 버전을 적는 대신 Workspace 프로토콜을 사용하면 로컬의 소스코드를 직접 참조합니다.

{
  "name": "web-app",
  "dependencies": {
    "@my-project/ui": "workspace:*"
  }
}

Tip: pnpm install을 실행하면 패키지 매니저가 심볼릭 링크(Symbolic Link)를 생성하여 node_modules에 연결해 줍니다.


3. TypeScript 설정 (경로 인식)

VS Code에서 코드를 작성할 때 빨간 줄이 뜨지 않게 하려면, 루트(Root)나 앱의 tsconfig.json에서 paths 설정을 확인해야 합니다. (Turborepo 등 현대적인 도구는 의존성 전파만으로도 충분한 경우가 많습니다.)

{
  "compilerOptions": {
    "paths": {
      "@my-project/ui": ["../../packages/ui/src/index.ts"]
    }
  }
}

4. 실제 코드에서 사용

설정이 끝났다면 일반적인 외부 라이브러리처럼 import해서 사용하면 됩니다.

import { Button } from '@my-project/ui';

const App = () => {
  return <Button>내 라이브러리 버튼</Button>;
};

💡 주의할 점 (트러블슈팅)

  1. 빌드 과정: 만약 라이브러리가 TypeScript 소스 그대로가 아니라 빌드된 결과물을 배포하는 구조라면, apps를 실행하기 전에 packages 내 라이브러리를 먼저 build 해야 할 수도 있습니다.
  2. 의존성 설치: package.json을 수정했다면 반드시 루트 경로에서 pnpm install (또는 npm/yarn install)을 실행해 주세요.
  3. Nohoist 이슈: 가끔 의존성이 꼬일 경우 node_modules를 한 번 지우고 다시 설치하는 것이 정신 건강에 이롭습니다.

경로 차이로 인한 문제

로그를 보니 문제의 핵심이 명확해졌습니다. Linux 환경에서의 빌드 구조나 실행 위치(CWD)가 Mac과 미세하게 다르기 때문에, 상대 경로인 ../../../가 빗나간 것입니다.

가장 안전하고 호환성 높은 방법은 **"파일을 찾을 때까지 상위 디렉토리로 거슬러 올라가는 로직"**을 사용하는 것입니다. 이렇게 하면 OS나 빌드 폴더 깊이에 상관없이 .env 파일을 찾아낼 수 있습니다.

🛠️ OS 호환성을 고려한 경로 자동 탐색 코드

기존의 고정된 ../../../ 대신 아래 코드를 사용해 보세요.

import dotenv from "dotenv"
import path from "path"
import { fileURLToPath } from "url"
import fs from "fs"

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

/**
 * 특정 디렉토리부터 시작해서 상위로 올라가며 파일을 찾는 함수
 */
function findEnvPath(startDir, fileName) {
  let curr = startDir
  while (curr !== path.parse(curr).root) {
    const fullPath = path.join(curr, fileName)
    if (fs.existsSync(fullPath)) {
      return fullPath
    }
    curr = path.dirname(curr) // 한 단계 위로
  }
  return null
}

// 1. 프로젝트 내부(앱 수준) .env 로드
const localEnv = path.resolve(__dirname, ".env")
dotenv.config({ path: localEnv })

// 2. 루트(모노레포 수준) .env 자동 탐색 로드
// 현재 위치에서 상위로 올라가며 root .env를 찾음
const rootEnv = findEnvPath(path.dirname(__dirname), ".env")

if (rootEnv && rootEnv !== localEnv) {
  dotenv.config({ path: rootEnv })
}

// 디버깅용 로그 (필요시 삭제)
console.log(`[ENV] Local: ${fs.existsSync(localEnv) ? 'Found' : 'Not Found'}`)
console.log(`[ENV] Root: ${rootEnv || 'Not Found'}`)

💡 왜 이렇게 수정해야 하나요?

  1. 상대 경로의 불확실성 제거: Linux 빌드 환경(CI/CD, Docker 등)에서는 dist, build, server 등 중간 폴더가 Mac 개발 환경보다 한 단계 더 깊거나 얕을 수 있습니다. ../../../는 이 변화에 매우 취약합니다.
  2. 모노레포 최적화: 모노레포 구조에서는 보통 apps/my-app/.env와 프로젝트 최상단 /.env 두 곳을 참조하는데, 위 로직은 상위로 올라가며 가장 먼저 만나는 .env들을 순차적으로 로드하기 때문에 안전합니다.
  3. 로그 분석 결과 반영: 현재 Linux 로그에서 injected env (0)이 뜬다는 것은, 파일 경로를 지정했음에도 불구하고 실제 파일이 없는 곳을 찔렀거나 빈 파일을 읽었다는 뜻입니다. 위 함수를 쓰면 실제 존재하는 파일의 경로를 절대 경로로 반환하므로 실패 확률이 거의 없습니다.

추가 팁

만약 이래도 안 된다면, Linux 서버 터미널에서 다음 명령어를 쳐서 실제 파일 위치를 다시 확인해 보세요: find . -name ".env" 그 경로와 코드상에 찍히는 Check Path가 일치하는지 비교하면 바로 답이 나옵니다.

On this page