diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/approval/CRONJOB_CONTEXT_FIX.md | 278 | ||||
| -rw-r--r-- | lib/approval/README.md | 514 | ||||
| -rw-r--r-- | lib/approval/README_CACHE.md | 253 |
3 files changed, 1021 insertions, 24 deletions
diff --git a/lib/approval/CRONJOB_CONTEXT_FIX.md b/lib/approval/CRONJOB_CONTEXT_FIX.md new file mode 100644 index 00000000..a8169a5e --- /dev/null +++ b/lib/approval/CRONJOB_CONTEXT_FIX.md @@ -0,0 +1,278 @@ +# Cronjob Request Context λ¬Έμ ν΄κ²° + +## π λ¬Έμ μν© + +κ²°μ¬ μλ£ ν `pendingActions`μμ νΈλ€λ¬λ₯Ό νΈμΆν λ, **node-cronμ cronjobμ΄ ν¨μλ₯Ό νΈμΆνλ―λ‘ Request Contextκ° μμ΄** λ€μ λ¬Έμ κ° λ°μνμ΅λλ€: + +``` +β Error: `headers` was called outside a request scope. +β Error: Invariant: static generation store missing in revalidateTag +``` + +### λ°μ μμΉ + +- `lib/vendors/service.ts` - `approveVendors()`, `rejectVendors()` +- Next.jsμ `headers()`, `revalidateTag()` λ±μ API μ¬μ© + +### κ·Όλ³Έ μμΈ + +```typescript +Cronjob (node-cron) + β (Request Context μμ) +ApprovalExecutionSaga + β +Handler (μ: approveVendorWithMDGInternal) + β +approveVendors() + β headers() β - AsyncLocalStorageμ μ κ·Ό λΆκ° + β revalidateTag() β - Static generation store μμ +``` + +## β
ν΄κ²° λ°©λ² + +### 1. API κΈ°λ° Revalidation μ¬μ© + +Request Contextκ° μλ νκ²½μμλ **λ΄λΆ APIλ₯Ό νΈμΆ**νμ¬ μΊμλ₯Ό 무ν¨νν©λλ€. + +```typescript +// β Before: μ§μ νΈμΆ (Request Context νμ) +revalidateTag("vendors"); +revalidateTag("users"); + +// β
After: API νΈμΆ (Request Context λΆνμ) +const { revalidateApprovalRelatedCaches } = await import('@/lib/revalidation-utils'); +await revalidateApprovalRelatedCaches(); +``` + +### 2. νκ²½λ³μλ‘ BaseURL κ΅¬μ± + +`headers()`λ₯Ό μ¬μ©νλ λμ νκ²½λ³μλ₯Ό μ¬μ©ν©λλ€. + +```typescript +// β Before: headers()λ‘ λμ κ΅¬μ± +const headersList = await headers(); +const host = headersList.get('host') || 'localhost:3000'; +const protocol = headersList.get('x-forwarded-proto') || 'http'; +const baseUrl = `${protocol}://${host}`; + +// β
After: νκ²½λ³μ μ¬μ© +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; +``` + +## π λ³κ²½λ νμΌ + +### 1. μλ‘ μΆκ°λ νμΌ + +#### `lib/revalidation-utils.ts` +Request Contextκ° μλ νκ²½μμ μΊμλ₯Ό 무ν¨ννκΈ° μν μ νΈλ¦¬ν° ν¨μλ€: + +```typescript +// λ²μ© revalidation +await revalidateViaCronJob({ tags: ['vendors', 'users'] }); + +// νΉμ λλ©μΈλ³ revalidation +await revalidateVendorCaches(); +await revalidateUserCaches(); +await revalidateApprovalRelatedCaches(); +``` + +### 2. μμ λ νμΌ + +#### `lib/vendors/service.ts` + +**approveVendors() ν¨μ:** +- β
`headers()` μ κ±° β `process.env.NEXT_PUBLIC_BASE_URL` μ¬μ© +- β
`revalidateTag()` μ κ±° β `revalidateApprovalRelatedCaches()` μ¬μ© + +**rejectVendors() ν¨μ:** +- β
`headers()` μ κ±° β `process.env.NEXT_PUBLIC_BASE_URL` μ¬μ© +- β
`revalidateTag()` μ κ±° β `revalidateVendorCaches()`, `revalidateUserCaches()` μ¬μ© + +## π§ μ€μ νμ + +### νκ²½λ³μ μ€μ + +`.env` νμΌμ λ€μ νκ²½λ³μλ₯Ό μΆκ°νμΈμ: + +```bash +# μ ν리μΌμ΄μ
Base URL (μ΄λ©μΌ λ§ν¬ λ±μ μ¬μ©) +NEXT_PUBLIC_BASE_URL=https://your-domain.com + +# Revalidation API 보μ (μ νμ¬ν) +REVALIDATION_SECRET=your-secret-key +``` + +**κ°λ° νκ²½:** +```bash +NEXT_PUBLIC_BASE_URL=http://localhost:3000 +``` + +**νλ‘λμ
νκ²½:** +```bash +NEXT_PUBLIC_BASE_URL=https://evcp.your-company.com +``` + +## π― μλ λ°©μ + +### 1. Cronjobμμ νΈλ€λ¬ μ€ν + +```typescript +// lib/approval/approval-polling-service.ts +const saga = new ApprovalExecutionSaga(apInfId); +await saga.execute(); // β Request Context μμ +``` + +### 2. νΈλ€λ¬μμ Service ν¨μ νΈμΆ + +```typescript +// lib/vendors/approval-handlers.ts +export async function approveVendorWithMDGInternal(payload) { + // MDG μ μ‘... + + const approveResult = await approveVendors({ + ids: payload.vendorIds, + userId: payload.userId, + }); // β Request Context μλ μνλ‘ νΈμΆλ¨ +} +``` + +### 3. Serviceμμ API κΈ°λ° Revalidation + +```typescript +// lib/vendors/service.ts +export async function approveVendors(input) { + await db.transaction(async (tx) => { + // 1. λ²€λ μΉμΈ μ²λ¦¬ + // 2. μ΄λ©μΌ λ°μ‘ (β
νκ²½λ³μ μ¬μ©) + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; + const loginUrl = `${baseUrl}/${userLang}/login`; + }); + + // 3. μΊμ 무ν¨ν (β
API νΈμΆ μ¬μ©) + const { revalidateApprovalRelatedCaches } = await import('@/lib/revalidation-utils'); + await revalidateApprovalRelatedCaches(); +} +``` + +### 4. Revalidation Utilsμμ λ΄λΆ API νΈμΆ + +```typescript +// lib/revalidation-utils.ts +export async function revalidateViaCronJob(options) { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; + + const response = await fetch(`${baseUrl}/api/revalidate/approval`, { + method: 'POST', + body: JSON.stringify({ + tags: options.tags, + secret: process.env.REVALIDATION_SECRET, + }), + }); + + return response.json(); +} +``` + +### 5. API Routeμμ μ€μ Revalidation μ€ν + +```typescript +// app/api/revalidate/approval/route.ts +export async function POST(request: NextRequest) { + // β
μ¬κΈ°λ HTTP Request Contextκ° μμ! + const { tags } = await request.json(); + + for (const tag of tags) { + revalidateTag(tag); // β
Request Contextκ° μμΌλ―λ‘ μλν¨ + } + + return Response.json({ success: true }); +} +``` + +## π νλ¦λ + +``` +βββββββββββββββββββββββββββββββββββββββββββββββββββββββ +β Cronjob (node-cron) - 1λΆλ§λ€ β +β Request Context: β μμ β +βββββββββββββββββββββββββββββββββββββββββββββββββββββββ + β +βββββββββββββββββββββββββββββββββββββββββββββββββββββββ +β ApprovalExecutionSaga β +β - Knox κ²°μ¬ μν νμΈ β +β - μΉμΈλ κ²½μ° Handler μ€ν β +βββββββββββββββββββββββββββββββββββββββββββββββββββββββ + β +βββββββββββββββββββββββββββββββββββββββββββββββββββββββ +β approveVendorWithMDGInternal() β +β - MDG μ μ‘ β +β - approveVendors() νΈμΆ β +βββββββββββββββββββββββββββββββββββββββββββββββββββββββ + β +βββββββββββββββββββββββββββββββββββββββββββββββββββββββ +β approveVendors() - lib/vendors/service.ts β +β β
baseUrl = process.env.NEXT_PUBLIC_BASE_URL β +β β
await revalidateViaCronJob({ tags: [...] }) β +βββββββββββββββββββββββββββββββββββββββββββββββββββββββ + β +βββββββββββββββββββββββββββββββββββββββββββββββββββββββ +β revalidateViaCronJob() - lib/revalidation-utils.ts β +β β
fetch('/api/revalidate/approval', ...) β +βββββββββββββββββββββββββββββββββββββββββββββββββββββββ + β +βββββββββββββββββββββββββββββββββββββββββββββββββββββββ +β POST /api/revalidate/approval β +β Request Context: β
μμ! β +β β
revalidateTag() μ¬μ© κ°λ₯ β +βββββββββββββββββββββββββββββββββββββββββββββββββββββββ +``` + +## π¨ μ£Όμμ¬ν + +### 1. νκ²½λ³μ μ€μ νμ + +`NEXT_PUBLIC_BASE_URL`μ΄ μ€μ λμ§ μμΌλ©΄ `http://localhost:3000`μ΄ κΈ°λ³Έκ°μΌλ‘ μ¬μ©λ©λλ€. νλ‘λμ
μμλ λ°λμ μ€μ νμΈμ. + +### 2. μ΄λ©μΌ λ§ν¬ μ νμ± + +μ΄λ©μΌμ ν¬ν¨λλ λ§ν¬κ° μ¬λ°λ₯Έ λλ©μΈμ κ°λ¦¬ν€λμ§ νμΈνμΈμ: + +```typescript +// λ‘κ·ΈμΈ λ§ν¬ μμ +https://evcp.your-company.com/ko/login + +// λΉλ°λ²νΈ μ¬μ€μ λ§ν¬ μμ +https://evcp.your-company.com/ko/auth/reset-password?token=xxxxx +``` + +### 3. Revalidation API 보μ + +`REVALIDATION_SECRET`μ μ€μ νμ¬ μΈλΆμμ 무λ¨μΌλ‘ μΊμλ₯Ό 무ν¨ννλ κ²μ λ°©μ§ν μ μμ΅λλ€ (μ νμ¬ν). + +### 4. λ€λ₯Έ Service ν¨μμλ μ μ© + +`lib/vendors/service.ts`μ λ€λ₯Έ ν¨μλ€λ λμΌν ν¨ν΄μ μ¬μ©ν΄μΌ ν μ μμ΅λλ€: + +- `requestPQVendors()` - line 3210-3215μ `revalidateTag()` μ¬μ© +- κΈ°ν `headers()` λλ `revalidateTag()`λ₯Ό μ¬μ©νλ ν¨μλ€ + +νμμ λμΌν ν¨ν΄μΌλ‘ μμ νμΈμ. + +## π κ²°κ³Ό + +μ΄μ cronjobμμ νΈλ€λ¬κ° μ€νλμ΄λ λ€μκ³Ό κ°μ΄ μ μ μλν©λλ€: + +```bash +β
[ExecutionSaga] Step 5: Executing action +β
[Vendor Approval Handler] MDG μ μ‘ μ±κ³΅ +β
[Vendor Approval Handler] λ²€λ μΉμΈ μλ£ +β
[Revalidation] Cache invalidated: vendors, users, roles +β
[ExecutionSaga] β Action executed +``` + +## π μ°Έκ³ μλ£ + +- [Next.js Dynamic APIs](https://nextjs.org/docs/messages/next-dynamic-api-wrong-context) +- [Next.js Revalidation](https://nextjs.org/docs/app/building-your-application/data-fetching/revalidating) +- [AsyncLocalStorage](https://nodejs.org/api/async_context.html#class-asynclocalstorage) + diff --git a/lib/approval/README.md b/lib/approval/README.md index 40f783c9..7e62a1d7 100644 --- a/lib/approval/README.md +++ b/lib/approval/README.md @@ -360,29 +360,56 @@ export async function requestMyActionWithApproval(data: { } ``` -### Step 4: UIμμ νΈμΆ +### Step 4: UIμμ νΈμΆ (미리보기 λ€μ΄μΌλ‘κ·Έ μ¬μ©) + +**β οΈ μ€μ: λͺ¨λ κ²°μ¬ μμ μ λ°λμ 미리보기 λ€μ΄μΌλ‘κ·Έλ₯Ό κ±°μ³μΌ ν©λλ€.** + +μ¬μ©μκ° κ²°μ¬ λ¬Έμ λ΄μ©μ νμΈνκ³ κ²°μ¬μ μ μ§μ μ€μ νλ κ³Όμ μ΄ νμμ
λλ€. ```typescript -// components/my-feature/my-dialog.tsx +// components/my-feature/my-dialog-with-preview.tsx 'use client'; +import { useState } from 'react'; +import { ApprovalPreviewDialog } from '@/lib/approval/client'; // β οΈ /clientμμ import import { requestMyActionWithApproval } from '@/lib/my-feature/approval-actions'; import { useSession } from 'next-auth/react'; -export function MyDialog() { +export function MyDialogWithPreview() { const { data: session } = useSession(); + const [showPreview, setShowPreview] = useState(false); + const [previewData, setPreviewData] = useState(null); + + const handleApproveClick = async (formData) => { + // 1. ν
νλ¦Ώ λ³μ μ€λΉ + const variables = await prepareTemplateVariables(formData); + + // 2. 미리보기 λ°μ΄ν° μ€μ + setPreviewData({ + variables, + title: `λ΄ κΈ°λ₯ μμ² - ${formData.id}`, + description: `ID ${formData.id}μ λν μΉμΈ μμ²`, + formData, // λμ€μ μ¬μ©ν λ°μ΄ν° μ μ₯ + }); + + // 3. 미리보기 λ€μ΄μΌλ‘κ·Έ μ΄κΈ° + setShowPreview(true); + }; - const handleSubmit = async (formData) => { + const handlePreviewConfirm = async (approvalData: { + approvers: string[]; + title: string; + description?: string; + }) => { try { const result = await requestMyActionWithApproval({ - id: formData.id, - reason: formData.reason, + ...previewData.formData, currentUser: { id: Number(session?.user?.id), epId: session?.user?.epId || null, email: session?.user?.email || undefined, }, - approvers: selectedApprovers, + approvers: approvalData.approvers, // 미리보기μμ μ€μ ν κ²°μ¬μ }); if (result.status === 'pending_approval') { @@ -393,10 +420,40 @@ export function MyDialog() { } }; - return <form onSubmit={handleSubmit}>...</form>; + return ( + <> + <Button onClick={handleApproveClick}>κ²°μ¬ μμ²</Button> + + {/* κ²°μ¬ λ―Έλ¦¬λ³΄κΈ° λ€μ΄μΌλ‘κ·Έ */} + {previewData && session?.user?.epId && ( + <ApprovalPreviewDialog + open={showPreview} + onOpenChange={setShowPreview} + templateName="λ΄ κΈ°λ₯ ν
νλ¦Ώ" + variables={previewData.variables} + title={previewData.title} + description={previewData.description} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined, + }} + onConfirm={handlePreviewConfirm} + /> + )} + </> + ); } ``` +**미리보기 λ€μ΄μΌλ‘κ·Έκ° νμμΈ μ΄μ :** +- β
κ²°μ¬ λ¬Έμ λ΄μ© μ΅μ’
νμΈ (λ°μ΄ν° μ νμ± κ²μ¦) +- β
κ²°μ¬μ (κ²°μ¬μ) μ§μ μ ν (μ¬λ°λ₯Έ κ²°μ¬ κ²½λ‘ μ€μ ) +- β
κ²°μ¬ μ λͺ©/μ€λͺ
컀μ€ν°λ§μ΄μ§ (λͺ
νν μμ¬μν΅) +- β
μ¬μ©μκ° κ²°μ¬ λ΄μ©μ μΈμ§νκ³ μ±
μκ° μκ² μμ +- β
λ°μν UI (Desktop: Dialog, Mobile: Drawer) + --- ## π API λ νΌλ°μ€ @@ -522,6 +579,74 @@ async function replaceTemplateVariables( ): Promise<string> ``` +### UI μ»΄ν¬λνΈ + +#### ApprovalPreviewDialog + +κ²°μ¬ λ¬Έμ 미리보기 λ° κ²°μ¬μ μ€μ λ€μ΄μΌλ‘κ·Έ μ»΄ν¬λνΈμ
λλ€. + +```typescript +interface ApprovalPreviewDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + templateName: string; // DBμμ μ‘°νν ν
νλ¦Ώ μ΄λ¦ + variables: Record<string, string>; // ν
νλ¦Ώ λ³μ + title: string; // κ²°μ¬ μ λͺ© + description?: string; // κ²°μ¬ μ€λͺ
+ currentUser: { + id: number; + epId: string; + name?: string; + email?: string; + deptName?: string; + }; + defaultApprovers?: string[]; // μ΄κΈ° κ²°μ¬μ (EP ID λ°°μ΄) + onConfirm: (data: { + approvers: string[]; + title: string; + description?: string; + }) => Promise<void>; + allowTitleEdit?: boolean; // μ λͺ© μμ νμ© (κΈ°λ³Έ: true) + allowDescriptionEdit?: boolean; // μ€λͺ
μμ νμ© (κΈ°λ³Έ: true) +} +``` + +**μ£Όμ κΈ°λ₯:** +- ν
νλ¦Ώ μ€μκ° λ―Έλ¦¬λ³΄κΈ° (λ³μ μλ μΉν) +- κ²°μ¬μ μ ν UI (ApprovalLineSelector ν΅ν©) +- μ λͺ©/μ€λͺ
μμ +- λ°μν λμμΈ (Desktop: Dialog, Mobile: Drawer) +- λ‘λ© μν μλ μ²λ¦¬ + +**μ¬μ© μμ:** + +```typescript +// β οΈ ν΄λΌμ΄μΈνΈ μ»΄ν¬λνΈλ λ°λμ /clientμμ import +import { ApprovalPreviewDialog } from '@/lib/approval/client'; + +<ApprovalPreviewDialog + open={showPreview} + onOpenChange={setShowPreview} + templateName="λ²€λ κ°μ
μΉμΈ μμ²" + variables={{ + 'μ
체λͺ
': 'ABC νλ ₯μ
체', + 'λ΄λΉμ': 'νκΈΈλ', + 'μμ²μΌ': '2024-11-06', + }} + title="νλ ₯μ
체 κ°μ
μΉμΈ" + description="ABC νλ ₯μ
체μ κ°μ
μ μΉμΈν©λλ€." + currentUser={{ + id: 1, + epId: 'EP001', + name: 'κΉμ² μ', + email: 'kim@example.com', + }} + onConfirm={async ({ approvers, title, description }) => { + await submitApproval(approvers); + }} +/> +``` + ### μΊμ κ΄λ¦¬ ```typescript @@ -544,32 +669,35 @@ async function revalidateAllApprovalCaches(): Promise<void> ``` lib/approval/ -βββ approval-saga.ts # Saga ν΄λμ€ (λ©μΈ λ‘μ§) +βββ approval-saga.ts # Saga ν΄λμ€ (λ©μΈ λ‘μ§) [μλ²] β βββ ApprovalSubmissionSaga # κ²°μ¬ μμ β βββ ApprovalExecutionSaga # μ‘μ
μ€ν β βββ ApprovalRejectionSaga # λ°λ € μ²λ¦¬ β -βββ approval-workflow.ts # νΈλ€λ¬ λ μ§μ€νΈλ¦¬ +βββ approval-workflow.ts # νΈλ€λ¬ λ μ§μ€νΈλ¦¬ [μλ²] β βββ registerActionHandler() β βββ getRegisteredHandlers() β βββ ensureHandlersInitialized() β -βββ approval-polling-service.ts # ν΄λ§ μλΉμ€ +βββ approval-polling-service.ts # ν΄λ§ μλΉμ€ [μλ²] β βββ startApprovalPollingScheduler() β βββ checkPendingApprovals() β βββ checkSingleApprovalStatus() β -βββ handlers-registry.ts # νΈλ€λ¬ μ€μ λ±λ‘μ +βββ handlers-registry.ts # νΈλ€λ¬ μ€μ λ±λ‘μ [μλ²] β βββ initializeApprovalHandlers() β -βββ template-utils.ts # ν
νλ¦Ώ μ νΈλ¦¬ν° +βββ template-utils.ts # ν
νλ¦Ώ μ νΈλ¦¬ν° [μλ²] β βββ getApprovalTemplateByName() β βββ replaceTemplateVariables() β βββ htmlTableConverter() β βββ htmlListConverter() β βββ htmlDescriptionList() β -βββ cache-utils.ts # μΊμ κ΄λ¦¬ +βββ approval-preview-dialog.tsx # κ²°μ¬ λ―Έλ¦¬λ³΄κΈ° λ€μ΄μΌλ‘κ·Έ [ν΄λΌμ΄μΈνΈ] +β βββ ApprovalPreviewDialog # ν
νλ¦Ώ 미리보기 + κ²°μ¬μ μ€μ +β +βββ cache-utils.ts # μΊμ κ΄λ¦¬ [μλ²] β βββ revalidateApprovalLogs() β βββ revalidatePendingActions() β βββ revalidateApprovalDetail() @@ -579,11 +707,31 @@ lib/approval/ β βββ ApprovalResult β βββ TemplateVariables β -βββ index.ts # κ³΅κ° API Export +βββ index.ts # μλ² μ μ© API Export +βββ client.ts # ν΄λΌμ΄μΈνΈ μ»΄ν¬λνΈ Export β οΈ β βββ README.md # μ΄ λ¬Έμ ``` +### Import κ²½λ‘ κ°μ΄λ + +**β οΈ μ€μ: μλ²/ν΄λΌμ΄μΈνΈ μ½λλ λ°λμ λΆλ¦¬ν΄μ import ν΄μΌ ν©λλ€.** + +```typescript +// β
μλ² μ‘μ
λλ μλ² μ»΄ν¬λνΈμμ +import { + ApprovalSubmissionSaga, + getApprovalTemplateByName, + htmlTableConverter +} from '@/lib/approval'; + +// β
ν΄λΌμ΄μΈνΈ μ»΄ν¬λνΈμμ +import { ApprovalPreviewDialog } from '@/lib/approval/client'; + +// β μλͺ»λ μ¬μ© (μλ² μ½λκ° ν΄λΌμ΄μΈνΈ λ²λ€μ ν¬ν¨λ¨) +import { ApprovalPreviewDialog } from '@/lib/approval'; +``` + --- ## π οΈ κ°λ° κ°μ΄λ @@ -635,6 +783,217 @@ INSERT INTO approval_templates (name, content, description) VALUES ( λ! ν΄λ§ μλΉμ€κ° μλμΌλ‘ μ²λ¦¬ν©λλ€. +### β οΈ Request Context μ£Όμμ¬ν (νμ) + +**ν΅μ¬: κ²°μ¬ νΈλ€λ¬λ Cronjob νκ²½μμ μ€νλ©λλ€!** + +κ²°μ¬ μΉμΈ ν μ€νλλ νΈλ€λ¬λ **ν΄λ§ μλΉμ€(Cronjob)**μ μν΄ νΈμΆλ©λλ€. +μ΄ νκ²½μμλ **Request Contextκ° μ‘΄μ¬νμ§ μμΌλ―λ‘** λ€μ ν¨μλ€μ **μ λ μ¬μ©ν μ μμ΅λλ€**: + +```typescript +// β Cronjob νκ²½μμ μ¬μ© λΆκ° +import { headers } from 'next/headers'; +import { getServerSession } from 'next-auth'; + +export async function myHandler(payload) { + const headersList = headers(); // β Error: headers() called outside request scope + const session = await getServerSession(); // β Error: cannot access request + revalidatePath('/some-path'); // β Error: revalidatePath outside request scope +} +``` + +#### μ¬λ°λ₯Έ ν΄κ²° λ°©λ² + +**1. μ μ μ λ³΄κ° νμν κ²½μ° β Payloadμ ν¬ν¨** + +```typescript +// β
κ²°μ¬ μμ μ currentUserλ₯Ό payloadμ ν¬ν¨ +export async function requestWithApproval(data: RequestData) { + const saga = new ApprovalSubmissionSaga( + 'my_action', + { + id: data.id, + currentUser: { // β οΈ νΈλ€λ¬μμ νμν μ μ μ 보 ν¬ν¨ + id: session.user.id, + name: session.user.name, + email: session.user.email, + epId: session.user.epId, + }, + }, + { ... } + ); +} + +// β
νΈλ€λ¬μμ payloadμ currentUser μ¬μ© +export async function myHandlerInternal(payload: { + id: number; + currentUser: { + id: string | number; + name?: string | null; + email?: string | null; + epId?: string | null; + }; +}) { + // payloadμμ μ μ μ 보 μ¬μ© + const userId = payload.currentUser.id; + + // DB μμ
+ await db.insert(myTable).values({ + createdBy: userId, + ... + }); +} +``` + +**2. Session/Payload λΆκΈ° μ²λ¦¬ (κΈ°μ‘΄ ν¨μ νΈν)** + +κΈ°μ‘΄ ν¨μλ₯Ό cronjobκ³Ό μΌλ° νκ²½μμ λͺ¨λ μ¬μ©νλ €λ©΄ λΆκΈ° μ²λ¦¬: + +```typescript +// β
Session/Payload λΆκΈ° μ²λ¦¬ +export async function myServiceFunction({ + id, + currentUser: providedUser // μ νμ νλΌλ―Έν° +}: { + id: number; + currentUser?: { + id: string | number; + name?: string | null; + email?: string | null; + }; +}) { + let currentUser; + + if (providedUser) { + // β
Cronjob νκ²½: payloadμμ λ°μ μ μ μ 보 μ¬μ© + currentUser = providedUser; + } else { + // β
μΌλ° νκ²½: sessionμμ μ μ μ 보 κ°μ Έμ€κΈ° + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error("μΈμ¦μ΄ νμν©λλ€."); + } + currentUser = session.user; + } + + // μ΄μ currentUser μμ νκ² μ¬μ© κ°λ₯ + await db.insert(...).values({ + createdBy: currentUser.id, + ... + }); +} +``` + +**3. Revalidateλ API κ²½λ‘ μ¬μ©** + +```typescript +// β μ§μ νΈμΆ λΆκ° +revalidatePath('/my-path'); + +// β
API κ²½λ‘λ₯Ό ν΅ν΄ νΈμΆ +await fetch('/api/revalidate/my-resource', { + method: 'POST', +}); +``` + +#### μ€μ μ¬λ‘: RFQ λ°μ‘ + +```typescript +// lib/rfq-last/service.ts +export interface SendRfqParams { + rfqId: number; + vendors: VendorForSend[]; + attachmentIds: number[]; + currentUser?: { // β οΈ Cronjob νκ²½μ μν μ νμ νλΌλ―Έν° + id: string | number; + name?: string | null; + email?: string | null; + epId?: string | null; + }; +} + +export async function sendRfqToVendors({ + rfqId, + vendors, + attachmentIds, + currentUser: providedUser +}: SendRfqParams) { + let currentUser; + + if (providedUser) { + // β
Cronjob νκ²½: payloadμμ λ°μ μ μ μ 보 μ¬μ© + currentUser = providedUser; + } else { + // β
μΌλ° νκ²½: sessionμμ μ μ μ 보 κ°μ Έμ€κΈ° + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error("μΈμ¦μ΄ νμν©λλ€."); + } + currentUser = session.user; + } + + // ... λλ¨Έμ§ λ‘μ§ +} + +// lib/rfq-last/approval-handlers.ts +export async function sendRfqWithApprovalInternal(payload: { + rfqId: number; + vendors: any[]; + attachmentIds: number[]; + currentUser: { // β οΈ νμ μ 보 + id: string | number; + name?: string | null; + email?: string | null; + epId?: string | null; + }; +}) { + // β
payloadμ currentUserλ₯Ό μλΉμ€ ν¨μμ μ λ¬ + const result = await sendRfqToVendors({ + rfqId: payload.rfqId, + vendors: payload.vendors, + attachmentIds: payload.attachmentIds, + currentUser: payload.currentUser, // β οΈ μ λ¬ + }); + + return result; +} + +// lib/rfq-last/approval-actions.ts +export async function requestRfqSendWithApproval(data: RfqSendApprovalData) { + const saga = new ApprovalSubmissionSaga( + 'rfq_send_with_attachments', + { + rfqId: data.rfqId, + vendors: data.vendors, + attachmentIds: data.attachmentIds, + currentUser: { // β οΈ payloadμ ν¬ν¨ + id: data.currentUser.id, + name: data.currentUser.name, + email: data.currentUser.email, + epId: data.currentUser.epId, + }, + }, + { ... } + ); +} +``` + +#### 체ν¬λ¦¬μ€νΈ + +μλ‘μ΄ κ²°μ¬ νΈλ€λ¬λ₯Ό μμ±ν λ λ€μμ νμΈνμΈμ: + +- [ ] νΈλ€λ¬ ν¨μμμ `headers()` μ¬μ©νμ§ μμ +- [ ] νΈλ€λ¬ ν¨μμμ `getServerSession()` μ§μ νΈμΆνμ§ μμ +- [ ] νΈλ€λ¬ ν¨μμμ `revalidatePath()` μ§μ νΈμΆνμ§ μμ +- [ ] μ μ μ λ³΄κ° νμνλ©΄ payloadμ `currentUser` ν¬ν¨ +- [ ] κΈ°μ‘΄ μλΉμ€ ν¨μλ session/payload λΆκΈ° μ²λ¦¬ +- [ ] μΊμ 무ν¨νλ API κ²½λ‘ μ¬μ© + +#### μ°Έκ³ λ¬Έμ + +- [CRONJOB_CONTEXT_FIX.md](./CRONJOB_CONTEXT_FIX.md) - Request Context λ¬Έμ μμΈ ν΄κ²° κ°μ΄λ +- [Next.js Dynamic API Error](https://nextjs.org/docs/messages/next-dynamic-api-wrong-context) + ### ν
μ€νΈνκΈ° ```typescript @@ -878,10 +1237,87 @@ const saga = new ApprovalSubmissionSaga(...); await saga.execute(); ``` +### 7. Request Context μ€λ₯ (headers/session) β οΈ + +**μ¦μ:** +``` +Error: `headers` was called outside a request scope +Error: Cannot access request in cronjob context +``` + +**μμΈ:** κ²°μ¬ νΈλ€λ¬λ Cronjob νκ²½μμ μ€νλλ―λ‘ Request Contextκ° μμ + +**ν΄κ²°:** + +```typescript +// β μλͺ»λ μ½λ +export async function myHandler(payload) { + const session = await getServerSession(authOptions); // β Error! + const userId = session.user.id; +} + +// β
μ¬λ°λ₯Έ μ½λ - payloadμμ μ μ μ 보 μ¬μ© +export async function myHandler(payload: { + id: number; + currentUser: { + id: string | number; + name?: string | null; + email?: string | null; + }; +}) { + const userId = payload.currentUser.id; // β
OK +} + +// β
κ²°μ¬ μμ μ currentUser ν¬ν¨ +const saga = new ApprovalSubmissionSaga( + 'my_action', + { + id: data.id, + currentUser: { // β οΈ νμ + id: session.user.id, + name: session.user.name, + email: session.user.email, + epId: session.user.epId, + }, + }, + { ... } +); +``` + +**κΈ°μ‘΄ μλΉμ€ ν¨μ νΈν:** + +```typescript +// β
Session/Payload λΆκΈ° μ²λ¦¬ +export async function myServiceFunction({ + id, + currentUser: providedUser +}: { + id: number; + currentUser?: { id: string | number; ... }; +}) { + let currentUser; + + if (providedUser) { + // Cronjob νκ²½ + currentUser = providedUser; + } else { + // μΌλ° νκ²½ + const session = await getServerSession(authOptions); + currentUser = session.user; + } + + // μμ νκ² μ¬μ© + await db.insert(...).values({ createdBy: currentUser.id }); +} +``` + +**μμΈν λ΄μ©:** [Request Context μ£Όμμ¬ν](#οΈ-request-context-μ£Όμμ¬ν-νμ) μΉμ
μ°Έμ‘° + --- ## π μΆκ° λ¬Έμ +- **[CRONJOB_CONTEXT_FIX.md](./CRONJOB_CONTEXT_FIX.md)** - Request Context λ¬Έμ μμΈ ν΄κ²° κ°μ΄λ β οΈ - **[SAGA_PATTERN.md](./SAGA_PATTERN.md)** - Saga ν¨ν΄ μμΈ μ€λͺ
λ° λ¦¬ν©ν°λ§ κ³Όμ - **[README_CACHE.md](./README_CACHE.md)** - μΊμ μ λ΅ λ° κ΄λ¦¬ λ°©λ² - **[USAGE_PATTERN_ANALYSIS.md](./USAGE_PATTERN_ANALYSIS.md)** - μ€μ μ¬μ© ν¨ν΄ λΆμ λ° κ°μ μ μ @@ -895,6 +1331,7 @@ await saga.execute(); - β
λΉμ¦λμ€ μ‘μ
μ κ²°μ¬ μΉμΈμ΄ νμν κ²½μ° - β
Knox κ²°μ¬ μμ€ν
κ³Ό μλ μ°λμ΄ νμν κ²½μ° - β
κ²°μ¬ μΉμΈ ν μλμΌλ‘ μ‘μ
μ μ€ννκ³ μΆμ κ²½μ° +- β
**λͺ¨λ κ²°μ¬λ 미리보기 λ€μ΄μΌλ‘κ·Έλ₯Ό ν΅ν΄ μμ ν΄μΌ ν¨ (νμ)** ### μ Saga ν¨ν΄μΈκ°? - β
Knoxλ μΈλΆ μμ€ν
β μΌλ° νΈλμμ
λΆκ° @@ -904,27 +1341,56 @@ await saga.execute(); ### μ΄λ»κ² μ¬μ©νλκ°? 1. **νΈλ€λ¬ μμ± λ° λ±λ‘** (1ν) -2. **Sagaλ‘ κ²°μ¬ μμ ** (νμν λλ§λ€) -3. **ν΄λ§μ΄ μλ μ€ν** (μ€μ λ§ νλ©΄ λ) +2. **UIμμ 미리보기 λ€μ΄μΌλ‘κ·Έ μ΄κΈ°** (νμ) +3. **μ¬μ©μκ° κ²°μ¬μ μ€μ ν μμ ** +4. **ν΄λ§μ΄ μλ μ€ν** (μ€μ λ§ νλ©΄ λ) -### μ½λ 3μ€ μμ½ +### μ½λ μμ½ ```typescript -// 1. νΈλ€λ¬ λ±λ‘ +// 1. νΈλ€λ¬ λ±λ‘ (μλ² μμ μ 1ν) registerActionHandler('my_action', myActionHandler); -// 2. κ²°μ¬ μμ -const saga = new ApprovalSubmissionSaga('my_action', payload, config); -const result = await saga.execute(); +// 2. UIμμ 미리보기 λ€μ΄μΌλ‘κ·Έ μ΄κΈ° +const { variables } = await prepareTemplateVariables(data); +setShowPreview(true); + +// 3. μ¬μ©μκ° νμΈ ν κ²°μ¬ μμ +<ApprovalPreviewDialog + templateName="λ΄ ν
νλ¦Ώ" + variables={variables} + onConfirm={async ({ approvers }) => { + await submitApproval(approvers); + }} +/> -// 3. λ! (ν΄λ§μ΄ μλ μ€ν) +// 4. λ! (ν΄λ§μ΄ μλ μ€ν) ``` --- ## π λ³κ²½ μ΄λ ₯ -### 2024-11 - Saga ν¨ν΄ μ λ©΄ 리ν©ν°λ§ +### 2024-11-06 - Request Context νΈνμ± κ°μ (RFQ λ°μ‘) +- β
Cronjob νκ²½μμ Request Context μ€λ₯ ν΄κ²° +- β
`headers()`, `getServerSession()` νΈμΆ λ¬Έμ μμ +- β
Session/Payload λΆκΈ° μ²λ¦¬ ν¨ν΄ λμ
+- β
`currentUser`λ₯Ό payloadμ ν¬ν¨νλ νμ€ ν¨ν΄ ν립 +- β
κΈ°μ‘΄ μλΉμ€ ν¨μ νΈνμ± μ μ§ (μ νμ `currentUser` νλΌλ―Έν°) +- β
RFQ λ°μ‘ νΈλ€λ¬μ μ μ© λ° κ²μ¦ +- β
READMEμ "Request Context μ£Όμμ¬ν" μΉμ
μΆκ° +- β
νΈλ¬λΈμν
κ°μ΄λ μ
λ°μ΄νΈ + +### 2024-11-06 - κ²°μ¬ λ―Έλ¦¬λ³΄κΈ° λ€μ΄μΌλ‘κ·Έ λμ
+- β
`ApprovalPreviewDialog` κ³΅ν΅ μ»΄ν¬λνΈ μΆκ° +- β
λͺ¨λ κ²°μ¬ μμ μ 미리보기λ₯Ό κ±°μΉλλ‘ νλ‘μΈμ€ λ³κ²½ +- β
μ¬μ©μκ° κ²°μ¬ λ¬Έμμ κ²°μ¬μ μ νμΈνλ νμ λ¨κ³ μΆκ° +- β
ν
νλ¦Ώ μ€μκ° λ―Έλ¦¬λ³΄κΈ° λ° λ³μ μΉν κΈ°λ₯ +- β
κ²°μ¬μ μ ν UI ν΅ν© (ApprovalLineSelector) +- β
λ°μν λμμΈ (Desktop: Dialog, Mobile: Drawer) +- β
μλ²/ν΄λΌμ΄μΈνΈ μ½λ λΆλ¦¬ (`index.ts` / `client.ts`) + +### 2024-11-05 - Saga ν¨ν΄ μ λ©΄ 리ν©ν°λ§ - β
κΈ°μ‘΄ λνΌ ν¨μ μ κ±° - β
Saga Orchestrator ν΄λμ€ λμ
- β
λΉμ¦λμ€ νλ‘μΈμ€ λͺ
μν (7λ¨κ³) diff --git a/lib/approval/README_CACHE.md b/lib/approval/README_CACHE.md new file mode 100644 index 00000000..b7bee00f --- /dev/null +++ b/lib/approval/README_CACHE.md @@ -0,0 +1,253 @@ +# κ²°μ¬ μμ€ν
μΊμ 무ν¨ν κ°μ΄λ + +## λ¬Έμ μν© + +Next.jsλ μλ² μ»΄ν¬λνΈμ λ°μ΄ν° fetch κ²°κ³Όλ₯Ό μλμΌλ‘ μΊμν©λλ€. κ²°μ¬ μμ€ν
μμ `withApproval()`λ‘ κ²°μ¬λ₯Ό μμ νκ±°λ ν΄λ§ μλΉμ€κ° κ²°μ¬λ₯Ό μΉμΈ/λ°λ € μ²λ¦¬ν΄λ μΊμκ° λ¬΄ν¨νλμ§ μμ νμ΄μ§μ μ΅μ λ°μ΄ν°κ° νμλμ§ μλ λ¬Έμ κ° μμμ΅λλ€. + +νΉν **λ°±κ·ΈλΌμ΄λ νλ‘μΈμ€** (ν΄λ§ μλΉμ€)μμλ Next.jsμ `revalidateTag`λ₯Ό μ§μ μ¬μ©ν μ μμ΅λλ€. `revalidateTag`λ request 컨ν
μ€νΈκ° νμνλ°, λ°±κ·ΈλΌμ΄λμμλ μ΄ μ»¨ν
μ€νΈκ° μ‘΄μ¬νμ§ μκΈ° λλ¬Έμ
λλ€. + +## ν΄κ²° λ°©λ² + +API λΌμ°νΈλ₯Ό ν΅ν μΊμ 무ν¨ν μμ€ν
μ ꡬμΆνμ΅λλ€. + +### 1. μΊμ νκ·Έ μμ€ν
+ +κ²°μ¬ κ΄λ ¨ λ°μ΄ν°μ μΊμ νκ·Έλ₯Ό μΆκ°: + +```typescript +// lib/approval-log/service.ts +export async function getApprovalLogList(input: ListInput) { + return unstable_cache( + async () => { + // ... λ°μ΄ν° μ‘°ν λ‘μ§ + }, + [cacheKey], + { + tags: ['approval-logs'], // π·οΈ μΊμ νκ·Έ + revalidate: 60, // 60μ΄λ§λ€ μλ μ¬κ²μ¦ (ν΄λ°±) + } + )(); +} +``` + +### 2. μΊμ 무ν¨ν API + +λ°±κ·ΈλΌμ΄λμμλ μ¬μ© κ°λ₯ν API λΌμ°νΈ: + +```typescript +// app/api/revalidate/approval/route.ts +export async function POST(request: NextRequest) { + const { tags } = await request.json(); + + // μΊμ νκ·Έ 무ν¨ν + for (const tag of tags) { + revalidateTag(tag); + } + + return NextResponse.json({ success: true }); +} +``` + +### 3. μΊμ 무ν¨ν ν¬νΌ ν¨μ + +νΈλ¦¬νκ² μ¬μ©ν μ μλ μ νΈλ¦¬ν°: + +```typescript +// lib/approval/cache-utils.ts + +// κ²°μ¬ λ‘κ·Έ μΊμ 무ν¨ν +export async function revalidateApprovalLogs() { + await fetch('/api/revalidate/approval', { + method: 'POST', + body: JSON.stringify({ tags: ['approval-logs'] }) + }); +} + +// λͺ¨λ κ²°μ¬ κ΄λ ¨ μΊμ 무ν¨ν +export async function revalidateAllApprovalCaches() { + await fetch('/api/revalidate/approval', { + method: 'POST', + body: JSON.stringify({ + tags: ['approval-logs', 'pending-actions', 'approval-templates'] + }) + }); +} +``` + +### 4. μν¬νλ‘μ°μ ν΅ν© + +κ²°μ¬ μμ /μΉμΈ/λ°λ € μ μλμΌλ‘ μΊμ 무ν¨ν: + +```typescript +// lib/approval/approval-workflow.ts + +export async function withApproval(...) { + // ... κ²°μ¬ μμ λ‘μ§ + + // μΊμ 무ν¨ν + await revalidateApprovalLogs(); + + return result; +} + +export async function executeApprovedAction(apInfId: string) { + // ... μ‘μ
μ€ν λ‘μ§ + + // μΊμ 무ν¨ν (λ°±κ·ΈλΌμ΄λμμλ λμ! β¨) + await revalidateApprovalLogs(); + + return result; +} + +export async function handleRejectedAction(apInfId: string, reason?: string) { + // ... λ°λ € μ²λ¦¬ λ‘μ§ + + // μΊμ 무ν¨ν + await revalidateApprovalLogs(); +} +``` + +## λμ μ리 + +```mermaid +sequenceDiagram + participant BG as λ°±κ·ΈλΌμ΄λ νλ‘μΈμ€<br/>(ν΄λ§ μλΉμ€) + participant API as API λΌμ°νΈ<br/>/api/revalidate/approval + participant Cache as Next.js μΊμ + participant Page as κ²°μ¬ λ‘κ·Έ νμ΄μ§ + + BG->>BG: κ²°μ¬ μΉμΈ μ²λ¦¬ + BG->>API: POST /api/revalidate/approval<br/>{ tags: ['approval-logs'] } + API->>Cache: revalidateTag('approval-logs') + Cache-->>Cache: μΊμ 무ν¨ν β
+ + Note over Page: μ¬μ©μκ° νμ΄μ§ μ μ + Page->>Cache: λ°μ΄ν° μμ² + Cache->>Page: μ΅μ λ°μ΄ν° λ°ν π +``` + +## μ¬μ© μμ + +### μμ 1: μλ‘μ΄ κ²°μ¬ μ‘μ
μ μΊμ 무ν¨ν μΆκ° + +```typescript +// lib/my-feature/approval-actions.ts +'use server'; + +import { withApproval } from '@/lib/approval'; + +export async function myActionWithApproval(data: MyData) { + // withApprovalμ΄ μλμΌλ‘ μΊμ 무ν¨νλ₯Ό μ²λ¦¬ν©λλ€ + return await withApproval('my_action', data, { + templateName: 'λμ μ‘μ
κ²°μ¬', + variables: { ... }, + currentUser: { ... } + }); +} +``` + +### μμ 2: μλμΌλ‘ μΊμ 무ν¨ν + +```typescript +import { revalidateApprovalLogs } from '@/lib/approval'; + +// νμν μμ μ μλμΌλ‘ νΈμΆ +await revalidateApprovalLogs(); +``` + +### μμ 3: μ¬λ¬ μΊμ λμ 무ν¨ν + +```typescript +import { revalidateApprovalCache } from '@/lib/approval'; + +await revalidateApprovalCache([ + 'approval-logs', + 'pending-actions', + 'my-custom-cache' +]); +``` + +## μΊμ νκ·Έ κ·μΉ + +| νκ·Έ μ΄λ¦ | μ μ© λμ | 무ν¨ν μμ | +|----------|---------|-----------| +| `approval-logs` | κ²°μ¬ λ‘κ·Έ λͺ©λ‘ | κ²°μ¬ μμ /μΉμΈ/λ°λ € μ | +| `pending-actions` | λκΈ° μ€μΈ μ‘μ
λͺ©λ‘ | μ‘μ
μ€ν/λ°λ € μ | +| `approval-log-${apInfId}` | νΉμ κ²°μ¬ μμΈ | ν΄λΉ κ²°μ¬ μν λ³κ²½ μ | +| `approval-templates` | κ²°μ¬ ν
νλ¦Ώ λͺ©λ‘ | ν
νλ¦Ώ μμ±/μμ /μμ μ | + +## 보μ (μ νμ¬ν) + +νκ²½ λ³μλ‘ μν¬λ¦Ώ ν€λ₯Ό μ€μ νμ¬ λ¬΄λ¨ μ κ·Ό λ°©μ§: + +```env +# .env.local +REVALIDATION_SECRET=your-secret-key-here +``` + +```typescript +// lib/approval/cache-utils.ts +await fetch('/api/revalidate/approval', { + method: 'POST', + body: JSON.stringify({ + tags: ['approval-logs'], + secret: process.env.REVALIDATION_SECRET + }) +}); +``` + +## μ₯μ + +β
**λ°±κ·ΈλΌμ΄λ νλ‘μΈμ€ μ§μ**: ν΄λ§ μλΉμ€μμλ μΊμ 무ν¨ν κ°λ₯ +β
**κ³΅ν΅ μ루μ
**: λͺ¨λ κ²°μ¬ κ΄λ ¨ νμ΄μ§μ μλ μ μ© +β
**μ μ°μ±**: νμν μΊμλ§ μ νμ μΌλ‘ 무ν¨ν +β
**μ λ’°μ±**: API νΈμΆ μ€ν¨ν΄λ 60μ΄ ν μλ μ¬κ²μ¦ (ν΄λ°±) +β
**νμ₯μ±**: μλ‘μ΄ μΊμ νκ·Έ μΆκ° μ©μ΄ + +## μ£Όμμ¬ν + +β οΈ **κ³Όλν 무ν¨ν λ°©μ§**: λ무 μμ£Ό μΊμλ₯Ό 무ν¨ννλ©΄ μ±λ₯ μ ν λ°μ +β οΈ **μλ¬ μ²λ¦¬**: μΊμ 무ν¨ν μ€ν¨λ μΉλͺ
μ μ΄μ§ μμΌλ―λ‘ λ‘κ·Έλ§ λ¨κΈ°κ³ μ§ν +β οΈ **κ°λ° νκ²½**: κ°λ° νκ²½μμλ μΊμ±μ΄ λΉνμ±νλ μ μμ + +## νΈλ¬λΈμν
+ +### λ¬Έμ : μΊμκ° λ¬΄ν¨νλμ§ μμ + +1. API λΌμ°νΈκ° μ¬λ°λ₯΄κ² νΈμΆλλμ§ νμΈ: + ```bash + # λ‘κ·Έ νμΈ + [Approval Workflow] Revalidating cache after approval submission + [Cache Revalidation] Tag revalidated: approval-logs + ``` + +2. μΊμ νκ·Έκ° μ¬λ°λ₯΄κ² μ€μ λμλμ§ νμΈ: + ```typescript + // getApprovalLogListμ tags: ['approval-logs'] μλμ§ νμΈ + ``` + +3. νκ²½ λ³μ νμΈ: + ```bash + # NEXT_PUBLIC_BASE_URLμ΄ μ¬λ°λ₯΄κ² μ€μ λμ΄ μλμ§ + ``` + +### λ¬Έμ : API νΈμΆμ΄ μ€ν¨ν¨ + +```typescript +// cache-utils.tsμμ μλ¬ λ‘κ·Έ νμΈ +[Approval Cache] Failed to revalidate cache: Error: ... +``` + +μμΈ: +- λ€νΈμν¬ μ΄μ +- μλͺ»λ BASE_URL +- μν¬λ¦Ώ ν€ λΆμΌμΉ + +ν΄κ²°: λ‘κ·Έλ₯Ό νμΈνκ³ νκ²½ λ³μλ₯Ό μ κ²νμΈμ. + +## μ°Έκ³ μλ£ + +- [Next.js Caching Documentation](https://nextjs.org/docs/app/building-your-application/caching) +- [revalidateTag API Reference](https://nextjs.org/docs/app/api-reference/functions/revalidateTag) +- [unstable_cache API Reference](https://nextjs.org/docs/app/api-reference/functions/unstable_cache) + |
