Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
NODE_ENV=development
PORT=3000

# DB 연결. 런타임·마이그레이션 공용.
# 로컬: 아래 로컬 postgres 주소 그대로 사용(TLS 불필요).
# Supabase: Direct connection을 우선 사용합니다(상주 서버라 풀러 불필요 + 레이턴시↓).
# postgresql://postgres:<pw>@db.<ref>.supabase.co:5432/postgres?sslmode=require
# (Cloud Run에서 Direct가 IPv6 이슈로 안 닿으면 session pooler로 폴백)
# postgresql://postgres.<ref>:<pw>@aws-<n>-<region>.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
Expand Down
42 changes: 33 additions & 9 deletions drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions scripts/db-schema-init.ts
Original file line number Diff line number Diff line change
@@ -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);
});
2 changes: 1 addition & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
4 changes: 3 additions & 1 deletion src/config/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
});

Expand Down
27 changes: 0 additions & 27 deletions src/database/database.service.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/database/schema/index.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/health/drizzle.health-indicator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/health/drizzle.health-indicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions src/infrastructures/db/database.service.ts
Original file line number Diff line number Diff line change
@@ -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<typeof postgres>;
readonly db: ReturnType<typeof drizzle>;

constructor(configService: ConfigService<Env>) {
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();
}
}
19 changes: 19 additions & 0 deletions src/infrastructures/db/db.env.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Loading