diff --git a/.github/workflows/deploy-staging-web.yml b/.github/workflows/deploy-staging-web.yml index a1b42116..fae4260f 100644 --- a/.github/workflows/deploy-staging-web.yml +++ b/.github/workflows/deploy-staging-web.yml @@ -8,28 +8,32 @@ on: - "web-seller/staging-*" - "web-admin/staging-*" -permissions: {} +permissions: + contents: read + +env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} jobs: deploy-vercel: runs-on: ubuntu-latest steps: - # 태그에서 프로젝트명과 환경 추출 (예: web-user/staging-v1.0.0) - name: Extract project name and environment from tag id: extract-project run: | TAG_NAME=${GITHUB_REF#refs/tags/} - PROJECT_NAME=$(echo $TAG_NAME | cut -d'/' -f1) - ENV_NAME=$(echo $TAG_NAME | cut -d'/' -f2 | cut -d'-' -f1) - echo "project=$PROJECT_NAME" >> $GITHUB_OUTPUT - echo "environment=$ENV_NAME" >> $GITHUB_OUTPUT - echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT + PROJECT_NAME=$(echo "$TAG_NAME" | cut -d'/' -f1) + ENV_NAME=$(echo "$TAG_NAME" | cut -d'/' -f2 | cut -d'-' -f1) + echo "project=$PROJECT_NAME" >> "$GITHUB_OUTPUT" + echo "environment=$ENV_NAME" >> "$GITHUB_OUTPUT" + echo "tag=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "app_dir=apps/$PROJECT_NAME" >> "$GITHUB_OUTPUT" echo "📦 Tag: $TAG_NAME" echo "📁 Project: $PROJECT_NAME" echo "🌍 Environment: $ENV_NAME" - # 프로젝트명과 환경 유효성 검증 - name: Validate project name and environment run: | PROJECT=${{ steps.extract-project.outputs.project }} @@ -37,66 +41,194 @@ jobs: if [[ "$PROJECT" != "web-user" && "$PROJECT" != "web-seller" && "$PROJECT" != "web-admin" ]]; then echo "❌ Invalid project name: $PROJECT" - echo "Valid project names: web-user, web-seller, web-admin" exit 1 fi if [[ "$ENV" != "staging" ]]; then echo "❌ Invalid environment: $ENV" - echo "Valid environment: staging" exit 1 fi echo "✅ Valid project: $PROJECT" echo "✅ Valid environment: $ENV" - # 프로젝트별 Vercel 웹훅 URL 설정 - - name: Set project-specific webhook URL - id: set-webhook + - name: Set Vercel project id + id: vercel-project run: | PROJECT=${{ steps.extract-project.outputs.project }} - case $PROJECT in + case "$PROJECT" in web-user) - echo "webhook_url=${{ secrets.VERCEL_WEBHOOK_URL_WEB_USER_STAGING }}" >> $GITHUB_OUTPUT + PROJECT_ID="${{ secrets.VERCEL_PROJECT_ID_WEB_USER_STAGING }}" ;; web-seller) - echo "webhook_url=${{ secrets.VERCEL_WEBHOOK_URL_WEB_SELLER_STAGING }}" >> $GITHUB_OUTPUT + PROJECT_ID="${{ secrets.VERCEL_PROJECT_ID_WEB_SELLER_STAGING }}" ;; web-admin) - echo "webhook_url=${{ secrets.VERCEL_WEBHOOK_URL_WEB_ADMIN_STAGING }}" >> $GITHUB_OUTPUT + PROJECT_ID="${{ secrets.VERCEL_PROJECT_ID_WEB_ADMIN_STAGING }}" ;; esac - # Vercel 웹훅을 통한 배포 트리거 - - name: Trigger Vercel deployment via webhook + if [ -z "$PROJECT_ID" ]; then + echo "❌ VERCEL_PROJECT_ID is not set for $PROJECT" + echo "Add VERCEL_PROJECT_ID_*_STAGING to GitHub repository secrets" + exit 1 + fi + + echo "id=$PROJECT_ID" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + # packageManager: yarn@4.x — setup-node의 cache: yarn은 Corepack 전 Yarn 1.x를 호출해 실패함 + - name: Enable Corepack + run: corepack enable + + - name: Get Yarn cache directory + id: yarn-cache-dir + run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" + + - name: Cache Yarn dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.yarn-cache-dir.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install dependencies + run: yarn install --immutable + + - name: Install Vercel CLI + run: npm install -g vercel@latest + + # 모노레포: apps/에서 vercel CLI를 실행하면 rootDirectory가 중복되어 + # apps/web-user/apps/web-user 경로가 되며 "spawn sh ENOENT"가 발생함 → 저장소 루트에서 실행 + - name: Pull Vercel environment + env: + VERCEL_PROJECT_ID: ${{ steps.vercel-project.outputs.id }} run: | - PROJECT=${{ steps.extract-project.outputs.project }} - TAG_NAME=${{ steps.extract-project.outputs.tag }} - WEBHOOK_URL=${{ steps.set-webhook.outputs.webhook_url }} + if [ -z "$VERCEL_TOKEN" ] || [ -z "$VERCEL_ORG_ID" ]; then + echo "❌ VERCEL_TOKEN and VERCEL_ORG_ID secrets are required" + exit 1 + fi + + rm -rf apps/*/.vercel .vercel + + vercel pull --yes --environment=production --token="$VERCEL_TOKEN" \ + 2>&1 | tee /tmp/vercel-pull.log + + # 의존성은 위에서 루트 yarn install 완료 — vercel build의 install 단계 스킵 + jq '.installCommand = "true"' .vercel/project.json > .vercel/project.json.tmp + mv .vercel/project.json.tmp .vercel/project.json - echo "🚀 Triggering deployment for $PROJECT (staging) via Vercel webhook..." - echo "📋 Tag: $TAG_NAME" - echo "🔗 Webhook URL: ${WEBHOOK_URL:0:50}..." # URL 일부만 표시 (보안) + - name: Build with Vercel + id: vercel-build + env: + VERCEL_PROJECT_ID: ${{ steps.vercel-project.outputs.id }} + run: | + set -o pipefail + vercel build --prod --token="$VERCEL_TOKEN" \ + 2>&1 | tee /tmp/vercel-build.log + echo "result=success" >> "$GITHUB_OUTPUT" + + - name: Deploy to Vercel + id: vercel-deploy + env: + VERCEL_PROJECT_ID: ${{ steps.vercel-project.outputs.id }} + run: | + set -o pipefail + vercel deploy --prebuilt --prod --yes --token="$VERCEL_TOKEN" \ + 2>&1 | tee /tmp/vercel-deploy.log - if [ -z "$WEBHOOK_URL" ]; then - echo "❌ Error: Webhook URL is not set for $PROJECT" - echo "Please set VERCEL_WEBHOOK_URL_${PROJECT^^}_STAGING secret in GitHub repository settings" + DEPLOY_URL=$(grep -Eo 'https://[a-zA-Z0-9./_-]+' /tmp/vercel-deploy.log | tail -n 1) + if [ -z "$DEPLOY_URL" ]; then + echo "❌ Could not parse deployment URL from Vercel CLI output" exit 1 fi - # Vercel 웹훅 호출 - echo "📤 Calling Vercel webhook..." - HTTP_STATUS=$(curl -s -o /tmp/vercel_response.txt -w "%{http_code}" \ + echo "url=$DEPLOY_URL" >> "$GITHUB_OUTPUT" + echo "✅ Deployed: $DEPLOY_URL" + + - name: Notify Discord + if: always() + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL_WEB_FE }} + PROJECT: ${{ steps.extract-project.outputs.project }} + TAG: ${{ steps.extract-project.outputs.tag }} + ENVIRONMENT: ${{ steps.extract-project.outputs.environment }} + DEPLOY_URL: ${{ steps.vercel-deploy.outputs.url }} + JOB_STATUS: ${{ job.status }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [ -z "$DISCORD_WEBHOOK_URL" ]; then + echo "⚠️ DISCORD_WEBHOOK_URL_WEB_FE is not set — skipping notification" + exit 0 + fi + + case "$JOB_STATUS" in + success) STATUS_LABEL="✅ 배포 성공"; COLOR=5763719 ;; + failure) STATUS_LABEL="❌ 배포 실패"; COLOR=15548997 ;; + cancelled) STATUS_LABEL="⚠️ 배포 취소"; COLOR=9807270 ;; + *) STATUS_LABEL="ℹ️ 배포 종료 ($JOB_STATUS)"; COLOR=3447003 ;; + esac + + LOG_SOURCE="/tmp/vercel-build.log" + if [ ! -s "$LOG_SOURCE" ]; then + LOG_SOURCE="/tmp/vercel-pull.log" + fi + if [ ! -s "$LOG_SOURCE" ]; then + LOG_SOURCE="/tmp/vercel-deploy.log" + fi + + LOG_SNIPPET="로그 파일 없음" + if [ -s "$LOG_SOURCE" ]; then + LOG_SNIPPET=$(tail -c 900 "$LOG_SOURCE" | sed 's/```/``\`/g') + fi + + DEPLOY_FIELD="${DEPLOY_URL:-배포 URL 없음 (빌드/배포 단계 실패)}" + if [ -n "$DEPLOY_URL" ]; then + DEPLOY_FIELD="[$DEPLOY_URL]($DEPLOY_URL)" + fi + + PAYLOAD=$(jq -n \ + --arg title "$STATUS_LABEL — $PROJECT (staging)" \ + --argjson color "$COLOR" \ + --arg project "$PROJECT" \ + --arg tag "$TAG" \ + --arg environment "$ENVIRONMENT" \ + --arg deploy "$DEPLOY_FIELD" \ + --arg run_url "$RUN_URL" \ + --arg log "$LOG_SNIPPET" \ + '{ + embeds: [{ + title: $title, + color: $color, + fields: [ + { name: "프로젝트", value: $project, inline: true }, + { name: "환경", value: $environment, inline: true }, + { name: "태그", value: ("`" + $tag + "`"), inline: false }, + { name: "배포 URL", value: $deploy, inline: false }, + { name: "GitHub Actions", value: ("[워크플로우 로그](" + $run_url + ")"), inline: false }, + { name: "Vercel 로그 (마지막 900자)", value: ("```\n" + $log + "\n```"), inline: false } + ], + timestamp: (now | strftime("%Y-%m-%dT%H:%M:%SZ")) + }] + }') + + HTTP_STATUS=$(curl -s -o /tmp/discord_response.txt -w "%{http_code}" \ -X POST \ -H "Content-Type: application/json" \ - "$WEBHOOK_URL") + -d "$PAYLOAD" \ + "$DISCORD_WEBHOOK_URL") if [ "$HTTP_STATUS" -ge 200 ] && [ "$HTTP_STATUS" -lt 300 ]; then - echo "✅ Webhook triggered successfully (HTTP $HTTP_STATUS)" - cat /tmp/vercel_response.txt 2>/dev/null || echo "No response body" + echo "✅ Discord notification sent (HTTP $HTTP_STATUS)" else - echo "❌ Webhook call failed (HTTP $HTTP_STATUS)" - cat /tmp/vercel_response.txt - exit 1 + echo "⚠️ Discord notification failed (HTTP $HTTP_STATUS) — deploy result is unchanged" + cat /tmp/discord_response.txt fi diff --git "a/docs/infra/vercel/Vercel \353\260\260\355\217\254 - \352\260\200\354\235\264\353\223\234.md" "b/docs/infra/vercel/Vercel \353\260\260\355\217\254 - \352\260\200\354\235\264\353\223\234.md" index b336c5cd..3b33654a 100644 --- "a/docs/infra/vercel/Vercel \353\260\260\355\217\254 - \352\260\200\354\235\264\353\223\234.md" +++ "b/docs/infra/vercel/Vercel \353\260\260\355\217\254 - \352\260\200\354\235\264\353\223\234.md" @@ -37,7 +37,7 @@ Picake 프로젝트의 web-user, web-seller, web-admin 애플리케이션을 Ver } ``` -이 설정은 웹훅을 통한 수동 배포만 사용한다는 의미입니다. +이 설정은 브랜치 push 시 Vercel 자동 배포를 막고, **태그 + GitHub Actions**로만 배포한다는 의미입니다. ### 2. Vercel 콘솔 설정 @@ -54,58 +54,31 @@ Picake 프로젝트의 web-user, web-seller, web-admin 애플리케이션을 Ver | web-admin-staging | staging | `staging` | Production | | web-admin-production | production | `main` | Production | -**중요 사항:** - -- staging과 production 환경 모두 별도의 Vercel 프로젝트로 구성됩니다 -- 각 프로젝트는 바라보는 브랜치만 다릅니다 (staging 브랜치 또는 main 브랜치) -- 모든 프로젝트는 Production 타입으로 배포됩니다 - #### 2.2 프로젝트 생성 -1. Vercel 대시보드에서 새 프로젝트 생성 (총 6개) -2. GitHub 저장소 연결 - - web-user-staging: `staging` 브랜치 연결 - - web-user-production: `main` 브랜치 연결 - - web-seller-staging: `staging` 브랜치 연결 - - web-seller-production: `main` 브랜치 연결 - - web-admin-staging: `staging` 브랜치 연결 - - web-admin-production: `main` 브랜치 연결 -3. 빌드 설정: - - Framework: Next.js (web-user) / Vite (web-seller, web-admin) - - Build Command: `next build` (web-user) / `yarn build` (web-seller, web-admin) - - Install Command: `yarn install` - - Root Directory: `apps/web-user` 또는 `apps/web-seller` 또는 `apps/web-admin` - - Output Directory: `.next` (web-user) / `dist` (web-seller) / `dist` (web-admin) +1. 이전에 만들었던 프로젝트 설정 확인 #### 2.3 환경변수 설정 1. Vercel 대시보드 → 프로젝트 설정 → Environment Variables 2. 필요한 환경변수 추가 -| 프로젝트 | 환경변수 | staging 예시 | -| ---------- | ------------------------ | --------------------------------- | -| web-user | (프로젝트별 설정) | — | -| web-seller | `VITE_PUBLIC_API_DOMAIN` | `https://api-staging.picakes.com` | -| web-admin | `VITE_PUBLIC_API_DOMAIN` | `https://api-staging.picakes.com` | - -#### 2.4 Deploy Hook 생성 (웹훅) - -1. Vercel 대시보드 → 프로젝트 설정 → Git → Deploy Hooks -2. Deploy Hook 생성 -3. 생성된 웹훅 URL 복사 (다음 단계에서 사용) - -### 3. GitHub 환경변수 설정 +- https://vercel.com/account/settings/tokens url직접 입력 -> 토큰 생성 및 깃허브 VERCEL_TOKEN secrets 설정 +- Vercel → 팀 선택 → Settings → General → Team ID 복사 및 깃허브 VERCEL_ORG_ID secrets 설정 +- Vercel → 팀 선택 → 각 프로젝트 -> Settings -> General -> Project ID 복사 및 깃허브 VERCEL_PROJECT_ID secrets 설정 -1. GitHub 저장소 → Settings → Secrets and variables → Actions -2. New repository secret 클릭 -3. 다음 Secrets 추가: - - `VERCEL_WEBHOOK_URL_WEB_USER_STAGING`: web-user 스테이징 환경 Vercel 웹훅 URL - - `VERCEL_WEBHOOK_URL_WEB_SELLER_STAGING`: web-seller 스테이징 환경 Vercel 웹훅 URL - - `VERCEL_WEBHOOK_URL_WEB_ADMIN_STAGING`: web-admin 스테이징 환경 Vercel 웹훅 URL +| Secret | 설명 | +| -------------------------------------- | ------------------------------ | +| `VERCEL_TOKEN` | Vercel API 토큰 | +| `VERCEL_ORG_ID` | Vercel 팀/개인 Org ID | +| `VERCEL_PROJECT_ID_WEB_USER_STAGING` | web-user-staging 프로젝트 ID | +| `VERCEL_PROJECT_ID_WEB_SELLER_STAGING` | web-seller-staging 프로젝트 ID | +| `VERCEL_PROJECT_ID_WEB_ADMIN_STAGING` | web-admin-staging 프로젝트 ID | +| `DISCORD_WEBHOOK_URL_WEB_FE` | 배포 결과 Discord 알림 웹훅 | -### 4. GitHub 워크플로 생성 (태그 기반) +### 3. GitHub 워크플로 (태그 기반 + Discord 알림) -`.github/workflows/deploy-staging-web.yml` 파일을 생성하여 태그 기반 배포 워크플로를 설정합니다. +`.github/workflows/deploy-staging-web.yml`에서 태그 푸시 시 Vercel CLI로 빌드·배포하고, 성공/실패 시 Discord로 알립니다. **워크플로 트리거:** @@ -115,14 +88,14 @@ Picake 프로젝트의 web-user, web-seller, web-admin 애플리케이션을 Ver **워크플로 동작:** -1. 태그에서 프로젝트명과 환경 추출 -2. 프로젝트명과 환경 유효성 검증 -3. 프로젝트별 Vercel 웹훅 URL 가져오기 -4. Vercel 웹훅 호출하여 배포 트리거 +1. 태그에서 프로젝트명·환경 추출 및 검증 +2. 모노레포 의존성 설치 (`yarn install`) +3. `vercel pull` → `vercel build` → `vercel deploy` (배포 완료까지 대기) +4. Discord에 성공/실패, 배포 URL, GitHub Actions 로그 링크, Vercel 빌드 로그 일부 전송 자세한 워크플로 내용은 `.github/workflows/deploy-staging-web.yml` 파일을 참고하세요. -### 5. 도메인 구성 (선택사항) +### 4. 도메인 구성 (선택사항) 커스텀 도메인 설정은 [AWS Route53(도메인) - 가이드](<../aws/AWS%20Route53(도메인)%20-%20가이드.md>)를 참고하세요. diff --git "a/docs/web-user/\354\227\220\353\237\254\354\231\200 \353\241\234\353\224\251 \354\262\230\353\246\254 - \352\260\200\354\235\264\353\223\234.md" "b/docs/web-user/\354\227\220\353\237\254\354\231\200 \353\241\234\353\224\251 \354\262\230\353\246\254 - \352\260\200\354\235\264\353\223\234.md" index c92f7c13..866291a6 100644 --- "a/docs/web-user/\354\227\220\353\237\254\354\231\200 \353\241\234\353\224\251 \354\262\230\353\246\254 - \352\260\200\354\235\264\353\223\234.md" +++ "b/docs/web-user/\354\227\220\353\237\254\354\231\200 \353\241\234\353\224\251 \354\262\230\353\246\254 - \352\260\200\354\235\264\353\223\234.md" @@ -2,201 +2,266 @@ ## 개요 -이 문서는 Picake 웹 사용자 앱에서 에러와 로딩을 처리하는 방식에 대해 설명합니다. +이 문서는 Picake **웹 사용자 앱**(`apps/web-user`)에서 에러와 로딩을 처리하는 **현재 구현**을 설명합니다. -## 에러 처리 +> **다른 앱과의 차이**: `web-seller` / `web-admin`은 `ContentLoading`, `AuthInitializerProvider` 등 별도 패턴을 사용합니다. 사용자 앱은 **Skeleton + `isLoading`** 중심이며, 이 문서는 web-user만 다룹니다. -### 1. ErrorBoundary (UI 에러 처리) +--- -**용도**: React 컴포넌트에서 발생하는 UI 에러를 잡아서 처리 +## 로딩 처리 (요약) -**구현 위치**: `apps/web-user/src/common/components/providers/ErrorBoundaryProvider.tsx` +| 계층 | 수단 | 용도 | +|------|------|------| +| 1 | 루트 `Suspense` + `LoadingFallback` | `useSearchParams` 등으로 suspend 되는 구간 | +| 2 | React Query `isLoading` + **Skeleton** | 페이지·섹션·목록 **첫 로딩** (주력) | +| 3 | `isFetchingNextPage` + 인라인 스피너 | 무한 스크롤 **추가 로딩** | +| 4 | Mutation `isPending` | 버튼·폼 제출·좋아요 등 **액션 로딩** | +| 5 | `Toast` `showSpinner` | 지역 변경 등 짧은 **피드백** | -```typescript -export function ErrorBoundaryProvider({ children }: ErrorBoundaryProviderProps) { - const handleError = (error: Error, errorInfo: ErrorInfo) => { - // 에러 로깅 (추후 에러 모니터링 서비스 연동 가능) - console.error("ErrorBoundary caught an error:", error, errorInfo); - }; +**사용하지 않는 것**: `useSuspenseQuery`, Query `suspense: true`, `throwOnError: true`(주석 처리됨). - return ( - ( - - )} +--- + +## 로딩 처리 (상세) + +### 1. 전역 Suspense + LoadingFallback + +**구현 위치**: `apps/web-user/src/app/layout.tsx` + +```typescript + + + } > - {children} - - ); -} + {children} + + + ``` **특징**: -- `react-error-boundary` 라이브러리 사용 -- 에러 발생 시 `ErrorFallback` 컴포넌트로 대체 -- 에러 로깅 기능 포함 -- 재시도 기능 제공 -- 리액트 쿼리 throwOnError true설정 시, 비동기 에러 발생 시 예외(throw error)를 던지며 상위의 ErrorBoundary 컴포넌트에서 인식됩니다. +- `RootWrapperLayout` 안에서 `AuthProvider`, `Header`, `Alert`, `LoginBottomSheet` 등이 함께 마운트됩니다. +- API 데이터 로딩의 **주된 UI는 Suspense가 아니라** 아래 2번(Skeleton + `isLoading`)입니다. +- 루트 Suspense는 주로 Next.js 클라이언트 컴포넌트가 suspend 할 때(예: `useSearchParams` 사용 페이지) fallback을 보여줍니다. + +**LoadingFallback** (`common/components/fallbacks/LoadingFallback.tsx`): + +| variant | 설명 | +|---------|------| +| `overlay` (기본) | 전체 화면 오버레이 + `loading-spinner-large` | +| `corner` | 우측 하단 작은 스피너 (정의만 있음, **현재 미사용**) | -### 2. React Query onError (비동기 에러 처리) +스피너 스타일: `common/styles/globals.css`의 `.loading-spinner-large`, `.loading-spinner-small` -**용도**: API 호출에서 발생하는 비동기 에러를 처리 +### 2. React Query `isLoading` + Skeleton (주력) -**구현 위치**: `apps/web-user/src/features/auth/hooks/queries/useAuth.ts` +**용도**: `useQuery` / `useInfiniteQuery`의 **첫 fetch** 동안 레이아웃을 유지한 채 로딩 표시 + +**기본 컴포넌트** (`common/components/skeleton/`): + +- `Skeleton`, `SkeletonCircle`, `SkeletonText` — 블록 단위 +- `HomeSkeleton`, `ProductDetailSkeleton`, `StoreDetailSkeleton`, `AlarmSkeleton`, `ChatListSkeleton`, `MyReviewsSkeleton`, `RecentProductsSkeleton` — 화면별 조합 + +**페이지 예시** — 상품 상세: ```typescript -export function useLogin() { - const { showAlert } = useAlertStore(); - - return useMutation({ - mutationFn: authApi.login, - onSuccess: (data) => { - // 성공 처리 - }, - onError: (error) => { - showAlert({ - type: "error", - title: "오류", - message: getApiMessage.error(error), - }); - }, - }); +const { data, isLoading } = useProductDetail(productId); + +if (isLoading) { + return ; } ``` -**특징**: +**섹션 예시** — 홈 슬라이더 (`CakeListSlider`): + +- 부모에서 `isLoading`을 계산해 prop으로 전달 +- 로딩 중에는 카드 형태의 `Skeleton` 블록을 여러 개 렌더 + +**목록 예시** — 검색·좋아요·채팅 등: + +- `isLoading`일 때 섹션/페이지 스켈레톤 또는 빈 목록 대신 placeholder +- 데이터 없음·에러는 별도 분기 + +**선택 가이드**: + +| 상황 | 권장 | +|------|------| +| 전체 페이지 첫 진입 | 전용 `*Skeleton` early return | +| 목록·카드 일부 | `Skeleton` 또는 슬라이더 내 placeholder | +| 버튼 한 번 누름 | `isPending` (아래 4번) | -- 대부분의 API 에러는 `onError`에서 Alert으로 표시 +### 3. 무한 스크롤 추가 로딩 (`isFetchingNextPage`) -### 3. 비즈니스 로직이 있는 경우의 에러 처리 +**훅**: `common/hooks/useInfiniteScroll.ts` — `IntersectionObserver`로 `fetchNextPage` 호출 -**구현 위치**: `apps/web-user/src/app/auth/login/google/page.tsx` +**UI**: 전용 컴포넌트 없이, 목록 하단에 인라인 스피너 ```typescript -const handleGoogleCallback = async (code: string) => { - // 특별한 비즈니스 로직(휴대폰 인증 필요)이 있으므로 try-catch 유지 - try { - await googleLoginMutation.mutateAsync(code); - } catch (error: any) { - const { googleId, googleEmail, message } = error?.response?.data?.data || {}; - - if (message === "휴대폰 인증이 필요합니다.") { - setGoogleLoginData({ googleId, googleEmail }); - setShowPhoneVerification(true); - } else { - // 다른 오류의 경우 로그인 페이지로 이동 - router.push(PATHS.HOME); - } - } -}; +{isFetchingNextPage && ( +
+
+ 더 많은 상품을 불러오는 중... +
+)} ``` -**특징**: +**사용 예**: `SearchProductListSection`, `SearchStoreListSection`, `chat/page`, `mypage/recent`, 좋아요 목록, 주문 목록, 스토어 상품/리뷰 탭 등 -- 비즈니스 로직이 필요한 경우 컴포넌트 내부에서 `try-catch`로 처리 -- `mutateAsync`를 사용하여 에러를 직접 catch -- React Query의 `onError`에서 `throw error`로 에러를 다시 전파 +### 4. Mutation `isPending` (액션 로딩) -### 5. Axios 인터셉터를 통한 에러 처리 +**용도**: 로그인·회원가입·좋아요·결제·업로드·주문 변경 등 **사용자 액션** 중복 방지 및 버튼 상태 표시 -**구현 위치**: `apps/web-user/src/common/config/axios.config.ts` +**구현 위치**: `features/*/hooks/mutations/*`, 각 폼·카드·바텀시트 ```typescript -apiClient.interceptors.response.use( - (response: AxiosResponse) => { - return response; - }, - async (error: AxiosError) => { - const status = error.response?.status; - const message = error.response?.data?.data?.message; - - // 401 에러 처리 (토큰 갱신) - if (status === 401 && message?.includes("ACCESS_TOKEN_INVALID")) { - // 토큰 갱신 로직 - } +const { mutate, isPending } = useSomeMutation(); - // 그 외 에러는 그대로 전파 - return Promise.reject(error); - }, -); + ``` -**특징**: +**패턴**: -- 401 에러 시 자동 토큰 갱신 시도 -- 다른 에러는 그대로 전파 -- 자세한 내용은 [통합 인증 - 가이드](../common/통합 인증 - 가이드.md) 참고 +- `disabled={isPending}` 또는 `if (isPending) return` (낙관적 업데이트 시 `onError` 롤백) +- 여러 mutation 조합: `isAddingLike || isRemovingLike` 등 -## 로딩 처리 +### 5. 페이지 단위 Suspense (OAuth 콜백 등) -### 1. Suspense (UI 로딩 처리) +`useSearchParams`를 쓰는 페이지는 **페이지 내부**에 별도 `Suspense`를 둡니다. 루트 `LoadingFallback`과 별개로, 단순 텍스트 fallback을 쓰는 경우가 많습니다. -**용도**: React 컴포넌트의 로딩 상태를 처리 +**구현 위치**: -**구현 위치**: `apps/web-user/src/app/layout.tsx` +- `app/auth/login/google/page.tsx` +- `app/auth/login/kakao/page.tsx` +- `app/auth/register/google/page.tsx` +- `app/auth/register/kakao/page.tsx` ```typescript - - +export default function GoogleAuthCallbackPage() { + return ( } + fallback={ +
+

로딩 중...

+
+ } > - {children} +
- - -
-
+ ); +} ``` -**특징**: +콜백 본문에서는 `authApi.googleLogin` 등을 `try/catch`로 호출하며, 화면에는 "구글 로그인 처리 중..." 문구를 표시합니다. + +### 6. Toast 스피너 (부가 피드백) + +**구현 위치**: `common/components/headers/Header.tsx` + +지역 선택 후 상품 목록 refetch가 끝날 때까지 `Toast` + `showSpinner` ("위치 설정 중..")를 표시합니다. `useIsFetching({ queryKey: ["product", "list"] })`로 refetch 완료를 감지합니다. -- 전역 Suspense로 페이지 로딩 처리 -- `LoadingFallback` 컴포넌트로 로딩 UI 표시 -- `overlay` 방식으로 전체 화면 로딩 표시 +--- -### 2. React Query isPending (비동기 로딩 처리) +## QueryClient 기본값 -**용도**: API 호출 중 로딩 상태를 처리 +**구현 위치**: `apps/web-user/src/common/config/query-client.ts` -**구현 위치**: 각 폼 컴포넌트들 +- `retry: 0` (queries / mutations) +- `throwOnError: true` — **주석 처리**, ErrorBoundary와 Query 에러를 **연동하지 않음** +- API 로딩 UI는 훅의 `isLoading` / `isPending` / `isFetchingNextPage`로만 처리 + +--- + +## 에러 처리 + +### 1. ErrorBoundary (렌더링/UI 에러) + +**구현 위치**: `apps/web-user/src/common/components/providers/ErrorBoundaryProvider.tsx` + +- `react-error-boundary` + `ErrorFallback` +- `console.error` 로깅 (추후 모니터링 연동 가능) +- **React Query fetch 에러는 기본적으로 여기로 오지 않음** (`throwOnError` 미사용) + +### 2. React Query `onError` (API 에러 — Alert) + +**구현 위치**: `apps/web-user/src/features/auth/hooks/mutations/useAuthMutation.ts` 등 ```typescript -// 로그인 폼 예시 -export default function LoginForm() { - const loginMutation = useLogin(); +return useMutation({ + mutationFn: authApi.someAction, + onError: (error) => { + showAlert({ + type: "error", + title: "오류", + message: getApiMessage.error(error), + }); + }, +}); +``` - return ( - - ); +대부분의 mutation API 에러는 Alert으로 표시합니다. + +### 3. 비즈니스 분기 — `try/catch` + `authApi` + +**구현 위치**: `apps/web-user/src/app/auth/login/google/page.tsx` (카카오 동일 패턴) + +OAuth 콜백에서는 mutation 대신 **`authApi` 직접 호출 + `try/catch`** 로 분기합니다. + +```typescript +try { + const data = await authApi.googleLogin(code); + login(data.accessToken); + router.replace(PATHS.HOME); +} catch (error: unknown) { + const { googleId, googleEmail, message } = /* response 파싱 */; + + if (message === AUTH_ERROR_MESSAGES.PHONE_VERIFICATION_REQUIRED && googleId && googleEmail) { + router.replace(`${PATHS.AUTH.GOOGLE_REGISTER}?...`); + } else { + router.replace(PATHS.HOME); + showAlert({ type: "error", ... }); + } } ``` -**특징**: +휴대폰 미인증 등 **응답 메시지에 따른 라우팅**이 필요할 때 이 패턴을 사용합니다. -- 지역 컴포넌트에서 `isPending` 사용 -- 버튼 비활성화 및 텍스트 변경 -- 낙관적 업데이트를 위한 로딩 UI 제공 +### 4. Axios 인터셉터 -### 3. LoadingFallback 컴포넌트 +**구현 위치**: `apps/web-user/src/common/config/axios.config.ts` -**구현 위치**: `apps/web-user/src/common/components/fallbacks/LoadingFallback.tsx` +- `401` + `ACCESS_TOKEN_INVALID` → 토큰 갱신 시도 +- 그 외 → `Promise.reject`로 호출부(React Query / try-catch)에 전달 -**특징**: +자세한 내용: [통합 인증 - 가이드](../common/통합%20인증%20-%20가이드.md) + +### 5. Query `isError` / 빈 데이터 UI + +페이지·섹션에서 `!data` 또는 query `isError` 시 "불러오지 못했습니다" 등 **인라인 메시지**를 표시하는 경우가 있습니다. 전역 ErrorBoundary 대신 **지역 처리**가 일반적입니다. + +--- + +## 새 기능 추가 시 체크리스트 + +1. **첫 목록/상세 로딩** → `isLoading` + 기존 Skeleton 패턴 또는 새 `*Skeleton` 추가 +2. **무한 스크롤** → `useInfiniteScroll` + `isFetchingNextPage` 하단 스피너 +3. **버튼·제출** → mutation `isPending` +4. **`useSearchParams` 페이지** → 페이지 내부 `Suspense` boundary +5. **전체 화면 블로킹** → 꼭 필요할 때만 `LoadingFallback` (남용 지양) +6. **에러** → mutation `onError` + Alert, 분기 필요 시 `try/catch` + +--- -- `overlay` 방식: 전체 화면 로딩 -- `corner` 방식: 우측 하단 로딩 +## 참고: seller/admin과의 차이 -## 참고사항 +| 항목 | web-user | web-seller / web-admin | +|------|----------|-------------------------| +| 본문 로딩 | Skeleton | `ContentLoading` + `LoadingSpinner` | +| 앱 초기화 | `AuthProvider` (persist) | `AuthInitializerProvider` + `LoadingFallback` | +| 무한 스크롤 하단 | 인라인 스피너 | seller: `InfiniteScrollLoading` | +| 백그라운드 refetch | Header 등 지역 로직 | seller: `FetchStatusInline` | -1. **useQuerySuspense**: 리액트 쿼리(TanStack Query v5)의 기본 기능으로, 비동기 데이터를 불러오는 동안 로딩 표시를 해줍니다. 상위의 Suspense 컴포넌트에서 인식됩니다. -2. **throwOnError**: 리액트 쿼리(TanStack Query v5)의 기본 기능으로, 비동기 데이터를 불러오는 동안 에러 발생 시 예외(throw error)를 던집니다. 상위의 ErrorBoundary 컴포넌트에서 인식됩니다. +통일이 필요하면 팀 합의 후 공통 패키지 또는 컴포넌트 추출을 검토합니다. diff --git a/yarn.lock b/yarn.lock index 2322f60a..9ef87956 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4054,6 +4054,7 @@ __metadata: react: "npm:^18.2.0" react-dom: "npm:^18.2.0" react-error-boundary: "npm:^6.0.0" + react-quill: "npm:^2.0.0" react-router-dom: "npm:^7.9.4" tailwind-merge: "npm:^2.6.0" tailwindcss: "npm:^3.4.18"