Initial commit

This commit is contained in:
2026-10-26 12:28:48 +09:00
commit 18f10cece4
269 changed files with 33654 additions and 0 deletions
@@ -0,0 +1,79 @@
import { join } from 'path';
import { DataSourceOptions } from 'typeorm';
import { parsePositiveInt } from '@/shared/utils/env';
/**
* DB 접속 설정 — 앱(TypeOrmModule)과 CLI(data-source.ts)가 **같은 설정을 공유**한다.
* 두 곳에 따로 적으면 반드시 어긋나므로 여기서만 정의한다.
*/
// 개발 환경 기본값 — **호스트에서 직접 실행할 때** 기준이다.
// docker 환경에서는 compose 가 DB_HOST=db 등을 항상 주입하므로 이 값들이 쓰이지 않고,
// 운영에서는 validateEnv 가 DB_* 를 필수로 요구한다.
const DEV_DB_DEFAULTS = {
host: 'localhost',
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,
};
}
@@ -0,0 +1,127 @@
import { validateEnv } from './env.validation';
/** 지정한 키를 제외한 복사본 (구조분해로 버리면 lint 의 no-unused-vars 에 걸린다) */
const omit = (source: Record<string, string>, ...keys: string[]) =>
Object.fromEntries(
Object.entries(source).filter(([key]) => !keys.includes(key)),
);
const prod = (extra: Record<string, string> = {}) => ({
NODE_ENV: 'production',
DB_HOST: 'db',
DB_USER: 'app_user',
DB_PASSWORD: 'a-real-secret',
DB_NAME: 'project_db',
...extra,
});
describe('validateEnv', () => {
describe('개발 환경', () => {
it('DB 설정이 없어도 통과한다 (코드 기본값 사용)', () => {
expect(() => validateEnv({ NODE_ENV: 'development' })).not.toThrow();
});
it('숫자 항목이 잘못되면 환경과 무관하게 실패한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', PORT: 'abc' }),
).toThrow(/PORT 는 양의 정수/);
});
it('빈 문자열은 통과한다 (compose 가 미정의 변수를 빈 값으로 주입한다)', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }),
).not.toThrow();
});
});
describe('운영 환경', () => {
it('정상 설정이면 통과한다', () => {
expect(() => validateEnv(prod())).not.toThrow();
});
it('필수 DB 설정이 없으면 실패한다', () => {
expect(() => validateEnv(omit(prod(), 'DB_PASSWORD'))).toThrow(
/DB_PASSWORD 가 설정되지 않았습니다/,
);
});
it('예시 placeholder 가 그대로면 실패한다', () => {
expect(() =>
validateEnv(prod({ DB_PASSWORD: 'CHANGE-ME-BEFORE-DEPLOY' })),
).toThrow(/DB_PASSWORD 가 예시 값 그대로입니다/);
expect(() =>
validateEnv(prod({ JWT_SECRET: 'change-me-please' })),
).toThrow(/JWT_SECRET 가 예시 값 그대로입니다/);
});
it('약한 기본값(password)도 placeholder 로 간주한다', () => {
expect(() => validateEnv(prod({ DB_PASSWORD: 'password' }))).toThrow(
/예시 값 그대로/,
);
});
it('공개 주소에 로컬 주소가 남아 있으면 실패한다', () => {
expect(() =>
validateEnv(prod({ CORS_ORIGIN: 'http://localhost:5173' })),
).toThrow(/CORS_ORIGIN 가 운영에서 쓸 수 없는 주소입니다/);
expect(() =>
validateEnv(prod({ BACKEND_API_URL: 'http://127.0.0.1:3000/api' })),
).toThrow(/BACKEND_API_URL 가 운영에서 쓸 수 없는 주소입니다/);
});
it('예시 파일의 example.com 이 그대로면 실패한다', () => {
expect(() =>
validateEnv(prod({ CORS_ORIGIN: 'https://app.example.com' })),
).toThrow(/CORS_ORIGIN 가 운영에서 쓸 수 없는 주소입니다/);
});
it('실제 도메인이면 통과한다', () => {
expect(() =>
validateEnv(
prod({
CORS_ORIGIN: 'https://app.myservice.io',
BACKEND_API_URL: 'https://api.myservice.io/api',
}),
),
).not.toThrow();
});
it('ALLOW_LOCALHOST_URL=true 면 공개 주소 검사를 건너뛴다 (운영 이미지 로컬 검증용)', () => {
expect(() =>
validateEnv(
prod({
CORS_ORIGIN: 'http://localhost:5173',
BACKEND_API_URL: 'http://localhost:3000/api',
ALLOW_LOCALHOST_URL: 'true',
}),
),
).not.toThrow();
});
it('공개 주소 검사는 운영 환경에서만 동작한다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
CORS_ORIGIN: 'http://localhost:5173',
BACKEND_API_URL: 'http://localhost:3000/api',
}),
).not.toThrow();
});
it('문제가 여러 개면 한 번에 모두 보여준다', () => {
const partial = omit(prod(), 'DB_HOST', 'DB_NAME');
try {
validateEnv({ ...partial, PORT: '0' });
fail('예외가 발생해야 한다');
} catch (error) {
const message = (error as Error).message;
expect(message).toContain('DB_HOST');
expect(message).toContain('DB_NAME');
expect(message).toContain('PORT');
}
});
});
});
+145
View File
@@ -0,0 +1,145 @@
/**
* 환경변수 검증 (부팅 시 fail-fast).
*
* 설정이 잘못되면 **서버가 뜨지 않게** 한다.
* 조용히 기본값으로 굴러가다 운영에서 터지는 것보다, 기동 시점에 실패하는 편이 안전하다.
*
* `ConfigModule.forRoot({ validate })` 에 연결되어 있다 (app.module.ts).
*/
/** 운영 배포 전에 반드시 교체해야 하는 예시 값들 — `*.example` 에서 복사한 흔적 */
const PLACEHOLDER_PATTERNS = [/change[-_]?me/i, /^password$/i, /^secret$/i];
/** 운영 환경에서 반드시 채워져야 하는 키 */
const REQUIRED_IN_PRODUCTION = [
'DB_HOST',
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
] as const;
/** 값이 있다면 양의 정수여야 하는 키 */
const POSITIVE_INT_KEYS = [
'PORT',
'DB_PORT',
'THROTTLE_TTL_MS',
'THROTTLE_LIMIT',
] as const;
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
const NO_PLACEHOLDER_IN_PRODUCTION = [
'DB_PASSWORD',
'REDIS_PASSWORD',
'JWT_SECRET',
] as const;
/**
* 운영에서 **공개 주소**여야 하는 키.
* 로컬 주소가 남은 채 배포되면 서버는 정상 기동하지만
* 브라우저가 API 에 닿지 못하거나 CORS 화이트리스트가 실제 도메인을 막아 **조용히** 실패한다.
*/
const PUBLIC_URL_IN_PRODUCTION = ['CORS_ORIGIN', 'BACKEND_API_URL'] as const;
/** 공개 주소로 쓸 수 없는 값 — 로컬 주소와 예시 파일의 예시 도메인 */
const NON_PUBLIC_URL_PATTERNS = [
/localhost/i,
/127\.0\.0\.1/,
/\[::1\]/,
/0\.0\.0\.0/,
/example\.com/i,
];
/**
* 위 공개 주소 검사를 건너뛰는 탈출구.
* 로컬에서 **운영 이미지를 검증**할 때만 쓴다 (예: graceful shutdown 확인 — .claude/skills/docker/SKILL.md).
* 실제 배포에서는 절대 설정하지 않는다.
*/
const ALLOW_LOCALHOST_URL_KEY = 'ALLOW_LOCALHOST_URL';
function readString(
config: Record<string, unknown>,
key: string,
): string | undefined {
const value = config[key];
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed === '' ? undefined : trimmed;
}
function looksLikePlaceholder(value: string): boolean {
return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value));
}
function looksNonPublic(value: string): boolean {
return NON_PUBLIC_URL_PATTERNS.some((pattern) => pattern.test(value));
}
/**
* 환경변수를 검증한다. 문제가 있으면 **모든 문제를 모아** 예외를 던진다.
* (하나씩 고쳐가며 재기동하는 낭비를 막기 위해 한 번에 보여준다)
*/
export function validateEnv(
config: Record<string, unknown>,
): Record<string, unknown> {
const isProduction = readString(config, 'NODE_ENV') === 'production';
const errors: string[] = [];
// 1. 숫자여야 하는 값 — 비어 있으면 코드의 기본값을 쓰므로 통과시킨다
for (const key of POSITIVE_INT_KEYS) {
const value = readString(config, key);
if (value === undefined) continue;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
errors.push(`${key} 는 양의 정수여야 합니다. (현재 값: "${value}")`);
}
}
if (isProduction) {
// 2. 운영 필수값 — 없으면 기동 실패
for (const key of REQUIRED_IN_PRODUCTION) {
if (readString(config, key) === undefined) {
errors.push(`${key} 가 설정되지 않았습니다. (운영 환경 필수)`);
}
}
// 3. 예시 파일의 placeholder 가 그대로 배포되는 것을 막는다
for (const key of NO_PLACEHOLDER_IN_PRODUCTION) {
const value = readString(config, key);
if (value !== undefined && looksLikePlaceholder(value)) {
errors.push(
`${key} 가 예시 값 그대로입니다. 운영용 값으로 교체해 주세요.`,
);
}
}
// 4. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다
const allowLocalhostUrl =
readString(config, ALLOW_LOCALHOST_URL_KEY) === 'true';
if (!allowLocalhostUrl) {
for (const key of PUBLIC_URL_IN_PRODUCTION) {
const value = readString(config, key);
if (value !== undefined && looksNonPublic(value)) {
errors.push(
`${key} 가 운영에서 쓸 수 없는 주소입니다. 실제 도메인으로 교체해 주세요. (현재 값: "${value}")` +
` — 로컬에서 운영 이미지를 검증하는 중이라면 ${ALLOW_LOCALHOST_URL_KEY}=true`,
);
}
}
}
}
if (errors.length > 0) {
throw new Error(
[
'환경변수 설정이 올바르지 않아 서버를 시작할 수 없습니다.',
...errors.map((message) => ` - ${message}`),
'',
'루트 .env.{APP_ENV} 를 확인해 주세요. (.claude/rules/env-structure.md)',
].join('\n'),
);
}
return config;
}
@@ -0,0 +1,22 @@
import { Injectable, Logger, OnApplicationShutdown } from '@nestjs/common';
/**
* 종료 로깅 — 컨테이너가 언제·왜 내려갔는지 남긴다.
*
* `process.on(signal)` 로 직접 잡지 않고 Nest 라이프사이클 훅을 쓰는 이유:
* Nest 는 shutdown hook 처리 후 같은 시그널을 재발생시켜 프로세스를 종료하는데,
* 직접 등록한 리스너는 이 흐름에 간섭해 종료 코드가 달라진다.
*
* 이 훅은 `app.close()` 가 끝난 뒤(= 처리 중인 요청과 커넥션 정리 후) 호출되므로,
* 로그가 남았다는 것은 정상 종료가 완료되었다는 뜻이다.
*/
@Injectable()
export class ShutdownLogger implements OnApplicationShutdown {
private readonly logger = new Logger(ShutdownLogger.name);
onApplicationShutdown(signal?: string): void {
this.logger.log(
`애플리케이션을 정상 종료했습니다. (사유: ${signal ?? '애플리케이션 종료 요청'})`,
);
}
}
@@ -0,0 +1,20 @@
import { utilities as nestWinstonUtilities } from 'nest-winston';
import * as winston from 'winston';
// 표준 로거 설정 — console.log 대체용
// 운영 환경에서는 파일 로테이션 또는 외부 로그 수집기(예: Loki, ELK) 추가 권장
export const winstonConfig: winston.LoggerOptions = {
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.ms(),
nestWinstonUtilities.format.nestLike('App', {
colors: process.env.NODE_ENV !== 'production',
prettyPrint: true,
}),
),
}),
],
};
+27
View File
@@ -0,0 +1,27 @@
import { parsePositiveInt } from './env';
describe('parsePositiveInt', () => {
it('정상적인 양의 정수 문자열을 숫자로 변환한다', () => {
expect(parsePositiveInt('60000', 100)).toBe(60000);
});
it('값이 없으면 기본값을 반환한다', () => {
expect(parsePositiveInt(undefined, 100)).toBe(100);
});
it('빈 문자열이면 기본값을 반환한다 (docker-compose 미정의 변수 대응)', () => {
// compose 는 정의되지 않은 변수를 빈 문자열로 주입한다 — Number('') === 0 이므로 방어가 필요하다
expect(parsePositiveInt('', 100)).toBe(100);
expect(parsePositiveInt(' ', 100)).toBe(100);
});
it('숫자가 아니면 기본값을 반환한다', () => {
expect(parsePositiveInt('abc', 100)).toBe(100);
});
it('0 이하이거나 소수면 기본값을 반환한다', () => {
expect(parsePositiveInt('0', 100)).toBe(100);
expect(parsePositiveInt('-5', 100)).toBe(100);
expect(parsePositiveInt('1.5', 100)).toBe(100);
});
});
+22
View File
@@ -0,0 +1,22 @@
/**
* 환경변수 파싱 유틸 (순수 함수).
*
* docker-compose 는 정의되지 않은 변수를 **빈 문자열**로 주입한다.
* `Number('')` 는 0 이므로 `??` 만으로는 기본값이 적용되지 않는다 — 이 함수로 방어한다.
*/
/**
* 환경변수를 양의 정수로 파싱한다.
* 값이 없거나(undefined·빈 문자열) 숫자가 아니거나 0 이하면 기본값을 반환한다.
*/
export function parsePositiveInt(
value: string | undefined,
fallback: number,
): number {
if (value === undefined || value.trim() === '') return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) return fallback;
return parsed;
}
+13
View File
@@ -0,0 +1,13 @@
// 공통 포맷터 — 부수효과 없는 순수 함수만 작성
/**
* Date 또는 ISO 문자열을 'YYYY-MM-DD' 형식으로 변환
*/
export function formatDate(input: Date | string): string {
const date = typeof input === 'string' ? new Date(input) : input;
if (Number.isNaN(date.getTime())) return '';
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}