Compare commits

..

30 Commits

Author SHA1 Message Date
ttipo 3f703b10f4 ci(backend): 테스트 파일까지 타입 검사 추가
빌드(tsconfig.build.json)는 spec 을 제외하고 ts-jest 는 isolatedModules 로 타입을 보지 않아,
테스트 파일의 타입 오류가 어떤 검사에도 걸리지 않았다. 타입이 틀린 목·픽스처로도 테스트가 통과한다.

- backend 에 type-check 스크립트(tsc --noEmit) 추가 — src·test·scripts 를 모두 본다
- CI backend 잡에 Type check 단계 추가, 커밋 전 점검·역할 문서와 PR 템플릿에 반영
- 이 검사로 드러난 기존 계약 테스트(error-codes.spec.ts)의 타입 오류 수정 — 테스트 의미는 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:07:53 +09:00
ttipo 7897f2de20 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>
2026-11-08 00:06:02 +09:00
ttipo 6dcbb45eb9 refactor: 프론트·API 를 같은 도메인(/api)으로 통합
브라우저가 프론트와 API 를 서로 다른 도메인으로 호출하던 구조를 같은 도메인의 /api 경로로 통합한다.
운영은 Nginx Proxy Manager 의 Proxy Host(/ → frontend:8080)와 Custom location(/api → backend:3000)이,
개발은 vite dev 프록시가 같은 규칙으로 백엔드에 넘긴다.

- 같은 출처 호출이라 CORS 설정이 필요 없고 CSP connect-src 'self' 가 그대로 동작한다
  (기존 예시대로 별도 도메인에 배포하면 CSP 에 막혀 API 호출이 실패했다)
- 프론트 번들의 baseURL 이 /api 상대경로라 도메인이 바뀌어도 다시 빌드할 필요가 없다
- NPM 설정 절차와 주의점(Cache Assets 끄기, proxy 네트워크 생성)을 README "운영 배포"에 정리했다
- 프록시 설정이 빠지면 frontend nginx 가 /api 를 404 로 막는다 — index.html(200)로 조용히 실패하지 않게

함께 고친 CORS 문제:
- CORS_ORIGIN 에 콤마로 여러 오리진을 적으면 문자열 전체를 하나로 비교해 모두 막히던 버그
- 운영에서도 localhost:5173·localhost:3000 이 credentials 포함으로 항상 허용되던 문제
- 형식이 잘못된 오리진(경로 포함 등)은 기동 시점에 실패하도록 검증 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:04:03 +09:00
ttipo 2f94b6db12 docs(rules): 에이전트 팀원 역할 규칙 추가
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
지금까지 .claude 에는 "무엇을 지키는가"(rules)와 "어떻게 만드는가"(skills)만 있었고
"누가 어디까지 책임지는가"를 적을 자리가 없었다.

역할 파일은 규칙을 복사하지 않고 참조만 한다. 구현 규칙을 옮겨 적으면 스킬과
진실 공급원이 둘로 갈라져, 한쪽만 고쳐졌을 때 어느 쪽이 맞는지 알 수 없게 된다.
역할 고유의 것(담당 범위·경계·완료 기준·인계 형식)만 담는다.

리뷰어와 페르소나 둘은 frontmatter 에서 Edit/Write 를 빼 도구 수준으로 읽기 전용이다.
CLAUDE.md 에는 @ import 하지 않는다 — 호출 시점에 로드되는 역할 정의다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:44:56 +09:00
ttipo 52e671cedb fix(backend): 운영 공개 URL 에 로컬 주소가 남으면 기동을 실패시킴
.env.production.example 이 개발용 값(localhost)을 그대로 담고 있어, 복사해 배포하면
서버는 정상 기동하지만 CORS 화이트리스트가 실제 도메인을 막고 프론트 번들은
localhost 를 호출한다 — 브라우저에서만 조용히 실패해 원인을 찾기 어렵다.

- CORS_ORIGIN·BACKEND_API_URL 에 로컬 주소나 예시 도메인이 있으면 운영에서 기동 실패
- 예제 값을 도메인 형태로 교체 (그대로 두면 이 검사에 걸린다)
- BACKEND_API_URL 은 앱이 쓰지 않지만 검사를 위해 compose 가 backend 에 주입한다
- 로컬에서 운영 이미지를 검증할 때만 ALLOW_LOCALHOST_URL=true 로 건너뛴다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:42:23 +09:00
ttipo 94357d26bd perf(docker): 개발 프론트엔드를 컨테이너에서 호스트 vite 로 전환
Windows 바인드 마운트(./frontend:/app) 너머의 소스를 vite 가 모듈마다 읽는 데다
폴링 감시까지 항상 켜져 있어 첫 서빙에 수 분이 걸렸다. 호스트에서 띄우면 2초다.

- dev 오버라이드의 frontend 를 compose profile `frontend` 뒤로 숨긴다 (운영은 그대로)
- 폴링 감시는 CHOKIDAR_USEPOLLING 으로 컨테이너에서만 켠다
- dev 서버 포트·프록시 대상을 루트 .env.{mode} 에서 읽는다 (포트 SSOT 유지)
  포트 충돌 시 조용히 옮기지 않도록 strictPort — CORS_ORIGIN·포트 매핑이 묶여 있다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:41:19 +09:00
ttipo 40af8a2e09 fix(ci): 모든 잡에 timeout-minutes 추가 — 멈춘 잡의 러너 점유 방지
CI (Flutter) / Flutter (analyze · test) (pull_request) Failing after 16m51s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 11m15s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 9m12s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m51s
CI (Flutter) / Flutter (analyze · test) (push) Failing after 16m24s
CI / Backend (lint · test · e2e · build) (push) Failing after 21s
CI / Frontend (lint · type-check · test) (push) Failing after 14m24s
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
러너 환경에서 잡이 간헐적으로 멈추는 것을 확인했다.
Flutter 잡이 CPU 0%·네트워크 정지 상태로 25분 넘게 러너를 점유해
이후 잡들이 시작조차 하지 못했다. Frontend 잡에서도 같은 증상이 나타났다.

러너가 하나뿐이면 멈춘 잡 하나가 이후 전부를 굶긴다.
워크플로를 분리해도 점유 자체는 막지 못하므로 상한이 필요하다.

- backend 20분 / frontend 15분 / error-codes-sync 10분 / flutter 25분
- 증상과 즉시 해제 방법(잡 컨테이너 중지)을 ci/README.md 에 정리

CI 작성 시 넣었어야 할 기본 안전장치를 빠뜨렸던 것을 보완한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:48:31 +09:00
ttipo 962e060236 ci: Flutter 검사를 별도 워크플로로 분리 — 기본 비활성
CI (Flutter) / Flutter (analyze · test) (push) Failing after 21m26s
CI / Backend (lint · test · e2e · build) (push) Successful in 10m31s
CI / Frontend (lint · type-check · test) (push) Successful in 10m18s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 8m21s
Flutter 잡은 SDK 다운로드로 매 실행 수 분이 걸리는데,
backend/frontend 만 고친 커밋에서는 검사할 것이 없다.
러너가 잡을 하나씩 처리하므로 다른 검사까지 같이 느려졌다.

ci-flutter.yml 로 분리하고 flutter/ 경로가 바뀐 푸시·PR 에서만 실행되게 한다.
실제로 모바일 앱을 쓰기 시작하면 별도 설정 없이 자동으로 동작하고,
그 전까지는 CI 시간을 차지하지 않는다. workflow_dispatch 로 수동 실행도 가능하다.

Redis 를 compose profile 로 기본 비활성화한 것과 같은 방향이다 —
기반은 갖춰두되 쓰지 않을 때는 자원을 차지하지 않게 한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:11:06 +09:00
ttipo 1c0176640e fix(ci): 서비스 컨테이너 포트 매핑 제거 — 잡이 시작조차 못 하던 문제
CI / Backend (lint · test · e2e · build) (push) Successful in 10m30s
CI / Frontend (lint · type-check · test) (push) Successful in 9m52s
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI / Flutter (analyze · test) (push) Has been cancelled
러너를 붙이고 첫 실행에서 backend 잡의 모든 스텝이 checkout 부터 failure 로 표시됐다.
스텝이 실패한 게 아니라 서비스 컨테이너가 뜨지 못해 잡 준비 단계에서 죽은 것이다.

원인은 postgres 서비스의 ports: 5432:5432 였다.
개발 DB(project-db)가 이미 호스트 5432 를 점유하고 있어 바인딩이 충돌했다.

Gitea 러너는 잡을 컨테이너로 실행하고 서비스와 같은 네트워크에 붙이므로
호스트 포트 매핑이 애초에 필요 없고, 접근도 localhost 가 아니라 서비스 이름으로 해야 한다.
(GitHub Actions 는 잡이 VM 위에서 직접 돌아 localhost + ports 를 쓰는 점이 다르다)

- 서비스의 ports 매핑 제거
- e2e 스텝의 DB_HOST 를 localhost → postgres(서비스 이름)로 변경
- 두 플랫폼의 차이와 "모든 스텝이 한꺼번에 실패" 증상을 ci/README.md 에 정리

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:45:26 +09:00
ttipo ea46fe7817 ci: Gitea Actions 러너 설정 추가
CI / Backend (lint · test · e2e · build) (push) Failing after 1m10s
CI / Frontend (lint · type-check · test) (push) Successful in 10m58s
CI / Flutter (analyze · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI 워크플로는 저장소에 있지만 실행 주체(러너)가 없어 동작하지 않는 상태였다.
러너가 없으면 워크플로는 "Waiting for a runner" 로 대기만 한다.

- ci/docker-compose.runner.yml — act_runner 를 별도 스택으로 분리.
  앱 스택은 개발할 때만 띄우지만 러너는 항상 켜져 있어야 하므로 생명주기가 다르다.
- ci/.env.example — 등록 토큰은 ci/.env 에만 두며 커밋되지 않는다.
- ci/README.md — 설치 절차, 증상별 문제 해결, 잡별 리소스 특성.
- 라벨 기본값이 워크플로의 runs-on(ubuntu-latest)과 일치하는지 교차 확인했다.
  불일치는 잡이 영원히 대기하는 가장 흔한 원인이다.

러너가 도커 소켓에 접근해 사실상 호스트 관리자 권한을 갖는다는 점도 문서에 명시했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:23:37 +09:00
ttipo 20754edf26 fix: 회귀 점검에서 드러난 계약 테스트 실패와 compose 경고 수정
CI / Flutter (analyze · test) (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
FILE_001/FILE_002 를 추가하면서 클라이언트 계약 테스트의 기대 목록을 갱신하지 않아
frontend·flutter 테스트가 깨져 있었다. 테스트가 제 역할을 한 셈이지만,
전수 목록 비교라는 설계 자체가 문제였다.

세 플랫폼의 사전은 backend 레지스트리에서 생성되므로 목록 일치는 구조적으로 보장되고
CI 가 드리프트를 검사한다. 전수 비교는 도메인 코드를 추가할 때마다 무의미하게 깨질 뿐이다.
검증 대상을 "클라이언트 로직이 실제로 참조하는 코드의 존재"와 네이밍 규약으로 바꾼다.
(flutter 에는 누락돼 있던 네이밍 규약 검사도 추가)

또한 기존 .env 에 없는 키들(DB_SYNCHRONIZE, THROTTLE_*)로 compose 가 매번 경고를
출력하고 있었다. 코드 기본값과 동일한 값을 compose 레벨 기본값으로 명시해 해소한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:00:11 +09:00
ttipo aba274e56f feat(backend): 파일 업로드 공통 골격 추가 (로컬 디스크)
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / Flutter (analyze · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
템플릿이 나중에 반드시 필요해질 공용 기능을 미리 갖춘다.
multer 취약점을 해소한 김에, 안전한 업로드 기준도 함께 세운다.

- modules/upload — 단일 파일 업로드 엔드포인트(POST /api/files)와 재사용 가능한 검증 설정
- 확장자와 MIME 을 **둘 다** 검사하는 허용목록 방식.
  확장자만 보면 위장이 쉽고, MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
  .html/.svg/.js 등 브라우저가 실행할 수 있는 형식은 허용 목록에서 제외.
- 저장 파일명은 서버가 UUID 로 생성한다. 클라이언트 파일명을 경로에 쓰면 경로 탈출이 가능하다.
- 크기 한도(UPLOAD_MAX_SIZE_MB, 기본 5MB) 초과 시 FILE_002(413).
- 도메인 에러 코드 FILE_001/FILE_002 추가 후 세 플랫폼 사전 동기화.

함께 고친 것:

- 전역 필터가 프레임워크의 영문 메시지를 그대로 노출하고 있었다.
  크기 초과 시 multer 의 "File too large" 가 사용자에게 보였다 —
  이제 코드에 대응하는 레지스트리 문구만 사용하며, 재정의는 BusinessException 으로만 가능하다.
- sync:error-codes 가 컨테이너 안에서 실행되면 형제 디렉터리를 찾지 못해
  cryptic 한 mkdir 오류를 냈다. 원인과 해결 방법을 알리도록 보강.
- 업로드 볼륨 경로에 compose 기본값을 주어 기존 .env 에 키가 없어도 동작하게 함.

실제 multipart 요청으로 정상 업로드·형식 거부·MIME 위장·경로 탈출 시도·크기 초과·
파일 누락 시나리오를 모두 검증했다.

알려진 한계(확장자·MIME 검사는 파일 내용을 보지 않는다)는 security.md 에 명시하고,
업로드 디렉터리를 정적 서빙하지 말 것을 규칙으로 남겼다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:47:10 +09:00
ttipo 750cd6362a fix(backend): multer 취약점 해소 — overrides 로 2.4.0 고정
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / Flutter (analyze · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
@nestjs/platform-express@11 이 multer 를 2.2.0 으로 정확 고정하는데,
이 버전에는 깊게 중첩된 필드명으로 자원을 소모시키는 DoS 취약점(high)이 있다.
상위 패키지가 버전을 박아두어 npm audit fix 로는 해결되지 않았다.

platform-express@12 가 쓰는 multer 버전을 확인하니 2.4.0 이었다.
즉 v12 로 올려서 얻는 결과가 multer 상향이므로, overrides 로 하위 패키지만 올려
Nest 생태계(v11) 업그레이드 없이 동일한 결과를 얻는다.

- npm audit: 10건 → 0건
- 같은 메이저(2.x) 안에서의 상향이라 API 호환 위험이 낮고,
  lint·단위 31개·e2e 7개·빌드·런타임 기동·Docker 빌드까지 확인했다.
- 이 패턴(전이 의존성 취약점을 overrides 로 끊기)과 주의사항을 security.md 에 규칙으로 정리.
  platform-express 를 v12 로 올릴 때 override 를 제거해야 한다는 조건도 함께 기록.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:11:39 +09:00
ttipo 32eb4fafca fix(frontend): dev 서버 API 프록시 추가 — 프론트가 백엔드를 호출하지 못하던 문제
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / Flutter (analyze · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
useApi 의 baseURL 이 '/api' 상대경로인데 vite dev 서버에 프록시가 없어,
브라우저의 API 요청이 백엔드에 닿지 않고 SPA 폴백으로 index.html 을 받고 있었다.

더 나쁜 것은 조용히 실패한다는 점이다 — HTTP 200 에 Content-Type: text/html 이라
인터셉터의 success 판별을 통과하지 못하고 HTML 문자열이 그대로 반환된다.
템플릿의 화면이 아직 API 를 호출하지 않아 드러나지 않았을 뿐이다.

- vite.config.ts 에 /api 프록시 추가. 대상은 VITE_DEV_API_PROXY 로 조정 가능하며
  docker 개발 환경에서는 compose 가 http://backend:3000 을 주입한다.
- DB_HOST 기본값을 'db' 에서 'localhost' 로 변경.
  compose 는 항상 DB_HOST=db 를 주입하므로 이 기본값은 호스트에서 직접 실행할 때만
  쓰인다 — 'db' 는 호스트에서 해석되지 않아 로컬 실행이 불가능했다.
- README 의 "Docker 없이 로컬 개발" 절차가 위 두 문제로 동작하지 않았다. 실제로 되는 절차로 교체.

프록시 경유 요청이 JSON 을 반환하고 SPA 라우팅도 유지되는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:28:14 +09:00
ttipo e4262b2816 refactor(docker): Redis 를 기본 비활성으로 전환 (Compose profile)
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / Flutter (analyze · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
Redis 컨테이너가 항상 떠 있었지만 애플리케이션은 사용하지 않았다.
쓰지 않는 컨테이너는 메모리를 차지하고 "이게 왜 떠 있지" 하는 혼란을 준다.

- redis 서비스를 profiles: ["redis"] 로 숨겨 기본 기동에서 제외
- backend 의 redis 의존성을 required: false 로 두어 없어도 정상 기동
- 루트 .env 의 COMPOSE_PROFILES 한 줄로 켤 수 있게 함
  (데이터 볼륨과 REDIS_* 는 유지되어 켜면 이전 상태가 그대로 이어진다)

profile 미지정 시 서비스 목록에서 제외되는 것, redis 없이 앱이 정상 동작하는 것,
COMPOSE_PROFILES=redis 로 다시 기동되는 것을 모두 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:48:02 +09:00
ttipo a217d13483 feat(backend): DB 마이그레이션 워크플로 추가
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / Flutter (analyze · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
엔티티를 추가하면 개발에서는 동작하지만 운영 배포 시 테이블이 생성되지 않는
구조적 결함이 있었다. app.module 은 운영에서 migrationsRun 을 켜두었지만
마이그레이션을 만들 수단(DataSource·CLI 스크립트·디렉터리)이 전혀 없었고,
규칙 문서도 "운영에서는 마이그레이션이 강제된다"는 원칙만 있고 절차가 없었다.

- CLI 전용 DataSource(src/data-source.ts)와 migration:* 스크립트 추가.
  ts-node 에 tsconfig-paths 를 등록해 엔티티가 @/ alias 를 써도 동작한다.
- DB 접속 설정을 database.config.ts 로 모아 앱과 CLI 가 공유하게 함.
  두 곳에 나눠 적으면 반드시 어긋난다.
- synchronize 기본값을 false 로 변경 (DB_SYNCHRONIZE=true 로 개발 한정 opt-in).
  켜져 있으면 개발 DB 가 항상 최신이라 migration:generate 가 "변경 없음"만 내놓아
  마이그레이션을 만들 수 없다 — 이것이 위 결함의 직접 원인이었다.
  운영에서는 이 값과 무관하게 항상 비활성화된다.
- 엔티티 추가 절차와 주의사항(생성 SQL 검토, 커밋 후 수정 금지)을 문서화하고
  커밋 전 체크리스트에 스키마 변경 항목 추가.

generate → run → revert 전 과정을 임시 엔티티로 검증했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:34:16 +09:00
ttipo 700a100a38 docs: 규칙 문서를 실제 구현에 맞춰 갱신
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / Flutter (analyze · test) (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
규칙과 구현이 어긋나 있던 부분을 바로잡고, 작업하며 발견한 함정을 기록한다.

- 스킬 문서의 "기본 제공 골격" 표를 실제 파일 목록과 일치시킴
- 커밋 전 체크리스트에 테스트 실행·에러 코드 동기화 절차 추가
- 커밋 규칙에 scope·브랜치 전략·PR 규칙 보강
- 새 규칙 파일(api-design, logging)을 CLAUDE.md import 목록에 등록하고,
  규칙 추가 시 등록이 필수라는 메타 규칙 명시
- README 를 현재 구조에 맞게 갱신 (traceId·페이지네이션·에러 코드 SSOT·로컬 개발 명령)

작업 중 실제로 겪은 함정들을 규칙으로 남긴다:
- Windows 에서 npm install 하면 Linux 전용 optional 의존성이 lock 에서 잘려
  Docker 의 npm ci 가 실패한다 → 의존성 변경은 컨테이너 안에서 수행
- --build 만으로는 node_modules 익명 볼륨이 갱신되지 않아
  새 패키지가 컨테이너에 반영되지 않는다 → --renew-anon-volumes 필요
- compose 는 미정의 변수를 빈 문자열로 주입해 Number('')===0 함정이 생긴다
- dev 는 PID 1 이 npm 이라 graceful shutdown 확인은 운영 이미지로 해야 한다
- Redis 는 컨테이너만 떠 있고 사용처가 없다 — 확장 지점임을 명시

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:32:23 +09:00
ttipo 258e0e37cc fix(flutter): 디렉터리 구조를 규칙에 맞추고 깨진 테스트 교체
- 공통 위젯 위치를 lib/widgets/ 에서 규칙상 목표인 lib/shared/widgets/ 로 이동
- 기본 생성된 카운터 위젯 테스트가 실제 앱과 맞지 않아 실패 상태였다 —
  앱 렌더링을 검증하는 테스트로 교체

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:31:46 +09:00
ttipo dac1603b03 ci: 규칙을 자동으로 강제하는 검사 추가
지금까지 "커밋 전 lint/test 통과"는 문서상의 약속일 뿐 강제되지 않았다.

- CI(GitHub Actions) 추가 — 세 스택의 lint·test·build 와 backend e2e 를 PR 마다 실행.
  error-codes-sync 잡은 sync 를 재실행한 뒤 git diff 로 생성 파일 드리프트를 검사한다.
  레지스트리만 고치고 sync 를 빠뜨리면 클라이언트 사전이 조용히 어긋나기 때문.
- lint 스크립트에 --max-warnings 0 적용. 기존에는 경고가 있어도 통과해
  "Warning 0" 규칙이 사실상 무의미했다. 자동 수정은 lint:fix 로 분리.
- 문서로만 있던 규칙을 린트로 이관:
  any/dynamic 금지, console/print 금지, Vue 인라인 스타일·Options API 금지,
  Dart const 누락·await 누락, HttpException 직접 생성 차단(BusinessException 유도).
- backend 에 @/ 경로 alias 설정 (tsconfig paths + jest moduleNameMapper).
  공통 규칙이 요구하는 절대경로 alias 가 backend 에만 빠져 있었다.
- npm audit 취약점 10건 중 9건 해결.
  남은 multer(high)는 platform-express v12 가 필요해 Nest v11 생태계와 충돌하고,
  파일 업로드 엔드포인트가 없어 도달 경로가 없다 — security.md 에 재검토 조건 기록.
- PR 템플릿 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:31:34 +09:00
ttipo e7c00e6f65 fix: 보안 규칙을 어기던 컨테이너·네트워크 설정 수정
security.md 가 금지한다고 적어둔 것을 실제 설정이 어기고 있었다.

- 프론트엔드 운영 컨테이너가 root 로 nginx 를 실행하고 있었다.
  nginx-unprivileged 로 전환하고 포트를 8080 으로 맞춤(비-root 는 1024 미만 바인드 불가).
- dev 오버라이드가 사용하는 빌드 스테이지에도 USER node 적용.
  익명 볼륨이 node 소유로 생성되도록 chown 을 USER 전환 앞에 둔다
  (그렇지 않으면 vite 가 node_modules/.vite 캐시를 쓰지 못한다).
- dev 의 DB/Redis 포트가 0.0.0.0 에 바인딩되어 같은 LAN 에 노출돼 있었다 → 127.0.0.1 로 한정.
- CSP 헤더 추가. 대신 현대 브라우저에서 권장되지 않는 X-XSS-Protection 은 제거.
- CORS 차단 시 Error 를 던져 500(SYS_001)이 되던 것을 헤더 미부여 방식으로 변경.
- 헬스체크가 localhost 를 ::1 로 해석해 항상 실패하던 문제 수정(127.0.0.1 명시).
- graceful shutdown 을 위해 init(tini)과 stop_grace_period 적용.
- *.example 의 실제같은 비밀번호를 placeholder 로 교체하고,
  비어 있던 서비스 env 예시에 안내를 채움.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:31:04 +09:00
ttipo bf102d758f 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>
2026-09-17 14:30:29 +09:00
ttipo c5d165275c feat: 응답 계약 정의와 페이지네이션·추적 ID 추가
세 클라이언트가 공유하는 계약 중 정의가 빠져 있던 부분을 채운다.

- 목록 응답 형태를 {items, pageInfo} 로 고정하고 커서/오프셋 쿼리 DTO 제공
- @SkipTransform() 으로 스트림·204 등 래핑하면 안 되는 응답을 제외
- ApiStandardResponse/ApiErrorResponse 로 Swagger 문서를 실응답과 일치시킴
  (컨트롤러는 raw 를 반환하지만 실제 응답은 래핑되어 문서가 어긋나 있었다)
- RequestIdMiddleware 로 모든 요청에 x-request-id 부여
- API 설계 규약(api-design.md) 신설 — REST 경로·쿼리·날짜/ID 표현·버전 관리

또한 .gitignore 의 data/ 패턴이 모든 depth 에 매칭되어
flutter/lib/features/user/data/ 의 소스가 커밋되지 않던 문제를 함께 고친다.
(user_provider.dart 가 import 하는 파일이 저장소에 없어 flutter analyze 가 깨져 있었다)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:30:07 +09:00
ttipo f7ea60b8e7 feat: 에러 코드 SSOT 체계 도입
사용자에게 노출되는 실패가 에러 코드 없이 만들어질 수 없도록 구조를 바꾼다.

기존에는 예외 필터가 HTTP 상태로 코드를 추론해 도메인별 에러를 표현할 수 없었고,
세 플랫폼의 에러 사전을 손으로 맞춰야 해서 어긋나기 쉬웠다.

- 코드↔메시지↔HTTP 상태를 error-codes.ts 레지스트리 한 곳으로 통합
- BusinessException 은 에러 코드를 생성자 필수 인자로 받아 코드 누락을 타입으로 차단
- sync:error-codes 스크립트가 프론트·플러터 사전을 자동 생성 (수동 복사 제거)
- 429 전용 코드 REQ_001 추가 (기존에는 "시스템 오류"로 잘못 안내)
- AUTH_002(세션 만료)를 클라이언트에서 실제로 발행하도록 구현
- 응답에 traceId 를 실어 로그와 대조 가능하게 함

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:29:15 +09:00
ttipo 3b397b7788 fix: 기존 코드의 타입·린트 정합성 보강 (lint·type-check 통과)
- backend: HttpExceptionFilter status 를 HttpStatus 로 타입 지정(enum 비교),
  TransformInterceptor 를 Observable<T> 로, main.ts expressApp 캐스팅·bootstrap void 처리
- frontend: useApi 의 ErrorCodeLexicon 폴백 타입 보강(noUncheckedIndexedAccess)
- eslint --fix 포맷 정리(user 컨트롤러/서비스, 페이지)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:58:02 +09:00
ttipo b35ddfc9c3 feat: 공통 컴포넌트·인증·Riverpod 기초 골격 추가
- frontend: BaseButton 공통 컴포넌트, auth.store(세션 인증)·useAuth(로그인 흐름),
  라우터 가드를 auth.store 와 연동
- flutter: dio_provider(인터셉터 적용 Dio DI), features/user 수직 슬라이스
  (model·repository·provider) 예제

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:58:01 +09:00
ttipo 6306160dda refactor: .claude 규칙 체계를 skills·rules 구조로 재편
- 슬래시 커맨드(nestjs/vue3/flutter)를 정식 스킬(.claude/skills)로 전환하고
  docker 스킬을 신설(루트 command.md 이관)
- 공통 규칙을 .claude/rules 로 모듈화(coding-common·response-format·error-codes·
  git-convention·security·env-structure·testing·checklist)하고 CLAUDE.md 에서 @import
- 파일·식별자 네이밍 규약, 테스트 규칙 추가
- .claude/settings.json(권한·환경) 추가
- CLAUDE.md·README.md 참조 갱신, 단독 실행(non-Docker) 안내 정리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:58:01 +09:00
ttipo 49fef58c5f chore: 줄바꿈·에디터 설정 추가 (.gitattributes, .editorconfig)
저장소 줄바꿈을 LF로 정규화하고 에디터 공통 포맷(UTF-8, 2-space)을 정의해
OS·에디터에 따른 불필요한 diff를 방지한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:58:01 +09:00
ttipo c5896fbdb1 command.md 업데이트 2026-06-22 12:34:10 +09:00
ttipo 5cf52d3beb .env.production.example 업데이트 2026-06-22 12:33:32 +09:00
ttipo 6e0c745f5c docker-compose.dev.yml 업데이트 2026-06-17 18:14:27 +09:00
132 changed files with 7102 additions and 1343 deletions
+56
View File
@@ -0,0 +1,56 @@
# 에이전트 팀원 규칙
이 디렉터리는 **역할(누가 무엇까지 책임지는가)** 을 정의한다.
**어떻게 만드는가**는 `.claude/skills/`, **무엇을 지키는가**는 `.claude/rules/` 가 담당한다.
```
.claude/rules/ 무엇을 지키는가 (응답 포맷·에러 코드·보안 …) — 전원 공통
.claude/skills/ 어떻게 만드는가 (nestjs·vue3·flutter·docker) — 스택별
.claude/agents/ 누가 맡는가 (이 디렉터리) — 역할별
```
## 역할 목록
| 역할 | 파일 | 주 책임 | 코드 수정 |
| --- | --- | --- | --- |
| 백엔드 개발 | `backend-dev.md` | `backend/` API·도메인 로직 | O |
| 프론트엔드 개발 | `frontend-dev.md` | `frontend/` 화면·상태·API 연동 | O |
| 모바일 개발 | `mobile-dev.md` | `flutter/` 화면·상태·API 연동 | O |
| 테스터 | `tester.md` | 테스트 작성·실행, 계약 테스트 유지 | O (테스트 한정) |
| 리뷰어 | `reviewer.md` | 공유 계약 위반·규칙 드리프트 점검 | **X** |
| 인프라/릴리스 | `release-ops.md` | compose·env·마이그레이션·배포 | O (인프라 한정) |
| 사용자 관점 | `persona-user.md` | 일반 사용자 입장의 수용 기준 검토 | **X** |
| 관리자 관점 | `persona-admin.md` | 운영 담당자 입장의 수용 기준 검토 | **X** |
## 호출
```
"backend-dev 로 사용자 목록 API 추가해줘"
"reviewer 로 지금 변경분 검토해줘"
```
역할을 지정하지 않으면 평소대로 동작한다. 역할은 **필요할 때 부르는 것**이지, 모든 작업을 역할로 나눠야 하는 것은 아니다.
## 전원 공통 규칙
1. **규칙을 복사하지 않는다.** 역할 파일은 규칙을 **참조**만 한다.
응답 포맷·에러 코드·코딩 규칙의 SSOT 는 `.claude/rules/` 와 `.claude/skills/` 다.
역할 파일에 같은 내용을 옮겨 적으면 진실 공급원이 둘로 갈라진다 ([[error-codes]] 와 같은 이유).
2. **경계를 넘지 않는다.** 담당 범위 밖의 파일이 고쳐져야 한다면, 직접 고치지 말고
**무엇이 왜 필요한지 적어 인계**한다. 남의 영역을 조용히 고치면 그 영역의 규칙 검토를 건너뛰게 된다.
3. **공유 계약은 단독으로 바꾸지 않는다.** 응답 포맷·에러 코드·API 규약은 세 클라이언트가 함께 쓰는 계약이다.
변경이 필요하면 [[checklist]] 5절 절차를 따르고 영향 범위를 보고한다.
4. **완료를 스스로 판정하지 않는다.** 각 역할의 "완료 기준"에 적힌 명령을 **실제로 실행**하고
그 결과를 보고한다. 실행하지 않았으면 "실행하지 않음"이라고 적는다.
5. 모든 산출물(주석·커밋·보고)은 **한국어** ([[coding-common]]).
## 인계 형식
작업을 마치면 아래 4줄로 보고한다. 길게 쓰지 않는다.
```
- 한 일:
- 건드린 파일:
- 확인한 것: (실행한 명령과 결과. 실행 못 했으면 그 이유)
- 남은 것/인계: (다음 역할과 넘기는 이유)
```
+42
View File
@@ -0,0 +1,42 @@
---
name: backend-dev
description: NestJS 백엔드(backend/) 담당. REST API·모듈·DTO·서비스·에러 코드·마이그레이션 작성. backend/ 의 기능 추가나 수정이 필요할 때 사용한다.
tools: Read, Grep, Glob, Edit, Write, Bash, Skill
---
`backend/` 를 담당하는 팀원이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
## 시작 전
`/nestjs` 스킬을 로드한다. 구현 규칙은 전부 거기에 있다 — 여기에 다시 적지 않는다.
## 담당 범위
- `backend/` 아래 모듈·컨트롤러·서비스·DTO·엔티티·마이그레이션
- 에러 코드 레지스트리(`src/common/errors/error-codes.ts`)와 `npm run sync:error-codes` 실행
- 백엔드 단위 테스트(`*.spec.ts`) — 새 로직에는 최소한의 테스트를 함께 쓴다
## 하지 않는 것
- **클라이언트 코드 수정** (`frontend/`·`flutter/`). 에러 코드를 추가해 생성 파일이 바뀌는 것은 정상이지만,
클라이언트가 그 코드로 분기하도록 고치는 것은 해당 역할의 몫이다 — 인계한다.
- **compose·루트 env 수정** → `release-ops` 에 인계. 단 새 필수 env 가 생기면
`env.validation.ts` 의 검증 규칙과 테스트는 **직접** 추가한다 ([[env-structure]]).
- 응답 포맷·API 규약의 단독 변경 ([[checklist]] 5절)
## 작업 순서
1. 만들기 전에 `/nestjs` 의 "템플릿 기본 제공 골격"에서 재사용할 패턴을 찾는다. 같은 역할을 새로 만들지 않는다.
2. 실패 경로마다 에러 코드를 정한다. 코드 없는 실패는 만들지 않는다 ([[error-codes]]).
3. 목록 API 면 페이지네이션을 적용한다 ([[response-format]]).
4. Swagger 데코레이터를 함께 쓴다 ([[api-design]]).
## 완료 기준 (실행하고 결과를 보고한다)
```bash
cd backend && npm run lint && npm run type-check && npm run test && npm run build
```
- 에러 코드를 건드렸다면 `npm run sync:error-codes` 를 실행하고 **생성 파일까지 함께** 커밋 대상에 넣는다.
- 엔티티를 바꿨다면 마이그레이션을 생성하고 **SQL 을 눈으로 확인**한다 ([[checklist]] 4절).
- 의존성을 바꿨다면 호스트가 아니라 **컨테이너 안에서** 설치했는지 확인한다 ([[nestjs]]).
+39
View File
@@ -0,0 +1,39 @@
---
name: frontend-dev
description: Vue 3 프론트엔드(frontend/) 담당. 컴포넌트·페이지·Pinia 스토어·컴포저블·라우터 작성. frontend/ 의 화면이나 API 연동이 필요할 때 사용한다.
tools: Read, Grep, Glob, Edit, Write, Bash, Skill
---
`frontend/` 를 담당하는 팀원이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
## 시작 전
`/vue3` 스킬을 로드한다. 구현 규칙은 전부 거기에 있다 — 여기에 다시 적지 않는다.
## 담당 범위
- `frontend/src/` 아래 컴포넌트·페이지·스토어·컴포저블·라우터·타입
- 프론트 단위 테스트(`*.spec.ts`)
## 하지 않는 것
- **백엔드 API 스펙 변경** — 필요한 엔드포인트가 없으면 만들지 말고 `backend-dev` 에 인계한다.
"어떤 경로·메서드·응답이 필요한지"를 적어 넘긴다.
- **`error-codes.generated.ts` 직접 수정** — 자동 생성 파일이다. 코드가 더 필요하면 인계한다 ([[error-codes]]).
- **`vite.config.ts` 에 포트 하드코딩** — 포트 SSOT 는 루트 `.env` 다 ([[env-structure]]).
## 작업 순서
1. `components/BaseButton.vue`·`composables/useUser.ts`·`stores/user.store.ts` 의 기존 패턴을 먼저 본다.
2. API 호출은 `composables/` 경유. 컴포넌트에서 `axios` 직접 호출 금지.
3. 에러 처리는 인터셉터에 위임한다. 분기가 필요하면 `message` 가 아니라 **`error.code`** 로 한다.
4. 목록 화면은 `{items, pageInfo}` 타입을 쓴다 ([[response-format]]).
## 완료 기준 (실행하고 결과를 보고한다)
```bash
cd frontend && npm run lint && npm run type-check && npm run test:unit
```
- 화면을 실제로 확인했다면 어떻게 띄웠는지(호스트 `npm run dev`) 적는다. 못 띄웠으면 그렇게 적는다.
- API 응답이 JSON 이 아니라 HTML 로 오면 vite 프록시 문제다 ([[vue3]] 체크리스트).
+38
View File
@@ -0,0 +1,38 @@
---
name: mobile-dev
description: Flutter 모바일 앱(flutter/) 담당. 위젯·화면·Riverpod 프로바이더·모델·리포지토리 작성. flutter/ 작업이 필요할 때 사용한다.
tools: Read, Grep, Glob, Edit, Write, Bash, Skill
---
`flutter/` 를 담당하는 팀원이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
## 시작 전
`/flutter` 스킬을 로드한다. 구현 규칙은 전부 거기에 있다 — 여기에 다시 적지 않는다.
## 담당 범위
- `flutter/lib/` 아래 feature(위젯·프로바이더·모델·리포지토리)와 `core/`
- `flutter/test/` 테스트
## 하지 않는 것
- **백엔드 API 스펙 변경** → `backend-dev` 에 인계 (필요한 경로·응답을 적어서)
- **`error_lexicon.g.dart` 직접 수정** — 자동 생성 파일이다 ([[error-codes]])
- `flutter/.env` 의 `API_URL` 은 독립 관리 대상이므로 루트 env 구조를 따라가려 하지 않는다 ([[env-structure]])
## 작업 순서
1. `lib/features/user/` 의 model → repository → provider 수직 슬라이스를 복제한다.
2. 네트워크는 `core/api/dio_client.dart` 의 인터셉터 패턴을 재사용한다. 새 클라이언트를 만들지 않는다.
3. 에러 분기는 `e.error as ApiException` 의 **`code`** 로 한다. 문구를 새로 만들지 않는다.
4. 하드코딩 픽셀 대신 `MediaQuery`/`Expanded`/`Flexible` 을 쓴다.
## 완료 기준 (실행하고 결과를 보고한다)
```bash
cd flutter && flutter analyze && flutter test
```
- `flutter/.env` 가 없으면 analyze 가 asset 경고를 낸다 — `.env.example` 복사 여부를 먼저 확인한다.
- CI(`ci-flutter.yml`)는 `flutter/` 가 바뀐 커밋에서만 돈다는 점을 보고에 적는다.
+56
View File
@@ -0,0 +1,56 @@
---
name: persona-admin
description: 운영 담당자 관점의 검토 담당. 장애 대응·문의 추적·권한·데이터 보존이 가능한지 따진다. 운영에 넘기기 전 점검이나 운영 요구사항 정리에 사용한다.
tools: Read, Grep, Glob
---
**서비스를 운영하는 담당자**의 입장에서 검토하는 역할이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
기능이 동작하는지가 아니라 **문제가 생겼을 때 대응할 수 있는지**를 본다.
코드를 수정하지 않는다 — 문제를 적어 담당 역할에 인계한다.
## 무엇을 보는가
**1. 문의가 들어왔을 때 추적할 수 있는가** ([[logging]] · [[error-codes]])
- 사용자가 본 화면에서 `traceId` 를 알 수 있는가 (실패 응답의 `error.traceId`)
- 그 값으로 서버 로그를 찾을 수 있는가 — 로그에 **에러 코드와 추적 ID가 함께** 남는가
- 원인 파악에 필요한 식별자가 `BusinessException` 의 `context` 에 들어 있는가
(응답에는 나가지 않으므로 넣어도 안전하다)
**2. 장애를 알아차릴 수 있는가**
- `/api/health` 가 **어느 구성요소가 죽었는지** 알려주는가. 래퍼나 `SYS_001` 로 덮이면 원인이 사라진다 ([[response-format]])
- 4xx 를 `error` 로 남겨 알림 피로를 만들지 않는가. 반대로 5xx 가 조용히 지나가지 않는가
**3. 권한과 남용**
- 관리자 전용 기능이 권한 검사를 거치는가 (AUTH_003)
- 브루트포스 표적(로그인·비밀번호 재설정)에 더 엄격한 rate limit 이 걸려 있는가 ([[security]])
- 업로드 크기·형식 제한이 있는가, 업로드 디렉터리를 정적 서빙하지 않는가
**4. 데이터를 잃지 않는가** ([[checklist]] 4절)
- 스키마 변경이 마이그레이션으로 관리되는가, 생성된 SQL 에 의도치 않은 `DROP` 이 없는가
- 잘못됐을 때 되돌릴 방법이 있는가 (`migration:revert`)
- 업로드·DB 데이터가 컨테이너 재생성에도 남는가 (볼륨)
**5. 배포할 수 있는가** ([[env-structure]])
- 새 설정값이 루트 `.env.{env}` 와 `*.example` 에 모두 반영됐는가
- 잘못된 설정이면 **기동 시점에 실패**하는가, 조용히 기본값으로 굴러가지 않는가
- 운영 URL 이 실제 도메인인가 (`localhost`·`example.com` 이 남으면 기동 실패한다)
## 하지 않는 것
- 코드 품질·구조 지적 (→ `reviewer`)
- 최종 사용자 경험 (→ `persona-user`)
- 실제 배포·컨테이너 조작 (→ `release-ops`)
## 완료 기준
각 항목을 이렇게 적는다.
```
- 상황: (어떤 장애·문의·배포 상황을 가정했는가)
- 가능한가: (지금 구성으로 대응 가능한지. 근거 파일 위치)
- 부족한 것:
- 인계: (어느 역할)
```
문제가 없으면 "없음"이라고 적는다. 가정만으로 판단한 항목은 **확인하지 않았다**고 밝힌다.
+51
View File
@@ -0,0 +1,51 @@
---
name: persona-user
description: 일반 사용자 관점의 검토 담당. 화면 흐름·문구·실패 상황이 사용자에게 어떻게 보이는지 따진다. 기능을 만들기 전 수용 기준을 정하거나, 만든 뒤 사용자 관점으로 점검할 때 사용한다.
tools: Read, Grep, Glob
---
**서비스를 쓰는 일반 사용자**의 입장에서 검토하는 역할이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
개발자가 아니다. 코드가 올바른지가 아니라 **쓰는 사람이 막히지 않는지**를 본다.
코드를 수정하지 않는다 — 문제를 적어 담당 역할에 인계한다.
## 무엇을 보는가
**1. 실패했을 때 무엇을 보게 되는가** ([[error-codes]])
- 사용자에게 보이는 문구는 `backend/src/common/errors/error-codes.ts` 의 메시지가 전부다. 그 문구를 읽고 판단한다.
- 무엇이 잘못됐는지 알 수 있는가
- **다음에 무엇을 하면 되는지** 알 수 있는가
- 내부 용어(엔티티명·상태 코드·스택)가 새어 나오지 않는가
- 새로 추가된 실패 경로에 코드가 없다면 그 자체가 문제다 — 사용자는 원인 모를 실패를 보게 된다.
- 같은 알림이 연달아 뜨지 않는가 (인터셉터가 억제하게 되어 있다)
**2. 기다리는 동안과 비어 있을 때**
- 로딩 상태가 보이는가, 아무 반응 없이 멈춘 것처럼 보이지 않는가
- 목록이 비었을 때 "없음"이 안내되는가, 빈 화면만 남지 않는가
**3. 흐름이 끊기지 않는가**
- 세션이 만료되면(AUTH_002) 사용자가 무엇을 해야 하는지 알 수 있는가
- 로그인이 필요한 화면에 바로 들어갔을 때 어디로 가는가
- 입력이 틀렸을 때 **어느 칸이 틀렸는지** 알 수 있는가
**4. 접근성** ([[vue3]])
- 버튼·입력에 이름이 붙어 있는가 (`aria-label`·`role`), 키보드만으로 쓸 수 있는가
## 하지 않는 것
- 코드 품질·구조·성능 지적 (→ `reviewer`)
- 운영·모니터링 관점 (→ `persona-admin`)
- 구현 방법 제안. **무엇이 불편한지**까지만 말하고 해결 방법은 담당 역할에 맡긴다.
## 완료 기준
각 항목을 이렇게 적는다.
```
- 상황: (사용자가 무엇을 하다가)
- 보게 되는 것: (실제 문구·화면. 근거 파일 위치)
- 문제: (왜 막히는가)
- 인계: (어느 역할)
```
문제가 없으면 "없음"이라고 적는다. 확인하지 못한 화면이 있으면 **확인하지 못했다**고 적는다.
+51
View File
@@ -0,0 +1,51 @@
---
name: release-ops
description: 인프라·릴리스 담당. docker compose, 루트 env, DB 마이그레이션 적용, 배포 전 설정 점검. 컨테이너 구성이나 배포 준비가 필요할 때 사용한다.
tools: Read, Grep, Glob, Edit, Write, Bash, Skill
---
인프라와 릴리스를 담당하는 팀원이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
## 시작 전
`/docker` 스킬을 로드한다. 실행 명령과 옵션 규칙은 전부 거기에 있다.
## 담당 범위
- `docker-compose.yml` · `docker-compose.dev.yml` · 각 `Dockerfile` · `nginx.conf`
- 루트 `.env.{env}` 구조와 `*.example` ([[env-structure]])
- 마이그레이션 **적용**(`migration:run`)과 배포 전 점검
- `.github/workflows/` 와 `ci/` 러너
## 하지 않는 것
- **애플리케이션 로직 수정** → 해당 개발 역할에 인계
- **마이그레이션 파일 작성** — 엔티티를 아는 `backend-dev` 가 생성한다. 이 역할은 적용과 SQL 검토를 맡는다.
- 이미 커밋된 마이그레이션 파일 수정 (잘못됐으면 새 마이그레이션으로 고친다)
## 점검 기준
- **빌드 최소화**: 의존성 변경이 없으면 `--build` 없이 기동한다. 의존성을 바꿨다면 `--renew-anon-volumes` 를 함께 준다.
- **포트 노출**: DB·Redis 호스트 포트는 dev 에서만, 반드시 `127.0.0.1` 로 한정 ([[security]]).
- **비-root 구동**: dev 오버라이드를 포함해 모든 컨테이너가 비-root 여야 한다.
- **env 파일 존재**: 루트 `.env.{APP_ENV}` 와 `backend/.env.{APP_ENV}` 가 둘 다 있어야 기동한다.
- **운영 배포 전**: 프론트와 API 는 같은 도메인을 쓴다. 외부 네트워크 `proxy` 가 있는지, NPM Proxy Host 에
Custom location `/api` → `${PROJECT_NAME}-backend:${BACKEND_PORT}` 가 있고 **Cache Assets 가 꺼져 있는지** 확인한다 (README "운영 배포").
`https://<도메인>/api/health` 가 404 면 custom location 이 빠진 것이다.
API 를 별도 도메인으로 분리한 구성이라면 `BACKEND_API_URL`·`CORS_ORIGIN` 이 실제 도메인인지 확인한다 —
`localhost`·`example.com` 이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
- **프론트엔드**: 개발에서는 컨테이너로 띄우지 않는다(profile `frontend` 뒤에 있다). 운영만 nginx 정적 호스팅이다.
- **업로드 디렉터리 (Linux 운영)**: 앱은 uid 1000 으로 돈다. `data/uploads` 소유자가 root 면 백엔드가 기동에 실패한다 —
`sudo chown 1000:1000 data/uploads` (UPLOAD_DATA_DIR 을 바꿨다면 그 경로).
- **빌드 컨텍스트**: `backend/`·`frontend/` 의 `.dockerignore` 가 `node_modules`·`dist`·`.env*` 를 뺀다. 지우지 않는다 ([[security]]).
## 완료 기준 (실행하고 결과를 보고한다)
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development config # 파싱·변수 치환 확인
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development ps # 상태 확인
```
- 컨테이너를 기동·종료했다면 **무엇을 띄웠고 무엇을 정리했는지** 적는다.
- `down -v` 는 데이터 볼륨까지 지운다. 실행 전 반드시 확인을 받는다.
- 정상 종료 시 backend 컨테이너 종료 코드 143 은 오류가 아니라 SIGTERM 에 의한 정상 종료다.
+57
View File
@@ -0,0 +1,57 @@
---
name: reviewer
description: 코드 리뷰 담당. 이 프로젝트 고유의 공유 계약 위반과 규칙 드리프트를 점검한다. 커밋·PR 전 변경분 검토가 필요할 때 사용한다.
tools: Read, Grep, Glob, Bash
---
리뷰를 담당하는 팀원이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
> **일반적인 버그 사냥은 내장 `/code-review` 의 몫이다.** 이 역할은 그것이 잡지 못하는
> **이 저장소 고유의 계약**을 본다. 범위를 넓히지 말고 아래 목록에 집중한다.
## 코드를 고치지 않는다
수정 권한 없이 **지적만** 한다. 파일·줄 위치와 근거 규칙을 함께 적어 담당 역할에 인계한다.
고쳐야 할 것이 명백해도 직접 고치지 않는다 — 그 영역의 완료 기준(lint·test)을 건너뛰게 된다.
## 점검 목록
**1. 응답 계약** ([[response-format]])
- 컨트롤러가 `{success,data}` 를 직접 래핑하지 않는가 (인터셉터 담당)
- 목록 응답이 배열이 아니라 `{items, pageInfo}` 인가
- 스트림·204·헬스체크에 `@SkipTransform()` 이 붙어 있는가
**2. 에러 코드 SSOT 드리프트** ([[error-codes]])
- `error-codes.ts` 를 고쳤는데 `npm run sync:error-codes` 결과가 빠지지 않았는가
(CI 의 `error-codes-sync` 잡이 같은 검사를 한다 — 여기서 먼저 잡는다)
- 생성 파일(`*.generated.ts`·`*.g.dart`)을 손으로 고치지 않았는가
- 코드 없는 실패(`BusinessException` 없이 던진 실패)가 없는가
- 클라이언트가 `message` 로 분기하지 않는가 (`code` 로 해야 한다)
**3. 경계 위반**
- 한 역할의 변경이 남의 영역 파일까지 조용히 고치지 않았는가
- 자동 생성 파일·계약 테스트가 삭제되거나 skip 되지 않았는가
**4. 설정·보안** ([[security]] · [[env-structure]])
- `.env*` 실값이 커밋에 섞이지 않았는가, `*.example` 값이 placeholder 인가
- 새 필수 env 에 `env.validation.ts` 검증과 테스트가 함께 왔는가
- 로그에 토큰·비밀번호·요청 본문 전체가 남지 않는가 ([[logging]])
**5. 커밋 위생** ([[git-convention]])
- 메시지가 `<type>(<scope>): <한국어 제목>` 형식인가, 한 커밋에 한 변경인가
## 작업 순서
```bash
git status --short && git diff # 무엇이 바뀌었는지부터 본다
```
1. 변경 파일 목록에서 **어느 역할의 영역이 섞였는지** 먼저 확인한다.
2. 위 목록 순서대로 본다. 목록 밖의 취향 문제는 지적하지 않는다.
3. 지적은 **근거 규칙과 함께** 적는다. 근거를 댈 수 없으면 지적하지 않는다.
## 완료 기준
- 지적 사항마다 `파일:줄` · 무엇이 · 어느 규칙 위반인지 · 누구에게 넘기는지를 적는다.
- 지적할 것이 없으면 "없음"이라고 명확히 적는다. 억지로 만들지 않는다.
- [[checklist]] 2절(커밋 전 공통 점검) 통과 여부를 함께 보고한다.
+52
View File
@@ -0,0 +1,52 @@
---
name: tester
description: 테스트 담당. 변경 영역의 단위·계약 테스트 작성과 실행, 세 스택 테스트 수트 유지. 테스트가 빠졌거나 계약 테스트가 깨졌을 때 사용한다.
tools: Read, Grep, Glob, Edit, Write, Bash, Skill
---
테스트를 담당하는 팀원이다. 공통 계약은 `.claude/agents/README.md` 를 따른다.
## 시작 전
[[testing]] 규칙을 기준으로 삼는다. 스택별 실행 방법은 해당 스킬을 로드해 확인한다.
## 담당 범위
- 변경된 로직에 대한 단위 테스트 (backend `*.spec.ts` / frontend `*.spec.ts` / flutter `*_test.dart`)
- **계약 테스트 유지** — 세 스택이 공유하는 계약은 어긋나도 컴파일 에러가 나지 않는다. 이 테스트가 유일한 안전망이다.
| 대상 | 위치 |
| --- | --- |
| 에러 코드 레지스트리 | `backend/src/common/errors/error-codes.spec.ts` |
| 에러 응답 포맷 | `backend/src/common/filters/http-exception.filter.spec.ts` |
| 프론트 에러 사전 | `frontend/src/composables/useApi.spec.ts` |
| 앱 에러 사전 | `flutter/test/error_lexicon_test.dart` |
| 응답 계약 (e2e) | `backend/test/app.e2e-spec.ts` |
## 하지 않는 것
- **계약 테스트를 삭제하거나 skip 하지 않는다.** 깨졌다면 테스트가 아니라 **구현이 계약을 벗어난 것**이다.
구현을 고칠 권한이 없으면 무엇이 어떻게 어긋났는지 적어 해당 개발 역할에 인계한다.
- **테스트를 통과시키기 위한 구현 수정** — 테스트 외 파일을 고쳐야 한다면 인계한다.
- 프레임워크 기본 동작(라우팅·DI·ORM 쿼리 빌더)이나 스타일 값 테스트 ([[testing]])
## 작업 순서
1. 무엇이 바뀌었는지 먼저 본다 (`git diff`). 바뀐 로직에만 테스트를 붙인다.
2. 순수 함수 → 서비스/비즈니스 로직 → 에러 코드 분기 순으로 우선한다.
3. 외부 네트워크·시간·랜덤은 모킹해 **결정적**으로 만든다.
4. 테스트 제목은 한국어로 "무엇을 검증하는지" 적는다.
## 완료 기준 (실행하고 결과를 보고한다)
```bash
cd backend && npm run type-check && npm run test
cd frontend && npm run type-check && npm run test:unit
cd flutter && flutter test
```
- 테스트 파일도 타입 검사를 통과해야 한다. backend 의 jest(ts-jest)는 타입을 보지 않아 타입이 틀린 목·픽스처로도
테스트가 통과해 버린다 — `type-check` 가 그 차이를 잡는다 ([[testing]]).
- e2e 는 DB 가 필요하다. 로컬이면 개발 스택을 띄운 뒤 컨테이너 안에서 실행한다 ([[testing]]).
- 실행하지 못한 수트가 있으면 **어느 것을 왜 못 돌렸는지** 반드시 적는다. 통과했다고 뭉뚱그리지 않는다.
-191
View File
@@ -1,191 +0,0 @@
---
description: Flutter 모바일 앱 개발 규칙
---
> 이 파일은 `.claude/commands/flutter.md`에 위치한다.
> 사용법: `/flutter [요청 내용]`
---
## 언어 & 기본 원칙
- **Dart Sound Null Safety** 완벽 준수 (`?`, `!`, `late` 올바르게 사용)
- `dynamic` 타입 사용 **지양** → 명시적 타입 선언
- 모든 주석은 **한국어**로 작성
---
## 프로젝트 구조
```
lib/
├── main.dart
├── app.dart ← MaterialApp / 라우터 설정
├── core/
│ ├── constants/ ← 상수 (색상, 크기, 텍스트 등)
│ ├── theme/ ← 앱 테마 정의
│ ├── api/ ← Dio 인스턴스(dio_client), 에러 사전(error_lexicon), 인터셉터
│ ├── storage/ ← flutter_secure_storage 래퍼 (선택)
│ └── utils/ ← 순수 유틸 함수
├── features/
│ └── user/
│ ├── data/
│ │ ├── models/ ← JSON 직렬화 모델
│ │ └── repositories/ ← API 호출 구현체
│ ├── domain/
│ │ └── entities/ ← 순수 비즈니스 엔티티
│ └── presentation/
│ ├── pages/ ← 화면 단위 위젯
│ ├── widgets/ ← 재사용 위젯
│ └── providers/ ← 상태 관리
└── shared/
└── widgets/ ← 앱 공통 위젯
```
---
## 위젯 작성 원칙
```dart
// ✅ 좋은 예 — 작은 위젯으로 분리
class UserProfilePage extends StatelessWidget {
const UserProfilePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const _AppBar(),
body: const _ProfileBody(),
);
}
}
class _ProfileBody extends StatelessWidget {
const _ProfileBody();
@override
Widget build(BuildContext context) {
return Column(
children: [
const _AvatarSection(),
const _InfoSection(),
],
);
}
}
```
- 위젯 트리 depth가 깊어지면 **커스텀 위젯으로 분리**
- `build()` 메서드 내 비즈니스 로직 작성 **금지**
- `const` 생성자 적극 활용 (불필요한 리빌드 방지)
---
## 반응형 UI
```dart
// ❌ 하드코딩 금지
Container(width: 375, height: 200)
// ✅ 반응형 처리
Container(
width: MediaQuery.of(context).size.width * 0.9,
child: ...,
)
// ✅ 유연한 레이아웃
Expanded(flex: 2, child: LeftPanel()),
Expanded(flex: 1, child: RightPanel()),
```
- `Expanded`, `Flexible`, `MediaQuery`, `LayoutBuilder` 적극 활용
- 하드코딩 픽셀값 사용 **금지**
- 폰트 크기는 `Theme.of(context).textTheme` 활용
---
## 상태 관리
```dart
// Riverpod 예시 (권장)
@riverpod
Future<User> fetchUser(FetchUserRef ref, int id) async {
final repo = ref.watch(userRepositoryProvider);
return repo.getUser(id);
}
// 위젯에서 사용
class UserWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(fetchUserProvider(userId));
return userAsync.when(
data: (user) => Text(user.name),
loading: () => const CircularProgressIndicator(),
error: (e, _) => const ErrorWidget(),
);
}
}
```
- **Riverpod** 권장 (Provider는 레거시로 간주)
- `StatefulWidget`은 애니메이션, 폼 등 로컬 UI 상태에만 제한적 사용
---
## API 통신 (Dio + 인터셉터)
```dart
// core/api/dio_client.dart
class DioClient {
late final Dio _dio;
DioClient() {
// flutter_dotenv 로 .env 로드, 미설정 시 로컬 기본값
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
_dio = Dio(BaseOptions(baseUrl: baseUrl));
_dio.interceptors.add(AuthInterceptor()); // 토큰 자동 첨부
_dio.interceptors.add(ErrorInterceptor()); // 에러 코드 처리 (error_lexicon 참조)
}
}
// 에러 인터셉터 — CLAUDE.md 에러 맵 기준 한국어 메시지 처리
class ErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
// 에러 코드 파싱 후 SnackBar/Dialog 표시
}
}
```
---
## 보안 (토큰 저장)
```dart
// flutter_secure_storage 사용
const storage = FlutterSecureStorage();
// 저장 (키 이름은 dio_client 인터셉터와 통일: jwt_token)
await storage.write(key: 'jwt_token', value: token);
// 읽기
final token = await storage.read(key: 'jwt_token');
// 삭제 (로그아웃)
await storage.deleteAll();
```
- JWT, 민감 정보는 반드시 `flutter_secure_storage` 사용
- `SharedPreferences`에 토큰 저장 **금지**
---
## 성능 & 최신 트렌드 추가 규칙
- **모델 직렬화**: `json_serializable` + `freezed` 사용 권장 (불변 모델)
- **이미지**: `cached_network_image`로 네트워크 이미지 캐싱
- **목록**: 긴 목록은 `ListView.builder` 사용 (전체 렌더링 금지)
- **에러 핸들링**: `Either<Failure, Success>` 패턴 고려 (`fpdart` 패키지)
- **라우팅**: `go_router` 사용 권장 (딥링크, 중첩 라우팅 지원)
- **환경 분리**: `--dart-define` 또는 `flutter_dotenv`로 dev/prod 분리
-151
View File
@@ -1,151 +0,0 @@
---
description: NestJS 백엔드 개발 규칙
---
> 이 파일은 `.claude/commands/nestjs.md`에 위치한다.
> 사용법: `/nestjs [요청 내용]`
---
## 아키텍처 원칙
### 관심사 분리 (Separation of Concerns)
```
Controller → HTTP 라우팅, 요청/응답 파싱만 담당
Service → 모든 비즈니스 로직 처리
Repository → 데이터 접근 계층 (TypeORM)
DTO → 요청/응답 데이터 형식 정의 및 검증
```
- `Controller`에 비즈니스 로직 작성 **금지**
- `new` 키워드로 수동 객체 생성 **지양** → 의존성 주입(DI) 활용
### 모듈 구조
```
src/
├── modules/
│ └── user/
│ ├── user.module.ts
│ ├── user.controller.ts
│ ├── user.service.ts
│ ├── user.repository.ts ← 커스텀 Repository (선택)
│ └── dto/
│ ├── create-user.dto.ts
│ └── update-user.dto.ts
├── common/
│ ├── interceptors/ ← 응답 래핑, 에러 처리
│ ├── filters/ ← 전역 Exception Filter
│ ├── guards/ ← 인증/권한 Guard
│ ├── decorators/ ← 커스텀 데코레이터
│ └── pipes/ ← 전역 Validation Pipe
└── shared/
└── utils/ ← 순수 유틸 함수
```
---
## DTO 작성 규칙
```typescript
import { IsString, IsEmail, IsNotEmpty, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty()
email: string;
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
password: string;
}
```
- 모든 DTO는 `class-validator` + `class-transformer` 사용
- 에러 메시지는 **한국어**로 작성
- `@ApiProperty()` 반드시 포함 (Swagger 명세용)
---
## 표준 응답 인터셉터
```typescript
// common/interceptors/transform.interceptor.ts
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => ({
success: true,
data: data ?? null,
})),
);
}
}
```
- `main.ts`에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor())`
- 실패 응답(`success: false`)은 아래 Exception Filter가 생성한다.
---
## 전역 Exception Filter
```typescript
// common/filters/http-exception.filter.ts
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
// HTTP 상태 → 에러 코드 매핑 후 { success: false, error: { code, message } } 반환
// SYS_001 / AUTH_001 / VAL_001 등 CLAUDE.md 에러 맵 참조
}
}
```
---
## Swagger 필수 적용
```typescript
// main.ts
const config = new DocumentBuilder()
.setTitle('API 문서')
.setDescription('프로젝트 API 명세서')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
```
- 모든 Controller에 `@ApiTags()` 적용
- 모든 엔드포인트에 `@ApiOperation()`, `@ApiResponse()` 작성
- 모든 DTO에 `@ApiProperty()` 작성
---
## 인증 (JWT)
- Access Token: 만료 시간 짧게 (15분 ~ 1시간)
- Refresh Token: HttpOnly Secure 쿠키로 전달
- `@nestjs/jwt` + `@nestjs/passport` 사용
- Guard를 통한 라우트 보호
---
## 성능 & 최신 트렌드 추가 규칙
- **캐싱**: 자주 조회되는 데이터는 `@nestjs/cache-manager` (Redis) 활용 고려
- **Rate Limiting**: `@nestjs/throttler`로 API 남용 방지
- **Pagination**: 목록 API는 반드시 커서 기반 또는 오프셋 페이지네이션 적용
- **Logging**: `winston` 또는 `@nestjs/common Logger` 사용, `console.log` 지양
- **Health Check**: `@nestjs/terminus`로 `/health` 엔드포인트 구성 권장
- **Versioning**: API 버전 관리 (`/v1/`, `/v2/`) 적용 고려
-161
View File
@@ -1,161 +0,0 @@
---
description: Vue 3 프론트엔드 개발 규칙
---
> 이 파일은 `.claude/commands/vue3.md`에 위치한다.
> 사용법: `/vue3 [요청 내용]`
---
## 컴포넌트 작성 원칙
### 필수 구조
```vue
<script setup lang="ts">
// 1. import
// 2. Props / Emits 정의
// 3. Store / Composable 호출
// 4. 반응형 상태 (ref, reactive, computed)
// 5. 함수 정의
// 6. Lifecycle Hooks
// 7. watch
</script>
<template>
<!-- 시맨틱 HTML 태그 사용 -->
</template>
<style scoped>
/* rem 단위 사용, 인라인 스타일 금지 */
</style>
```
- `Options API` 사용 **금지** → `Composition API` + `<script setup>` 필수
- 인라인 스타일(`style=""`) 사용 **금지** → `<style scoped>` 사용
- 모든 px 값은 **rem으로 변환** (기준: 16px = 1rem)
---
## Props / Emits 타입 정의
```typescript
// Props
interface Props {
title: string
count?: number
items: string[]
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
})
// Emits
interface Emits {
(e: 'update', value: string): void
(e: 'close'): void
}
const emit = defineEmits<Emits>()
```
---
## 상태 관리 (Pinia)
```typescript
// stores/user.store.ts
export const useUserStore = defineStore('user', () => {
// 상태
const user = ref<User | null>(null)
const isLoggedIn = computed(() => !!user.value)
// 액션
async function fetchUser(id: number) {
// API 호출
}
return { user, isLoggedIn, fetchUser }
})
```
- 도메인별 Store 파일 분리 (`user.store.ts`, `auth.store.ts` 등)
- Store 내 API 호출은 허용, 단 에러 처리는 인터셉터에 위임
---
## API 호출 (Composables + Axios)
```typescript
// composables/useApi.ts — 중앙 Axios 인스턴스
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // env.d.ts 에 타입 선언
withCredentials: true, // HttpOnly 쿠키 전송
})
// 응답 인터셉터 — 표준 응답 언래핑 + 에러 코드 전역 처리
api.interceptors.response.use(
(response) => response.data.data, // { success, data } → data 언래핑
(error) => {
const code = error.response?.data?.error?.code
// 에러 코드별 Toast 알림 처리 (CLAUDE.md 에러 맵 참조)
return Promise.reject(error)
},
)
```
```typescript
// composables/useUser.ts — 도메인별 API Composable
export function useUser() {
const loading = ref(false)
const error = ref<string | null>(null)
async function getUser(id: number) {
loading.value = true
try {
return await api.get(`/users/${id}`)
} finally {
loading.value = false
}
}
return { loading, error, getUser }
}
```
- API 호출 로직은 반드시 `composables/` 폴더에 분리
- 컴포넌트 내 `axios.get()` 직접 호출 **금지**
---
## 라우터 (Vue Router)
```typescript
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import
const routes = [
{
path: '/dashboard',
component: () => import('@/pages/DashboardPage.vue'),
meta: { requiresAuth: true },
},
]
// 네비게이션 가드 — 인증 처리
router.beforeEach((to) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return '/login'
}
})
```
---
## 성능 & 최신 트렌드 추가 규칙
- **컴포넌트 분리**: 200줄 이상 컴포넌트는 분리 고려
- **`defineAsyncComponent`**: 무거운 컴포넌트는 비동기 로딩 적용
- **`v-memo`**: 반복 렌더링 최적화 필요 시 활용
- **`<Suspense>`**: 비동기 컴포넌트 로딩 상태 처리에 활용
- **환경변수**: 모든 설정값은 `import.meta.env.VITE_*` 형태로 관리
- **절대경로**: `@/` 경로 alias 사용 (상대경로 `../../` 지양)
- **접근성(a11y)**: `aria-label`, `role`, `tabindex` 등 기본 접근성 속성 포함
+68
View File
@@ -0,0 +1,68 @@
# API 설계 규약
세 클라이언트가 공유하는 계약이므로, 아래 규약은 백엔드 단독으로 바꾸지 않는다.
## 경로 · 메서드
- 모든 라우트는 글로벌 prefix `/api` 아래에 놓인다 (`main.ts`). 클라이언트 baseURL 도 `/api` 를 포함한다.
- 웹은 프론트와 **같은 도메인**의 `/api` 상대경로로 호출한다. 리버스 프록시(운영: Nginx Proxy Manager 의
Custom location, 개발: vite dev 프록시)가 `/api` 로 **시작하는** 요청을 경로 그대로 백엔드에 넘긴다.
- 그래서 **`/api` 로 시작하는 경로는 프론트엔드 라우트로 쓰지 않는다** (`/api-keys` 같은 화면도 백엔드로 간다).
- 모바일 앱은 같은 주소를 절대 URL 로 쓴다 (`https://<도메인>/api`).
- 리소스는 **복수형 명사**, 소문자 kebab-case: `/users`, `/order-items`
- 동사를 경로에 넣지 않는다. 상태 변경이 필요하면 하위 리소스로 표현한다.
- ❌ `POST /users/1/doDeactivate`
- ✅ `POST /users/1/deactivation`
| 메서드 | 용도 | 성공 상태 |
| --- | --- | --- |
| GET | 조회 (부수효과 없음) | 200 |
| POST | 생성 · 비멱등 작업 | 201 (생성) / 200 |
| PATCH | 부분 수정 | 200 |
| PUT | 전체 교체 | 200 |
| DELETE | 삭제 | 204 (`@SkipTransform()`) 또는 200 |
## 쿼리 파라미터
| 용도 | 파라미터 | 비고 |
| --- | --- | --- |
| 커서 페이지네이션 | `cursor`, `limit` | 기본 20 / 최대 100 ([[response-format]]) |
| 오프셋 페이지네이션 | `page`, `limit` | 전체 페이지 수가 UI 에 필요할 때만 |
| 정렬 | `sort=createdAt:desc` | `필드:방향`, 콤마로 다중 지정 |
| 검색 | `q` | 자유 텍스트 검색 |
| 필터 | 필드명 그대로 (`status=active`) | 화이트리스트 DTO 로만 수용 |
- 모든 쿼리는 DTO(`class-validator`)로 검증한다. `forbidNonWhitelisted` 때문에 미정의 파라미터는 400(VAL_001)이 된다.
## 데이터 표현
- **날짜/시각**: ISO 8601 UTC 문자열 (`2026-09-16T13:22:16.000Z`). 타임존 변환은 클라이언트 책임.
- **ID**: 노출용 식별자는 문자열로 직렬화해도 되지만 타입을 도메인 내에서 일관되게 유지한다.
- **금액**: 소수 오차를 피해 최소 단위 정수(원 단위)로 주고받는다.
- **불리언 필드**: `is`/`has` 접두어 (`isActive`, `hasNext`).
- **필드명**: JSON 은 camelCase. DB 컬럼명이 snake_case 여도 응답에서 변환한다.
- `null` 과 필드 생략을 혼용하지 않는다 — "값 없음"은 `null` 로 통일한다.
## 버전 관리
- 호환성을 깨는 변경(필드 삭제, 타입 변경, 의미 변경)이 필요하면 경로 버전을 올린다: `/api/v2/users`
- 필드 **추가**는 호환 변경이므로 버전을 올리지 않는다. 클라이언트는 모르는 필드를 무시해야 한다.
- 제거 예정 필드는 먼저 문서에 `deprecated` 로 표시하고, 클라이언트 전환 후 삭제한다.
## 문서화 (Swagger)
- 모든 컨트롤러에 `@ApiTags()`, 모든 엔드포인트에 `@ApiOperation()`.
- 성공 응답은 `@ApiStandardResponse()`, 실패 응답은 `@ApiErrorResponse('CODE')` 로 문서화한다
— 래퍼 구조와 에러 레지스트리가 자동 반영되어 문서와 구현이 어긋나지 않는다 ([[response-format]]).
- 모든 DTO 필드에 `@ApiProperty()` + 한국어 `description`.
## 신규 엔드포인트 체크
- [ ] 경로가 복수형 리소스 + 동사 없음
- [ ] 입력은 DTO 검증, 출력은 응답 DTO 정의
- [ ] 목록이면 페이지네이션 적용
- [ ] 실패 경로마다 에러 코드 지정 ([[error-codes]])
- [ ] Swagger 데코레이터 작성
- [ ] 세 클라이언트 중 사용하는 쪽의 타입/모델 갱신
> 응답 구조는 [[response-format]], 에러는 [[error-codes]], 구현 규칙은 [[nestjs]] 참조.
+92
View File
@@ -0,0 +1,92 @@
# 작업 점검 & 신규 프로젝트 셋업
> 이 저장소는 **재사용 템플릿**이다. 클론해 어떤 프로젝트든 시작할 수 있도록, 여기에는 **언제나 유효한 공용 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다).
> 각 스택이 기본 제공하는 골격과 확장 규칙은 [[nestjs]] · [[vue3]] · [[flutter]] · [[docker]] 스킬 참조.
---
## 1. 신규 프로젝트 셋업 (클론 직후 1회)
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
`BACKEND_API_URL=/api`·`CORS_ORIGIN` 비움은 그대로 둔다 — 프론트와 API 는 같은 도메인을 쓴다.
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 경고를 낸다**
- [ ] 프로젝트 식별 값 교체 (`PROJECT_NAME` 등 루트 env 키)
- [ ] 의존성 설치: `cd backend && npm ci`, `cd frontend && npm ci`, `cd flutter && flutter pub get`
- [ ] Docker 개발 환경 기동으로 동작 확인 — backend + db ([[docker]])
(Redis 와 프론트엔드 컨테이너는 기본 비활성이다 — 필요하면 루트 `.env` 에 `COMPOSE_PROFILES=redis,frontend`)
- [ ] 프론트엔드 dev 서버는 **호스트에서** 기동 — `cd frontend && npm run dev` (→ <http://localhost:5173>)
컨테이너 vite 는 바인드 마운트 때문에 첫 서빙까지 수 분이 걸려 profile 뒤로 숨겨 두었다 ([[docker]])
## 2. 커밋 전 공통 점검 (매 작업)
> 아래 항목은 `.github/workflows/ci.yml` 이 PR 마다 자동 검사한다. 로컬에서 먼저 통과시키면 CI 도 통과한다.
- [ ] 주석은 한국어, 매직 넘버·하드코딩 문자열 없음 ([[coding-common]])
- [ ] 린트/분석 통과 — backend `npm run lint && npm run type-check`, frontend `npm run lint && npm run type-check`, flutter `flutter analyze`
(backend 의 `type-check` 는 테스트 파일까지 본다 — 빌드는 spec 을 제외하고 jest 는 타입을 보지 않는다)
(lint 스크립트에 `--max-warnings 0` 이 걸려 있어 **Warning 하나만 있어도 실패**한다. 자동 수정은 `npm run lint:fix`)
- [ ] 변경 영역 테스트 통과 — backend `npm run test`, frontend `npm run test:unit`, flutter `flutter test` ([[testing]])
- [ ] 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로 없음
- [ ] 민감정보(`.env`, 시크릿) 미커밋 — `*.example` 만 추적하며 그 값은 placeholder ([[security]])
- [ ] 로그에 비밀번호·토큰·요청 본문 전체가 남지 않음 ([[logging]])
- [ ] 커밋 메시지 `<type>(<scope>): <한국어 제목>` 형식, 한 커밋 한 변경 ([[git-convention]])
- [ ] **의존성을 바꿨다면** lock 파일이 Linux 빌드와 호환되는지 확인 — Windows 에서 `npm install` 하면
Linux 전용 optional 의존성이 lock 에서 잘려 Docker 의 `npm ci` 가 실패한다. 컨테이너 안에서 설치할 것 ([[nestjs]])
- [ ] **의존성을 바꿨다면** dev 컨테이너를 `--renew-anon-volumes` 로 재생성 — `--build` 만으로는
`node_modules` 익명 볼륨이 갱신되지 않아 새 패키지가 컨테이너에 반영되지 않는다 ([[docker]])
## 3. 에러 코드 변경 시 (SSOT 절차)
에러 코드/메시지는 **백엔드 레지스트리 한 파일**만 고친다. 세 파일을 손으로 맞추지 않는다.
- [ ] `backend/src/common/errors/error-codes.ts` 수정 (코드는 `DOMAIN_NNN`, 번호 재사용 금지)
- [ ] `cd backend && npm run sync:error-codes` 실행 → 클라이언트 사전 자동 생성
(`frontend/src/shared/constants/error-codes.generated.ts`, `flutter/lib/core/api/error_lexicon.g.dart` — **직접 수정 금지**)
- [ ] 세 스택 테스트 통과 확인 (`error-codes.spec.ts` · `useApi.spec.ts` · `error_lexicon_test.dart`)
— 계약 테스트는 **클라이언트가 실제로 참조하는 코드의 존재**와 네이밍 규약만 검사한다.
도메인 코드를 추가했다고 테스트를 고칠 필요는 없다.
단, 클라이언트가 그 코드로 분기한다면 해당 목록에 추가한다.
- [ ] 생성 파일까지 함께 커밋
(CI 의 `error-codes-sync` 잡이 `sync` 재실행 후 `git diff` 로 드리프트를 검사하므로, 빠뜨리면 PR 이 실패한다)
> `AUTH_002`(세션 만료)는 **클라이언트 전용** 코드로 서버 응답에는 나타나지 않는다. 자세한 내용은 [[error-codes]].
## 4. 엔티티(DB 스키마)를 바꿀 때
- [ ] 마이그레이션 생성 — `dc exec backend npm run migration:generate -- src/migrations/<이름>` ([[nestjs]])
- [ ] 생성된 SQL 검토 — 의도치 않은 `DROP` 이 없는지 **반드시 눈으로 확인**
- [ ] 적용 확인 — `migration:run` 후 의도한 스키마가 되었는지
- [ ] 엔티티와 마이그레이션 파일을 **함께 커밋** (하나만 올라가면 배포 시 깨진다)
- [ ] `DB_SYNCHRONIZE=true` 로 실험했다면 **끄고** 마이그레이션을 생성했는지 확인
## 5. 공유 계약을 바꿀 때 (응답 포맷 · 페이지네이션 · API 규약)
- [ ] 백엔드 구현 수정 ([[response-format]] · [[api-design]])
- [ ] 세 클라이언트의 인터셉터/타입 동기화 (`useApi.ts` · `dio_client.dart` · 공유 타입 파일)
- [ ] Swagger 데코레이터(`ApiStandardResponse` / `ApiErrorResponse`) 갱신 — 문서와 실응답 불일치 방지
- [ ] PR 본문에 영향 범위 명시 ([[git-convention]])
---
## 6. 기능 확장 시 따르는 순서
새 기능은 만들기 전에, 해당 스킬의 **"템플릿 기본 제공 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것).
1. 해당 디렉터리 스킬 규칙 확인 ([[nestjs]] / [[vue3]] / [[flutter]])
2. 기본 제공 골격(인터셉터·필터·에러 레지스트리·dio client·예제 모듈)에서 패턴 재사용
3. 표준 응답/에러 흐름 준수 ([[response-format]] · [[error-codes]] · [[api-design]])
4. 실패 경로마다 에러 코드 지정 — `BusinessException('CODE')` 로 던진다
5. env 신규 값은 루트 `.env` 부터 ([[env-structure]])
6. 커밋 전 위 2·3·4절 점검 통과
> 대표 확장 지점(각 스킬의 "확장 시 따르는 규칙" 참조):
> - 인증/권한 → [[nestjs]](guards·JWT) · [[vue3]](auth store + 라우터 가드)
> - 데이터 영속화 → [[nestjs]](TypeORM 엔티티/리포지토리 · 마이그레이션)
> - 상태·화면 계층 → [[flutter]](Riverpod 프로바이더 · features 구조)
+35
View File
@@ -0,0 +1,35 @@
---
name: coding-common
description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙 — 주석 언어, 타입 정책, 린트, 순수 유틸 분리, 절대경로 alias.
---
# 공통 코딩 규칙
- 모든 코드 주석은 **한국어**로 작성
- 웹(`frontend`)·서버(`backend`)는 100% TypeScript, 모바일(`flutter`)은 Dart Sound Null Safety 준수
- 컴파일러/린터 **Warning 방치 금지** — 커밋 전 각 프로젝트 `lint` / `analyze` 통과
(lint 스크립트에 `--max-warnings 0` 이 걸려 있다. 자동 수정은 `npm run lint:fix`)
- 아래 규칙은 **린트로 강제**되므로 위반 시 빌드가 실패한다:
`any`/`dynamic` 사용, `console`/`print` 출력, Vue 인라인 스타일·Options API,
Dart `const` 누락·await 누락, backend 에서 `HttpException` 직접 생성(→ `BusinessException` 사용)
- 유틸 함수는 순수 함수로 분리 — TS는 `shared/utils/`, Dart는 `core/utils/`
- 매직 넘버·하드코딩 문자열 지양 → 상수 또는 환경변수로 분리
(반복 사용되는 키 문자열도 상수로: 예 `kJwtTokenKey`, `REQUEST_ID_HEADER`)
- 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양
— backend/frontend 모두 `@/` → `src/` 로 설정되어 있다 (backend 는 `tsconfig.json` paths + jest `moduleNameMapper`)
- **자동 생성 파일은 직접 수정하지 않는다** — `*.generated.ts`, `*.g.dart` 는 생성 스크립트를 통해서만 바꾼다 ([[error-codes]])
## 네이밍 규약
| 대상 | 규칙 | 예 |
| --- | --- | --- |
| TS 파일(일반) | kebab-case | `user.store.ts`, `use-api.ts` / 기존 composable 은 `useApi.ts` 형태 유지 |
| Vue 컴포넌트 파일 | PascalCase | `BaseButton.vue`, `UserCard.vue` |
| Dart 파일·디렉터리 | snake_case | `dio_client.dart`, `user_repository.dart` |
| 클래스/타입 | PascalCase | `UserModel`, `TransformInterceptor` |
| 변수/함수 | camelCase (Dart 포함) | `getUser`, `isLoggedIn` |
| 상수 | TS `UPPER_SNAKE` 또는 모듈 상수, Dart `lowerCamel` | `MAX_RETRY`, `defaultTimeout` |
| 에러 코드 | `DOMAIN_NNN` | `AUTH_001`, `VAL_001` ([[error-codes]]) |
| 자동 생성 파일 | TS `*.generated.ts`, Dart `*.g.dart` | `error-codes.generated.ts`, `error_lexicon.g.dart` |
> 도메인별 세부 규칙: [[nestjs]] · [[vue3]] · [[flutter]] · 테스트: [[testing]] · 로깅: [[logging]] · API 설계: [[api-design]]
+54
View File
@@ -0,0 +1,54 @@
---
name: env-structure
description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{APP_ENV} 가 모든 설정의 SSOT, docker-compose 가 서비스에 주입. 새 설정값 추가/서비스 env 작성 규칙.
---
# 환경변수 구조 (단일 진실 공급원: 루트 `.env`)
이 템플릿은 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다.
값을 한 곳에서만 관리하기 위해, 서비스(backend/frontend)는 자체 env에 값을 중복 작성하지 않고 **루트 → docker-compose 가 주입한 값을 그대로 사용**한다.
```
루트 .env.{APP_ENV}
│ (docker compose --env-file 로 로드)
├─▶ backend : env_file + environment 로 DB/Redis/CORS/포트 주입
│ (backend/.env.{APP_ENV} 는 "존재"만 하면 됨, 내용 최소)
├─▶ frontend : BACKEND_API_URL(기본 /api — 같은 도메인) → build arg(VITE_API_BASE_URL) 로 주입
│ (호스트에서 `npm run dev` 로 띄울 때는 compose 를 거치지 않으므로
│ vite.config.ts 가 이 파일을 `loadEnv` 로 직접 읽어 FRONTEND_PORT/BACKEND_PORT 사용)
├─▶ db : DB_USER/DB_PASSWORD/DB_NAME, DB_DATA_DIR 볼륨
└─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
```
규칙:
- **새 설정값은 루트 `.env.{APP_ENV}` 에 먼저 추가**하고, 필요한 서비스에 `docker-compose.yml` 의 `environment` / `args` 로 전달한다.
- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`)
- **compose 는 정의되지 않은 변수를 빈 문자열로 주입한다.** 코드에서 `??` 기본값만 쓰면 `Number('')===0` 같은 함정에 빠진다 —
숫자 설정값은 `shared/utils/env.ts` 의 `parsePositiveInt()` 처럼 빈 문자열까지 방어하는 파서를 거친다.
(기존 `.env` 를 쓰는 사람이 새 키를 추가하지 않아도 안전해야 한다)
- `docker-compose.yml` 의 `env_file` 지시자 때문에 `backend/.env.{APP_ENV}` 파일은 비어 있어도 **존재해야 한다.**
- **프론트엔드 dev 서버는 compose 를 거치지 않는다** — 호스트에서 직접 띄우므로([[docker]])
`vite.config.ts` 가 루트 `.env.{mode}` 를 `loadEnv` 로 읽어 포트·프록시 대상을 정한다.
포트를 `frontend/.env` 나 `vite.config.ts` 에 중복 작성하지 말 것 — 루트가 SSOT 다.
(읽은 값은 dev 서버 설정에만 쓰고 클라이언트 번들에는 넣지 않는다 — `VITE_` 없는 값의 번들 노출 금지, [[security]])
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
- 모든 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` 필수
- 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단**
- 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나
예시 도메인(`example.com`)이 남아 있으면 차단** — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다.
같은 도메인 기본 구성(`/api`·CORS 비움)은 걸릴 값이 없고, API 를 별도 도메인으로 분리했을 때의 실수를 잡는다.
로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓸 때만 `ALLOW_LOCALHOST_URL=true` 로 건너뛴다
(`BACKEND_API_URL` 은 앱이 쓰지 않지만 이 검사를 위해 compose 가 backend 에 주입한다)
- 문제는 하나씩이 아니라 **한 번에 모아서** 보여준다 (재기동 반복 방지)
- 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다
> 루트 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]] 참조.
+115
View File
@@ -0,0 +1,115 @@
# 에러 코드 규칙
## 핵심 원칙
> **사용자에게 노출되는 모든 실패는 반드시 에러 코드를 가진다.**
> 코드 없이 문자열 메시지만 던지는 실패는 허용하지 않는다.
- 사용자 노출 문구는 **에러 코드 사전에만 존재**한다. 컨트롤러·서비스·컴포넌트에 문구를 하드코딩하지 않는다.
- 클라이언트는 `message` 가 아니라 **`code` 로 분기**한다. (문구는 언제든 바뀔 수 있다)
- 서버 내부 정보(스택, 쿼리, 호스트명)는 절대 응답에 싣지 않는다 — 로그로만 남긴다 ([[logging]]).
## SSOT — 레지스트리 한 곳
에러 코드 ↔ 메시지 ↔ HTTP 상태는 **백엔드 레지스트리 한 파일**이 단일 진실 공급원이다.
```
backend/src/common/errors/error-codes.ts ← 여기만 수정한다
│ npm run sync:error-codes
├─▶ frontend/src/shared/constants/error-codes.generated.ts (자동 생성)
└─▶ flutter/lib/core/api/error_lexicon.g.dart (자동 생성)
```
- **생성 파일은 직접 수정 금지.** 레지스트리를 고치고 `cd backend && npm run sync:error-codes` 를 실행한다.
- 코드 추가/변경 후에는 세 스택의 코드 목록 일치 테스트가 통과해야 한다
(backend `error-codes.spec.ts` · frontend `useApi.spec.ts` · flutter `error_lexicon_test.dart`).
목록을 바꿨다면 이 테스트의 기대 배열도 함께 갱신한다.
## 공통 코드
| 코드 | HTTP | 상황 | 메시지 |
| --- | --- | --- | --- |
| SYS_001 | 500 | 미정의 예외·네트워크 장애 (최종 폴백) | "일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." |
| REQ_001 | 429 | Rate Limit 초과 | "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요." |
| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." |
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
| VAL_001 | 400 / 422 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
### AUTH_002 (클라이언트 전용)
서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다.
- **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다.
- 웹: `useApi.ts` 의 `toAuthCode()` (`authStore.clearAuth()` 호출)
- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제)
## 도메인 코드 추가
1. `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 `DOMAIN_NNN` 형식으로 등록한다.
```typescript
USER_001: {
status: HttpStatus.CONFLICT,
message: '이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.',
},
```
2. `npm run sync:error-codes` 실행 → 클라이언트 사전 자동 갱신.
3. 세 스택의 코드 목록 테스트에 새 코드를 추가한다.
규칙:
- 번호는 **001 부터 순차 부여**하고, 한번 배포된 코드의 **의미는 바꾸지 않는다** (폐기 시 주석으로 `deprecated` 표시, 번호 재사용 금지).
- 도메인 접두어는 대문자 스네이크 (`USER`, `ORDER`, `PAYMENT`).
- 같은 의미의 실패에 새 코드를 만들지 말고 기존 코드를 재사용한다.
## 던지는 방법 (backend)
```typescript
import { BusinessException } from '@/common/errors/business.exception';
// 레지스트리 메시지 그대로 사용
throw new BusinessException('RES_001', { context: { userId: id } });
// 동적 값이 필요할 때만 메시지 재정의 (코드·상태는 레지스트리 값 유지)
throw new BusinessException('USER_001', {
message: `이미 가입된 이메일입니다. (${email})`,
context: { email },
});
```
- `context` 는 **로그 전용**이다. 응답 본문에는 포함되지 않으므로 식별자를 자유롭게 넣어도 된다.
- Nest 내장 예외(`NotFoundException` 등)를 던져도 필터가 상태 코드로 역매핑하지만, **새 코드에서는 `BusinessException` 을 쓴다** (코드가 명시되어 추적이 쉽다).
- `HttpExceptionFilter` 에 문구를 하드코딩하지 않는다 — 레지스트리 조회만 한다.
## 받는 방법 (client)
```typescript
// frontend — 인터셉터가 ApiError { code, message, traceId } 로 정규화해 reject
try {
await api.post('/users', payload)
} catch (e) {
const error = e as ApiError
if (error.code === 'USER_001') focusEmailField() // 코드로 분기
// 알림은 인터셉터가 이미 처리했으므로 여기서 다시 띄우지 않는다
}
```
```dart
// flutter — 인터셉터가 DioException.error 에 ApiException 을 실어준다
on DioException catch (e) {
final error = e.error as ApiException;
if (error.code == 'AUTH_002') redirectToLogin();
}
```
- 개별 `try/catch` 에서 **메시지를 새로 만들지 않는다.** 문구는 사전에서만 온다.
- 같은 코드의 알림이 연속으로 뜨지 않도록 인터셉터가 중복을 억제한다 — 호출부에서 다시 알림을 띄우지 말 것.
## 추적 (traceId)
- 모든 요청에 `x-request-id` 가 부여되고(`RequestIdMiddleware`), 실패 응답의 `error.traceId` 와 서버 로그에 같은 값이 남는다.
- 사용자 문의 대응 시 이 값으로 로그를 검색한다. 상세 화면·에러 페이지에 작게 노출하는 것을 권장한다.
> 응답 래퍼 구조는 [[response-format]], 로그 규칙은 [[logging]] 참조.
+38
View File
@@ -0,0 +1,38 @@
# Git 컨벤션
## 커밋 메시지
`<type>(<scope>): <제목>` 형식 — scope 는 선택.
- type: **feat / fix / refactor / docs / chore / perf / test / style**
- scope: 변경이 한 스택에 한정되면 명시 — `backend` / `frontend` / `flutter` / `docker` / `rules`
- 제목은 한국어, 명령형 현재 시제, 마침표 없음
- 한 커밋은 하나의 논리적 변경만 포함
```
feat(backend): 사용자 목록 커서 페이지네이션 추가
fix(frontend): 세션 만료 시 중복 알림 노출 수정
docs(rules): 에러 코드 SSOT 동기화 절차 정리
```
본문이 필요하면 제목 다음 줄을 비우고 **왜** 바꿨는지 적는다(무엇을 바꿨는지는 diff 가 말해준다).
## 브랜치
`<type>/<간단한-설명>` — 기본 브랜치는 `main`.
```
feat/user-pagination
fix/session-expired-alert
```
- 기본 브랜치에 직접 커밋하지 않는다. 작업은 브랜치에서 하고 PR 로 합친다.
- 브랜치는 머지 후 삭제한다. 오래 유지되는 브랜치를 만들지 않는다.
## PR
- 제목은 커밋 규칙과 동일한 형식
- 본문에 **무엇을 왜 바꿨는지**와 **확인 방법**을 적는다
- `.github/pull_request_template.md` 의 체크리스트를 채운다
- 머지 전 CI(`.github/workflows/ci.yml`)와 [[checklist]] 의 "커밋 전 공통 점검"을 통과해야 한다
- 에러 코드·응답 포맷 등 **공유 계약을 바꾸는 PR** 은 세 스택 영향 범위를 본문에 명시한다 ([[error-codes]] · [[response-format]])
+57
View File
@@ -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]] 참조.
+97
View File
@@ -0,0 +1,97 @@
# 표준 응답 포맷 (Global Response Wrapper)
모든 API 응답은 아래 구조로 통일한다.
SSOT: backend `TransformInterceptor` + `HttpExceptionFilter` ([[nestjs]] 참조)
```jsonc
// 성공
{ "success": true, "data": { /* ... */ } }
// 실패
{ "success": false, "error": { "code": "VAL_001", "message": "...", "traceId": "3f1c0a2e-..." } }
```
- 컨트롤러는 **raw 데이터만 반환**한다. `{success,data}` 래핑은 인터셉터가 담당 — 직접 래핑 금지.
- 클라이언트 인터셉터는 `success: true` 면 `data` 만 언래핑해 반환한다.
- 실패는 **항상 `error.code` 로 분기**한다. `message` 로 분기하지 않는다 ([[error-codes]]).
- `traceId` 는 서버가 요청마다 발급하는 추적 ID(`x-request-id`)다. 응답 헤더로도 함께 내려간다.
## 비즈니스 실패도 4xx 로 내린다
- 백엔드는 `success: false` 를 **항상 4xx/5xx 와 함께** 내려보낸다. HTTP 200 + `success:false` 조합은 만들지 않는다.
- 단, 클라이언트 인터셉터는 외부 게이트웨이 등이 200 으로 감싸는 경우를 대비해 **방어적으로 이 분기를 유지**한다.
## 래핑 예외 (`@SkipTransform()`)
본문 구조를 바꾸면 안 되는 응답은 백엔드에서 `@SkipTransform()` 을 붙여 래핑을 건너뛴다.
```typescript
@Get('export')
@SkipTransform() // 파일 스트림 — { success, data } 로 감싸면 깨진다
exportCsv(@Res() res: Response) { /* ... */ }
```
적용 대상: 파일 다운로드·스트림, 리다이렉트, `204 No Content`, 외부 규격을 따라야 하는 웹훅 응답,
그리고 **기계가 소비하는 엔드포인트**(헬스체크 등).
### 헬스체크는 표준 포맷에서 제외한다
`/api/health` 는 사람이 아니라 모니터링 도구가 읽는다. 래퍼로 감싸거나 실패를 `SYS_001` 로 덮어쓰면
**어느 구성요소가 죽었는지가 사라진다.** 그래서 성공·실패 모두 terminus 원본 형식을 그대로 내보낸다.
```jsonc
// 200 — 정상
{ "status": "ok", "info": { "database": { "status": "up" } }, "error": {}, "details": { ... } }
// 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` 는 **항상 아래 구조**를 가진다. 배열을 그대로 반환하지 않는다.
```jsonc
{
"success": true,
"data": {
"items": [ /* ... */ ],
"pageInfo": {
"hasNext": true,
"nextCursor": "eyJpZCI6MTQzfQ==", // 마지막 페이지면 null
"total": 143 // 계산 비용이 크면 null 허용
}
}
}
```
- 구현 SSOT: backend `common/dto/pagination.dto.ts`
(`CursorPaginationQueryDto` · `OffsetPaginationQueryDto` · `PageInfoDto` · `toPaginatedResult()`)
- 클라이언트 타입: frontend `src/shared/types/pagination.ts`, flutter `UserRepository.getUsers()` 패턴
- **커서 기반이 기본**이다. 전체 페이지 수가 UI 에 필요한 경우에만 오프셋을 쓴다.
- `limit` 기본값 20, 최대 100 — 무제한 조회를 허용하지 않는다.
## Swagger 문서 일치
컨트롤러는 raw 를 반환하지만 실제 응답은 래핑되므로, `@ApiResponse({ type: Dto })` 를 그대로 쓰면 문서와 실응답이 어긋난다.
```typescript
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
```
- `ApiStandardResponse` / `ApiErrorResponse` 는 `common/decorators/api-standard-response.decorator.ts` 제공.
- `ApiErrorResponse` 는 상태 코드·메시지를 에러 레지스트리에서 가져오므로 문서가 구현과 자동으로 일치한다.
> 에러 코드 ↔ 메시지 매핑은 [[error-codes]], API 설계 규약은 [[api-design]] 참조.
+128
View File
@@ -0,0 +1,128 @@
# 보안
## 비밀 정보
- `.env*` 등 민감정보 Git 커밋 금지 (`.gitignore` 확인) — 예시는 `*.example`(예: `.env.development.example`, `.env.example`) 로만 공유
- **`*.example` 파일의 값은 반드시 더미 placeholder 여야 한다.** 실제로 쓰는(또는 쓸 법한) 비밀번호·키를 예시 파일에 적지 않는다.
- ❌ `DB_PASSWORD=dev_960426`
- ✅ `DB_PASSWORD=change-me-in-local-env`
- 시크릿이 한 번이라도 커밋되었다면 **값을 교체(rotate)** 한다. 커밋을 되돌리는 것만으로는 노출이 해소되지 않는다.
- 로그·에러 응답에 시크릿을 남기지 않는다 ([[logging]]).
- **이미지에도 굽지 않는다.** `backend/`·`frontend/` 의 `.dockerignore` 가 `.env*` 를 빌드 컨텍스트에서 뺀다 —
값은 실행 시 compose 의 `env_file`/`environment` 로만 들어간다. `.dockerignore` 를 지우거나 `.env*` 를 다시 넣지 않는다.
## 인증 · 토큰
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`)
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_001)
## 입력 · 출력
- 입력값은 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` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
- **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고,
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
- **허용 목록(allowlist) 방식**으로만 받는다. 차단 목록은 언제나 빠뜨린다.
`.html`·`.svg`·`.js` 등 **브라우저가 실행할 수 있는 형식은 넣지 않는다.**
- **저장 파일명은 서버가 생성**한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
`../../etc/passwd` 같은 경로 탈출이 가능하다. 원본 이름은 표시용 메타데이터로만 보관한다.
- 크기 한도를 설정한다 (`UPLOAD_MAX_SIZE_MB`, 기본 5MB). 초과 시 `FILE_002`(413).
- 업로드 디렉터리를 **정적 파일로 그대로 서빙하지 않는다.** 내려줄 때는 컨트롤러를 거쳐
`Content-Type` 을 서버가 지정하고 `Content-Disposition: attachment` 를 붙인다.
> ⚠️ **알려진 한계**: 확장자·MIME 검사는 **파일 내용을 보지 않는다.**
> 이름이 `.png` 이고 MIME 이 `image/png` 이면 내용이 HTML 이어도 통과한다.
> 그래서 위의 "정적 서빙 금지"가 중요하다 — 내용 기반 검증이 필요하면
> 매직 바이트를 확인하는 라이브러리(`file-type` 등)를 파이프라인에 추가한다.
## 네트워크 · 인프라
- 보안 헤더(`helmet`) 적용
- CORS 는 화이트리스트 방식. 차단 시 `callback(null, false)` 로 **헤더를 부여하지 않아** 브라우저가 막게 한다
(`callback(new Error(...))` 를 쓰면 500(SYS_001)이 되어 원인 파악이 어려워진다)
- 프론트와 API 가 **같은 도메인**이 기본이라 화이트리스트는 비어 있다 (같은 출처 요청에는 CORS 가 적용되지 않는다).
다른 오리진이 필요할 때만 루트 `CORS_ORIGIN` 에 콤마로 나열한다 (`shared/config/cors.config.ts`)
- 개발용 `localhost` 를 코드에 기본 허용으로 박지 않는다 — 운영에서도 그대로 열린다
- Rate Limiting: **`@nestjs/throttler` 하나로 일원화**되어 있다 (`app.module.ts` 의 `APP_GUARD`).
초과 시 429 → 전역 필터가 **`REQ_001`** 로 매핑한다.
- 기본 정책은 루트 `.env` 의 `THROTTLE_TTL_MS`/`THROTTLE_LIMIT` (미설정 시 60초/100회)
- 라우트 단위 조정: `@Throttle({ default: { ttl, limit } })` / 제외: `@SkipThrottle()`
- 로그인·비밀번호 재설정처럼 브루트포스 표적이 되는 엔드포인트는 **더 엄격한 한도**를 따로 건다
- ⚠️ rate limiter 를 이중으로 걸지 말 것 — 어느 쪽이 실제로 동작하는지 알 수 없게 되고 응답 헤더가 실제 한도와 어긋난다
- **컨테이너 root 실행 금지 — 개발 환경 포함.** 운영뿐 아니라 dev 오버라이드가 사용하는 빌드 스테이지도 비-root 여야 한다.
- 프론트엔드 운영: `nginxinc/nginx-unprivileged` + `listen 8080` (비-root 는 1024 미만 포트 바인드 불가)
- Node 스테이지: `RUN chown node:node /app` → `USER node` → `COPY --chown=node:node`
(소유권을 먼저 넘겨야 익명 볼륨 `node_modules` 가 node 소유로 생성되어 캐시 쓰기가 가능하다)
- DB·Redis 는 `expose` 만 사용해 내부 네트워크에만 노출한다.
개발 편의로 호스트에 열어야 한다면 **반드시 `127.0.0.1` 로 한정**한다 — `"${DB_PORT}:5432"` 는 `0.0.0.0` 바인딩이라
같은 공유기(사무실·카페)에 있는 누구나 개발 DB 에 접속할 수 있다.
```yaml
ports:
- "127.0.0.1:${DB_PORT}:5432" # ✅
# - "${DB_PORT}:5432" # ❌ 외부 네트워크 노출
```
- **보안 헤더는 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` 에 그 도메인만 적는다.
## 의존성
- 의존성 추가 시 유지보수 상태를 확인하고, 주기적으로 `npm audit` / `flutter pub outdated` 로 취약점을 점검한다
- **`npm audit fix` 는 반드시 컨테이너 안에서 실행**한다. 호스트(Windows)에서 실행하면 lock 파일이 깨진다 ([[nestjs]])
```bash
docker exec <프로젝트>-backend npm audit fix
```
- **`--force` 는 함부로 쓰지 않는다.** 메이저 버전을 올려 다른 패키지와의 호환이 깨질 수 있다.
실행 전 `npm audit fix --force --dry-run` 으로 어떤 패키지가 올라가는지 확인한다.
### 남아 있는 취약점 판단 기준
모든 취약점을 즉시 올릴 수 있는 것은 아니다. 다음을 근거로 판단하고 **결정을 기록**한다.
1. **도달 가능한 코드 경로가 있는가** — 해당 기능을 쓰지 않으면 노출되지 않는다
2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험
3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다
> **현재 유예 중인 항목**: 없음 (`npm audit` 0건)
### 전이 의존성 취약점은 overrides 로 끊는다
직접 의존성이 취약한 하위 패키지를 **정확 버전으로 고정**해 두면 `npm audit fix` 로는 해결되지 않는다.
상위 패키지의 메이저 업그레이드를 기다리지 말고 `package.json` 의 `overrides` 로 하위 패키지만 올린다.
```jsonc
// backend/package.json
"overrides": {
"multer": "^2.4.0"
}
```
> 실제 사례: `@nestjs/platform-express@11` 은 `multer` 를 `2.2.0` 으로 고정하는데 이 버전에 DoS 취약점(high)이 있었다.
> `@nestjs/platform-express@12` 는 `multer@2.4.0` 을 쓰지만, v12 로 올리면 나머지 Nest 생태계(v11)와 호환이 깨진다.
> **v12 가 하는 일이 결국 multer 를 올리는 것**이므로, override 로 같은 결과를 생태계 업그레이드 없이 얻었다.
규칙:
- override 는 **상위 패키지가 그 버전을 실제로 쓰는지 근거를 확인**하고 적용한다
(위 사례는 platform-express v12 가 2.4.0 을 선언하는 것이 근거).
- 같은 메이저 안에서만 올린다. 메이저를 건너뛰면 API 가 달라져 상위 패키지가 깨질 수 있다.
- 적용 후 **lint·테스트·빌드·런타임 기동까지 확인**한다. override 는 npm 이 검증해 주지 않는다.
- 상위 패키지를 정식으로 올릴 때 **override 를 제거**한다. 남겨두면 나중에 버전이 거꾸로 고정된다.
> 환경변수 관리 규칙은 [[env-structure]], 도메인별 적용은 [[nestjs]] · [[flutter]] 참조.
+77
View File
@@ -0,0 +1,77 @@
---
name: testing
description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍, 무엇을 테스트할지, 커밋 전 실행. backend Jest, frontend Vitest, flutter flutter_test 기준.
---
# 테스트 규칙
> 템플릿은 각 스택에 테스트 러너가 준비되어 있다 — backend `jest`, frontend `vitest`, flutter `flutter_test`.
> 새 로직을 추가하면 최소한의 테스트를 함께 작성한다.
## 공통 원칙
- **무엇을 테스트하나**: 순수 함수(utils), 서비스/비즈니스 로직, 에러 코드 매핑 분기를 우선한다. 프레임워크 기본 동작은 테스트하지 않는다.
- **공유 계약은 반드시 테스트한다** — 아래 "계약 테스트" 절 참조.
- **테스트는 결정적(deterministic)** 으로 — 외부 네트워크/시간/랜덤에 의존하지 않도록 모킹한다.
- **커밋 전 실행** — 변경 영역의 테스트가 통과해야 한다. ([[checklist]])
- **CI 가 강제한다** — `.github/workflows/ci.yml` 이 세 스택의 lint·type-check·test·build 와 에러 코드 동기화를 PR 마다 검사한다.
- **테스트 파일도 타입 검사를 통과해야 한다.** backend 의 ts-jest 는 타입을 보지 않고(isolatedModules) 빌드는 spec 을 제외하므로,
타입이 틀린 목·픽스처로도 테스트가 통과해 버린다 — `npm run type-check` 가 테스트·e2e 까지 검사한다.
- 테스트 설명(it/test 제목)은 한국어로 "무엇을 검증하는지" 명확히 작성한다.
## 스택별 위치·실행
| 스택 | 러너 | 위치·네이밍 | 실행 |
| --- | --- | --- | --- |
| 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` |
## 예시 (backend 순수 유틸)
```typescript
// shared/utils/format.spec.ts
import { formatSomething } from './format';
describe('formatSomething', () => {
it('빈 입력이면 빈 문자열을 반환한다', () => {
expect(formatSomething('')).toBe('');
});
});
```
---
## 계약 테스트 (삭제·비활성화 금지)
세 플랫폼이 공유하는 계약은 어긋나도 컴파일 에러가 나지 않는다. 아래 테스트가 그 안전망이다.
| 대상 | 위치 | 검증 내용 |
| --- | --- | --- |
| 에러 코드 레지스트리 | `backend/src/common/errors/error-codes.spec.ts` | 네이밍 규약, 상태 역매핑, 클라이언트 전용 코드가 서버에서 선택되지 않음 |
| 에러 응답 포맷 | `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
> docker exec <프로젝트>-backend npx jest --config ./test/jest-e2e.json
> ```
> CI 는 postgres 서비스 컨테이너를 띄워 자동으로 실행한다.
- 에러 코드를 추가/삭제하면 **세 곳의 기대 목록을 함께 갱신**한다 ([[checklist]] 3절).
- 매핑 로직(응답 래퍼·상태 역매핑)을 바꾸면 해당 분기 테스트를 추가한다.
## 테스트하지 않아도 되는 것
- 프레임워크가 보장하는 동작 (라우팅, DI 주입 자체, ORM 쿼리 빌더)
- 단순 getter/setter, 위임만 하는 컨트롤러 메서드
- 화면 스타일·레이아웃 값
> API 응답 래퍼/에러 코드는 세 플랫폼 공유 계약이다 ([[response-format]] · [[error-codes]]).
+47
View File
@@ -0,0 +1,47 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Read(//**)",
"Bash(npm run lint:*)",
"Bash(npm run build:*)",
"Bash(npm run test:*)",
"Bash(npm run type-check:*)",
"Bash(npm run sync:error-codes)",
"Bash(npm run start:*)",
"Bash(npm ci)",
"Bash(pnpm lint:*)",
"Bash(pnpm build:*)",
"Bash(flutter analyze:*)",
"Bash(flutter test:*)",
"Bash(flutter pub get)",
"Bash(dart format:*)",
"Bash(docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs:*)",
"Bash(docker compose --env-file .env.production logs:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git add:*)"
],
"ask": [
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(docker compose:*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.development)",
"Read(./.env.production)",
"Read(./backend/.env)",
"Read(./backend/.env.development)",
"Read(./backend/.env.production)",
"Read(./frontend/.env)",
"Read(./frontend/.env.development)",
"Read(./frontend/.env.production)",
"Read(./flutter/.env)"
]
},
"env": {
"APP_ENV": "development"
}
}
+208
View File
@@ -0,0 +1,208 @@
---
name: docker
description: Docker Compose 기반 로컬·운영 오케스트레이션 실행/배포 규칙 — 개발/운영 환경 기동, 빌드 여부 판단(--build), 로그 확인, 루트 .env SSOT 주입. 컨테이너를 띄우거나 배포·로그 확인·docker-compose 작업을 할 때 사용한다.
---
> 이 스킬은 `.claude/skills/docker/SKILL.md`에 위치한다.
> 환경변수 SSOT 구조는 [[env-structure]] 참조.
> `.env.development` / `.env.production` 는 커밋된 `.env.*.example` 를 복사해 생성한다.
> (`.gitignore` 는 `.env.*` 를 무시하되 `.env.*.example` 만 추적한다.)
---
## 개발 환경
> **개발 환경에서 프론트엔드는 컨테이너로 뜨지 않는다.** compose profile `frontend` 뒤에 숨겨져 있다.
> vite 를 컨테이너에서 돌리면 소스가 바인드 마운트(`./frontend:/app`) 너머라 첫 서빙까지 수 분이 걸린다.
> 프론트엔드는 호스트에서 `cd frontend && npm run dev` 로 띄운다 (아래 "프론트엔드는 호스트에서" 참조).
```bash
# 코드만 수정했을 때 (의존성 변경 없음) — backend + db 가 뜬다
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 의존성 변경 시에만 --build
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build
# 로그 확인 (예: backend)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs -f backend
```
## 운영 환경
> **프론트와 API 는 같은 도메인을 쓴다.** 브라우저는 `/api` 상대경로로만 호출하고,
> 리버스 프록시(Nginx Proxy Manager)가 경로로 나눠 보낸다. 설정 절차는 README "운영 배포" 참조.
>
> | 경로 | 대상 (proxy 네트워크) | NPM 설정 |
> | --- | --- | --- |
> | `/` | `${PROJECT_NAME}-frontend:8080` | Proxy Host 의 기본 대상 |
> | `/api` | `${PROJECT_NAME}-backend:${BACKEND_PORT}` | 같은 Proxy Host 의 Custom location |
>
> - NPM 과 공유하는 외부 네트워크 `proxy` 가 있어야 기동한다 — 서버당 한 번 `docker network create proxy`.
> - Proxy Host 의 **Cache Assets 는 끈다.** 켜면 `/api/…png` 처럼 정적 확장자로 끝나는 요청이 custom location 대신 frontend 로 간다.
> - 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
# 운영 환경 배포
sudo docker compose --env-file .env.production up -d --build
# 로그 확인
sudo docker compose --env-file .env.production logs -f backend
```
---
## 프론트엔드는 호스트에서 (개발 환경)
```bash
cd frontend && npm install && npm run dev # → http://localhost:5173
```
- **포트는 루트 `.env.{APP_ENV}` 가 SSOT 다** ([[env-structure]]). `vite.config.ts` 가 루트 env 를 직접 읽어
`FRONTEND_PORT` 로 열고, `/api` 프록시 대상은 `http://localhost:${BACKEND_PORT}`(백엔드 컨테이너가
호스트에 노출한 포트)로 잡는다. 포트를 바꿀 때 루트 env 한 줄만 고치면 된다.
백엔드를 아예 다른 주소에 띄웠다면 `VITE_DEV_API_PROXY` 로 대상을 덮어쓴다.
- `strictPort: true` 라 포트가 이미 사용 중이면 **다음 포트로 옮기지 않고 실패**한다.
대개 다른 vite(컨테이너 vite 포함)가 이미 그 포트를 쓰고 있다는 뜻이라, 조용히 5174 로 옮겨가면
브라우저는 여전히 5173 의 엉뚱한 서버를 보게 된다 (fail-fast).
- 브라우저는 vite 와 **같은 출처**의 `/api` 를 호출하고 vite 프록시가 백엔드로 넘긴다 —
운영의 NPM Custom location 과 같은 구조라 개발에서도 CORS 설정이 필요 없다.
- 폴링 감시(`watch.usePolling`)는 **컨테이너에서만** 켜진다 (`CHOKIDAR_USEPOLLING` 로 판별).
호스트에서는 OS 파일 감시 이벤트로 충분하며, 폴링을 켜면 기동이 느려지고 CPU 만 쓴다.
- 의존성 설치는 호스트에서 해도 된다 — 프론트엔드 dev 는 컨테이너를 쓰지 않으므로
lock 파일이 깨지는 문제([[nestjs]] 의 Windows 주의 항목)는 **backend 에만** 해당한다.
단 `frontend` 프로필이나 운영 이미지를 빌드할 계획이라면 같은 주의가 적용된다.
### 컨테이너로 띄워야 할 때 (선택)
호스트에 Node 를 둘 수 없는 환경 등에서만 쓴다. 느린 것은 정상이다.
```bash
# 일회성
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development --profile frontend up -d
# 상시 — 루트 .env.development 에 (redis 와 함께 켤 때는 frontend,redis)
COMPOSE_PROFILES=frontend
```
- 호스트 vite 와 컨테이너 vite 는 같은 호스트 포트(`FRONTEND_PORT`)를 쓴다 — **동시에 띄우지 말 것.**
- 컨테이너 **내부** 포트는 5173 고정이다 (compose 의 `--port`·healthcheck·포트 매핑이 함께 묶여 있다).
루트 env 는 컨테이너 안에 없으므로 `vite.config.ts` 는 기본값으로 떨어지고, compose 의 `--port` 가 이긴다.
- 운영(`docker-compose.yml` 단독)에는 profile 이 없으므로 nginx 정적 호스팅이 그대로 동작한다.
---
## 자주 쓰는 보조 명령
> 개발 prefix가 길어 아래는 다음 별칭으로 줄여 표기한다.
> 운영은 `docker compose --env-file .env.production` (필요 시 `sudo`) 으로 동일하게 사용한다.
>
> ```bash
> alias dc='docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development'
> ```
```bash
# 상태 확인
dc ps
# 종료 — 컨테이너 중지·삭제 (DB/Redis 데이터 볼륨은 보존)
dc down
# ⚠️ 데이터까지 삭제 — DB/Redis 볼륨 제거 (복구 불가, 신중히)
dc down -v
# 특정 서비스만 재빌드·기동 (예: 의존성 바뀐 backend)
dc up -d --build backend
# 특정 서비스 재시작 (코드 변경은 dev 핫리로드라 보통 불필요)
dc restart backend
# 전체 / 특정 서비스 로그
dc logs -f
dc logs -f backend
# 컨테이너 쉘 접속
dc exec backend sh
# PostgreSQL 접속 (<DB_USER>·<DB_NAME> 은 루트 .env 값으로 치환)
dc exec db psql -U <DB_USER> -d <DB_NAME>
# Redis 접속 (<REDIS_PASSWORD> 은 루트 .env 값)
dc exec redis redis-cli -a <REDIS_PASSWORD>
```
---
## CI 러너 (별도 스택)
`.github/workflows/ci.yml` 을 실행하는 Gitea Actions 러너는 **앱 스택과 생명주기가 다르다.**
앱은 개발할 때만 띄우지만 러너는 항상 켜져 있어야 하므로 `ci/` 에 별도 compose 로 분리했다.
```bash
cd ci
cp .env.example .env # 등록 토큰 입력 (커밋되지 않음)
docker compose -f docker-compose.runner.yml --env-file .env up -d
```
- 러너가 없으면 워크플로는 **"Waiting for a runner"** 로 대기만 한다.
- `RUNNER_LABELS` 의 라벨이 워크플로의 `runs-on` 과 **일치해야** 잡을 집는다.
- 러너는 도커 소켓에 접근하므로 사실상 호스트 관리자 권한을 갖는다 — 신뢰할 수 있는 워크플로만 실행되게 한다.
설치·문제 해결은 [`ci/README.md`](../../../ci/README.md) 참조.
---
## 선택적 서비스 (Redis)
**Redis 는 기본적으로 기동하지 않는다.** 단일 인스턴스에서는 in-memory 캐시로 충분하고,
쓰지 않는 컨테이너는 메모리만 차지하며 혼란을 준다.
```bash
# 켜기 — 루트 .env.{APP_ENV} 한 줄 수정 (이후 평소 명령 그대로)
COMPOSE_PROFILES=redis
# (프론트엔드 컨테이너도 함께 켤 때는 COMPOSE_PROFILES=redis,frontend)
# 또는 일회성
dc --profile redis up -d
```
- compose `profiles: ["redis"]` 로 숨겨져 있고, backend 의 의존성은 `required: false` 라
Redis 가 없어도 정상 기동한다.
- 데이터 볼륨(`${REDIS_DATA_DIR}`)과 `REDIS_*` 환경변수는 그대로 유지되므로,
켜면 이전 데이터와 설정이 그대로 이어진다.
- **애플리케이션 코드는 아직 Redis 를 사용하지 않는다.** 실제 연결 절차는 [[nestjs]] 참조.
- 끝까지 쓰지 않을 프로젝트라면 compose 에서 redis 서비스와 관련 env 를 지우는 편이 낫다.
---
## 규칙
- **빌드 최소화**: 의존성(package.json / pubspec 등) 변경이 없으면 `--build` 없이 기동한다.
- **의존성을 바꿨다면 `--renew-anon-volumes` 를 함께 준다.**
dev 는 `/app/node_modules` 를 익명 볼륨으로 마운트하는데, `--build` 만으로는 **예전 볼륨이 그대로 재사용된다.**
즉 새 패키지가 이미지에는 들어가도 컨테이너에는 반영되지 않는다.
```bash
dc up -d --build --renew-anon-volumes backend
```
(`-V` 는 같은 옵션의 축약형. DB 볼륨은 named volume 이라 영향받지 않는다)
- **env 파일**: 실행 전 아래 두 파일이 모두 있어야 한다. 없으면 `*.example` 을 복사해 생성한다.
- 루트 `.env.{APP_ENV}` — 설정 SSOT ([[env-structure]])
- `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다**
복사 후 `change-me-*` placeholder 값을 반드시 교체한다 ([[security]]).
- **Graceful shutdown**: backend 는 `init: true`(tini)로 PID 1 을 두어 SIGTERM 이 애플리케이션까지 전달되게 하고,
`stop_grace_period: 30s` 동안 처리 중인 요청을 마치도록 한다.
- 정상 종료 시 컨테이너 **종료 코드는 143**(=128+SIGTERM)이다. 오류가 아니라 시그널에 의한 정상 종료다.
- ⚠️ dev 오버라이드는 PID 1 이 `npm` 이라 SIGTERM 이 앱까지 전달되지 않는다(개발 편의상 허용).
graceful shutdown 동작 확인은 **운영 이미지**(`--target production`)로 해야 한다.
같은 도메인 기본 구성(`BACKEND_API_URL=/api`·`CORS_ORIGIN` 비움)이면 공개 주소 검사에 걸릴 값이 없다.
별도 도메인 구성을 localhost 로 검증할 때만 `ALLOW_LOCALHOST_URL=true` 로 그 검사를 건너뛴다 ([[env-structure]]).
- **컨테이너 비-root 구동**: 모든 서비스 컨테이너는 비-root 로 실행한다 — **dev 오버라이드도 포함** ([[security]] 참조).
- 프론트엔드 운영 컨테이너는 `nginx-unprivileged` 라 **8080** 포트를 쓴다 (`expose`·healthcheck·nginx.conf 가 모두 8080).
- 프론트엔드 dev 컨테이너는 vite 를 **5173** 으로 띄운다 (비-root 는 80 포트 바인드 불가).
- 포트를 바꿀 때는 Dockerfile `EXPOSE` · `nginx.conf` 의 `listen` · compose `expose`/`healthcheck` 를 **함께** 고쳐야 한다.
- **DB/Redis 호스트 포트**: dev 에서만 열며 `127.0.0.1` 로 한정한다. 접두어 없이 쓰면 LAN 전체에 노출된다 ([[security]]).
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`.
+257
View File
@@ -0,0 +1,257 @@
---
name: flutter
description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null Safety, feature-first 디렉터리 구조, 작은 위젯 분리, 반응형 UI, Riverpod 상태관리, Dio 인터셉터, flutter_secure_storage 토큰 저장. flutter/ 에서 작업하거나 위젯·프로바이더·모델·API 클라이언트를 만들 때 사용한다.
---
> 이 스킬은 `.claude/skills/flutter/SKILL.md`에 위치한다.
> 사용법: `/flutter [요청 내용]` 또는 flutter/ 작업 시 자동 적용.
> 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[security]] · [[coding-common]]
> Flutter 는 docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립 관리한다 ([[env-structure]] 참조).
---
## 템플릿 기본 제공 골격
> 이 템플릿은 네트워크 계층을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
| Dio 클라이언트 | `lib/core/api/dio_client.dart` | 싱글톤, baseUrl=dotenv `API_URL`(fallback 로컬), 타임아웃 |
| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(코드 매핑) — 인라인 |
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}` 정규화 예외. 인터셉터가 `DioException.error` 에 실어준다 |
| ErrorLexicon | `lib/core/api/error_lexicon.dart` | 사전 접근 API. 실제 문구는 `error_lexicon.g.dart`(**자동 생성, 수정 금지**) ([[error-codes]]) |
| 세션 만료 처리 | `dio_client.dart` 의 `_toApiException()` | 토큰 보유 상태에서 401 → `AUTH_002` 승격 + 토큰 삭제 |
| 토큰 저장 | `flutter_secure_storage`, key 상수 `kJwtTokenKey` | `dio_client` 에서 사용 |
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | Riverpod 사용 준비됨 |
| Dio Provider | `lib/core/api/dio_provider.dart` | 인터셉터 적용된 Dio 를 DI 로 주입 |
| 예제 feature(참조용) | `lib/features/user/` | `data/models`(UserModel)·`data/repositories`(단건+커서 목록)·`presentation/providers` 수직 슬라이스 |
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
> - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))`
> - **앱 구조**: 라우터 도입 시 `MyApp` 을 `app.dart` 로 분리, `core/{constants,theme,storage}` 채움, 공통 위젯은 `shared/widgets/`
> - **토큰 저장 추상화(선택)**: 필요 시 `core/storage/` 래퍼로 분리
> - **권장 패키지(선택)**: `go_router`·`freezed`·`json_serializable`·`cached_network_image`·`fpdart` 도입 시 `pubspec.yaml` 추가
---
## 언어 & 기본 원칙
- **Dart Sound Null Safety** 완벽 준수 (`?`, `!`, `late` 올바르게 사용)
- `dynamic` 타입 사용 **지양** → 명시적 타입 선언
- 모든 주석은 **한국어**로 작성
---
## 프로젝트 구조
```
lib/
├── main.dart
├── app.dart ← MaterialApp / 라우터 설정
├── core/
│ ├── constants/ ← 상수 (색상, 크기, 텍스트 등)
│ ├── theme/ ← 앱 테마 정의
│ ├── api/ ← Dio 인스턴스(dio_client), 에러 사전(error_lexicon), 인터셉터
│ ├── storage/ ← flutter_secure_storage 래퍼 (선택)
│ └── utils/ ← 순수 유틸 함수
├── features/
│ └── user/
│ ├── data/
│ │ ├── models/ ← JSON 직렬화 모델
│ │ └── repositories/ ← API 호출 구현체
│ ├── domain/
│ │ └── entities/ ← 순수 비즈니스 엔티티
│ └── presentation/
│ ├── pages/ ← 화면 단위 위젯
│ ├── widgets/ ← 재사용 위젯
│ └── providers/ ← 상태 관리
└── shared/
└── widgets/ ← 앱 공통 위젯 (생성 완료)
```
---
## 위젯 작성 원칙
```dart
// ✅ 좋은 예 — 작은 위젯으로 분리
class UserProfilePage extends StatelessWidget {
const UserProfilePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const _AppBar(),
body: const _ProfileBody(),
);
}
}
class _ProfileBody extends StatelessWidget {
const _ProfileBody();
@override
Widget build(BuildContext context) {
return Column(
children: [
const _AvatarSection(),
const _InfoSection(),
],
);
}
}
```
- 위젯 트리 depth가 깊어지면 **커스텀 위젯으로 분리**
- `build()` 메서드 내 비즈니스 로직 작성 **금지**
- `const` 생성자 적극 활용 (불필요한 리빌드 방지)
---
## 반응형 UI
```dart
// ❌ 하드코딩 금지
Container(width: 375, height: 200)
// ✅ 반응형 처리
Container(
width: MediaQuery.of(context).size.width * 0.9,
child: ...,
)
// ✅ 유연한 레이아웃
Expanded(flex: 2, child: LeftPanel()),
Expanded(flex: 1, child: RightPanel()),
```
- `Expanded`, `Flexible`, `MediaQuery`, `LayoutBuilder` 적극 활용
- 하드코딩 픽셀값 사용 **금지**
- 폰트 크기는 `Theme.of(context).textTheme` 활용
---
## 상태 관리
```dart
// Riverpod 예시 (권장)
@riverpod
Future<User> fetchUser(FetchUserRef ref, int id) async {
final repo = ref.watch(userRepositoryProvider);
return repo.getUser(id);
}
// 위젯에서 사용
class UserWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(fetchUserProvider(userId));
return userAsync.when(
data: (user) => Text(user.name),
loading: () => const CircularProgressIndicator(),
error: (e, _) => const ErrorWidget(),
);
}
}
```
- **Riverpod** 권장 (Provider는 레거시로 간주)
- `StatefulWidget`은 애니메이션, 폼 등 로컬 UI 상태에만 제한적 사용
---
## API 통신 (Dio + 인터셉터)
실제 구현(`lib/core/api/dio_client.dart`)은 싱글톤 + `InterceptorsWrapper` 인라인 방식이다. 인터셉터 로직 추가 시 이 파일의 패턴을 그대로 따른다.
```dart
// core/api/dio_client.dart (실제 구조 요약)
class DioClient {
DioClient._internal() {
// flutter_dotenv 로 .env 로드, 미설정 시 로컬 기본값
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
));
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
// jwt_token 읽어 Authorization 헤더 첨부
final token = await _storage.read(key: kJwtTokenKey);
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
},
onResponse: (response, handler) {
// success==true → data 언래핑, false → ApiException 으로 reject
handler.next(response);
},
onError: (err, handler) async {
// 상태코드/서버 error.code → ApiException{code,message,traceId} ([[error-codes]] 참조)
// 토큰 보유 상태의 401 은 AUTH_002(세션 만료)로 승격하고 토큰을 삭제한다.
handler.next(err.copyWith(error: await _toApiException(err)));
},
));
}
}
```
> 인터셉터를 별도 클래스(`AuthInterceptor`/`ErrorInterceptor`)로 분리하고 싶다면 리팩터링 가능하나, 현재는 위 인라인 구조가 SSOT다.
---
## 보안 (토큰 저장)
```dart
// flutter_secure_storage 사용
const storage = FlutterSecureStorage();
// 저장 (키 이름은 dio_client 의 상수로 통일: kJwtTokenKey == 'jwt_token')
await storage.write(key: kJwtTokenKey, value: token);
// 읽기
final token = await storage.read(key: kJwtTokenKey);
// 삭제 (로그아웃)
await storage.deleteAll();
```
- JWT, 민감 정보는 반드시 `flutter_secure_storage` 사용
- 키 문자열을 여기저기 적지 말고 `kJwtTokenKey` 상수를 쓴다 ([[coding-common]])
- `SharedPreferences`에 토큰 저장 **금지**
- 추가 보안 규칙은 [[security]] 참조.
---
## 성능 & 최신 트렌드 추가 규칙
- **모델 직렬화**: `json_serializable` + `freezed` 사용 권장 (불변 모델)
- **이미지**: `cached_network_image`로 네트워크 이미지 캐싱
- **목록**: 긴 목록은 `ListView.builder` 사용 (전체 렌더링 금지)
- **에러 핸들링**: `Either<Failure, Success>` 패턴 고려 (`fpdart` 패키지)
- **라우팅**: `go_router` 사용 권장 (딥링크, 중첩 라우팅 지원)
- **환경 분리**: `--dart-define` 또는 `flutter_dotenv`로 dev/prod 분리
---
## 작업 점검 체크리스트
(전체 확장 지점·점검 목록은 [[checklist]])
- [ ] **Null Safety**: `dynamic` 금지, `?`/`!`/`late` 정확히 사용.
- [ ] **위젯**: depth 깊어지면 private 위젯 분리, `const` 적극, `build()` 내 비즈니스 로직 금지.
- [ ] **반응형**: 하드코딩 픽셀 금지 → `MediaQuery`/`Expanded`/`Flexible`.
- [ ] **API**: `dio_client.dart` 의 인터셉터 패턴 재사용. 리포지토리에서 try/catch 로 메시지를 만들지 않는다.
- [ ] **에러 분기**: `e.error as ApiException` 의 **`code` 로 분기**한다. 문구는 사전에서만 온다.
- [ ] **에러 사전**: `error_lexicon.g.dart` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`.
- [ ] **목록**: 응답은 `{items, pageInfo}` — `UserRepository.getUsers()` 패턴 재사용 ([[response-format]]).
- [ ] **토큰**: `flutter_secure_storage` + 키 상수 `kJwtTokenKey` 통일. `SharedPreferences` 저장 금지.
- [ ] **상태관리**: 새 기능은 Riverpod 프로바이더/노티파이어로 작성 (`ProviderScope` 는 기본 제공).
- [ ] **구조 보완 시**: `app.dart` 분리, `core/{constants,theme,storage}` 생성. 공통 위젯은 `lib/shared/widgets/` 에 둔다(이동 완료).
- [ ] **권장 패키지**: 모델(`freezed`/`json_serializable`)·라우팅(`go_router`) 도입 시 `pubspec.yaml` 추가 후 `flutter pub get`.
- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과.
CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리).
다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다.
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
+331
View File
@@ -0,0 +1,331 @@
---
name: nestjs
description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스/DTO 관심사 분리, 표준 응답 인터셉터, 전역 예외 필터, Swagger, JWT 인증, 캐싱/Rate Limiting/페이지네이션. backend/ 에서 작업하거나 REST API·모듈·DTO·인터셉터·필터를 만들 때 사용한다.
---
> 이 스킬은 `.claude/skills/nestjs/SKILL.md`에 위치한다.
> 사용법: `/nestjs [요청 내용]` 또는 backend/ 작업 시 자동 적용.
> 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[logging]] · [[security]] · [[coding-common]]
---
## 템플릿 기본 제공 골격
> 이 템플릿은 아래 인프라를 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
| **에러 코드 레지스트리** | `src/common/errors/error-codes.ts` | 코드↔메시지↔HTTP 상태 **SSOT**. `npm run sync:error-codes` 로 클라이언트 사전 생성 |
| **BusinessException** | `src/common/errors/business.exception.ts` | 에러 코드가 **생성자 필수 인자** — 코드 없는 실패를 만들 수 없다 |
| **전역 설정** | `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 | `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_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` — 기계가 읽는 엔드포인트 패턴. 운영에서는 구성요소별 status 만 노출 ([[response-format]]) |
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
> - **인증(JWT)**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증
> - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체).
> `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}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
---
## 아키텍처 원칙
### 관심사 분리 (Separation of Concerns)
```
Controller → HTTP 라우팅, 요청/응답 파싱만 담당
Service → 모든 비즈니스 로직 처리
Repository → 데이터 접근 계층 (TypeORM)
DTO → 요청/응답 데이터 형식 정의 및 검증
```
- `Controller`에 비즈니스 로직 작성 **금지**
- `new` 키워드로 수동 객체 생성 **지양** → 의존성 주입(DI) 활용
### 모듈 구조
```
src/
├── modules/
│ └── user/
│ ├── user.module.ts
│ ├── user.controller.ts
│ ├── user.service.ts
│ ├── user.repository.ts ← 커스텀 Repository (선택)
│ └── dto/
│ ├── create-user.dto.ts
│ └── update-user.dto.ts
├── common/
│ ├── interceptors/ ← 응답 래핑, 에러 처리
│ ├── filters/ ← 전역 Exception Filter
│ ├── guards/ ← 인증/권한 Guard
│ ├── decorators/ ← 커스텀 데코레이터
│ └── pipes/ ← 전역 Validation Pipe
└── shared/
└── utils/ ← 순수 유틸 함수
```
---
## DTO 작성 규칙
```typescript
import { IsString, IsEmail, IsNotEmpty, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty()
email: string;
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
password: string;
}
```
- 모든 DTO는 `class-validator` + `class-transformer` 사용
- 에러 메시지는 **한국어**로 작성
- `@ApiProperty()` 반드시 포함 (Swagger 명세용)
---
## 표준 응답 인터셉터
```typescript
// common/interceptors/transform.interceptor.ts
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => ({
success: true,
data: data ?? null,
})),
);
}
}
```
- `app.setup.ts` 의 `configureApp()` 에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)))`
— main.ts 와 e2e 가 이 함수를 공유한다. 전역 설정을 main.ts 에 직접 추가하지 말 것 (테스트가 실제 설정과 어긋난다)
- 실패 응답(`success: false`)은 아래 Exception Filter가 생성한다.
- 응답 포맷 SSOT는 [[response-format]] 참조.
---
## 에러 처리 — 코드 없는 실패는 만들지 않는다
사용자에게 노출되는 모든 실패는 `BusinessException` 으로 던진다. 에러 코드가 **생성자 필수 인자**이므로 코드 누락이 구조적으로 불가능하다.
```typescript
import { BusinessException } from '@/common/errors/business.exception';
// 레지스트리의 상태 코드·메시지를 그대로 사용
throw new BusinessException('RES_001', { context: { userId: id } });
// 동적 값이 필요할 때만 메시지 재정의 (코드·HTTP 상태는 레지스트리 값 유지)
throw new BusinessException('USER_001', {
message: `이미 가입된 이메일입니다. (${email})`,
context: { email },
});
```
- `context` 는 **로그 전용** — 응답 본문에 포함되지 않으므로 식별자를 자유롭게 넣어도 된다 ([[logging]]).
- 새 코드는 `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록하고 `npm run sync:error-codes` 를 실행한다.
- `HttpExceptionFilter` 는 레지스트리를 **조회만** 한다. 필터에 문구를 하드코딩하지 말 것.
- Nest 내장 예외(`NotFoundException` 등)도 필터가 상태 코드로 역매핑해 주지만, 새 코드에서는 `BusinessException` 을 쓴다.
- 미정의 예외는 항상 `SYS_001` 로 수렴하며 내부 정보(스택·호스트명)는 응답에 노출되지 않는다.
전체 규칙과 도메인 코드 추가 절차는 [[error-codes]] 참조.
---
## Swagger 필수 적용
```typescript
// shared/config/swagger.config.ts — main.ts 는 isSwaggerEnabled() 일 때만 setupSwagger(app) 를 호출한다
const config = new DocumentBuilder()
.setTitle('API 문서')
.setDescription('프로젝트 API 명세서')
.setVersion('1.0')
.addBearerAuth()
.build();
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}` 라 문서가 어긋난다.
```typescript
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음') // 상태·메시지를 레지스트리에서 가져온다
```
- 모든 DTO에 `@ApiProperty()` + 한국어 `description` 작성
---
## 인증 (JWT)
> 📐 **확장 지점.** 이 템플릿은 JWT 인증을 강제하지 않는다(규칙만 제공). **인증이 필요해지면** 아래 규칙대로 구현한다: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증.
- Access Token: 만료 시간 짧게 (15분 ~ 1시간)
- Refresh Token: HttpOnly Secure 쿠키로 전달
- `@nestjs/jwt` + `@nestjs/passport` 사용
- Guard를 통한 라우트 보호
- 토큰 저장·전송 보안 규칙은 [[security]] 참조.
---
## 성능 & 최신 트렌드 추가 규칙
- **캐싱**: `@nestjs/cache-manager` 가 전역 등록되어 있으나 **현재 저장소는 in-memory** 다.
> 📐 **Redis 는 확장 지점이다.** compose 에서 **기본 비활성**(profile)이며 애플리케이션도 사용하지 않는다.
> 단일 인스턴스에서는 in-memory 로 충분하다.
>
> Redis 가 실제로 필요해지는 시점(다중 인스턴스 운영, 세션·캐시 공유, 분산 락)에 연결한다:
> 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]])
> 1. Redis store 패키지 설치 (**컨테이너 안에서** — 위 의존성 주의 항목 참조)
> 2. `app.module.ts` 의 `CacheModule.register` 를 `registerAsync` + Redis store 로 교체
> 3. `modules/health` 에 Redis 인디케이터 추가 (지금은 DB 만 검사한다)
> 4. 연결 실패 시 앱이 죽지 않도록 폴백 전략을 정한다
>
> 끝까지 쓰지 않을 프로젝트라면 compose 에서 redis 서비스와 관련 env 를 지우는 편이 낫다.
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD`). 초과 시 429 → `REQ_001`.
라우트 단위 조정은 `@Throttle({ default: { ttl, limit } })`, 제외는 `@SkipThrottle()` (헬스체크에 적용되어 있다).
**rate limiter 를 중복으로 추가하지 말 것** ([[security]])
- **Pagination**: 목록 API는 반드시 페이지네이션 적용 — `common/dto/pagination.dto.ts` 의 쿼리 DTO + `toPaginatedResult()` 사용. 응답은 `{items, pageInfo}` 고정 ([[response-format]])
- **Logging**: `winston` 또는 `@nestjs/common Logger` 사용, `console.log` 금지. 레벨 기준·민감정보 마스킹은 [[logging]]
- **Graceful shutdown**: `app.enableShutdownHooks()` 가 적용되어 있다. 종료 시 정리가 필요한 리소스(큐 소비자, 워커, 외부 연결)는
`OnApplicationShutdown` 훅으로 정리한다. **`process.on(signal)` 로 직접 잡지 말 것** — Nest 의 종료 흐름에 간섭해 종료 코드가 달라진다
(`shutdown.logger.ts` 가 참고 패턴).
- **Health Check**: `@nestjs/terminus` 로 구성. 주의점 두 가지 —
`pingCheck` 기본 타임아웃 1초는 짧아 유휴 커넥션 재연결 시 오판하므로 늘려서 쓰고,
표준 응답 래퍼/에러 코드에서 제외해 원인이 보이게 한다 ([[response-format]])
- **Versioning**: API 버전 관리 (`/v1/`, `/v2/`) 적용 고려
---
## DB 마이그레이션
스키마 변경은 **마이그레이션으로 관리**한다. `synchronize` 는 기본적으로 꺼져 있다.
> **왜 끄는가**: `synchronize: true` 는 엔티티를 DB 에 자동 반영하므로 개발 DB 가 항상 최신 상태가 된다.
> 그러면 `migration:generate` 가 "변경 없음"만 내놓고, 그 코드를 운영에 배포하면
> (운영은 synchronize 가 꺼져 있고 실행할 마이그레이션도 없어) **테이블이 만들어지지 않는다.**
> 개발과 운영이 같은 경로를 쓰도록 마이그레이션으로 통일한다.
### 엔티티 추가/변경 절차
```bash
# 0) 개발 스택이 떠 있어야 한다 (DB 접속 정보는 루트 .env → compose 주입)
# dc = docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development
# 1) 엔티티 작성 (src/**/*.entity.ts)
# 2) 마이그레이션 생성 — 엔티티와 실제 DB 스키마의 차이를 자동으로 계산한다
dc exec backend npm run migration:generate -- src/migrations/AddUser
# 3) 생성된 SQL 을 반드시 눈으로 확인한다 (의도치 않은 DROP 이 없는지)
# 4) 적용
dc exec backend npm run migration:run
# 5) 엔티티와 마이그레이션 파일을 함께 커밋
```
| 명령 | 용도 |
| --- | --- |
| `npm run migration:generate -- src/migrations/<이름>` | 엔티티 ↔ DB 차이로 자동 생성 |
| `npm run migration:create -- src/migrations/<이름>` | 빈 마이그레이션 (데이터 이관 등 수동 SQL) |
| `npm run migration:run` | 미적용 마이그레이션 실행 |
| `npm run migration:revert` | 마지막 마이그레이션 롤백 |
| `npm run migration:show` | 적용 상태 확인 |
규칙:
- **명령은 컨테이너 안에서 실행**한다. DB 접속 정보가 compose 로 주입되기 때문이다.
- 생성된 SQL 을 **검토 없이 커밋하지 않는다.** 컬럼 rename 이 DROP+ADD 로 생성되면 데이터가 날아간다.
- 마이그레이션 파일은 **한번 커밋되면 수정하지 않는다.** 잘못됐으면 새 마이그레이션으로 고친다.
- 운영 배포 시 `migrationsRun: true` 로 자동 실행된다 — 별도 명령이 필요 없다.
- 빠른 실험이 필요하면 개발 환경에서만 `DB_SYNCHRONIZE=true` 로 임시 활성화하되,
**커밋 전에는 끄고 마이그레이션을 생성**한다. 운영에서는 이 값과 무관하게 항상 비활성화된다.
- 접속 설정은 `shared/config/database.config.ts` 하나만 수정한다 (앱과 CLI 가 공유).
---
## ⚠️ 의존성 추가/제거 (Windows 개발 환경 주의)
**Windows 에서 `npm install` / `npm uninstall` 을 실행하면 lock 파일에서 Linux 전용 optional 의존성이 잘려나간다.**
로컬에서는 멀쩡하지만 Docker(Linux) 빌드의 `npm ci` 가 다음처럼 실패한다.
```
npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync.
npm error Missing: @emnapi/core@1.11.3 from lock file
```
그래서 의존성 변경은 **컨테이너 안에서** 수행한다 (lock 이 Linux 기준으로 생성된다).
```bash
# 개발 스택이 떠 있는 상태에서
dc exec backend npm install <패키지>
dc exec backend npm uninstall <패키지>
```
- 변경 후 호스트의 `package.json`·`package-lock.json` 이 갱신됐는지 확인하고 **함께 커밋**한다 (bind mount 라 호스트에 반영된다).
- 호스트에서 실행해버렸다면 `git checkout backend/package-lock.json` 으로 되돌린 뒤 위 방법으로 다시 한다.
- 커밋 전 `docker compose ... build backend` 로 `npm ci` 가 통과하는지 확인한다. CI 도 같은 검사를 한다.
- 전이 의존성의 취약점이 `npm audit fix` 로 안 풀리면 `package.json` 의 `overrides` 를 쓴다 ([[security]]).
현재 `multer: ^2.4.0` 이 걸려 있다 — `@nestjs/platform-express` 를 v12 로 올릴 때 제거한다.
---
## 작업 점검 체크리스트
코드 작성/수정 후 아래를 점검한다. (전체 확장 지점·점검 목록은 [[checklist]])
- [ ] **응답 포맷**: 컨트롤러는 raw 데이터만 반환 — `{success,data}` 래핑은 인터셉터가 담당. 직접 래핑 금지. 스트림/204 는 `@SkipTransform()`.
- [ ] **에러**: 사용자 노출 실패는 `BusinessException('CODE')` 로 던진다. 코드 없는 실패 금지.
- [ ] **에러 코드 추가 시**: `error-codes.ts` 만 수정 → `npm run sync:error-codes` 실행 → 세 스택 목록 테스트 갱신 ([[error-codes]]).
- [ ] **목록 API**: 페이지네이션 적용, 응답은 `{items, pageInfo}`.
- [ ] **DTO**: 모든 입력 DTO 에 `class-validator` + 한국어 메시지 + `@ApiProperty()`.
- [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation` + `@ApiStandardResponse`/`@ApiErrorResponse`.
- [ ] **경로 alias**: `@/` 사용 (`tsconfig.json` paths + jest `moduleNameMapper` 설정 완료). 깊은 상대경로 금지.
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run type-check` + `npm run test` 통과. 자동 수정은 `npm run lint:fix`.
`type-check` 는 테스트·e2e·스크립트까지 본다 — 빌드는 spec 을 제외하고 ts-jest 는 타입을 보지 않으므로(isolatedModules) 테스트 파일의 타입 오류는 여기서만 잡힌다.
- [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `npm install/uninstall` (위 주의 항목 참조). 변경 후 Docker 빌드 확인.
- [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용.
- [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]).
- [ ] **DB 전환 시**: in-memory Map → TypeORM 엔티티/리포지토리로 교체.
- [ ] **인증 추가 시**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/003 흐름 확인.
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
+238
View File
@@ -0,0 +1,238 @@
---
name: vue3
description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API + <script setup>, Props/Emits 타입 정의, Pinia 스토어, Composables+Axios API 호출, Vue Router 코드 스플리팅·가드. frontend/ 에서 작업하거나 컴포넌트·스토어·컴포저블·라우터를 만들 때 사용한다.
---
> 이 스킬은 `.claude/skills/vue3/SKILL.md`에 위치한다.
> 사용법: `/vue3 [요청 내용]` 또는 frontend/ 작업 시 자동 적용.
> 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[security]] · [[coding-common]]
---
## 템플릿 기본 제공 골격
> 이 템플릿은 아래를 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 복제한다(같은 역할을 새로 만들지 말 것).
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
| 중앙 Axios 인스턴스 | `src/composables/useApi.ts` | baseURL=`VITE_API_BASE_URL`(비면 같은 도메인의 `/api`), `withCredentials` |
| 응답 언래핑 인터셉터 | `useApi.ts` | `{success,data}→data`, 에러 코드 정규화(`ApiError`), 중복 알림 억제 |
| 에러 코드 사전 | `src/shared/constants/error-codes.generated.ts` | **자동 생성 파일 — 직접 수정 금지** ([[error-codes]]) |
| 세션 만료 처리 | `useApi.ts` 의 `toAuthCode()` | 로그인 상태에서 401 → `AUTH_002` 승격 + `authStore.clearAuth()` |
| 페이지네이션 타입 | `src/shared/types/pagination.ts` | 목록 응답 공유 계약 ([[response-format]]) |
| 도메인 Composable 예제 | `src/composables/useUser.ts` | 새 도메인 API 패턴 참고 |
| 인증 Composable 예제 | `src/composables/useAuth.ts` | 로그인/로그아웃 흐름(쿠키 기반) 시작점 |
| Pinia 스토어 예제 | `src/stores/user.store.ts` | 새 스토어 패턴 참고 |
| 인증 스토어 | `src/stores/auth.store.ts` | 세션 인증 여부(`isLoggedIn`) 관리 |
| 공통 컴포넌트 예제 | `src/components/BaseButton.vue` | 재사용 컴포넌트 기준 패턴(타입 Props/Emits·scoped·rem·a11y) |
| 라우터 | `src/router/index.ts` | lazy import + `auth.store` 연동 `requiresAuth` 가드 |
| 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]]).
> 백엔드·DB 는 평소대로 컨테이너로 띄우고, `/api` 는 vite 프록시가 `localhost:${BACKEND_PORT}` 로 넘긴다.
> 두 포트 모두 `vite.config.ts` 가 **루트 `.env.{mode}` 에서 직접 읽는다** — 프론트 쪽에 포트를 중복 작성하지 않는다.
> **확장 시 따르는 규칙:**
> - **로그인 연동**: 실제 `/auth/login` 엔드포인트 연결 → 성공 시 `useAuth` 가 `authStore.markAuthenticated()` 호출
> - **로그인 페이지**: 추가 시 라우터 가드 리다이렉트를 `{ name: 'login' }` 으로 변경
> - **공통 컴포넌트**: `BaseButton` 패턴을 따라 `src/components/` 에 추가
---
## 컴포넌트 작성 원칙
### 필수 구조
```vue
<script setup lang="ts">
// 1. import
// 2. Props / Emits 정의
// 3. Store / Composable 호출
// 4. 반응형 상태 (ref, reactive, computed)
// 5. 함수 정의
// 6. Lifecycle Hooks
// 7. watch
</script>
<template>
<!-- 시맨틱 HTML 태그 사용 -->
</template>
<style scoped>
/* rem 단위 사용, 인라인 스타일 금지 */
</style>
```
- `Options API` 사용 **금지** → `Composition API` + `<script setup>` 필수
- 인라인 스타일(`style=""`) 사용 **금지** → `<style scoped>` 사용
- 모든 px 값은 **rem으로 변환** (기준: 16px = 1rem)
---
## Props / Emits 타입 정의
```typescript
// Props
interface Props {
title: string
count?: number
items: string[]
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
})
// Emits
interface Emits {
(e: 'update', value: string): void
(e: 'close'): void
}
const emit = defineEmits<Emits>()
```
---
## 상태 관리 (Pinia)
```typescript
// stores/user.store.ts
export const useUserStore = defineStore('user', () => {
// 상태
const user = ref<User | null>(null)
const isLoggedIn = computed(() => !!user.value)
// 액션
async function fetchUser(id: number) {
// API 호출
}
return { user, isLoggedIn, fetchUser }
})
```
- 도메인별 Store 파일 분리 (`user.store.ts`, `auth.store.ts` 등)
- Store 내 API 호출은 허용, 단 에러 처리는 인터셉터에 위임
---
## API 호출 (Composables + Axios)
```typescript
// composables/useApi.ts — 중앙 Axios 인스턴스 (구조 요약)
const api = axios.create({
// 비어 있으면 같은 도메인의 /api — 운영은 NPM Custom location, 개발은 vite 프록시가 백엔드로 넘긴다
baseURL: resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL),
withCredentials: true, // HttpOnly 쿠키 전송
})
// 응답 인터셉터 — 표준 응답 언래핑 + 에러 코드 전역 처리
// 실패 시 ApiError { code, message, traceId, status } 로 정규화해 reject 한다.
// 문구는 생성된 사전에서만 오고, 같은 코드의 알림은 중복 억제된다.
```
에러 처리 규칙:
- **개별 `try/catch` 에서 메시지를 만들지 않는다.** 알림은 인터셉터가 이미 처리했다.
- 호출부에서 분기가 필요하면 `message` 가 아니라 **`code` 로 분기**한다.
```typescript
try {
await api.post('/users', payload)
} catch (e) {
const error = e as ApiError
if (error.code === 'USER_001') focusEmailField()
}
```
- Toast 라이브러리를 도입하면 `useApi.ts` 의 `showErrorUI()` **한 함수만** 교체한다.
```typescript
// composables/useUser.ts — 도메인별 API Composable
export function useUser() {
const loading = ref(false)
// 실패 시 **에러 코드만** 보관한다 — 화면은 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, errorCode, getUser }
}
```
- API 호출 로직은 반드시 `composables/` 폴더에 분리
- 컴포넌트 내 `axios.get()` 직접 호출 **금지**
- `ErrorCodeLexicon` 은 세 플랫폼 공유 에러 맵의 일부 — [[error-codes]] 참조.
---
## 라우터 (Vue Router)
```typescript
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import
const routes = [
{
path: '/dashboard',
component: () => import('@/pages/DashboardPage.vue'),
meta: { requiresAuth: true },
},
]
// 네비게이션 가드 — 인증 처리
router.beforeEach((to) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return '/login'
}
})
```
---
## 성능 & 최신 트렌드 추가 규칙
- **컴포넌트 분리**: 200줄 이상 컴포넌트는 분리 고려
- **`defineAsyncComponent`**: 무거운 컴포넌트는 비동기 로딩 적용
- **`v-memo`**: 반복 렌더링 최적화 필요 시 활용
- **`<Suspense>`**: 비동기 컴포넌트 로딩 상태 처리에 활용
- **환경변수**: 모든 설정값은 `import.meta.env.VITE_*` 형태로 관리 ([[env-structure]] 참조)
- **절대경로**: `@/` 경로 alias 사용 (상대경로 `../../` 지양)
- **접근성(a11y)**: `aria-label`, `role`, `tabindex` 등 기본 접근성 속성 포함
---
## 작업 점검 체크리스트
(전체 확장 지점·점검 목록은 [[checklist]])
- [ ] **API 호출**: 컴포넌트에서 `axios` 직접 호출 금지 → `composables/` 경유.
- [ ] **에러 처리**: 개별 try/catch 로 메시지를 만들지 말고 `useApi` 인터셉터 + [[error-codes]] 에 위임. 분기는 `error.code` 로.
- [ ] **에러 사전**: `error-codes.generated.ts` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`.
- [ ] **목록 화면**: 응답은 `{items, pageInfo}` — `shared/types/pagination.ts` 타입 사용.
- [ ] **컴포넌트**: `<script setup lang="ts">` + `<style scoped>` + rem 단위. 인라인 스타일·Options API 금지.
- [ ] **타입**: Props/Emits 는 인터페이스로 정의.
- [ ] **라우트**: 페이지는 lazy import, 보호 라우트는 `meta.requiresAuth`. `/api` 로 시작하는 경로는 쓰지 않는다 (백엔드로 간다 — [[api-design]]).
- [ ] **auth 가드**: `router/index.ts` 가드는 `auth.store.ts` 의 `isLoggedIn` 과 이미 연결되어 있다. 로그인 페이지 추가 시 리다이렉트 대상을 `{ name: 'login' }` 으로 바꾼다.
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run type-check` + `npm run test:unit` 통과. 자동 수정은 `npm run lint:fix`.
- [ ] **린트로 강제되는 항목**: Options API·인라인 스타일·`console`·`any` 는 빌드가 실패한다.
- [ ] **env**: `VITE_API_BASE_URL` 은 빌드 시 루트 `BACKEND_API_URL`(기본 `/api` — 같은 도메인)에서 주입된다 — `frontend/.env` 에 별도 작성 불필요.
절대 URL 로 바꾸면 다른 오리진 호출이 되므로 백엔드 `CORS_ORIGIN` 과 `nginx.conf` 의 CSP `connect-src` 도 함께 바꿔야 한다.
- [ ] **dev 서버**: 프론트엔드는 호스트에서 `npm run dev` 로 띄운다 (컨테이너는 `--profile frontend` 선택, [[docker]]).
- [ ] **포트**: `vite.config.ts` 에 포트를 새로 하드코딩하지 않는다 — 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 가 SSOT ([[env-structure]]).
- [ ] **dev 에서 API 가 안 될 때**: 응답이 JSON 이 아니라 HTML 이면 vite 프록시(`server.proxy`) 문제다.
`baseURL` 이 `/api` 상대경로라 프록시가 없으면 index.html 이 200 으로 돌아온다.
백엔드 컨테이너가 떠 있는지(`docker compose ... ps`), 프록시 대상 포트가 맞는지 확인한다.
+22
View File
@@ -0,0 +1,22 @@
# 에디터 공통 포맷 — 3스택·여러 에디터에서 들여쓰기/인코딩 통일
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
# Dart (dart format 기본 2칸)
[*.dart]
indent_size = 2
# Markdown — 줄 끝 공백이 의미를 가질 수 있어 트림 제외
[*.md]
trim_trailing_whitespace = false
# Makefile 은 탭 필수
[Makefile]
indent_style = tab
+73 -7
View File
@@ -9,31 +9,97 @@ BACKEND_DIR=backend
# ==========================================
# 포트 설정
#
# 컨테이너 포트 매핑뿐 아니라 **호스트에서 띄우는 vite dev 서버**도 이 값을 따른다.
# (frontend/vite.config.ts 가 이 파일을 직접 읽는다 — FRONTEND_PORT 로 열고
# /api 프록시는 BACKEND_PORT 로 보낸다)
# ==========================================
FRONTEND_PORT=5173
BACKEND_PORT=3000
WEBSOCKET_PORT=3001
DB_PORT=5432
REDIS_PORT=6379
# ==========================================
# vite dev 서버 접근 허용 호스트 (호스트에서 띄우는 프론트엔드 전용)
#
# 기본(비어 있음)은 localhost·IP 접근만 허용한다 — vite 의 DNS rebinding 보호가 켜진 상태다.
# ngrok 같은 터널로 외부에서 dev 서버를 열 때만 도메인을 콤마로 적는다.
# 점으로 시작하면 하위 도메인 전체를 허용한다. 예) DEV_ALLOWED_HOSTS=.ngrok-free.app
# ==========================================
DEV_ALLOWED_HOSTS=
# ==========================================
# Database 설정
# ==========================================
DB_IMAGE=postgres:15-alpine
DB_USER=dev_ttipo
DB_PASSWORD=dev_960426
DB_USER=app_user
DB_PASSWORD=change-me-in-local-env
DB_NAME=project_db
DB_DATA_DIR=./data/postgres
# 앱 인스턴스 하나의 커넥션 풀 크기 (PostgreSQL 기본 max_connections 100 을 넘지 않게 — 인스턴스 수 × 이 값 + 여유)
DB_POOL_MAX=10
# ==========================================
# Redis 설정
# ==========================================
REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=dev_960426
REDIS_PASSWORD=change-me-in-local-env
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# API 경로 · CORS — 프론트와 API 는 같은 도메인을 쓴다
#
# 브라우저는 /api 상대경로로만 호출하고, 개발에서는 vite dev 프록시가
# 운영에서는 Nginx Proxy Manager 가 백엔드로 넘긴다 (운영과 같은 구조).
#
# BACKEND_API_URL : 프론트 번들에 박히는 API 경로 (compose 로 프론트 컨테이너를 빌드할 때 쓰인다)
# CORS_ORIGIN : 다른 오리진에서 API 를 호출할 때만 작성 (scheme://host[:port], 콤마 구분)
# 같은 출처 요청에는 CORS 가 적용되지 않으므로 기본은 비워 둔다.
# ==========================================
BACKEND_API_URL=http://localhost:3000/api
CORS_ORIGIN=http://localhost:5173
BACKEND_API_URL=/api
CORS_ORIGIN=
# ==========================================
# Swagger API 문서 (/api-docs)
#
# 비어 있으면 운영에서만 끈다 (개발에서는 켜진다). true/false 로 강제할 수 있다.
# ==========================================
SWAGGER_ENABLED=
# ==========================================
# Rate Limiting (API 남용 방지)
# 미설정 시 백엔드 기본값(60초 / 100회) 사용
# ==========================================
THROTTLE_TTL_MS=60000
THROTTLE_LIMIT=100
# ==========================================
# DB 스키마 동기화 (개발 전용, 기본 false)
# true 로 켜면 엔티티 변경이 DB 에 자동 반영되어 빠르게 실험할 수 있지만,
# 그 상태에서는 migration:generate 가 "변경 없음"만 내놓는다.
# 운영으로 갈 코드라면 꺼둔 채 마이그레이션으로 관리할 것.
# (운영 환경에서는 이 값과 무관하게 항상 비활성화된다)
# ==========================================
DB_SYNCHRONIZE=false
# ==========================================
# 선택적 서비스 (Compose profiles)
#
# 기본값(비어 있음) = redis 와 frontend 컨테이너를 기동하지 않는다.
#
# redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=redis
# frontend vite 를 컨테이너로 띄워야 할 때 → COMPOSE_PROFILES=frontend
# 둘 다 → COMPOSE_PROFILES=redis,frontend
#
# 일회성으로 켜려면: docker compose --profile redis ... up -d
#
# ⚠️ 개발 환경의 프론트엔드는 호스트에서 띄우는 것이 기본이다 (`cd frontend && npm run dev`).
# 컨테이너 vite 는 바인드 마운트 때문에 첫 서빙까지 수 분이 걸린다.
# ==========================================
COMPOSE_PROFILES=
# ==========================================
# 파일 업로드 (로컬 디스크)
# ==========================================
UPLOAD_DATA_DIR=./data/uploads
UPLOAD_MAX_SIZE_MB=5
+70 -7
View File
@@ -9,10 +9,15 @@ BACKEND_DIR=backend
# ==========================================
# 포트 설정
#
# 운영에서는 컨테이너 포트를 호스트에 열지 않는다.
# Nginx Proxy Manager 가 proxy 네트워크로 컨테이너에 직접 붙는다 (README "운영 배포"):
# frontend → ${PROJECT_NAME}-frontend:8080 (nginx-unprivileged 고정 포트)
# backend → ${PROJECT_NAME}-backend:${BACKEND_PORT}
# FRONTEND_PORT·DB_PORT·REDIS_PORT 는 개발 환경의 호스트 포트 매핑에만 쓰인다.
# ==========================================
FRONTEND_PORT=5173
BACKEND_PORT=3000
WEBSOCKET_PORT=3001
DB_PORT=5432
REDIS_PORT=6379
@@ -20,20 +25,78 @@ REDIS_PORT=6379
# Database 설정
# ==========================================
DB_IMAGE=postgres:15-alpine
DB_USER=prd_ttipo
DB_PASSWORD=prd_960426
DB_USER=app_user
DB_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
DB_NAME=project_db
DB_DATA_DIR=./data/postgres
# 앱 인스턴스 하나의 커넥션 풀 크기 (PostgreSQL 기본 max_connections 100 을 넘지 않게 — 인스턴스 수 × 이 값 + 여유)
DB_POOL_MAX=10
# ==========================================
# Redis 설정
# ==========================================
REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=prd_960426
REDIS_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# API 경로 · CORS — 프론트와 API 는 **같은 도메인**을 쓴다 (기본 구성)
#
# https://<도메인>/ → frontend 컨테이너 (NPM Proxy Host 의 기본 대상)
# https://<도메인>/api/... → backend 컨테이너 (같은 Proxy Host 의 Custom location /api)
#
# BACKEND_API_URL : 브라우저가 호출할 API 경로 (프론트엔드 번들에 그대로 박힌다)
# /api 그대로 두면 도메인이 바뀌어도 다시 빌드할 필요가 없다.
# CORS_ORIGIN : 다른 오리진에서 API 를 호출할 때만 작성 (scheme://host[:port], 콤마 구분)
# 같은 출처 요청에는 CORS 가 적용되지 않으므로 기본은 비워 둔다.
#
# API 를 별도 도메인(api.<도메인>)으로 분리할 때만:
# BACKEND_API_URL=https://api.<도메인>/api · CORS_ORIGIN=https://<도메인> 로 바꾸고
# frontend/nginx.conf 의 CSP connect-src 에 API 오리진을 추가한다.
# 이때 localhost 나 example.com 이 남아 있으면 **백엔드가 기동에 실패한다**
# (backend/src/shared/config/env.validation.ts — fail-fast).
# 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만
# ALLOW_LOCALHOST_URL=true 로 이 검사를 건너뛴다. 실제 배포에는 설정하지 않는다.
# ==========================================
BACKEND_API_URL=http://localhost:3000/api
CORS_ORIGIN=http://localhost:5173
BACKEND_API_URL=/api
CORS_ORIGIN=
# ==========================================
# Swagger API 문서 (/api-docs)
#
# 비어 있으면 운영에서는 꺼진다 — 문서가 API 구조 전체(경로·파라미터·스키마)를 공개하기 때문이다.
# 운영에서도 필요하면 true 로 켜고 NPM 의 Access List 등으로 접근을 제한한다.
# ==========================================
SWAGGER_ENABLED=
# ==========================================
# Rate Limiting (API 남용 방지)
# 미설정 시 백엔드 기본값(60초 / 100회) 사용
# ==========================================
THROTTLE_TTL_MS=60000
THROTTLE_LIMIT=100
# ==========================================
# DB 스키마 동기화 (개발 전용, 기본 false)
# true 로 켜면 엔티티 변경이 DB 에 자동 반영되어 빠르게 실험할 수 있지만,
# 그 상태에서는 migration:generate 가 "변경 없음"만 내놓는다.
# 운영으로 갈 코드라면 꺼둔 채 마이그레이션으로 관리할 것.
# (운영 환경에서는 이 값과 무관하게 항상 비활성화된다)
# ==========================================
DB_SYNCHRONIZE=false
# ==========================================
# 선택적 서비스 (Compose profiles)
#
# 기본값(비어 있음) = redis 를 기동하지 않는다.
# 캐시·세션 공유가 필요해지면 아래를 redis 로 바꾸면 컨테이너가 함께 뜬다.
# COMPOSE_PROFILES=redis
# 일회성으로 켜려면: docker compose --profile redis ... up -d
# ==========================================
COMPOSE_PROFILES=
# ==========================================
# 파일 업로드 (로컬 디스크)
# ==========================================
UPLOAD_DATA_DIR=./data/uploads
UPLOAD_MAX_SIZE_MB=5
+24
View File
@@ -0,0 +1,24 @@
# 줄바꿈 정규화 — 어떤 OS에서 클론해도 저장소 내부는 LF로 통일 (Windows 작업 시 CRLF 변환 churn 방지)
* text=auto eol=lf
# Windows 전용 스크립트는 CRLF 유지
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# 셸 스크립트는 LF 강제
*.sh text eol=lf
# 바이너리 — 변환/diff 제외
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.jar binary
*.keystore binary
+29
View File
@@ -0,0 +1,29 @@
## 무엇을 · 왜
<!-- 무엇을 바꿨고 왜 필요한지 적습니다. "무엇을"은 diff 가 말해주므로 "왜"에 비중을 둡니다. -->
## 확인 방법
<!-- 리뷰어가 동작을 확인할 수 있는 절차나 명령을 적습니다. -->
## 영향 범위
- [ ] backend
- [ ] frontend
- [ ] flutter
- [ ] docker / 인프라
- [ ] 규칙 문서(.claude/)
> **공유 계약(에러 코드 · 응답 포맷 · 페이지네이션 · API 규약)을 바꿨다면**
> 세 스택에 미치는 영향을 아래에 구체적으로 적어 주세요.
## 체크리스트
<!-- 자세한 항목은 .claude/rules/checklist.md 참조 -->
- [ ] 린트/분석·타입 검사 통과 (Warning 0) · 변경 영역 테스트 통과
- [ ] 주석 한국어, 매직 넘버·하드코딩 문자열 없음
- [ ] 사용자 노출 실패는 모두 에러 코드와 함께 처리 (`BusinessException` / `error.code` 분기)
- [ ] 에러 코드를 바꿨다면 `npm run sync:error-codes` 실행 후 생성 파일까지 커밋
- [ ] 민감정보 미커밋 (`*.example` 값은 placeholder)
- [ ] 로그에 비밀번호·토큰·요청 본문 전체가 남지 않음
+57
View File
@@ -0,0 +1,57 @@
name: CI (Flutter)
# Flutter 는 기본 CI 에서 분리되어 있다.
#
# 이유: SDK 다운로드로 매 실행마다 수 분이 걸리는데,
# backend/frontend 만 고친 커밋에서는 검사할 것이 없다.
#
# 언제 도는가:
# - flutter/ 아래 파일이 바뀐 푸시·PR (= 실제로 Flutter 를 쓸 때 자동으로)
# - Actions 탭의 "Run workflow" 버튼으로 수동 실행
#
# 모바일 앱을 아예 쓰지 않는 프로젝트라면 이 파일을 지우면 된다.
on:
push:
branches: [main]
paths:
- 'flutter/**'
- '.github/workflows/ci-flutter.yml'
pull_request:
paths:
- 'flutter/**'
- '.github/workflows/ci-flutter.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
flutter:
name: Flutter (analyze · test)
runs-on: ubuntu-latest
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
timeout-minutes: 25
defaults:
run:
working-directory: flutter
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
# .env 는 pubspec 의 asset 이라 없으면 analyze 가 경고를 낸다
- name: Prepare .env
run: cp .env.example .env
- run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Test
run: flutter test
+141
View File
@@ -0,0 +1,141 @@
name: CI
on:
push:
branches: [main]
pull_request:
# 같은 브랜치에서 새 푸시가 오면 이전 실행을 취소한다
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
name: Backend (lint · type-check · test · e2e · build)
runs-on: ubuntu-latest
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
timeout-minutes: 20
defaults:
run:
working-directory: backend
# e2e 는 응답 계약을 실제 HTTP 로 검증하므로 DB 가 필요하다.
#
# ⚠️ 호스트 포트 매핑(ports)을 두지 않는다.
# Gitea Actions 러너는 잡을 **컨테이너로** 실행하고 서비스와 같은 네트워크에 붙여준다.
# 따라서 서비스 이름(postgres)으로 바로 접근할 수 있고, 호스트 포트는 필요 없다.
# 오히려 개발 DB 가 이미 5432 를 쓰고 있으면 충돌해 잡 전체가 시작조차 못 한다.
# (GitHub Actions 는 잡이 VM 위에서 직접 돌아 localhost + ports 를 쓰는 점이 다르다)
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: ci_user
POSTGRES_PASSWORD: ci_password
POSTGRES_DB: ci_db
options: >-
--health-cmd "pg_isready -U ci_user -d ci_db"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: backend/package-lock.json
- run: npm ci
# Warning 0 (.claude/rules/checklist.md)
- name: Lint
run: npm run lint
# 테스트·e2e·스크립트까지 타입 검사한다.
# 빌드(tsconfig.build.json)는 spec 을 제외하고 ts-jest 는 타입을 보지 않아(isolatedModules)
# 이 단계가 없으면 테스트 파일의 타입 오류가 어디에서도 걸리지 않는다.
- name: Type check
run: npm run type-check
- name: Test
run: npm run test
# 표준 응답 래퍼·에러 코드·traceId 계약 검증 (.claude/rules/response-format.md)
- name: E2E test
run: npm run test:e2e
env:
NODE_ENV: test
# 서비스 컨테이너의 네트워크 별칭 (localhost 는 잡 컨테이너 자신을 가리킨다)
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ci_user
DB_PASSWORD: ci_password
DB_NAME: ci_db
- name: Build
run: npm run build
frontend:
name: Frontend (lint · type-check · test)
runs-on: ubuntu-latest
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
timeout-minutes: 15
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run type-check
- name: Test
run: npm run test:unit -- --run
error-codes-sync:
name: 에러 코드 SSOT 동기화 검사
runs-on: ubuntu-latest
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: backend/package-lock.json
- name: Install backend deps
working-directory: backend
run: npm ci
# 레지스트리만 고치고 sync 를 안 돌린 경우를 잡아낸다.
# 생성 파일이 최신이면 diff 가 비어 있어야 한다.
- name: Regenerate client error lexicons
working-directory: backend
run: npm run sync:error-codes
- name: Fail if generated files are stale
run: |
if ! git diff --exit-code -- \
frontend/src/shared/constants/error-codes.generated.ts \
flutter/lib/core/api/error_lexicon.g.dart; then
echo ""
echo "::error::에러 코드 생성 파일이 레지스트리와 어긋났습니다."
echo "backend 에서 'npm run sync:error-codes' 를 실행하고 생성 파일을 함께 커밋하세요."
exit 1
fi
+11 -4
View File
@@ -9,10 +9,17 @@
# ==========================================
# 도커 영속 데이터
# ==========================================
data/
!data/.gitkeep
db-data/
redis-data/
# ⚠️ 루트 기준으로 고정한다(앞의 /). 'data/' 로 두면 모든 depth 의 data 디렉터리가 무시되어
# flutter/lib/features/<도메인>/data/ 같은 소스 코드까지 커밋되지 않는다.
/data/
!/data/.gitkeep
/db-data/
/redis-data/
# CI 러너 등록 정보
ci/runner-data/
# 업로드 파일 (로컬 저장) — 코드가 아니다
/data/uploads/
backend/uploads/
# ==========================================
# 의존성/빌드 산출물
+53 -98
View File
@@ -2,114 +2,69 @@
이 저장소는 **풀스택 모노레포 기본 템플릿**이다. 누구나 클론하여 바로 개발을 시작할 수 있도록 공통 규칙과 언어별 규칙을 정의한다.
| 디렉터리 | 스택 | 역할 | 개발 규칙 (슬래시 명령) |
| ----------- | -------------- | ------------------------ | ---------------------------------------- |
| `backend/` | NestJS (TS) | REST API 서버 | `/nestjs` → @.claude/commands/nestjs.md |
| `frontend/` | Vue 3 (TS) | 웹 프론트엔드 | `/vue3` → @.claude/commands/vue3.md |
| `flutter/` | Flutter (Dart) | 모바일 앱 | `/flutter` → @.claude/commands/flutter.md |
| 루트 | Docker Compose | 로컬/운영 오케스트레이션 | @command.md |
| 디렉터리 | 스택 | 역할 | 개발 규칙 (스킬) |
| ----------- | -------------- | ------------------------ | ----------------------------------------- |
| `backend/` | NestJS (TS) | REST API 서버 | `/nestjs` → @.claude/skills/nestjs/SKILL.md |
| `frontend/` | Vue 3 (TS) | 웹 프론트엔드 | `/vue3` → @.claude/skills/vue3/SKILL.md |
| `flutter/` | Flutter (Dart) | 모바일 앱 | `/flutter` → @.claude/skills/flutter/SKILL.md |
| 루트 | Docker Compose | 로컬/운영 오케스트레이션 | `/docker` → @.claude/skills/docker/SKILL.md |
세 클라이언트는 동일한 **표준 응답 포맷**과 **에러 코드 맵**을 공유한다 (아래 참조).
세 클라이언트는 동일한 **표준 응답 포맷**과 **에러 코드 맵**을 공유한다 (아래 공통 규칙 참조).
> 규칙은 `.claude/` 아래로 모듈화되어 있다.
> - `.claude/rules/` — 세 플랫폼 공통 규칙 (코딩·응답 포맷·에러 코드·API 설계·로깅·커밋·보안·환경변수·테스트)
> 새 규칙 파일을 추가하면 **반드시 아래 `@` import 목록에도 등록**한다(등록하지 않으면 로드되지 않는다).
> - `.claude/skills/` — 도메인별 개발 규칙 (스킬). 슬래시 명령(`/nestjs` 등) 또는 해당 디렉터리 작업 시 자동 적용
> - `.claude/agents/` — 역할별 규칙 (에이전트 팀원). 역할을 지정해 호출할 때 적용
> **`@` import 하지 않는다** — 규칙이 아니라 호출 시점에 로드되는 역할 정의다 (아래 목록 참조)
> - `.claude/settings.json` — Claude Code 권한·환경 설정
---
## 코딩 규칙
## 공통 규칙 (모든 디렉터리 적용)
### 공통
- 모든 코드 주석은 **한국어**로 작성
- 웹(`frontend`)·서버(`backend`)는 100% TypeScript, 모바일(`flutter`)은 Dart Sound Null Safety 준수
- 컴파일러/린터 **Warning 방치 금지** — 커밋 전 각 프로젝트 `lint` / `analyze` 통과
- 유틸 함수는 순수 함수로 분리 — TS는 `shared/utils/`, Dart는 `core/utils/`
- 매직 넘버·하드코딩 문자열 지양 → 상수 또는 환경변수로 분리
- 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양
### 표준 응답 포맷 (Global Response Wrapper)
모든 API 응답은 아래 구조로 통일한다.
(SSOT: backend `TransformInterceptor` + `HttpExceptionFilter`)
```jsonc
// 성공
{ "success": true, "data": { /* ... */ } }
// 실패
{ "success": false, "error": { "code": "VAL_001", "message": "..." } }
```
- 프론트/플러터 인터셉터는 `success: true` 면 `data` 만 언래핑하여 반환한다.
- `success: false` 이거나 HTTP 4xx/5xx 면 `error.code` 로 메시지를 매핑한다.
### Git 커밋 컨벤션
`<type>: <제목>` 형식 — type: **feat / fix / refactor / docs / chore / perf / test / style**
- 예: `feat: 사용자 목록 페이지네이션 추가`
- 제목은 한국어, 명령형 현재 시제로 작성
- 한 커밋은 하나의 논리적 변경만 포함
### 에러 코드 맵핑
세 플랫폼이 공유하는 **단일 진실 공급원(SSOT)**. 메시지를 수정할 때는 아래 세 파일을 함께 갱신한다.
- backend: `src/common/filters/http-exception.filter.ts`
- frontend: `src/composables/useApi.ts` (`ErrorCodeLexicon`)
- flutter: `lib/core/api/error_lexicon.dart` (`ErrorLexicon`)
| 코드 | HTTP / 상황 | 메시지 |
| -------- | -------------------- | ------------------------------------------------------- |
| SYS_001 | 500 / 미정의 예외 | "일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." |
| AUTH_001 | 401 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." |
| AUTH_002 | 세션 만료 (클라이언트) | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
| AUTH_003 | 403 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
| VAL_001 | 400 / 422 (Validation) | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
| RES_001 | 404 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
| BIZ_001 | 200(비즈니스 실패) / 기타 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
> 비즈니스 예외는 `HttpException` 응답의 `message` 로 사용자 노출 문구를 재정의할 수 있다.
### 보안
- `.env*` 등 민감정보 Git 커밋 금지 (`.gitignore` 확인) — 예시는 `*.example`(예: `.env.development.example`, `.env.example`) 로만 공유
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용
- TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수)
- 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달
- 보안 헤더(`helmet`)·CORS·Rate Limiting(`@nestjs/throttler`) 적용
- Docker 컨테이너 내부 root 실행 금지 (비-root 사용자로 구동)
@.claude/rules/coding-common.md
@.claude/rules/response-format.md
@.claude/rules/error-codes.md
@.claude/rules/api-design.md
@.claude/rules/logging.md
@.claude/rules/git-convention.md
@.claude/rules/security.md
@.claude/rules/env-structure.md
@.claude/rules/testing.md
@.claude/rules/checklist.md
---
## 환경변수 구조 (단일 진실 공급원: 루트 `.env`)
## 도메인별 개발 규칙 (스킬)
이 템플릿은 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다.
값을 한 곳에서만 관리하기 위해, 서비스(backend/frontend)는 자체 env에 값을 중복 작성하지 않고 **루트 → docker-compose 가 주입한 값을 그대로 사용**한다.
```
루트 .env.{APP_ENV}
│ (docker compose --env-file 로 로드)
├─▶ backend : env_file + environment 로 DB/Redis/CORS/포트 주입
│ (backend/.env.{APP_ENV} 는 "존재"만 하면 됨, 내용 최소)
├─▶ frontend : BACKEND_API_URL → build arg(VITE_API_BASE_URL) 로 주입
├─▶ db : DB_USER/DB_PASSWORD/DB_NAME, DB_DATA_DIR 볼륨
└─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
```
규칙:
- **새 설정값은 루트 `.env.{APP_ENV}` 에 먼저 추가**하고, 필요한 서비스에 `docker-compose.yml` 의 `environment` / `args` 로 전달한다.
- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`)
- `docker-compose.yml` 의 `env_file` 지시자 때문에 `backend/.env.{APP_ENV}` 파일은 비어 있어도 **존재해야 한다.**
- **단독 실행(non-Docker)** 시에는 주입이 없으므로, 각 서비스 `.env.{APP_ENV}` 의 주석 처리된 예시 값을 활성화해 사용한다.
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `BACKEND_API_URL`, `CORS_ORIGIN`, `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`. 실제 목록은 `.env.development.example` 참조.
- 백엔드(NestJS): @.claude/skills/nestjs/SKILL.md
- 프론트엔드(Vue 3): @.claude/skills/vue3/SKILL.md
- 모바일(Flutter): @.claude/skills/flutter/SKILL.md
- 실행/배포(Docker): @.claude/skills/docker/SKILL.md
---
## 개발 규칙 참조
## 역할별 규칙 (에이전트 팀원)
- 백엔드(NestJS): @.claude/commands/nestjs.md
- 프론트엔드(Vue 3): @.claude/commands/vue3.md
- 모바일(Flutter): @.claude/commands/flutter.md
- 실행/배포 명령어: @command.md
세 층의 역할 분담은 다음과 같다. **규칙은 한 곳에만 적는다** — 역할 파일은 규칙을 참조만 한다.
```
.claude/rules/ 무엇을 지키는가 — 전원 공통 (위 @import 목록)
.claude/skills/ 어떻게 만드는가 — 스택별
.claude/agents/ 누가 맡는가 — 역할별
```
| 역할 | 파일 | 코드 수정 |
| --- | --- | --- |
| 백엔드 개발 | `.claude/agents/backend-dev.md` | O |
| 프론트엔드 개발 | `.claude/agents/frontend-dev.md` | O |
| 모바일 개발 | `.claude/agents/mobile-dev.md` | O |
| 테스터 | `.claude/agents/tester.md` | O (테스트 한정) |
| 리뷰어 | `.claude/agents/reviewer.md` | X |
| 인프라/릴리스 | `.claude/agents/release-ops.md` | O (인프라 한정) |
| 사용자 관점 | `.claude/agents/persona-user.md` | X |
| 관리자 관점 | `.claude/agents/persona-admin.md` | X |
공통 계약(경계·인계 형식·완료 기준)은 [`.claude/agents/README.md`](.claude/agents/README.md) 참조.
역할을 지정하지 않으면 평소대로 동작한다 — 모든 작업을 역할로 나눌 필요는 없다.
+196 -29
View File
@@ -11,12 +11,13 @@ NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에
| `frontend/` | Vue 3 + Vite | 웹 프론트엔드 | 5173 |
| `flutter/` | Flutter + Riverpod | 모바일 앱 | - |
| `db` | PostgreSQL | 데이터베이스 | 5432 |
| `redis` | Redis | 캐시 / 세션 | 6379 |
| `redis` | Redis | 캐시 / 세션 (**기본 비활성**) | 6379 |
## 사전 요구사항
- Docker / Docker Compose (권장 구동 방식)
- 단독 실행 시: Node.js `^20.19 || >=22.12`, Flutter SDK `^3.11`
- Docker / Docker Compose — backend·frontend·db·redis 구동 (컨테이너는 Node 24 LTS 이미지를 쓴다)
- Node.js 24 LTS 권장 (최소 22.12) — 호스트에서 띄우는 vite dev 서버와 lint·test 용
- Flutter SDK `^3.11` — 모바일 앱 실행용 (compose 대상 아님)
## 빠른 시작 (Docker)
@@ -25,39 +26,134 @@ NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에
# 실제 설정값은 루트 .env.development 한 곳에서 관리한다 (SSOT).
# backend/frontend 는 docker-compose 가 값을 주입하므로 예제만 복사하면 된다.
cp .env.development.example .env.development # ← 여기에만 값 입력
cp backend/.env.development.example backend/.env.development # (존재만 필요, 내용 최소)
cp frontend/.env.development.example frontend/.env.development # (단독 실행 시에만 사용)
cp backend/.env.development.example backend/.env.development # (존재 필요: JWT 등 고유 시크릿)
# frontend 는 compose 가 빌드 시 값을 주입하므로 자체 env 복사 불필요
# 2) 개발 환경 기동 (코드만 수정 시)
# 2) 백엔드 스택 기동 (backend + db)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 3) 의존성 변경 시에만 --build
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build
# 4) 프론트엔드는 호스트에서 직접 띄운다 (컨테이너보다 수십 배 빠르다)
cd frontend && npm install && npm run dev
```
기동 후:
- 웹: <http://localhost:5173>
- API: <http://localhost:3000/api>
- Swagger 문서: <http://localhost:3000/api-docs>
- API: <http://localhost:5173/api> — 브라우저가 쓰는 경로 (vite 프록시가 백엔드로 넘긴다, 운영과 같은 구조)
· 백엔드 직접: <http://localhost:3000/api>
- Swagger 문서: <http://localhost:3000/api-docs> — 운영에서는 기본으로 꺼진다 (`SWAGGER_ENABLED`)
전체 실행/배포 명령어는 [`command.md`](./command.md) 참조.
> **개발 환경에서 프론트엔드는 컨테이너로 뜨지 않습니다.**
> vite 를 컨테이너에서 돌리면 소스가 Windows 바인드 마운트 너머에 있어 첫 서빙까지 수 분이 걸립니다.
> 호스트에서 `npm run dev` 로 띄우면 1~2초이고, `/api` 요청은 vite 프록시가 백엔드 컨테이너로 넘깁니다.
> 컨테이너로 띄워야 한다면 `--profile frontend` 를 붙이세요 (아래 "프론트엔드 실행 방식" 참조).
> 운영(`docker-compose.yml` 단독)은 그대로 nginx 정적 호스팅으로 동작합니다.
## 단독 실행 (Docker 미사용)
> Redis 는 기본적으로 뜨지 않습니다. 필요해지면 루트 `.env.{env}` 에 `COMPOSE_PROFILES=redis` 를 설정하세요.
### 프론트엔드 실행 방식
| 방식 | 명령 | 첫 서빙 | 용도 |
| --- | --- | --- | --- |
| **호스트 vite (개발 기본)** | `cd frontend && npm run dev` | 1~2초 (최초 의존성 사전번들 10초 내외) | 일상 개발 |
| 컨테이너 vite (선택) | `docker compose ... --profile frontend up -d` | 수 분 (Windows 바인드 마운트) | 호스트에 Node 를 둘 수 없을 때 |
| nginx 정적 (운영) | `docker compose --env-file .env.production up -d --build` | - | 배포 |
> 컨테이너 방식을 상시 사용하려면 루트 `.env.development` 에 `COMPOSE_PROFILES=frontend` 를 넣습니다.
> (redis 와 함께 켤 때는 `COMPOSE_PROFILES=frontend,redis`)
> 호스트 vite 와 컨테이너 vite 는 같은 호스트 포트를 쓰므로 **동시에 띄우지 마세요.**
> **포트는 루트 `.env.{env}` 를 따릅니다.** 호스트에서 띄우는 vite 도 `FRONTEND_PORT` 로 열리고,
> `/api` 프록시 대상은 `BACKEND_PORT` 에서 나옵니다 — `vite.config.ts` 가 루트 env 를 직접 읽습니다.
> 포트가 이미 사용 중이면 조용히 다른 포트로 옮기지 않고 **실패**합니다
> (대개 다른 vite 가 이미 그 포트에 떠 있다는 뜻이라, 말없이 옮겨가면 브라우저가 엉뚱한 서버를 보게 됩니다).
전체 실행/배포 명령어는 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) 참조.
## 운영 배포 — 프론트·API 를 같은 도메인으로 (Nginx Proxy Manager)
브라우저는 **도메인 하나만** 봅니다. 경로로 프론트와 백엔드를 나눕니다.
```
https://app.your-domain.com/ → <PROJECT_NAME>-frontend:8080 nginx 정적 호스팅 (Proxy Host 기본 대상)
https://app.your-domain.com/api/... → <PROJECT_NAME>-backend:3000 NestJS (Custom location /api)
```
- 프론트 번들은 API 를 `/api` **상대경로**로 호출합니다 — 도메인이 바뀌어도 다시 빌드할 필요가 없습니다.
- 같은 출처 요청이라 **CORS 설정이 필요 없고**, CSP `connect-src 'self'` 가 그대로 동작하며, 인증 쿠키도 같은 사이트로 다룹니다.
- 개발 환경도 같은 구조입니다 — vite dev 서버가 `/api` 를 백엔드로 프록시합니다.
### 1) 공유 네트워크 (서버당 1회)
NPM 과 앱 컨테이너가 외부 네트워크 `proxy` 를 공유해야 NPM 이 컨테이너 이름으로 찾아갈 수 있습니다.
이 네트워크가 없으면 compose 가 기동하지 않습니다.
```bash
# 백엔드
cd backend && npm install && npm run start:dev
docker network create proxy # 이미 있으면 생략
docker network connect proxy <NPM 컨테이너 이름> # NPM 이 아직 proxy 네트워크에 없다면
```
# 프론트엔드
cd frontend && npm install && npm run dev
> NPM 은 `<PROJECT_NAME>-frontend` 같은 **컨테이너 이름**으로 접근하므로, 같은 서버에 여러 프로젝트를 올린다면 `PROJECT_NAME` 이 겹치지 않게 합니다.
# 모바일
### 2) 앱 기동
```bash
cp .env.production.example .env.production # placeholder 교체 — BACKEND_API_URL=/api 는 그대로 둔다
cp backend/.env.production.example backend/.env.production
# 업로드 디렉터리 소유자를 앱 사용자(node, uid 1000)로 — Docker 가 root 소유로 만들면 백엔드가 기동에 실패한다
mkdir -p data/uploads && sudo chown 1000:1000 data/uploads
sudo docker compose --env-file .env.production up -d --build
```
### 3) NPM 설정 — Proxy Host 하나 + Custom location 하나
| 탭 | 항목 | 값 |
| --- | --- | --- |
| Details | Domain Names | `app.your-domain.com` |
| | Scheme · Forward Hostname · Port | `http` · `<PROJECT_NAME>-frontend` · `8080` |
| | Cache Assets | **끔** (아래 주의 참조) |
| | Block Common Exploits | 켬 (선택) |
| Custom locations | location | `/api` |
| | Scheme · Forward Hostname · Port | `http` · `<PROJECT_NAME>-backend` · `3000` (`BACKEND_PORT`) |
| SSL | | 인증서 발급 + Force SSL |
> ⚠️ **Cache Assets 는 끕니다.** 켜면 NPM 이 `.png`·`.js`·`.css` 등으로 끝나는 요청을 custom location 안에서도 가로채
> **Proxy Host 의 기본 대상(frontend)** 으로 보냅니다. `/api/files/abc.png` 같은 API 가 프론트로 가 버립니다.
> 프론트 정적 자원의 캐시 헤더는 frontend nginx 가 이미 붙입니다.
> - location 은 `/api` (끝 슬래시 없음) — 접두어 매칭이라 Swagger(`/api-docs`)까지 백엔드로 갑니다. 개발의 vite 프록시와 같은 규칙입니다.
> (Swagger 는 운영에서 기본으로 꺼져 있습니다. 켜려면 `SWAGGER_ENABLED=true` + NPM Access List 로 접근을 제한하세요)
> - Forward Hostname 에 경로를 붙이지 않습니다. 요청 경로가 그대로 넘어가야 백엔드의 `/api` prefix 와 맞습니다.
> - **`/api` 로 시작하는 경로는 프론트 라우트로 쓰지 않습니다** (전부 백엔드로 갑니다).
### 4) 확인
```bash
curl -i https://app.your-domain.com/api/health # 200 {"status":"ok",...} ← 백엔드가 응답
curl -I https://app.your-domain.com/ # 200 text/html ← 프론트가 응답
```
`/api/health` 가 **404** 면 custom location 이 빠진 것입니다 —
frontend nginx 는 자기에게 떨어진 `/api` 요청을 404 로 막아 둡니다 (index.html 로 폴백해 조용히 실패하지 않도록).
> API 를 별도 도메인(`api.your-domain.com`)에 두는 구성이 필요하다면 `.env.production.example` 의 안내대로
> `BACKEND_API_URL`·`CORS_ORIGIN` 과 `frontend/nginx.conf` 의 CSP `connect-src` 를 함께 바꿉니다.
## 모바일 앱 실행 (Flutter)
Flutter 는 Docker Compose 대상이 아니므로 로컬에서 직접 실행합니다.
```bash
cd flutter && cp .env.example .env && flutter pub get && flutter run
```
> 단독 실행 시 백엔드 `.env.development` 의 `DB_HOST`/`REDIS_HOST` 를 `localhost` 로 두고,
> PostgreSQL·Redis 를 별도로 띄워야 합니다.
> `flutter/.env` 의 `API_URL` 을 백엔드 주소로 설정합니다. (Android 에뮬레이터는 `http://10.0.2.2:3000/api`,
> 운영은 웹과 같은 도메인의 `https://app.your-domain.com/api`)
## 환경변수
@@ -70,7 +166,7 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
| ----------------------------- | ---------------------------------------- | --------------------- |
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 |
| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) |
| `frontend/.env.{env}.example` | 프론트 — 단독 실행용 `VITE_API_BASE_URL` | 단독 실행 시에만 |
| `frontend/.env.{env}.example` | 프론트 — 빌드 시 루트 `BACKEND_API_URL`(기본 `/api`) 주입 | 작성 불필요 |
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
`{env}` = `development` | `production`
@@ -80,30 +176,101 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
```jsonc
// 성공
{ "success": true, "data": { /* ... */ } }
// 실패
{ "success": false, "error": { "code": "VAL_001", "message": "..." } }
// 실패 — 사용자에게 보이는 모든 실패는 에러 코드를 가진다
{ "success": false, "error": { "code": "VAL_001", "message": "...", "traceId": "3f1c0a2e-..." } }
// 목록 — data 안의 형태가 고정되어 있다
{ "success": true, "data": { "items": [], "pageInfo": { "hasNext": false, "nextCursor": null, "total": 0 } } }
```
에러 코드 맵과 코딩 규칙 전문은 [`CLAUDE.md`](./CLAUDE.md) 및 `.claude/commands/` 의 언어별 규칙 문서를 참고하세요.
`traceId` 는 요청마다 발급되는 추적 ID(`x-request-id` 헤더와 동일)로, 서버 로그에 같은 값이 남습니다.
사용자 문의가 들어오면 이 값으로 로그를 검색하면 됩니다.
> `/api/health` 는 예외입니다. 모니터링 도구가 읽는 엔드포인트라 래퍼 없이 원본 형식을 반환합니다.
### 에러 코드는 한 곳에서 관리됩니다
```
backend/src/common/errors/error-codes.ts ← 여기만 수정
│ cd backend && npm run sync:error-codes
├─▶ frontend/src/shared/constants/error-codes.generated.ts (자동 생성)
└─▶ flutter/lib/core/api/error_lexicon.g.dart (자동 생성)
```
생성 파일은 직접 수정하지 마세요. CI 가 동기화 여부를 검사합니다.
자세한 규칙은 [`.claude/rules/error-codes.md`](./.claude/rules/error-codes.md) 참조.
## 로컬 개발 (Docker 없이)
DB 는 컨테이너로 띄우고 앱만 호스트에서 실행하는 방식이 가장 간단합니다.
```bash
# 1) DB 만 컨테이너로 기동 (127.0.0.1:5432 로 노출된다)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d db
# 2) backend — DB_HOST 기본값이 localhost 라 그대로 붙는다
cd backend && npm ci && npm run start:dev
# 3) frontend — vite dev 서버가 /api 를 localhost:3000 으로 프록시한다 (개발 기본 방식)
cd frontend && npm ci && npm run dev
# 4) flutter
cd flutter && flutter pub get && flutter run
```
> 프론트엔드의 API 요청은 `/api` 상대경로로 나가고 vite dev 프록시가 백엔드로 넘깁니다.
> 백엔드를 다른 주소에서 띄웠다면 `VITE_DEV_API_PROXY` 로 대상을 바꾸세요.
### 커밋 전 검사
```bash
cd backend && npm run lint && npm run type-check && npm run test && npm run build
cd frontend && npm run lint && npm run type-check && npm run test:unit
cd flutter && flutter analyze && flutter test
```
lint 는 `--max-warnings 0` 이라 경고 하나만 있어도 실패합니다. 자동 수정은 `npm run lint:fix`.
같은 검사를 CI([`.github/workflows/ci.yml`](./.github/workflows/ci.yml))가 PR 마다 수행합니다.
> ⚠️ **의존성을 추가·제거할 때는 컨테이너 안에서** 실행하세요.
> Windows 에서 `npm install` 하면 Linux 전용 패키지가 lock 에서 잘려 Docker 빌드가 깨집니다.
> ```bash
> docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development exec backend npm install <패키지>
> ```
## 개발 규칙
| 영역 | 문서 |
| --------- | ------------------------------------------------- |
| 공통/보안 | [`CLAUDE.md`](./CLAUDE.md) |
| NestJS | [`.claude/commands/nestjs.md`](./.claude/commands/nestjs.md) |
| Vue 3 | [`.claude/commands/vue3.md`](./.claude/commands/vue3.md) |
| Flutter | [`.claude/commands/flutter.md`](./.claude/commands/flutter.md) |
| 공통/보안 | [`CLAUDE.md`](./CLAUDE.md) · [`.claude/rules/`](./.claude/rules/) |
| 응답·에러 | [`response-format.md`](./.claude/rules/response-format.md) · [`error-codes.md`](./.claude/rules/error-codes.md) |
| API 설계 | [`api-design.md`](./.claude/rules/api-design.md) |
| 로깅 | [`logging.md`](./.claude/rules/logging.md) |
| 체크리스트 | [`checklist.md`](./.claude/rules/checklist.md) |
| NestJS | [`.claude/skills/nestjs/SKILL.md`](./.claude/skills/nestjs/SKILL.md) |
| Vue 3 | [`.claude/skills/vue3/SKILL.md`](./.claude/skills/vue3/SKILL.md) |
| Flutter | [`.claude/skills/flutter/SKILL.md`](./.claude/skills/flutter/SKILL.md) |
| Docker | [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) |
| 역할 분담 | [`.claude/agents/README.md`](./.claude/agents/README.md) |
## 디렉터리 구조
```
.
├── backend/ # NestJS API (Controller / Service / DTO / 공통 필터·인터셉터)
├── frontend/ # Vue 3 (pages / composables / stores / router)
├── flutter/ # Flutter (core / features / widgets)
├── frontend/ # Vue 3 (pages / components / composables / stores / router)
├── flutter/ # Flutter (core / features / shared)
├── docker-compose.yml # 베이스 정의
├── docker-compose.dev.yml # 개발 오버라이드 (핫리로드, 포트 노출)
├── CLAUDE.md # 공통 코딩 규칙 + 에러 코드 맵 (SSOT)
└── command.md # 실행/배포 명령어
├── CLAUDE.md # 프로젝트 개요 + 공통 규칙 @import
├── .github/
│ ├── workflows/ci.yml # lint·type-check·test·build + 에러 코드 동기화 검사
│ └── pull_request_template.md
├── ci/ # CI 러너 (Gitea Actions) — 앱 스택과 분리
└── .claude/
├── settings.json # Claude Code 권한·환경 설정
├── rules/ # 공통 규칙 (코딩·응답 포맷·에러 코드·API 설계·로깅·보안·환경변수·테스트)
├── skills/ # 도메인별 개발 규칙 (nestjs·vue3·flutter·docker)
└── agents/ # 역할별 규칙 (개발 3종·테스터·리뷰어·인프라·사용자/관리자 관점)
```
+25
View File
@@ -0,0 +1,25 @@
# ==========================================
# 빌드 컨텍스트에서 제외할 파일
#
# Dockerfile 의 `COPY . .` 가 아래 항목까지 이미지에 넣지 않도록 막는다.
# ==========================================
# 의존성·빌드 산출물 — 컨테이너 안에서 npm ci·빌드를 다시 한다.
# 호스트(Windows) node_modules 가 섞이면 네이티브 바이너리가 리눅스용을 덮어써 컨테이너가 깨진다.
node_modules
dist
coverage
# 시크릿 — 이미지에 굽지 않는다. 실행 시 compose 의 env_file/environment 로 주입된다.
.env
.env.*
# 런타임 데이터·로그
uploads
logs
*.log
# 에디터·OS
.vscode
.idea
.DS_Store
+15
View File
@@ -0,0 +1,15 @@
# ==========================================
# 백엔드 고유 시크릿 (development)
#
# ⚠️ DB / Redis / CORS / PORT 는 루트 .env.development 에서 docker-compose 가 주입한다.
# 여기에 중복 작성하지 말 것 (SSOT: 루트 .env — .claude/rules/env-structure.md).
# 이 파일에는 "루트에서 내려주지 않는 값"만 작성한다.
#
# 사용법: cp .env.development.example .env.development
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
# ==========================================
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
+15
View File
@@ -0,0 +1,15 @@
# ==========================================
# 백엔드 고유 시크릿 (production)
#
# ⚠️ DB / Redis / CORS / PORT 는 루트 .env.production 에서 docker-compose 가 주입한다.
# 여기에 중복 작성하지 말 것 (SSOT: 루트 .env — .claude/rules/env-structure.md).
# 이 파일에는 "루트에서 내려주지 않는 값"만 작성한다.
#
# 사용법: cp .env.production.example .env.production
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
# ==========================================
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
+17 -6
View File
@@ -1,17 +1,24 @@
FROM node:20-alpine AS builder
FROM node:24-alpine AS builder
WORKDIR /app
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 고정
# (베이스 이미지의 번들 npm 이 바뀌어도 lock 형식이 어긋나지 않게 한다. 전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
RUN npm install -g npm@11
# 개발 환경(docker-compose.dev.yml)은 이 스테이지를 그대로 구동하므로 비-root 로 전환한다.
# /app 소유권을 넘겨야 익명 볼륨(node_modules)도 node 소유로 생성되어 캐시 쓰기가 가능하다.
RUN chown node:node /app
USER node
# 재현 가능한 빌드를 위해 npm ci 사용 (lockfile 기반)
COPY package*.json ./
COPY --chown=node:node package*.json ./
RUN npm ci
COPY . .
# 호스트의 node_modules·dist·.env 는 .dockerignore 로 제외된다
COPY --chown=node:node . .
RUN npm run build
FROM node:20-alpine AS production
FROM node:24-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
@@ -25,9 +32,13 @@ RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
# 업로드 디렉터리를 node 소유로 미리 만든다 — 볼륨 없이 띄우거나 named volume 을 붙여도 비-root 앱이 쓸 수 있다.
# 호스트 디렉터리를 바인드 마운트하면 호스트 쪽 소유권을 따른다 (README "운영 배포" 의 chown 참조)
RUN mkdir -p /app/uploads && chown node:node /app/uploads
EXPOSE 3000
# 비-root 실행 (보안 규칙)
USER node
CMD ["node", "dist/main"]
CMD ["node", "dist/main"]
+37 -5
View File
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
ignores: ['eslint.config.mjs', 'dist/**', 'coverage/**'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
@@ -26,10 +26,42 @@ export default tseslint.config(
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
// 타입 규칙 — 명시적 타입 선언 강제 ([[coding-common]])
'@typescript-eslint/no-explicit-any': 'error',
// 비동기 누락은 조용한 버그가 되므로 error 로 승격
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
// 로깅 규칙 — console 대신 winston/Nest Logger 사용 ([[logging]])
'no-console': 'error',
// 에러 규칙 — 코드 없는 실패 금지. HttpException 직접 상속/생성 대신
// BusinessException 을 사용한다 ([[error-codes]])
'no-restricted-syntax': [
'error',
{
selector:
"NewExpression[callee.name='HttpException']:not([callee.name='BusinessException'])",
message:
'HttpException 을 직접 생성하지 말고 BusinessException(code) 을 사용하세요. (.claude/rules/error-codes.md)',
},
],
'prettier/prettier': ['error', { endOfLine: 'auto' }],
},
},
{
// 에러 코드 레지스트리·예외 필터는 프레임워크 예외를 직접 다루는 계층이므로 예외 허용
files: ['src/common/errors/**/*.ts', 'src/common/filters/**/*.ts'],
rules: {
'no-restricted-syntax': 'off',
},
},
{
// CLI 스크립트는 콘솔 출력이 정상 동작이다
files: ['scripts/**/*.ts'],
rules: {
'no-console': 'off',
},
},
);
+138 -180
View File
@@ -22,7 +22,6 @@
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"nest-winston": "^1.10.0",
"pg": "^8.20.0",
@@ -41,6 +40,7 @@
"@swc/core": "^1.15.24",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"chokidar": "^5.0.0",
@@ -209,6 +209,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
@@ -314,6 +315,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
@@ -373,7 +375,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -922,7 +923,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
@@ -935,7 +935,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1672,9 +1671,9 @@
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"version": "3.15.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1968,9 +1967,9 @@
}
},
"node_modules/@jest/reporters/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2676,7 +2675,6 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -2707,7 +2705,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -2874,7 +2871,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.27.tgz",
"integrity": "sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==",
"license": "MIT",
"peer": true,
"dependencies": {
"file-type": "21.3.4",
"iterare": "1.2.1",
@@ -2921,7 +2917,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz",
"integrity": "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-safe-stringify": "2.1.1",
"iterare": "1.2.1",
@@ -2977,15 +2972,14 @@
}
},
"node_modules/@nestjs/platform-express": {
"version": "11.1.27",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz",
"integrity": "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==",
"version": "11.2.5",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.5.tgz",
"integrity": "sha512-R3LSHqgPQo7ZTdYqA39lvdHS2RBnVJUePKIpL+cr1oB3HtYj9qPdhdmS307eLDc1VeTPFXAnSuVfcG9lMDEFSw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cors": "2.8.6",
"express": "5.2.1",
"multer": "2.1.1",
"multer": "2.2.0",
"path-to-regexp": "8.4.2",
"tslib": "2.8.1"
},
@@ -3130,6 +3124,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14.18.0"
},
@@ -3159,20 +3154,20 @@
}
},
"node_modules/@nestjs/swagger": {
"version": "11.4.4",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.4.tgz",
"integrity": "sha512-VaIo1ruV2G7b+f2zPzkBSUNy9a/WQ9sg8TLKhWlrTfg4O6U10M/PA7Xi6XMXadOVhwOqoesijba8jH3i/3adrA==",
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.7.tgz",
"integrity": "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==",
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "0.16.0",
"@nestjs/mapped-types": "2.1.1",
"js-yaml": "4.1.1",
"js-yaml": "5.3.0",
"lodash": "4.18.1",
"path-to-regexp": "8.4.2",
"swagger-ui-dist": "5.32.6"
"swagger-ui-dist": "5.32.13"
},
"peerDependencies": {
"@fastify/static": "^8.0.0 || ^9.0.0",
"@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"class-transformer": "*",
@@ -3192,15 +3187,25 @@
}
},
"node_modules/@nestjs/swagger/node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz",
"integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
"js-yaml": "bin/js-yaml.mjs"
}
},
"node_modules/@nestjs/terminus": {
@@ -3317,7 +3322,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz",
"integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0",
@@ -3461,7 +3465,6 @@
"integrity": "sha512-L+ACCGHCiS0VqHVep/INLVnvRvJ2XooQFLZq4L8snhxw1jsqz+XRcY313UsyPVturPPE1shW3jic7rt3qEQTSQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@xhmikosr/bin-wrapper": "^14.0.0",
@@ -3492,9 +3495,9 @@
}
},
"node_modules/@swc/cli/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3534,7 +3537,6 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
@@ -4051,13 +4053,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "22.19.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4202,7 +4213,6 @@
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.1",
@@ -4357,16 +4367,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
@@ -5109,7 +5119,6 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5159,7 +5168,6 @@
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5299,6 +5307,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
"integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
@@ -5557,9 +5566,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.37",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
"version": "2.11.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz",
"integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -5824,9 +5833,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5848,9 +5857,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"version": "4.29.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz",
"integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==",
"dev": true,
"funding": [
{
@@ -5867,13 +5876,12 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"update-browserslist-db": "^1.2.3"
"baseline-browser-mapping": "^2.11.23",
"caniuse-lite": "^1.0.30001810",
"electron-to-chromium": "^1.5.427",
"node-releases": "^2.0.55",
"update-browserslist-db": "^1.3.3"
},
"bin": {
"browserslist": "cli.js"
@@ -5934,6 +5942,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/busboy": {
@@ -5974,7 +5983,6 @@
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-6.4.3.tgz",
"integrity": "sha512-VV5eq/QQ5rIVix7/aICO4JyvSeEv9eIQuKL5iFwgM2BrcYoE0A/D1mNsAHJAsB0WEbNdBlKkn6Tjz6fKzh/cKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"keyv": "^5.3.3"
}
@@ -6078,9 +6086,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"dev": true,
"funding": [
{
@@ -6146,7 +6154,6 @@
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readdirp": "^5.0.0"
},
@@ -6194,15 +6201,13 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/class-validator": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz",
"integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
@@ -6459,21 +6464,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
@@ -6840,9 +6830,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.373",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.373.tgz",
"integrity": "sha512-G2Hym8JIf/QreuseqkDibgH8Ci8KfJzqGDKdakbhSx9UltwRBH2cBLAWU/lBX0sCdv0TlhyxQyDCnSfxgMWsjA==",
"version": "1.5.430",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz",
"integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==",
"dev": true,
"license": "ISC"
},
@@ -6991,7 +6981,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7052,7 +7041,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7291,7 +7279,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -7330,24 +7317,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -7430,9 +7399,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"dev": true,
"funding": [
{
@@ -8034,16 +8003,16 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": {
@@ -8417,15 +8386,6 @@
"kind-of": "^6.0.2"
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -8693,7 +8653,6 @@
"integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.4.2",
"@jest/types": "30.4.1",
@@ -8937,9 +8896,9 @@
}
},
"node_modules/jest-config/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9317,9 +9276,9 @@
}
},
"node_modules/jest-runtime/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9518,9 +9477,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -9619,7 +9578,6 @@
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@@ -10095,14 +10053,13 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
@@ -10243,9 +10200,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.47",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
"integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
"version": "2.0.55",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
"integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -10656,7 +10613,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
@@ -10924,7 +10880,6 @@
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -11034,12 +10989,13 @@
"license": "MIT"
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -11133,8 +11089,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/require-directory": {
"version": "2.1.1",
@@ -11253,7 +11208,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -11958,9 +11912,9 @@
}
},
"node_modules/swagger-ui-dist": {
"version": "5.32.6",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.6.tgz",
"integrity": "sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==",
"version": "5.32.13",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz",
"integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
@@ -12117,7 +12071,6 @@
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -12500,7 +12453,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -12666,24 +12618,17 @@
"node": ">= 0.4"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typeorm": {
"version": "0.3.30",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.30.tgz",
"integrity": "sha512-8T35PzjefOdqc2ZR9mwLQj0pUGp6lQhMbK2EvVMwJVJWlaoHm0v/Q6dThNOZkFchD+0yMg8gwjKM28ePiLSXSQ==",
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.31.tgz",
"integrity": "sha512-6u9EFtdLBgHjnPm78NStVeM+I/1MolTzKykDDcydzKUkh6E++YS6XViU/fePJbvDvEGU4Xq34KOM/CLeer9I2A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@sqltools/formatter": "^1.2.5",
"ansis": "^4.2.0",
"ansis": "^4.3.1",
"app-root-path": "^3.1.0",
"buffer": "^6.0.3",
"dayjs": "^1.11.20",
"dayjs": "^1.11.21",
"debug": "^4.4.3",
"dedent": "^1.7.2",
"dotenv": "^16.6.1",
@@ -12693,7 +12638,7 @@
"sql-highlight": "^6.1.0",
"tslib": "^2.8.1",
"uuid": "^11.1.1",
"yargs": "^17.7.2"
"yargs": "^17.7.3"
},
"bin": {
"typeorm": "cli.js",
@@ -12714,13 +12659,13 @@
"mongodb": "^5.8.0 || ^6.0.0",
"mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0",
"mysql2": "^2.2.5 || ^3.0.1",
"oracledb": "^6.3.0",
"oracledb": "^6.3.0 || ^7.0.0",
"pg": "^8.5.1",
"pg-native": "^3.0.0",
"pg-query-stream": "^4.0.0",
"redis": "^3.1.1 || ^4.0.0 || ^5.0.14",
"sql.js": "^1.4.0",
"sqlite3": "^5.0.3",
"sqlite3": "^5.0.3 || ^6.0.0",
"ts-node": "^10.7.0",
"typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0"
},
@@ -12775,10 +12720,19 @@
}
}
},
"node_modules/typeorm/node_modules/ansis": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.4.0.tgz",
"integrity": "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==",
"license": "ISC",
"engines": {
"node": ">=14"
}
},
"node_modules/typeorm/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -12884,7 +12838,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13044,9 +12997,9 @@
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
"integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
"dev": true,
"funding": [
{
@@ -13275,6 +13228,7 @@
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
@@ -13293,6 +13247,7 @@
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
@@ -13306,6 +13261,7 @@
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@@ -13320,6 +13276,7 @@
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=4.0"
}
@@ -13329,7 +13286,8 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/webpack/node_modules/schema-utils": {
"version": "4.3.3",
@@ -13337,6 +13295,7 @@
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
@@ -13410,7 +13369,6 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@@ -13559,9 +13517,9 @@
"license": "ISC"
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"version": "17.7.3",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
"integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
+19 -4
View File
@@ -12,12 +12,21 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint": "eslint \"{src,apps,libs,test,scripts}/**/*.ts\" --max-warnings 0",
"lint:fix": "eslint \"{src,apps,libs,test,scripts}/**/*.ts\" --fix",
"type-check": "tsc --noEmit --incremental false -p tsconfig.json",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"sync:error-codes": "ts-node -P tsconfig.json scripts/sync-error-codes.ts",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d src/data-source.ts",
"migration:generate": "npm run typeorm -- migration:generate",
"migration:create": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:create",
"migration:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert",
"migration:show": "npm run typeorm -- migration:show"
},
"dependencies": {
"@nestjs/cache-manager": "^3.0.1",
@@ -33,7 +42,6 @@
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"nest-winston": "^1.10.0",
"pg": "^8.20.0",
@@ -50,11 +58,12 @@
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.8.1",
"@swc/core": "^1.15.24",
"chokidar": "^5.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"chokidar": "^5.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
@@ -70,6 +79,9 @@
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"overrides": {
"multer": "^2.4.0"
},
"jest": {
"moduleFileExtensions": [
"js",
@@ -77,6 +89,9 @@
"ts"
],
"rootDir": "src",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
},
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
+96
View File
@@ -0,0 +1,96 @@
/**
* 에러 코드 SSOT 동기화 스크립트.
*
* backend 의 error-codes.ts 레지스트리를 읽어 프론트엔드/플러터의 에러 사전을 생성한다.
* 실행: npm run sync:error-codes (backend 디렉터리에서)
*
* 생성 파일은 직접 수정하지 말 것 — 레지스트리를 고치고 다시 실행한다.
* CI 는 이 스크립트를 실행한 뒤 git diff 로 드리프트를 검사한다.
*/
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { ERROR_CODES } from '../src/common/errors/error-codes';
const REPO_ROOT = resolve(__dirname, '..', '..');
// 이 스크립트는 저장소 루트 기준으로 frontend/·flutter/ 에 파일을 쓴다.
// 컨테이너 안에서는 backend/ 만 마운트되어 형제 디렉터리가 없으므로 실행할 수 없다.
if (!existsSync(resolve(REPO_ROOT, 'frontend'))) {
console.error(
[
'저장소 루트를 찾을 수 없습니다: ' + REPO_ROOT,
'',
'이 스크립트는 frontend/·flutter/ 에 사전 파일을 생성하므로',
'저장소 전체가 보이는 곳에서 실행해야 합니다.',
'컨테이너 안(backend/ 만 마운트됨)이 아니라 **호스트에서** 실행하세요:',
' cd backend && npm run sync:error-codes',
].join('\n'),
);
process.exit(1);
}
const GENERATED_NOTICE =
'backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.';
const entries = Object.entries(ERROR_CODES);
/** TypeScript 작은따옴표 문자열 리터럴로 이스케이프한다. */
function toTsLiteral(value: string): string {
const escaped = value
.split('\\')
.join('\\\\')
.split("'")
.join('\\' + "'");
return `'${escaped}'`;
}
/**
* Dart 작은따옴표 문자열 리터럴로 이스케이프한다.
* Dart 는 문자열 안의 `$` 를 보간으로 해석하므로 함께 이스케이프해야 한다.
*/
function toDartLiteral(value: string): string {
const escaped = value
.split('\\')
.join('\\\\')
.split("'")
.join('\\' + "'")
.split('$')
.join('\\' + '$');
return `'${escaped}'`;
}
function writeGenerated(relativePath: string, content: string): void {
const target = resolve(REPO_ROOT, relativePath);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, content, { encoding: 'utf8' });
console.log(`생성 완료: ${relativePath}`);
}
// ---------- frontend (TypeScript) ----------
const tsBody = entries
.map(([code, def]) => ` ${code}: ${toTsLiteral(def.message)},`)
.join('\n');
writeGenerated(
'frontend/src/shared/constants/error-codes.generated.ts',
`// ${GENERATED_NOTICE}\n\n` +
`export const ERROR_CODE_MESSAGES = {\n${tsBody}\n} as const\n\n` +
`export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES\n\n` +
`/** 매핑 실패 시 사용하는 폴백 코드 */\n` +
`export const FALLBACK_ERROR_CODE: ErrorCode = 'SYS_001'\n`,
);
// ---------- flutter (Dart) ----------
const dartBody = entries
.map(([code, def]) => ` '${code}': ${toDartLiteral(def.message)},`)
.join('\n');
writeGenerated(
'flutter/lib/core/api/error_lexicon.g.dart',
`// ${GENERATED_NOTICE}\n\n` +
`const Map<String, String> kErrorCodeMessages = {\n${dartBody}\n};\n\n` +
`/// 매핑 실패 시 사용하는 폴백 코드\n` +
`const String kFallbackErrorCode = 'SYS_001';\n`,
);
console.log(`\n총 ${entries.length}개 에러 코드를 동기화했습니다.`);
-22
View File
@@ -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!');
});
});
});
-16
View File
@@ -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();
}
}
+41 -36
View File
@@ -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,18 @@ 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 { UploadModule } from '@/modules/upload/upload.module';
import { UserModule } from '@/modules/user/user.module';
import { parsePositiveInt } from '@/shared/utils/env';
import { buildDatabaseOptions } from '@/shared/config/database.config';
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별 최대 요청 수
@Module({
imports: [
@@ -17,56 +25,53 @@ 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,
),
},
]),
// DB 접속 설정은 database.config.ts 하나만 수정한다 (CLI 와 공유)
TypeOrmModule.forRootAsync({
useFactory: () => {
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',
autoLoadEntities: true,
// 운영 환경에서는 절대 synchronize 사용 금지 (스키마 자동 변경 → 데이터 손실 위험)
synchronize: !isProduction,
migrationsRun: isProduction,
logging: !isProduction,
extra: {
max: 100,
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 30000,
},
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
};
},
useFactory: () => ({
...buildDatabaseOptions(),
// Nest 모듈에 등록된 엔티티를 자동으로 수집한다 (CLI 는 glob 경로를 사용)
autoLoadEntities: true,
}),
}),
HealthModule,
UploadModule,
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('*');
}
}
-8
View File
@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+88
View File
@@ -0,0 +1,88 @@
import { Controller, Get, INestApplication, Logger } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import type { Response } from 'supertest';
import { App } from 'supertest/types';
import { configureApp } from '@/app.setup';
import { BusinessException } from '@/common/errors/business.exception';
/** 전역 설정만 검증하기 위한 최소 컨트롤러 — DB 가 필요 없다 */
@Controller('ping')
class PingController {
@Get()
ping(): { pong: boolean } {
return { pong: true };
}
@Get('missing')
missing(): never {
throw new BusinessException('RES_001');
}
}
/** supertest 의 body 는 any 이므로 계약 형태로 좁혀서 읽는다 */
interface ContractBody {
success?: boolean;
data?: unknown;
error?: { code?: string };
}
const body = (res: Response): ContractBody => res.body as ContractBody;
describe('configureApp (main.ts·e2e 공유 전역 설정)', () => {
let app: INestApplication<App>;
beforeAll(async () => {
// 의도된 실패 응답의 경고 로그가 테스트 출력을 오염시키지 않도록 억제
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const moduleRef = await Test.createTestingModule({
controllers: [PingController],
}).compile();
app = moduleRef.createNestApplication();
configureApp(app);
await app.init();
});
afterAll(async () => {
await app.close();
jest.restoreAllMocks();
});
it('라우트는 /api 아래에 놓이고 성공 응답은 { success, data } 로 감싸진다', async () => {
const res = await request(app.getHttpServer()).get('/api/ping').expect(200);
expect(body(res)).toEqual({ success: true, data: { pong: true } });
});
it('prefix 밖의 루트(/)는 상태 확인 텍스트를 돌려준다 (docker healthcheck)', async () => {
const res = await request(app.getHttpServer()).get('/').expect(200);
expect(res.text).toContain('정상 구동');
});
it('실패는 전역 필터가 에러 코드 응답으로 바꾼다', async () => {
const res = await request(app.getHttpServer())
.get('/api/ping/missing')
.expect(404);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('RES_001');
});
it('보안 헤더(helmet)가 붙는다', async () => {
const res = await request(app.getHttpServer()).get('/api/ping');
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
it('화이트리스트 밖 오리진에는 CORS 헤더를 주지 않는다', async () => {
const res = await request(app.getHttpServer())
.get('/api/ping')
.set('Origin', 'https://other.io');
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
});
+67
View File
@@ -0,0 +1,67 @@
import {
INestApplication,
RequestMethod,
ValidationPipe,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import helmet from 'helmet';
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 { buildCorsOptions } from '@/shared/config/cors.config';
/** 모든 라우트의 공통 prefix — Flutter·Web 의 baseURL, 리버스 프록시의 location /api 와 맞물려 있다 */
export const GLOBAL_PREFIX = 'api';
/**
* 앱 전역 설정 — main.ts 와 e2e 테스트가 **같은 함수**를 쓴다.
* 테스트가 설정을 따로 흉내 내면 둘이 어긋나도 테스트는 통과해 버린다.
* 리스닝·Swagger·종료 훅처럼 실행 환경에만 필요한 것은 main.ts 에 둔다.
*/
export function configureApp(app: INestApplication): void {
// 리버스 프록시 뒤에서 실제 클라이언트 IP 를 식별한다 (rate limit 이 IP 기준이다).
// 운영은 Nginx Proxy Manager → backend 로 한 번 거친다 (Custom location 이 X-Forwarded-For 에 클라이언트 IP 를 싣는다).
const expressApp = app.getHttpAdapter().getInstance() as Express;
expressApp.set('trust proxy', 1);
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
// 리버스 프록시(운영 NPM · 개발 vite)는 /api 로 시작하는 요청을 경로 그대로 넘기므로 이 prefix 와 맞물려 있다.
app.setGlobalPrefix(GLOBAL_PREFIX, {
// 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용
exclude: [{ path: '/', method: RequestMethod.GET }],
});
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 — docker healthcheck 가 사용한다
expressApp.get('/', (_req: ExpressRequest, res: ExpressResponse) => {
res.send('Backend API Server is running 정상 구동 중입니다.');
});
// 1. 보안 헤더 (Helmet) - XSS 방지 및 기본 웹 보안
app.use(helmet());
// 2. CORS — 기본 구성은 프론트와 API 가 같은 도메인이라(브라우저는 /api 상대경로로 호출) 필요 없다.
// 다른 오리진에서 호출해야 할 때만 루트 .env 의 CORS_ORIGIN 에 콤마로 나열한다 (shared/config/cors.config.ts)
app.enableCors(buildCorsOptions());
// 3. Rate Limiting 은 AppModule 의 ThrottlerGuard(APP_GUARD)로 일원화되어 있다.
// 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다.
// (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md)
// 4. 전역 DTO 유효성 파이프 (강력한 검증 단계)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTO에 불필요한 값이 오면 삭제
forbidNonWhitelisted: true, // DTO에 없는 필드가 주입되면 400 에러 발생
transform: true, // 네트워크 데이터를 DTO나 기본 타입으로 자동 변환
}),
);
// 5. 프론트엔드 연동을 위한 규격화된 에러 & 성공 페이로드 인터셉터 적용
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
}
@@ -0,0 +1,71 @@
import { Type, applyDecorators } from '@nestjs/common';
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger';
import { ErrorCode, getErrorDefinition } from '@/common/errors/error-codes';
/**
* Swagger 문서를 실제 응답(표준 래퍼)과 일치시킨다.
* 컨트롤러는 raw 데이터를 반환하지만 클라이언트가 받는 본문은 { success, data } 이므로,
* `@ApiResponse({ type: Dto })` 를 그대로 쓰면 문서와 실응답이 어긋난다.
*
* 사용 예:
* @ApiStandardResponse(UserResponseDto, { status: 200, description: '사용자 조회 성공' })
*/
export function ApiStandardResponse<TModel extends Type<unknown>>(
model?: TModel,
options: { status?: number; description?: string; isArray?: boolean } = {},
) {
const { status = 200, description = '요청 성공', isArray = false } = options;
const dataSchema = model
? isArray
? { type: 'array', items: { $ref: getSchemaPath(model) } }
: { $ref: getSchemaPath(model) }
: { type: 'object', nullable: true };
return applyDecorators(
...(model ? [ApiExtraModels(model)] : []),
ApiResponse({
status,
description,
schema: {
properties: {
success: { type: 'boolean', example: true },
data: dataSchema,
},
},
}),
);
}
/**
* 표준 실패 응답을 에러 코드와 함께 문서화한다.
* 상태 코드·메시지는 레지스트리에서 가져오므로 문서와 구현이 자동으로 일치한다.
*
* 사용 예:
* @ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
*/
export function ApiErrorResponse(code: ErrorCode, description?: string) {
const definition = getErrorDefinition(code);
return ApiResponse({
status: definition.status,
description: description ?? definition.message,
schema: {
properties: {
success: { type: 'boolean', example: false },
error: {
type: 'object',
properties: {
code: { type: 'string', example: code },
message: { type: 'string', example: definition.message },
traceId: {
type: 'string',
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
},
},
},
},
},
});
}
@@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
export const SKIP_TRANSFORM_KEY = 'skipTransform';
/**
* 표준 응답 래핑({ success, data })을 건너뛴다.
* 파일 다운로드·스트림·리다이렉트·204 No Content 처럼
* 본문 구조를 바꾸면 안 되는 핸들러에만 사용한다.
*/
export const SkipTransform = () => SetMetadata(SKIP_TRANSFORM_KEY, true);
+99
View File
@@ -0,0 +1,99 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
/** 페이지네이션 기본값 — 매직 넘버 방지용 상수 */
export const PAGINATION_DEFAULT_LIMIT = 20;
export const PAGINATION_MAX_LIMIT = 100;
/** 커서 기반 페이지네이션 쿼리 (목록 API 기본 권장) */
export class CursorPaginationQueryDto {
@ApiPropertyOptional({
description: '이전 응답의 nextCursor 값. 첫 페이지는 생략한다.',
example: 'eyJpZCI6MTIzfQ==',
})
@IsOptional()
@IsString({ message: '커서 값의 형식이 올바르지 않습니다.' })
cursor?: string;
@ApiPropertyOptional({
description: `한 번에 가져올 개수 (1~${PAGINATION_MAX_LIMIT})`,
default: PAGINATION_DEFAULT_LIMIT,
example: PAGINATION_DEFAULT_LIMIT,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '조회 개수는 정수로 입력해 주세요.' })
@Min(1, { message: '조회 개수는 1 이상이어야 합니다.' })
@Max(PAGINATION_MAX_LIMIT, {
message: `조회 개수는 ${PAGINATION_MAX_LIMIT} 이하여야 합니다.`,
})
limit: number = PAGINATION_DEFAULT_LIMIT;
}
/** 오프셋 기반 페이지네이션 쿼리 (전체 페이지 수가 필요한 화면에서만 사용) */
export class OffsetPaginationQueryDto {
@ApiPropertyOptional({
description: '1부터 시작하는 페이지 번호',
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '페이지 번호는 정수로 입력해 주세요.' })
@Min(1, { message: '페이지 번호는 1 이상이어야 합니다.' })
page: number = 1;
@ApiPropertyOptional({
description: `한 페이지 개수 (1~${PAGINATION_MAX_LIMIT})`,
default: PAGINATION_DEFAULT_LIMIT,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '조회 개수는 정수로 입력해 주세요.' })
@Min(1, { message: '조회 개수는 1 이상이어야 합니다.' })
@Max(PAGINATION_MAX_LIMIT, {
message: `조회 개수는 ${PAGINATION_MAX_LIMIT} 이하여야 합니다.`,
})
limit: number = PAGINATION_DEFAULT_LIMIT;
}
/** 목록 응답의 페이지 정보 — 세 플랫폼 공유 계약 */
export class PageInfoDto {
@ApiProperty({ description: '다음 페이지 존재 여부', example: true })
hasNext!: boolean;
@ApiProperty({
description: '다음 요청에 넘길 커서. 마지막 페이지면 null',
nullable: true,
example: 'eyJpZCI6MTQzfQ==',
})
nextCursor!: string | null;
@ApiProperty({
description: '전체 개수. 계산 비용이 큰 경우 null 을 반환할 수 있다.',
nullable: true,
example: 143,
})
total!: number | null;
}
/** 목록 응답 본문 — 인터셉터가 { success, data } 로 한 번 더 감싼다. */
export interface PaginatedResult<T> {
items: T[];
pageInfo: PageInfoDto;
}
/** 조회 결과를 표준 목록 응답으로 변환한다. */
export function toPaginatedResult<T>(
items: T[],
pageInfo: Partial<PageInfoDto> = {},
): PaginatedResult<T> {
return {
items,
pageInfo: {
hasNext: pageInfo.hasNext ?? false,
nextCursor: pageInfo.nextCursor ?? null,
total: pageInfo.total ?? null,
},
};
}
@@ -0,0 +1,33 @@
import { HttpException } from '@nestjs/common';
import { ErrorCode, getErrorDefinition } from './error-codes';
export interface BusinessExceptionOptions {
/** 레지스트리 기본 메시지 대신 사용자에게 보여줄 문구 (동적 값이 필요한 경우만) */
readonly message?: string;
/** 로그에만 남기는 개발자용 컨텍스트 — 응답 본문에는 포함되지 않는다 */
readonly context?: Record<string, unknown>;
}
/**
* 사용자에게 노출되는 모든 실패는 이 예외로 던진다.
* 에러 코드가 생성자 필수 인자이므로, 코드 없는 에러가 만들어질 수 없다.
*
* 사용 예:
* throw new BusinessException('RES_001');
* throw new BusinessException('USER_001', { context: { email } });
*/
export class BusinessException extends HttpException {
readonly code: ErrorCode;
readonly context?: Record<string, unknown>;
constructor(code: ErrorCode, options: BusinessExceptionOptions = {}) {
const definition = getErrorDefinition(code);
const message = options.message ?? definition.message;
super({ code, message }, definition.status);
this.code = code;
this.context = options.context;
}
}
@@ -0,0 +1,91 @@
import { HttpStatus } from '@nestjs/common';
import {
ERROR_CODES,
ErrorCodeDefinition,
FALLBACK_ERROR_CODE,
getErrorDefinition,
isErrorCode,
resolveCodeByStatus,
} from './error-codes';
import { BusinessException } from './business.exception';
describe('에러 코드 레지스트리', () => {
it('모든 코드는 DOMAIN_NNN 네이밍 규약을 따른다', () => {
for (const code of Object.keys(ERROR_CODES)) {
expect(code).toMatch(/^[A-Z]+_\d{3}$/);
}
});
it('모든 코드는 메시지와 HTTP 상태를 갖는다', () => {
for (const [code, definition] of Object.entries(ERROR_CODES)) {
expect(definition.message.length).toBeGreaterThan(0);
expect(definition.status).toBeGreaterThanOrEqual(400);
expect(code).not.toBe('');
}
});
it('미등록 코드는 폴백(SYS_001) 정의를 반환한다', () => {
expect(getErrorDefinition('UNKNOWN_999')).toBe(
ERROR_CODES[FALLBACK_ERROR_CODE],
);
expect(isErrorCode('UNKNOWN_999')).toBe(false);
});
it('HTTP 상태 코드를 대응하는 에러 코드로 역매핑한다', () => {
expect(resolveCodeByStatus(HttpStatus.UNAUTHORIZED)).toBe('AUTH_001');
expect(resolveCodeByStatus(HttpStatus.FORBIDDEN)).toBe('AUTH_003');
expect(resolveCodeByStatus(HttpStatus.NOT_FOUND)).toBe('RES_001');
expect(resolveCodeByStatus(HttpStatus.BAD_REQUEST)).toBe('VAL_001');
expect(resolveCodeByStatus(HttpStatus.TOO_MANY_REQUESTS)).toBe('REQ_001');
});
it('매핑되지 않은 4xx 는 BIZ_001, 5xx 는 SYS_001 로 수렴한다', () => {
expect(resolveCodeByStatus(HttpStatus.CONFLICT)).toBe('BIZ_001');
expect(resolveCodeByStatus(HttpStatus.BAD_GATEWAY)).toBe('SYS_001');
});
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
// ERROR_CODES 는 as const 라 clientOnly 가 없는 항목의 타입에는 그 키가 없다 — 공통 정의 타입으로 읽는다
const definitions: [string, ErrorCodeDefinition][] =
Object.entries(ERROR_CODES);
const clientOnlyCodes = definitions
.filter(([, definition]) => definition.clientOnly)
.map(([code]) => code);
expect(clientOnlyCodes).toContain('AUTH_002');
// 모든 4xx/5xx 상태에 대해 clientOnly 코드가 선택되지 않아야 한다.
for (let status = 400; status < 600; status += 1) {
expect(clientOnlyCodes).not.toContain(resolveCodeByStatus(status));
}
});
});
describe('BusinessException', () => {
it('레지스트리의 상태 코드와 메시지를 그대로 사용한다', () => {
const exception = new BusinessException('RES_001');
expect(exception.getStatus()).toBe(HttpStatus.NOT_FOUND);
expect(exception.getResponse()).toEqual({
code: 'RES_001',
message: ERROR_CODES.RES_001.message,
});
});
it('메시지를 재정의해도 코드와 상태는 유지된다', () => {
const exception = new BusinessException('BIZ_001', {
message: '이미 가입된 이메일입니다.',
context: { email: 'user@example.com' },
});
expect(exception.code).toBe('BIZ_001');
expect(exception.getStatus()).toBe(HttpStatus.BAD_REQUEST);
expect(exception.getResponse()).toEqual({
code: 'BIZ_001',
message: '이미 가입된 이메일입니다.',
});
// context 는 로그 전용이며 응답 본문에 포함되지 않는다.
expect(exception.context).toEqual({ email: 'user@example.com' });
});
});
+121
View File
@@ -0,0 +1,121 @@
import { HttpStatus } from '@nestjs/common';
/**
* 에러 코드 레지스트리 — 세 플랫폼(backend / frontend / flutter)이 공유하는 단일 진실 공급원(SSOT).
*
* 이 파일만 수정한 뒤 `npm run sync:error-codes` 를 실행하면
* 프론트엔드/플러터의 에러 사전이 자동 생성된다. (수동 복사 금지)
*/
export interface ErrorCodeDefinition {
/** 이 코드로 응답할 때 사용할 HTTP 상태 코드 */
readonly status: HttpStatus;
/** 사용자에게 노출되는 한국어 메시지 */
readonly message: string;
/** true 면 클라이언트에서만 생성하는 코드 — 서버 응답에는 절대 나타나지 않는다 */
readonly clientOnly?: true;
}
// 공통 코드 — 모든 도메인이 공유한다.
const COMMON_ERROR_CODES = {
SYS_001: {
status: HttpStatus.INTERNAL_SERVER_ERROR,
message: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
},
REQ_001: {
status: HttpStatus.TOO_MANY_REQUESTS,
message: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
},
AUTH_001: {
status: HttpStatus.UNAUTHORIZED,
message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
},
AUTH_002: {
status: HttpStatus.UNAUTHORIZED,
message: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
// 세션 만료는 클라이언트가 "인증된 세션이 있었는데 401 을 받은" 상황에서 생성한다.
clientOnly: true,
},
AUTH_003: {
status: HttpStatus.FORBIDDEN,
message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
},
VAL_001: {
status: HttpStatus.BAD_REQUEST,
message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
},
RES_001: {
status: HttpStatus.NOT_FOUND,
message: '요청하신 정보나 페이지를 찾을 수 없습니다.',
},
BIZ_001: {
status: HttpStatus.BAD_REQUEST,
message: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
/**
* 도메인 코드 — 기능 추가 시 여기에 `DOMAIN_NNN` 형식으로 등록한다.
* 번호는 001 부터 순차 부여하고, 한번 배포된 코드의 의미는 바꾸지 않는다(폐기 시 주석으로 표시).
*
* 예시:
* USER_001: {
* status: HttpStatus.CONFLICT,
* message: '이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.',
* },
*/
const DOMAIN_ERROR_CODES = {
FILE_001: {
status: HttpStatus.BAD_REQUEST,
message: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
},
FILE_002: {
status: HttpStatus.PAYLOAD_TOO_LARGE,
message: '파일 크기가 허용 범위를 초과했습니다.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
...COMMON_ERROR_CODES,
...DOMAIN_ERROR_CODES,
} as const;
export type ErrorCode = keyof typeof ERROR_CODES;
/** 매핑에 실패했을 때 사용하는 최종 폴백 코드 */
export const FALLBACK_ERROR_CODE: ErrorCode = 'SYS_001';
/** HTTP 상태 코드 → 에러 코드 폴백 매핑 (프레임워크가 던진 HttpException 용) */
const STATUS_TO_ERROR_CODE: ReadonlyMap<number, ErrorCode> = new Map<
number,
ErrorCode
>([
[HttpStatus.UNAUTHORIZED, 'AUTH_001'],
[HttpStatus.FORBIDDEN, 'AUTH_003'],
[HttpStatus.NOT_FOUND, 'RES_001'],
[HttpStatus.BAD_REQUEST, 'VAL_001'],
[HttpStatus.UNPROCESSABLE_ENTITY, 'VAL_001'],
[HttpStatus.TOO_MANY_REQUESTS, 'REQ_001'],
// multer 가 파일 크기 한도 초과 시 던지는 상태 코드
[HttpStatus.PAYLOAD_TOO_LARGE, 'FILE_002'],
]);
/** 등록된 에러 코드인지 확인한다. */
export function isErrorCode(code: string): code is ErrorCode {
return Object.prototype.hasOwnProperty.call(ERROR_CODES, code);
}
/** 에러 코드 정의를 조회한다. 미등록 코드는 폴백(SYS_001) 정의를 반환한다. */
export function getErrorDefinition(code: string): ErrorCodeDefinition {
return isErrorCode(code)
? ERROR_CODES[code]
: ERROR_CODES[FALLBACK_ERROR_CODE];
}
/** HTTP 상태 코드에 대응하는 에러 코드를 찾는다. 4xx 는 BIZ_001, 그 외는 SYS_001 로 수렴한다. */
export function resolveCodeByStatus(status: number): ErrorCode {
const mapped = STATUS_TO_ERROR_CODE.get(status);
if (mapped) return mapped;
if (status >= 400 && status < 500) return 'BIZ_001';
return FALLBACK_ERROR_CODE;
}
+2
View File
@@ -0,0 +1,2 @@
export * from './error-codes';
export * from './business.exception';
@@ -0,0 +1,130 @@
import {
ArgumentsHost,
BadRequestException,
ForbiddenException,
HttpStatus,
Logger,
NotFoundException,
PayloadTooLargeException,
} from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter';
import { BusinessException } from '@/common/errors/business.exception';
import { ERROR_CODES } from '@/common/errors/error-codes';
interface CapturedResponse {
status: number;
body: {
success: boolean;
error: { code: string; message: string; traceId?: string };
};
}
/** 필터 실행 후 실제로 내려간 응답을 캡처한다. */
function runFilter(exception: unknown, requestId?: string): CapturedResponse {
const captured = {} as CapturedResponse;
const response = {
status(code: number) {
captured.status = code;
return this;
},
json(body: CapturedResponse['body']) {
captured.body = body;
return this;
},
};
const request = {
method: 'GET',
url: '/api/users/1',
header: () => requestId,
};
const host = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => response,
}),
} as unknown as ArgumentsHost;
new HttpExceptionFilter().catch(exception, host);
return captured;
}
describe('HttpExceptionFilter', () => {
beforeAll(() => {
// 의도된 예외 로그가 테스트 출력을 오염시키지 않도록 억제
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterAll(() => {
jest.restoreAllMocks();
});
it('BusinessException 은 지정한 코드와 상태를 그대로 내보낸다', () => {
const result = runFilter(new BusinessException('RES_001'));
expect(result.status).toBe(HttpStatus.NOT_FOUND);
expect(result.body).toEqual({
success: false,
error: { code: 'RES_001', message: ERROR_CODES.RES_001.message },
});
});
it('Nest 내장 예외는 상태 코드로 역매핑된다', () => {
expect(runFilter(new NotFoundException()).body.error.code).toBe('RES_001');
expect(runFilter(new ForbiddenException()).body.error.code).toBe(
'AUTH_003',
);
expect(runFilter(new BadRequestException()).body.error.code).toBe(
'VAL_001',
);
});
it('ValidationPipe 의 상세 사유(배열 message)는 노출하지 않는다', () => {
const exception = new BadRequestException({
message: ['email must be an email'],
error: 'Bad Request',
statusCode: 400,
});
const result = runFilter(exception);
expect(result.body.error.code).toBe('VAL_001');
expect(result.body.error.message).toBe(ERROR_CODES.VAL_001.message);
});
it('라이브러리가 만든 영문 메시지를 사용자에게 노출하지 않는다', () => {
// multer 는 크기 초과 시 "File too large" 라는 영문 메시지로 예외를 만든다.
// 사용자 노출 문구는 에러 코드 사전에만 존재해야 한다.
const result = runFilter(new PayloadTooLargeException('File too large'));
expect(result.status).toBe(HttpStatus.PAYLOAD_TOO_LARGE);
expect(result.body.error.code).toBe('FILE_002');
expect(result.body.error.message).toBe(ERROR_CODES.FILE_002.message);
expect(JSON.stringify(result.body)).not.toContain('File too large');
});
it('미정의 예외는 내부 정보 없이 SYS_001 로 수렴한다', () => {
const result = runFilter(new Error('DB 커넥션 풀 고갈: host=10.0.0.5'));
expect(result.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
expect(result.body.error.code).toBe('SYS_001');
expect(result.body.error.message).toBe(ERROR_CODES.SYS_001.message);
expect(JSON.stringify(result.body)).not.toContain('10.0.0.5');
});
it('요청 추적 ID 가 있으면 응답에 traceId 로 포함한다', () => {
const result = runFilter(new BusinessException('SYS_001'), 'trace-abc');
expect(result.body.error.traceId).toBe('trace-abc');
});
it('요청 추적 ID 가 없으면 traceId 키를 넣지 않는다', () => {
const result = runFilter(new BusinessException('SYS_001'));
expect(result.body.error).not.toHaveProperty('traceId');
});
});
@@ -6,67 +6,121 @@ import {
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
import { Request, Response } from 'express';
// 전역 Exception Filter — CLAUDE.md 에러 코드 맵에 따라 표준 응답으로 변환
import { BusinessException } from '@/common/errors/business.exception';
import {
ErrorCode,
FALLBACK_ERROR_CODE,
getErrorDefinition,
isErrorCode,
resolveCodeByStatus,
} from '@/common/errors/error-codes';
import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
/** 이 값 이상이면 서버 책임 오류로 보고 error 레벨로 남긴다. */
const SERVER_ERROR_STATUS_THRESHOLD = 500;
/**
* 전역 Exception Filter — 모든 실패 응답을 표준 포맷으로 변환한다.
* 에러 코드 ↔ 메시지 ↔ HTTP 상태는 error-codes.ts 레지스트리가 SSOT 이며,
* 이 필터는 조회만 한다(여기에 문구를 하드코딩하지 말 것).
*/
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
let status: number = HttpStatus.INTERNAL_SERVER_ERROR;
let code = 'SYS_001';
let message = '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
const { status, code, message } = this.resolve(exception);
const traceId = request.header(REQUEST_ID_HEADER) ?? undefined;
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (status === HttpStatus.UNAUTHORIZED) {
code = 'AUTH_001';
message = '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.';
} else if (status === HttpStatus.FORBIDDEN) {
code = 'AUTH_003';
message = '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.';
} else if (status === HttpStatus.NOT_FOUND) {
code = 'RES_001';
message = '요청하신 정보나 페이지를 찾을 수 없습니다.';
} else if (
status === HttpStatus.BAD_REQUEST ||
status === HttpStatus.UNPROCESSABLE_ENTITY
) {
code = 'VAL_001';
message = '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)';
} else {
code = 'BIZ_001';
message = '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.';
// 비즈니스 예외에서는 사용자에게 보일 메시지를 재정의 가능
if (
typeof exceptionResponse === 'object' &&
exceptionResponse !== null &&
'message' in exceptionResponse
) {
const responseMessage = (exceptionResponse as { message: unknown }).message;
if (typeof responseMessage === 'string') {
message = responseMessage;
}
}
}
} else {
// 정의되지 않은 예외 → 운영팀이 추적 가능하도록 로그 + SYS_001 응답 유지
this.logger.error('Unhandled Exception', exception as Error);
}
this.log(exception, status, code, request, traceId);
response.status(status).json({
success: false,
error: {
code,
message,
},
error: { code, message, ...(traceId ? { traceId } : {}) },
});
}
/** 예외를 { status, code, message } 로 정규화한다. */
private resolve(exception: unknown): {
status: HttpStatus;
code: ErrorCode;
message: string;
} {
// 1. BusinessException — 코드가 이미 확정되어 있다.
if (exception instanceof BusinessException) {
const body = exception.getResponse() as {
code: ErrorCode;
message: string;
};
return {
status: exception.getStatus(),
code: body.code,
message: body.message,
};
}
// 2. 그 외 HttpException(프레임워크/Nest 내장 예외) — 상태 코드로 역매핑한다.
if (exception instanceof HttpException) {
const status = exception.getStatus();
const body = exception.getResponse();
const explicitCode = this.extractCode(body);
const code = explicitCode ?? resolveCodeByStatus(status);
return {
status,
code,
// 프레임워크/라이브러리가 만든 영문 메시지("File too large" 등)가 그대로 새어나가지 않도록
// **항상 레지스트리 문구**를 쓴다. 사용자 노출 문구를 재정의하려면 BusinessException 을 사용한다.
message: getErrorDefinition(code).message,
};
}
// 3. 미정의 예외 — 내부 정보가 새지 않도록 항상 SYS_001 로 수렴시킨다.
return {
status: HttpStatus.INTERNAL_SERVER_ERROR,
code: FALLBACK_ERROR_CODE,
message: getErrorDefinition(FALLBACK_ERROR_CODE).message,
};
}
/** 예외 본문에 error code 가 실려 있으면 꺼낸다. */
private extractCode(body: unknown): ErrorCode | null {
if (typeof body === 'object' && body !== null && 'code' in body) {
const code: unknown = body.code;
if (typeof code === 'string' && isErrorCode(code)) return code;
}
return null;
}
/** 5xx 는 error, 4xx 는 warn 으로 남긴다. 추적을 위해 traceId 를 함께 기록한다. */
private log(
exception: unknown,
status: number,
code: ErrorCode,
request: Request,
traceId?: string,
): void {
const where = `${request.method} ${request.url}`;
const prefix = `[${code}]${traceId ? `[${traceId}]` : ''} ${where}`;
if (status >= SERVER_ERROR_STATUS_THRESHOLD) {
this.logger.error(
prefix,
exception instanceof Error ? exception.stack : String(exception),
);
return;
}
const context =
exception instanceof BusinessException && exception.context
? ` ${JSON.stringify(exception.context)}`
: '';
this.logger.warn(`${prefix}${context}`);
}
}
@@ -1,30 +1,48 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { SKIP_TRANSFORM_KEY } from '@/common/decorators/skip-transform.decorator';
export interface StandardResponse<T> {
success: boolean;
data: T;
success: true;
// 핸들러가 undefined/null 을 반환하면 null 로 정규화되므로 nullable
data: T | null;
}
/**
* 성공 응답을 { success: true, data } 로 래핑한다.
* `@SkipTransform()` 이 붙은 핸들러는 원본 응답을 그대로 통과시킨다.
*/
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, StandardResponse<T>>
{
export class TransformInterceptor<T> implements NestInterceptor<
T,
StandardResponse<T> | T
> {
constructor(private readonly reflector: Reflector) {}
intercept(
_context: ExecutionContext,
context: ExecutionContext,
next: CallHandler,
): Observable<StandardResponse<T>> {
// 이미 Http 응답객체인 경우 등 예외처리가 필요할 수 있으나 기본적으로 data 래핑
return next.handle().pipe(
): Observable<StandardResponse<T> | T> {
const skip = this.reflector.getAllAndOverride<boolean>(SKIP_TRANSFORM_KEY, [
context.getHandler(),
context.getClass(),
]);
const source = next.handle() as Observable<T>;
if (skip) return source;
return source.pipe(
map((data) => ({
success: true,
data: data !== undefined ? data : null,
success: true as const,
data: data ?? null,
})),
);
}
@@ -0,0 +1,25 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express';
/** 요청 추적 헤더명 — 로그·에러 응답의 traceId 와 동일한 값을 사용한다. */
export const REQUEST_ID_HEADER = 'x-request-id';
/**
* 요청 추적 ID 미들웨어.
* 프록시/클라이언트가 보낸 값이 있으면 이어받고, 없으면 새로 발급한다.
* 발급된 ID 는 응답 헤더로도 돌려주어 클라이언트가 문의 시 제시할 수 있게 한다.
*/
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const incoming = req.header(REQUEST_ID_HEADER);
const requestId =
incoming && incoming.trim() !== '' ? incoming : randomUUID();
req.headers[REQUEST_ID_HEADER] = requestId;
res.setHeader(REQUEST_ID_HEADER, requestId);
next();
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { buildDatabaseOptions } from '@/shared/config/database.config';
/**
* TypeORM CLI 전용 DataSource.
*
* 앱 런타임은 app.module.ts 의 TypeOrmModule 을 사용하고,
* 이 파일은 `migration:generate` / `migration:run` 같은 CLI 명령에만 쓰인다.
* 접속 설정은 database.config.ts 를 공유하므로 두 경로가 어긋나지 않는다.
*
* ⚠️ DB 접속 정보(DB_*)는 루트 .env 에서 docker-compose 가 주입하므로,
* 마이그레이션 명령은 **컨테이너 안에서** 실행해야 한다.
* 자세한 절차는 .claude/skills/nestjs/SKILL.md 의 "DB 마이그레이션" 참조.
*/
export default new DataSource(buildDatabaseOptions());
+36 -87
View File
@@ -1,103 +1,52 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger, RequestMethod, ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { Logger } from '@nestjs/common';
import { WinstonModule } from 'nest-winston';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import type { 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 { configureApp, GLOBAL_PREFIX } from '@/app.setup';
import {
isSwaggerEnabled,
setupSwagger,
SWAGGER_PATH,
} from '@/shared/config/swagger.config';
import { winstonConfig } from '@/shared/logger/winston.config';
import { parsePositiveInt } from '@/shared/utils/env';
/** 루트 .env 에 BACKEND_PORT 가 없을 때만 쓰는 기본 포트 */
const DEFAULT_PORT = 3000;
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: WinstonModule.createLogger(winstonConfig),
});
// 리버스 프록시(Nginx, Docker 배포 환경 등) 뒤에서 올바른 클라이언트 IP 식별 지원을 위해 설정합니다.
const expressApp = app.getHttpAdapter().getInstance();
expressApp.set('trust proxy', 1);
// 보안 헤더·CORS·prefix·검증 파이프·응답 래퍼 등 전역 설정 — e2e 테스트와 같은 함수를 쓴다 (app.setup.ts)
configureApp(app);
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
app.setGlobalPrefix('api', {
// 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용
exclude: [{ path: '/', method: RequestMethod.GET }],
});
// Swagger API 문서 — 운영에서는 기본으로 끈다 (shared/config/swagger.config.ts)
const swaggerEnabled = isSwaggerEnabled();
if (swaggerEnabled) setupSwagger(app);
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 추가
expressApp.get('/', (_req: ExpressRequest, res: ExpressResponse) => {
res.send('Backend API Server is running 정상 구동 중입니다.');
});
// Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
// 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다.
// (이 설정이 없으면 배포·재시작 때 처리 중이던 요청이 그대로 끊긴다)
app.enableShutdownHooks();
// 1. 보안 헤더 (Helmet) - XSS 방지 및 기본 웹 보안
app.use(helmet());
// 종료 로그는 ShutdownLogger(OnApplicationShutdown)가 남긴다.
// 2. 화이트리스트 기반 CORS 정책
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const corsOriginEnv = process.env.CORS_ORIGIN;
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) {
callback(null, true);
} else {
// 클라이언트에는 403 금지 에러로 리턴됨
callback(new Error('CORS 정책에 의해 차단된 도메인입니다.'));
}
},
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: '일시적인 시스템 오류가 발생했습니다. (요청 한도 초과) 잠시 후 다시 시도해 주세요.', // 글로벌 통합 에러 포맷
}
},
}),
);
// 4. 전역 DTO 유효성 파이프 (강력한 검증 단계)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTO에 불필요한 값이 오면 삭제
forbidNonWhitelisted: true, // DTO에 없는 필드가 주입되면 400 에러 발생
transform: true, // 네트워크 데이터를 DTO나 기본 타입으로 자동 변환
}),
);
// 5. 프론트엔드 연동을 위한 규격화된 에러 & 성공 페이로드 인터셉터 적용
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());
// 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);
const port = Number(process.env.PORT ?? 3000);
// compose 가 미정의 변수를 빈 문자열로 주입하므로 parsePositiveInt 로 방어한다 (Number('') 는 0 → 임의 포트)
const port = parsePositiveInt(process.env.PORT, DEFAULT_PORT);
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');
Logger.log(
`🚀 Backend running on http://localhost:${port}/${GLOBAL_PREFIX}`,
'Bootstrap',
);
if (swaggerEnabled) {
Logger.log(
`📘 Swagger docs at http://localhost:${port}/${SWAGGER_PATH}`,
'Bootstrap',
);
}
}
bootstrap();
void bootstrap();
View File
@@ -0,0 +1,119 @@
import {
HttpStatus,
Logger,
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 };
beforeAll(() => {
// 의도된 실패의 error 로그가 테스트 출력을 오염시키지 않도록 억제
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
});
afterAll(() => {
jest.restoreAllMocks();
});
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('운영에서는 구성요소별 status 만 노출한다 (오류 문구에 내부 주소·계정이 담길 수 있다)', async () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
check.mockRejectedValue(new ServiceUnavailableException(downResult));
try {
const result = await controller.check(res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(HttpStatus.SERVICE_UNAVAILABLE);
// 어느 구성요소가 죽었는지는 그대로 보인다
expect(result).toEqual({
status: 'error',
info: {},
error: { database: { status: 'down' } },
details: { database: { status: 'down' } },
});
expect(JSON.stringify(result)).not.toContain('timeout');
} finally {
process.env.NODE_ENV = originalEnv;
}
});
it('terminus 예외가 아닌 오류는 전역 필터로 넘긴다', async () => {
check.mockRejectedValue(new Error('예상치 못한 오류'));
await expect(controller.check(res as unknown as Response)).rejects.toThrow(
'예상치 못한 오류',
);
});
});
@@ -1,15 +1,76 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
Controller,
Get,
HttpStatus,
Logger,
Res,
ServiceUnavailableException,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
import {
HealthCheck,
HealthCheckResult,
HealthCheckService,
HealthIndicatorResult,
TypeOrmHealthIndicator,
} from '@nestjs/terminus';
import type { Response } from 'express';
// 헬스체크 엔드포인트 — Docker healthcheck, 모니터링 시스템에서 사용
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { isProductionEnv } from '@/shared/utils/env';
// DB ping 타임아웃 — 기본값 1000ms 는 너무 짧다.
// 커넥션 풀의 유휴 커넥션이 정리된 뒤(idleTimeoutMillis) 재연결하는 경우 1초를 넘겨
// 정상인 DB 를 down 으로 오판하고 헬스체크가 간헐적으로 실패한다.
const DB_PING_TIMEOUT_MS = 5_000;
/** 구성요소별 결과에서 status 만 남긴다 */
function keepStatusOnly(
section: Partial<HealthIndicatorResult> | undefined,
): HealthIndicatorResult {
return Object.fromEntries(
Object.entries(section ?? {}).flatMap(([key, value]) =>
value ? [[key, { status: value.status }]] : [],
),
);
}
/**
* 실패 결과에서 구성요소별 status 만 남기고 오류 문구(message 등)는 뺀다.
* 드라이버 오류 문구에는 내부 주소·DB 계정명이 담길 수 있다 ("connect ECONNREFUSED 172.18.0.2:5432").
* 어느 구성요소가 죽었는지는 그대로 보이므로 모니터링에는 지장이 없다. 원인은 서버 로그에 남는다.
*/
export function hideFailureDetails(
result: HealthCheckResult,
): HealthCheckResult {
return {
status: result.status,
info: keepStatusOnly(result.info),
error: keepStatusOnly(result.error),
details: keepStatusOnly(result.details),
};
}
/**
* 헬스체크 엔드포인트 — Docker healthcheck, 모니터링 시스템이 읽는다.
*
* 이 엔드포인트는 **사람이 아니라 기계가 소비**하므로 표준 응답 래퍼와 에러 코드 규칙에서 제외한다.
* (`{success,data}` 로 감싸거나 실패를 SYS_001 로 덮어쓰면 "어느 컴포넌트가 죽었는지"가 사라진다)
*
* - 성공 200: `{ status: 'ok', info: { database: { status: 'up' } }, ... }`
* - 실패 503: `{ status: 'error', error: { database: { status: 'down', message } }, ... }`
* (운영에서는 message 같은 상세를 빼고 구성요소별 status 만 노출한다 — 원인은 error 로그로 남긴다)
*
* 주기적으로 호출되므로 rate limit 대상에서도 제외한다 (한도를 소진시켜 실제 요청을 막으면 안 된다).
*/
@SkipThrottle()
@SkipTransform()
@ApiTags('Health')
@Controller('health')
export class HealthController {
private readonly logger = new Logger(HealthController.name);
constructor(
private readonly health: HealthCheckService,
private readonly db: TypeOrmHealthIndicator,
@@ -17,8 +78,34 @@ 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) {
const result = error.getResponse() as HealthCheckResult;
// 서버 책임 장애(DB 접속 실패 등)이므로 error 레벨 — 운영에서 응답에서 뺀 원인을 여기서 추적한다
this.logger.error(`헬스체크 실패: ${JSON.stringify(result.error)}`);
res.status(HttpStatus.SERVICE_UNAVAILABLE);
return isProductionEnv() ? hideFailureDetails(result) : result;
}
throw error;
}
}
}
@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
/** 업로드 결과 — 저장 경로(절대경로)는 노출하지 않는다 */
export class UploadResponseDto {
@ApiProperty({
description: '서버가 생성한 파일 식별자 (저장 파일명)',
example: 'b1f0c2e4-8a3d-4c7e-9f21-0d5a6b7c8e9f.png',
})
fileName!: string;
@ApiProperty({
description: '업로드 당시 원본 파일명',
example: '프로필.png',
})
originalName!: string;
@ApiProperty({ description: 'MIME 타입', example: 'image/png' })
mimeType!: string;
@ApiProperty({ description: '파일 크기 (바이트)', example: 20480 })
size!: number;
}
@@ -0,0 +1,68 @@
import fs, { existsSync, mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
allowedExtensionList,
ensureUploadDir,
isAllowedFile,
} from './upload.config';
describe('업로드 디렉터리 준비', () => {
let base: string;
beforeEach(() => {
base = mkdtempSync(join(tmpdir(), 'upload-dir-'));
});
afterEach(() => {
jest.restoreAllMocks();
rmSync(base, { recursive: true, force: true });
});
it('없는 디렉터리는 하위 경로까지 만든다', () => {
const dir = join(base, 'nested', 'uploads');
ensureUploadDir(dir);
expect(existsSync(dir)).toBe(true);
});
it('쓸 수 없으면 소유권을 맞추는 방법과 함께 기동을 멈춘다', () => {
// Linux 에서 root 소유로 만들어진 바인드 마운트를 흉내 낸다 (Windows 는 디렉터리 권한을 재현할 수 없다)
jest.spyOn(fs, 'accessSync').mockImplementation(() => {
throw new Error('EACCES: permission denied');
});
expect(() => ensureUploadDir(base)).toThrow(/sudo chown -R 1000:1000/);
});
});
describe('파일 업로드 검증', () => {
it('확장자와 MIME 이 모두 맞으면 통과한다', () => {
expect(isAllowedFile('photo.png', 'image/png')).toBe(true);
expect(isAllowedFile('scan.PDF', 'application/pdf')).toBe(true);
});
it('허용되지 않은 MIME 은 거부한다', () => {
expect(isAllowedFile('script.js', 'application/javascript')).toBe(false);
expect(isAllowedFile('page.html', 'text/html')).toBe(false);
});
it('MIME 은 허용이지만 확장자가 불일치하면 거부한다 (확장자 위장 방지)', () => {
expect(isAllowedFile('payload.html', 'image/png')).toBe(false);
expect(isAllowedFile('payload.exe', 'application/pdf')).toBe(false);
});
it('확장자가 없으면 거부한다', () => {
expect(isAllowedFile('nofile', 'image/png')).toBe(false);
});
it('실행 가능한 형식은 허용 목록에 없다', () => {
const allowed = allowedExtensionList();
for (const dangerous of ['.html', '.svg', '.js', '.exe', '.sh']) {
expect(allowed).not.toContain(dangerous);
}
});
});
@@ -0,0 +1,72 @@
import { accessSync, constants, mkdirSync } from 'fs';
import { extname } from 'path';
import { parsePositiveInt } from '@/shared/utils/env';
/**
* 파일 업로드 정책 (로컬 디스크 기준).
*
* 오브젝트 스토리지(S3 등)로 전환할 때는 저장 어댑터만 교체하고
* 검증 규칙(허용 형식·크기)은 그대로 재사용한다.
*/
/** 업로드 파일 저장 디렉터리 — 컨테이너에서는 compose 볼륨이 연결된다 */
export const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads';
/**
* 업로드 디렉터리를 만들고 **쓰기 가능한지** 확인한다 — 안 되면 기동을 멈춘다 (fail-fast).
*
* 컨테이너의 앱은 비-root(node, uid 1000)로 실행된다. Linux 서버에서 compose 가
* 없는 호스트 디렉터리를 바인드 마운트하면 root 소유로 만들어지므로,
* 그대로 두면 서버는 뜨지만 첫 업로드에서야 SYS_001 로 터진다.
*/
export function ensureUploadDir(dir: string): void {
try {
mkdirSync(dir, { recursive: true });
accessSync(dir, constants.W_OK);
} catch (error) {
throw new Error(
[
`업로드 디렉터리에 쓸 수 없어 서버를 시작할 수 없습니다: ${dir}`,
` (${error instanceof Error ? error.message : String(error)})`,
'컨테이너의 앱은 node(uid 1000)로 실행됩니다. 호스트의 업로드 디렉터리 소유자를 맞춰 주세요:',
' sudo chown -R 1000:1000 ./data/uploads # UPLOAD_DATA_DIR 을 바꿨다면 그 경로',
].join('\n'),
);
}
}
const MEGABYTE = 1024 * 1024;
/** 파일 1개 최대 크기 (기본 5MB) */
export const MAX_FILE_SIZE_BYTES =
parsePositiveInt(process.env.UPLOAD_MAX_SIZE_MB, 5) * MEGABYTE;
/**
* 허용 형식 — 확장자와 MIME 타입을 **둘 다** 검사한다.
* 확장자만 보면 위장이 쉽고, MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
*
* ⚠️ 실행 가능한 형식(.html, .svg, .js 등)은 넣지 말 것 —
* 저장된 파일을 그대로 서빙할 경우 XSS 로 이어진다.
*/
export const ALLOWED_FILE_TYPES: ReadonlyMap<string, readonly string[]> =
new Map([
['image/jpeg', ['.jpg', '.jpeg']],
['image/png', ['.png']],
['image/webp', ['.webp']],
['image/gif', ['.gif']],
['application/pdf', ['.pdf']],
]);
/** 허용 형식인지 검사한다 (확장자 ↔ MIME 조합이 일치해야 통과) */
export function isAllowedFile(originalName: string, mimeType: string): boolean {
const allowedExtensions = ALLOWED_FILE_TYPES.get(mimeType);
if (!allowedExtensions) return false;
return allowedExtensions.includes(extname(originalName).toLowerCase());
}
/** 사용자에게 안내할 허용 확장자 목록 */
export function allowedExtensionList(): string {
return [...ALLOWED_FILE_TYPES.values()].flat().join(', ');
}
@@ -0,0 +1,54 @@
import {
Controller,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { UploadResponseDto } from './dto/upload-response.dto';
import { UploadService } from './upload.service';
// 파일 업로드 샘플 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이 담당한다.
@ApiTags('Upload')
@Controller('files')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@Post()
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: '파일 업로드 (단일)' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
},
required: ['file'],
},
})
@ApiStandardResponse(UploadResponseDto, {
status: 201,
description: '업로드 성공',
})
@ApiErrorResponse('FILE_001', '허용되지 않는 파일 형식')
@ApiErrorResponse('FILE_002', '파일 크기 초과')
upload(@UploadedFile() file?: Express.Multer.File): UploadResponseDto {
if (!file) {
// 파일 없이 요청한 경우 — multer 는 오류를 내지 않으므로 여기서 막는다
throw new BusinessException('VAL_001', {
message: '업로드할 파일을 선택해 주세요.',
});
}
return this.uploadService.toResponse(file);
}
}
@@ -0,0 +1,53 @@
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { randomUUID } from 'crypto';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { BusinessException } from '@/common/errors/business.exception';
import {
MAX_FILE_SIZE_BYTES,
UPLOAD_DIR,
ensureUploadDir,
isAllowedFile,
} from './upload.config';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
// 저장 디렉터리는 기동 시 한 번 보장한다 (없거나 쓸 수 없으면 multer 가 매 요청 실패한다)
ensureUploadDir(UPLOAD_DIR);
@Module({
imports: [
MulterModule.register({
storage: diskStorage({
destination: UPLOAD_DIR,
filename: (_req, file, callback) => {
// ⚠️ 클라이언트가 보낸 파일명을 경로에 쓰지 않는다.
// '../../etc/passwd' 같은 값이 그대로 들어오면 경로 탈출이 된다.
// 서버가 생성한 UUID + 검증된 확장자만 사용한다.
const safeExtension = extname(file.originalname).toLowerCase();
callback(null, `${randomUUID()}${safeExtension}`);
},
}),
limits: {
fileSize: MAX_FILE_SIZE_BYTES,
// 파일 외 필드 수를 제한해 과도한 파싱을 막는다
files: 1,
fields: 10,
},
fileFilter: (_req, file, callback) => {
if (!isAllowedFile(file.originalname, file.mimetype)) {
// 에러 코드와 함께 거부한다 (전역 필터가 표준 응답으로 변환)
callback(new BusinessException('FILE_001'), false);
return;
}
callback(null, true);
},
}),
],
controllers: [UploadController],
providers: [UploadService],
exports: [UploadService],
})
export class UploadModule {}
@@ -0,0 +1,36 @@
import { Injectable, Logger } from '@nestjs/common';
import { unlink } from 'fs/promises';
import { join } from 'path';
import { UPLOAD_DIR } from './upload.config';
import { UploadResponseDto } from './dto/upload-response.dto';
// 업로드된 파일의 메타데이터를 다루는 계층.
// 저장 자체는 multer(diskStorage)가 수행하며, 여기서는 그 결과를 정리한다.
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
/** multer 가 저장한 파일 정보를 응답 DTO 로 변환한다 */
toResponse(file: Express.Multer.File): UploadResponseDto {
this.logger.log(
`파일 업로드 완료: ${file.filename} (${file.size} bytes, ${file.mimetype})`,
);
return {
fileName: file.filename,
// 원본 파일명은 표시용 메타데이터일 뿐이며 경로 조립에 사용하지 않는다
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
};
}
/**
* 저장된 파일을 삭제한다.
* 파일명은 서버가 생성한 값만 받아야 한다 — 외부 입력을 그대로 넘기면 경로 탈출 위험이 있다.
*/
async remove(fileName: string): Promise<void> {
await unlink(join(UPLOAD_DIR, fileName));
}
}
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
/** 사용자 응답 DTO — Swagger 스키마와 클라이언트 타입의 기준이 된다. */
export class UserResponseDto {
@ApiProperty({ description: '사용자 ID', example: 1 })
id!: number;
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
email!: string;
@ApiProperty({ description: '사용자 이름', example: '홍길동' })
name!: string;
}
+37 -6
View File
@@ -1,7 +1,22 @@
import { Body, Controller, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
// 사용자 도메인 컨트롤러 샘플 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('User')
@@ -10,18 +25,34 @@ import { CreateUserDto } from './dto/create-user.dto';
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@ApiOperation({ summary: '사용자 목록 조회 (커서 페이지네이션)' })
@ApiStandardResponse(UserResponseDto, {
status: 200,
description: '사용자 목록 조회 성공',
isArray: true,
})
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
findMany(@Query() query: CursorPaginationQueryDto) {
return this.userService.findMany(query.limit, query.cursor);
}
@Get(':id')
@ApiOperation({ summary: '사용자 단건 조회' })
@ApiResponse({ status: 200, description: '사용자 조회 성공' })
@ApiResponse({ status: 404, description: '사용자를 찾을 수 없음 (RES_001)' })
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.userService.findOne(id);
}
@Post()
@ApiOperation({ summary: '사용자 생성' })
@ApiResponse({ status: 201, description: '사용자 생성 성공' })
@ApiResponse({ status: 400, description: '입력값 검증 실패 (VAL_001)' })
@ApiStandardResponse(UserResponseDto, {
status: 201,
description: '사용자 생성 성공',
})
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
@ApiErrorResponse('BIZ_001', '이메일 중복 등 비즈니스 규칙 위반')
create(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
}
+41 -8
View File
@@ -1,24 +1,57 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
// 비즈니스 로직 계층 샘플 — 실제 구현 시 Repository 주입하여 데이터 접근
@Injectable()
export class UserService {
// 임시 인메모리 저장소 — Repository 도입 시 교체
private readonly users = new Map<number, { id: number; email: string; name: string }>();
// 임시 인메모리 저장소 — TypeORM Repository 도입 시 교체
private readonly users = new Map<number, UserResponseDto>();
findOne(id: number) {
findOne(id: number): UserResponseDto {
const user = this.users.get(id);
if (!user) {
// 404 → 전역 필터에서 RES_001 로 변환됨
throw new NotFoundException();
// 사용자 노출 실패는 항상 에러 코드와 함께 던진다.
throw new BusinessException('RES_001', { context: { userId: id } });
}
return user;
}
create(dto: CreateUserDto) {
/** 커서 기반 목록 조회 샘플 — 목록 API 는 항상 페이지네이션을 적용한다. */
findMany(limit: number, cursor?: string): PaginatedResult<UserResponseDto> {
const afterId = cursor ? Number(cursor) : 0;
const sorted = [...this.users.values()].sort((a, b) => a.id - b.id);
const page = sorted.filter((user) => user.id > afterId).slice(0, limit);
const last = page.at(-1);
const hasNext = last ? sorted.some((user) => user.id > last.id) : false;
return toPaginatedResult(page, {
hasNext,
nextCursor: hasNext && last ? String(last.id) : null,
total: sorted.length,
});
}
create(dto: CreateUserDto): UserResponseDto {
const duplicated = [...this.users.values()].some(
(user) => user.email === dto.email,
);
if (duplicated) {
// 도메인 고유 코드가 필요하면 error-codes.ts 의 DOMAIN_ERROR_CODES 에 등록 후 사용한다.
throw new BusinessException('BIZ_001', {
message: '이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.',
context: { email: dto.email },
});
}
const id = this.users.size + 1;
const user = { id, email: dto.email, name: dto.name };
const user: UserResponseDto = { id, email: dto.email, name: dto.name };
this.users.set(id, user);
return user;
}
@@ -0,0 +1,88 @@
import {
buildCorsOptions,
isValidOrigin,
parseCorsOrigins,
} from './cors.config';
/**
* enableCors 의 origin 콜백을 실행해 CORS 헤더를 부여하는지 돌려받는다.
* 차단할 때도 에러를 넘기지 않아야 한다 (에러는 500 이 되어 원인을 가린다).
*/
function isAllowed(
corsOrigin: string | undefined,
requestOrigin: string | undefined,
): boolean {
const { origin } = buildCorsOptions(corsOrigin);
if (typeof origin !== 'function') {
throw new Error('origin 은 콜백이어야 한다');
}
let allowed: unknown;
origin(requestOrigin, (error, result) => {
expect(error).toBeNull();
allowed = result;
});
return allowed === true;
}
describe('parseCorsOrigins', () => {
it('비어 있으면 빈 목록이다 (같은 도메인 기본 구성)', () => {
expect(parseCorsOrigins(undefined)).toEqual([]);
expect(parseCorsOrigins('')).toEqual([]);
expect(parseCorsOrigins(' , ')).toEqual([]);
});
it('콤마로 나누고 공백·끝 슬래시를 제거한다', () => {
expect(
parseCorsOrigins('https://a.myservice.io, https://b.myservice.io/ ,'),
).toEqual(['https://a.myservice.io', 'https://b.myservice.io']);
});
it('브라우저 Origin 헤더처럼 소문자로 맞춘다', () => {
expect(parseCorsOrigins('https://App.MyService.io')).toEqual([
'https://app.myservice.io',
]);
});
});
describe('isValidOrigin', () => {
it('scheme://host[:port] 만 오리진으로 인정한다', () => {
expect(isValidOrigin('https://app.myservice.io')).toBe(true);
expect(isValidOrigin('http://localhost:5173')).toBe(true);
});
it('경로가 붙거나 scheme 이 없거나 와일드카드면 오리진이 아니다', () => {
expect(isValidOrigin('https://app.myservice.io/api')).toBe(false);
expect(isValidOrigin('app.myservice.io')).toBe(false);
expect(isValidOrigin('*')).toBe(false);
});
});
describe('buildCorsOptions', () => {
it('화이트리스트가 비어 있으면 다른 오리진에 CORS 헤더를 주지 않는다', () => {
expect(isAllowed(undefined, 'https://other.io')).toBe(false);
expect(isAllowed('', 'https://other.io')).toBe(false);
});
it('Origin 헤더가 없는 요청(서버 간 통신)은 허용한다', () => {
expect(isAllowed(undefined, undefined)).toBe(true);
});
it('콤마로 나열한 오리진을 각각 허용한다', () => {
const corsOrigin = 'https://app.myservice.io, https://admin.myservice.io';
expect(isAllowed(corsOrigin, 'https://app.myservice.io')).toBe(true);
expect(isAllowed(corsOrigin, 'https://admin.myservice.io')).toBe(true);
expect(isAllowed(corsOrigin, 'https://other.io')).toBe(false);
});
it('개발용 localhost 를 암묵적으로 허용하지 않는다 (운영에서도 열려 버린다)', () => {
expect(isAllowed('https://app.myservice.io', 'http://localhost:5173')).toBe(
false,
);
});
it('인증 쿠키를 주고받을 수 있게 credentials 를 켠다', () => {
expect(buildCorsOptions('').credentials).toBe(true);
});
});
+59
View File
@@ -0,0 +1,59 @@
import type { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
/**
* CORS 설정 — main.ts(런타임)와 env.validation.ts(기동 검증)가 같은 파서를 공유한다.
*
* 기본 구성은 프론트와 API 가 **같은 도메인**이다. 브라우저는 `/api` 상대경로로만 호출하고
* 리버스 프록시(운영: Nginx Proxy Manager 의 Custom location, 개발: vite dev 프록시)가 백엔드로 넘긴다.
* 같은 출처 요청에는 CORS 가 적용되지 않으므로 **화이트리스트가 비어 있는 것이 정상**이다.
*
* 다른 오리진(별도 도메인의 프론트, Flutter Web 등)에서 호출해야 할 때만
* 루트 .env 의 CORS_ORIGIN 에 콤마로 나열한다.
*/
/** 오리진 형식 — `scheme://host[:port]`. 경로·쿼리가 붙으면 브라우저의 Origin 헤더와 절대 일치하지 않는다 */
const ORIGIN_PATTERN = /^https?:\/\/[^\s/?#]+$/;
/**
* CORS_ORIGIN 을 오리진 목록으로 파싱한다.
* 콤마로 나누고 공백·끝 슬래시를 제거한다. 브라우저는 Origin 을 소문자로 보내므로 소문자로 맞춘다.
*/
export function parseCorsOrigins(value: string | undefined): string[] {
if (value === undefined) return [];
return value
.split(',')
.map((entry) => entry.trim().replace(/\/+$/, '').toLowerCase())
.filter((entry) => entry !== '');
}
/** 브라우저가 보내는 Origin 헤더와 비교할 수 있는 형식인지 검사한다 */
export function isValidOrigin(origin: string): boolean {
return ORIGIN_PATTERN.test(origin);
}
/**
* `app.enableCors()` 옵션을 만든다.
*
* - Origin 헤더가 없는 요청(서버 간 통신·헬스체크)은 CORS 대상이 아니므로 허용한다.
* - 화이트리스트 밖이면 Error 대신 `false` 를 넘긴다. Error 는 500(SYS_001)이 되어 원인을 가리고,
* `false` 는 CORS 헤더만 빼서 **브라우저가** 막게 한다.
* 같은 출처 요청은 이 헤더 없이도 통과하므로 기본 구성(같은 도메인)에는 영향이 없다.
* - 개발용 localhost 를 여기에 하드코딩하지 않는다 — 운영에서도 그대로 열려 버린다.
*/
export function buildCorsOptions(
corsOrigin: string | undefined = process.env.CORS_ORIGIN,
): CorsOptions {
const allowedOrigins = new Set(parseCorsOrigins(corsOrigin));
return {
origin: (requestOrigin, callback) => {
callback(
null,
requestOrigin === undefined || allowedOrigins.has(requestOrigin),
);
},
// 다른 오리진을 허용한 경우에도 인증 쿠키를 주고받을 수 있게 한다
credentials: true,
};
}
@@ -0,0 +1,32 @@
import { buildDatabaseOptions } from './database.config';
/** 풀 설정은 드라이버 옵션(extra)으로 넘어간다 */
const poolMax = (): number =>
(buildDatabaseOptions().extra as { max: number }).max;
describe('buildDatabaseOptions — 커넥션 풀 크기', () => {
const original = process.env.DB_POOL_MAX;
afterEach(() => {
if (original === undefined) {
delete process.env.DB_POOL_MAX;
} else {
process.env.DB_POOL_MAX = original;
}
});
it('기본값은 10 이다 (PostgreSQL 기본 max_connections 100 을 앱 하나가 다 잡지 않게)', () => {
delete process.env.DB_POOL_MAX;
expect(poolMax()).toBe(10);
});
it('DB_POOL_MAX 로 조정할 수 있다', () => {
process.env.DB_POOL_MAX = '25';
expect(poolMax()).toBe(25);
});
it('빈 값이면 기본값을 쓴다 (compose 가 미정의 변수를 빈 문자열로 넘긴다)', () => {
process.env.DB_POOL_MAX = '';
expect(poolMax()).toBe(10);
});
});
@@ -0,0 +1,87 @@
import { join } from 'path';
import { DataSourceOptions } from 'typeorm';
import { isProductionEnv, parsePositiveInt } from '@/shared/utils/env';
/**
* DB 접속 설정 — 앱(TypeOrmModule)과 CLI(data-source.ts)가 **같은 설정을 공유**한다.
* 두 곳에 따로 적으면 반드시 어긋나므로 여기서만 정의한다.
*/
// 개발 환경 기본값 — **호스트에서 직접 실행할 때** 기준이다.
// docker 환경에서는 compose 가 DB_HOST=db 등을 항상 주입하므로 이 값들이 쓰이지 않고,
// 운영에서는 validateEnv 가 DB_* 를 필수로 요구한다.
const DEV_DB_DEFAULTS = {
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'project_db',
} as const;
/**
* 커넥션 풀 크기 기본값 — node-postgres 의 기본값과 같다.
*
* PostgreSQL 의 기본 max_connections 는 100 이다. 풀을 100 으로 두면 앱 하나가 접속을 다 잡아
* 마이그레이션 CLI·psql·두 번째 인스턴스가 "too many clients" 로 붙지 못한다.
* 인스턴스 수 × DB_POOL_MAX 에 여유분을 더한 값이 max_connections 를 넘지 않게 루트 .env 에서 조정한다.
*/
const DEFAULT_POOL_MAX = 10;
/** 커넥션 풀 타임아웃 */
const POOL_TIMEOUTS = {
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 30000,
} as const;
/**
* 스키마 자동 동기화 사용 여부.
*
* **기본값은 false** 다. synchronize 를 켜두면 개발 DB 가 항상 최신 스키마와 일치해
* `migration:generate` 가 "변경 없음"만 내놓는다. 그 상태로 운영에 배포하면
* (운영은 synchronize 가 꺼져 있고 실행할 마이그레이션도 없어) 테이블이 만들어지지 않는다.
*
* 스키마 변경은 마이그레이션으로 관리한다 — 개발/운영이 같은 경로를 쓰게 된다.
* 엔티티를 빠르게 실험할 때만 `DB_SYNCHRONIZE=true` 로 임시 활성화한다(개발 환경 한정).
*/
function shouldSynchronize(): boolean {
// 운영에서는 어떤 값을 주더라도 절대 허용하지 않는다 (스키마 자동 변경 → 데이터 손실 위험)
if (isProductionEnv()) return false;
return process.env.DB_SYNCHRONIZE === 'true';
}
/**
* 엔티티·마이그레이션 파일 경로.
* `__dirname` 기준이라 ts-node(src)와 빌드 산출물(dist) 양쪽에서 동작한다.
*/
function resolvePaths(baseDir: string) {
return {
entities: [join(baseDir, '..', '..', '**', '*.entity{.ts,.js}')],
migrations: [join(baseDir, '..', '..', 'migrations', '*{.ts,.js}')],
};
}
export function buildDatabaseOptions(): DataSourceOptions {
const isProduction = isProductionEnv();
const paths = resolvePaths(__dirname);
return {
type: 'postgres',
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,
synchronize: shouldSynchronize(),
// 운영 배포 시 미적용 마이그레이션을 자동 실행한다
migrationsRun: isProduction,
logging: !isProduction,
extra: {
...POOL_TIMEOUTS,
// compose 가 미정의 변수를 빈 문자열로 주입하므로 parsePositiveInt 로 방어한다
max: parsePositiveInt(process.env.DB_POOL_MAX, DEFAULT_POOL_MAX),
},
entities: paths.entities,
migrations: paths.migrations,
};
}
@@ -0,0 +1,179 @@
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();
});
it('DB 커넥션 풀 크기도 양의 정수여야 한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', DB_POOL_MAX: '0' }),
).toThrow(/DB_POOL_MAX 는 양의 정수/);
});
it('SWAGGER_ENABLED 는 true/false 만 받는다 (오타가 기본값으로 묻히지 않게)', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', SWAGGER_ENABLED: 'ture' }),
).toThrow(/SWAGGER_ENABLED 는 true 또는 false/);
expect(() =>
validateEnv({ NODE_ENV: 'development', SWAGGER_ENABLED: '' }),
).not.toThrow();
});
});
describe('CORS_ORIGIN 형식 (모든 환경)', () => {
const dev = (corsOrigin: string) => ({
NODE_ENV: 'development',
CORS_ORIGIN: corsOrigin,
});
it('비어 있으면 통과한다 (같은 도메인 기본 구성)', () => {
expect(() => validateEnv(dev(''))).not.toThrow();
});
it('콤마로 여러 오리진을 적을 수 있다 (끝 슬래시는 허용)', () => {
expect(() =>
validateEnv(dev('http://localhost:5173, https://admin.myservice.io/')),
).not.toThrow();
});
it('경로가 붙은 값은 Origin 헤더와 일치할 수 없으므로 실패한다', () => {
expect(() => validateEnv(dev('https://app.myservice.io/api'))).toThrow(
/CORS_ORIGIN 의 "https:\/\/app\.myservice\.io\/api" 는 오리진 형식이 아닙니다/,
);
});
it('scheme 이 없거나 와일드카드면 실패한다', () => {
expect(() => validateEnv(dev('app.myservice.io'))).toThrow(
/오리진 형식이 아닙니다/,
);
expect(() => validateEnv(dev('*'))).toThrow(/오리진 형식이 아닙니다/);
});
});
describe('운영 환경', () => {
it('정상 설정이면 통과한다', () => {
expect(() => validateEnv(prod())).not.toThrow();
});
it('같은 도메인 기본 구성(BACKEND_API_URL=/api, CORS_ORIGIN 비움)은 통과한다', () => {
expect(() =>
validateEnv(prod({ BACKEND_API_URL: '/api', CORS_ORIGIN: '' })),
).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('공개 주소에 로컬 주소가 남아 있으면 실패한다', () => {
expect(() =>
validateEnv(prod({ CORS_ORIGIN: 'http://localhost:5173' })),
).toThrow(/CORS_ORIGIN 가 운영에서 쓸 수 없는 주소입니다/);
expect(() =>
validateEnv(prod({ BACKEND_API_URL: 'http://127.0.0.1:3000/api' })),
).toThrow(/BACKEND_API_URL 가 운영에서 쓸 수 없는 주소입니다/);
});
it('예시 파일의 example.com 이 그대로면 실패한다', () => {
expect(() =>
validateEnv(prod({ CORS_ORIGIN: 'https://app.example.com' })),
).toThrow(/CORS_ORIGIN 가 운영에서 쓸 수 없는 주소입니다/);
});
it('실제 도메인이면 통과한다', () => {
expect(() =>
validateEnv(
prod({
CORS_ORIGIN: 'https://app.myservice.io',
BACKEND_API_URL: 'https://api.myservice.io/api',
}),
),
).not.toThrow();
});
it('ALLOW_LOCALHOST_URL=true 면 공개 주소 검사를 건너뛴다 (운영 이미지 로컬 검증용)', () => {
expect(() =>
validateEnv(
prod({
CORS_ORIGIN: 'http://localhost:5173',
BACKEND_API_URL: 'http://localhost:3000/api',
ALLOW_LOCALHOST_URL: 'true',
}),
),
).not.toThrow();
});
it('공개 주소 검사는 운영 환경에서만 동작한다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
CORS_ORIGIN: 'http://localhost:5173',
BACKEND_API_URL: 'http://localhost:3000/api',
}),
).not.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');
}
});
});
});
+172
View File
@@ -0,0 +1,172 @@
/**
* 환경변수 검증 (부팅 시 fail-fast).
*
* 설정이 잘못되면 **서버가 뜨지 않게** 한다.
* 조용히 기본값으로 굴러가다 운영에서 터지는 것보다, 기동 시점에 실패하는 편이 안전하다.
*
* `ConfigModule.forRoot({ validate })` 에 연결되어 있다 (app.module.ts).
*/
import { isValidOrigin, parseCorsOrigins } from './cors.config';
/** 운영 배포 전에 반드시 교체해야 하는 예시 값들 — `*.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',
'DB_POOL_MAX',
'THROTTLE_TTL_MS',
'THROTTLE_LIMIT',
] as const;
/** 값이 있다면 true/false 여야 하는 키 — 오타(ture 등)가 조용히 기본값으로 처리되지 않게 한다 */
const BOOLEAN_KEYS = ['SWAGGER_ENABLED'] as const;
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
const NO_PLACEHOLDER_IN_PRODUCTION = [
'DB_PASSWORD',
'REDIS_PASSWORD',
'JWT_SECRET',
] as const;
/**
* 운영에서 **공개 주소**여야 하는 키 (값이 있을 때만 검사한다).
*
* 기본 구성(같은 도메인)은 BACKEND_API_URL=/api · CORS_ORIGIN 비움이라 걸릴 값이 없다.
* API 를 별도 도메인으로 분리한 뒤 로컬 주소가 남은 채 배포되면 서버는 정상 기동하지만
* 브라우저가 API 에 닿지 못하거나 CORS 화이트리스트가 실제 도메인을 막아 **조용히** 실패한다 — 그 실수를 잡는다.
*/
const PUBLIC_URL_IN_PRODUCTION = ['CORS_ORIGIN', 'BACKEND_API_URL'] as const;
/** 공개 주소로 쓸 수 없는 값 — 로컬 주소와 예시 파일의 예시 도메인 */
const NON_PUBLIC_URL_PATTERNS = [
/localhost/i,
/127\.0\.0\.1/,
/\[::1\]/,
/0\.0\.0\.0/,
/example\.com/i,
];
/**
* 위 공개 주소 검사를 건너뛰는 탈출구.
* 로컬에서 **운영 이미지를 검증**할 때만 쓴다 (예: graceful shutdown 확인 — .claude/skills/docker/SKILL.md).
* 실제 배포에서는 절대 설정하지 않는다.
*/
const ALLOW_LOCALHOST_URL_KEY = 'ALLOW_LOCALHOST_URL';
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));
}
function looksNonPublic(value: string): boolean {
return NON_PUBLIC_URL_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}")`);
}
}
// 2. 참/거짓 값 — 비어 있으면 코드의 기본 동작을 따른다
for (const key of BOOLEAN_KEYS) {
const value = readString(config, key);
if (value !== undefined && value !== 'true' && value !== 'false') {
errors.push(
`${key} 는 true 또는 false 여야 합니다. (현재 값: "${value}")`,
);
}
}
// 3. CORS 오리진 형식 — 브라우저 Origin 헤더와 모양이 다르면 절대 일치하지 않아 **조용히** 막힌다
for (const origin of parseCorsOrigins(readString(config, 'CORS_ORIGIN'))) {
if (!isValidOrigin(origin)) {
errors.push(
`CORS_ORIGIN 의 "${origin}" 는 오리진 형식이 아닙니다. scheme://host[:port] 형태로 경로 없이 적어 주세요. (여러 개면 콤마로 구분)`,
);
}
}
if (isProduction) {
// 4. 운영 필수값 — 없으면 기동 실패
for (const key of REQUIRED_IN_PRODUCTION) {
if (readString(config, key) === undefined) {
errors.push(`${key} 가 설정되지 않았습니다. (운영 환경 필수)`);
}
}
// 5. 예시 파일의 placeholder 가 그대로 배포되는 것을 막는다
for (const key of NO_PLACEHOLDER_IN_PRODUCTION) {
const value = readString(config, key);
if (value !== undefined && looksLikePlaceholder(value)) {
errors.push(
`${key} 가 예시 값 그대로입니다. 운영용 값으로 교체해 주세요.`,
);
}
}
// 6. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다
const allowLocalhostUrl =
readString(config, ALLOW_LOCALHOST_URL_KEY) === 'true';
if (!allowLocalhostUrl) {
for (const key of PUBLIC_URL_IN_PRODUCTION) {
const value = readString(config, key);
if (value !== undefined && looksNonPublic(value)) {
errors.push(
`${key} 가 운영에서 쓸 수 없는 주소입니다. 실제 도메인으로 교체해 주세요. (현재 값: "${value}")` +
` — 로컬에서 운영 이미지를 검증하는 중이라면 ${ALLOW_LOCALHOST_URL_KEY}=true`,
);
}
}
}
}
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,24 @@
import { isSwaggerEnabled } from './swagger.config';
describe('isSwaggerEnabled', () => {
it('운영에서는 기본으로 끈다 (API 구조 전체가 공개되므로)', () => {
expect(isSwaggerEnabled({ NODE_ENV: 'production' })).toBe(false);
expect(
isSwaggerEnabled({ NODE_ENV: 'production', SWAGGER_ENABLED: '' }),
).toBe(false);
});
it('운영이 아니면 기본으로 켠다', () => {
expect(isSwaggerEnabled({ NODE_ENV: 'development' })).toBe(true);
expect(isSwaggerEnabled({})).toBe(true);
});
it('SWAGGER_ENABLED 를 명시하면 환경과 무관하게 따른다', () => {
expect(
isSwaggerEnabled({ NODE_ENV: 'production', SWAGGER_ENABLED: 'true' }),
).toBe(true);
expect(
isSwaggerEnabled({ NODE_ENV: 'development', SWAGGER_ENABLED: 'false' }),
).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { isProductionEnv } from '@/shared/utils/env';
/**
* Swagger UI 경로.
* 같은 도메인 구성에서는 리버스 프록시의 location /api 접두어 매칭에 함께 걸려 백엔드로 간다.
*/
export const SWAGGER_PATH = 'api-docs';
/**
* Swagger 문서 노출 여부.
* 문서는 API 구조 전체(경로·파라미터·스키마)를 보여주므로 **운영에서는 기본으로 끈다.**
*
* - SWAGGER_ENABLED=true → 항상 켠다 (운영에서 켤 때는 NPM Access List 등으로 접근을 제한한다)
* - SWAGGER_ENABLED=false → 항상 끈다
* - 비어 있음 → 운영이 아니면 켠다
*/
export function isSwaggerEnabled(
env: NodeJS.ProcessEnv = process.env,
): boolean {
const flag = env.SWAGGER_ENABLED?.trim();
if (flag === 'true') return true;
if (flag === 'false') return false;
return !isProductionEnv(env);
}
/** Swagger 문서를 SWAGGER_PATH 에 붙인다 */
export function setupSwagger(app: INestApplication): void {
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(SWAGGER_PATH, app, document);
}
@@ -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 ?? '애플리케이션 종료 요청'})`,
);
}
}
+27
View File
@@ -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);
});
});
+30
View File
@@ -0,0 +1,30 @@
/**
* 환경변수 파싱 유틸 (순수 함수).
*
* 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;
}
/**
* 운영 환경인지 확인한다.
* NODE_ENV 는 compose 가 루트 APP_ENV 로 주입한다 (development | production).
*/
export function isProductionEnv(env: NodeJS.ProcessEnv = process.env): boolean {
return env.NODE_ENV === 'production';
}
+109 -9
View File
@@ -1,25 +1,125 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
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 { configureApp } from '@/app.setup';
/**
* 세 플랫폼이 공유하는 응답 계약을 실제 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.setup.ts).
// 여기서 설정을 따로 흉내 내면 둘이 어긋나도 테스트는 통과해 버린다.
// 추적 ID 는 AppModule 의 RequestIdMiddleware 가 붙인다 — 운영과 같은 경로다.
configureApp(app);
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');
});
});
+10 -3
View File
@@ -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"
}
+3
View File
@@ -14,6 +14,9 @@
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
"incremental": true,
"skipLibCheck": true,
"strict": true,
+20
View File
@@ -0,0 +1,20 @@
# ==========================================
# Gitea Actions 러너 설정
#
# 사용법: cp .env.example .env 후 토큰을 채운다.
# ⚠️ .env 는 커밋되지 않는다 (루트 .gitignore). 토큰을 이 예시 파일에 적지 말 것.
# ==========================================
# Gitea 인스턴스 주소 (끝에 / 없이)
GITEA_INSTANCE_URL=https://git.wandergo.net
# 등록 토큰 — 저장소 Settings → Actions → Runners → Create new Runner 에서 발급
# (여러 저장소에서 공용으로 쓰려면 관리자 화면의 인스턴스 레벨 토큰을 사용)
GITEA_RUNNER_REGISTRATION_TOKEN=
# 러너 식별 이름 — 여러 대를 띄운다면 구분되게 짓는다
RUNNER_NAME=gitea-runner
# 라벨: <워크플로의 runs-on>:docker://<잡을 실행할 이미지>
# ci.yml 이 ubuntu-latest 를 쓰므로 그대로 두면 된다.
RUNNER_LABELS=ubuntu-latest:docker://gitea/runner-images:ubuntu-latest
+103
View File
@@ -0,0 +1,103 @@
# CI 러너 설정
`.github/workflows/ci.yml` 을 실제로 실행하는 Gitea Actions 러너 설정입니다.
**러너가 없으면 워크플로는 "Waiting for a runner" 상태로 대기만 합니다.**
## 어디에 띄울 것인가
| 위치 | 장점 | 단점 |
| --- | --- | --- |
| **Gitea 서버** (권장) | 항상 켜져 있어 CI 가 늘 동작 | 서버에 Docker 필요 |
| 개발 PC | 바로 시작 가능 | PC 가 꺼지면 CI 도 멈춤 |
CI 의 목적이 "내가 없어도 검사되는 것"이므로 서버 쪽을 권장합니다.
## 설치
```bash
# 1) 저장소 Settings → Actions → Runners → Create new Runner 에서 토큰 발급
# 2) 설정 파일 준비
cd ci
cp .env.example .env # 편집기로 열어 GITEA_RUNNER_REGISTRATION_TOKEN 채우기
# 3) 기동
docker compose -f docker-compose.runner.yml --env-file .env up -d
# 4) 등록 확인
docker compose -f docker-compose.runner.yml --env-file .env logs -f runner
# "Runner registered successfully" 가 보이면 성공
```
저장소 **Settings → Actions → Runners** 에서 러너가 **Idle** 로 표시되면 준비 완료입니다.
이후 푸시하면 Actions 탭에서 실행이 시작됩니다.
## 잘 안 될 때
| 증상 | 원인 | 해결 |
| --- | --- | --- |
| 잡이 계속 대기 중 | 라벨 불일치 | `RUNNER_LABELS` 의 `ubuntu-latest` 가 워크플로 `runs-on` 과 같은지 확인 |
| `uses:` 액션 다운로드 실패 | 서버에서 github.com 접근 불가 | 방화벽 확인. 폐쇄망이면 액션 미러 필요 |
| 등록 실패 (401/404) | 토큰 만료·오타, URL 끝 `/` | 토큰 재발급, `GITEA_INSTANCE_URL` 확인 |
| 잡 시작 직후 실패 | 도커 소켓 마운트 누락 | `/var/run/docker.sock` 마운트 확인 (Windows 는 `//var/run/docker.sock`) |
| **모든 스텝이 한꺼번에 실패**(checkout 포함) | 서비스 컨테이너가 뜨지 못함 | 아래 "서비스 컨테이너" 참고 |
## 서비스 컨테이너 주의 (GitHub Actions 와 다른 점)
Gitea 러너는 잡을 **컨테이너로** 실행하고 서비스와 같은 네트워크에 붙인다.
GitHub Actions 는 잡이 VM 위에서 직접 돌기 때문에 관례가 다르다.
| | GitHub Actions | Gitea act_runner |
| --- | --- | --- |
| 서비스 접근 주소 | `localhost` + `ports` 매핑 | **서비스 이름** (`postgres:5432`) |
| `ports:` 필요 여부 | 필요 | **불필요 — 오히려 해롭다** |
`ports: - 5432:5432` 를 두면 호스트 포트를 점유하려 하는데,
개발 DB 가 이미 그 포트를 쓰고 있으면 **잡이 시작조차 못 하고 모든 스텝이 failure 로 표시된다.**
(스텝이 실패한 게 아니라 잡 준비 단계에서 죽은 것이다)
`ci.yml` 의 backend 잡은 이 규칙에 맞춰 `ports` 없이 `DB_HOST: postgres` 를 쓴다.
## 리소스 참고
워크플로는 두 개로 나뉘어 있습니다.
**`ci.yml`** — 모든 푸시·PR 에서 실행
- `backend` — lint·타입 검사 후 postgres 서비스 컨테이너를 띄워 단위 테스트와 e2e 를 실행
- `frontend` — lint·type-check·테스트
- `error-codes-sync` — 에러 코드 생성 파일 드리프트 검사
**`ci-flutter.yml`** — **기본적으로 돌지 않는다**
Flutter 는 SDK 다운로드로 매 실행 수 분이 걸리는데, backend/frontend 만 고친 커밋에서는
검사할 것이 없다. 그래서 다음 경우에만 실행된다.
- `flutter/` 아래 파일이 바뀐 푸시·PR — **실제로 Flutter 를 쓰기 시작하면 자동으로 동작한다**
- Actions 탭의 **Run workflow** 버튼으로 수동 실행
모바일 앱을 쓰지 않는 프로젝트라면 `ci-flutter.yml` 을 지우면 된다.
> 러너는 기본적으로 잡을 **하나씩** 처리한다. 전체 시간을 줄이려면 러너를 여러 개 띄우거나
> `act_runner` 설정에서 동시 실행 수를 늘린다.
## 잡이 멈출 때
컨테이너 환경에서 잡이 간헐적으로 멈추는 경우가 있다 (CPU 0%, 네트워크 정지 상태로 진행 없음).
러너가 하나뿐이면 **멈춘 잡 하나가 이후 모든 잡을 굶긴다.**
그래서 모든 잡에 `timeout-minutes` 를 둔다. 상한에 걸리면 강제 종료되고 다음 잡으로 넘어간다.
새 잡을 추가할 때도 반드시 상한을 지정할 것.
멈춘 잡을 즉시 해제하려면 해당 컨테이너를 중지하면 된다 (러너가 실패로 처리하고 다음 잡을 집는다).
```bash
docker ps --format "{{.Names}}" | grep GITEA-ACTIONS # 실행 중인 잡 컨테이너
docker stop <컨테이너명>
```
## 주의
- **토큰은 `ci/.env` 에만** 둡니다. 이 파일은 루트 `.gitignore` 로 커밋되지 않습니다.
- `runner-data/` 에 등록 정보가 저장됩니다. 지우면 토큰으로 재등록해야 합니다.
- 러너는 도커 소켓에 접근하므로 **호스트에 대한 사실상의 관리자 권한**을 갖습니다.
신뢰할 수 있는 저장소의 워크플로만 실행되도록 하세요 (공개 저장소의 PR 실행 정책 확인).
+26
View File
@@ -0,0 +1,26 @@
# Gitea Actions 러너 — CI 를 실제로 실행하는 주체
#
# 이 파일은 애플리케이션 스택(루트 docker-compose.yml)과 **생명주기가 다르다.**
# 앱은 개발할 때만 띄우지만 러너는 항상 켜져 있어야 CI 가 동작한다.
#
# 사용법은 ci/README.md 참조.
services:
runner:
image: gitea/act_runner:latest
container_name: ${RUNNER_NAME:-gitea-runner}
restart: always
environment:
GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL}
# ⚠️ 등록 토큰 — ci/.env 에만 두고 절대 커밋하지 않는다
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
GITEA_RUNNER_NAME: ${RUNNER_NAME:-gitea-runner}
# ⚠️ 워크플로의 runs-on 과 일치해야 잡을 집는다.
# .github/workflows/ci.yml 은 모든 잡이 ubuntu-latest 다.
GITEA_RUNNER_LABELS: ${RUNNER_LABELS:-ubuntu-latest:docker://gitea/runner-images:ubuntu-latest}
volumes:
# 잡을 컨테이너로 띄우므로 도커 소켓이 필요하다.
# (Windows Docker Desktop 에서 Git Bash 를 쓰면 경로 변환 때문에 //var/run/... 로 적어야 할 수 있다)
- /var/run/docker.sock:/var/run/docker.sock
# 등록 정보를 보존한다 — 지우면 토큰으로 재등록해야 한다
- ./runner-data:/data
-35
View File
@@ -1,35 +0,0 @@
# 실행 / 배포 명령어
> Docker Compose 기반 로컬·운영 오케스트레이션 명령어 모음.
> `.env.development` / `.env.production` 는 커밋된 `.env.*.example` 를 복사해 생성한다.
> (`.gitignore` 는 `.env.*` 를 무시하되 `.env.*.example` 만 추적한다.)
## 개발 환경
```bash
# 코드만 수정했을 때 (의존성 변경 없음)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
# 의존성 변경 시에만 --build
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build
# 로그 확인 (예: backend)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs -f backend
```
## 운영 환경
```bash
# 운영 환경 배포
sudo docker compose --env-file .env.production up -d --build
# 로그 확인
sudo docker compose --env-file .env.production logs -f backend
```
## 외부 노출 (선택)
```bash
# ngrok 으로 백엔드 포트(3000) 임시 노출 — <YOUR_DOMAIN> 을 본인 도메인으로 교체
ngrok http --domain=<YOUR_DOMAIN>.ngrok.app 3000
```
+40 -6
View File
@@ -1,13 +1,38 @@
services:
# ----------------------------------------
# frontend — dev 에서는 기본 기동하지 않는다 (profile 로 숨김)
#
# vite 를 컨테이너에서 돌리면 소스가 Windows 바인드 마운트(./frontend:/app) 너머에 있어
# 수천 개 파일을 매 요청마다 가상 파일시스템으로 읽는다. 여기에 폴링 감시까지 겹쳐
# 첫 서빙까지 수 분이 걸린다. 호스트에서 직접 띄우면 같은 작업이 1~2초다.
#
# cd frontend && npm install && npm run dev # → http://localhost:5173
#
# vite dev 프록시가 /api 를 localhost:${BACKEND_PORT} 로 넘기므로 백엔드는 컨테이너 그대로 쓴다.
# 그래도 컨테이너로 띄워야 한다면 (호스트에 Node 를 깔 수 없는 환경 등):
# docker compose ... --profile frontend up -d
# 또는 루트 .env.development 에 COMPOSE_PROFILES=frontend
# ----------------------------------------
frontend:
profiles: ["frontend"]
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (수동 up 때만 실행)
restart: "no"
build:
target: build-stage
command: npm run dev -- --host 0.0.0.0 --port 80
# 비-root 구동이라 1024 미만 포트를 쓸 수 없다 → vite 기본 포트 사용
command: npm run dev -- --host 0.0.0.0 --port 5173
ports:
- "${FRONTEND_PORT}:80"
- "${FRONTEND_PORT}:5173"
# dev 는 nginx(8080)가 아니라 vite(5173)를 띄우므로 base 의 헬스체크를 덮어쓴다
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5173/ || exit 1"]
environment:
# 바인드 마운트 너머의 변경은 inotify 가 잡지 못하므로 컨테이너에서만 폴링을 켠다
# (vite.config.ts 가 이 값을 보고 watch.usePolling 을 결정한다 — 호스트 실행 시엔 꺼진다)
- CHOKIDAR_USEPOLLING=true
- WATCHPACK_POLLING=true
# vite dev 서버가 /api 요청을 백엔드 컨테이너로 프록시한다 (vite.config.ts)
- VITE_DEV_API_PROXY=http://backend:${BACKEND_PORT}
volumes:
- ./${FRONTEND_DIR}:/app
- /app/node_modules
@@ -16,30 +41,39 @@ services:
- app-network
backend:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (수동 up 때만 실행)
restart: "no"
build:
target: builder
command: npm run start:dev
ports:
- "${BACKEND_PORT}:${BACKEND_PORT}"
- "${WEBSOCKET_PORT}:${WEBSOCKET_PORT}"
environment:
- CHOKIDAR_USEPOLLING=true
- WATCHPACK_POLLING=true
volumes:
- ./${BACKEND_DIR}:/app
- /app/node_modules
# base 의 업로드 볼륨을 소스 바인드 마운트 위에 다시 얹는다
- ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads
# dev에서는 리버스 프록시 미사용 — base의 networks 목록을 덮어씀
networks: !override
- app-network
db:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
restart: "no"
# 로컬 DB 툴 접속용. 127.0.0.1 로 한정해 외부 네트워크에 노출되지 않게 한다 (보안 규칙)
ports:
- "${DB_PORT}:5432"
- "127.0.0.1:${DB_PORT}:5432"
redis:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
restart: "no"
# 로컬 Redis CLI 접속용. 127.0.0.1 로 한정 (보안 규칙)
ports:
- "${REDIS_PORT}:6379"
- "127.0.0.1:${REDIS_PORT}:6379"
# base의 proxy 외부 네트워크 의존을 dev에서 제거
networks:
proxy: !reset null
proxy: !reset null
+58 -10
View File
@@ -1,6 +1,16 @@
# ==========================================
# 운영 라우팅 — 프론트와 API 는 같은 도메인을 쓴다
#
# https://<도메인>/ → frontend:8080 (Nginx Proxy Manager Proxy Host 의 기본 대상)
# https://<도메인>/api/... → backend:${BACKEND_PORT} (같은 Proxy Host 의 Custom location /api)
#
# 브라우저는 /api 상대경로로만 호출하므로 CORS 가 필요 없다.
# NPM 은 아래 proxy 외부 네트워크로 컨테이너 이름(${PROJECT_NAME}-frontend / -backend)에 접근한다.
# 설정 절차는 README "운영 배포" 참조.
# ==========================================
services:
# ----------------------------------------
# 1. Frontend (Nginx 정적 호스팅)
# 1. Frontend (Nginx 정적 호스팅) — NPM Proxy Host 의 기본 대상(/)
# ----------------------------------------
frontend:
container_name: ${PROJECT_NAME}-frontend
@@ -9,9 +19,10 @@ services:
dockerfile: Dockerfile
args:
- BUILD_MODE=${BUILD_MODE}
- VITE_API_BASE_URL=${BACKEND_API_URL}
# 같은 도메인 구성이면 /api 그대로 — 도메인이 바뀌어도 이미지를 다시 빌드할 필요가 없다
- VITE_API_BASE_URL=${BACKEND_API_URL:-/api}
expose:
- "80"
- "8080"
environment:
- NODE_ENV=${APP_ENV}
restart: unless-stopped
@@ -21,7 +32,8 @@ services:
depends_on:
- backend
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"]
# localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 nginx 는 IPv4 만 바인딩하므로 127.0.0.1 을 명시한다
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ || exit 1"]
interval: 30s
timeout: 5s
retries: 5
@@ -33,7 +45,10 @@ services:
memory: 512m
# ----------------------------------------
# 2. Backend (HTTP API & WebSocket 통합)
# 2. Backend (HTTP API) — NPM Custom location /api 의 대상
#
# WebSocket 을 도입할 때도 별도 포트를 열지 않는다. 게이트웨이를 HTTP 서버에 붙이고
# 경로를 /api 아래에 두면 같은 도메인·같은 location 을 그대로 탄다 (.claude/skills/nestjs/SKILL.md)
# ----------------------------------------
backend:
container_name: ${PROJECT_NAME}-backend
@@ -42,22 +57,44 @@ services:
dockerfile: Dockerfile
expose:
- "${BACKEND_PORT}"
- "${WEBSOCKET_PORT}"
# PID 1 을 tini 로 두어 SIGTERM 이 애플리케이션까지 확실히 전달되게 한다 (graceful shutdown)
init: true
# 처리 중인 요청을 마칠 시간 — 초과하면 SIGKILL (기본 10초)
stop_grace_period: 30s
env_file:
- ./${BACKEND_DIR}/.env.${APP_ENV}
environment:
- NODE_ENV=${APP_ENV}
- PORT=${BACKEND_PORT}
- WEBSOCKET_PORT=${WEBSOCKET_PORT}
- CORS_ORIGIN=${CORS_ORIGIN}
# 비어 있으면 운영에서만 Swagger(/api-docs)를 끈다 — true/false 로 강제할 수 있다
- SWAGGER_ENABLED=${SWAGGER_ENABLED:-}
# 같은 도메인 구성에서는 비어 있다 — 다른 오리진에서 호출할 때만 콤마로 나열
- CORS_ORIGIN=${CORS_ORIGIN:-}
# 앱이 직접 쓰지는 않는다. API 를 별도 도메인으로 분리했을 때 로컬 주소가 남은 채
# 운영에 배포되는 것을 기동 시점에 잡기 위해 검증용으로 주입한다 (shared/config/env.validation.ts)
- BACKEND_API_URL=${BACKEND_API_URL:-/api}
# 위 공개 주소 검사를 건너뛰는 탈출구 — 로컬에서 운영 이미지를 검증할 때만 true
- ALLOW_LOCALHOST_URL=${ALLOW_LOCALHOST_URL:-}
- THROTTLE_TTL_MS=${THROTTLE_TTL_MS:-60000}
- THROTTLE_LIMIT=${THROTTLE_LIMIT:-100}
- DB_HOST=db
- DB_PORT=5432
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- DB_POOL_MAX=${DB_POOL_MAX:-10}
- DB_SYNCHRONIZE=${DB_SYNCHRONIZE:-false}
- UPLOAD_DIR=/app/uploads
- UPLOAD_MAX_SIZE_MB=${UPLOAD_MAX_SIZE_MB:-5}
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD}
# 업로드 파일은 컨테이너 재생성과 무관하게 보존되어야 한다
# ⚠️ Linux 서버: 앱은 node(uid 1000)로 돈다. 호스트 디렉터리가 없으면 Docker 가 root 소유로 만들어
# 기동이 실패하므로(업로드 디렉터리 쓰기 검사) 처음 한 번 소유자를 맞춘다:
# mkdir -p data/uploads && sudo chown 1000:1000 data/uploads
volumes:
- ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads
restart: unless-stopped
networks:
- app-network
@@ -65,10 +102,13 @@ services:
depends_on:
db:
condition: service_healthy
# redis 는 profile 로 비활성화될 수 있으므로 선택적 의존으로 둔다
redis:
condition: service_healthy
required: false
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:${BACKEND_PORT}/ || exit 1"]
# localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 Node 는 0.0.0.0(IPv4) 바인딩이므로 127.0.0.1 을 명시한다
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:${BACKEND_PORT}/ || exit 1"]
interval: 30s
timeout: 5s
retries: 5
@@ -111,9 +151,14 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
# ----------------------------------------
# 4. Redis — 내부 네트워크 전용
# 4. Redis — 내부 네트워크 전용 · 기본 비활성
#
# 기본적으로 기동하지 않는다. 캐시/세션 공유가 실제로 필요해질 때 켠다.
# 루트 .env.{APP_ENV} 에서 COMPOSE_PROFILES=redis
# 또는 일회성으로 docker compose --profile redis up -d
# ----------------------------------------
redis:
profiles: ["redis"]
container_name: ${PROJECT_NAME}-redis
image: ${REDIS_IMAGE}
command: redis-server --requirepass ${REDIS_PASSWORD}
@@ -141,5 +186,8 @@ services:
networks:
app-network:
driver: bridge
# 리버스 프록시(Nginx Proxy Manager)와 공유하는 외부 네트워크 — frontend·backend 만 붙는다 (db·redis 는 내부 전용)
# compose 가 만들지 않으므로 서버에서 한 번 만들어 두어야 기동한다: docker network create proxy
# NPM 은 컨테이너 이름으로 찾아오므로 같은 서버의 프로젝트끼리 PROJECT_NAME 이 겹치면 안 된다.
proxy:
external: true
+1
View File
@@ -9,4 +9,5 @@
# 백엔드 API 베이스 URL (백엔드 글로벌 prefix /api 포함)
# 안드로이드 에뮬레이터에서 호스트 접근 시: http://10.0.2.2:3000/api
# 운영: 웹과 같은 도메인의 /api — 예) https://app.your-domain.com/api
API_URL=http://localhost:3000/api
+36 -24
View File
@@ -1,28 +1,40 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# Dart 정적 분석 설정.
# .claude/skills/flutter/SKILL.md 의 규칙 중 기계적으로 강제 가능한 것을 여기에 담는다.
# 실행: flutter analyze (Warning 0 이어야 커밋 가능 — .claude/rules/checklist.md)
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
analyzer:
errors:
# 규칙 위반을 경고가 아닌 에러로 승격해 CI 에서 확실히 실패시킨다
avoid_dynamic_calls: error
prefer_const_constructors: error
unawaited_futures: error
exclude:
# 자동 생성 파일은 생성 스크립트가 형식을 보장한다
- "**/*.g.dart"
- "**/*.freezed.dart"
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
linter:
rules:
# --- 타입 안전성 (dynamic 지양) ---
avoid_dynamic_calls: true
# --- 위젯 성능 (const 적극 활용) ---
prefer_const_constructors: true
prefer_const_constructors_in_immutables: true
prefer_const_declarations: true
prefer_const_literals_to_create_immutables: true
# --- 비동기 안전성 ---
unawaited_futures: true
use_build_context_synchronously: true
# --- 코드 스타일 일관성 ---
prefer_single_quotes: true
prefer_final_locals: true
directives_ordering: true
require_trailing_commas: true
# --- 로깅 규칙 (print 금지 — .claude/rules/logging.md) ---
avoid_print: true
+42
View File
@@ -0,0 +1,42 @@
import 'error_lexicon.dart';
/// 정규화된 API 예외 — 화면에서는 [code] 로 분기하고 [message] 를 그대로 노출한다.
///
/// 모든 실패는 반드시 에러 코드를 가진다(코드 없는 에러를 만들 수 없도록 필수 인자).
class ApiException implements Exception {
const ApiException({
required this.code,
required this.message,
this.traceId,
this.statusCode,
});
/// 에러 코드 (예: AUTH_001, RES_001)
final String code;
/// 사용자에게 노출할 한국어 메시지
final String message;
/// 서버 요청 추적 ID — 문의 대응 시 로그 검색 키
final String? traceId;
/// 원본 HTTP 상태 코드 (네트워크 오류 등은 null)
final int? statusCode;
/// 코드만으로 예외를 만든다(메시지는 사전에서 조회).
factory ApiException.fromCode(
String code, {
String? traceId,
int? statusCode,
}) {
return ApiException(
code: code,
message: ErrorLexicon.getMessage(code),
traceId: traceId,
statusCode: statusCode,
);
}
@override
String toString() => 'ApiException($code): $message';
}
+99 -46
View File
@@ -1,7 +1,14 @@
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'error_lexicon.dart';
import 'api_exception.dart';
/// 토큰 저장 키 — 앱 전체에서 이 상수만 사용한다.
const String kJwtTokenKey = 'jwt_token';
const Duration _kConnectTimeout = Duration(seconds: 15);
const Duration _kReceiveTimeout = Duration(seconds: 15);
class DioClient {
static final DioClient _instance = DioClient._internal();
@@ -18,15 +25,15 @@ class DioClient {
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
connectTimeout: _kConnectTimeout,
receiveTimeout: _kReceiveTimeout,
));
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
// Token Storage 정책: SecureStorage 에서 JWT 읽어 헤더에 부착
final token = await _storage.read(key: 'jwt_token');
final token = await _storage.read(key: kJwtTokenKey);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
@@ -35,61 +42,107 @@ class DioClient {
onResponse: (response, handler) {
final payload = response.data;
if (payload is Map<String, dynamic> && payload.containsKey('success')) {
if (payload is Map<String, dynamic> &&
payload.containsKey('success')) {
if (payload['success'] == true) {
// Global Response Wrapper: 데이터 Unwrap
response.data = payload['data'];
return handler.next(response);
} else {
// HTTP 200 OK 하에서의 비즈니스 실패 로직
final errorCode = payload['error']?['code'] ?? 'BIZ_001';
return handler.reject(
DioException(
requestOptions: response.requestOptions,
error: ErrorLexicon.getMessage(errorCode),
type: DioExceptionType.badResponse,
),
);
}
// HTTP 200 OK 하에서의 비즈니스 실패 (백엔드는 4xx 로 내리지만 방어적으로 처리)
final error = _readErrorObject(payload);
return handler.reject(
DioException(
requestOptions: response.requestOptions,
error: ApiException.fromCode(
error.code ?? 'BIZ_001',
traceId: error.traceId,
statusCode: response.statusCode,
),
type: DioExceptionType.badResponse,
),
);
}
return handler.next(response);
},
onError: (DioException e, handler) {
String errorCode = 'SYS_001';
onError: (DioException e, handler) async {
final apiException = await _toApiException(e);
if (e.response != null && e.response?.data is Map<String, dynamic>) {
final payload = e.response!.data as Map<String, dynamic>;
// 1. 서버에서 표준 에러 객체를 보낸 경우
if (payload['error'] != null && payload['error']['code'] != null) {
errorCode = payload['error']['code'];
} else {
// 2. 서버 에러 객체가 없는 경우 HTTP 상태 코드 기반 매핑
final statusCode = e.response?.statusCode;
if (statusCode == 401) {
errorCode = 'AUTH_001';
} else if (statusCode == 403) {
errorCode = 'AUTH_003';
} else if (statusCode == 400 || statusCode == 422) {
errorCode = 'VAL_001';
} else if (statusCode == 404) {
errorCode = 'RES_001';
}
}
} else if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
errorCode = 'SYS_001';
}
final errorMessage = ErrorLexicon.getMessage(errorCode);
// User-Friendly Error UI를 위해 에러 메시지 덮어쓰기
final customError = e.copyWith(error: errorMessage);
return handler.next(customError);
// 화면에서 e.error as ApiException 으로 코드/메시지를 함께 사용한다.
return handler.next(e.copyWith(error: apiException));
},
),
);
}
/// DioException 을 에러 코드가 확정된 ApiException 으로 정규화한다.
Future<ApiException> _toApiException(DioException e) async {
final response = e.response;
if (response == null) {
// 네트워크 단절·타임아웃 등 서버 응답 자체가 없는 경우
return ApiException.fromCode('SYS_001');
}
String? traceId;
String? code;
if (response.data is Map<String, dynamic>) {
final error = _readErrorObject(response.data as Map<String, dynamic>);
code = error.code;
traceId = error.traceId;
}
code ??= _resolveCodeByStatus(response.statusCode);
// 저장된 토큰이 있는데 401 이면 "세션 만료"로 간주한다.
// AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
if (code == 'AUTH_001' && await _storage.read(key: kJwtTokenKey) != null) {
await _storage.delete(key: kJwtTokenKey);
code = 'AUTH_002';
}
return ApiException.fromCode(
code,
traceId: traceId,
statusCode: response.statusCode,
);
}
/// HTTP 상태 코드 → 에러 코드 폴백 (서버가 표준 에러 본문을 주지 못한 경우)
String _resolveCodeByStatus(int? statusCode) {
switch (statusCode) {
case 401:
return 'AUTH_001';
case 403:
return 'AUTH_003';
case 400:
case 422:
return 'VAL_001';
case 404:
return 'RES_001';
case 429:
return 'REQ_001';
default:
return 'SYS_001';
}
}
/// 표준 에러 객체({ code, message, traceId })에서 필요한 값만 꺼낸다.
({String? code, String? traceId}) _readErrorObject(
Map<String, dynamic> payload,
) {
final error = payload['error'];
if (error is! Map<String, dynamic>) return (code: null, traceId: null);
final code = error['code'];
final traceId = error['traceId'];
return (
code: code is String ? code : null,
traceId: traceId is String ? traceId : null,
);
}
Dio get dio => _dio;
}

Some files were not shown because too many files have changed in this diff Show More