fix: 템플릿 점검 결과 반영 — Node 24·이미지 위생·운영 노출 최소화
템플릿 점검에서 나온 항목을 한 번에 반영한다. 항목별 이유: - Node 20 → 24 LTS (Dockerfile·CI): Node 20 은 2026-04-30 에 지원이 끝나 보안 패치가 없다 - backend·frontend .dockerignore 추가: `COPY . .` 가 호스트 node_modules(Windows 바이너리)·dist· .env*(시크릿)를 이미지에 넣고 있었다 - 업로드 디렉터리 쓰기 검사(fail-fast): Linux 에서 Docker 가 root 소유로 만든 바인드 마운트에 비-root 앱이 쓰지 못해 첫 업로드에서야 SYS_001 로 터졌다. 기동 시 해결 명령을 안내한다 - DB 커넥션 풀 100 → 10 (DB_POOL_MAX): PostgreSQL 기본 max_connections(100)를 앱 하나가 다 잡았다 - Swagger 운영 기본 비활성 (SWAGGER_ENABLED): API 구조 전체가 공개 도메인에 노출됐다 - 헬스체크 운영 응답에서 오류 상세 제거: 드라이버 오류 문구에 내부 주소·DB 계정명이 담긴다. 원인은 error 로그로 남긴다 - nginx: location 의 add_header 때문에 정적 자원에서 보안 헤더가 빠지던 문제와, index.html 이 캐시돼 배포 직후 빈 화면이 날 수 있던 문제를 고친다 - vite allowedHosts: true 제거 (DEV_ALLOWED_HOSTS): DNS rebinding 보호가 꺼져 있었다 - 전역 설정을 configureApp() 으로 분리해 main.ts 와 e2e 가 공유: e2e 가 설정을 복사해 재현해 실제 설정과 어긋나도 통과할 수 있었다. PORT 도 빈 값을 방어하는 파서를 거친다 - 쓰이지 않던 WEBSOCKET_PORT 제거: 별도 포트는 같은 도메인 라우팅과 맞지 않는다 - 예제 useUser 가 메시지 대신 에러 코드를 보관하도록 수정, testing.md 표 깨짐 수정, settings.json 허용 목록에서 npm install 제거 (Windows lock 파일 규칙과 충돌) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,9 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
|
||||
> - location 은 `/api`(끝 슬래시 없음), Forward Hostname 에는 경로를 붙이지 않는다 — 경로가 그대로 넘어가야 백엔드 prefix 와 맞는다.
|
||||
> - `BACKEND_API_URL=/api`·`CORS_ORIGIN` 비움이 기본이다. API 를 별도 도메인으로 분리할 때만 실제 도메인을 적는다 —
|
||||
> 그때 `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
|
||||
> - **Linux 서버는 업로드 디렉터리 소유자를 먼저 맞춘다** — 앱은 uid 1000 으로 도는데 Docker 가 없는 디렉터리를 root 소유로 만든다.
|
||||
> `mkdir -p data/uploads && sudo chown 1000:1000 data/uploads` (안 하면 백엔드가 기동에 실패하며 같은 명령을 안내한다)
|
||||
> - Swagger(`/api-docs`)는 운영에서 기본으로 꺼진다 (`SWAGGER_ENABLED`).
|
||||
|
||||
```bash
|
||||
# 운영 환경 배포
|
||||
|
||||
@@ -17,23 +17,25 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
| --- | --- | --- |
|
||||
| **에러 코드 레지스트리** | `src/common/errors/error-codes.ts` | 코드↔메시지↔HTTP 상태 **SSOT**. `npm run sync:error-codes` 로 클라이언트 사전 생성 |
|
||||
| **BusinessException** | `src/common/errors/business.exception.ts` | 에러 코드가 **생성자 필수 인자** — 코드 없는 실패를 만들 수 없다 |
|
||||
| 표준 응답 인터셉터 | `src/common/interceptors/transform.interceptor.ts` | main.ts 전역 등록 — 컨트롤러는 raw 데이터만 반환. `@SkipTransform()` 으로 제외 |
|
||||
| **전역 설정** | `src/app.setup.ts` 의 `configureApp()` | prefix·helmet·CORS·ValidationPipe·필터·인터셉터. **main.ts 와 e2e 가 같은 함수를 쓴다** — 전역 설정은 여기에만 추가한다 |
|
||||
| 표준 응답 인터셉터 | `src/common/interceptors/transform.interceptor.ts` | `configureApp()` 에서 전역 등록 — 컨트롤러는 raw 데이터만 반환. `@SkipTransform()` 으로 제외 |
|
||||
| 전역 예외 필터 | `src/common/filters/http-exception.filter.ts` | 레지스트리 조회로 `{success:false,error:{code,message,traceId}}` 생성 ([[error-codes]]) |
|
||||
| 요청 추적 미들웨어 | `src/common/middleware/request-id.middleware.ts` | `x-request-id` 발급 → 로그·응답 `traceId` ([[logging]]) |
|
||||
| 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 화이트리스트(`shared/config/cors.config.ts` — 같은 도메인 구성이 기본이라 비어 있다. 다른 오리진만 `CORS_ORIGIN` 에 콤마로) |
|
||||
| 전역 ValidationPipe | `app.setup.ts` | `whitelist/forbidNonWhitelisted/transform` |
|
||||
| Swagger | `shared/config/swagger.config.ts` → `/api-docs` | 모든 엔드포인트 데코레이터. **운영에서는 기본 비활성** — `SWAGGER_ENABLED=true/false` 로 강제 |
|
||||
| 보안 미들웨어 | `app.setup.ts` | helmet · CORS 화이트리스트(`shared/config/cors.config.ts` — 같은 도메인 구성이 기본이라 비어 있다. 다른 오리진만 `CORS_ORIGIN` 에 콤마로) |
|
||||
| Rate Limiting | `app.module.ts` 의 `ThrottlerGuard`(APP_GUARD) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정 |
|
||||
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
|
||||
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
|
||||
| DB 설정 | `shared/config/database.config.ts` | 앱·CLI 공유 SSOT. `synchronize` 기본 off |
|
||||
| DB 설정 | `shared/config/database.config.ts` | 앱·CLI 공유 SSOT. `synchronize` 기본 off. 풀 크기 `DB_POOL_MAX`(기본 10 — 인스턴스 수 × 풀 크기가 DB `max_connections` 를 넘지 않게) |
|
||||
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션") |
|
||||
| Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 |
|
||||
| 컨테이너 빌드 | `Dockerfile`(Node 24 LTS) + `.dockerignore` | 호스트 `node_modules`·`dist`·`.env*` 는 빌드 컨텍스트에서 빠진다 — 시크릿을 이미지에 굽지 않는다 |
|
||||
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
|
||||
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]) |
|
||||
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
|
||||
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]). 저장 디렉터리에 쓸 수 없으면 **기동 실패** |
|
||||
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴. 운영에서는 구성요소별 status 만 노출 ([[response-format]]) |
|
||||
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
|
||||
|
||||
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
|
||||
@@ -42,6 +44,9 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
> `app.module.ts` 는 이미 `TypeOrmModule.forRootAsync` 로 연결되어 있다.
|
||||
> 스키마는 **마이그레이션으로 관리**하며 `synchronize` 는 기본 off 다 (아래 "DB 마이그레이션" 참조)
|
||||
> - **도메인 에러 코드**: `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록 → `npm run sync:error-codes`
|
||||
> - **WebSocket**: 별도 포트를 열지 않는다. 게이트웨이를 HTTP 서버에 붙이고 경로를 `/api` 아래에 둔다
|
||||
> (socket.io 면 `@WebSocketGateway({ path: '/api/socket.io' })`). 그래야 같은 도메인의 NPM location `/api` 를 그대로 탄다.
|
||||
> NPM Proxy Host 의 **Websockets Support** 를 켜고, 개발용 vite 프록시(`/api`)에 `ws: true` 를 추가한다.
|
||||
> - `common/{guards,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
|
||||
|
||||
---
|
||||
@@ -127,7 +132,8 @@ export class TransformInterceptor<T> implements NestInterceptor {
|
||||
}
|
||||
```
|
||||
|
||||
- `main.ts`에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor())`
|
||||
- `app.setup.ts` 의 `configureApp()` 에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)))`
|
||||
— main.ts 와 e2e 가 이 함수를 공유한다. 전역 설정을 main.ts 에 직접 추가하지 말 것 (테스트가 실제 설정과 어긋난다)
|
||||
- 실패 응답(`success: false`)은 아래 Exception Filter가 생성한다.
|
||||
- 응답 포맷 SSOT는 [[response-format]] 참조.
|
||||
|
||||
@@ -163,7 +169,7 @@ throw new BusinessException('USER_001', {
|
||||
## Swagger 필수 적용
|
||||
|
||||
```typescript
|
||||
// main.ts
|
||||
// shared/config/swagger.config.ts — main.ts 는 isSwaggerEnabled() 일 때만 setupSwagger(app) 를 호출한다
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('API 문서')
|
||||
.setDescription('프로젝트 API 명세서')
|
||||
@@ -175,6 +181,8 @@ const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api-docs', app, document);
|
||||
```
|
||||
|
||||
- 문서는 API 구조 전체를 공개하므로 **운영에서는 기본으로 꺼진다.** 필요하면 `SWAGGER_ENABLED=true` 로 켜고
|
||||
NPM Access List 등으로 접근을 제한한다 (`SWAGGER_ENABLED=false` 는 개발에서도 끈다).
|
||||
- 모든 Controller에 `@ApiTags()` 적용
|
||||
- 모든 엔드포인트에 `@ApiOperation()` 작성
|
||||
- **응답은 `@ApiResponse` 대신 래퍼 데코레이터를 쓴다** — 컨트롤러는 raw 를 반환하지만 실응답은 `{success,data}` 라 문서가 어긋난다.
|
||||
|
||||
@@ -29,6 +29,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
| env 타입 선언 | `frontend/env.d.ts` | `VITE_API_BASE_URL` |
|
||||
| dev API 프록시 | `vite.config.ts` 의 `server.proxy` | `/api` → 백엔드 (운영 NPM Custom location 과 같은 역할). 없으면 vite 가 SPA 폴백으로 **HTML 을 200 으로** 돌려줘 조용히 실패한다 |
|
||||
| dev 서버 포트 | `vite.config.ts` 의 `loadEnv` | 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 를 읽는다. `strictPort` 로 포트 충돌 시 fail-fast ([[env-structure]]) |
|
||||
| dev 서버 허용 호스트 | `vite.config.ts` 의 `allowedHosts` | 기본은 localhost·IP 만 (DNS rebinding 보호). 터널은 루트 `DEV_ALLOWED_HOSTS` 에 콤마로 — `true` 로 열지 않는다 ([[security]]) |
|
||||
|
||||
> **개발 서버는 호스트에서 띄운다** — `cd frontend && npm run dev` (→ `http://localhost:${FRONTEND_PORT}`).
|
||||
> 컨테이너 vite 는 바인드 마운트 때문에 첫 서빙에 수 분이 걸려 compose profile `frontend` 뒤로 숨겼다 ([[docker]]).
|
||||
@@ -153,18 +154,23 @@ try {
|
||||
// composables/useUser.ts — 도메인별 API Composable
|
||||
export function useUser() {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
// 실패 시 **에러 코드만** 보관한다 — 화면은 code 로 분기하고, 알림 문구는 인터셉터가 이미 띄웠다
|
||||
const errorCode = ref<ErrorCode | null>(null)
|
||||
|
||||
async function getUser(id: number) {
|
||||
loading.value = true
|
||||
errorCode.value = null
|
||||
try {
|
||||
return await api.get(`/users/${id}`)
|
||||
} catch (e) {
|
||||
errorCode.value = isApiError(e) ? e.code : FALLBACK_ERROR_CODE // 메시지를 새로 만들지 않는다
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, getUser }
|
||||
return { loading, errorCode, getUser }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user