diff --git a/.claude/skills/nestjs/SKILL.md b/.claude/skills/nestjs/SKILL.md index 6384d97..12a8b99 100644 --- a/.claude/skills/nestjs/SKILL.md +++ b/.claude/skills/nestjs/SKILL.md @@ -23,8 +23,8 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스 | Swagger 래퍼 데코레이터 | `src/common/decorators/api-standard-response.decorator.ts` | `ApiStandardResponse` / `ApiErrorResponse` — 문서를 실응답과 일치시킨다 | | 페이지네이션 DTO | `src/common/dto/pagination.dto.ts` | 커서/오프셋 쿼리 + `PageInfoDto` + `toPaginatedResult()` | | 전역 ValidationPipe | `main.ts` | `whitelist/forbidNonWhitelisted/transform` | -| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터 | -| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트 | +| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터. **개발·테스트에서만 열린다**(`isDevNodeEnv`) — 운영에서 API 구조를 공개하지 않는다 | +| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트(루트 `CORS_ORIGIN`, 여러 개면 콤마 구분. 개발·테스트는 localhost 를 더 허용) | | Rate Limiting | `common/guards/throttler.guards.ts` (APP_GUARD) | IP 단위 기본 한도(`THROTTLE_*`) → 인증 → 코드 조회 한도(`@InviteCodeLookup()`, 회원 단위 한 바구니). 초과 시 429 → `REQ_001` | | 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) | | env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) | diff --git a/backend/src/main.ts b/backend/src/main.ts index 314c824..9a5f1c7 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -13,9 +13,14 @@ import type { import { Reflector } from '@nestjs/core'; import { HttpExceptionFilter } from '@/common/filters/http-exception.filter'; +import { isDevNodeEnv } from '@/shared/config/node-env'; +import { parseCommaList } from '@/shared/utils/env'; import { TransformInterceptor } from '@/common/interceptors/transform.interceptor'; import { winstonConfig } from '@/shared/logger/winston.config'; +/** 개발·테스트에서만 더 허용하는 오리진 — 호스트에서 띄운 vite·Swagger. 운영에는 들어가지 않는다 */ +const DEV_CORS_ORIGINS = ['http://localhost:5173', 'http://localhost:3000']; + async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: WinstonModule.createLogger(winstonConfig), @@ -39,20 +44,20 @@ async function bootstrap() { // 1. 보안 헤더 (Helmet) - XSS 방지 및 기본 웹 보안 app.use(helmet()); - // 2. 화이트리스트 기반 CORS 정책 - const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; - const corsOriginEnv = process.env.CORS_ORIGIN; + // 2. 화이트리스트 기반 CORS 정책 — 루트 env 의 CORS_ORIGIN(여러 개면 콤마 구분) + const isDevEnv = isDevNodeEnv(process.env.NODE_ENV); + const whitelist = new Set([ + ...parseCommaList(process.env.CORS_ORIGIN), + ...(isDevEnv ? DEV_CORS_ORIGINS : []), + ]); app.enableCors({ origin: ( origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void, ) => { - // 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용 - const whitelist = [frontendUrl, 'http://localhost:3000']; - if (corsOriginEnv) whitelist.push(corsOriginEnv); - - if (!origin || whitelist.indexOf(origin) !== -1) { + // 서버간의 통신·앱(origin이 undefined)이거나 명시된 리스트일 때만 허용 + if (!origin || whitelist.has(origin)) { callback(null, true); } else { // Error 를 넘기면 500(SYS_001)이 되어 원인 파악이 어려우므로, @@ -80,15 +85,19 @@ async function bootstrap() { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector))); - // 6. Swagger API 문서 설정 - const config = new DocumentBuilder() - .setTitle('Backend API Documentation') - .setDescription('The API documentation conforming to the global guidelines') - .setVersion('1.0') - .addBearerAuth() - .build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api-docs', app, document); + // 6. Swagger API 문서 — 개발·테스트에서만 연다 (운영에서 API 전체 구조를 공개하지 않는다) + if (isDevEnv) { + const config = new DocumentBuilder() + .setTitle('Backend API Documentation') + .setDescription( + 'The API documentation conforming to the global guidelines', + ) + .setVersion('1.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api-docs', app, document); + } // 7. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고 // 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다. @@ -100,10 +109,12 @@ async function bootstrap() { const port = Number(process.env.PORT ?? 3000); await app.listen(port, '0.0.0.0'); Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap'); - Logger.log( - `📘 Swagger docs at http://localhost:${port}/api-docs`, - 'Bootstrap', - ); + if (isDevEnv) { + Logger.log( + `📘 Swagger docs at http://localhost:${port}/api-docs`, + 'Bootstrap', + ); + } } void bootstrap(); diff --git a/backend/src/modules/auth/auth.config.ts b/backend/src/modules/auth/auth.config.ts index 0d240f7..28f2e81 100644 --- a/backend/src/modules/auth/auth.config.ts +++ b/backend/src/modules/auth/auth.config.ts @@ -1,5 +1,6 @@ import { isDevNodeEnv } from '@/shared/config/node-env'; import { parseDurationMs } from '@/shared/utils/duration'; +import { parseCommaList } from '@/shared/utils/env'; /** * 인증 설정 — backend/.env.{APP_ENV} 에서 읽는다 (루트에서 내려주지 않는 백엔드 고유 값). @@ -57,10 +58,7 @@ export function isDevLoginEnabled(env: Env = process.env): boolean { /** 콤마로 나눈 목록 (빈 칸 제거) */ function readList(env: Env, key: string): string[] { - return (readEnv(env, key) ?? '') - .split(',') - .map((v) => v.trim()) - .filter((v) => v !== ''); + return parseCommaList(readEnv(env, key)); } /** 소셜 제공자별 앱 정보 — 비어 있으면 그 제공자 로그인은 거절한다 */ diff --git a/backend/src/modules/upload/upload.module.ts b/backend/src/modules/upload/upload.module.ts index 6a6e478..5faefa0 100644 --- a/backend/src/modules/upload/upload.module.ts +++ b/backend/src/modules/upload/upload.module.ts @@ -3,7 +3,7 @@ import { MulterModule } from '@nestjs/platform-express'; import { TypeOrmModule } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { diskStorage } from 'multer'; -import { mkdirSync } from 'fs'; +import { accessSync, constants as fsConstants, mkdirSync } from 'fs'; import { extname } from 'path'; import { BusinessException } from '@/common/errors/business.exception'; @@ -17,8 +17,16 @@ import { FileSweeper } from './file-sweeper'; import { UploadController } from './upload.controller'; import { UploadService } from './upload.service'; -// 저장 디렉터리는 기동 시 한 번 보장한다 (없으면 multer 가 매 요청 실패한다) +// 저장 디렉터리는 기동 시 한 번 보장한다 (없으면 multer 가 매 요청 실패한다). +// 쓸 수 없으면(운영 서버에서 root 소유로 만들어진 호스트 경로 등) 첫 업로드에서야 500 이 나므로 기동을 막는다 — fail-fast mkdirSync(UPLOAD_DIR, { recursive: true }); +try { + accessSync(UPLOAD_DIR, fsConstants.W_OK); +} catch { + throw new Error( + `업로드 디렉터리에 쓸 수 없습니다: ${UPLOAD_DIR} — 백엔드(uid 1000)가 쓸 수 있게 소유권을 맞춰 주세요 (.claude/skills/docker/SKILL.md "첫 배포 전")`, + ); +} @Module({ imports: [ diff --git a/backend/src/shared/config/database.config.ts b/backend/src/shared/config/database.config.ts index 18fb194..fe51528 100644 --- a/backend/src/shared/config/database.config.ts +++ b/backend/src/shared/config/database.config.ts @@ -80,7 +80,8 @@ export function buildDatabaseOptions(): DataSourceOptions { database: process.env.DB_NAME ?? DEV_DB_DEFAULTS.database, synchronize: shouldSynchronize(), migrationsRun: shouldRunMigrations(), - logging: !isProduction, + // 운영은 쿼리를 남기지 않는다(양·민감정보). 기동 때 적용한 마이그레이션만 남겨 배포 기록으로 쓴다 + logging: isProduction ? ['migration'] : true, // 컬럼은 snake_case, 엔티티 속성은 camelCase namingStrategy: new SnakeNamingStrategy(), extra: { ...POOL_OPTIONS }, diff --git a/backend/src/shared/utils/env.spec.ts b/backend/src/shared/utils/env.spec.ts index f88e1bb..38923e2 100644 --- a/backend/src/shared/utils/env.spec.ts +++ b/backend/src/shared/utils/env.spec.ts @@ -1,4 +1,18 @@ -import { parsePositiveInt } from './env'; +import { parseCommaList, parsePositiveInt } from './env'; + +describe('parseCommaList', () => { + it('콤마로 나누고 앞뒤 공백을 지운다', () => { + expect( + parseCommaList('https://a.com, https://b.com ,https://c.com'), + ).toEqual(['https://a.com', 'https://b.com', 'https://c.com']); + }); + + it('값이 없거나 빈 칸뿐이면 빈 목록이다 (compose 미정의 변수 대응)', () => { + expect(parseCommaList(undefined)).toEqual([]); + expect(parseCommaList('')).toEqual([]); + expect(parseCommaList(' , ,')).toEqual([]); + }); +}); describe('parsePositiveInt', () => { it('정상적인 양의 정수 문자열을 숫자로 변환한다', () => { diff --git a/backend/src/shared/utils/env.ts b/backend/src/shared/utils/env.ts index 366f163..e4bdccf 100644 --- a/backend/src/shared/utils/env.ts +++ b/backend/src/shared/utils/env.ts @@ -5,6 +5,14 @@ * `Number('')` 는 0 이므로 `??` 만으로는 기본값이 적용되지 않는다 — 이 함수로 방어한다. */ +/** 콤마로 나눈 목록 — 앞뒤 공백과 빈 칸은 버린다 (값이 없으면 빈 목록) */ +export function parseCommaList(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map((v) => v.trim()) + .filter((v) => v !== ''); +} + /** * 환경변수를 양의 정수로 파싱한다. * 값이 없거나(undefined·빈 문자열) 숫자가 아니거나 0 이하면 기본값을 반환한다.