861c8efa6b
세 겹의 제한을 모두 분당 1000회로 상향(테스트 중 429 회피): - main.ts express-rate-limit: 15분 150회 → 1분 1000회 - 전역 ThrottlerModule: 분당 100회 → 1000회 - 라우트별 @Throttle(login/signup/resend/forgot/reset/ai): 3~20회 → 1000회 각 라우트 주석에 운영 권장값을 남겨 추후 복원/ env 화 용이. 운영 배포 전 적정값으로 되돌릴 것. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
129 lines
5.0 KiB
TypeScript
129 lines
5.0 KiB
TypeScript
import { NestFactory } from '@nestjs/core';
|
|
import { AppModule } from './app.module';
|
|
import { Logger, RequestMethod, ValidationPipe } from '@nestjs/common';
|
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
|
import { WinstonModule } from 'nest-winston';
|
|
import helmet from 'helmet';
|
|
import rateLimit from 'express-rate-limit';
|
|
import cookieParser from 'cookie-parser';
|
|
import type {
|
|
Express,
|
|
Request as ExpressRequest,
|
|
Response as ExpressResponse,
|
|
} from 'express';
|
|
|
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
|
import { winstonConfig } from './shared/logger/winston.config';
|
|
|
|
async function bootstrap() {
|
|
const app = await NestFactory.create(AppModule, {
|
|
logger: WinstonModule.createLogger(winstonConfig),
|
|
});
|
|
|
|
// 리버스 프록시(Nginx, Docker 배포 환경 등) 뒤에서 올바른 클라이언트 IP 식별 지원을 위해 설정합니다.
|
|
const expressApp = app.getHttpAdapter().getInstance() as Express;
|
|
expressApp.set('trust proxy', 1);
|
|
|
|
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
|
|
app.setGlobalPrefix('api', {
|
|
// 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용
|
|
exclude: [{ path: '/', method: RequestMethod.GET }],
|
|
});
|
|
|
|
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 추가
|
|
expressApp.get('/', (_req: ExpressRequest, res: ExpressResponse) => {
|
|
res.send('Backend API Server is running 정상 구동 중입니다.');
|
|
});
|
|
|
|
// 0. 쿠키 파서 — HttpOnly 인증 쿠키(access/refresh) 파싱
|
|
app.use(cookieParser());
|
|
|
|
// 1. 보안 헤더 (Helmet) - XSS 방지 및 기본 웹 보안
|
|
app.use(helmet());
|
|
|
|
// 2. 화이트리스트 기반 CORS 정책
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
|
const corsOriginEnv = process.env.CORS_ORIGIN;
|
|
// 운영에서는 로컬 개발 오리진(localhost:3000)을 허용 목록에서 제외
|
|
const corsWhitelist = [
|
|
frontendUrl,
|
|
...(isProduction ? [] : ['http://localhost:3000']),
|
|
...(corsOriginEnv ? [corsOriginEnv] : []),
|
|
];
|
|
|
|
app.enableCors({
|
|
origin: (
|
|
origin: string | undefined,
|
|
callback: (err: Error | null, allow?: boolean) => void,
|
|
) => {
|
|
// 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용
|
|
if (!origin || corsWhitelist.indexOf(origin) !== -1) {
|
|
callback(null, true);
|
|
} else {
|
|
// 클라이언트에는 403 금지 에러로 리턴됨
|
|
callback(new Error('CORS 정책에 의해 차단된 도메인입니다.'));
|
|
}
|
|
},
|
|
credentials: true, // 쿠키 교환 허용
|
|
});
|
|
|
|
// 3. 글로벌 Rate Limiting 적용 (DDoS, 브루트포스 예방)
|
|
// NOTE: 개발/테스트 편의를 위해 분당 1000회로 완화함. 운영 배포 전 적정값으로 되돌리거나 env 화 필요.
|
|
app.use(
|
|
rateLimit({
|
|
windowMs: 60 * 1000, // 1분
|
|
max: 1000, // 1분 IP당 최대 1000개 요청
|
|
message: {
|
|
success: false,
|
|
error: {
|
|
code: 'SYS_001',
|
|
message:
|
|
'일시적인 시스템 오류가 발생했습니다. (요청 한도 초과) 잠시 후 다시 시도해 주세요.', // 글로벌 통합 에러 포맷
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
// 4. 전역 DTO 유효성 파이프 (강력한 검증 단계)
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true, // DTO에 불필요한 값이 오면 삭제
|
|
forbidNonWhitelisted: true, // DTO에 없는 필드가 주입되면 400 에러 발생
|
|
transform: true, // 네트워크 데이터를 DTO나 기본 타입으로 자동 변환
|
|
}),
|
|
);
|
|
|
|
// 5. 프론트엔드 연동을 위한 규격화된 에러 & 성공 페이로드 인터셉터 적용
|
|
app.useGlobalFilters(new HttpExceptionFilter());
|
|
app.useGlobalInterceptors(new TransformInterceptor());
|
|
|
|
// 6. Swagger API 문서 설정 — 운영 환경에서는 노출하지 않는다(공격 표면 축소)
|
|
if (!isProduction) {
|
|
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);
|
|
}
|
|
|
|
// 종료 시그널(SIGTERM/SIGINT) 에 lifecycle 훅 실행 — onModuleDestroy 로 Redis pub/sub 등
|
|
// 외부 연결을 정상 종료(graceful shutdown). 미설정 시 정리 훅이 호출되지 않는다.
|
|
app.enableShutdownHooks();
|
|
|
|
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',
|
|
);
|
|
}
|
|
void bootstrap();
|