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:
2026-10-12 00:04:05 +09:00
parent f60654bed6
commit cffc5a5d06
7 changed files with 71 additions and 31 deletions
+2 -2
View File
@@ -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
View File
@@ -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();
+2 -4
View File
@@ -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));
}
/** 소셜 제공자별 앱 정보 — 비어 있으면 그 제공자 로그인은 거절한다 */
+10 -2
View File
@@ -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: [
+2 -1
View File
@@ -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 },
+15 -1
View File
@@ -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('정상적인 양의 정수 문자열을 숫자로 변환한다', () => {
+8
View File
@@ -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 이하면 기본값을 반환한다.