diff --git a/README.md b/README.md index f556833..453dd14 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,30 @@ mysql -h -u -p < prisma/sql/001-index-cleanup.sql npx prisma db pull # 적용 후 schema.prisma 와 대조 ``` +### ⚠️ DDL 을 빠뜨리면 기동이 막힌다 + +사람이 적용하는 방식이라 **코드만 배포되고 DDL 이 빠지는 일**이 구조적으로 가능하다. +그때 앱은 **멀쩡히 뜨고 조용히 못 한다** — 예를 들어 `risk_scores.missing_factors` 가 없으면 +위험도 저장이 매번 실패해 시민 화면에 아무 단계도 안 나오는데, 기동 로그는 정상이고 실패는 +한 시간 뒤 배치에서 처음 드러난다. 그 한 시간은 하필 배포 직후다. + +그래서 기동 시 스키마를 점검하고, 없으면 **뜨지 않는다**(`shared/persistence/schema-guard.ts`). +오류에 무엇이 없고 무엇이 안 되는지와 **적용할 SQL 파일**이 함께 나온다. + +``` +DB 스키마가 코드보다 뒤쳐져 있습니다 (1건). + · risk_scores.missing_factors 없음 → 위험도 저장이 매번 실패해 시민 화면에 아무 단계도 나오지 않는다 + 적용: mysql -u -p < prisma/sql/008-risk-missing-factors.sql +``` + +**`prisma/sql/` 에 파일을 추가하면 `schema-requirements.ts` 에도 함께 적는다.** 안 적으면 +다음 사람이 DDL 을 빠뜨려도 아무도 모른다 — 이 목록의 값은 빠짐없음에 있다. 인덱스처럼 +있으면 빠르고 없으면 느린 것은 적지 않는다(기동을 막을 근거가 아니다). + +⚠️ **점검 자체가 실패하면 경고만 남기고 통과시킨다.** 관리형 DB 에서 `information_schema` +권한이 제한될 수 있는데, 점검기가 서비스 전면 중단의 원인이 되면 안 되기 때문이다 +(레이트 리밋이 Redis 장애 때 fail-open 하는 것과 같은 기준). + ### 값 계약 CHECK 제약 (`prisma/sql/003`) 상태값은 도메인에서 소문자 union 으로, DB 에서 `VARCHAR + CHECK` 로 **같은 목록을 두 번** 표현한다. diff --git a/src/shared/persistence/kysely/kysely.module.ts b/src/shared/persistence/kysely/kysely.module.ts index 565a768..4ecbb78 100644 --- a/src/shared/persistence/kysely/kysely.module.ts +++ b/src/shared/persistence/kysely/kysely.module.ts @@ -1,12 +1,17 @@ import { Global, Module } from '@nestjs/common'; import { KyselyService } from './kysely.service'; +import { SchemaGuard } from '../schema-guard'; /** * 전역 Kysely 모듈. 복잡 조회 어댑터가 KyselyService 를 주입받는다. + * + * SchemaGuard 를 여기 둔 이유 — 스키마 점검은 **DB 연결이 준비된 직후** 한 번 돌아야 하고, + * 이 모듈이 그 연결을 만드는 곳이다. 컨텍스트 모듈에 두면 어느 모듈이 먼저 뜨느냐에 따라 + * 점검 시점이 달라진다. */ @Global() @Module({ - providers: [KyselyService], + providers: [KyselyService, SchemaGuard], exports: [KyselyService], }) export class KyselyModule {} diff --git a/src/shared/persistence/schema-guard.ts b/src/shared/persistence/schema-guard.ts new file mode 100644 index 0000000..6b7409c --- /dev/null +++ b/src/shared/persistence/schema-guard.ts @@ -0,0 +1,93 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { sql } from 'kysely'; +import { KyselyService } from './kysely/kysely.service'; +import { SCHEMA_REQUIREMENTS, SchemaRequirement } from './schema-requirements'; + +/** + * 기동 시 **코드가 가정하는 스키마가 실제로 있는지** 확인한다. + * + * ── 막으려는 사고 ──────────────────────────────────────────────────────────────────── + * DB-first 라 운영에 `prisma migrate` 를 쓰지 않는다. `prisma/sql/*.sql` 을 사람이 적용하므로 + * **코드만 배포되고 DDL 이 빠지는 일**이 구조적으로 가능하다. + * + * ⚠️ 그때 앱은 멀쩡히 뜨고 **조용히 못 한다.** `missing_factors` 가 없으면 위험도 저장이 매번 + * 실패해 시민 화면에 아무 단계도 안 나오는데, 기동 로그는 정상이고 실패는 한 시간 뒤 + * 배치에서 처음 드러난다. 그 한 시간은 하필 배포 직후다. + * + * ── 두 가지 실패를 다르게 다룬다 ───────────────────────────────────────────────────── + * · **없는 것을 확인함** → 기동을 막는다. 조용히 못 하는 것보다 안 뜨는 편이 낫다. + * · **확인 자체가 실패함** → 경고만 남기고 통과시킨다. + * + * 뒤를 통과시키는 이유 — 관리형 DB 에서 `information_schema` 권한이 제한될 수 있는데, + * **점검기가 서비스 전면 중단의 원인이 되면 안 된다.** 레이트 리밋이 Redis 장애 때 + * fail-open 하는 것과 같은 기준이다. 대신 로그에 크게 남긴다. + */ +@Injectable() +export class SchemaGuard implements OnModuleInit { + private readonly logger = new Logger(SchemaGuard.name); + + constructor(private readonly db: KyselyService) {} + + async onModuleInit(): Promise { + let missing: SchemaRequirement[]; + + try { + missing = await this.findMissing(SCHEMA_REQUIREMENTS); + } catch (err) { + // 점검기가 장애의 원인이 되면 안 된다(위 주석). + this.logger.warn( + '스키마 점검을 하지 못했습니다 — **DDL 누락을 확인하지 못한 채 기동합니다.** ' + + 'information_schema 권한을 확인하세요: ' + + (err instanceof Error ? err.message : String(err)), + ); + return; + } + + if (missing.length === 0) { + this.logger.log(`스키마 점검 통과 (${SCHEMA_REQUIREMENTS.length}개 요소)`); + return; + } + + // 무엇을, 왜, 어떻게 고치는지를 한 번에 준다. 기동을 막을 때는 다음 행동이 분명해야 한다. + const lines = missing.map( + (r) => + ` · ${r.table}${r.column ? `.${r.column}` : ''} 없음 → ${r.breaks}\n` + + ` 적용: mysql -u -p < prisma/sql/${r.sqlFile}`, + ); + + throw new Error( + `DB 스키마가 코드보다 뒤쳐져 있습니다 (${missing.length}건).\n` + + `${lines.join('\n')}\n` + + '이 상태로 뜨면 앱은 정상으로 보이지만 위 기능이 조용히 실패합니다. 그래서 기동을 막습니다.', + ); + } + + /** 없는 요소만 돌려준다. 테이블이 없으면 그 테이블의 컬럼 요구는 중복으로 세지 않는다. */ + private async findMissing( + requirements: readonly SchemaRequirement[], + ): Promise { + const tables = [...new Set(requirements.map((r) => r.table))]; + + const tableRows = await sql<{ TABLE_NAME: string }>` + SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (${sql.join(tables)}) + `.execute(this.db); + const existingTables = new Set(tableRows.rows.map((r) => r.TABLE_NAME)); + + const columnRows = await sql<{ TABLE_NAME: string; COLUMN_NAME: string }>` + SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (${sql.join(tables)}) + `.execute(this.db); + const existingColumns = new Set( + columnRows.rows.map((r) => `${r.TABLE_NAME}.${r.COLUMN_NAME}`), + ); + + return requirements.filter((r) => { + if (!existingTables.has(r.table)) { + // 테이블이 통째로 없으면 그 테이블을 요구하는 항목 하나만 보고한다. + return r.column === undefined; + } + return r.column !== undefined && !existingColumns.has(`${r.table}.${r.column}`); + }); + } +} diff --git a/src/shared/persistence/schema-requirements.ts b/src/shared/persistence/schema-requirements.ts new file mode 100644 index 0000000..4e7851f --- /dev/null +++ b/src/shared/persistence/schema-requirements.ts @@ -0,0 +1,72 @@ +/** + * 코드가 **반드시 있다고 가정하는** 스키마 요소. + * + * ── 왜 필요한가 ────────────────────────────────────────────────────────────────────── + * 이 저장소는 DB-first 라 운영에 `prisma migrate` 를 쓰지 않는다. `prisma/sql/*.sql` 을 + * **사람이 확인하고 적용한다.** 그래서 코드만 배포되고 DDL 이 빠지는 일이 구조적으로 가능하다. + * + * ⚠️ 그때 앱은 **멀쩡히 뜬다.** 그리고 조용히 못 한다 — 예를 들어 `missing_factors` 가 없으면 + * 위험도 저장이 매번 실패해서 **시민 화면에 아무 단계도 안 나온다.** 기동 로그는 정상이고, + * 실패는 한 시간 뒤 배치에서 처음 드러난다. + * + * "조용히 열린 채 뜨는 것보다 실패하는 쪽이 낫다" 는 이 저장소의 기준을 스키마에도 적용한다 + * (CORS_ORIGIN 이 운영에서 기동을 막는 것과 같은 이유다). + * + * ── 이 목록을 언제 고치나 ──────────────────────────────────────────────────────────── + * `prisma/sql/` 에 파일을 추가하면서 **함께** 적는다. 안 적으면 다음 사람이 DDL 을 빠뜨려도 + * 아무도 모른다 — 이 목록의 값은 빠짐없음에 있다. + * + * ⚠️ 여기에 적는 것은 **코드가 없으면 못 도는 것**만이다. 인덱스처럼 있으면 빠르고 없으면 + * 느린 것은 적지 않는다. 기동을 막을 근거가 아니다. + */ + +export interface SchemaRequirement { + table: string; + /** 없으면 테이블 자체만 확인한다. */ + column?: string; + /** 이 요소를 만드는 SQL 파일. 오류 메시지에 그대로 실어 운영자가 바로 적용할 수 있게 한다. */ + sqlFile: string; + /** 없으면 무엇이 안 되는가. 기동을 막는 이유를 사람 말로 남긴다. */ + breaks: string; +} + +export const SCHEMA_REQUIREMENTS: readonly SchemaRequirement[] = [ + { + table: 'refresh_tokens', + sqlFile: '002-refresh-tokens.sql', + breaks: '관리자 로그인 세션 재발급이 전부 실패한다', + }, + { + table: 'field_observations', + sqlFile: '004-groundtruth.sql', + breaks: '현장 관측 기록과 예측 대조가 전부 실패한다', + }, + { + table: 'prediction_evaluations', + column: 'actual_granularity', + sqlFile: '007-evaluation-granularity.sql', + breaks: '예측 대조 배치가 매번 실패해 정확도가 영원히 비어 있다', + }, + { + table: 'risk_scores', + column: 'missing_factors', + sqlFile: '008-risk-missing-factors.sql', + breaks: '위험도 저장이 매번 실패해 시민 화면에 아무 단계도 나오지 않는다', + }, + { + table: 'static_guide_translations', + sqlFile: '009-static-guide-translations.sql', + breaks: '안내 문구 조회가 실패해 응급대처법이 화면에서 사라진다', + }, + { + table: 'risk_overrides', + sqlFile: '006-risk-overrides.sql', + breaks: '운영자가 위험 단계를 손으로 올릴 수 없다', + }, + { + table: 'daily_reports', + column: 'public_comment', + sqlFile: '005-daily-report-public-comment.sql', + breaks: '일간 리포트의 공개 코멘트를 저장할 수 없다', + }, +]; diff --git a/test/persistence.smoke-spec.ts b/test/persistence.smoke-spec.ts index 4188159..4e17ae1 100644 --- a/test/persistence.smoke-spec.ts +++ b/test/persistence.smoke-spec.ts @@ -1,4 +1,7 @@ import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { SCHEMA_REQUIREMENTS } from '@shared/persistence/schema-requirements'; import { GUIDE_QUERY, GuideQueryPort } from '@contexts/beach/application/port/out/guide-query.port'; import { Test } from '@nestjs/testing'; import request from 'supertest'; @@ -2341,6 +2344,43 @@ describe('영속성 스모크', () => { * DB 에 저장된 원문 해시와 현재 원문을 맞춰 보는 것이다. 단위 테스트는 해시 비교만 * 확인할 뿐, 시드가 해시를 제대로 심었는지는 확인하지 못한다. */ + /** + * 스키마 점검. + * + * ⚠️ DB 없이는 의미가 없는 테스트다 — 이 점검이 막으려는 것이 "코드는 배포됐는데 DDL 이 + * 안 올라간 상태" 이고, 그건 실제 DB 를 봐야만 알 수 있다. + * + * 이 테스트가 깨지면 **prisma/sql 에 새 파일을 추가하고 적용하지 않았거나**, 요구 목록에 + * 적지 않았다는 뜻이다. 둘 다 배포 사고로 이어진다. + */ + describe('스키마 요구사항', () => { + it('코드가 가정하는 스키마가 실제 DB 에 전부 있다', async () => { + const rows = await prisma.$queryRawUnsafe<{ TABLE_NAME: string; COLUMN_NAME: string }[]>( + `SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()`, + ); + const tables = new Set(rows.map((r) => r.TABLE_NAME)); + const columns = new Set(rows.map((r) => `${r.TABLE_NAME}.${r.COLUMN_NAME}`)); + + const missing = SCHEMA_REQUIREMENTS.filter((req) => + req.column === undefined + ? !tables.has(req.table) + : !columns.has(`${req.table}.${req.column}`), + ); + + expect(missing.map((m) => `${m.table}${m.column ? '.' + m.column : ''} (${m.sqlFile})`)).toEqual( + [], + ); + }); + + it('⚠️ 요구 목록이 가리키는 SQL 파일이 실제로 있다', () => { + // 파일명을 잘못 적으면 오류 메시지가 존재하지 않는 파일을 적용하라고 안내한다. + // 기동을 막을 때는 다음 행동이 분명해야 하므로, 그 안내가 틀리면 안 된다. + for (const req of SCHEMA_REQUIREMENTS) { + expect(existsSync(join(process.cwd(), 'prisma', 'sql', req.sqlFile))).toBe(true); + } + }); + }); + describe('안내 문구 다국어', () => { let guides: GuideQueryPort;