generated from ttipo/project-base
fix(backend): 운영 점검 반영 — CORS 콤마 목록·운영 Swagger 닫기·마이그레이션 로그·업로드 디렉터리 확인
- CORS_ORIGIN 을 문자열 통째로 비교해 예시 파일 안내대로 콤마로 여러 개를 주면 어느 것도 맞지 않았고, 운영에서도 localhost 두 곳이 늘 허용됐다 → 콤마 목록으로 읽고 localhost 는 개발·테스트에서만 더한다 - Swagger 가 운영에서도 공개돼 API 전체 구조가 드러났다 → 개발·테스트에서만 연다 - 운영은 DB 로그를 끄면서 기동 때 적용한 마이그레이션 기록도 남지 않았다 → migration 로그만 남긴다 - 업로드 디렉터리에 쓸 수 없어도 기동돼 첫 업로드에서야 500 이 났다 → 기동할 때 확인해 멈춘다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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]]) |
|
||||
|
||||
+32
-21
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/** 소셜 제공자별 앱 정보 — 비어 있으면 그 제공자 로그인은 거절한다 */
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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('정상적인 양의 정수 문자열을 숫자로 변환한다', () => {
|
||||
|
||||
@@ -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 이하면 기본값을 반환한다.
|
||||
|
||||
Reference in New Issue
Block a user