From a217d13483f0c1dd25d8148baf2eb4ae040d381c Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 17 Sep 2026 18:34:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20DB=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엔티티를 추가하면 개발에서는 동작하지만 운영 배포 시 테이블이 생성되지 않는 구조적 결함이 있었다. app.module 은 운영에서 migrationsRun 을 켜두었지만 마이그레이션을 만들 수단(DataSource·CLI 스크립트·디렉터리)이 전혀 없었고, 규칙 문서도 "운영에서는 마이그레이션이 강제된다"는 원칙만 있고 절차가 없었다. - CLI 전용 DataSource(src/data-source.ts)와 migration:* 스크립트 추가. ts-node 에 tsconfig-paths 를 등록해 엔티티가 @/ alias 를 써도 동작한다. - DB 접속 설정을 database.config.ts 로 모아 앱과 CLI 가 공유하게 함. 두 곳에 나눠 적으면 반드시 어긋난다. - synchronize 기본값을 false 로 변경 (DB_SYNCHRONIZE=true 로 개발 한정 opt-in). 켜져 있으면 개발 DB 가 항상 최신이라 migration:generate 가 "변경 없음"만 내놓아 마이그레이션을 만들 수 없다 — 이것이 위 결함의 직접 원인이었다. 운영에서는 이 값과 무관하게 항상 비활성화된다. - 엔티티 추가 절차와 주의사항(생성 SQL 검토, 커밋 후 수정 금지)을 문서화하고 커밋 전 체크리스트에 스키마 변경 항목 추가. generate → run → revert 전 과정을 임시 엔티티로 검증했다. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/rules/checklist.md | 12 ++- .claude/skills/nestjs/SKILL.md | 52 ++++++++++++- .env.development.example | 9 +++ .env.production.example | 9 +++ backend/package.json | 8 +- backend/src/app.module.ts | 42 ++--------- backend/src/data-source.ts | 17 +++++ backend/src/migrations/.gitkeep | 0 backend/src/shared/config/database.config.ts | 78 ++++++++++++++++++++ docker-compose.yml | 1 + 10 files changed, 189 insertions(+), 39 deletions(-) create mode 100644 backend/src/data-source.ts create mode 100644 backend/src/migrations/.gitkeep create mode 100644 backend/src/shared/config/database.config.ts diff --git a/.claude/rules/checklist.md b/.claude/rules/checklist.md index b03755c..412b2dd 100644 --- a/.claude/rules/checklist.md +++ b/.claude/rules/checklist.md @@ -46,7 +46,15 @@ > `AUTH_002`(세션 만료)는 **클라이언트 전용** 코드로 서버 응답에는 나타나지 않는다. 자세한 내용은 [[error-codes]]. -## 4. 공유 계약을 바꿀 때 (응답 포맷 · 페이지네이션 · API 규약) +## 4. 엔티티(DB 스키마)를 바꿀 때 + +- [ ] 마이그레이션 생성 — `dc exec backend npm run migration:generate -- src/migrations/<이름>` ([[nestjs]]) +- [ ] 생성된 SQL 검토 — 의도치 않은 `DROP` 이 없는지 **반드시 눈으로 확인** +- [ ] 적용 확인 — `migration:run` 후 의도한 스키마가 되었는지 +- [ ] 엔티티와 마이그레이션 파일을 **함께 커밋** (하나만 올라가면 배포 시 깨진다) +- [ ] `DB_SYNCHRONIZE=true` 로 실험했다면 **끄고** 마이그레이션을 생성했는지 확인 + +## 5. 공유 계약을 바꿀 때 (응답 포맷 · 페이지네이션 · API 규약) - [ ] 백엔드 구현 수정 ([[response-format]] · [[api-design]]) - [ ] 세 클라이언트의 인터셉터/타입 동기화 (`useApi.ts` · `dio_client.dart` · 공유 타입 파일) @@ -55,7 +63,7 @@ --- -## 5. 기능 확장 시 따르는 순서 +## 6. 기능 확장 시 따르는 순서 새 기능은 만들기 전에, 해당 스킬의 **"템플릿 기본 제공 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것). diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index 848129f..83a7226 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -28,6 +28,8 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 | Rate Limiting | `app.module.ts` 의 `ThrottlerGuard`(APP_GUARD) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정 | | 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) | | env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) | +| DB 설정 | `shared/config/database.config.ts` | 앱·CLI 공유 SSOT. `synchronize` 기본 off | +| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션") | | Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 | | 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 | | 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) | @@ -36,7 +38,8 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 > **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현): > - **인증(JWT)**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증 > - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체). -> `app.module.ts` 는 이미 `TypeOrmModule.forRootAsync` 로 연결되어 있으며 **운영에서는 `synchronize:false` + 마이그레이션**이 강제된다 — 이 설정을 바꾸지 말 것 +> `app.module.ts` 는 이미 `TypeOrmModule.forRootAsync` 로 연결되어 있다. +> 스키마는 **마이그레이션으로 관리**하며 `synchronize` 는 기본 off 다 (아래 "DB 마이그레이션" 참조) > - **도메인 에러 코드**: `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록 → `npm run sync:error-codes` > - `common/{guards,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조) @@ -224,6 +227,53 @@ SwaggerModule.setup('api-docs', app, document); --- +## DB 마이그레이션 + +스키마 변경은 **마이그레이션으로 관리**한다. `synchronize` 는 기본적으로 꺼져 있다. + +> **왜 끄는가**: `synchronize: true` 는 엔티티를 DB 에 자동 반영하므로 개발 DB 가 항상 최신 상태가 된다. +> 그러면 `migration:generate` 가 "변경 없음"만 내놓고, 그 코드를 운영에 배포하면 +> (운영은 synchronize 가 꺼져 있고 실행할 마이그레이션도 없어) **테이블이 만들어지지 않는다.** +> 개발과 운영이 같은 경로를 쓰도록 마이그레이션으로 통일한다. + +### 엔티티 추가/변경 절차 + +```bash +# 0) 개발 스택이 떠 있어야 한다 (DB 접속 정보는 루트 .env → compose 주입) +# dc = docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development + +# 1) 엔티티 작성 (src/**/*.entity.ts) + +# 2) 마이그레이션 생성 — 엔티티와 실제 DB 스키마의 차이를 자동으로 계산한다 +dc exec backend npm run migration:generate -- src/migrations/AddUser + +# 3) 생성된 SQL 을 반드시 눈으로 확인한다 (의도치 않은 DROP 이 없는지) +# 4) 적용 +dc exec backend npm run migration:run + +# 5) 엔티티와 마이그레이션 파일을 함께 커밋 +``` + +| 명령 | 용도 | +| --- | --- | +| `npm run migration:generate -- src/migrations/<이름>` | 엔티티 ↔ DB 차이로 자동 생성 | +| `npm run migration:create -- src/migrations/<이름>` | 빈 마이그레이션 (데이터 이관 등 수동 SQL) | +| `npm run migration:run` | 미적용 마이그레이션 실행 | +| `npm run migration:revert` | 마지막 마이그레이션 롤백 | +| `npm run migration:show` | 적용 상태 확인 | + +규칙: + +- **명령은 컨테이너 안에서 실행**한다. DB 접속 정보가 compose 로 주입되기 때문이다. +- 생성된 SQL 을 **검토 없이 커밋하지 않는다.** 컬럼 rename 이 DROP+ADD 로 생성되면 데이터가 날아간다. +- 마이그레이션 파일은 **한번 커밋되면 수정하지 않는다.** 잘못됐으면 새 마이그레이션으로 고친다. +- 운영 배포 시 `migrationsRun: true` 로 자동 실행된다 — 별도 명령이 필요 없다. +- 빠른 실험이 필요하면 개발 환경에서만 `DB_SYNCHRONIZE=true` 로 임시 활성화하되, + **커밋 전에는 끄고 마이그레이션을 생성**한다. 운영에서는 이 값과 무관하게 항상 비활성화된다. +- 접속 설정은 `shared/config/database.config.ts` 하나만 수정한다 (앱과 CLI 가 공유). + +--- + ## ⚠️ 의존성 추가/제거 (Windows 개발 환경 주의) **Windows 에서 `npm install` / `npm uninstall` 을 실행하면 lock 파일에서 Linux 전용 optional 의존성이 잘려나간다.** diff --git a/.env.development.example b/.env.development.example index f7afaee..22890f3 100644 --- a/.env.development.example +++ b/.env.development.example @@ -44,3 +44,12 @@ CORS_ORIGIN=http://localhost:5173 # ========================================== THROTTLE_TTL_MS=60000 THROTTLE_LIMIT=100 + +# ========================================== +# DB 스키마 동기화 (개발 전용, 기본 false) +# true 로 켜면 엔티티 변경이 DB 에 자동 반영되어 빠르게 실험할 수 있지만, +# 그 상태에서는 migration:generate 가 "변경 없음"만 내놓는다. +# 운영으로 갈 코드라면 꺼둔 채 마이그레이션으로 관리할 것. +# (운영 환경에서는 이 값과 무관하게 항상 비활성화된다) +# ========================================== +DB_SYNCHRONIZE=false diff --git a/.env.production.example b/.env.production.example index 7a3a3b0..92ead9e 100644 --- a/.env.production.example +++ b/.env.production.example @@ -44,3 +44,12 @@ CORS_ORIGIN=http://localhost:5173 # ========================================== THROTTLE_TTL_MS=60000 THROTTLE_LIMIT=100 + +# ========================================== +# DB 스키마 동기화 (개발 전용, 기본 false) +# true 로 켜면 엔티티 변경이 DB 에 자동 반영되어 빠르게 실험할 수 있지만, +# 그 상태에서는 migration:generate 가 "변경 없음"만 내놓는다. +# 운영으로 갈 코드라면 꺼둔 채 마이그레이션으로 관리할 것. +# (운영 환경에서는 이 값과 무관하게 항상 비활성화된다) +# ========================================== +DB_SYNCHRONIZE=false diff --git a/backend/package.json b/backend/package.json index 08fa0bd..b3787b5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,7 +19,13 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", - "sync:error-codes": "ts-node -P tsconfig.json scripts/sync-error-codes.ts" + "sync:error-codes": "ts-node -P tsconfig.json scripts/sync-error-codes.ts", + "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d src/data-source.ts", + "migration:generate": "npm run typeorm -- migration:generate", + "migration:create": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:create", + "migration:run": "npm run typeorm -- migration:run", + "migration:revert": "npm run typeorm -- migration:revert", + "migration:show": "npm run typeorm -- migration:show" }, "dependencies": { "@nestjs/cache-manager": "^3.0.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index bb8e822..0c84617 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -10,6 +10,7 @@ import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware'; import { HealthModule } from '@/modules/health/health.module'; import { UserModule } from '@/modules/user/user.module'; import { parsePositiveInt } from '@/shared/utils/env'; +import { buildDatabaseOptions } from '@/shared/config/database.config'; import { validateEnv } from '@/shared/config/env.validation'; import { ShutdownLogger } from '@/shared/logger/shutdown.logger'; @@ -17,16 +18,6 @@ import { ShutdownLogger } from '@/shared/logger/shutdown.logger'; const THROTTLE_DEFAULT_TTL_MS = 60_000; // 집계 구간: 60초 const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 -// 개발 환경 DB 기본값 — compose 가 주입하는 값과 동일해야 한다. -// 운영에서는 validateEnv 가 DB_* 를 필수로 요구하므로 이 값들이 쓰이지 않는다. -const DEV_DB_DEFAULTS = { - host: 'db', - port: 5432, - username: 'postgres', - password: 'postgres', - database: 'project_db', -} as const; - @Module({ imports: [ ScheduleModule.forRoot(), @@ -57,32 +48,13 @@ const DEV_DB_DEFAULTS = { ), }, ]), + // DB 접속 설정은 database.config.ts 하나만 수정한다 (CLI 와 공유) TypeOrmModule.forRootAsync({ - useFactory: () => { - const isProduction = process.env.NODE_ENV === 'production'; - return { - type: 'postgres', - // 개발 편의를 위한 기본값 — 운영 환경 필수값은 validateEnv 가 기동 전에 보장한다. - // (약한 기본값 'password' 로 조용히 동작하던 문제를 제거) - host: process.env.DB_HOST ?? DEV_DB_DEFAULTS.host, - port: parsePositiveInt(process.env.DB_PORT, DEV_DB_DEFAULTS.port), - username: process.env.DB_USER ?? DEV_DB_DEFAULTS.username, - password: process.env.DB_PASSWORD ?? DEV_DB_DEFAULTS.password, - database: process.env.DB_NAME ?? DEV_DB_DEFAULTS.database, - autoLoadEntities: true, - // 운영 환경에서는 절대 synchronize 사용 금지 (스키마 자동 변경 → 데이터 손실 위험) - synchronize: !isProduction, - migrationsRun: isProduction, - logging: !isProduction, - extra: { - max: 100, - connectionTimeoutMillis: 5000, - idleTimeoutMillis: 30000, - }, - entities: [__dirname + '/**/*.entity{.ts,.js}'], - migrations: [__dirname + '/migrations/*{.ts,.js}'], - }; - }, + useFactory: () => ({ + ...buildDatabaseOptions(), + // Nest 모듈에 등록된 엔티티를 자동으로 수집한다 (CLI 는 glob 경로를 사용) + autoLoadEntities: true, + }), }), HealthModule, UserModule, diff --git a/backend/src/data-source.ts b/backend/src/data-source.ts new file mode 100644 index 0000000..0a0dc96 --- /dev/null +++ b/backend/src/data-source.ts @@ -0,0 +1,17 @@ +import 'reflect-metadata'; +import { DataSource } from 'typeorm'; + +import { buildDatabaseOptions } from '@/shared/config/database.config'; + +/** + * TypeORM CLI 전용 DataSource. + * + * 앱 런타임은 app.module.ts 의 TypeOrmModule 을 사용하고, + * 이 파일은 `migration:generate` / `migration:run` 같은 CLI 명령에만 쓰인다. + * 접속 설정은 database.config.ts 를 공유하므로 두 경로가 어긋나지 않는다. + * + * ⚠️ DB 접속 정보(DB_*)는 루트 .env 에서 docker-compose 가 주입하므로, + * 마이그레이션 명령은 **컨테이너 안에서** 실행해야 한다. + * 자세한 절차는 .claude/skills/nestjs/SKILL.md 의 "DB 마이그레이션" 참조. + */ +export default new DataSource(buildDatabaseOptions()); diff --git a/backend/src/migrations/.gitkeep b/backend/src/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/shared/config/database.config.ts b/backend/src/shared/config/database.config.ts new file mode 100644 index 0000000..9edec8c --- /dev/null +++ b/backend/src/shared/config/database.config.ts @@ -0,0 +1,78 @@ +import { join } from 'path'; +import { DataSourceOptions } from 'typeorm'; + +import { parsePositiveInt } from '@/shared/utils/env'; + +/** + * DB 접속 설정 — 앱(TypeOrmModule)과 CLI(data-source.ts)가 **같은 설정을 공유**한다. + * 두 곳에 따로 적으면 반드시 어긋나므로 여기서만 정의한다. + */ + +// 개발 환경 기본값 — compose 가 주입하는 값과 동일해야 한다. +// 운영에서는 validateEnv 가 DB_* 를 필수로 요구하므로 이 값들이 쓰이지 않는다. +const DEV_DB_DEFAULTS = { + host: 'db', + port: 5432, + username: 'postgres', + password: 'postgres', + database: 'project_db', +} as const; + +/** 커넥션 풀 설정 */ +const POOL_OPTIONS = { + max: 100, + connectionTimeoutMillis: 5000, + idleTimeoutMillis: 30000, +} as const; + +export function isProductionEnv(): boolean { + return process.env.NODE_ENV === 'production'; +} + +/** + * 스키마 자동 동기화 사용 여부. + * + * **기본값은 false** 다. synchronize 를 켜두면 개발 DB 가 항상 최신 스키마와 일치해 + * `migration:generate` 가 "변경 없음"만 내놓는다. 그 상태로 운영에 배포하면 + * (운영은 synchronize 가 꺼져 있고 실행할 마이그레이션도 없어) 테이블이 만들어지지 않는다. + * + * 스키마 변경은 마이그레이션으로 관리한다 — 개발/운영이 같은 경로를 쓰게 된다. + * 엔티티를 빠르게 실험할 때만 `DB_SYNCHRONIZE=true` 로 임시 활성화한다(개발 환경 한정). + */ +function shouldSynchronize(): boolean { + // 운영에서는 어떤 값을 주더라도 절대 허용하지 않는다 (스키마 자동 변경 → 데이터 손실 위험) + if (isProductionEnv()) return false; + return process.env.DB_SYNCHRONIZE === 'true'; +} + +/** + * 엔티티·마이그레이션 파일 경로. + * `__dirname` 기준이라 ts-node(src)와 빌드 산출물(dist) 양쪽에서 동작한다. + */ +function resolvePaths(baseDir: string) { + return { + entities: [join(baseDir, '..', '..', '**', '*.entity{.ts,.js}')], + migrations: [join(baseDir, '..', '..', 'migrations', '*{.ts,.js}')], + }; +} + +export function buildDatabaseOptions(): DataSourceOptions { + const isProduction = isProductionEnv(); + const paths = resolvePaths(__dirname); + + return { + type: 'postgres', + host: process.env.DB_HOST ?? DEV_DB_DEFAULTS.host, + port: parsePositiveInt(process.env.DB_PORT, DEV_DB_DEFAULTS.port), + username: process.env.DB_USER ?? DEV_DB_DEFAULTS.username, + password: process.env.DB_PASSWORD ?? DEV_DB_DEFAULTS.password, + database: process.env.DB_NAME ?? DEV_DB_DEFAULTS.database, + synchronize: shouldSynchronize(), + // 운영 배포 시 미적용 마이그레이션을 자동 실행한다 + migrationsRun: isProduction, + logging: !isProduction, + extra: { ...POOL_OPTIONS }, + entities: paths.entities, + migrations: paths.migrations, + }; +} diff --git a/docker-compose.yml b/docker-compose.yml index 3c9dbda..7e8de0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,6 +62,7 @@ services: - DB_USER=${DB_USER} - DB_PASSWORD=${DB_PASSWORD} - DB_NAME=${DB_NAME} + - DB_SYNCHRONIZE=${DB_SYNCHRONIZE} - REDIS_HOST=redis - REDIS_PORT=6379 - REDIS_PASSWORD=${REDIS_PASSWORD}