feat: 기동·종료·헬스체크 견고성 보강
운영에서 조용히 문제를 일으킬 수 있는 지점들을 정리한다. - env 검증(fail-fast): 설정이 잘못되면 서버가 뜨지 않는다. 운영에서 DB_* 누락과 'change-me' 같은 예시 값 배포를 차단하고, 문제를 하나씩이 아니라 한 번에 모아 보여준다. DB_PASSWORD 가 없을 때 'password' 로 조용히 동작하던 폴백도 제거. - graceful shutdown: SIGTERM 시 요청·커넥션을 정리한 뒤 종료한다. 종료 로그는 OnApplicationShutdown 훅으로 남긴다 — process.on(signal) 로 직접 잡으면 Nest 의 종료 흐름에 간섭해 종료 코드가 달라진다. - 헬스체크 두 가지 결함 수정: pingCheck 기본 타임아웃 1초가 짧아 유휴 커넥션 재연결 시 정상 DB 를 down 으로 오판했고, 실패 응답이 SYS_001 로 덮여 어느 구성요소가 죽었는지 알 수 없었다. 모니터링이 읽는 엔드포인트이므로 표준 래퍼·에러 코드에서 제외한다. - Rate limiting 을 @nestjs/throttler 로 일원화 (express-rate-limit 제거). 두 리미터가 겹쳐 헤더의 한도와 실제 동작이 어긋나 있었다. - e2e 를 응답 계약 검증으로 교체. 기존 테스트는 없는 경로를 검사해 이미 실패 상태였다. - 로깅 규칙(logging.md) 신설 — 레벨 기준·민감정보 마스킹·traceId Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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]] 참조.
|
||||
@@ -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>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+49
-18
@@ -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('*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
+20
-22
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
'예상치 못한 오류',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<HealthCheckResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { validateEnv } from './env.validation';
|
||||
|
||||
/** 지정한 키를 제외한 복사본 (구조분해로 버리면 lint 의 no-unused-vars 에 걸린다) */
|
||||
const omit = (source: Record<string, string>, ...keys: string[]) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(source).filter(([key]) => !keys.includes(key)),
|
||||
);
|
||||
|
||||
const prod = (extra: Record<string, string> = {}) => ({
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
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;
|
||||
}
|
||||
@@ -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 ?? '애플리케이션 종료 요청'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<App>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "..",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/src/$1"
|
||||
},
|
||||
"testRegex": "test/.*\\.e2e-spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user