import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { Logger } from '@nestjs/common'; import type { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { WinstonModule } from 'nest-winston'; import type { Request as ExpressRequest, Response as ExpressResponse, } from 'express'; import { configureApp, createHttpAdapter } from '@/app.setup'; import { ACCESS_TOKEN_COOKIE } from '@/modules/auth/auth.constants'; import { winstonConfig } from '@/shared/logger/winston.config'; import { parsePositiveInt } from '@/shared/utils/env'; import { addAlternativeSecurity } from '@/shared/utils/openapi-security'; import { buildAllowedOrigins, normalizeOrigin, } from '@/shared/utils/request-origin'; /** PORT 가 없거나 비었을 때 듣는 포트 */ const DEFAULT_PORT = 3000; /** Swagger 인증 이름 — `@ApiCookieAuth()` · `@ApiBearerAuth()` 의 기본 이름과 같다 */ const SWAGGER_COOKIE_AUTH = 'cookie'; const SWAGGER_BEARER_AUTH = 'bearer'; async function bootstrap() { // 어댑터는 createHttpAdapter — 라우팅이 대소문자를 가린다(어댑터를 만들 때 켜야 한다 — app.setup.ts). e2e 도 같은 함수를 쓴다 const app = await NestFactory.create( AppModule, createHttpAdapter(), { logger: WinstonModule.createLogger(winstonConfig), // 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정) bodyParser: false, }, ); // 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 (컨테이너 healthcheck 가 읽는다) const expressApp = app.getHttpAdapter().getInstance(); expressApp.get('/', (_req: ExpressRequest, res: ExpressResponse) => { res.send('Backend API Server is running 정상 구동 중입니다.'); }); // 1. 공통 HTTP 파이프라인 — prefix · helmet · 쿠키 · 본문 파서 · 검증 · 표준 응답 // e2e 테스트도 같은 함수를 쓴다 (app.setup.ts) configureApp(app); // 2. 화이트리스트 기반 CORS 정책 — 운영은 CORS_ORIGIN 만, localhost 기본값은 개발에서만. // 화면과 API 를 같은 주소로 배포하면 CORS 는 쓰이지 않는다 (docs/mail/api-contract.md 머리말). // 같은 목록을 상태 변경 요청의 출처 검사(OriginGuard)도 쓴다. const allowedOrigins = new Set( buildAllowedOrigins((key) => process.env[key]), ); app.enableCors({ origin: ( origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void, ) => { // 서버 간 통신(origin 없음)이거나 목록에 있는 출처일 때만 허용 const normalized = origin === undefined ? null : normalizeOrigin(origin); if (!origin || (normalized !== null && allowedOrigins.has(normalized))) { callback(null, true); } else { // Error 를 넘기면 500(SYS_001)이 되어 원인 파악이 어려우므로, // CORS 헤더를 부여하지 않아 브라우저가 차단하도록 한다. callback(null, false); } }, credentials: true, // 쿠키 교환 허용 }); // 3. Rate Limiting 은 AppModule 의 ThrottlerGuard(APP_GUARD)로 일원화되어 있다. // 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다. // (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md) // 4. Swagger API 문서 설정 — 인증은 웹은 HttpOnly 쿠키(access_token), 앱은 Authorization: Bearer (앱 로그인이 준 토큰만 — 계약 1절 "앱 로그인"). // 보호 라우트는 @ApiCookieAuth() 만 적는다 — 가드(JwtAuthGuard)가 두 길을 다 받으므로 Bearer 는 여기서 "또는"으로 한 번에 붙인다 const config = new DocumentBuilder() .setTitle('Backend API Documentation') .setDescription('The API documentation conforming to the global guidelines') .setVersion('1.0') .addCookieAuth(ACCESS_TOKEN_COOKIE, { type: 'apiKey' }, SWAGGER_COOKIE_AUTH) .addBearerAuth( { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: '앱(모바일) 전용 — POST /api/auth/app/login · refresh 가 준 액세스 토큰만 받는다(웹 토큰은 401). 웹은 쿠키를 쓴다', }, SWAGGER_BEARER_AUTH, ) .build(); const document = SwaggerModule.createDocument(app, config); addAlternativeSecurity(document, SWAGGER_COOKIE_AUTH, SWAGGER_BEARER_AUTH); SwaggerModule.setup('api-docs', app, document); // 5. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고 // 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다. // (이 설정이 없으면 배포·재시작 때 처리 중이던 요청이 그대로 끊긴다) app.enableShutdownHooks(); // 종료 로그는 ShutdownLogger(OnApplicationShutdown)가 남긴다. // compose 는 정의되지 않은 변수를 빈 문자열로 넣는다 — Number('') 는 0 이라 parsePositiveInt 로 기본값을 쓴다 const port = parsePositiveInt(process.env.PORT, DEFAULT_PORT); 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();