feat(backend): 장소 DB 스키마와 마이그레이션 추가

공공데이터 장소(place)와 운영자 큐레이션(place_curation)을 테이블로 나눠
동기화가 사람이 확인한 값을 덮어쓰지 않게 한다. 앱이 보는 값(큐레이션 >
공공 > 미확인)과 노출 규칙은 place_listing 뷰 한 곳에서만 정한다.

- 노출 규칙(2026-11-02 합의): 노출 중인 장소는 모두 검색, 홈 추천은
  추천(O)+확인일, AI 후보는 1순위 큐레이션 추천·2순위 관광공사 장소.
  확인 안 된 상가정보 매장은 AI 후보가 아니다
- 축제·공연·행사(festival_event), 언어별 텍스트(place_i18n),
  동기화 기록(place_sync_run) 포함
- 코드값은 place.constants.ts 하나로 CHECK 제약·뷰가 공유
- 전역 SnakeNamingStrategy(DB snake_case ↔ TS camelCase), uuid 는 pgcrypto

임시 Postgres 15 에서 생성 → 적용 → 재생성 무차이 → revert 를 확인했고,
뷰 규칙은 8가지 경우로 검증했다. 설계: docs/design/place-db.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 14:51:03 +09:00
parent 52de9917e4
commit 2d0d760d98
17 changed files with 1400 additions and 0 deletions
@@ -1,6 +1,7 @@
import { join } from 'path';
import { DataSourceOptions } from 'typeorm';
import { SnakeNamingStrategy } from '@/shared/config/snake-naming.strategy';
import { parsePositiveInt } from '@/shared/utils/env';
/**
@@ -73,6 +74,10 @@ export function buildDatabaseOptions(): DataSourceOptions {
migrationsRun: isProduction,
logging: !isProduction,
extra: { ...POOL_OPTIONS },
// 테이블·컬럼은 snake_case, TS 속성은 camelCase — 엔티티마다 name 을 적지 않는다
namingStrategy: new SnakeNamingStrategy(),
// uuid 기본값을 gen_random_uuid() 로 만든다(pgcrypto, PostgreSQL 13+ 는 기본 내장 함수)
uuidExtension: 'pgcrypto',
entities: paths.entities,
migrations: paths.migrations,
};
@@ -0,0 +1,33 @@
import { SnakeNamingStrategy } from './snake-naming.strategy';
describe('SnakeNamingStrategy', () => {
const strategy = new SnakeNamingStrategy();
it('클래스 이름으로 테이블 이름을 만들고, 직접 지정한 이름은 그대로 쓴다', () => {
expect(strategy.tableName('PlaceCuration', undefined)).toBe(
'place_curation',
);
expect(strategy.tableName('FestivalEvent', 'festival_event')).toBe(
'festival_event',
);
});
it('속성 이름을 snake_case 컬럼으로 바꾸고, 직접 지정한 컬럼 이름은 그대로 쓴다', () => {
expect(strategy.columnName('verifiedAt', undefined, [])).toBe(
'verified_at',
);
expect(strategy.columnName('verifiedAt', 'checked_on', [])).toBe(
'checked_on',
);
});
it('임베디드 접두어를 붙인다', () => {
expect(strategy.columnName('lat', undefined, ['geoPoint'])).toBe(
'geo_point_lat',
);
});
it('관계 조인 컬럼을 snake_case 로 만든다', () => {
expect(strategy.joinColumnName('place', 'id')).toBe('place_id');
});
});
@@ -0,0 +1,52 @@
import { DefaultNamingStrategy, NamingStrategyInterface } from 'typeorm';
import { toSnakeCase } from '@/shared/utils/snake-case';
/**
* DB 이름 규칙 — 테이블·컬럼은 snake_case, TS 속성은 camelCase.
* 엔티티마다 `name:` 을 적지 않아도 되게 한 곳에서 바꾼다(앱·CLI 가 database.config.ts 로 공유).
* `name:` 을 직접 지정한 경우에는 그 이름을 그대로 쓴다.
*/
export class SnakeNamingStrategy
extends DefaultNamingStrategy
implements NamingStrategyInterface
{
tableName(targetName: string, userSpecifiedName: string | undefined): string {
return userSpecifiedName ?? toSnakeCase(targetName);
}
columnName(
propertyName: string,
customName: string | undefined,
embeddedPrefixes: string[],
): string {
const prefix = toSnakeCase(embeddedPrefixes.concat('').join('_'));
return prefix + (customName ?? toSnakeCase(propertyName));
}
relationName(propertyName: string): string {
return toSnakeCase(propertyName);
}
joinColumnName(relationName: string, referencedColumnName: string): string {
return toSnakeCase(`${relationName}_${referencedColumnName}`);
}
joinTableName(
firstTableName: string,
secondTableName: string,
firstPropertyName: string,
): string {
return toSnakeCase(
`${firstTableName}_${firstPropertyName.replace(/\./g, '_')}_${secondTableName}`,
);
}
joinTableColumnName(
tableName: string,
propertyName: string,
columnName?: string,
): string {
return toSnakeCase(`${tableName}_${columnName ?? propertyName}`);
}
}
@@ -0,0 +1,22 @@
import { toSnakeCase } from './snake-case';
describe('toSnakeCase', () => {
it('camelCase 속성 이름을 snake_case 컬럼 이름으로 바꾼다', () => {
expect(toSnakeCase('sourceId')).toBe('source_id');
expect(toSnakeCase('ldongRegnCd')).toBe('ldong_regn_cd');
expect(toSnakeCase('publicHoursText')).toBe('public_hours_text');
});
it('PascalCase 클래스 이름을 테이블 이름으로 바꾼다(숫자 포함)', () => {
expect(toSnakeCase('PlaceI18n')).toBe('place_i18n');
expect(toSnakeCase('PlaceSyncRun')).toBe('place_sync_run');
});
it('대문자 약어가 이어져도 단어 경계를 지킨다', () => {
expect(toSnakeCase('HTTPServer')).toBe('http_server');
});
it('이미 snake_case 인 이름은 그대로 둔다', () => {
expect(toSnakeCase('place_id')).toBe('place_id');
});
});
+14
View File
@@ -0,0 +1,14 @@
/**
* camelCase·PascalCase 식별자를 snake_case 로 바꾼다 (DB 테이블·컬럼 이름 규칙용).
* 예: sourceId → source_id, PlaceI18n → place_i18n, ldongRegnCd → ldong_regn_cd
*/
export function toSnakeCase(name: string): string {
return (
name
// 소문자·숫자 뒤의 대문자 앞에 밑줄: sourceId → source_Id
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
// 대문자 연속 뒤에 단어가 시작될 때: HTTPServer → HTTP_Server
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.toLowerCase()
);
}