FSD 아키텍처로 프로젝트 구조 리팩토링

주요 변경사항:
- FSD(Feature-Sliced Design) 아키텍처 도입
- shared 레이어 구조 생성 (ui, lib, api, model, types, config)
- 공통 UI 컴포넌트를 shared/ui로 이동 (Header, BottomNavigation, Loading, Toast)
- 라이브러리 코드를 shared/lib로 이동 (theme-provider, react-query-provider, theme)
- 모든 import 경로 업데이트하여 새로운 구조 반영
- 기존 components, lib 디렉토리 제거
- 빌드 검증 완료

향후 확장:
- widgets: 복잡한 UI 블록
- features: 기능 단위 코드
- entities: 비즈니스 엔티티

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
cherry2250
2025-10-27 09:45:41 +09:00
parent ea94dc97a1
commit edcd0cd559
13 changed files with 7 additions and 61 deletions
+24
View File
@@ -0,0 +1,24 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode, useState } from 'react';
export function ReactQueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
refetchOnWindowFocus: true,
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
+18
View File
@@ -0,0 +1,18 @@
'use client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter';
import { theme } from '@/shared/lib/theme';
import { ReactNode } from 'react';
export function MUIThemeProvider({ children }: { children: ReactNode }) {
return (
<AppRouterCacheProvider>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</AppRouterCacheProvider>
);
}
+253
View File
@@ -0,0 +1,253 @@
import { createTheme } from '@mui/material/styles';
// KT 브랜드 컬러 시스템
const colors = {
// Primary: KT Red
primary: {
main: '#E31E24',
light: '#FF4D52',
dark: '#C71820',
contrastText: '#FFFFFF',
},
// Secondary: AI Blue
secondary: {
main: '#0066FF',
light: '#4D94FF',
dark: '#004DBF',
contrastText: '#FFFFFF',
},
// Grayscale
gray: {
900: '#1A1A1A', // Black
700: '#4A4A4A',
500: '#9E9E9E',
300: '#D9D9D9',
100: '#F5F5F5',
50: '#FFFFFF', // White
},
// Semantic Colors
success: {
main: '#00C853',
},
warning: {
main: '#FFA000',
},
error: {
main: '#D32F2F',
},
info: {
main: '#0288D1',
},
};
// Breakpoints (Mobile First)
const breakpoints = {
values: {
xs: 320,
sm: 768,
md: 1024,
lg: 1280,
xl: 1920,
},
};
// Typography (Pretendard)
const typography = {
fontFamily: '"Pretendard", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", system-ui, sans-serif',
// Display
h1: {
fontSize: '28px',
fontWeight: 700,
lineHeight: 1.3,
letterSpacing: '-0.5px',
'@media (min-width:768px)': {
fontSize: '32px',
},
'@media (min-width:1024px)': {
fontSize: '36px',
},
},
// H1
h2: {
fontSize: '24px',
fontWeight: 700,
lineHeight: 1.3,
letterSpacing: '-0.3px',
'@media (min-width:768px)': {
fontSize: '28px',
},
'@media (min-width:1024px)': {
fontSize: '32px',
},
},
// H2
h3: {
fontSize: '20px',
fontWeight: 700,
lineHeight: 1.4,
letterSpacing: '-0.2px',
'@media (min-width:768px)': {
fontSize: '22px',
},
'@media (min-width:1024px)': {
fontSize: '24px',
},
},
// H3
h4: {
fontSize: '18px',
fontWeight: 600,
lineHeight: 1.4,
letterSpacing: '0px',
'@media (min-width:768px)': {
fontSize: '20px',
},
},
// Body Large
body1: {
fontSize: '16px',
fontWeight: 400,
lineHeight: 1.5,
letterSpacing: '0px',
'@media (min-width:768px)': {
fontSize: '18px',
},
},
// Body Medium
body2: {
fontSize: '14px',
fontWeight: 400,
lineHeight: 1.5,
letterSpacing: '0px',
'@media (min-width:768px)': {
fontSize: '16px',
},
},
// Body Small
caption: {
fontSize: '12px',
fontWeight: 400,
lineHeight: 1.5,
letterSpacing: '0px',
'@media (min-width:768px)': {
fontSize: '14px',
},
},
// Button
button: {
fontSize: '16px',
fontWeight: 600,
lineHeight: 1.5,
letterSpacing: '0px',
textTransform: 'none' as const,
},
};
// Spacing (4px Grid System)
const spacing = 4;
// Component Overrides
const components = {
MuiButton: {
styleOverrides: {
root: {
borderRadius: '8px',
padding: '12px 20px',
boxShadow: 'none',
'&:hover': {
boxShadow: 'none',
},
},
sizeLarge: {
height: '48px',
padding: '16px 24px',
},
sizeMedium: {
height: '44px',
padding: '12px 20px',
},
sizeSmall: {
height: '36px',
padding: '8px 16px',
},
contained: {
boxShadow: '0 2px 4px rgba(227, 30, 36, 0.2)',
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
border: '1px solid #E0E0E0',
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: '8px',
'& fieldset': {
borderColor: '#D9D9D9',
},
'&:hover fieldset': {
borderColor: '#E31E24',
},
'&.Mui-focused fieldset': {
borderColor: '#0066FF',
borderWidth: '2px',
},
},
},
},
},
MuiCheckbox: {
styleOverrides: {
root: {
'&.Mui-checked': {
color: '#E31E24',
},
},
},
},
MuiRadio: {
styleOverrides: {
root: {
'&.Mui-checked': {
color: '#E31E24',
},
},
},
},
};
// Create MUI Theme
export const theme = createTheme({
palette: {
primary: colors.primary,
secondary: colors.secondary,
success: colors.success,
warning: colors.warning,
error: colors.error,
info: colors.info,
grey: colors.gray,
background: {
default: '#FFFFFF',
paper: '#FFFFFF',
},
text: {
primary: colors.gray[900],
secondary: colors.gray[700],
disabled: colors.gray[500],
},
},
breakpoints,
typography,
spacing,
components,
shape: {
borderRadius: 8,
},
});
+115
View File
@@ -0,0 +1,115 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { BottomNavigation as MuiBottomNavigation, BottomNavigationAction, Paper } from '@mui/material';
import { Home, Celebration, Analytics, Person } from '@mui/icons-material';
export default function BottomNavigation() {
const pathname = usePathname();
const router = useRouter();
// Determine current value based on pathname
const getCurrentValue = () => {
if (pathname === '/' || pathname === '/dashboard') return 'home';
if (pathname.startsWith('/events')) return 'events';
if (pathname.startsWith('/analytics')) return 'analytics';
if (pathname.startsWith('/profile')) return 'profile';
return 'home';
};
const value = getCurrentValue();
const handleChange = (_event: React.SyntheticEvent, newValue: string) => {
switch (newValue) {
case 'home':
router.push('/');
break;
case 'events':
router.push('/events');
break;
case 'analytics':
router.push('/analytics');
break;
case 'profile':
router.push('/profile');
break;
}
};
return (
<Paper
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: (theme) => theme.zIndex.drawer + 1,
borderTop: '1px solid',
borderColor: 'divider',
}}
elevation={3}
>
<MuiBottomNavigation
value={value}
onChange={handleChange}
showLabels
sx={{
height: { xs: 56, sm: 64 },
'& .MuiBottomNavigationAction-root': {
minWidth: 'auto',
px: 1,
},
'& .MuiBottomNavigationAction-label': {
fontSize: { xs: '0.7rem', sm: '0.75rem' },
fontWeight: 600,
mt: 0.5,
},
'& .MuiBottomNavigationAction-root.Mui-selected': {
color: 'primary.main',
},
}}
>
<BottomNavigationAction
label="홈"
value="home"
icon={<Home />}
sx={{
'& .MuiSvgIcon-root': {
fontSize: { xs: 24, sm: 28 },
},
}}
/>
<BottomNavigationAction
label="이벤트"
value="events"
icon={<Celebration />}
sx={{
'& .MuiSvgIcon-root': {
fontSize: { xs: 24, sm: 28 },
},
}}
/>
<BottomNavigationAction
label="분석"
value="analytics"
icon={<Analytics />}
sx={{
'& .MuiSvgIcon-root': {
fontSize: { xs: 24, sm: 28 },
},
}}
/>
<BottomNavigationAction
label="프로필"
value="profile"
icon={<Person />}
sx={{
'& .MuiSvgIcon-root': {
fontSize: { xs: 24, sm: 28 },
},
}}
/>
</MuiBottomNavigation>
</Paper>
);
}
+114
View File
@@ -0,0 +1,114 @@
'use client';
import { useRouter } from 'next/navigation';
import { AppBar, Toolbar, IconButton, Typography, Box } from '@mui/material';
import { ArrowBack, Menu, AccountCircle } from '@mui/icons-material';
interface HeaderProps {
title?: string;
showBack?: boolean;
showMenu?: boolean;
showProfile?: boolean;
onBackClick?: () => void;
onMenuClick?: () => void;
onProfileClick?: () => void;
}
export default function Header({
title = '',
showBack = true,
showMenu = false,
showProfile = true,
onBackClick,
onMenuClick,
onProfileClick,
}: HeaderProps) {
const router = useRouter();
const handleBackClick = () => {
if (onBackClick) {
onBackClick();
} else {
router.back();
}
};
const handleProfileClick = () => {
if (onProfileClick) {
onProfileClick();
} else {
router.push('/profile');
}
};
return (
<AppBar
position="fixed"
elevation={0}
sx={{
bgcolor: 'background.paper',
borderBottom: '1px solid',
borderColor: 'divider',
zIndex: (theme) => theme.zIndex.drawer + 1,
}}
>
<Toolbar
sx={{
minHeight: { xs: 56, sm: 64 },
px: { xs: 2, sm: 3 },
}}
>
{/* Left Section */}
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
{showMenu && (
<IconButton
edge="start"
color="inherit"
aria-label="메뉴"
onClick={onMenuClick}
sx={{ mr: 1, color: 'text.primary' }}
>
<Menu />
</IconButton>
)}
{showBack && (
<IconButton
edge="start"
color="inherit"
aria-label="뒤로가기"
onClick={handleBackClick}
sx={{ mr: 1, color: 'text.primary' }}
>
<ArrowBack />
</IconButton>
)}
{title && (
<Typography
variant="h6"
sx={{
fontWeight: 700,
color: 'text.primary',
fontSize: { xs: '1.1rem', sm: '1.25rem' },
}}
>
{title}
</Typography>
)}
</Box>
{/* Right Section */}
{showProfile && (
<IconButton
edge="end"
color="inherit"
aria-label="프로필"
onClick={handleProfileClick}
sx={{ color: 'text.primary' }}
>
<AccountCircle />
</IconButton>
)}
</Toolbar>
</AppBar>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { Box, CircularProgress, Typography } from '@mui/material';
interface LoadingProps {
message?: string;
size?: number;
}
export function Loading({ message = '로딩 중...', size = 40 }: LoadingProps) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '200px',
gap: 2,
}}
>
<CircularProgress size={size} />
{message && (
<Typography variant="body2" color="text.secondary">
{message}
</Typography>
)}
</Box>
);
}
+27
View File
@@ -0,0 +1,27 @@
'use client';
import { Snackbar, Alert } from '@mui/material';
import { useUIStore } from '@/stores/uiStore';
export function Toast() {
const { toast, hideToast } = useUIStore();
return (
<Snackbar
open={toast.open}
autoHideDuration={3000}
onClose={hideToast}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={{ bottom: { xs: 80, sm: 24 } }}
>
<Alert
onClose={hideToast}
severity={toast.severity}
variant="filled"
sx={{ width: '100%' }}
>
{toast.message}
</Alert>
</Snackbar>
);
}