Initial commit

This commit is contained in:
2026-09-28 17:59:25 +09:00
commit 101e04a455
269 changed files with 33654 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
---
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
```
## 운영 환경
> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다.
> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
> 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 `ALLOW_LOCALHOST_URL=true` 를 함께 준다.
```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` 라 포트가 이미 사용 중이면 **다음 포트로 옮기지 않고 실패**한다.
`CORS_ORIGIN` 과 compose 포트 매핑이 그 포트에 묶여 있어, 조용히 5174 로 옮겨가면
API 요청이 원인 모르게 막힌다 (fail-fast).
- 폴링 감시(`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`)로 해야 한다.
이때 로컬 주소를 쓰게 되므로 `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` 가 경고**한다.
+322
View File
@@ -0,0 +1,322 @@
---
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/common/interceptors/transform.interceptor.ts` | main.ts 전역 등록 — 컨트롤러는 raw 데이터만 반환. `@SkipTransform()` 으로 제외 |
| 전역 예외 필터 | `src/common/filters/http-exception.filter.ts` | 레지스트리 조회로 `{success:false,error:{code,message,traceId}}` 생성 ([[error-codes]]) |
| 요청 추적 미들웨어 | `src/common/middleware/request-id.middleware.ts` | `x-request-id` 발급 → 로그·응답 `traceId` ([[logging]]) |
| Swagger 래퍼 데코레이터 | `src/common/decorators/api-standard-response.decorator.ts` | `ApiStandardResponse` / `ApiErrorResponse` — 문서를 실응답과 일치시킨다 |
| 페이지네이션 DTO | `src/common/dto/pagination.dto.ts` | 커서/오프셋 쿼리 + `PageInfoDto` + `toPaginatedResult()` |
| 전역 ValidationPipe | `main.ts` | `whitelist/forbidNonWhitelisted/transform` |
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터 |
| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트 |
| 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 |
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션") |
| Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 |
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]) |
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[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`
> - `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,
})),
);
}
}
```
- `main.ts`에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor())`
- 실패 응답(`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
// 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` 대신 래퍼 데코레이터를 쓴다** — 컨트롤러는 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 test` 통과. 자동 수정은 `npm run lint:fix`.
- [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `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` 로 로드).
+230
View File
@@ -0,0 +1,230 @@
---
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`, `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` → 백엔드. 없으면 vite 가 SPA 폴백으로 **HTML 을 200 으로** 돌려줘 조용히 실패한다 |
| dev 서버 포트 | `vite.config.ts` 의 `loadEnv` | 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 를 읽는다. `strictPort` 로 포트 충돌 시 fail-fast ([[env-structure]]) |
> **개발 서버는 호스트에서 띄운다** — `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({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // env.d.ts 에 타입 선언
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)
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()` 직접 호출 **금지**
- `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`.
- [ ] **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` 에서 주입된다 — `frontend/.env` 에 별도 작성 불필요.
- [ ] **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`), 프록시 대상 포트가 맞는지 확인한다.