diff --git a/.env.example b/.env.example index 00150799..34c335b3 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,20 @@ NODE_ENV=development PORT=3000 + +# DB 연결. 런타임·마이그레이션 공용. +# 로컬: 아래 로컬 postgres 주소 그대로 사용(TLS 불필요). +# Supabase: Direct connection을 우선 사용합니다(상주 서버라 풀러 불필요 + 레이턴시↓). +# postgresql://postgres:@db..supabase.co:5432/postgres?sslmode=require +# (Cloud Run에서 Direct가 IPv6 이슈로 안 닿으면 session pooler로 폴백) +# postgresql://postgres.:@aws--.pooler.supabase.com:5432/postgres?sslmode=require +# ※ 트랜잭션 풀러(6543)는 사용하지 않습니다. DATABASE_URL=postgres://postgres:postgres@localhost:5432/team_mino -DB_POOL_SIZE=10 + +# 접근할 스키마(환경 분리). 미설정 시 develop. 운영에서만 production 으로 주입. +# DATABASE_SCHEMA=develop + +# 인스턴스당 최대 커넥션 수(기본 5). +DB_POOL_SIZE=5 POSTGRES_HOST=localhost POSTGRES_PORT=5432 diff --git a/drizzle.config.ts b/drizzle.config.ts index d31ee2bb..f63752d7 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,16 +1,40 @@ import { defineConfig } from "drizzle-kit"; +import { resolveDbSchema } from "./src/infrastructures/db/db.env"; -const databaseUrl = process.env.DATABASE_URL; - -if (!databaseUrl) { - throw new Error("DATABASE_URL is required"); +/** + * drizzle-kit 설정. 마이그레이션 생성/적용 시 사용합니다. + * + * bun run db:schema-init # 환경 스키마(CREATE SCHEMA) 준비 — 최초 1회 + * bun run db:generate # 스키마 변경 → 마이그레이션 SQL 생성 + * bun run db:migrate # 마이그레이션 적용 + * bun run db:studio # 로컬 DB 브라우저 + * + * 환경별 스키마 분리(production / develop): + * - 테이블 DDL은 스키마 이름 없이 생성 → 마이그레이션 기록을 하나로 유지. + * - 적용 시 바라볼 스키마(search_path)를 지정해 그 스키마에 테이블이 생깁니다. + * - 적용 기록 테이블(__drizzle_migrations)도 환경 스키마별로 따로 둡니다(migrations.schema). + * → dev/prod가 같은 기록을 공유해 한쪽만 반영되는 문제 방지. + * + * 연결은 런타임과 동일한 DATABASE_URL(Direct connection 또는 session pooler)을 씁니다. + * 둘 다 세션 단위 연결이라 락/멀티스테이트먼트 DDL이 안전합니다. + * (트랜잭션 풀러 6543은 마이그레이션에 부적합하므로 사용하지 않습니다.) + */ +const baseUrl = process.env.DATABASE_URL; +if (!baseUrl) { + throw new Error("DATABASE_URL is required for migrations."); } +const schema = resolveDbSchema(process.env.DATABASE_SCHEMA); +// 마이그레이션이 해당 스키마에 적용되도록, 접속 시 바라볼 스키마(search_path)를 지정. +const url = `${baseUrl}${baseUrl.includes("?") ? "&" : "?"}options=-csearch_path%3D${schema}`; + export default defineConfig({ - schema: "./src/database/schema/index.ts", - out: "./drizzle", dialect: "postgresql", - dbCredentials: { - url: databaseUrl, - }, + // 테이블 스키마는 각 모듈의 *.schema.ts에 위치합니다 (예: src/modules/user/user.schema.ts). + schema: "./src/modules/**/*.schema.ts", + out: "./drizzle", + dbCredentials: { url }, + casing: "snake_case", + schemaFilter: [schema], + migrations: { schema }, }); diff --git a/package.json b/package.json index be98c5ce..440cc3b2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "check:staged": "nano-staged --bail", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", - "db:push": "drizzle-kit push", + "db:schema-init": "bun run scripts/db-schema-init.ts", + "db:studio": "drizzle-kit studio", "format": "biome format --write .", "lint": "biome lint .", "postinstall": "simple-git-hooks", diff --git a/scripts/db-schema-init.ts b/scripts/db-schema-init.ts new file mode 100644 index 00000000..76bc873b --- /dev/null +++ b/scripts/db-schema-init.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env bun + +/** + * 환경 스키마(production / develop)를 준비합니다. 최초 1회 또는 신규 환경 추가 시 실행. + * drizzle-kit은 스키마를 자동 생성하지 않으므로, 마이그레이션 전에 먼저 실행해야 합니다. + * + * bun run db:schema-init # DATABASE_SCHEMA(기본 develop) 스키마 생성 + * DATABASE_SCHEMA=production bun run db:schema-init + */ +import postgres from "postgres"; +import { resolveDbSchema } from "../src/infrastructures/db/db.env"; + +async function main() { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL 환경변수가 설정되지 않았습니다."); + process.exit(1); + } + + // resolveDbSchema 반환값은 develop/production 두 리터럴로 고정되어 인젝션 위험이 없습니다. + const schema = resolveDbSchema(process.env.DATABASE_SCHEMA); + const sql = postgres(url, { max: 1 }); + + try { + await sql.unsafe(`create schema if not exists "${schema}"`); + console.log(`✅ 스키마 준비 완료: "${schema}"`); + } catch (e) { + console.error("스키마 생성 실패:", e instanceof Error ? e.message : e); + process.exitCode = 1; + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((e) => { + console.error("스키마 초기화 실패:", e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/src/app.module.ts b/src/app.module.ts index 57ef22bd..597c9a3e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,9 +9,9 @@ import { TerminusModule } from "@nestjs/terminus"; import { LoggerModule } from "nestjs-pino"; import { LoggingMiddleware } from "./common/middlewares/logging.middleware"; import { validateEnv } from "./config/env.schema"; -import { DatabaseModule } from "./database/database.module"; import { DrizzleHealthIndicator } from "./health/drizzle.health-indicator"; import { HealthController } from "./health/health.controller"; +import { DatabaseModule } from "./infrastructures/db/database.module"; @Module({ imports: [ diff --git a/src/config/env.schema.ts b/src/config/env.schema.ts index fe2bcefa..03b9585e 100644 --- a/src/config/env.schema.ts +++ b/src/config/env.schema.ts @@ -16,9 +16,11 @@ const envSchema = v.object({ "3000", ), DATABASE_URL: v.pipe(v.string(), v.minLength(1), v.url()), + // 접근할 스키마. 미설정 시 안전한 기본값 develop. 운영에서만 production 주입. + DATABASE_SCHEMA: v.optional(v.picklist(["develop", "production"]), "develop"), DB_POOL_SIZE: v.optional( v.pipe(v.string(), v.regex(/^\d+$/), v.transform(Number), v.minValue(1)), - "10", + "5", ), }); diff --git a/src/database/database.service.ts b/src/database/database.service.ts deleted file mode 100644 index c1b16d59..00000000 --- a/src/database/database.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable, type OnModuleDestroy } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; -import type { Env } from "../config/env.schema"; - -@Injectable() -export class DatabaseService implements OnModuleDestroy { - private readonly client: ReturnType; - readonly db: ReturnType; - - constructor(configService: ConfigService) { - const databaseUrl = configService.getOrThrow("DATABASE_URL", { - infer: true, - }); - const max = configService.get("DB_POOL_SIZE", 10, { infer: true }); - - this.client = postgres(databaseUrl, { - max, - }); - this.db = drizzle(this.client); - } - - async onModuleDestroy() { - await this.client.end(); - } -} diff --git a/src/database/schema/index.ts b/src/database/schema/index.ts deleted file mode 100644 index cb0ff5c3..00000000 --- a/src/database/schema/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/src/health/drizzle.health-indicator.spec.ts b/src/health/drizzle.health-indicator.spec.ts index f198b883..8e601b74 100644 --- a/src/health/drizzle.health-indicator.spec.ts +++ b/src/health/drizzle.health-indicator.spec.ts @@ -2,7 +2,7 @@ import "reflect-metadata"; import { beforeEach, describe, expect, it, jest } from "bun:test"; import { HealthIndicatorService } from "@nestjs/terminus"; import { Test } from "@nestjs/testing"; -import { DatabaseService } from "../database/database.service"; +import { DatabaseService } from "../infrastructures/db/database.service"; import { DrizzleHealthIndicator } from "./drizzle.health-indicator"; describe("DrizzleHealthIndicator", () => { diff --git a/src/health/drizzle.health-indicator.ts b/src/health/drizzle.health-indicator.ts index 8cab4743..a265e81b 100644 --- a/src/health/drizzle.health-indicator.ts +++ b/src/health/drizzle.health-indicator.ts @@ -4,7 +4,7 @@ import { HealthIndicatorService, } from "@nestjs/terminus"; import { sql } from "drizzle-orm"; -import { DatabaseService } from "../database/database.service"; +import { DatabaseService } from "../infrastructures/db/database.service"; @Injectable() export class DrizzleHealthIndicator { diff --git a/src/database/database.module.ts b/src/infrastructures/db/database.module.ts similarity index 100% rename from src/database/database.module.ts rename to src/infrastructures/db/database.module.ts diff --git a/src/infrastructures/db/database.service.ts b/src/infrastructures/db/database.service.ts new file mode 100644 index 00000000..c0f06cb7 --- /dev/null +++ b/src/infrastructures/db/database.service.ts @@ -0,0 +1,36 @@ +import { Injectable, type OnModuleDestroy } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import type { Env } from "../../config/env.schema"; +import { resolveDbSchema } from "./db.env"; + +@Injectable() +export class DatabaseService implements OnModuleDestroy { + private readonly client: ReturnType; + readonly db: ReturnType; + + constructor(configService: ConfigService) { + const databaseUrl = configService.getOrThrow("DATABASE_URL", { + infer: true, + }); + const max = configService.get("DB_POOL_SIZE", 5, { infer: true }); + const schema = resolveDbSchema( + configService.get("DATABASE_SCHEMA", { infer: true }), + ); + + this.client = postgres(databaseUrl, { + max, + // 이 환경의 스키마만 바라보게 고정. 실수로 다른 환경 테이블을 건드리지 않도록. + connection: { search_path: schema }, + // TLS는 연결 문자열로 제어합니다(로컬은 미지정, Supabase는 ?sslmode=require). + // 연결은 Direct connection(또는 session pooler)을 사용합니다. 트랜잭션 풀러(6543)가 + // 아니므로 prepared statement 기본 동작(prepare: true)을 유지합니다. + }); + this.db = drizzle(this.client, { casing: "snake_case" }); + } + + async onModuleDestroy() { + await this.client.end(); + } +} diff --git a/src/infrastructures/db/db.env.ts b/src/infrastructures/db/db.env.ts new file mode 100644 index 00000000..919236fb --- /dev/null +++ b/src/infrastructures/db/db.env.ts @@ -0,0 +1,19 @@ +/** + * 서버가 접근할 Postgres 스키마(환경 분리용)를 결정합니다. + * + * 하나의 Supabase 인스턴스 안에서 `production` / `develop` 스키마로 환경을 가릅니다. + * 선택 기준은 `DATABASE_SCHEMA` 하나뿐입니다(NODE_ENV에 의존하지 않음). + * - `DATABASE_SCHEMA === 'production'` → `production` + * - 그 외(미설정 포함) → 안전한 기본값 `develop` + * + * 운영 환경에서만 `DATABASE_SCHEMA=production`을 명시적으로 주입합니다. + * 반환값이 항상 두 리터럴 중 하나로 고정되므로 search_path/연결 문자열에 + * 끼워 넣어도 인젝션 위험이 없습니다. + * + * 런타임(DatabaseService)·drizzle.config(마이그레이션)·스크립트에서 공통으로 사용합니다. + */ +export type DbSchema = "develop" | "production"; + +export function resolveDbSchema(value?: string | null): DbSchema { + return value === "production" ? "production" : "develop"; +}