diff --git a/frontend/src/features/symbol/Symbol.jsx b/frontend/src/features/symbol/Symbol.jsx
new file mode 100644
index 0000000..f9000e1
--- /dev/null
+++ b/frontend/src/features/symbol/Symbol.jsx
@@ -0,0 +1,324 @@
+import { useState, useEffect } from 'react'
+import { useQuery, useMutation } from '@tanstack/react-query'
+import { api } from '../../api/client'
+import { useLayout } from '../../components/Layout'
+import { SYMBOL_TABS, SYMBOLS } from './data'
+
+function CharacterCard({ char, active, onSelect, onRemove }) {
+ return (
+
{
+ if (e.target.closest('button')) return
+ onSelect()
+ }}
+ className={`group relative shrink-0 w-36 rounded-xl border cursor-pointer select-none transition ${
+ active
+ ? 'border-emerald-500/40 bg-emerald-500/[0.08]'
+ : 'border-white/5 hover:border-white/15 bg-gray-950/40 hover:bg-gray-950/60'
+ }`}
+ >
+ {/* 삭제 (우상단) */}
+
+
+ {/* 내용 */}
+
+
+ {char.character_image ? (
+

+ ) : (
+
?
+ )}
+
+
+ {char.character_name}
+
+
+ Lv.{char.character_level} · {char.job_name}
+
+
+
+ )
+}
+
+function TabImage({ name }) {
+ const { data } = useQuery({
+ queryKey: ['image', name],
+ queryFn: () => api('/api/images/' + encodeURIComponent(name)).catch(() => null),
+ staleTime: Infinity,
+ })
+ if (!data?.url) return
+ return
+}
+
+function SymbolCard({ symbol, equipped }) {
+ // 임시 목업 데이터
+ const level = equipped ? 7 : 0
+ const maxLevel = 20
+ const growth = equipped ? 120 : 0
+ const requireGrowth = 60
+ const remainingSymbols = 540
+ const remainingMeso = 128_000_000
+ const daysLeft = equipped ? 84 : '-'
+ const completeDate = equipped ? '2026년 07월 09일 (목)' : '미장착'
+
+ return (
+
+
+
+

+
+
+
{symbol.name}
+
+ Lv.{level}
+ / {maxLevel}
+
+
+
+
+ {/* 진행도 바 */}
+
+
+ 성장치 {growth} / {requireGrowth}
+ {requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}%
+
+
+
+
+ {/* 획득량 입력 */}
+
+
+ {/* 정보 */}
+
+
+ 남은 심볼
+ {remainingSymbols.toLocaleString()}
+
+
+ 필요 메소
+ {remainingMeso.toLocaleString()}
+
+
+ 체납 메소
+ {(equipped ? 18_000_000 : 0).toLocaleString()}
+
+
+ 남은 일수
+ {typeof daysLeft === 'number' ? `${daysLeft}일` : daysLeft}
+
+
+ 예상 완료일
+
+ {completeDate}
+
+
+
+
+ )
+}
+
+export default function Symbol() {
+ const { setFullscreen } = useLayout()
+ useEffect(() => {
+ setFullscreen(true)
+ return () => setFullscreen(false)
+ }, [setFullscreen])
+
+ const STORAGE_KEY = 'maple-symbol'
+ const [tab, setTab] = useState('arcane')
+ const [characters, setCharacters] = useState(() => {
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY)
+ if (saved) return JSON.parse(saved).characters || []
+ } catch { /* ignore */ }
+ return []
+ })
+ const [selectedCharId, setSelectedCharId] = useState(() => {
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY)
+ if (saved) return JSON.parse(saved).selectedCharId ?? null
+ } catch { /* ignore */ }
+ return null
+ })
+ const [addName, setAddName] = useState('')
+ const [addError, setAddError] = useState('')
+
+ useEffect(() => {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ characters, selectedCharId }))
+ }, [characters, selectedCharId])
+ const symbols = SYMBOLS[tab]
+ const tabInfo = SYMBOL_TABS.find((t) => t.key === tab)
+
+ const searchMutation = useMutation({
+ mutationFn: (name) => api(`/api/character/search?name=${encodeURIComponent(name)}`),
+ onSuccess: (data) => {
+ setCharacters((prev) => {
+ if (prev.find((c) => c.character_name === data.character_name)) {
+ setAddError('이미 추가된 캐릭터입니다')
+ return prev
+ }
+ setAddError('')
+ setAddName('')
+ setSelectedCharId(data.ocid)
+ return [...prev, { ...data, id: data.ocid }]
+ })
+ },
+ onError: (err) => setAddError(err.message || '조회 실패'),
+ })
+
+ const handleSearch = (e) => {
+ e.preventDefault()
+ const n = addName.trim()
+ if (!n) return
+ setAddError('')
+ searchMutation.mutate(n)
+ }
+
+ // 임시: 첫 번째 심볼만 장착된 것으로 표시
+ const isEquipped = (i) => i === 0
+
+ return (
+
+ {/* 캐릭터 조회 */}
+
+
+ {addError &&
{addError}
}
+
+ {/* 캐릭터 목록 */}
+ {characters.length > 0 && (
+
+ {characters.map((c) => (
+ setSelectedCharId(c.id)}
+ onRemove={() => {
+ setCharacters((prev) => prev.filter((x) => x.id !== c.id))
+ if (selectedCharId === c.id) setSelectedCharId(null)
+ }}
+ />
+ ))}
+
+ )}
+
+
+ {/* 심볼 타입 탭 */}
+
+ {SYMBOL_TABS.map((t) => (
+
+ ))}
+
+
+ {/* 심볼 카드 그리드 */}
+
+ {symbols.map((s, i) => (
+
+ ))}
+
+
+ {/* 전체 요약 */}
+
+
+
{tabInfo?.label} 전체 만렙 완료 예상일
+
2026년 09월 12일 (토)
+
+
+
+
누적 체납 메소
+
108,000,000
+
+
+
+
누적 필요 메소
+
768,000,000
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/symbol/data.js b/frontend/src/features/symbol/data.js
new file mode 100644
index 0000000..d565f07
--- /dev/null
+++ b/frontend/src/features/symbol/data.js
@@ -0,0 +1,30 @@
+export const SYMBOL_TABS = [
+ { key: 'arcane', label: '아케인 심볼', imageName: '아케인심볼 : 소멸의 여로', maxLevel: 20 },
+ { key: 'authentic', label: '어센틱 심볼', imageName: '어센틱심볼 : 세르니움', maxLevel: 11 },
+ { key: 'grand', label: '그랜드 어센틱 심볼', imageName: '그랜드 어센틱심볼 : 탈라하트', maxLevel: 11 },
+]
+
+const BASE = 'https://s3.caadiq.co.kr/maplestory/symbol'
+
+export const SYMBOLS = {
+ arcane: [
+ { key: 'yeoro', name: '소멸의 여로', image: `${BASE}/아케인심볼(소멸의 여로).webp` },
+ { key: 'chuchu', name: '츄츄 아일랜드', image: `${BASE}/아케인심볼(츄츄 아일랜드).webp` },
+ { key: 'lachelein', name: '레헬른', image: `${BASE}/아케인심볼(레헬른).webp` },
+ { key: 'arcana', name: '아르카나', image: `${BASE}/아케인심볼(아르카나).webp` },
+ { key: 'morass', name: '모라스', image: `${BASE}/아케인심볼(모라스).webp` },
+ { key: 'esfera', name: '에스페라', image: `${BASE}/아케인심볼(에스페라).webp` },
+ ],
+ authentic: [
+ { key: 'cernium', name: '세르니움', image: `${BASE}/어센틱심볼(세르니움).webp` },
+ { key: 'arcs', name: '아르크스', image: `${BASE}/어센틱심볼(아르크스).webp` },
+ { key: 'odium', name: '오디움', image: `${BASE}/어센틱심볼(오디움).webp` },
+ { key: 'dowongyeong', name: '도원경', image: `${BASE}/어센틱심볼(도원경).webp` },
+ { key: 'arteria', name: '아르테리아', image: `${BASE}/어센틱심볼(아르테리아).webp` },
+ { key: 'carcion', name: '카르시온', image: `${BASE}/어센틱심볼(카르시온).webp` },
+ ],
+ grand: [
+ { key: 'talahart', name: '탈라하트', image: `${BASE}/그랜드 어센틱심볼(탈라하트).webp` },
+ { key: 'geardrock', name: '기어드락', image: `${BASE}/그랜드 어센틱심볼(기어드락).webp` },
+ ],
+}