Skip to content
This repository was archived by the owner on Sep 24, 2026. It is now read-only.
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,30 @@ mysql -h <host> -u <user> -p <db> < 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 <user> -p <db> < 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` 로 **같은 목록을 두 번** 표현한다.
Expand Down
7 changes: 6 additions & 1 deletion src/shared/persistence/kysely/kysely.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
93 changes: 93 additions & 0 deletions src/shared/persistence/schema-guard.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 <user> -p <db> < prisma/sql/${r.sqlFile}`,
);

throw new Error(
`DB 스키마가 코드보다 뒤쳐져 있습니다 (${missing.length}건).\n` +
`${lines.join('\n')}\n` +
'이 상태로 뜨면 앱은 정상으로 보이지만 위 기능이 조용히 실패합니다. 그래서 기동을 막습니다.',
);
}

/** 없는 요소만 돌려준다. 테이블이 없으면 그 테이블의 컬럼 요구는 중복으로 세지 않는다. */
private async findMissing(
requirements: readonly SchemaRequirement[],
): Promise<SchemaRequirement[]> {
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}`);
});
}
}
72 changes: 72 additions & 0 deletions src/shared/persistence/schema-requirements.ts
Original file line number Diff line number Diff line change
@@ -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: '일간 리포트의 공개 코멘트를 저장할 수 없다',
},
];
40 changes: 40 additions & 0 deletions test/persistence.smoke-spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;

Expand Down
Loading