Skip to content

Commit 6bd2b42

Browse files
docs : 400번대 에러 처리에 대한 가이드 문서 작성
1 parent a314fb5 commit 6bd2b42

1 file changed

Lines changed: 256 additions & 0 deletions

File tree

docs/4xx-error-handling.md

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
# 401 Unauthenticated → Login Modal Pattern
2+
3+
**Type**: Architecture Guide
4+
**Date**: 2026-04-09
5+
**Status**: Pattern defined in `.claude/rules/error-handling.md` — pending implementation
6+
7+
---
8+
9+
## 1. Overview — Why Modal Instead of Redirect or Toast
10+
11+
When a 401 response occurs (session expired or unauthenticated action), the current implementation redirects the user via `window.location.replace()`. This loses page context: any in-progress form state, scroll position, and the URL the user was on are all discarded.
12+
13+
The target pattern shows a login modal **in place**:
14+
15+
| Approach | UX Impact | When to use |
16+
|----------|-----------|-------------|
17+
| Redirect (`window.location.replace`) | Destroys page context, forces full navigation | ❌ Legacy pattern — being replaced |
18+
| Toast (error message only) | Confusing — no clear recovery action | ❌ Wrong for auth failures |
19+
| Login Modal (in-place) | Page stays intact, user logs back in and continues | ✅ Target pattern |
20+
21+
The modal approach is especially important for flows where the user has filled out a form or navigated deep into the app. A redirect would restart that journey from scratch.
22+
23+
---
24+
25+
## 2. Core Concepts
26+
27+
### Two Interception Points
28+
29+
401 errors can originate from two distinct paths. Each has its own interception point:
30+
31+
#### Point 1 — Token Refresh Failure (`auth-session-recovery.ts`)
32+
33+
Triggered when the access token has expired and the silent refresh also fails (e.g., refresh token expired or invalid). Currently calls `window.location.replace()`.
34+
35+
**Target behavior**: Call `useLoginModalStore.getState().open()` instead.
36+
37+
```typescript
38+
// src/api/client/auth-session-recovery.ts
39+
// Current (loses page context)
40+
window.location.replace(nextUrl);
41+
42+
// Target
43+
import { useLoginModalStore } from '@/stores/use-login-modal-store';
44+
useLoginModalStore.getState().open();
45+
```
46+
47+
`hasPendingDocumentAuthRecovery` acts as an idempotency flag — prevents multiple concurrent failing requests from each triggering a separate modal open. Reset this flag when the modal closes.
48+
49+
#### Point 2 — Unauthenticated Mutation (`query-client.ts`)
50+
51+
Triggered when a guest user (not logged in) attempts an action that requires auth. Currently all `MutationCache.onError` failures show a generic error toast.
52+
53+
**Target behavior**: Check `statusCode === 401` before the toast path.
54+
55+
```typescript
56+
// src/config/query-client.ts (target)
57+
mutationCache: new MutationCache({
58+
onError: (error, _variables, _context, mutation) => {
59+
if (mutation.options.onError) return;
60+
if (isServer) return;
61+
62+
const errorInfo = analyzeError(error);
63+
64+
if (errorInfo.statusCode === 401) {
65+
useLoginModalStore.getState().open();
66+
return; // no Sentry — this is expected flow, same as AUTH001
67+
}
68+
69+
useToastStore.getState().showToast(errorInfo.userMessage, 'error');
70+
sendErrorToSentry(errorInfo, { source: 'MutationCache.onError' });
71+
},
72+
}),
73+
```
74+
75+
### Zustand Store Pattern
76+
77+
Model this after `use-toast-store.ts`, which demonstrates the key technique: calling `.getState()` to trigger state changes from **outside React** (inside axios interceptors, module-level functions).
78+
79+
```typescript
80+
// src/stores/use-login-modal-store.ts (to be created)
81+
import { create } from 'zustand';
82+
83+
interface LoginModalState {
84+
isOpen: boolean;
85+
open: () => void;
86+
close: () => void;
87+
}
88+
89+
export const useLoginModalStore = create<LoginModalState>((set) => ({
90+
isOpen: false,
91+
open: () => set({ isOpen: true }),
92+
close: () => set({ isOpen: false }),
93+
}));
94+
```
95+
96+
Usage outside React (interceptors, module-level callbacks):
97+
```typescript
98+
useLoginModalStore.getState().open();
99+
```
100+
101+
Usage inside React components:
102+
```typescript
103+
const { isOpen, close } = useLoginModalStore();
104+
```
105+
106+
### LoginModal Controlled Mode
107+
108+
The current `LoginModal` component manages `isOpen` internally with `useState` and requires `openTrigger`. To allow Zustand to drive it, add optional controlled props while keeping the existing `openTrigger` usage intact:
109+
110+
```typescript
111+
// src/components/auth/modals/login-modal.tsx
112+
export default function LoginModal({
113+
openTrigger,
114+
open, // new: optional controlled open
115+
onOpenChange, // new: optional controlled setter
116+
}: {
117+
openTrigger?: ReactNode; // made optional — not needed in controlled mode
118+
open?: boolean;
119+
onOpenChange?: (open: boolean) => void;
120+
}) {
121+
const [internalOpen, setInternalOpen] = useState(false);
122+
123+
// Use controlled props if provided, otherwise fall back to internal state
124+
const isOpen = open ?? internalOpen;
125+
const setIsOpen = onOpenChange ?? setInternalOpen;
126+
127+
// ... rest of component unchanged
128+
}
129+
```
130+
131+
### Global Mount Point
132+
133+
Mount `<GlobalLoginModal />` in `(service)/layout.tsx` alongside `<GlobalToast />`:
134+
135+
```tsx
136+
// src/app/(service)/layout.tsx — inside <MainProvider>
137+
<GlobalToast />
138+
<GlobalLoginModal /> // reads isOpen from useLoginModalStore
139+
```
140+
141+
```tsx
142+
// src/components/auth/modals/global-login-modal.tsx
143+
'use client';
144+
145+
import { useLoginModalStore } from '@/stores/use-login-modal-store';
146+
import LoginModal from './login-modal';
147+
148+
export function GlobalLoginModal() {
149+
const { isOpen, close } = useLoginModalStore();
150+
return <LoginModal open={isOpen} onOpenChange={(v) => !v && close()} />;
151+
}
152+
```
153+
154+
### AUTH Error Code Classification
155+
156+
Understanding which error codes map to which behavior prevents both over- and under-triggering the modal:
157+
158+
| Code | HTTP | Cause | Frontend Action |
159+
|------|------|-------|----------------|
160+
| AUTH001 | 401 | Missing / expired / invalid access token | Login modal |
161+
| AUTH002 | 403 | Authenticated but insufficient role | Error toast |
162+
| AUTH003 | 401 | Unsupported OAuth code (OAuth flow) | Login modal |
163+
| AUTH004 | **400** | Refresh token invalid/expired | `auth-session-recovery.ts` only — never reaches `MutationCache` |
164+
165+
AUTH004 returns **400**, not 401. This means it bypasses the `statusCode === 401` gate in `query-client.ts` automatically. It is handled exclusively by `auth-session-recovery.ts`.
166+
167+
---
168+
169+
## 3. Patterns in This Project
170+
171+
### Idempotency: `hasPendingDocumentAuthRecovery`
172+
173+
In `auth-session-recovery.ts`, the module-level flag prevents concurrent requests from each opening a new modal:
174+
175+
```typescript
176+
let hasPendingDocumentAuthRecovery = false;
177+
178+
export const requestDocumentAuthRecovery = (): boolean => {
179+
if (!currentUrl || hasPendingDocumentAuthRecovery) {
180+
return false; // already opening — skip
181+
}
182+
183+
hasPendingDocumentAuthRecovery = true;
184+
useLoginModalStore.getState().open();
185+
186+
return true;
187+
};
188+
```
189+
190+
Reset when the modal closes — wire it to the store's `close()` action or to `GlobalLoginModal`'s `onOpenChange`:
191+
192+
```typescript
193+
onOpenChange={(v) => {
194+
if (!v) {
195+
close();
196+
hasPendingDocumentAuthRecovery = false;
197+
}
198+
}}
199+
```
200+
201+
### Why No Sentry on 401
202+
203+
AUTH001 is explicitly excluded from Sentry in `sentry.client.config.ts` via `beforeSend`. The same reasoning applies to 401s in `MutationCache.onError`: token expiry is expected, not an error to alert on. Only unexpected errors should fire Sentry.
204+
205+
### `getState()` vs Hook
206+
207+
| Context | Pattern |
208+
|---------|---------|
209+
| Inside React component | `const { isOpen, close } = useLoginModalStore()` |
210+
| Outside React (interceptor, module function) | `useLoginModalStore.getState().open()` |
211+
212+
This is the same pattern used by `useToastStore` today — `use-toast-store.ts:13` shows the `create()` call that enables both usages.
213+
214+
---
215+
216+
## 4. Decision Guide
217+
218+
Use this flowchart when deciding how to handle an auth-related error:
219+
220+
```
221+
Error received
222+
223+
├── Is it 401?
224+
│ ├── YES → Show login modal (in-place, preserves context)
225+
│ │ Don't show toast. Don't report to Sentry.
226+
│ └── NO ↓
227+
228+
├── Is it 403?
229+
│ ├── YES → Show error toast (user is logged in, just lacks permission)
230+
│ │ Report to Sentry if unexpected.
231+
│ └── NO ↓
232+
233+
├── Is it 400 with AUTH004?
234+
│ ├── YES → Handled only in auth-session-recovery.ts.
235+
│ │ Never reaches MutationCache.onError.
236+
│ └── NO ↓
237+
238+
└── Other error → Show error toast + report to Sentry
239+
```
240+
241+
**Never** show the login modal for 403 — the user is authenticated, they just lack the right role. Showing a login modal would incorrectly imply they need to log in again.
242+
243+
**Never** redirect using `window.location.replace()` on 401 — this is the legacy pattern being replaced. The modal preserves page context, which is the whole point.
244+
245+
---
246+
247+
## Implementation Checklist
248+
249+
When implementing this pattern:
250+
251+
- [ ] Create `src/stores/use-login-modal-store.ts`
252+
- [ ] Update `src/components/auth/modals/login-modal.tsx` to accept optional `open?`/`onOpenChange?`
253+
- [ ] Create `src/components/auth/modals/global-login-modal.tsx`
254+
- [ ] Mount `<GlobalLoginModal />` in `src/app/(service)/layout.tsx`
255+
- [ ] Update `src/api/client/auth-session-recovery.ts`: replace `window.location.replace()` with `useLoginModalStore.getState().open()`, reset `hasPendingDocumentAuthRecovery` on modal close
256+
- [ ] Update `src/config/query-client.ts`: add `statusCode === 401` gate before toast in `MutationCache.onError`

0 commit comments

Comments
 (0)