MonoRepo
MonoRepo 만드는 쉘스크립트, 설정
실행
> mkdir monorepo
> cd monorepo
> vi monorepo.sh
> chmod +x monorepo.sh
> ./monorepo.shmonorepo.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>;
};💡 주의할 점 (트러블슈팅)
- 빌드 과정: 만약 라이브러리가 TypeScript 소스 그대로가 아니라 빌드된 결과물을 배포하는 구조라면,
apps를 실행하기 전에packages내 라이브러리를 먼저build해야 할 수도 있습니다. - 의존성 설치:
package.json을 수정했다면 반드시 루트 경로에서pnpm install(또는 npm/yarn install)을 실행해 주세요. - 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'}`)💡 왜 이렇게 수정해야 하나요?
- 상대 경로의 불확실성 제거:
Linux 빌드 환경(CI/CD, Docker 등)에서는
dist,build,server등 중간 폴더가 Mac 개발 환경보다 한 단계 더 깊거나 얕을 수 있습니다.../../../는 이 변화에 매우 취약합니다. - 모노레포 최적화:
모노레포 구조에서는 보통
apps/my-app/.env와 프로젝트 최상단/.env두 곳을 참조하는데, 위 로직은 상위로 올라가며 가장 먼저 만나는.env들을 순차적으로 로드하기 때문에 안전합니다. - 로그 분석 결과 반영:
현재 Linux 로그에서
injected env (0)이 뜬다는 것은, 파일 경로를 지정했음에도 불구하고 실제 파일이 없는 곳을 찔렀거나 빈 파일을 읽었다는 뜻입니다. 위 함수를 쓰면 실제 존재하는 파일의 경로를 절대 경로로 반환하므로 실패 확률이 거의 없습니다.
추가 팁
만약 이래도 안 된다면, Linux 서버 터미널에서 다음 명령어를 쳐서 실제 파일 위치를 다시 확인해 보세요:
find . -name ".env"
그 경로와 코드상에 찍히는 Check Path가 일치하는지 비교하면 바로 답이 나옵니다.