diff --git a/frontend/src/app/providers/AppProviders.tsx b/frontend/src/app/providers/AppProviders.tsx
index 6714fbf8..e69a3889 100644
--- a/frontend/src/app/providers/AppProviders.tsx
+++ b/frontend/src/app/providers/AppProviders.tsx
@@ -2,6 +2,7 @@ import { Suspense } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from 'react-router-dom'
import { AuthProvider } from '@/features/auth'
+import { ToastViewport } from '@/shared/ui'
import { router } from '@/app/router'
const queryClient = new QueryClient({
@@ -21,6 +22,7 @@ export function AppProviders() {
+
)
diff --git a/frontend/src/features/repo/model/useRepositories.ts b/frontend/src/features/repo/model/useRepositories.ts
index 8aa82e24..9423fa2a 100644
--- a/frontend/src/features/repo/model/useRepositories.ts
+++ b/frontend/src/features/repo/model/useRepositories.ts
@@ -1,4 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { isApiError } from '@/shared/api'
+import { toast } from '@/shared/ui'
import {
deleteRepository,
fetchCandidateRepositories,
@@ -7,6 +9,9 @@ import {
} from '../api/repo'
import type { CandidateRepository, RegisteredRepository } from './types'
+const errMessage = (e: unknown, fallback: string) =>
+ isApiError(e) ? e.message : fallback
+
export const repoKeys = {
// registered 와 candidates 모두 ['repositories'] prefix → 한 번에 invalidate 가능
registered: ['repositories'] as const,
@@ -39,6 +44,7 @@ export function useRegisterRepository() {
mutationFn: registerRepository,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: repoKeys.registered })
+ toast.success('레포지토리를 등록했어요. 분석이 곧 시작됩니다.')
},
})
}
@@ -52,6 +58,8 @@ export function useDeleteRepository() {
// 분석 결과(documents)는 analysis feature 소유라 직접 import 하지 않고(FSD 동일레이어 금지)
// 키 리터럴 ['documents'] 로 무효화 — 삭제는 클라이언트 액션이라 SSE 가 오지 않는다.
void queryClient.invalidateQueries({ queryKey: ['documents'] })
+ toast.success('레포지토리를 삭제했어요')
},
+ onError: (e) => toast.error(errMessage(e, '레포지토리 삭제에 실패했어요')),
})
}
diff --git a/frontend/src/features/resume/model/useResumes.ts b/frontend/src/features/resume/model/useResumes.ts
index 99134dfd..8e32dffb 100644
--- a/frontend/src/features/resume/model/useResumes.ts
+++ b/frontend/src/features/resume/model/useResumes.ts
@@ -1,7 +1,12 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { isApiError } from '@/shared/api'
+import { toast } from '@/shared/ui'
import { deleteResume, fetchResumes, uploadResume } from '../api/resume'
import type { Resume } from './types'
+const errMessage = (e: unknown, fallback: string) =>
+ isApiError(e) ? e.message : fallback
+
export const resumeKeys = {
all: ['resumes'] as const,
}
@@ -19,6 +24,7 @@ export function useUploadResume() {
mutationFn: uploadResume,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: resumeKeys.all })
+ toast.success('이력서를 업로드했어요. 분석이 곧 시작됩니다.')
},
})
}
@@ -32,6 +38,8 @@ export function useDeleteResume() {
// 분석 결과(documents)는 analysis feature 소유라 직접 import 하지 않고(FSD 동일레이어 금지)
// 키 리터럴 ['documents'] 로 무효화 — 삭제는 클라이언트 액션이라 SSE 가 오지 않는다.
void queryClient.invalidateQueries({ queryKey: ['documents'] })
+ toast.success('이력서를 삭제했어요')
},
+ onError: (e) => toast.error(errMessage(e, '이력서 삭제에 실패했어요')),
})
}
diff --git a/frontend/src/shared/ui/Toast/Toast.test.tsx b/frontend/src/shared/ui/Toast/Toast.test.tsx
new file mode 100644
index 00000000..b92ee523
--- /dev/null
+++ b/frontend/src/shared/ui/Toast/Toast.test.tsx
@@ -0,0 +1,44 @@
+import { describe, it, expect, afterEach } from 'vitest'
+import { render, screen, act } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { ToastViewport } from './ToastViewport'
+import { toast, getToasts, dismissToast } from './toastStore'
+
+afterEach(() => {
+ // 스토어는 모듈 싱글톤 — 테스트 간 잔여 토스트 정리
+ for (const t of getToasts()) dismissToast(t.id)
+})
+
+describe('toastStore', () => {
+ it('push 하면 항목이 쌓이고 dismiss 하면 제거된다', () => {
+ const id = toast.success('저장됐어요', 0)
+ expect(getToasts().map((t) => t.message)).toContain('저장됐어요')
+ dismissToast(id)
+ expect(getToasts()).toHaveLength(0)
+ })
+
+ it('tone 별 헬퍼가 올바른 tone 을 단다', () => {
+ toast.error('실패', 0)
+ expect(getToasts().at(-1)?.tone).toBe('error')
+ })
+})
+
+describe('ToastViewport', () => {
+ it('토스트 메시지를 렌더하고 닫기 버튼으로 제거한다', async () => {
+ render()
+ act(() => {
+ toast.info('알림입니다', 0)
+ })
+ expect(screen.getByText('알림입니다')).toBeTruthy()
+ await userEvent.click(screen.getByRole('button', { name: '알림 닫기' }))
+ expect(screen.queryByText('알림입니다')).toBeNull()
+ })
+
+ it('status 역할로 노출되어 스크린리더가 읽는다', () => {
+ render()
+ act(() => {
+ toast.success('완료', 0)
+ })
+ expect(screen.getByRole('status')).toBeTruthy()
+ })
+})
diff --git a/frontend/src/shared/ui/Toast/ToastViewport.tsx b/frontend/src/shared/ui/Toast/ToastViewport.tsx
new file mode 100644
index 00000000..f2aa6b10
--- /dev/null
+++ b/frontend/src/shared/ui/Toast/ToastViewport.tsx
@@ -0,0 +1,64 @@
+import { useSyncExternalStore } from 'react'
+import { createPortal } from 'react-dom'
+import {
+ dismissToast,
+ getToasts,
+ subscribeToasts,
+ type ToastTone,
+} from './toastStore'
+
+const toneClass: Record = {
+ success: 'border-l-success text-success-700',
+ error: 'border-l-danger text-danger-700',
+ info: 'border-l-info text-info-700',
+}
+
+function ToneIcon({ tone }: { tone: ToastTone }) {
+ return (
+
+ )
+}
+
+// 화면 우하단(모바일 상단)에 토스트 스택을 렌더한다. AppProviders 에 1회 마운트.
+export function ToastViewport() {
+ const items = useSyncExternalStore(subscribeToasts, getToasts, getToasts)
+ if (typeof document === 'undefined' || items.length === 0) return null
+
+ return createPortal(
+
+ {items.map((t) => (
+
+
+
+
+
{t.message}
+
+
+ ))}
+
,
+ document.body,
+ )
+}
diff --git a/frontend/src/shared/ui/Toast/index.ts b/frontend/src/shared/ui/Toast/index.ts
new file mode 100644
index 00000000..31c33797
--- /dev/null
+++ b/frontend/src/shared/ui/Toast/index.ts
@@ -0,0 +1,3 @@
+export { ToastViewport } from './ToastViewport'
+export { toast, dismissToast } from './toastStore'
+export type { ToastTone, ToastItem } from './toastStore'
diff --git a/frontend/src/shared/ui/Toast/toastStore.ts b/frontend/src/shared/ui/Toast/toastStore.ts
new file mode 100644
index 00000000..5dfc2f2f
--- /dev/null
+++ b/frontend/src/shared/ui/Toast/toastStore.ts
@@ -0,0 +1,58 @@
+// 도메인 비종속 토스트 스토어. 모듈 싱글톤 + 구독 모델이라 훅 밖(예: 뮤테이션 onSuccess)에서도
+// toast.success(...) 로 호출할 수 있다. ToastViewport 가 useSyncExternalStore 로 구독한다.
+
+export type ToastTone = 'success' | 'error' | 'info'
+export type ToastItem = { id: number; tone: ToastTone; message: string }
+
+const DEFAULT_DURATION_MS = 4000
+
+let items: ToastItem[] = []
+let seq = 0
+const listeners = new Set<() => void>()
+const timers = new Map>()
+
+function emit() {
+ for (const l of listeners) l()
+}
+
+export function subscribeToasts(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+}
+
+// useSyncExternalStore 는 변경이 없으면 동일 참조를 기대하므로 items 를 그대로 반환한다.
+export function getToasts(): ToastItem[] {
+ return items
+}
+
+export function dismissToast(id: number): void {
+ const t = timers.get(id)
+ if (t) {
+ clearTimeout(t)
+ timers.delete(id)
+ }
+ const next = items.filter((i) => i.id !== id)
+ if (next.length !== items.length) {
+ items = next
+ emit()
+ }
+}
+
+function push(tone: ToastTone, message: string, durationMs = DEFAULT_DURATION_MS): number {
+ const id = ++seq
+ items = [...items, { id, tone, message }]
+ emit()
+ if (durationMs > 0) {
+ timers.set(
+ id,
+ setTimeout(() => dismissToast(id), durationMs),
+ )
+ }
+ return id
+}
+
+export const toast = {
+ success: (message: string, durationMs?: number) => push('success', message, durationMs),
+ error: (message: string, durationMs?: number) => push('error', message, durationMs),
+ info: (message: string, durationMs?: number) => push('info', message, durationMs),
+}
diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts
index 3f371790..7920cb07 100644
--- a/frontend/src/shared/ui/index.ts
+++ b/frontend/src/shared/ui/index.ts
@@ -4,3 +4,5 @@ export { Modal } from './Modal'
export type { ModalProps } from './Modal'
export { ConfirmDialog } from './ConfirmDialog'
export type { ConfirmDialogProps } from './ConfirmDialog'
+export { ToastViewport, toast, dismissToast } from './Toast'
+export type { ToastTone, ToastItem } from './Toast'