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:
2026-11-08 00:06:02 +09:00
parent 6dcbb45eb9
commit 7897f2de20
41 changed files with 770 additions and 154 deletions
+2
View File
@@ -12,6 +12,8 @@
API 를 별도 도메인으로 분리할 때만 실제 도메인을 적는다 (`localhost`/`example.com` 이 남으면 기동 실패)
- [ ] 운영 서버: NPM 과 공유하는 외부 네트워크 생성 `docker network create proxy` (없으면 compose 가 기동하지 않는다)
→ NPM 에 Proxy Host(`/` → frontend:8080) + Custom location(`/api` → backend:3000) 설정, **Cache Assets 끔** (README "운영 배포")
- [ ] 운영 서버(Linux): 업로드 디렉터리 소유자를 앱 사용자(uid 1000)로 — `mkdir -p data/uploads && sudo chown 1000:1000 data/uploads`
(Docker 가 root 소유로 만들면 백엔드가 **기동에 실패**하고 해결 명령을 로그로 알려준다)
- [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패)
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
— **이 파일이 없으면 `flutter analyze` 가 asset 경고를 낸다**
+3 -2
View File
@@ -36,7 +36,8 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
- **설정이 잘못되면 서버가 뜨지 않아야 한다(fail-fast).** 조용히 기본값으로 굴러가다 운영에서 터지는 것이 더 위험하다.
검증은 `backend/src/shared/config/env.validation.ts` 가 담당하며 `ConfigModule.forRoot({ validate })` 에 연결되어 있다.
- 숫자 항목은 형식 검사 (모든 환경)
- 숫자 항목(`PORT`·`DB_PORT`·`DB_POOL_MAX`·`THROTTLE_*`)은 형식 검사 (모든 환경)
- 참/거짓 항목(`SWAGGER_ENABLED`)은 `true`/`false` 만 허용 (모든 환경) — 오타가 조용히 기본값으로 묻히지 않게
- `CORS_ORIGIN` 은 각 항목이 오리진 형식(`scheme://host[:port]`, 경로 없음)이어야 한다 (모든 환경)
— 경로가 붙으면 브라우저 Origin 헤더와 절대 일치하지 않아 조용히 막힌다
- 운영 환경: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` 필수
@@ -49,5 +50,5 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
- 문제는 하나씩이 아니라 **한 번에 모아서** 보여준다 (재기동 반복 방지)
- 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `BACKEND_API_URL`, `CORS_ORIGIN`, `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`. 실제 목록은 `.env.development.example` 참조.
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `BACKEND_API_URL`, `CORS_ORIGIN`, `SWAGGER_ENABLED`, `DB_*`/`DB_DATA_DIR`/`DB_POOL_MAX`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `DEV_ALLOWED_HOSTS`(호스트 vite 전용). 실제 목록은 `.env.development.example` 참조.
> 실행/배포 명령은 [[docker]], 보안 규칙은 [[security]] 참조.
+8 -1
View File
@@ -43,14 +43,21 @@ exportCsv(@Res() res: Response) { /* ... */ }
// 200 — 정상
{ "status": "ok", "info": { "database": { "status": "up" } }, "error": {}, "details": { ... } }
// 503 — 실패 (원인이 보인다)
// 503 — 실패 (어느 구성요소가 죽었는지 보인다)
{ "status": "error", "info": {}, "error": { "database": { "status": "down", "message": "..." } }, ... }
// 503 — 운영(NODE_ENV=production): 구성요소별 status 만 남기고 message 등 상세는 뺀다
{ "status": "error", "info": {}, "error": { "database": { "status": "down" } }, "details": { "database": { "status": "down" } } }
```
구현: `HealthController` 에 `@SkipTransform()` 을 붙이고, terminus 의 `ServiceUnavailableException` 은
컨트롤러에서 직접 잡아 503 + 원본 본문을 반환한다(전역 필터까지 가지 않게 한다).
같은 성격의 엔드포인트를 추가할 때 이 패턴을 재사용한다.
운영에서 상세를 빼는 이유: 같은 도메인 구성에서 `/api/health` 는 공개 주소로 열려 있고,
드라이버 오류 문구에는 내부 주소·DB 계정명이 담길 수 있다(`connect ECONNREFUSED 172.18.0.2:5432`).
원인은 응답 대신 **서버 로그(error)** 로 남긴다 ([[security]] — 응답에 내부 호스트명을 싣지 않는다).
## 목록 응답 (페이지네이션)
목록 API 의 `data` 는 **항상 아래 구조**를 가진다. 배열을 그대로 반환하지 않는다.
+11
View File
@@ -8,6 +8,8 @@
- ✅ `DB_PASSWORD=change-me-in-local-env`
- 시크릿이 한 번이라도 커밋되었다면 **값을 교체(rotate)** 한다. 커밋을 되돌리는 것만으로는 노출이 해소되지 않는다.
- 로그·에러 응답에 시크릿을 남기지 않는다 ([[logging]]).
- **이미지에도 굽지 않는다.** `backend/`·`frontend/` 의 `.dockerignore` 가 `.env*` 를 빌드 컨텍스트에서 뺀다 —
값은 실행 시 compose 의 `env_file`/`environment` 로만 들어간다. `.dockerignore` 를 지우거나 `.env*` 를 다시 넣지 않는다.
## 인증 · 토큰
@@ -21,6 +23,10 @@
- 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달한다. 전역 `ValidationPipe` 의 `whitelist`/`forbidNonWhitelisted` 를 끄지 않는다
- TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수)
- 에러 응답에 스택·쿼리·내부 호스트명을 싣지 않는다 — 미정의 예외는 항상 `SYS_001` 로 수렴시킨다 ([[error-codes]])
- 헬스체크(`/api/health`)도 운영에서는 구성요소별 status 만 내보낸다 — 드라이버 오류 문구에 내부 주소가 담긴다 ([[response-format]])
- **Swagger 는 운영에서 기본으로 끈다** (`SWAGGER_ENABLED` 비움 = 운영에서만 off). API 구조 전체를 공개하기 때문이다.
운영에서 켜야 하면 `SWAGGER_ENABLED=true` + NPM Access List 등으로 접근을 제한한다.
### 파일 업로드
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
@@ -69,6 +75,11 @@
- **보안 헤더는 CSP 까지** 설정한다 (`frontend/nginx.conf`). 외부 CDN·폰트·분석 스크립트를 추가하면
해당 도메인을 CSP 에 명시한다. `script-src` 에 `'unsafe-inline'`/`'unsafe-eval'` 을 넣으면 CSP 의 의미가 사라진다.
API 는 같은 도메인의 `/api` 라 `connect-src 'self'` 로 충분하다. API 를 별도 도메인으로 분리하면 그 오리진을 추가해야 요청이 차단되지 않는다.
- ⚠️ nginx 는 location 에 `add_header` 가 하나라도 있으면 server 레벨의 보안 헤더를 **전부 상속하지 않는다.**
location 의 캐시 정책은 `add_header` 대신 `expires` 로 지정한다 (`frontend/nginx.conf` 주석 참조).
- **vite dev 서버의 `allowedHosts` 를 `true` 로 열지 않는다** — DNS rebinding 보호가 꺼져 악성 사이트가
개발자 브라우저를 거쳐 dev 서버(소스)와 `/api` 프록시(로컬 백엔드)에 닿을 수 있다.
터널(ngrok 등)이 필요하면 루트 `DEV_ALLOWED_HOSTS` 에 그 도메인만 적는다.
## 의존성
+4 -1
View File
@@ -22,7 +22,6 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
| 스택 | 러너 | 위치·네이밍 | 실행 |
| --- | --- | --- | --- |
| backend | Jest | 단위 `*.spec.ts` (소스 옆), e2e `test/*.e2e-spec.ts` | `npm run test`, `npm run test:e2e` |
| frontend | Vitest | `*.spec.ts` / `*.test.ts` (대상 옆 또는 `__tests__/`) | `npm run test:unit` |
| flutter | flutter_test | `test/` 하위, `*_test.dart` | `flutter test` |
@@ -51,8 +50,12 @@ describe('formatSomething', () => {
| 에러 응답 포맷 | `backend/src/common/filters/http-exception.filter.spec.ts` | 코드/상태/traceId, **내부 정보 미노출**, 검증 상세 사유 미노출 |
| 프론트 에러 사전 | `frontend/src/composables/useApi.spec.ts` | 코드 목록이 백엔드와 일치, 폴백 동작 |
| 앱 에러 사전 | `flutter/test/error_lexicon_test.dart` | 코드 목록이 백엔드와 일치, 폴백 동작 |
| 전역 설정 | `backend/src/app.setup.spec.ts` | main.ts·e2e 가 공유하는 `configureApp()` 의 prefix·응답 래퍼·에러 필터·보안 헤더·CORS (DB 불필요) |
| 응답 계약 (e2e) | `backend/test/app.e2e-spec.ts` | 실제 HTTP 로 래퍼·에러코드·traceId·페이지네이션·헬스체크 형식 검증 |
> **e2e 는 main.ts 와 같은 `configureApp()` 을 쓴다.** 테스트에서 전역 설정을 따로 흉내 내지 않는다 —
> 흉내 낸 설정은 실제와 어긋나도 테스트가 통과해 버린다.
> **e2e 는 DB 가 필요하다** (AppModule 이 TypeORM 을 초기화한다).
> 로컬에서는 개발 스택을 띄운 뒤 컨테이너 안에서 실행하는 것이 가장 간단하다.
> ```bash