Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions .ai/sentry.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,16 @@ Settings → Environment Variables에 위 4개 등록. 적용 환경 체크박

- `SENTRY_AUTH_TOKEN`은 **Sensitive로 표시**
- 환경 구분: 코드에서 `process.env.NEXT_PUBLIC_VERCEL_ENV ?? process.env.NODE_ENV`로
`environment` 태깅. Vercel이 자동으로 `NEXT_PUBLIC_VERCEL_ENV`에
`production` / `preview` / `development` 중 하나 주입 → Preview 배포는
Sentry에서 `environment: preview`로 분류되어 Discord 알림(`production`만 필터)에 안 섞임.
`environment` 태깅. Vercel은 서버용 `VERCEL_ENV`만 자동 주입하고 `NEXT_PUBLIC_*`
접두사 변수는 자동 주입하지 않으므로, `next.config.mjs`의 `env` 블록에서
`NEXT_PUBLIC_VERCEL_ENV: process.env.VERCEL_ENV`로 매핑해 빌드타임에 클라이언트
번들에 인라인한다. → Preview 배포는 Sentry에서 `environment: preview`로 분류되어
Discord 알림(`production`만 필터)에 안 섞임.

> ⚠️ `next.config.mjs`의 `env` 매핑이 빠지면 클라이언트에서 `NEXT_PUBLIC_VERCEL_ENV`가
> `undefined` → `NODE_ENV`로 fallback → Vercel 빌드에선 NODE_ENV가 항상 `production`이라
> dev 서버 에러까지 `environment: production`으로 태깅되어 `#sentry-prod`에 섞이는
> 버그가 발생한다.

### iOS 빌드 (Capacitor)

Expand Down Expand Up @@ -112,6 +119,7 @@ prod 트레이싱 10%는 비용 통제용. 에러 발생 세션은 100% 리플
별도 코드 없이 자동으로 잡히는 것들:

- React 컴포넌트 렌더 중 throw → `global-error.tsx`가 잡아서 자동 전송
- `<ErrorBoundary>`로 잡힌 React 트리 에러 → `componentDidCatch`에서 자동 전송 (`componentStack` 컨텍스트 포함)
- `window.onerror` / `unhandledrejection` → SDK 글로벌 핸들러
- Next.js 서버 사이드 에러 → `instrumentation.ts`의 `onRequestError`
- iOS 네이티브 크래시 (Capacitor 빌드만) → @sentry/capacitor가 자동
Expand Down Expand Up @@ -228,8 +236,8 @@ npm run dev
# 5번 섹션 → "Error Boundary 테스트 보기" → "에러 발생시키기"
```

> Note: `ErrorBoundary`로 잡힌 에러는 unhandled가 아니라 Sentry에 자동 전송되지 않을 수 있음.
> 확실히 테스트하려면 브라우저 콘솔에서 `setTimeout(() => { throw new Error("client test"); }, 0);` 실행.
> `ErrorBoundary` (`src/components/common/ErrorBoundary.tsx`)는 `componentDidCatch`에서 `Sentry.captureException`을 호출하므로 트리 안 에러도 자동 전송된다. fallback UI는 뜨고 Sentry에도 같이 들어감.
> 글로벌 unhandled 경로(`window.onerror`)까지 확인하려면 브라우저 콘솔에서 `setTimeout(() => { throw new Error("client test"); }, 0);` 실행.

### 3. 서버 사이드 에러

Expand All @@ -246,24 +254,39 @@ Next.js 페이지 RSC/loader에서 throw → 자동으로 `onRequestError`가

### 알림 채널 구성

| 채널 | 받는 환경 | 트리거 |
| -------------- | --------------- | ------------------------------- |
| `#sentry-prod` | `production` 만 | 신규 이슈 / escalating / 재발생 |
| 채널 | Sentry environment | 대상 배포 | 트리거 |
| -------------- | ------------------ | -------------------------- | ------------------------------- |
| `#sentry-prod` | `production` | `www.gotcha.it.com` (main) | 신규 이슈 / escalating / 재발생 |
| `#sentry-dev` | `preview` | `dev.gotcha.it.com` (dev) | 신규 이슈 / escalating |

> dev/staging 환경은 **Discord 알림에서 제외**. 개발 중 발생하는 에러는 Sentry 대시보드에서만 확인하고, Discord 노이즈는 prod 이슈로만 한정.
> **환경 작명**: `next.config.mjs`의 `env` 매핑으로 Vercel이 주입하는 `VERCEL_ENV` 값(`production` / `preview` / `development`)이 그대로 `NEXT_PUBLIC_VERCEL_ENV`에 인라인되어 Sentry environment 태그로 박힌다. 알림 룰의 environment 필터도 동일한 값(`production`, `preview`)으로 설정한다.
>
> ⚠️ PR 자동 preview 배포가 활성화돼 있다면 같은 `preview` 환경으로 들어와 `#sentry-dev`에 섞일 수 있다. 노이즈가 심해지면 `dev-deploy.yml`에서 별도 env 주입(예: `NEXT_PUBLIC_VERCEL_ENV=dev` override)으로 분리 검토.

### 알림 규칙 (Sentry → Alerts → Issue Alert)

생성 시 핵심 옵션:
#### 운영 (production)

- **Source**: project = `gotcha-web`
- **Filter Issues**: Environment = **`production` 만** 선택 (`All Environments`는 dev 노이즈까지 옴 → 금지)
- **Environment**: `production`
- **WHEN** (any of):
- `A new issue is created` ✓ 필수
- `An issue escalates` ✓ 필수 (잠잠하던 에러 폭증)
- `A resolved issue becomes unresolved` ✓ 권장 (재발 추적)
- `An issue is resolved` ✗ 비권장 (메시지 폭주)
- **THEN**: `Send a Discord notification` → 서버/채널 선택
- **THEN**: `Send a Discord notification` → `#sentry-prod`
- **Throttling**: 30분

#### 개발 (dev)

- **Source**: project = `gotcha-web`
- **Environment**: `preview`
- **WHEN** (any of):
- `A new issue is created` ✓
- `An issue escalates` ✓
- `A resolved issue becomes unresolved` ✗ (dev에선 노이즈)
- **THEN**: `Send a Discord notification` → `#sentry-dev`
- **Rate Limit**: `최대 10건 / 60분` 권장 (dev 이슈 폭증 시 채널 도배 방지)

### 알림 메시지에서 받는 정보

Expand Down
41 changes: 41 additions & 0 deletions .claude/commands/commit.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,47 @@ EOF

완료 후 PR URL을 사용자에게 출력.

### 8. PR 머지 후 로컬 브랜치 정리

PR 생성 직후, 사용자에게 머지 후 자동 정리 여부를 안내:

```text
PR 머지가 완료되면 알려주세요. 다음을 자동으로 처리합니다:
- dev로 체크아웃
- origin/dev pull (최신화)
- 로컬 작업 브랜치 삭제
- (원격 브랜치는 GitHub "Delete branch" 버튼이 처리)
```

사용자가 "머지됐다" / "merged" 등을 알리면 **반드시 머지 여부를 먼저 확인**한 뒤 정리 실행:

**1) 머지 여부 확인** (병렬):

- `gh pr view <PR번호> --json state,mergedAt,mergeCommit` — `state == "MERGED"` 인지 검증
- `git branch --show-current` — 현재 브랜치 확인

머지 상태가 아니면 정리 중단하고 사용자에게 현 상태 보고.

**2) 정리 실행** (순차):

```bash
git checkout dev
git pull origin dev
git branch -D <branch-name>
```

- 이 저장소는 **squash merge가 기본**이므로 머지된 브랜치라도 `git branch -d`(안전 삭제)는 거의 항상 실패한다 (커밋 SHA가 다름). 따라서 `gh pr view`로 `state == "MERGED"` 검증을 마쳤다면 `-D`(강제 삭제) 사용이 정상 경로.
- **전제 조건**: 1단계의 `gh pr view` 머지 검증을 반드시 통과한 상태여야 함. 검증 없이 `-D` 사용 금지.
- merge 방식이 squash가 아닌 일반/rebase merge인 경우엔 `-d`도 성공하지만, 일관성을 위해 `-D` 사용해도 무방.

**3) 결과 보고**:

```text
✅ 정리 완료
- 현재 브랜치: dev (origin/dev 최신)
- 삭제된 로컬 브랜치: <branch-name>
```

## 안전 수칙 (항상 적용)

- main/master 직접 푸시 금지
Expand Down
32 changes: 18 additions & 14 deletions instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,24 @@ const baseConfig = {
enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN,
};

if (isCapacitor) {
// Capacitor 네이티브 빌드: iOS 네이티브 크래시까지 캡처 (@sentry/capacitor가 JS SDK를 래핑)
// require로 받는 이유 — @sentry/capacitor는 자체 @sentry/core를 번들해 nextjs와 타입이 다름
// eslint-disable-next-line @typescript-eslint/no-require-imports
const SentryCapacitor = require("@sentry/capacitor");
SentryCapacitor.init(baseConfig, Sentry.init);
} else {
Sentry.init({
...baseConfig,
// 세션 리플레이: 평상시 10%, 에러 발생 세션 100% (웹 전용)
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
integrations: [Sentry.replayIntegration()],
});
// providers.tsx에서 side-effect import할 때 SSR/prerender 단계에서도 이 모듈이 평가되므로
// Sentry.replayIntegration() 같은 client-only API가 server에서 호출되는 것을 가드한다.
if (typeof window !== "undefined") {
if (isCapacitor) {
// Capacitor 네이티브 빌드: iOS 네이티브 크래시까지 캡처 (@sentry/capacitor가 JS SDK를 래핑)
// require로 받는 이유 — @sentry/capacitor는 자체 @sentry/core를 번들해 nextjs와 타입이 다름
// eslint-disable-next-line @typescript-eslint/no-require-imports
const SentryCapacitor = require("@sentry/capacitor");
SentryCapacitor.init(baseConfig, Sentry.init);
} else {
Sentry.init({
...baseConfig,
// 세션 리플레이: 평상시 10%, 에러 발생 세션 100% (웹 전용)
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
integrations: [Sentry.replayIntegration()],
});
}
}

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
7 changes: 7 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { withSentryConfig } from "@sentry/nextjs";
const isCapacitor = process.env.NEXT_PUBLIC_BUILD_TARGET === "capacitor";

const nextConfig = {
// Vercel이 자동 주입하는 VERCEL_ENV(서버 전용)를 클라이언트 번들에 인라인.
// NEXT_PUBLIC_VERCEL_ENV는 Vercel이 자동 주입하지 않아 빈 값으로 들어와
// Sentry environment가 NODE_ENV("production")로 fallback되는 버그가 있었음.
env: {
NEXT_PUBLIC_VERCEL_ENV: process.env.VERCEL_ENV ?? "",
},

// Capacitor 빌드 시 정적 내보내기
...(isCapacitor && {
output: "export",
Expand Down
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions src/api/request.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AxiosRequestConfig } from "axios";
import apiClient from "./client";
import type { ApiResponse } from "./types";
import { extractApiError } from "./types";
import { ApiRequestError, extractApiError } from "./types";

type HttpMethod = "get" | "post" | "put" | "patch" | "delete";

Expand Down Expand Up @@ -127,10 +127,11 @@ export async function request<T>(
}
}

// API 에러 메시지 추출
const apiError = extractApiError(error);
if (apiError) {
throw new Error(apiError.message);
// HTTP 상태 코드가 있으면 ApiRequestError로 보존
const status = (error as { response?: { status?: number } })?.response?.status;
if (status !== undefined) {
const apiError = extractApiError(error);
throw new ApiRequestError(apiError?.message ?? errorMessage, status, apiError?.code);
}

// 이미 Error 객체면 그대로 throw
Expand Down
15 changes: 15 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ export interface ApiError {
message: string;
}

/**
* HTTP 상태 코드를 보존하는 API 요청 에러
*/
export class ApiRequestError extends Error {
status: number;
code?: string;

constructor(message: string, status: number, code?: string) {
super(message);
this.name = "ApiRequestError";
this.status = status;
this.code = code;
}
}

/**
* API 공통 응답 타입
* @template T - 응답 데이터 타입
Expand Down
48 changes: 33 additions & 15 deletions src/app/community/[postId]/PostDetailClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
import { useState, useRef, useEffect, use } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { Heart, MessageCircle, RefreshCcw, CornerDownRight, Trash2 } from "lucide-react";
import { Heart, MessageCircle, RefreshCcw, CornerDownRight, Trash2, ArrowLeft } from "lucide-react";
import { useCreateComment } from "@/api/mutations/useCreateComment";
import { useDeleteComment } from "@/api/mutations/useDeleteComment";
import { useDeletePost } from "@/api/mutations/useDeletePost";
import { useToggleCommentLike } from "@/api/mutations/useToggleCommentLike";
import { useTogglePostLike } from "@/api/mutations/useTogglePostLike";
import { usePostDetail } from "@/api/queries/usePostDetail";
import { SimpleHeader, Spinner } from "@/components/common";
import { ApiRequestError } from "@/api/types";
import { BackHeader, Spinner } from "@/components/common";
import { DEFAULT_IMAGES } from "@/constants";
import { useToast } from "@/hooks";
import type { PostComment, CommentReply } from "@/types/api";
Expand Down Expand Up @@ -236,7 +237,7 @@ export default function PostDetailPage({ params }: { params: Promise<{ postId: s

return (
<main className="h-[100dvh] w-full max-w-[480px] mx-auto bg-white flex flex-col">
<SimpleHeader title={post?.typeName ?? "게시글"} />
<BackHeader title={post?.typeName ?? "게시글"} />

<div className="flex-1 overflow-y-auto">
{isLoading ? (
Expand All @@ -246,18 +247,35 @@ export default function PostDetailPage({ params }: { params: Promise<{ postId: s
) : error || !post ? (
<div className="flex flex-col items-center justify-center gap-4 h-full px-5">
<p className="text-center text-[16px] font-normal leading-[1.5] tracking-[-0.16px] text-grey-600">
{error instanceof Error ? error.message : "게시글을 불러올 수 없어요."}
{error instanceof ApiRequestError && error.status === 404
? "삭제되었거나 존재하지 않는 게시글이에요."
: error instanceof Error
? error.message
: "게시글을 불러올 수 없어요."}
</p>
<button
type="button"
onClick={() => refetch()}
className="rounded-lg bg-grey-900 w-[174px] h-[44px] flex items-center justify-center gap-1"
>
<span className="text-[16px] text-white font-normal leading-[1.5] tracking-[-0.16px]">
다시 시도
</span>
<RefreshCcw size={16} className="stroke-white" strokeWidth={2} />
</button>
{error instanceof ApiRequestError && error.status === 404 ? (
<button
type="button"
onClick={() => router.back()}
className="rounded-lg bg-grey-900 w-[174px] h-[44px] flex items-center justify-center gap-1"
>
<ArrowLeft size={16} className="stroke-white" strokeWidth={2} />
<span className="text-[16px] text-white font-normal leading-[1.5] tracking-[-0.16px]">
돌아가기
</span>
</button>
) : (
<button
type="button"
onClick={() => refetch()}
className="rounded-lg bg-grey-900 w-[174px] h-[44px] flex items-center justify-center gap-1"
>
<span className="text-[16px] text-white font-normal leading-[1.5] tracking-[-0.16px]">
다시 시도
</span>
<RefreshCcw size={16} className="stroke-white" strokeWidth={2} />
</button>
)}
</div>
) : (
<>
Expand All @@ -266,7 +284,7 @@ export default function PostDetailPage({ params }: { params: Promise<{ postId: s
<div className="flex items-center gap-2">
<div className="relative w-9 h-9 rounded-full overflow-hidden shrink-0">
<Image
src={DEFAULT_IMAGES.PROFILE}
src={post.authorProfileImageUrl || DEFAULT_IMAGES.PROFILE}
alt={post.authorNickname}
fill
sizes="36px"
Expand Down
18 changes: 10 additions & 8 deletions src/app/community/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,16 @@ export default function CommunityPage() {
</main>

{/* 글쓰기 플로팅 버튼 */}
<button
onClick={() => router.push("/community/write")}
className="fixed bottom-[calc(var(--footer-height)+16px)] right-4 flex items-center gap-1.5 bg-grey-900 text-white rounded-full px-4 py-2.5 shadow-lg z-10"
aria-label="게시글 작성"
>
<PenLine size={16} className="stroke-white" strokeWidth={2} />
<span className="text-[14px] font-medium leading-[1.5] tracking-[-0.14px]">글쓰기</span>
</button>
<div className="fixed bottom-[calc(var(--footer-height)+52px)] left-0 right-0 mx-auto w-full max-w-[480px] z-[50] pointer-events-none">
<button
onClick={() => router.push("/community/write")}
className="absolute right-6 flex items-center gap-1.5 bg-grey-900 text-white rounded-full px-4 py-2.5 shadow-lg pointer-events-auto"
aria-label="게시글 작성"
>
<PenLine size={16} className="stroke-white" strokeWidth={2} />
<span className="text-[14px] font-medium leading-[1.5] tracking-[-0.14px]">글쓰기</span>
</button>
</div>

<Footer />
</>
Expand Down
Loading