Compare commits
4 Commits
c5896fbdb1
...
3b397b7788
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b397b7788 | |||
| b35ddfc9c3 | |||
| 6306160dda | |||
| 49fef58c5f |
@@ -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 분리
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: checklist
|
||||
description: 신규 프로젝트 셋업 절차 + 매 작업 공통 점검(에러코드 SSOT 동기화 등). 특정 시점의 구현 상태가 아니라 언제나 유효한 공용 규칙만 담는다.
|
||||
---
|
||||
|
||||
# 작업 점검 & 신규 프로젝트 셋업
|
||||
|
||||
> 이 저장소는 **재사용 템플릿**이다. 클론해 어떤 프로젝트든 시작할 수 있도록, 여기에는 **언제나 유효한 공용 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다).
|
||||
> 각 스택이 기본 제공하는 골격과 확장 규칙은 [[nestjs]] · [[vue3]] · [[flutter]] · [[docker]] 스킬 참조.
|
||||
|
||||
---
|
||||
|
||||
## 1. 신규 프로젝트 셋업 (클론 직후 1회)
|
||||
|
||||
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 (루트가 설정 SSOT — 대표 키가 채워져 있음, [[env-structure]])
|
||||
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
|
||||
- [ ] 프로젝트 식별 값 교체 (`PROJECT_NAME` 등 루트 env 키)
|
||||
- [ ] 백엔드 고유 시크릿(`JWT_SECRET` 등)을 `backend/.env.{env}` 에 작성 (compose `env_file` 로 로드)
|
||||
- [ ] Docker 개발 환경 기동으로 동작 확인 ([[docker]])
|
||||
|
||||
## 2. 커밋 전 공통 점검 (매 작업)
|
||||
|
||||
- [ ] 주석은 한국어, 매직 넘버·하드코딩 문자열 없음 ([[coding-common]])
|
||||
- [ ] 린트/분석 통과 — backend `npm run lint`, frontend `npm run lint && npm run type-check`, flutter `flutter analyze` (Warning 0)
|
||||
- [ ] 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로 없음
|
||||
- [ ] 민감정보(`.env`, 시크릿) 미커밋 — `*.example` 만 추적 ([[security]])
|
||||
- [ ] 커밋 메시지 `<type>: <한국어 제목>` 형식, 한 커밋 한 변경 ([[git-convention]])
|
||||
|
||||
## 3. 에러 코드 SSOT 동기화 (메시지/코드 변경 시 반드시 3곳 동시 수정)
|
||||
|
||||
- [ ] backend `src/common/filters/http-exception.filter.ts`
|
||||
- [ ] frontend `src/composables/useApi.ts` (`ErrorCodeLexicon`)
|
||||
- [ ] flutter `lib/core/api/error_lexicon.dart` (`ErrorLexicon`)
|
||||
|
||||
> `AUTH_002`(세션 만료)는 **클라이언트 전용** 코드로 서버 응답에는 나타나지 않는다. 자세한 내용은 [[error-codes]].
|
||||
|
||||
---
|
||||
|
||||
## 4. 기능 확장 시 따르는 순서
|
||||
|
||||
새 기능은 만들기 전에, 해당 스킬의 **"템플릿 기본 제공 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것).
|
||||
|
||||
1. 해당 디렉터리 스킬 규칙 확인 ([[nestjs]] / [[vue3]] / [[flutter]])
|
||||
2. 기본 제공 골격(인터셉터·필터·dio client·error lexicon·예제 모듈)에서 패턴 재사용
|
||||
3. 표준 응답/에러 흐름 준수 ([[response-format]] · [[error-codes]])
|
||||
4. env 신규 값은 루트 `.env` 부터 ([[env-structure]])
|
||||
5. 커밋 전 위 2·3절 점검 통과
|
||||
|
||||
> 대표 확장 지점(각 스킬의 "확장 시 따르는 규칙" 참조):
|
||||
> - 인증/권한 → [[nestjs]](guards·JWT) · [[vue3]](auth store + 라우터 가드)
|
||||
> - 데이터 영속화 → [[nestjs]](TypeORM 엔티티/리포지토리)
|
||||
> - 상태·화면 계층 → [[flutter]](Riverpod 프로바이더 · features 구조)
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: coding-common
|
||||
description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙 — 주석 언어, 타입 정책, 린트, 순수 유틸 분리, 절대경로 alias.
|
||||
---
|
||||
|
||||
# 공통 코딩 규칙
|
||||
|
||||
- 모든 코드 주석은 **한국어**로 작성
|
||||
- 웹(`frontend`)·서버(`backend`)는 100% TypeScript, 모바일(`flutter`)은 Dart Sound Null Safety 준수
|
||||
- 컴파일러/린터 **Warning 방치 금지** — 커밋 전 각 프로젝트 `lint` / `analyze` 통과
|
||||
- 유틸 함수는 순수 함수로 분리 — TS는 `shared/utils/`, Dart는 `core/utils/`
|
||||
- 매직 넘버·하드코딩 문자열 지양 → 상수 또는 환경변수로 분리
|
||||
- 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양
|
||||
|
||||
## 네이밍 규약
|
||||
|
||||
| 대상 | 규칙 | 예 |
|
||||
| --- | --- | --- |
|
||||
| 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]]) |
|
||||
|
||||
> 도메인별 세부 규칙: [[nestjs]] · [[vue3]] · [[flutter]] · 테스트: [[testing]]
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
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 → 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}` 파일은 비어 있어도 **존재해야 한다.**
|
||||
- **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` 참조.
|
||||
> 실행/배포 명령은 [[docker]], 보안 규칙은 [[security]] 참조.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: error-codes
|
||||
description: 세 플랫폼이 공유하는 에러 코드 ↔ 한국어 메시지 매핑(SSOT). 메시지 수정 시 backend/frontend/flutter 3개 파일을 함께 갱신한다.
|
||||
---
|
||||
|
||||
# 에러 코드 맵핑
|
||||
|
||||
세 플랫폼이 공유하는 **단일 진실 공급원(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` 로 사용자 노출 문구를 재정의할 수 있다.
|
||||
> 응답 래퍼 구조는 [[response-format]] 참조.
|
||||
|
||||
> **구현 주의:** 백엔드 필터(`http-exception.filter.ts`)는 **6개 코드**(SYS_001 / AUTH_001 / AUTH_003 / VAL_001 / RES_001 / BIZ_001)만 방출한다.
|
||||
> `AUTH_002`(세션 만료)는 **클라이언트(frontend/flutter)에서만** 생성하는 코드로, 서버 응답에는 나타나지 않는다.
|
||||
> 코드/메시지 변경 시 위 3개 파일을 함께 갱신해야 하며, 점검 항목은 [[checklist]] 참조.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: git-convention
|
||||
description: Git 커밋 메시지 컨벤션 — <type>: <제목> 형식, 한국어 명령형, 한 커밋 한 논리 변경.
|
||||
---
|
||||
|
||||
# Git 커밋 컨벤션
|
||||
|
||||
`<type>: <제목>` 형식 — type: **feat / fix / refactor / docs / chore / perf / test / style**
|
||||
|
||||
- 예: `feat: 사용자 목록 페이지네이션 추가`
|
||||
- 제목은 한국어, 명령형 현재 시제로 작성
|
||||
- 한 커밋은 하나의 논리적 변경만 포함
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: response-format
|
||||
description: 모든 API 응답이 따르는 표준 응답 래퍼(success/data/error) 규칙. SSOT는 backend TransformInterceptor + HttpExceptionFilter.
|
||||
---
|
||||
|
||||
# 표준 응답 포맷 (Global Response Wrapper)
|
||||
|
||||
모든 API 응답은 아래 구조로 통일한다.
|
||||
(SSOT: backend `TransformInterceptor` + `HttpExceptionFilter` — [[nestjs]] 참조)
|
||||
|
||||
```jsonc
|
||||
// 성공
|
||||
{ "success": true, "data": { /* ... */ } }
|
||||
|
||||
// 실패
|
||||
{ "success": false, "error": { "code": "VAL_001", "message": "..." } }
|
||||
```
|
||||
|
||||
- 프론트/플러터 인터셉터는 `success: true` 면 `data` 만 언래핑하여 반환한다.
|
||||
- `success: false` 이거나 HTTP 4xx/5xx 면 `error.code` 로 메시지를 매핑한다.
|
||||
- 에러 코드 ↔ 메시지 매핑은 [[error-codes]] 참조.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: security
|
||||
description: 프로젝트 공통 보안 규칙 — 민감정보 커밋 금지, JWT 쿠키/secure storage, SQL Injection 차단, DTO 검증, 보안 헤더/CORS/Rate Limiting, 컨테이너 비-root 구동.
|
||||
---
|
||||
|
||||
# 보안
|
||||
|
||||
- `.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 사용자로 구동)
|
||||
|
||||
> 환경변수 관리 규칙은 [[env-structure]], 도메인별 적용은 [[nestjs]] · [[flutter]] 참조.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: testing
|
||||
description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍, 무엇을 테스트할지, 커밋 전 실행. backend Jest, frontend Vitest, flutter flutter_test 기준.
|
||||
---
|
||||
|
||||
# 테스트 규칙
|
||||
|
||||
> 템플릿은 각 스택에 테스트 러너가 준비되어 있다 — backend `jest`, frontend `vitest`, flutter `flutter_test`.
|
||||
> 새 로직을 추가하면 최소한의 테스트를 함께 작성한다.
|
||||
|
||||
## 공통 원칙
|
||||
|
||||
- **무엇을 테스트하나**: 순수 함수(utils), 서비스/비즈니스 로직, 에러 코드 매핑 분기를 우선한다. 프레임워크 기본 동작은 테스트하지 않는다.
|
||||
- **테스트는 결정적(deterministic)** 으로 — 외부 네트워크/시간/랜덤에 의존하지 않도록 모킹한다.
|
||||
- **커밋 전 실행** — 변경 영역의 테스트가 통과해야 한다. ([[checklist]])
|
||||
- 테스트 설명(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('');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
> API 응답 래퍼/에러 코드는 세 플랫폼 공유 계약이므로([[response-format]] · [[error-codes]]),
|
||||
> 매핑 로직을 변경하면 해당 분기 테스트를 추가/갱신한다.
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"$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 start:*)",
|
||||
"Bash(npm ci)",
|
||||
"Bash(npm install)",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
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` 만 추적한다.)
|
||||
|
||||
---
|
||||
|
||||
## 개발 환경
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 자주 쓰는 보조 명령
|
||||
|
||||
> 개발 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>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 규칙
|
||||
|
||||
- **빌드 최소화**: 의존성(package.json / pubspec 등) 변경이 없으면 `--build` 없이 기동한다.
|
||||
- **env 파일**: 실행 전 `.env.{APP_ENV}` 존재를 확인한다. 없으면 `.env.{APP_ENV}.example` 을 복사해 생성.
|
||||
- **컨테이너 비-root 구동**: 모든 서비스 컨테이너는 비-root 사용자로 실행한다 ([[security]] 참조).
|
||||
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`.
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
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]] · [[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(코드 매핑) — 인라인 |
|
||||
| ErrorLexicon | `lib/core/api/error_lexicon.dart` | 에러 코드 → 한국어 메시지, fallback `SYS_001` ([[error-codes]]) |
|
||||
| 토큰 저장 | `flutter_secure_storage`, key `jwt_token` | `dio_client` 에서 사용 |
|
||||
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | Riverpod 사용 준비됨 |
|
||||
| Dio Provider | `lib/core/api/dio_provider.dart` | 인터셉터 적용된 Dio 를 DI 로 주입 |
|
||||
| 예제 feature(참조용) | `lib/features/user/` | `data/models`·`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: 'jwt_token');
|
||||
if (token != null) options.headers['Authorization'] = 'Bearer $token';
|
||||
handler.next(options);
|
||||
},
|
||||
onResponse: (response, handler) {
|
||||
// success==true → data 언래핑, false → 에러 코드로 reject
|
||||
handler.next(response);
|
||||
},
|
||||
onError: (err, handler) {
|
||||
// 상태코드/서버 error.code → ErrorLexicon 한국어 메시지 ([[error-codes]] 참조)
|
||||
handler.next(err);
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 인터셉터를 별도 클래스(`AuthInterceptor`/`ErrorInterceptor`)로 분리하고 싶다면 리팩터링 가능하나, 현재는 위 인라인 구조가 SSOT다.
|
||||
|
||||
---
|
||||
|
||||
## 보안 (토큰 저장)
|
||||
|
||||
```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`에 토큰 저장 **금지**
|
||||
- 추가 보안 규칙은 [[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` 의 인터셉터 패턴 재사용. 에러 메시지는 `error_lexicon.dart` 경유.
|
||||
- [ ] **토큰**: `flutter_secure_storage` + key `jwt_token` 통일. `SharedPreferences` 저장 금지.
|
||||
- [ ] **상태관리**: 새 기능은 Riverpod 프로바이더/노티파이어로 작성 (`ProviderScope` 는 기본 제공).
|
||||
- [ ] **구조 보완 시**: `app.dart` 분리, `core/{constants,theme,storage}` 생성, 공통 위젯 `lib/widgets/` → `shared/widgets/` 이동 고려.
|
||||
- [ ] **권장 패키지**: 모델(`freezed`/`json_serializable`)·라우팅(`go_router`) 도입 시 `pubspec.yaml` 추가 후 `flutter pub get`.
|
||||
- [ ] **analyze**: 커밋 전 `flutter analyze` 통과.
|
||||
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
|
||||
@@ -1,9 +1,34 @@
|
||||
---
|
||||
description: NestJS 백엔드 개발 규칙
|
||||
name: nestjs
|
||||
description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스/DTO 관심사 분리, 표준 응답 인터셉터, 전역 예외 필터, Swagger, JWT 인증, 캐싱/Rate Limiting/페이지네이션. backend/ 에서 작업하거나 REST API·모듈·DTO·인터셉터·필터를 만들 때 사용한다.
|
||||
---
|
||||
|
||||
> 이 파일은 `.claude/commands/nestjs.md`에 위치한다.
|
||||
> 사용법: `/nestjs [요청 내용]`
|
||||
> 이 스킬은 `.claude/skills/nestjs/SKILL.md`에 위치한다.
|
||||
> 사용법: `/nestjs [요청 내용]` 또는 backend/ 작업 시 자동 적용.
|
||||
> 공통 규칙: [[response-format]] · [[error-codes]] · [[security]] · [[coding-common]]
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 기본 제공 골격
|
||||
|
||||
> 이 템플릿은 아래 인프라를 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
|
||||
|
||||
| 제공 항목 | 위치 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 표준 응답 인터셉터 | `src/common/interceptors/transform.interceptor.ts` | main.ts 전역 등록 — 컨트롤러는 raw 데이터만 반환 |
|
||||
| 전역 예외 필터 | `src/common/filters/http-exception.filter.ts` | `HttpException` → `{success:false,error:{code,message}}` ([[error-codes]]) |
|
||||
| 전역 ValidationPipe | `main.ts` | `whitelist/forbidNonWhitelisted/transform` |
|
||||
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터 |
|
||||
| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트 · Rate Limiting(`express-rate-limit`) |
|
||||
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 |
|
||||
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger), `modules/health`(`/health`) | 새 모듈 패턴 참고. user 예제는 in-memory 데모 저장소 |
|
||||
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
|
||||
|
||||
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
|
||||
> - **인증(JWT)**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증
|
||||
> - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체)
|
||||
> - **페이지네이션**: 목록 API 에 커서/오프셋 적용
|
||||
> - `common/{guards,decorators,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
|
||||
|
||||
---
|
||||
|
||||
@@ -90,6 +115,7 @@ export class TransformInterceptor<T> implements NestInterceptor {
|
||||
|
||||
- `main.ts`에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor())`
|
||||
- 실패 응답(`success: false`)은 아래 Exception Filter가 생성한다.
|
||||
- 응답 포맷 SSOT는 [[response-format]] 참조.
|
||||
|
||||
---
|
||||
|
||||
@@ -104,11 +130,13 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
// HTTP 상태 → 에러 코드 매핑 후 { success: false, error: { code, message } } 반환
|
||||
// SYS_001 / AUTH_001 / VAL_001 등 CLAUDE.md 에러 맵 참조
|
||||
// SYS_001 / AUTH_001 / VAL_001 등 에러 맵은 [[error-codes]] 참조
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
이 필터는 세 플랫폼 공유 에러 코드 맵의 **단일 진실 공급원(SSOT)** 이다. 메시지 수정 시 [[error-codes]] 의 3개 파일을 함께 갱신한다.
|
||||
|
||||
---
|
||||
|
||||
## Swagger 필수 적용
|
||||
@@ -134,18 +162,36 @@ SwaggerModule.setup('api-docs', app, document);
|
||||
|
||||
## 인증 (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` (Redis) 활용 고려
|
||||
- **Rate Limiting**: `@nestjs/throttler`로 API 남용 방지
|
||||
- **Rate Limiting**: 템플릿은 `main.ts` 에서 `express-rate-limit`(초과 시 `SYS_001`)로 API 남용을 방지한다. 라우트 단위 데코레이터 제어가 필요하면 `@nestjs/throttler` 로 전환 고려
|
||||
- **Pagination**: 목록 API는 반드시 커서 기반 또는 오프셋 페이지네이션 적용
|
||||
- **Logging**: `winston` 또는 `@nestjs/common Logger` 사용, `console.log` 지양
|
||||
- **Health Check**: `@nestjs/terminus`로 `/health` 엔드포인트 구성 권장
|
||||
- **Versioning**: API 버전 관리 (`/v1/`, `/v2/`) 적용 고려
|
||||
|
||||
---
|
||||
|
||||
## 작업 점검 체크리스트
|
||||
|
||||
코드 작성/수정 후 아래를 점검한다. (전체 확장 지점·점검 목록은 [[checklist]])
|
||||
|
||||
- [ ] **응답 포맷**: 컨트롤러는 raw 데이터만 반환 — `{success,data}` 래핑은 인터셉터가 담당. 직접 래핑 금지.
|
||||
- [ ] **에러**: 사용자 노출 실패는 `HttpException` 으로 던져 필터가 코드 매핑하게 한다. 새 코드 추가 시 [[error-codes]] 의 3개 파일 동시 갱신.
|
||||
- [ ] **DTO**: 모든 입력 DTO 에 `class-validator` + 한국어 메시지 + `@ApiProperty()`.
|
||||
- [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation`/`@ApiResponse`.
|
||||
- [ ] **lint**: 커밋 전 `npm run lint` 통과 (Warning 0).
|
||||
- [ ] **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` 로 로드).
|
||||
@@ -1,9 +1,35 @@
|
||||
---
|
||||
description: Vue 3 프론트엔드 개발 규칙
|
||||
name: vue3
|
||||
description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API + <script setup>, Props/Emits 타입 정의, Pinia 스토어, Composables+Axios API 호출, Vue Router 코드 스플리팅·가드. frontend/ 에서 작업하거나 컴포넌트·스토어·컴포저블·라우터를 만들 때 사용한다.
|
||||
---
|
||||
|
||||
> 이 파일은 `.claude/commands/vue3.md`에 위치한다.
|
||||
> 사용법: `/vue3 [요청 내용]`
|
||||
> 이 스킬은 `.claude/skills/vue3/SKILL.md`에 위치한다.
|
||||
> 사용법: `/vue3 [요청 내용]` 또는 frontend/ 작업 시 자동 적용.
|
||||
> 공통 규칙: [[response-format]] · [[error-codes]] · [[security]] · [[coding-common]]
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 기본 제공 골격
|
||||
|
||||
> 이 템플릿은 아래를 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 복제한다(같은 역할을 새로 만들지 말 것).
|
||||
|
||||
| 제공 항목 | 위치 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 중앙 Axios 인스턴스 | `src/composables/useApi.ts` | baseURL=`VITE_API_BASE_URL`, `withCredentials` |
|
||||
| 응답 언래핑 인터셉터 | `useApi.ts` | `{success,data}→data`, 에러 코드 처리 |
|
||||
| ErrorCodeLexicon | `useApi.ts` 내부 | 에러 코드 → 한국어 메시지 ([[error-codes]]) |
|
||||
| 도메인 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` |
|
||||
|
||||
> **확장 시 따르는 규칙:**
|
||||
> - **로그인 연동**: 실제 `/auth/login` 엔드포인트 연결 → 성공 시 `useAuth` 가 `authStore.markAuthenticated()` 호출
|
||||
> - **로그인 페이지**: 추가 시 라우터 가드 리다이렉트를 `{ name: 'login' }` 으로 변경
|
||||
> - **공통 컴포넌트**: `BaseButton` 패턴을 따라 `src/components/` 에 추가
|
||||
|
||||
---
|
||||
|
||||
@@ -97,7 +123,7 @@ api.interceptors.response.use(
|
||||
(response) => response.data.data, // { success, data } → data 언래핑
|
||||
(error) => {
|
||||
const code = error.response?.data?.error?.code
|
||||
// 에러 코드별 Toast 알림 처리 (CLAUDE.md 에러 맵 참조)
|
||||
// 에러 코드별 Toast 알림 처리 ([[error-codes]] 참조)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
@@ -124,6 +150,7 @@ export function useUser() {
|
||||
|
||||
- API 호출 로직은 반드시 `composables/` 폴더에 분리
|
||||
- 컴포넌트 내 `axios.get()` 직접 호출 **금지**
|
||||
- `ErrorCodeLexicon` 은 세 플랫폼 공유 에러 맵의 일부 — [[error-codes]] 참조.
|
||||
|
||||
---
|
||||
|
||||
@@ -156,6 +183,21 @@ router.beforeEach((to) => {
|
||||
- **`defineAsyncComponent`**: 무거운 컴포넌트는 비동기 로딩 적용
|
||||
- **`v-memo`**: 반복 렌더링 최적화 필요 시 활용
|
||||
- **`<Suspense>`**: 비동기 컴포넌트 로딩 상태 처리에 활용
|
||||
- **환경변수**: 모든 설정값은 `import.meta.env.VITE_*` 형태로 관리
|
||||
- **환경변수**: 모든 설정값은 `import.meta.env.VITE_*` 형태로 관리 ([[env-structure]] 참조)
|
||||
- **절대경로**: `@/` 경로 alias 사용 (상대경로 `../../` 지양)
|
||||
- **접근성(a11y)**: `aria-label`, `role`, `tabindex` 등 기본 접근성 속성 포함
|
||||
|
||||
---
|
||||
|
||||
## 작업 점검 체크리스트
|
||||
|
||||
(전체 확장 지점·점검 목록은 [[checklist]])
|
||||
|
||||
- [ ] **API 호출**: 컴포넌트에서 `axios` 직접 호출 금지 → `composables/` 경유.
|
||||
- [ ] **에러 처리**: 개별 try/catch 로 메시지를 만들지 말고 `useApi` 인터셉터 + [[error-codes]] 에 위임.
|
||||
- [ ] **컴포넌트**: `<script setup lang="ts">` + `<style scoped>` + rem 단위. 인라인 스타일·Options API 금지.
|
||||
- [ ] **타입**: Props/Emits 는 인터페이스로 정의.
|
||||
- [ ] **라우트**: 페이지는 lazy import, 보호 라우트는 `meta.requiresAuth`.
|
||||
- [ ] **auth 가드(인증 도입 시)**: `router/index.ts` 의 `TODO` — `auth.store.ts` 작성 후 `isLoggedIn` 검사 연결.
|
||||
- [ ] **lint**: 커밋 전 `npm run lint` + `npm run type-check` 통과.
|
||||
- [ ] **env**: `VITE_API_BASE_URL` 은 빌드 시 루트 `BACKEND_API_URL` 에서 주입된다 — `frontend/.env` 에 별도 작성 불필요.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -2,114 +2,38 @@
|
||||
|
||||
이 저장소는 **풀스택 모노레포 기본 템플릿**이다. 누구나 클론하여 바로 개발을 시작할 수 있도록 공통 규칙과 언어별 규칙을 정의한다.
|
||||
|
||||
| 디렉터리 | 스택 | 역할 | 개발 규칙 (슬래시 명령) |
|
||||
| ----------- | -------------- | ------------------------ | ---------------------------------------- |
|
||||
| `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/` — 세 플랫폼 공통 규칙 (코딩·응답 포맷·에러 코드·커밋·보안·환경변수)
|
||||
> - `.claude/skills/` — 도메인별 개발 규칙 (스킬). 슬래시 명령(`/nestjs` 등) 또는 해당 디렉터리 작업 시 자동 적용
|
||||
> - `.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/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/commands/nestjs.md
|
||||
- 프론트엔드(Vue 3): @.claude/commands/vue3.md
|
||||
- 모바일(Flutter): @.claude/commands/flutter.md
|
||||
- 실행/배포 명령어: @command.md
|
||||
- 백엔드(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
|
||||
|
||||
@@ -15,8 +15,8 @@ NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에
|
||||
|
||||
## 사전 요구사항
|
||||
|
||||
- Docker / Docker Compose (권장 구동 방식)
|
||||
- 단독 실행 시: Node.js `^20.19 || >=22.12`, Flutter SDK `^3.11`
|
||||
- Docker / Docker Compose — backend·frontend·db·redis 구동
|
||||
- Flutter SDK `^3.11` — 모바일 앱 실행용 (compose 대상 아님)
|
||||
|
||||
## 빠른 시작 (Docker)
|
||||
|
||||
@@ -25,8 +25,8 @@ 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) 개발 환경 기동 (코드만 수정 시)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
@@ -41,23 +41,17 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
|
||||
- API: <http://localhost:3000/api>
|
||||
- Swagger 문서: <http://localhost:3000/api-docs>
|
||||
|
||||
전체 실행/배포 명령어는 [`command.md`](./command.md) 참조.
|
||||
전체 실행/배포 명령어는 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) 참조.
|
||||
|
||||
## 단독 실행 (Docker 미사용)
|
||||
## 모바일 앱 실행 (Flutter)
|
||||
|
||||
Flutter 는 Docker Compose 대상이 아니므로 로컬에서 직접 실행합니다.
|
||||
|
||||
```bash
|
||||
# 백엔드
|
||||
cd backend && npm install && npm run start:dev
|
||||
|
||||
# 프론트엔드
|
||||
cd frontend && npm install && npm run dev
|
||||
|
||||
# 모바일
|
||||
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`)
|
||||
|
||||
## 환경변수
|
||||
|
||||
@@ -70,7 +64,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` 주입 | 작성 불필요 |
|
||||
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
|
||||
|
||||
`{env}` = `development` | `production`
|
||||
@@ -84,26 +78,30 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
|
||||
{ "success": false, "error": { "code": "VAL_001", "message": "..." } }
|
||||
```
|
||||
|
||||
에러 코드 맵과 코딩 규칙 전문은 [`CLAUDE.md`](./CLAUDE.md) 및 `.claude/commands/` 의 언어별 규칙 문서를 참고하세요.
|
||||
에러 코드 맵과 코딩 규칙 전문은 [`CLAUDE.md`](./CLAUDE.md) 및 `.claude/rules/`·`.claude/skills/` 의 규칙 문서를 참고하세요.
|
||||
|
||||
## 개발 규칙
|
||||
|
||||
| 영역 | 문서 |
|
||||
| --------- | ------------------------------------------------- |
|
||||
| 공통/보안 | [`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/) |
|
||||
| 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) |
|
||||
|
||||
## 디렉터리 구조
|
||||
|
||||
```
|
||||
.
|
||||
├── backend/ # NestJS API (Controller / Service / DTO / 공통 필터·인터셉터)
|
||||
├── frontend/ # Vue 3 (pages / composables / stores / router)
|
||||
├── frontend/ # Vue 3 (pages / components / composables / stores / router)
|
||||
├── flutter/ # Flutter (core / features / widgets)
|
||||
├── docker-compose.yml # 베이스 정의
|
||||
├── docker-compose.dev.yml # 개발 오버라이드 (핫리로드, 포트 노출)
|
||||
├── CLAUDE.md # 공통 코딩 규칙 + 에러 코드 맵 (SSOT)
|
||||
└── command.md # 실행/배포 명령어
|
||||
├── CLAUDE.md # 프로젝트 개요 + 공통 규칙 @import
|
||||
└── .claude/
|
||||
├── settings.json # Claude Code 권한·환경 설정
|
||||
├── rules/ # 공통 규칙 (코딩·응답 포맷·에러 코드·커밋·보안·환경변수)
|
||||
└── skills/ # 도메인별 개발 규칙 (nestjs·vue3·flutter·docker)
|
||||
```
|
||||
|
||||
@@ -17,9 +17,11 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status: number = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
// 비교 대상인 HttpStatus enum 과 동일 타입으로 선언 (no-unsafe-enum-comparison 대응)
|
||||
let status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let code = 'SYS_001';
|
||||
let message = '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
||||
let message =
|
||||
'일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
@@ -39,7 +41,8 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
status === HttpStatus.UNPROCESSABLE_ENTITY
|
||||
) {
|
||||
code = 'VAL_001';
|
||||
message = '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)';
|
||||
message =
|
||||
'입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)';
|
||||
} else {
|
||||
code = 'BIZ_001';
|
||||
message = '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.';
|
||||
@@ -50,7 +53,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
exceptionResponse !== null &&
|
||||
'message' in exceptionResponse
|
||||
) {
|
||||
const responseMessage = (exceptionResponse as { message: unknown }).message;
|
||||
const responseMessage = exceptionResponse.message;
|
||||
if (typeof responseMessage === 'string') {
|
||||
message = responseMessage;
|
||||
}
|
||||
|
||||
@@ -9,22 +9,24 @@ import { map } from 'rxjs/operators';
|
||||
|
||||
export interface StandardResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
// 핸들러가 undefined/null 을 반환하면 null 로 정규화되므로 nullable
|
||||
data: T | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T>
|
||||
implements NestInterceptor<T, StandardResponse<T>>
|
||||
{
|
||||
export class TransformInterceptor<T> implements NestInterceptor<
|
||||
T,
|
||||
StandardResponse<T>
|
||||
> {
|
||||
intercept(
|
||||
_context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<StandardResponse<T>> {
|
||||
// 이미 Http 응답객체인 경우 등 예외처리가 필요할 수 있으나 기본적으로 data 래핑
|
||||
return next.handle().pipe(
|
||||
return (next.handle() as Observable<T>).pipe(
|
||||
map((data) => ({
|
||||
success: true,
|
||||
data: data !== undefined ? data : null,
|
||||
data: data ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
+14
-6
@@ -5,7 +5,11 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
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 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';
|
||||
@@ -17,7 +21,7 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
// 리버스 프록시(Nginx, Docker 배포 환경 등) 뒤에서 올바른 클라이언트 IP 식별 지원을 위해 설정합니다.
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
const expressApp = app.getHttpAdapter().getInstance() as Express;
|
||||
expressApp.set('trust proxy', 1);
|
||||
|
||||
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
|
||||
@@ -66,8 +70,9 @@ async function bootstrap() {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'SYS_001',
|
||||
message: '일시적인 시스템 오류가 발생했습니다. (요청 한도 초과) 잠시 후 다시 시도해 주세요.', // 글로벌 통합 에러 포맷
|
||||
}
|
||||
message:
|
||||
'일시적인 시스템 오류가 발생했습니다. (요청 한도 초과) 잠시 후 다시 시도해 주세요.', // 글로벌 통합 에러 포맷
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -98,6 +103,9 @@ async function bootstrap() {
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap');
|
||||
Logger.log(`📘 Swagger docs at http://localhost:${port}/api-docs`, 'Bootstrap');
|
||||
Logger.log(
|
||||
`📘 Swagger docs at http://localhost:${port}/api-docs`,
|
||||
'Bootstrap',
|
||||
);
|
||||
}
|
||||
bootstrap();
|
||||
void bootstrap();
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
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,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { UserService } from './user.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import { CreateUserDto } from './dto/create-user.dto';
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
// 임시 인메모리 저장소 — Repository 도입 시 교체
|
||||
private readonly users = new Map<number, { id: number; email: string; name: string }>();
|
||||
private readonly users = new Map<
|
||||
number,
|
||||
{ id: number; email: string; name: string }
|
||||
>();
|
||||
|
||||
findOne(id: number) {
|
||||
const user = this.users.get(id);
|
||||
|
||||
-28
@@ -1,28 +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
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dio_client.dart';
|
||||
|
||||
// 전역 Dio Provider — 리포지토리/데이터소스에서 ref.watch(dioProvider) 로 주입받는다.
|
||||
// 인터셉터(토큰 첨부·응답 언래핑·에러 매핑)가 적용된 DioClient.dio 를 노출한다.
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
return DioClient().dio;
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/api/dio_provider.dart';
|
||||
import '../../data/models/user_model.dart';
|
||||
import '../../data/repositories/user_repository.dart';
|
||||
|
||||
// 리포지토리 Provider — dioProvider 를 주입받아 생성
|
||||
final userRepositoryProvider = Provider<UserRepository>((ref) {
|
||||
return UserRepository(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
// 특정 사용자 조회 Provider (family)
|
||||
// 위젯에서: ref.watch(userProvider(id)).when(data:..., loading:..., error:...)
|
||||
final userProvider = FutureProvider.family<UserModel, int>((ref, id) {
|
||||
return ref.watch(userRepositoryProvider).getUser(id);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
// 공통 버튼 컴포넌트 — 모든 재사용 컴포넌트의 기준 패턴
|
||||
// (타입 지정 Props/Emits, scoped 스타일, rem 단위, 기본 접근성 속성)
|
||||
// 사용 예: <BaseButton label="저장" variant="primary" @click="onSave" />
|
||||
|
||||
// Props 정의
|
||||
interface Props {
|
||||
// 버튼 라벨 (접근성 aria-label 로도 사용)
|
||||
label: string
|
||||
// 시각 변형
|
||||
variant?: 'primary' | 'secondary'
|
||||
// 비활성화 여부
|
||||
disabled?: boolean
|
||||
// 네이티브 button type
|
||||
type?: 'button' | 'submit'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'primary',
|
||||
disabled: false,
|
||||
type: 'button',
|
||||
})
|
||||
|
||||
// Emits 정의
|
||||
interface Emits {
|
||||
// 클릭 이벤트
|
||||
(e: 'click', event: MouseEvent): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 클릭 핸들러 — 비활성화 상태에서는 이벤트를 전파하지 않음
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (props.disabled) return
|
||||
emit('click', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:class="['base-button', `base-button--${variant}`]"
|
||||
:disabled="disabled"
|
||||
:aria-label="label"
|
||||
:aria-disabled="disabled"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 모든 치수는 rem 기준 (16px = 1rem) */
|
||||
.base-button {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.base-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.base-button--primary {
|
||||
background-color: #4f46e5;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.base-button--secondary {
|
||||
background-color: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,8 @@ export interface StandardResponse<T = unknown> {
|
||||
}
|
||||
|
||||
// 에러 코드 사전 — CLAUDE.md / 백엔드 HttpExceptionFilter 와 1:1 매핑
|
||||
const ErrorCodeLexicon: Record<string, string> = {
|
||||
// SYS_001 은 폴백 메시지로 항상 존재함을 타입으로 보장 (noUncheckedIndexedAccess 대응)
|
||||
const ErrorCodeLexicon: Record<string, string> & { SYS_001: string } = {
|
||||
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
@@ -27,7 +28,6 @@ const ErrorCodeLexicon: Record<string, string> = {
|
||||
|
||||
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수만 교체하면 됨
|
||||
const showErrorUI = (message: string) => {
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(message)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 인증 도메인 API Composable 샘플 — 로그인/로그아웃 흐름의 시작점
|
||||
// 실제 인증 엔드포인트가 추가되면 이 패턴을 그대로 사용/확장한다.
|
||||
export interface LoginPayload {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const api = useApi()
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref<boolean>(false)
|
||||
|
||||
async function login(payload: LoginPayload): Promise<boolean> {
|
||||
loading.value = true
|
||||
try {
|
||||
// 서버는 인증 성공 시 HttpOnly 쿠키로 토큰을 내려준다 (응답 바디에 토큰 없음)
|
||||
await api.post('/auth/login', payload)
|
||||
authStore.markAuthenticated()
|
||||
return true
|
||||
} catch {
|
||||
// 에러 메시지/알림은 useApi 인터셉터에서 전역 처리됨
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
} finally {
|
||||
authStore.clearAuth()
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, login, logout }
|
||||
}
|
||||
@@ -10,7 +10,11 @@ const message = ref<string>('You did it!')
|
||||
<h1>{{ message }}</h1>
|
||||
<p>
|
||||
Visit
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a>
|
||||
<a
|
||||
href="https://vuejs.org/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>vuejs.org</a>
|
||||
to read the documentation
|
||||
</p>
|
||||
</main>
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="not-found" role="alert">
|
||||
<main
|
||||
class="not-found"
|
||||
role="alert"
|
||||
>
|
||||
<h1>404</h1>
|
||||
<p>요청하신 정보나 페이지를 찾을 수 없습니다.</p>
|
||||
<RouterLink to="/">홈으로 돌아가기</RouterLink>
|
||||
<RouterLink to="/">
|
||||
홈으로 돌아가기
|
||||
</RouterLink>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import 처리
|
||||
const routes: RouteRecordRaw[] = [
|
||||
@@ -20,10 +21,13 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
// 네비게이션 가드 — 인증 분기 (스토어 도입 후 useAuthStore로 교체)
|
||||
// 네비게이션 가드 — 보호 라우트(requiresAuth) 접근 시 인증 여부 검사
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.requiresAuth) {
|
||||
// TODO: useAuthStore().isLoggedIn 검사 후 '/login' 으로 리다이렉트
|
||||
const authStore = useAuthStore()
|
||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||
// 미인증 시 홈으로 리다이렉트
|
||||
// (실제 로그인 페이지 추가 시 { name: 'login' } 으로 변경)
|
||||
return { name: 'home' }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
// 인증 세션 상태 스토어
|
||||
// 웹은 JWT 를 HttpOnly·Secure 쿠키로 보관하므로(보안 규칙), JS 에서 토큰을 직접
|
||||
// 저장/조회하지 않는다. 여기서는 "현재 인증된 세션인지" 여부만 관리한다.
|
||||
// (사용자 프로필 데이터는 user.store.ts 에서 별도 관리)
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// 로그인 성공 또는 세션 확인 API 응답으로 갱신되는 인증 여부
|
||||
const authenticated = ref<boolean>(false)
|
||||
const isLoggedIn = computed<boolean>(() => authenticated.value)
|
||||
|
||||
// 로그인 성공 시 호출 (실제 로그인 API 연동 시 useAuth 에서 사용)
|
||||
function markAuthenticated() {
|
||||
authenticated.value = true
|
||||
}
|
||||
|
||||
// 로그아웃/세션 만료 시 호출
|
||||
function clearAuth() {
|
||||
authenticated.value = false
|
||||
}
|
||||
|
||||
return { isLoggedIn, markAuthenticated, clearAuth }
|
||||
})
|
||||
Reference in New Issue
Block a user