generated from ttipo/project-base
docs(rules): 앱 토큰 경로 · 모듈 틀을 flutter 스킬과 공통 규칙에 적는다
- flutter 스킬: 기본 제공 골격을 이번 앱 기반으로(인증 인터셉터 · 토큰 보관 · 세션 노티파이어 · 모듈 틀), 새 모듈 붙이는 법, 비밀이 든 요청의 실패 예외, 자동 다시 하기 끔, Flutter 판 - 보안 · 에러 코드 · 코딩 규칙: 앱은 리프레시 토큰을 본문으로 받아 보안 저장소에(kRefreshTokenKey), 세션 만료 승격은 갱신 뒤 · 스스로 로그아웃한 뒤의 401 은 승격하지 않는다(웹과 같다) - mobile-dev 역할: 작업 순서 · 완료 기준(apk 빌드 포함) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+132
-98
@@ -3,7 +3,7 @@ name: flutter
|
||||
description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null Safety, feature-first 디렉터리 구조, 작은 위젯 분리, 반응형 UI, Riverpod 상태관리, Dio 인터셉터, flutter_secure_storage 토큰 저장. flutter/ 에서 작업하거나 위젯·프로바이더·모델·API 클라이언트를 만들 때 사용한다.
|
||||
---
|
||||
|
||||
> 이 스킬은 `.claude/skills/flutter/SKILL.md`에 위치한다.
|
||||
> 이 스킬은 `.claude/skills/flutter/SKILL.md`에 위치한다. 앱 로그인의 근거는 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 (앱 전용 토큰 경로)", 화면 기준은 `docs/design/app/README.md`, 개발 실행 · 배포 준비 메모는 `flutter/README.md` 다.
|
||||
> 사용법: `/flutter [요청 내용]` 또는 flutter/ 작업 시 자동 적용.
|
||||
> 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[security]] · [[coding-common]]
|
||||
> Flutter 는 docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립 관리한다 ([[env-structure]] 참조).
|
||||
@@ -12,25 +12,44 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
|
||||
## 템플릿 기본 제공 골격
|
||||
|
||||
> 이 템플릿은 네트워크 계층을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
|
||||
> 이 템플릿은 네트워크 · 세션 · 라우터 · 모듈 틀을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
|
||||
> 앱 이름 "ARA SYSTEM", 식별자 `kr.it.arasystem.app`(Android applicationId · iOS bundle id — 스토어에 올린 뒤에는 못 바꾼다), 패키지 `arasystem_app`.
|
||||
|
||||
| 제공 항목 | 위치 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 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` 에 실어준다 |
|
||||
| 앱 뿌리 | `lib/main.dart` · `lib/app.dart` | main: dotenv → 글꼴 라이선스 → 토큰 보관 준비(첫 실행 지우기) → 기기 정보(판 · User-Agent) → `ProviderScope(overrides)`. app: `MaterialApp.router` · 한국어만(`locale: ko` · `GlobalMaterialLocalizations.delegates`) · 전역 `scaffoldMessengerKey` |
|
||||
| **모듈 목록** | `lib/core/modules/app_modules.dart` | **짝: 웹 `frontend/src/shared/constants/app-modules.ts`** — 같은 id · 이름 · 차례(한쪽을 바꾸면 둘 다). 항목마다 id · 이름 · 아이콘 · 첫 화면 경로(`/<id>`) · 켜짐. 웹은 준비 중 모듈을 꺼진 채 보이지만 앱은 **켜진 모듈만** 탭이 된다(첫 판: 메일 · 설정) |
|
||||
| 모듈 라우트 묶음 | `lib/features/<모듈>/<모듈>_routes.dart` → `lib/core/router/module_registry.dart` | 모듈마다 `ModuleRoutes`(`core/modules/module_routes.dart` — 탭 안 라우트 · 알림 id → 경로 · 설정 절) 하나. registry 의 exhaustive switch 가 id → 묶음. 켰는데 묶음이 없으면 라우터를 만들 때 `StateError` |
|
||||
| 라우터 · 탭 막대 | `lib/core/router/` — `app_router.dart` · `app_shell.dart` · `session_redirect.dart` · `app_routes.dart` | go_router. 로그인 전 · 제한 단계(`/start` · `/login/...`)는 셸 밖, 로그인 뒤는 `StatefulShellRoute.indexedStack`(켜진 모듈마다 탭 하나 — 탭마다 이동 기록을 지킨다). 아래 탭 막대(`NavigationBar`)가 웹의 전역 앱 레일이다. 읽기 · 쓰기 같은 전체 화면은 `parentNavigatorKey: rootNavigatorKey`. `redirect` 하나(`resolveSessionRedirect` — 순수 함수)가 세션 상태로 화면을 고르고, 모르는 · 꺼진 모듈의 주소는 메일 첫 화면(`onException` 도) |
|
||||
| **세션** | `lib/features/auth/presentation/providers/session_provider.dart` | `AsyncNotifier` 하나(`sessionProvider` — 저절로 다시 하지 않는다: Riverpod 3 의 `retry: null` 은 기본 다시 하기라 늘 null 을 돌려주는 `noAutomaticRetry` 를 준다). 상태 `SignedOut` · `CodeRequired` · `SignedIn(info.restriction)` + 되살리는 중(`AsyncLoading`) · 되살리기 장애(`AsyncError`). 로그인 때 넣은 비밀번호는 상태가 아니라 `LoginSecret`(메모리 · 10분 — 코드 단계 · 제한 단계가 쓴다). 인터셉터와는 `SessionEventBus`(`core/api/session_events.dart`)로만 잇는다 — 세션 끝 · `AUTH_010` 다시 맞추기(`SessionRecheckNeeded` 에 답 `RestrictionCheck` — renewed · restricted · stale · unknown, 웹과 같다) · 갱신 응답의 제한. **시도 세대**: 로그아웃 · [다른 계정으로 로그인] · 세션 끝 때 올라 그 전에 시작한 되살리기 · 로그인 · 코드 · 다시 맞추기의 늦은 결과를 버린다(버린 결과에 토큰이 있으면 조용히 앱 logout). 로그아웃은 토큰 · 상태를 먼저 지우고 서버 logout · 알림 토큰 지우기는 그 뒤. 제한이 풀리면 쥔 비밀번호를 지운다 |
|
||||
| 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 |
|
||||
| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 |
|
||||
| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) |
|
||||
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 |
|
||||
| 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` 수직 슬라이스 |
|
||||
| **인증 인터셉터** | `lib/core/api/auth_interceptor.dart` · `token_refresher.dart` | `QueuedInterceptor`. Bearer 붙이기 · 액세스 토큰이 없거나 30초 안에 끝나면 미리 갱신 · 401 이면 갱신 한 번 → 원래 요청 한 번 다시(로그인 · 갱신 · 로그아웃 제외 — `ApiPaths.noRefresh`). 갱신은 `TokenRefresher` 가 **한 번에 하나**(동시 401 은 토큰 세대를 보고 묶는다). 갱신 401 · 유예 응답(`tokens: null`) → 토큰 지움 + `AUTH_002` + 세션 끝, 5xx · 연결 끊김은 세션을 두고 그 코드로 알림, 갱신 중 로그아웃이면 받은 토큰을 버리고 서버 세션도 끝낸다. 갱신의 일시 장애는 같은 토큰 세대에서 5초 동안 다시 쓴다(`kRefreshFailureReuse` — 연결이 끊긴 동안 줄 선 요청마다 갱신하지 않게). `AUTH_010` 은 세션 노티파이어에 다시 맞춰 달라고 하고(`requestRecheck` — 안전 한도 65초) renewed 면 한 번 다시 보낸다 · restricted · stale 은 알리지 않는다 · unknown 은 알린다(웹 `recheckRestriction` 과 같은 결). 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다(그사이 새 토큰이면 묻지 않고 다시 보내고, restricted 는 그 세대 동안 · unknown 은 5초 다시 쓴다). **다시 보낸 요청의 실패도 첫 요청과 같은 규칙**(401 · `AUTH_010` 마다 한 번씩 — `kAuthRetriedExtraKey` · `kRestrictionRetriedExtraKey`). **요청을 보낸 세션이 끝났으면**(세션 세대 `kSessionGenerationExtraKey` 가 다르다 — 로그아웃 · 세션 끝 · 그 뒤 다른 직원 로그인) 어떤 실패든 조용히 끝낸다 — 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`. 실패를 내보내기 전에 Bearer · 비밀 본문을 지운다(`withoutSecrets`). 처리기는 뜻밖의 예외에도 꼭 부른다(`SYS_001`). 이 인터셉터의 오류 처리 안에서 보내는 요청(갱신 · 세션 확인 · 다시 보내기)은 모두 안쪽 Dio — 같은 줄로 보내면 서로 기다린다 |
|
||||
| 오류 알림 | `lib/core/api/error_notifier.dart` | 전역 `ScaffoldMessenger` 키 하나로 스낵바(웹 `showErrorUI` 자리). 같은 코드는 3초 안에 다시 띄우지 않는다. 요청마다 끄기: `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록(`ApiOptions.silentFor`) — `request_flags.dart`. **비밀이 든 요청**(비밀번호 · 코드 · 리프레시 토큰이 본문에)은 `ApiOptions.secret(...)` 으로 보낸다 — 실패 예외의 본문을 비워 던져 화면이 `AsyncValue` 로 상태에 두어도 된다 |
|
||||
| 토큰 보관 | `lib/core/storage/` — `token_store.dart` · `secure_store.dart` · `first_run_marker.dart` | 리프레시 토큰만 `flutter_secure_storage`(키 상수 `kRefreshTokenKey`), 액세스 토큰은 메모리. 새 토큰은 받자마자 저장(로그인 저장이 실패하면 그 세션을 끝내고 `SYS_001`, 갱신 저장이 실패하면 이번 실행은 메모리의 새 토큰으로 이어 가고 다음 읽기 · 저장 때 다시 쓴다). 보안 저장소 일은 부른 차례대로 하나씩(로그인 저장과 로그아웃 지우기가 엇갈리지 않게). 토큰 세대 `generation` · 세션 세대 `sessionGeneration`(지울 때만 오른다), 로그아웃은 `signOut()`(메모리를 곧바로 비우고 서버 logout 에 쓸 토큰을 돌려준다). 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각, `core/api/server_time.dart`)로 남은 시간을 재고 받은 때부터 센다. 첫 실행 표시(`shared_preferences` — 참 · 거짓 하나)가 없으면 보안 저장소를 모두 지운다(iOS Keychain 은 앱을 지워도 남는다). Android 는 백업을 껐다(`AndroidManifest` · `res/xml/data_extraction_rules.xml`) |
|
||||
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값 |
|
||||
| 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) |
|
||||
| 푸시 기기 등록 자리 | `lib/core/push/` — `push_registration.dart` · `push_target.dart` | 모듈 공통. 지금은 `NoopPushRegistrar`(서버 `/api/push-devices` 가 생기면 채운다 — 세션 노티파이어가 로그인 뒤 `register` · 로그아웃 전 `unregister` 를 부른다). 알림 데이터에는 **모듈 이름과 id 만**(`{module, id}`) — `locationForPush` 가 모듈의 `locationOfItem` 으로 경로를 고른다 |
|
||||
| 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 |
|
||||
| 예제 feature(템플릿) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남긴 템플릿. 앱 API 에 `/users` 는 없다 |
|
||||
| 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) |
|
||||
|
||||
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
|
||||
> - **상태관리**: `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` 추가
|
||||
> - **새 모듈 붙이는 법**(웹 [[vue3]] "새 사내 모듈"에 해당): `core/modules/app_modules.dart` 의 항목을 켜고(`isEnabled`) → `features/<모듈>/<모듈>_routes.dart` 에 `ModuleRoutes`(첫 라우트 경로 = `/<모듈>`)를 만들고 → `core/router/module_registry.dart` 의 switch 에 그 묶음을 적는다. 탭 · 주소 · 알림 · 설정 절은 저절로 붙는다. 웹 목록도 함께 본다(짝)
|
||||
> - **모듈끼리 서로 import 하지 않는다.** 함께 쓰는 것(사람 고르기 · 파일 첨부 · 날짜 표기 등)은 `core/` · `shared/` 에 둔다. 세션(`features/auth`)은 모듈이 아니라 모두가 쓴다. 다른 모듈의 설정 화면에 절을 더할 때는 `ModuleRoutes.settingsSections` 로 내놓기만 한다
|
||||
> - **주소는 모듈 이름부터**(`/mail/...` · `/settings/...` · 나중 `/calendar/...`). 깊은 링크 · 푸시도 모듈 이름으로 고른다
|
||||
> - **상태관리 · 복제할 패턴**: `features/auth` 의 `data/models`(손으로 쓴 모델) → `data/repositories`(`dioProvider` 의 Dio) → `presentation/providers`(타입을 적은 Riverpod 프로바이더 · 노티파이어)를 복제한다. 목록은 `features/user` 의 커서 페이지 예제. import 는 `package:arasystem_app/...`
|
||||
> - **화면 글**: 디자인 README 「화면 문구 — 사용자에게 말하듯」. 모듈 화면의 글은 그 모듈 안(`features/<모듈>/presentation/<모듈>_texts.dart`), 로그인 단계는 `core/constants/screen_titles.dart`
|
||||
> - **모델 생성기**: `freezed` · `json_serializable` · `riverpod_generator` 는 아직 넣지 않았다 — build_runner 가 같은 폴더의 `error_lexicon.g.dart`(자동 생성 사전)를 자기 출력으로 보고 지울 수 있다. 넣을 때는 `build.yaml` 의 `generate_for` 를 모델 폴더로 좁히고 한 번 돌려 사전이 남는지 확인한다
|
||||
> - **권장 패키지(선택)**: `cached_network_image`·`fpdart`·`webview_flutter`(받은 메일) 도입 시 `pubspec.yaml` 추가. 새 패키지는 배포 2주가 지난 판만 ([[security]] "의존성")
|
||||
|
||||
### Flutter 판
|
||||
|
||||
**개발 PC 와 CI 를 같은 판으로 고정한다 — 지금 `3.41.9` stable (Dart 3.11.5).** 함께 고치는 곳: `.github/workflows/ci-flutter.yml` 의 `flutter-version` · `flutter/pubspec.yaml` 의 `environment.sdk` 와 `intl`(`flutter_localizations` 가 SDK 마다 정확한 판으로 고정한다 — 지금 `0.20.2`, 새 SDK 의 `packages/flutter_localizations/pubspec.yaml` 값으로) · `flutter/README.md` · [[docker]] 이미지 버전 표의 "Flutter SDK" 줄. CI 는 `flutter pub get --enforce-lockfile` 로 lock 그대로 받는다.
|
||||
개발 PC 의 Flutter 는 다른 프로젝트도 함께 써서 그 판에 맞췄다 — 3.47 로 올리는 것은 사용자가 정한다(올리면 개발 PC · CI 를 함께, 2주 규칙).
|
||||
|
||||
---
|
||||
|
||||
@@ -47,26 +66,33 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
```
|
||||
lib/
|
||||
├── main.dart
|
||||
├── app.dart ← MaterialApp / 라우터 설정
|
||||
├── core/
|
||||
│ ├── constants/ ← 상수 (색상, 크기, 텍스트 등)
|
||||
│ ├── theme/ ← 앱 테마 정의
|
||||
│ ├── api/ ← Dio 인스턴스(dio_client), 에러 사전(error_lexicon), 인터셉터
|
||||
│ ├── storage/ ← flutter_secure_storage 래퍼 (선택)
|
||||
│ └── utils/ ← 순수 유틸 함수
|
||||
├── app.dart ← MaterialApp.router · 한국어 · 테마 · 전역 알림 자리
|
||||
├── core/ ← 모듈 공통 (모듈은 core · shared 만 함께 쓴다)
|
||||
│ ├── api/ ← Dio(dio_client) · 인터셉터 셋 · 토큰 갱신 · 에러 사전 · User-Agent
|
||||
│ ├── constants/ ← 앱 이름 · 로그인 단계 화면 이름 · 앱 판
|
||||
│ ├── modules/ ← 모듈 목록(app_modules — 웹과 짝) · 라우트 묶음 꼴(module_routes)
|
||||
│ ├── push/ ← 푸시 기기 등록 자리 · 알림 → 경로
|
||||
│ ├── router/ ← 라우터 · 탭 막대 · redirect · 모듈 묶음 모으기(module_registry)
|
||||
│ ├── storage/ ← 토큰 보관 · 보안 저장소 · 첫 실행 표시
|
||||
│ ├── theme/ ← 색 · 간격 · 글자(임시) · 글꼴
|
||||
│ └── utils/ ← 순수 유틸 함수 (시계 등)
|
||||
├── features/
|
||||
│ └── user/
|
||||
│ ├── data/
|
||||
│ │ ├── models/ ← JSON 직렬화 모델
|
||||
│ │ └── repositories/ ← API 호출 구현체
|
||||
│ ├── domain/
|
||||
│ │ └── entities/ ← 순수 비즈니스 엔티티
|
||||
│ └── presentation/
|
||||
│ ├── pages/ ← 화면 단위 위젯
|
||||
│ ├── widgets/ ← 재사용 위젯
|
||||
│ └── providers/ ← 상태 관리
|
||||
│ ├── auth/ ← 세션 · 앱 로그인 (모듈이 아니다 — 모든 모듈이 쓴다)
|
||||
│ ├── mail/ ← 모듈 — 주소 /mail/...
|
||||
│ │ ├── mail_routes.dart ← 이 모듈의 라우트 묶음 (core/router 가 모은다)
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── models/ ← JSON 모델 (손으로 — 위 "모델 생성기")
|
||||
│ │ │ └── repositories/ ← API 호출 구현체
|
||||
│ │ ├── domain/
|
||||
│ │ │ └── entities/ ← 순수 비즈니스 엔티티
|
||||
│ │ └── presentation/
|
||||
│ │ ├── pages/ ← 화면 단위 위젯
|
||||
│ │ ├── widgets/ ← 재사용 위젯
|
||||
│ │ └── providers/ ← 상태 관리
|
||||
│ ├── settings/ ← 모듈 — 주소 /settings/... (다른 모듈의 설정 절을 받아 붙인다)
|
||||
│ └── user/ ← 예제(참조용)
|
||||
└── shared/
|
||||
└── widgets/ ← 앱 공통 위젯 (생성 완료)
|
||||
└── widgets/ ← 앱 공통 위젯 (자리 화면 등)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -133,103 +159,109 @@ Expanded(flex: 1, child: RightPanel()),
|
||||
|
||||
## 상태 관리
|
||||
|
||||
```dart
|
||||
// Riverpod 예시 (권장)
|
||||
@riverpod
|
||||
Future<User> fetchUser(FetchUserRef ref, int id) async {
|
||||
final repo = ref.watch(userRepositoryProvider);
|
||||
return repo.getUser(id);
|
||||
}
|
||||
생성기(`riverpod_generator` · `@riverpod`)는 쓰지 않는다 — 프로바이더는 타입을 적어 손으로 만든다(위 "모델 생성기").
|
||||
|
||||
```dart
|
||||
// 프로바이더 — 타입을 적는다 (features/user 예제 · features/auth 와 같은 꼴)
|
||||
final Provider<UserRepository> userRepositoryProvider = Provider<UserRepository>(
|
||||
(Ref ref) => UserRepository(ref.watch(dioProvider)),
|
||||
);
|
||||
|
||||
final FutureProviderFamily<UserModel, int> userProvider =
|
||||
FutureProvider.family<UserModel, int>(
|
||||
(Ref ref, int id) => ref.watch(userRepositoryProvider).getUser(id),
|
||||
);
|
||||
|
||||
// 위젯에서 사용 — 실패는 [다시 시도](ref.invalidate)로 다시 한다(저절로 다시 하지 않는다)
|
||||
class UserTile extends ConsumerWidget {
|
||||
const UserTile({required this.userId, super.key});
|
||||
|
||||
final int userId;
|
||||
|
||||
// 위젯에서 사용
|
||||
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),
|
||||
final AsyncValue<UserModel> user = ref.watch(userProvider(userId));
|
||||
return user.when(
|
||||
data: (UserModel value) => Text(value.name),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, _) => const ErrorWidget(),
|
||||
error: (Object error, StackTrace _) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Riverpod** 권장 (Provider는 레거시로 간주)
|
||||
- **Riverpod 3** — 세션처럼 바꾸는 일이 있는 상태는 `AsyncNotifier`/`Notifier`(`sessionProvider` 가 본보기)
|
||||
- **저절로 다시 하기는 끈다**: Riverpod 3 은 프로바이더의 `retry` 를 주지 않으면 실패한 프로바이더를 기본으로 10번까지 다시 만든다(요청을 다시 보낸다).
|
||||
앱은 ProviderScope(main.dart) · 시험의 ProviderContainer 에 `noAutomaticRetry`(`core/utils/provider_retry.dart`)를 건다 — 다시 하기는 화면의 [다시 시도]가 한다
|
||||
- `StatefulWidget`은 애니메이션, 폼 등 로컬 UI 상태에만 제한적 사용
|
||||
- 실패를 상태(`AsyncValue`)에 두어도 된다 — 비밀이 든 요청(`ApiOptions.secret` — 로그인 · 코드 · 비밀번호 단계 · logout)의 실패 예외는 본문을 비워 던지고, 인증 인터셉터가 내보내는 모든 실패 예외는 Bearer 를 지운 채 온다
|
||||
|
||||
---
|
||||
|
||||
|
||||
## API 통신 (Dio + 인터셉터)
|
||||
|
||||
실제 구현(`lib/core/api/dio_client.dart`)은 싱글톤 + `InterceptorsWrapper` 인라인 방식이다. 인터셉터 로직 추가 시 이 파일의 패턴을 그대로 따른다.
|
||||
앱 로그인은 앱 전용 토큰 경로다(계약 `docs/mail/api-contract.md` 1절 "앱 로그인" — `POST /api/auth/app/login` · `/app/refresh` · `/app/logout`, 보호 API 는 `Authorization: Bearer`).
|
||||
세션 규칙(1회용 회전 · 30초 유예 · 끊은 세션 바로 막기 · 제한된 세션)은 웹과 같고 토큰을 나르는 길만 다르다.
|
||||
|
||||
```
|
||||
화면 · 저장소 ── ref.watch(dioProvider)
|
||||
│
|
||||
▼ 인터셉터 줄 (core/api/dio_client.dart)
|
||||
ResponseInterceptor {success,data}→data · 실패는 DioException.error = ApiException
|
||||
AuthInterceptor QueuedInterceptor — Bearer · 미리 갱신 · 401 → TokenRefresher(한 번에 하나) → 한 번 다시 · AUTH_010 → 다시 맞추기(renewed 면 한 번 다시)
|
||||
ErrorNotifyInterceptor 맨 끝 — silent 가 아니면 ErrorNotifier(스낵바 · 같은 코드 3초)
|
||||
│ 갱신 · 다시 보내기는 언래핑만 있는 안쪽 Dio 로 (같은 줄을 다시 타지 않게)
|
||||
▼
|
||||
SessionEventBus ──▶ 세션 노티파이어 (세션 끝 → 로그인 화면 · AUTH_010 → 세션 확인(안쪽 Dio) · 갱신 → RestrictionCheck 로 답 · 갱신 응답의 제한)
|
||||
```
|
||||
|
||||
```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)));
|
||||
},
|
||||
));
|
||||
}
|
||||
// 저장소 — 파싱만 한다. 문구를 만들지 않는다
|
||||
final Response<Map<String, Object?>> response = await _dio.get<Map<String, Object?>>(
|
||||
'/mail-state',
|
||||
options: ApiOptions.silent(), // 요청마다 알림 끄기 — Options(extra: {kSilentExtraKey: true}) 와 같다
|
||||
);
|
||||
|
||||
// 화면 — code 로 가른다
|
||||
try {
|
||||
await ref.read(sessionProvider.notifier).signIn(email: email, password: password);
|
||||
} on DioException catch (e) {
|
||||
if (apiErrorCodeOf(e) == 'AUTH_004') showTopError();
|
||||
}
|
||||
```
|
||||
|
||||
> 인터셉터를 별도 클래스(`AuthInterceptor`/`ErrorInterceptor`)로 분리하고 싶다면 리팩터링 가능하나, 현재는 위 인라인 구조가 SSOT다.
|
||||
- 인터셉터를 더할 때는 위 차례를 지킨다 — 알림은 늘 맨 끝(갱신 · 다시 보내기가 끝난 결과만 알린다).
|
||||
- 세션 노티파이어를 인터셉터가 직접 부르지 않는다(노티파이어가 Dio 를 쓰므로 서로 물린다) — `SessionEventBus` 로 알린다.
|
||||
- 갱신을 새로 부르는 곳(제한을 푼 직후 등)도 `TokenRefresher`(`AuthRepository.refresh`)를 지난다 — 두 갱신이 겹치면 늦은 쪽이 "이미 쓴 토큰"이다.
|
||||
|
||||
---
|
||||
|
||||
## 보안 (토큰 저장)
|
||||
|
||||
```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();
|
||||
// 토큰은 TokenStore 로만 다룬다 (core/storage/token_store.dart)
|
||||
await tokenStore.save(tokens); // 리프레시 토큰 → 보안 저장소(kRefreshTokenKey), 액세스 토큰 → 메모리
|
||||
final String? refresh = await tokenStore.readRefreshToken();
|
||||
await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser 없이 — 늦은 401 알림을 가른다)
|
||||
```
|
||||
|
||||
- JWT, 민감 정보는 반드시 `flutter_secure_storage` 사용
|
||||
- 키 문자열을 여기저기 적지 말고 `kJwtTokenKey` 상수를 쓴다 ([[coding-common]])
|
||||
- `SharedPreferences`에 토큰 저장 **금지**
|
||||
- 리프레시 토큰 · 민감 정보는 `flutter_secure_storage`(`SecureStore`)에만, 액세스 토큰은 메모리에만 둔다
|
||||
- 키 문자열을 여기저기 적지 말고 `kRefreshTokenKey` 상수를 쓴다 ([[coding-common]])
|
||||
- `SharedPreferences` 에 토큰 저장 **금지** — 첫 실행 표시(참 · 거짓 하나)만 둔다
|
||||
- 로그인 때 넣은 비밀번호는 Riverpod 상태에 두지 않는다(`LoginSecret` — 메모리 · 10분)
|
||||
- 비밀번호 · 토큰은 로그 · 예외 문장에 남기지 않는다 — `LogInterceptor` 를 넣지 않고, `AuthTokens.toString()` 은 값을 가린다
|
||||
- 추가 보안 규칙은 [[security]] 참조.
|
||||
|
||||
---
|
||||
|
||||
## 성능 & 최신 트렌드 추가 규칙
|
||||
|
||||
- **모델 직렬화**: `json_serializable` + `freezed` 사용 권장 (불변 모델)
|
||||
- **모델 직렬화**: 지금은 손으로 쓴 불변 모델(`final` 칸 + `fromJson`/`tryParse` — `features/auth/data/models`). `json_serializable` · `freezed` 는 위 "모델 생성기" 주의를 지킨 뒤에 넣는다
|
||||
- **이미지**: `cached_network_image`로 네트워크 이미지 캐싱
|
||||
- **목록**: 긴 목록은 `ListView.builder` 사용 (전체 렌더링 금지)
|
||||
- **에러 핸들링**: `Either<Failure, Success>` 패턴 고려 (`fpdart` 패키지)
|
||||
- **라우팅**: `go_router` 사용 권장 (딥링크, 중첩 라우팅 지원)
|
||||
- **라우팅**: `go_router` (도입 완료 — `core/router/`). 새 화면은 그 모듈의 라우트 묶음에 더한다
|
||||
- **환경 분리**: `--dart-define` 또는 `flutter_dotenv`로 dev/prod 분리
|
||||
|
||||
---
|
||||
@@ -241,15 +273,17 @@ await storage.deleteAll();
|
||||
- [ ] **Null Safety**: `dynamic` 금지, `?`/`!`/`late` 정확히 사용.
|
||||
- [ ] **위젯**: depth 깊어지면 private 위젯 분리, `const` 적극, `build()` 내 비즈니스 로직 금지.
|
||||
- [ ] **반응형**: 하드코딩 픽셀 금지 → `MediaQuery`/`Expanded`/`Flexible`.
|
||||
- [ ] **API**: `dio_client.dart` 의 인터셉터 패턴 재사용. 리포지토리에서 try/catch 로 메시지를 만들지 않는다.
|
||||
- [ ] **에러 분기**: `e.error as ApiException` 의 **`code` 로 분기**한다. 문구는 사전에서만 온다.
|
||||
- [ ] **API**: `dioProvider` 의 Dio 를 쓴다(새 클라이언트 · 인터셉터 차례를 만들지 않는다). 리포지토리에서 try/catch 로 메시지를 만들지 않는다. 화면이 칸에 보이는 실패는 `silent` 로 끈다.
|
||||
- [ ] **에러 분기**: `apiErrorCodeOf(e)`(또는 `e.error as ApiException`)의 **`code` 로 분기**한다. 문구는 사전에서만 온다. `AUTH_010` 은 화면이 다루지 않는다(세션이 그 단계로 옮긴다).
|
||||
- [ ] **에러 사전**: `error_lexicon.g.dart` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`.
|
||||
- [ ] **목록**: 응답은 `{items, pageInfo}` — `UserRepository.getUsers()` 패턴 재사용 ([[response-format]]).
|
||||
- [ ] **토큰**: `flutter_secure_storage` + 키 상수 `kJwtTokenKey` 통일. `SharedPreferences` 저장 금지.
|
||||
- [ ] **토큰**: `TokenStore` 로만(리프레시 = `flutter_secure_storage` 의 `kRefreshTokenKey`, 액세스 = 메모리). `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` 통과.
|
||||
- [ ] **모듈**: 새 기능은 그 모듈 폴더(`features/<모듈>/`)와 라우트 묶음 안에. 모듈끼리 import 하지 않는다 — 함께 쓰는 것은 `core/` · `shared/`. 모듈 목록을 바꾸면 웹 `app-modules.ts` 도(짝).
|
||||
- [ ] **화면**: 캔버스로 확인받은 뒤 만든다 — 그때까지 자리 화면(`PlaceholderPage`). 글자 크기는 `textTheme` 역할 이름으로만, 누르는 곳은 48dp 이상.
|
||||
- [ ] **권장 패키지**: 새 패키지는 배포 2주가 지난 판만, `pubspec.yaml` 추가 후 `flutter pub get`. 모델 생성기를 넣을 때는 위 "모델 생성기" 주의(build.yaml `generate_for`).
|
||||
- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과. 배포 판은 `flutter build apk` 로 한 번 본다(release 매니페스트에 INTERNET 권한이 있어야 네트워크가 된다).
|
||||
Flutter 판은 위 "Flutter 판"(3.41.9) — CI 도 같은 판이다.
|
||||
CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리).
|
||||
다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다.
|
||||
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
|
||||
|
||||
Reference in New Issue
Block a user