diff --git a/apps/web/app/(pages)/admin/page.tsx b/apps/web/app/(pages)/admin/page.tsx index eaff3a1..61a530d 100644 --- a/apps/web/app/(pages)/admin/page.tsx +++ b/apps/web/app/(pages)/admin/page.tsx @@ -2,80 +2,93 @@ import { api } from "@/lib/api"; import { Badge } from "@/components/shared/badge"; import { Card, CardHeader } from "@/components/shared/card"; import { JobControls } from "@/components/shared/job-controls"; +import { PageErrorState } from "@/components/shared/page-error-state"; export default async function AdminPage() { - const data = await api.admin(); + try { + const data = await api.admin(); - return ( -
- - -
+ return ( +
+ + +
+ +

활성 소스

+
    + {data.sources.map((source) => ( +
  • + {source.name} : {source.kind} : 신뢰도 {source.reliabilityScore.toFixed(2)} +
  • + ))} +
+
+ +

역할별 모델 라우팅

+
    + {data.modelRouting.map((route) => ( +
  • + {route.role} : {route.model} +
  • + ))} +
+
+
+
+ + + + + + +
-

활성 소스

-
    - {data.sources.map((source) => ( -
  • - {source.name} · {source.kind} · 신뢰도 {source.reliabilityScore.toFixed(2)} -
  • - ))} -
+

비용 가드레일

+

+ 일일 최대 ${data.costGuardrails.dailyBudgetUsd.toFixed(2)} / 작업당 최대 $ + {data.costGuardrails.perJobBudgetUsd.toFixed(2)} +

-

역할별 모델 라우팅

-
    - {data.modelRouting.map((route) => ( -
  • - {route.role} → {route.model} -
  • - ))} -
+

스케줄러

+

+ 프리마켓 {data.scheduler.premarketCron} +
+ 포스트마켓 {data.scheduler.postmarketCron} +

+
+ +
+

승격 승인

+ + {data.promotion.manualApprovalRequired ? "수동 승인 필요" : "자동 승인"} + +
+

+ 모든 승격은 평가 아티팩트 저장, 롤링 OOS 검증, 사용자 수동 승인까지 완료되어야 운영 설정에 반영됩니다. +

- - - - - - - -
- -

비용 가드레일

-

- 일일 최대 ${data.costGuardrails.dailyBudgetUsd.toFixed(2)} / 작업당 최대 $ - {data.costGuardrails.perJobBudgetUsd.toFixed(2)} -

-
- -

스케줄러

-

- 프리마켓 {data.scheduler.premarketCron} -
- 포스트마켓 {data.scheduler.postmarketCron} -

-
- -
-

승격 승인

- - {data.promotion.manualApprovalRequired ? "수동 승인 필요" : "자동 아님"} - -
-

- 후보 승격은 평가 아티팩트 저장, 롤링 OOS 개선 확인, 사용자 수동 승인 전까지 - 실운영 라우팅에 반영되지 않습니다. -

-
-
- ); + ); + } catch (error) { + const message = error instanceof Error ? error.message : "알 수 없는 오류"; + + return ( + + ); + } } diff --git a/apps/web/app/(pages)/dashboard/page.tsx b/apps/web/app/(pages)/dashboard/page.tsx index 9678e63..b0fcacd 100644 --- a/apps/web/app/(pages)/dashboard/page.tsx +++ b/apps/web/app/(pages)/dashboard/page.tsx @@ -5,191 +5,181 @@ import { Card, CardHeader } from "@/components/shared/card"; import { JobControls } from "@/components/shared/job-controls"; import { SectionList } from "@/components/shared/section-list"; import { MetricCard } from "@/components/dashboard/metric-card"; +import { PageErrorState } from "@/components/shared/page-error-state"; export default async function DashboardPage() { - const data = await api.dashboard(); + try { + const data = await api.dashboard(); - return ( -
- -
-
-
-
-
-
-

Premarket Briefing

-
-

- 미국 반응을 검증해 내일 한국장 테마와 주도주 후보를 빠르게 압축합니다. -

-

- 공식 원문, 실측 반응, 아날로그 검색, 역할별 에이전트 논증을 하나의 - 증거 사슬로 묶어 다음 영업일 한국장 해석을 빠르게 압축합니다. -

-
-
- - U.S. Macro - - - Market Reaction - - - Korea Themes - -
-
- {data.riskFlags.map((flag) => ( - - {flag} - - ))} -
-
-
-
-
-

오늘의 핵심 결론

-
-

우선 테마

-

- {data.topTheme?.name ?? "데이터 대기"} -

-
-
-

주도주 후보

-

- {data.topLeader?.ticker ?? "-"} {data.topLeader?.name ?? ""} -

+ return ( +
+ +
+
+
+
+
+
+

Premarket Briefing

+
+

+ 미국 반응을 검증해 내일 한국장의 테마와 주도주 후보를 빠르게 점검합니다 +

+

+ 공식 원문, 미국 시장 반응, 과거 패턴, 에이전트 분석을 하나의 흐름으로 묶어 다음 영업일 한국장 + 아이디어를 압축해서 보여줍니다. +

+
+
+ + U.S. Macro + + + Market Reaction + + + Korea Themes + +
+
+ {data.riskFlags.map((flag) => ( + + {flag} + + ))} +
-
-
-

- 종합 신뢰도 -

-

- {data.topTheme ? formatScore(data.topTheme.confidence) : "-"} -

+
+
+

오늘의 핵심 결론

+
+

우선 테마

+

+ {data.topTheme?.name ?? "데이터 대기"} +

+
+
+

주도주 후보

+

+ {data.topLeader?.ticker ?? "-"} {data.topLeader?.name ?? ""} +

+
-
-

- 미국 반응 -

-

- {formatPct(data.usReactionSummary.totalMovePct)} -

+
+
+

종합 확신

+

+ {data.topTheme ? formatScore(data.topTheme.confidence) : "-"} +

+
+
+

미국 반응

+

+ {formatPct(data.usReactionSummary.totalMovePct)} +

+
-
- - -
- - - - -
+ - - - - +
+ + + + +
-
- ({ - title: `${event.categoryLabel} · ${event.title}`, - subtitle: `${event.sourceName} | 직접성 ${formatScore(event.directnessScore)} | 시장확인 ${formatScore(event.marketConfirmationScore)}`, - meta: event.publishedAtLabel - }))} + eyebrow="Run Pipeline" + title="버튼으로 즉시 수집 / 분석 실행" + description="터미널 명령 없이 바로 작업을 요청할 수 있습니다. 전체 파이프라인 또는 개별 단계만 실행 가능합니다." /> + - - -
- {data.usReactionSummary.items.map((item) => ( -
-
-

{item.label}

- = 0 ? "ok" : "danger"}> - {formatPct(item.movePct)} - + +
+ + + ({ + title: `${event.categoryLabel} : ${event.title}`, + subtitle: `${event.sourceName} | 직접성 ${formatScore(event.directnessScore)} | 시장 확인 ${formatScore(event.marketConfirmationScore)}`, + meta: event.publishedAtLabel + }))} + /> + + + +
+ {data.usReactionSummary.items.map((item) => ( +
+
+

{item.label}

+ = 0 ? "ok" : "danger"}>{formatPct(item.movePct)} +
+

{item.commentary}

-

- {item.commentary} -

-
- ))} -
- -
+ ))} +
+ +
-
- - - ({ - title: `${theme.name} · ${formatScore(theme.confidence)}`, - subtitle: `${theme.rationale} | 거래용이성 ${formatScore(theme.tradabilityScore)} | 갭페이드 위험 ${formatScore(theme.gapFadeRisk)}`, - meta: theme.marketView - }))} - /> - - - - ({ - title: `${stock.ticker} ${stock.name}`, - subtitle: `${stock.tierLabel} | ${stock.rationale} | 선반영 위험 ${formatScore(stock.pricedInRisk)}`, - meta: formatScore(stock.score) - }))} - /> - +
+ + + ({ + title: `${theme.name} : ${formatScore(theme.confidence)}`, + subtitle: `${theme.rationale} | 거래 용이성 ${formatScore(theme.tradabilityScore)} | 갭 페이드 위험 ${formatScore(theme.gapFadeRisk)}`, + meta: theme.marketView + }))} + /> + + + + ({ + title: `${stock.ticker} ${stock.name}`, + subtitle: `${stock.tierLabel} | ${stock.rationale} | 선반영 위험 ${formatScore(stock.pricedInRisk)}`, + meta: formatScore(stock.score) + }))} + /> + +
-
- ); + ); + } catch (error) { + const message = error instanceof Error ? error.message : "알 수 없는 오류"; + + return ( + + ); + } } diff --git a/apps/web/app/(pages)/error.tsx b/apps/web/app/(pages)/error.tsx new file mode 100644 index 0000000..d4fa8da --- /dev/null +++ b/apps/web/app/(pages)/error.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Card, CardHeader } from "@/components/shared/card"; + +export default function PagesError({ + error, + reset +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+ + +
+

오류 메시지

+

{error.message}

+ +
+
+
+ ); +} diff --git a/apps/web/app/(pages)/evaluation/page.tsx b/apps/web/app/(pages)/evaluation/page.tsx index 69dde9c..372ef5d 100644 --- a/apps/web/app/(pages)/evaluation/page.tsx +++ b/apps/web/app/(pages)/evaluation/page.tsx @@ -1,6 +1,7 @@ import { api } from "@/lib/api"; import { Card, CardHeader } from "@/components/shared/card"; import { Badge } from "@/components/shared/badge"; +import { PageErrorState } from "@/components/shared/page-error-state"; import { formatPct, formatScore } from "@/lib/format"; export default async function EvaluationPage() { @@ -12,8 +13,8 @@ export default async function EvaluationPage() {
@@ -29,7 +30,7 @@ export default async function EvaluationPage() {

{formatPct(data.rollingMetrics.falsePositiveRate * 100)}

-

갭 페이드 실패

+

갭 페이드 실패율

{formatPct(data.rollingMetrics.gapFadeMissRate * 100)}

@@ -37,7 +38,7 @@ export default async function EvaluationPage() {
- +
{data.promptLeaderboard.map((item) => (
- - -
-

오류 메시지

-

{message}

-
-
-
+ ); } } diff --git a/apps/web/app/(pages)/events/page.tsx b/apps/web/app/(pages)/events/page.tsx index 7df48ac..4239ad0 100644 --- a/apps/web/app/(pages)/events/page.tsx +++ b/apps/web/app/(pages)/events/page.tsx @@ -1,111 +1,125 @@ import { api } from "@/lib/api"; import { Badge } from "@/components/shared/badge"; import { Card, CardHeader } from "@/components/shared/card"; +import { PageErrorState } from "@/components/shared/page-error-state"; import { formatPct, formatScore } from "@/lib/format"; export default async function EventsPage() { - const data = await api.events(); + try { + const data = await api.events(); - return ( -
- - -
- {data.clusters.map((cluster) => ( -
-
-
-
- {cluster.categoryLabel} - - {cluster.marketConfirmed ? "시장 확인" : "시장 미확인"} - + return ( +
+ + +
+ {data.clusters.map((cluster) => ( +
+
+
+
+ {cluster.categoryLabel} + + {cluster.marketConfirmed ? "시장 확인" : "시장 미확인"} + +
+

+ {cluster.title} +

+

+ {cluster.summary} +

+
+
+

새로움 {formatScore(cluster.noveltyScore)}

+

직접성 {formatScore(cluster.directnessScore)}

+

서프라이즈 {formatScore(cluster.surpriseScore)}

+

지속성 {formatScore(cluster.persistenceScore)}

-

- {cluster.title} -

-

- {cluster.summary} -

-
-

새로움 {formatScore(cluster.noveltyScore)}

-

직접성 {formatScore(cluster.directnessScore)}

-

서프라이즈 {formatScore(cluster.surpriseScore)}

-

지속성 {formatScore(cluster.persistenceScore)}

+
+ +

원문 출처

+ {cluster.sources.length > 0 ? ( +
    + {cluster.sources.map((source) => ( +
  • + + {source.name} + + 수집 {source.fetchedAtLabel} +
  • + ))} +
+ ) : ( +

+ 아직 연결된 원문 출처가 없습니다. +

+ )} +
+ +

미국 시장 반응

+ {cluster.reactions.length > 0 ? ( +
    + {cluster.reactions.map((reaction) => ( +
  • + {reaction.label}: {formatPct(reaction.movePct)} / {reaction.window} +
  • + ))} +
+ ) : ( +

+ 아직 시장 반응 데이터가 기록되지 않았습니다. +

+ )} +
+ +

에이전트 코멘터리

+ {cluster.agentNotes.length > 0 ? ( +
    + {cluster.agentNotes.map((note) => ( +
  • + {note.role} + {" : "} + {note.note} +
  • + ))} +
+ ) : ( +

+ 아직 기록된 에이전트 코멘터리가 없습니다. 최신 분석을 한 번 더 실행해 주세요. +

+ )} +
-
- -

원문 출처

- {cluster.sources.length > 0 ? ( -
    - {cluster.sources.map((source) => ( -
  • - - {source.name} - - 수집 {source.fetchedAtLabel} -
  • - ))} -
- ) : ( -

- 아직 연결된 원문 출처가 없습니다. -

- )} -
- -

미국 시장 반응

- {cluster.reactions.length > 0 ? ( -
    - {cluster.reactions.map((reaction) => ( -
  • - {reaction.label}: {formatPct(reaction.movePct)} / {reaction.window} -
  • - ))} -
- ) : ( -

- 아직 시장 반응 데이터가 기록되지 않았습니다. -

- )} -
- -

에이전트 코멘터리

- {cluster.agentNotes.length > 0 ? ( -
    - {cluster.agentNotes.map((note) => ( -
  • - {note.role} - {" : "} - {note.note} -
  • - ))} -
- ) : ( -

- 아직 기록된 에이전트 코멘터리가 없습니다. 최신 분석을 한 번 더 실행해 주세요. -

- )} -
-
-
- ))} -
-
-
- ); + ))} +
+ +
+ ); + } catch (error) { + const message = error instanceof Error ? error.message : "알 수 없는 오류"; + + return ( + + ); + } } diff --git a/apps/web/app/(pages)/themes/page.tsx b/apps/web/app/(pages)/themes/page.tsx index 03c5773..6a4880e 100644 --- a/apps/web/app/(pages)/themes/page.tsx +++ b/apps/web/app/(pages)/themes/page.tsx @@ -1,87 +1,101 @@ import { api } from "@/lib/api"; import { Badge } from "@/components/shared/badge"; import { Card, CardHeader } from "@/components/shared/card"; +import { PageErrorState } from "@/components/shared/page-error-state"; import { formatScore } from "@/lib/format"; export default async function ThemesPage() { - const data = await api.themes(); + try { + const data = await api.themes(); - return ( -
- - -
- {data.themes.map((theme) => ( -
-
-
-
- {theme.name} - = 0.7 ? "ok" : "warn"}> - 확신 {formatScore(theme.confidence)} - + return ( +
+ + +
+ {data.themes.map((theme) => ( +
+
+
+
+ {theme.name} + = 0.7 ? "ok" : "warn"}> + 확신 {formatScore(theme.confidence)} + +
+

+ {theme.rationale} +

+
+
+

테마 적합도 {formatScore(theme.themeFitScore)}

+

거래 용이성 {formatScore(theme.tradabilityScore)}

+

갭 페이드 위험 {formatScore(theme.gapFadeRisk)}

+

선반영 위험 {formatScore(theme.pricedInRisk)}

-

- {theme.rationale} -

-
-

테마 적합도 {formatScore(theme.themeFitScore)}

-

거래 용이성 {formatScore(theme.tradabilityScore)}

-

갭페이드 위험 {formatScore(theme.gapFadeRisk)}

-

선반영 위험 {formatScore(theme.pricedInRisk)}

+
+ +

주도주 후보

+
    + {theme.leaders.map((stock) => ( +
  • + + {stock.ticker} {stock.name} + + {" : "} + {stock.rationale} +
  • + ))} +
+
+ +

2등주 후보

+
    + {theme.secondTier.map((stock) => ( +
  • + + {stock.ticker} {stock.name} + + {" : "} + {stock.rationale} +
  • + ))} +
+
+ +

무효화 / 추적 메모

+
    +
  • 무효화 조건: {theme.invalidationCondition}
  • +
  • 시가 메모: {theme.openNote}
  • +
  • 15분 메모: {theme.fifteenMinuteNote}
  • +
  • 종가 메모: {theme.closeNote}
  • +
+
-
- -

주도주 후보

-
    - {theme.leaders.map((stock) => ( -
  • - - {stock.ticker} {stock.name} - - {" · "} - {stock.rationale} -
  • - ))} -
-
- -

2등주 후보

-
    - {theme.secondTier.map((stock) => ( -
  • - - {stock.ticker} {stock.name} - - {" · "} - {stock.rationale} -
  • - ))} -
-
- -

무효화 / 추적 메모

-
    -
  • 무효화 조건: {theme.invalidationCondition}
  • -
  • 시가 메모: {theme.openNote}
  • -
  • 15분 메모: {theme.fifteenMinuteNote}
  • -
  • 종가 메모: {theme.closeNote}
  • -
-
-
-
- ))} -
-
-
- ); + ))} +
+ +
+ ); + } catch (error) { + const message = error instanceof Error ? error.message : "알 수 없는 오류"; + + return ( + + ); + } } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 1ce210f..9e24774 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -5,7 +5,7 @@ import { AppShell } from "@/components/layout/app-shell"; export const metadata: Metadata = { title: "K-테마 리서치 허브", description: - "미국 이벤트를 기반으로 다음 영업일 한국 시장 테마와 주도주를 예측하는 개인용 이벤트 드리븐 리서치 플랫폼" + "미국 이벤트를 기반으로 다음 영업일 한국 증시 테마와 주도주를 추론하는 개인용 크로스마켓 리서치 플랫폼" }; export default function RootLayout({ diff --git a/apps/web/components/layout/app-shell.tsx b/apps/web/components/layout/app-shell.tsx index b7a1bb6..b5c869d 100644 --- a/apps/web/components/layout/app-shell.tsx +++ b/apps/web/components/layout/app-shell.tsx @@ -28,15 +28,15 @@ export function AppShell({ children }: { children: React.ReactNode }) {

Personal Research OS

-

+

미국 이벤트에서
내일 한국장 힌트를 찾습니다

- 미국 정책, 기업, 매크로 이벤트를 수집하고 실제 시장 반응으로 검증한 뒤 - 한국 시장의 다음 영업일 테마와 주도주 후보를 압축합니다. + 미국 정책, 기업, 매크로 이벤트를 수집하고 실제 시장 반응으로 검증한 뒤 한국 시장의 다음 영업일 테마와 + 주도주 후보를 빠르게 점검합니다.

- 프리마켓, 장중, 마감 후 점검 흐름을 하나의 작업 체인으로 연결합니다. + 프리마켓, 장중, 마감 후 흐름을 하나의 작업 체인으로 연결합니다.

diff --git a/apps/web/components/shared/job-controls.tsx b/apps/web/components/shared/job-controls.tsx index 04ee169..3d2f56a 100644 --- a/apps/web/components/shared/job-controls.tsx +++ b/apps/web/components/shared/job-controls.tsx @@ -33,30 +33,30 @@ const jobConfig: Record< refresh: { path: "/jobs/refresh", label: "수집 후 분석 실행", - description: "원문 수집부터 이벤트/미국 반응/한국장 번역까지 한 번에 실행합니다.", - tone: "bg-[#1ed760] text-[#121212] hover:bg-[#3be477]", + description: "원문 수집부터 이벤트, 미국 반응, 한국장 번역까지 한 번에 실행합니다.", + tone: "bg-[#1ed760] text-[#121212] hover:bg-[#3be477]" }, ingest: { path: "/jobs/ingest", label: "수집만 실행", - description: "공식 소스와 피드를 다시 읽어 원문 문서를 적재합니다.", - tone: "bg-[color:var(--bg-deep)] text-[color:var(--text)] hover:bg-[#2a2a2a]", + description: "공식 소스와 뉴스 피드를 다시 읽어 원문 문서를 적재합니다.", + tone: "bg-[color:var(--bg-deep)] text-[color:var(--text)] hover:bg-[#2a2a2a]" }, analyze: { path: "/jobs/analyze", label: "분석만 실행", - description: "이미 수집된 문서를 이벤트/시장 반응/한국 테마로 변환합니다.", - tone: "bg-[color:var(--surface-white)] text-[color:var(--text)] hover:bg-[#313131]", - }, + description: "이미 수집된 문서를 이벤트, 시장 반응, 한국 테마로 변환합니다.", + tone: "bg-[color:var(--surface-white)] text-[color:var(--text)] hover:bg-[#313131]" + } }; async function triggerJob(kind: JobKind) { const response = await fetch(`${API_BASE_URL}${jobConfig[kind].path}`, { method: "POST", headers: { - "Content-Type": "application/json", + "Content-Type": "application/json" }, - body: JSON.stringify({}), + body: JSON.stringify({}) }); if (!response.ok) { @@ -68,7 +68,7 @@ async function triggerJob(kind: JobKind) { async function fetchRecentJobs() { const response = await fetch(`${API_BASE_URL}/jobs/recent`, { - cache: "no-store", + cache: "no-store" }); if (!response.ok) { @@ -110,7 +110,7 @@ export function JobControls({ compact = false }: { compact?: boolean }) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [message, setMessage] = useState( - "워커가 실행 중이면 버튼으로 바로 수집/분석 파이프라인을 시작할 수 있습니다.", + "워커가 실행 중이면 버튼으로 바로 수집/분석 파이프라인을 시작할 수 있습니다." ); const [jobs, setJobs] = useState([]); const [isPolling, setIsPolling] = useState(true); @@ -125,9 +125,7 @@ export function JobControls({ compact = false }: { compact?: boolean }) { router.refresh(); } } catch (error) { - setMessage( - error instanceof Error ? error.message : "작업 상태를 불러오지 못했습니다.", - ); + setMessage(error instanceof Error ? error.message : "작업 상태를 불러오지 못했습니다."); } }; @@ -150,7 +148,7 @@ export function JobControls({ compact = false }: { compact?: boolean }) { try { const result = await triggerJob(kind); setMessage( - `${jobConfig[kind].label} 요청을 큐에 넣었습니다. jobId: ${result.jobId ?? "-"} / 진행 상태는 아래 패널에서 자동 갱신됩니다.`, + `${jobConfig[kind].label} 요청이 접수됐습니다. jobId: ${result.jobId ?? "-"} / 아래 패널에서 자동 갱신됩니다.` ); setIsPolling(true); await loadJobs(); @@ -161,7 +159,7 @@ export function JobControls({ compact = false }: { compact?: boolean }) { setMessage( error instanceof Error ? `${error.message}. API, Redis, 워커가 실행 중인지 확인해 주세요.` - : "작업 실행에 실패했습니다.", + : "작업 실행에 실패했습니다." ); } }); @@ -211,7 +209,7 @@ export function JobControls({ compact = false }: { compact?: boolean }) {
- {jobs.length ? ( + {jobs.length > 0 ? ( jobs.slice(0, 2).map((job) => (
+ +
+

오류 메시지

+

{message}

+
+ + ); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 755e9f9..65b09fe 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -11,19 +11,60 @@ import { const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000/api/v1"; -async function fetchJson(path: string, schema: { parse: (value: unknown) => T }): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { - cache: "no-store", - headers: { - "Content-Type": "application/json" +const REQUEST_TIMEOUT_MS = 8000; +const MAX_ATTEMPTS = 3; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function toFriendlyError(path: string, error: unknown) { + if (error instanceof Error) { + if (error.name === "AbortError") { + return new Error(`API 응답이 지연되고 있습니다: ${path}`); } - }); - if (!response.ok) { - throw new Error(`API request failed: ${response.status} ${response.statusText}`); + if ( + error.message.includes("fetch failed") || + error.message.includes("ECONNREFUSED") || + error.message.includes("ENOTFOUND") + ) { + return new Error(`API 서버에 연결하지 못했습니다: ${path}`); + } + } + + return error instanceof Error ? error : new Error(`API 요청에 실패했습니다: ${path}`); +} + +async function fetchJson(path: string, schema: { parse: (value: unknown) => T }): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + try { + const response = await fetch(`${API_BASE_URL}${path}`, { + cache: "no-store", + headers: { + "Content-Type": "application/json" + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + return schema.parse(await response.json()); + } catch (error) { + lastError = toFriendlyError(path, error) as Error; + + if (attempt < MAX_ATTEMPTS) { + await sleep(250 * attempt); + continue; + } + } } - return schema.parse(await response.json()); + throw lastError ?? new Error(`API 요청에 실패했습니다: ${path}`); } export const api = { diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 576a483..88c2b69 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,10 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - experimental: { - typedRoutes: true - } + typedRoutes: true }; export default nextConfig; -