diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md new file mode 100644 index 0000000..fe9dc28 --- /dev/null +++ b/.claude/rules/logging.md @@ -0,0 +1,57 @@ +# 로깅 규칙 + +`console.log` 사용 금지. 백엔드는 winston(`shared/logger/winston.config.ts`), Nest 내부는 `Logger` 를 쓴다. + +## 레벨 기준 + +| 레벨 | 쓰는 상황 | 예 | +| --- | --- | --- | +| `error` | 서버 책임 오류(5xx), 즉시 조치가 필요한 실패 | 미정의 예외, DB 커넥션 실패 | +| `warn` | 사용자 책임 실패(4xx), 예상된 비정상 | 검증 실패, 권한 없음, Rate Limit 초과 | +| `log`(info) | 상태 변화가 있는 주요 이벤트 | 서버 기동, 가입 완료, 결제 승인 | +| `debug` | 개발 환경 진단 | 외부 API 요청/응답 요약 | + +- 4xx 를 `error` 로 남기지 않는다 — 알림 피로도가 올라가 진짜 장애를 놓친다. +- 반복되는 정상 흐름(헬스체크, 폴링)은 로그를 남기지 않는다. + +## 필수 포함 정보 + +모든 에러 로그에는 **에러 코드**와 **추적 ID**를 함께 남긴다. + +``` +[RES_001][3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d] GET /api/users/1 {"userId":1} +``` + +- 추적 ID는 `RequestIdMiddleware` 가 발급하는 `x-request-id` 이며, 실패 응답의 `error.traceId` 와 같은 값이다. +- 사용자 문의가 들어오면 이 값으로 로그를 검색한다 ([[error-codes]]). +- 개발자용 컨텍스트는 `BusinessException` 의 `context` 에 담는다 — 로그에만 남고 응답에는 포함되지 않는다. + +## 민감정보 마스킹 (필수) + +아래 값은 **어떤 레벨에서도 원문 그대로 남기지 않는다.** + +- 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더 +- 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보 +- 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다) + +```typescript +// ❌ 금지 +this.logger.log(`로그인 시도: ${JSON.stringify(dto)}`); + +// ✅ 필요한 식별자만 +this.logger.log(`로그인 시도: email=${maskEmail(dto.email)}`); +``` + +- 마스킹 유틸은 순수 함수로 `shared/utils/` 에 둔다 ([[coding-common]]). + +## 클라이언트 로깅 + +- 프론트/앱에서 사용자 정보를 콘솔에 출력하지 않는다. 운영 빌드에는 디버그 로그를 남기지 않는다. +- 사용자 노출 문구는 로그가 아니라 에러 사전에서 온다 ([[error-codes]]). + +## 운영 확장 + +- 운영 환경은 JSON 포맷 + 외부 수집기(Loki/ELK/CloudWatch) 연동을 권장한다. +- 파일로 남길 경우 로테이션(`winston-daily-rotate-file`)을 적용하고 보존 기간을 정한다. + +> 보안 규칙은 [[security]], 테스트 규칙은 [[testing]] 참조. diff --git a/backend/src/app.controller.spec.ts b/backend/src/app.controller.spec.ts deleted file mode 100644 index d22f389..0000000 --- a/backend/src/app.controller.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); - - appController = app.get(AppController); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); -}); diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts deleted file mode 100644 index f167b96..0000000 --- a/backend/src/app.controller.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; -import { AppService } from './app.service'; - -// 루트 컨트롤러 — 헬스체크/디버그 용 단순 엔드포인트 -@ApiTags('App') -@Controller() -export class AppController { - constructor(private readonly appService: AppService) {} - - @Get('hello') - @ApiOperation({ summary: '동작 확인용 Hello World' }) - getHello(): string { - return this.appService.getHello(); - } -} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f18c50f..bb8e822 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; @@ -6,10 +6,26 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { CacheModule } from '@nestjs/cache-manager'; import { APP_GUARD } from '@nestjs/core'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { HealthModule } from './modules/health/health.module'; -import { UserModule } from './modules/user/user.module'; +import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware'; +import { HealthModule } from '@/modules/health/health.module'; +import { UserModule } from '@/modules/user/user.module'; +import { parsePositiveInt } from '@/shared/utils/env'; +import { validateEnv } from '@/shared/config/env.validation'; +import { ShutdownLogger } from '@/shared/logger/shutdown.logger'; + +// Rate Limiting 기본 정책 — 루트 .env 의 THROTTLE_* 로 덮어쓸 수 있다 (.claude/rules/env-structure.md) +const THROTTLE_DEFAULT_TTL_MS = 60_000; // 집계 구간: 60초 +const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 + +// 개발 환경 DB 기본값 — compose 가 주입하는 값과 동일해야 한다. +// 운영에서는 validateEnv 가 DB_* 를 필수로 요구하므로 이 값들이 쓰이지 않는다. +const DEV_DB_DEFAULTS = { + host: 'db', + port: 5432, + username: 'postgres', + password: 'postgres', + database: 'project_db', +} as const; @Module({ imports: [ @@ -17,17 +33,28 @@ import { UserModule } from './modules/user/user.module'; ConfigModule.forRoot({ isGlobal: true, envFilePath: `.env.${process.env.NODE_ENV || 'development'}`, + // 설정이 잘못되면 조용히 기본값으로 굴러가지 않고 기동 시점에 실패시킨다 + validate: validateEnv, }), // 전역 캐시 매니저 — Redis 도입 시 store 옵션을 redisStore 로 변경 CacheModule.register({ isGlobal: true, ttl: 60_000, // 60초 기본 TTL }), - // 전역 Rate Limiter — main.ts 의 express-rate-limit 와 별개로 라우트 단위 제어 가능 + // 전역 Rate Limiter — API 남용/브루트포스 방지 (프로젝트의 유일한 rate limiting 수단) + // 한도 초과 시 429 → 전역 필터가 REQ_001 로 매핑한다. + // 라우트 단위 조정은 @Throttle({ default: { ttl, limit } }), 제외는 @SkipThrottle(). ThrottlerModule.forRoot([ { - ttl: 60_000, - limit: 100, + // compose 가 미정의 변수를 빈 문자열로 주입하므로 parsePositiveInt 로 방어한다 + ttl: parsePositiveInt( + process.env.THROTTLE_TTL_MS, + THROTTLE_DEFAULT_TTL_MS, + ), + limit: parsePositiveInt( + process.env.THROTTLE_LIMIT, + THROTTLE_DEFAULT_LIMIT, + ), }, ]), TypeOrmModule.forRootAsync({ @@ -35,13 +62,13 @@ import { UserModule } from './modules/user/user.module'; const isProduction = process.env.NODE_ENV === 'production'; return { type: 'postgres', - host: process.env.DB_HOST || 'db', - port: parseInt(process.env.DB_PORT || '5432', 10), - username: - process.env.DB_USER || process.env.DB_USERNAME || 'postgres', - password: process.env.DB_PASSWORD || 'password', - database: - process.env.DB_NAME || process.env.DB_DATABASE || 'project_db', + // 개발 편의를 위한 기본값 — 운영 환경 필수값은 validateEnv 가 기동 전에 보장한다. + // (약한 기본값 'password' 로 조용히 동작하던 문제를 제거) + 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, autoLoadEntities: true, // 운영 환경에서는 절대 synchronize 사용 금지 (스키마 자동 변경 → 데이터 손실 위험) synchronize: !isProduction, @@ -60,13 +87,17 @@ import { UserModule } from './modules/user/user.module'; HealthModule, UserModule, ], - controllers: [AppController], providers: [ - AppService, + ShutdownLogger, { provide: APP_GUARD, useClass: ThrottlerGuard, }, ], }) -export class AppModule {} +export class AppModule implements NestModule { + // 모든 요청에 추적 ID 를 부여한다 (로그·에러 응답 traceId 와 동일 값) + configure(consumer: MiddlewareConsumer): void { + consumer.apply(RequestIdMiddleware).forRoutes('*'); + } +} diff --git a/backend/src/app.service.ts b/backend/src/app.service.ts deleted file mode 100644 index 927d7cc..0000000 --- a/backend/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/backend/src/main.ts b/backend/src/main.ts index 5531264..314c824 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -4,16 +4,17 @@ 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 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'; +import { Reflector } from '@nestjs/core'; + +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, { @@ -54,28 +55,17 @@ async function bootstrap() { if (!origin || whitelist.indexOf(origin) !== -1) { callback(null, true); } else { - // 클라이언트에는 403 금지 에러로 리턴됨 - callback(new Error('CORS 정책에 의해 차단된 도메인입니다.')); + // Error 를 넘기면 500(SYS_001)이 되어 원인 파악이 어려우므로, + // CORS 헤더를 부여하지 않아 브라우저가 차단하도록 한다. + callback(null, false); } }, credentials: true, // 쿠키 교환 허용 }); - // 3. 글로벌 Rate Limiting 적용 (DDoS, 브루트포스 예방) - app.use( - rateLimit({ - windowMs: 15 * 60 * 1000, // 15분 - max: 150, // 15분 IP당 최대 150개 요청 - message: { - success: false, - error: { - code: 'SYS_001', - message: - '일시적인 시스템 오류가 발생했습니다. (요청 한도 초과) 잠시 후 다시 시도해 주세요.', // 글로벌 통합 에러 포맷 - }, - }, - }), - ); + // 3. Rate Limiting 은 AppModule 의 ThrottlerGuard(APP_GUARD)로 일원화되어 있다. + // 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다. + // (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md) // 4. 전역 DTO 유효성 파이프 (강력한 검증 단계) app.useGlobalPipes( @@ -88,7 +78,7 @@ async function bootstrap() { // 5. 프론트엔드 연동을 위한 규격화된 에러 & 성공 페이로드 인터셉터 적용 app.useGlobalFilters(new HttpExceptionFilter()); - app.useGlobalInterceptors(new TransformInterceptor()); + app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector))); // 6. Swagger API 문서 설정 const config = new DocumentBuilder() @@ -100,6 +90,13 @@ async function bootstrap() { const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api-docs', app, document); + // 7. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고 + // 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다. + // (이 설정이 없으면 배포·재시작 때 처리 중이던 요청이 그대로 끊긴다) + app.enableShutdownHooks(); + + // 종료 로그는 ShutdownLogger(OnApplicationShutdown)가 남긴다. + 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'); @@ -108,4 +105,5 @@ async function bootstrap() { 'Bootstrap', ); } + void bootstrap(); diff --git a/backend/src/modules/health/health.controller.spec.ts b/backend/src/modules/health/health.controller.spec.ts new file mode 100644 index 0000000..9cd9f36 --- /dev/null +++ b/backend/src/modules/health/health.controller.spec.ts @@ -0,0 +1,84 @@ +import { HttpStatus, ServiceUnavailableException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + HealthCheckResult, + HealthCheckService, + TypeOrmHealthIndicator, +} from '@nestjs/terminus'; +import { Response } from 'express'; + +import { HealthController } from './health.controller'; +import { SKIP_TRANSFORM_KEY } from '@/common/decorators/skip-transform.decorator'; + +const okResult = { + status: 'ok', + info: { database: { status: 'up' } }, + error: {}, + details: { database: { status: 'up' } }, +} as unknown as HealthCheckResult; + +const downResult = { + status: 'error', + info: {}, + error: { database: { status: 'down', message: 'timeout' } }, + details: { database: { status: 'down', message: 'timeout' } }, +}; + +describe('HealthController', () => { + let controller: HealthController; + let check: jest.Mock; + let res: { status: jest.Mock }; + + beforeEach(async () => { + check = jest.fn(); + res = { status: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + { provide: HealthCheckService, useValue: { check } }, + { provide: TypeOrmHealthIndicator, useValue: { pingCheck: jest.fn() } }, + ], + }).compile(); + + controller = module.get(HealthController); + }); + + it('헬스체크는 표준 응답 래핑에서 제외된다 (모니터링 도구가 원본 형식을 읽는다)', () => { + // SkipTransform 메타데이터가 컨트롤러에 붙어 있어야 인터셉터가 래핑을 건너뛴다 + const skip: unknown = Reflect.getMetadata( + SKIP_TRANSFORM_KEY, + HealthController, + ); + expect(skip).toBe(true); + }); + + it('정상이면 terminus 결과를 그대로 반환한다', async () => { + check.mockResolvedValue(okResult); + + const result = await controller.check(res as unknown as Response); + + expect(result).toEqual(okResult); + // 성공 시에는 상태 코드를 건드리지 않는다 + expect(res.status).not.toHaveBeenCalled(); + }); + + it('실패하면 503 과 함께 어느 구성요소가 죽었는지 그대로 노출한다', async () => { + check.mockRejectedValue(new ServiceUnavailableException(downResult)); + + const result = await controller.check(res as unknown as Response); + + expect(res.status).toHaveBeenCalledWith(HttpStatus.SERVICE_UNAVAILABLE); + // SYS_001 로 덮어쓰지 않고 원인을 유지해야 한다 + expect(result).toEqual(downResult); + expect(JSON.stringify(result)).toContain('database'); + }); + + it('terminus 예외가 아닌 오류는 전역 필터로 넘긴다', async () => { + check.mockRejectedValue(new Error('예상치 못한 오류')); + + await expect(controller.check(res as unknown as Response)).rejects.toThrow( + '예상치 못한 오류', + ); + }); +}); diff --git a/backend/src/modules/health/health.controller.ts b/backend/src/modules/health/health.controller.ts index 341b6ec..03c2e29 100644 --- a/backend/src/modules/health/health.controller.ts +++ b/backend/src/modules/health/health.controller.ts @@ -1,12 +1,40 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + Controller, + Get, + HttpStatus, + Res, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; import { HealthCheck, + HealthCheckResult, HealthCheckService, TypeOrmHealthIndicator, } from '@nestjs/terminus'; +import type { Response } from 'express'; -// 헬스체크 엔드포인트 — Docker healthcheck, 모니터링 시스템에서 사용 +import { SkipTransform } from '@/common/decorators/skip-transform.decorator'; + +// DB ping 타임아웃 — 기본값 1000ms 는 너무 짧다. +// 커넥션 풀의 유휴 커넥션이 정리된 뒤(idleTimeoutMillis) 재연결하는 경우 1초를 넘겨 +// 정상인 DB 를 down 으로 오판하고 헬스체크가 간헐적으로 실패한다. +const DB_PING_TIMEOUT_MS = 5_000; + +/** + * 헬스체크 엔드포인트 — Docker healthcheck, 모니터링 시스템이 읽는다. + * + * 이 엔드포인트는 **사람이 아니라 기계가 소비**하므로 표준 응답 래퍼와 에러 코드 규칙에서 제외한다. + * (`{success,data}` 로 감싸거나 실패를 SYS_001 로 덮어쓰면 "어느 컴포넌트가 죽었는지"가 사라진다) + * + * - 성공 200: `{ status: 'ok', info: { database: { status: 'up' } }, ... }` + * - 실패 503: `{ status: 'error', error: { database: { status: 'down', message } }, ... }` + * + * 주기적으로 호출되므로 rate limit 대상에서도 제외한다 (한도를 소진시켜 실제 요청을 막으면 안 된다). + */ +@SkipThrottle() +@SkipTransform() @ApiTags('Health') @Controller('health') export class HealthController { @@ -17,8 +45,29 @@ export class HealthController { @Get() @HealthCheck() - @ApiOperation({ summary: '애플리케이션 상태 확인' }) - check() { - return this.health.check([() => this.db.pingCheck('database')]); + @ApiOperation({ + summary: '애플리케이션 상태 확인 (표준 응답 래퍼 미적용)', + }) + @ApiResponse({ status: 200, description: '모든 구성요소 정상' }) + @ApiResponse({ + status: 503, + description: '하나 이상의 구성요소 비정상 — 본문의 error 에 원인이 담긴다', + }) + async check( + @Res({ passthrough: true }) res: Response, + ): Promise { + try { + return await this.health.check([ + () => this.db.pingCheck('database', { timeout: DB_PING_TIMEOUT_MS }), + ]); + } catch (error) { + // terminus 는 실패 시 ServiceUnavailableException 을 던지며 본문에 상세 결과를 담는다. + // 전역 필터가 SYS_001 로 덮어쓰지 않도록 여기서 직접 503 과 원본 본문을 반환한다. + if (error instanceof ServiceUnavailableException) { + res.status(HttpStatus.SERVICE_UNAVAILABLE); + return error.getResponse() as HealthCheckResult; + } + throw error; + } } } diff --git a/backend/src/shared/config/env.validation.spec.ts b/backend/src/shared/config/env.validation.spec.ts new file mode 100644 index 0000000..e67221e --- /dev/null +++ b/backend/src/shared/config/env.validation.spec.ts @@ -0,0 +1,78 @@ +import { validateEnv } from './env.validation'; + +/** 지정한 키를 제외한 복사본 (구조분해로 버리면 lint 의 no-unused-vars 에 걸린다) */ +const omit = (source: Record, ...keys: string[]) => + Object.fromEntries( + Object.entries(source).filter(([key]) => !keys.includes(key)), + ); + +const prod = (extra: Record = {}) => ({ + 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('문제가 여러 개면 한 번에 모두 보여준다', () => { + 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'); + } + }); + }); +}); diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts new file mode 100644 index 0000000..8ac8692 --- /dev/null +++ b/backend/src/shared/config/env.validation.ts @@ -0,0 +1,102 @@ +/** + * 환경변수 검증 (부팅 시 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; + +function readString( + config: Record, + 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)); +} + +/** + * 환경변수를 검증한다. 문제가 있으면 **모든 문제를 모아** 예외를 던진다. + * (하나씩 고쳐가며 재기동하는 낭비를 막기 위해 한 번에 보여준다) + */ +export function validateEnv( + config: Record, +): Record { + 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} 가 예시 값 그대로입니다. 운영용 값으로 교체해 주세요.`, + ); + } + } + } + + if (errors.length > 0) { + throw new Error( + [ + '환경변수 설정이 올바르지 않아 서버를 시작할 수 없습니다.', + ...errors.map((message) => ` - ${message}`), + '', + '루트 .env.{APP_ENV} 를 확인해 주세요. (.claude/rules/env-structure.md)', + ].join('\n'), + ); + } + + return config; +} diff --git a/backend/src/shared/logger/shutdown.logger.ts b/backend/src/shared/logger/shutdown.logger.ts new file mode 100644 index 0000000..4f00950 --- /dev/null +++ b/backend/src/shared/logger/shutdown.logger.ts @@ -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 ?? '애플리케이션 종료 요청'})`, + ); + } +} diff --git a/backend/src/shared/utils/env.spec.ts b/backend/src/shared/utils/env.spec.ts new file mode 100644 index 0000000..f88e1bb --- /dev/null +++ b/backend/src/shared/utils/env.spec.ts @@ -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); + }); +}); diff --git a/backend/src/shared/utils/env.ts b/backend/src/shared/utils/env.ts new file mode 100644 index 0000000..366f163 --- /dev/null +++ b/backend/src/shared/utils/env.ts @@ -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; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 36852c5..7f6875a 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -1,25 +1,136 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; import request from 'supertest'; +import type { Response } from 'supertest'; import { App } from 'supertest/types'; -import { AppModule } from './../src/app.module'; -describe('AppController (e2e)', () => { +import { AppModule } from '@/app.module'; +import { HttpExceptionFilter } from '@/common/filters/http-exception.filter'; +import { TransformInterceptor } from '@/common/interceptors/transform.interceptor'; +import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware'; + +/** + * 세 플랫폼이 공유하는 응답 계약을 실제 HTTP 요청으로 검증한다. + * + * ⚠️ DB 연결이 필요하다 (AppModule 이 TypeORM 을 초기화한다). + * 로컬: docker 개발 스택을 띄운 뒤 실행. CI: postgres 서비스 컨테이너 사용. + */ +/** supertest 의 body 는 any 이므로 계약 형태로 좁혀서 읽는다 */ +interface ContractBody { + success?: boolean; + data?: { + items?: unknown[]; + pageInfo?: { + hasNext: boolean; + nextCursor: string | null; + total: number | null; + }; + }; + error?: { code?: string; message?: string; traceId?: string }; + status?: string; + details?: unknown; +} + +const body = (res: Response): ContractBody => res.body as ContractBody; + +describe('응답 계약 (e2e)', () => { let app: INestApplication; - beforeEach(async () => { + beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); + + // main.ts 와 동일한 전역 설정을 적용해야 실제 응답과 같아진다 + app.setGlobalPrefix('api'); + app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware())); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector))); + await app.init(); }); - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); + afterAll(async () => { + await app.close(); + }); + + it('성공 응답은 { success, data } 로 감싸진다', async () => { + const res = await request(app.getHttpServer()) + .get('/api/users') + .expect(200); + + expect(body(res).success).toBe(true); + expect(body(res).data).toHaveProperty('items'); + expect(body(res).data).toHaveProperty('pageInfo'); + }); + + it('목록 응답의 pageInfo 는 공유 계약 형태를 지킨다', async () => { + const res = await request(app.getHttpServer()) + .get('/api/users') + .expect(200); + + const pageInfo = body(res).data?.pageInfo; + + expect(Object.keys(pageInfo ?? {}).sort()).toEqual([ + 'hasNext', + 'nextCursor', + 'total', + ]); + expect(typeof pageInfo?.hasNext).toBe('boolean'); + expect(pageInfo?.nextCursor).toBeNull(); + expect(typeof pageInfo?.total).toBe('number'); + }); + + it('없는 리소스는 RES_001 과 traceId 를 반환한다', async () => { + const res = await request(app.getHttpServer()) + .get('/api/users/999999') + .expect(404); + + expect(body(res).success).toBe(false); + expect(body(res).error?.code).toBe('RES_001'); + expect(typeof body(res).error?.traceId).toBe('string'); + }); + + it('검증 실패는 VAL_001 이며 상세 사유를 노출하지 않는다', async () => { + const res = await request(app.getHttpServer()) + .post('/api/users') + .send({ email: '잘못된형식', password: '1' }) + .expect(400); + + expect(body(res).error?.code).toBe('VAL_001'); + // class-validator 의 개별 메시지가 그대로 새어나가면 안 된다 + expect(JSON.stringify(body(res))).not.toContain('must be an email'); + }); + + it('DTO 에 없는 필드는 거부된다 (forbidNonWhitelisted)', async () => { + const res = await request(app.getHttpServer()) + .get('/api/users?정의되지않은파라미터=1') + .expect(400); + + expect(body(res).error?.code).toBe('VAL_001'); + }); + + it('헬스체크는 표준 래퍼 없이 terminus 원본 형식을 반환한다', async () => { + const res = await request(app.getHttpServer()).get('/api/health'); + + expect(body(res)).not.toHaveProperty('success'); + expect(body(res)).toHaveProperty('status'); + expect(body(res)).toHaveProperty('details'); + }); + + it('모든 응답에 x-request-id 헤더가 붙는다', async () => { + const res = await request(app.getHttpServer()).get('/api/users'); + + expect(typeof res.headers['x-request-id']).toBe('string'); }); }); diff --git a/backend/test/jest-e2e.json b/backend/test/jest-e2e.json index e9d912f..9062338 100644 --- a/backend/test/jest-e2e.json +++ b/backend/test/jest-e2e.json @@ -1,8 +1,15 @@ { - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "..", "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", + "moduleNameMapper": { + "^@/(.*)$": "/src/$1" + }, + "testRegex": "test/.*\\.e2e-spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }