1. 기획 배경 — “하나의 페이지에 모든 걸 넣은 게 문제였다”
처음 식단 페이지를 만들 때는 MVP 수준으로 단일 컴포넌트에
모든 로직(데이터 fetch, 상태, 날짜 이동, UI)을 넣어 빠르게 완성했어요.
하지만 그 구조는 다음과 같은 문제를 드러냈습니다.
- 모바일에서도 데스크톱용 주간 테이블이 그대로 표시되어 가독성이 나쁨
- 브라우저 크기에 따라 한 컴포넌트 안에서 조건문이 난무함
- React Query / 상태 / 날짜 계산 로직이 뒤섞여 유지보수가 어려움
- 간단한 변경도 “하루 걸리는 구조”가 됨
이 시점에서 UI 관심사와 데이터 로직을 분리하고, 기기별 UX를 맞추자는 기획이 시작됐습니다.
2. 리팩터링 방향성 — “역할 단위로 쪼갠다”
우리는 React의 단일 책임 원칙(Single Responsibility Principle) 을 기준으로 세 방향을 잡았습니다.
UI 분리 | 모바일/데스크톱 UI 독립 | DailyMenuView, WeeklyMenuView 생성 |
데이터/로직 분리 | 데이터 fetch와 가공을 훅으로 이동 | useMenuData 커스텀 훅 도입 |
공통 유틸 정리 | 주차, 기간 계산 함수 재활용 가능하게 | menuStartOfWeek 등 prefix 유틸로 export |
결과적으로 UI는 UI만, 데이터는 데이터만, 유틸은 유틸만 다루게 되었고
각 컴포넌트는 ‘작지만 명확한 역할’을 가지게 되었습니다.
3. 폴더 구조 설계 — “읽히는 구조로 바꾸기”
src/
├── api/
│ └── menu.ts // 식단 데이터 API
├── hooks/
│ └── menu/
│ └── useMenuData.ts // 커스텀 훅: 데이터 + 유틸 + 키 관리
├── components/
│ └── atoms/
│ └── Meal/
│ ├── Mobile/
│ │ └── DailyMenuView.tsx // 모바일 전용 UI
│ └── Web/
│ └── WeeklyMenuView.tsx // 데스크톱 전용 UI
└── pages/
└── menu/
└── index.tsx // 뷰 전환/상태 관리/렌더 분기
기존 한 파일(MenuPage.tsx)에 있었던 모든 로직 -> 역할별로 분기.
UI, 데이터, 유틸, 상태 단위로 분리하면서 코드 가독성이 크게 향상되었으며, 동료 개발자 간의 협업 능률도 올릴 수 있었습니다.
4. UI 리디자인 — “기기별 UX를 고려한 반응형 설계”
📱
모바일 (DailyMenuView.tsx)
- 오늘 기준 1일 단위 식단만 표시
- 좌우 화살표로 onPrev, onNext 날짜 이동
- 아침/점심/저녁 섹션을 명확히 구분
- 메뉴가 없는 경우 "메뉴 없음"을 표시하여 UX 혼란 방지
import type { MenuItem } from '@/api/menu';
import MenuDayButton from '@/components/molecules/MenuDayButton';
import MenuItemButton from '@/components/molecules/MenuItemButton';
type DailyMenuViewProps = {
dateKey: string;
items: MenuItem[];
nowCategory?: 'BREAKFAST' | 'LUNCH' | 'DINNER';
onPrev: () => void;
onNext: () => void;
};
// 모바일 전용(≤md) — 하루 3끼 + 이전/다음 이동
export function DailyMenuView({ dateKey, items, nowCategory, onPrev, onNext }: DailyMenuViewProps) {
const weekday = dateKey.match(/\((.+)\)/)?.[1] ?? '';
const row = ['BREAKFAST', 'LUNCH', 'DINNER'].map((k) => items.find((i) => i.menuCategory === k));
return (
<section className='md:hidden rounded-lg border-2 border-grey-05 p-4 space-y-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<button onClick={onPrev} aria-label='이전 날짜' className='px-2 text-sm'>
‹
</button>
<MenuDayButton label={`${weekday} 요일`} focused />
<button onClick={onNext} aria-label='다음 날짜' className='px-2 text-sm'>
›
</button>
</div>
<span className='text-xs text-gray-500'>{dateKey}</span>
</div>
<div className='grid gap-2 text-center'>
{row.map((item, idx) =>
item ? (
<MenuItemButton
key={item.menuCategory}
time={{ BREAKFAST: '아침', LUNCH: '점심', DINNER: '저녁' }[item.menuCategory]}
menus={item.meals}
focused={item.menuCategory === nowCategory}
/>
) : (
<div
key={idx}
className='min-h-14 rounded-xl border border-dashed text-xs text-grey-40 flex items-center justify-center'
>
메뉴 없음
</div>
),
)}
</div>
</section>
);
}
💻
데스크톱 (WeeklyMenuView.tsx)
- 주간 단위로 모든 식단을 한눈에
- sticky 요일 컬럼 + 3끼(아침/점심/저녁) 헤더
- 오늘 날짜에 강조 효과(focused)
- 상단에 주차 및 기간(10.07 ~ 10.13) 자동 표시
import React from 'react';
import type { MenuItem } from '@/api/menu';
import MenuDayButton from '@/components/molecules/MenuDayButton';
import MenuItemButton from '@/components/molecules/MenuItemButton';
import { useMenuData } from '@/hooks/menu/useMenuData';
const timeLabelMap = {
BREAKFAST: '아침',
LUNCH: '점심',
DINNER: '저녁',
} as const;
const ORDER: Array<keyof typeof timeLabelMap> = ['BREAKFAST', 'LUNCH', 'DINNER'];
type WeeklyMenuViewProps = {
groupedByDate: Array<[string, MenuItem[]]>;
todayKey: string;
nowCategory?: 'BREAKFAST' | 'LUNCH' | 'DINNER';
};
// 데스크톱 전용(≥md) — 주간 테이블
export default function WeeklyMenuView({
groupedByDate,
todayKey,
nowCategory,
}: WeeklyMenuViewProps) {
const todayDate = todayKey;
const { startOfWeek, endOfWeek, getWeekOfMonth, fmtMD } = useMenuData();
const now = new Date();
const weekStartsOn = 1;
const weekStart = startOfWeek(now, weekStartsOn);
const weekEnd = endOfWeek(now, weekStartsOn);
const weekOfMonth = getWeekOfMonth(now, weekStartsOn);
const monthLabel = now.getMonth() + 1;
const weekRangeLabel = `${fmtMD(weekStart)} ~ ${fmtMD(weekEnd)}`;
const WeekHeader = () => (
<div className='mb-3 md:mb-4 flex items-baseline gap-2 justify-center md:justify-start'>
<h3 className='text-base md:text-xl font-semibold text-mju-primary'>
{monthLabel}월 {weekOfMonth}주차
</h3>
<span className='text-xs md:text-sm text-gray-500'>{weekRangeLabel}</span>
</div>
);
const DesktopTable = () => (
<div className='hidden md:grid grid-cols-[88px_repeat(3,minmax(0,1fr))] gap-3'>
<div className='sticky left-0 z-10 bg-white/80 backdrop-blur-md font-semibold text-sm px-2 py-1 rounded'>
요일
</div>
{ORDER.map((k) => (
<div key={k} className='font-semibold text-sm px-2 py-1'>
{timeLabelMap[k]}
</div>
))}
{groupedByDate.map(([date, dayItems]) => {
const isToday = date === todayDate;
const weekdayLabel = `${date.split('(')[1]?.split(')')[0]?.trim() ?? ''}요일`;
const fullRow = ORDER.map((k) => dayItems.find((d) => d.menuCategory === k));
return (
<React.Fragment key={date}>
<div
className='sticky left-0 z-10 px-0 py-0'
aria-current={isToday ? 'date' : undefined}
>
<MenuDayButton label={weekdayLabel} focused={isToday} />
</div>
{fullRow.map((item, idx) =>
item ? (
<MenuItemButton
key={item.menuCategory}
time={timeLabelMap[item.menuCategory as keyof typeof timeLabelMap]}
menus={item.meals}
focused={isToday && item.menuCategory === nowCategory}
/>
) : (
<div
key={`empty-${date}-${idx}`}
className='min-h-16 rounded-xl border border-dashed text-xs text-gray-400 flex items-center justify-center'
>
등록된 식단 내용이 없습니다.
</div>
),
)}
</React.Fragment>
);
})}
</div>
);
return (
<div className='w-full'>
<WeekHeader />
<DesktopTable />
</div>
);
}
5. 커스텀 훅 도입
핵심은 데이터 fetch, 날짜 그룹화, 오늘 키 계산, 유틸 함수를 모두 훅으로 정리했습니다.
커스텀 훅 분리 효과
- 컴포넌트에서는 “UI 렌더링만” 신경쓰면 됨
- 데이터 구조나 날짜 계산 변경 시, 훅만 수정하면 전체에 반영
- Re-render 최소화 (React Query 캐시 덕분에 네트워크 절약)
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getMenus, type MenuItem } from '@/api/menu';
const ORDER: Array<MenuItem['menuCategory']> = ['BREAKFAST', 'LUNCH', 'DINNER'];
export function useMenuData() {
const {
data = [],
isLoading,
error,
refetch,
} = useQuery<MenuItem[]>({
queryKey: ['menus'],
queryFn: getMenus,
staleTime: 5 * 60 * 1000,
});
const stripDow = (s: string) => s.replace(/\s*\([^)]*\)\s*/g, '').trim(); // '(월)' 같은 요일 제거
// 날짜별 그룹 + 끼니 순 정렬 + 날짜 정렬
const groupedByDate = useMemo(() => {
const map = new Map<string, MenuItem[]>();
for (const m of data) {
if (!map.has(m.date)) map.set(m.date, []);
map.get(m.date)!.push(m);
}
for (const [, arr] of map) {
arr.sort((a, b) => ORDER.indexOf(a.menuCategory) - ORDER.indexOf(b.menuCategory));
}
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
}, [data]);
// 키 모음 / 오늘 키 / 헬퍼
const keys = groupedByDate.map(([k]) => k);
const todayKey = useMemo(() => {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const canonical = `${mm}.${dd}`; // 요일 없는 '정규 키'
// 실제 keys 배열에서 요일 유무와 무관하게 매칭되는 '실제 키'를 찾음
const found = keys.find((k) => stripDow(k) === canonical);
// 찾으면 그걸 todayKey로, 없으면 첫 번째 키
return found ?? keys[0] ?? '';
}, [keys]);
const getByDate = (key: string) => groupedByDate.find(([d]) => d === key)?.[1] ?? [];
// ---------- shared date/week utilities (exported) ----------
function startOfWeek(d: Date, weekStartsOn = 1) {
const date = new Date(d);
const day = date.getDay();
const diff = (day - weekStartsOn + 7) % 7;
date.setDate(date.getDate() - diff);
date.setHours(0, 0, 0, 0);
return date;
}
function endOfWeek(d: Date, weekStartsOn = 1) {
const start = startOfWeek(d, weekStartsOn);
const end = new Date(start);
end.setDate(start.getDate() + 6);
end.setHours(23, 59, 59, 999);
return end;
}
function getWeekOfMonth(d: Date, weekStartsOn = 1) {
const date = new Date(d);
const month = date.getMonth();
const firstOfMonth = new Date(date.getFullYear(), month, 1);
const firstWeekStart = startOfWeek(firstOfMonth, weekStartsOn);
const thisWeekStart = startOfWeek(date, weekStartsOn);
const diffDays = Math.floor((thisWeekStart.getTime() - firstWeekStart.getTime()) / 86400000);
return Math.floor(diffDays / 7) + 1;
}
function fmtMD(date: Date) {
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${m}.${d}`;
}
return {
isLoading,
error,
refetch,
groupedByDate,
keys,
todayKey,
getByDate,
startOfWeek,
endOfWeek,
fmtMD,
getWeekOfMonth,
};
}
6. 페이지 로직 — idx 상태 관리
브라우저 크기에 따라 두 뷰가 동일한 데이터 소스를 공유합니다.
idx는 현재 선택된 날짜의 인덱스를 관리하고,
좌우 이동 시 상태만 변경되어 렌더링이 갱신됩니다.
7. 결과 및 개선 효과
UI 구조 | 하나의 거대 컴포넌트 | 모바일/웹 분리 컴포넌트 |
데이터 처리 | useEffect + fetch | React Query + 캐시 |
유틸 관리 | 각 파일 중복 정의 | 커스텀 훅에서 중앙 관리 |
코드 길이 | 500+ lines | 3개 파일로 역할별 분리 |
UX | 좁은 화면에서 깨짐 | 기기별 맞춤 UX 완성 |
이 리팩터링은 **“모바일 UX 개선”**이라는 작은 이슈에서 시작했지만,
결과적으로 MJS 전체 프론트 구조를 개선하는 시발점이 되었습니다.
- UI, 데이터, 유틸을 완전히 분리
- 유지보수가 쉬운 구조로 전환
- 코드 일관성과 확장성 확보
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useMenuData } from '@/hooks/menu/useMenuData';
import { DailyMenuView } from '@/components/atoms/Meal/Mobile/DailyMenuView';
import WeeklyMenuView from '@/components/atoms/Meal/Web/WeeklyMenuView';
import { Typography } from '@/components/atoms/Typography';
import LoadingIndicator from '@/components/atoms/LoadingIndicator';
export default function MenuPage() {
const { isLoading, error, groupedByDate, keys, todayKey, getByDate } = useMenuData();
// 모바일: 기본 오늘, 스와이프 시 인덱스 이동
const [idx, setIdx] = useState<number | null>(null);
// 최초 1회만 todayKey로 맞추고, keys 길이 변하면 범위만 보정
useEffect(() => {
if (!keys.length) return;
setIdx((prev) => {
if (prev === null) {
const i = keys.indexOf(todayKey);
return i >= 0 ? i : 0;
}
const max = Math.max(0, keys.length - 1);
return Math.min(Math.max(prev, 0), max);
});
}, [keys, todayKey]);
const dateKey = keys[idx] ?? todayKey;
// 현재 끼니
type Meal = 'BREAKFAST' | 'LUNCH' | 'DINNER';
const now = new Date();
const h = now.getHours() + now.getMinutes() / 60;
const nowCategory: Meal | undefined =
h >= 6 && h < 9
? 'BREAKFAST'
: h >= 11 && h < 14
? 'LUNCH'
: h >= 16 && h < 18.5
? 'DINNER'
: undefined;
const atStart = idx <= 0;
const atEnd = idx >= keys.length - 1;
const onPrev = () => {
if (atStart) return;
setIdx((i) => (i == null ? 0 : Math.max(0, i - 1)));
};
const onNext = () => {
if (atEnd) return;
setIdx((i) => (i == null ? 0 : Math.min(keys.length - 1, i + 1)));
};
if (isLoading) return <LoadingIndicator />;
if (error) return <div className='p-4 text-red-500'>식단 로딩 실패</div>;
return (
<div className='w-full md:w-[1280px] flex-1 flex flex-col p-4 md:p-12 gap-2 mx-auto'>
<Link to='/menu'>
<Typography variant='heading02' className='md:hidden block text-mju-primary'>
학식
</Typography>
<Typography variant='heading01' className='hidden md:block text-mju-primary'>
학식
</Typography>
</Link>
<hr className='w-[381px] border-t-2 border-blue-10 rounded-xl md:hidden' />
<div className='flex flex-row justify-center mt-4 '>
<Typography
variant='heading02'
className='text-mju-primary text-center hidden md:flex flex-row justify-center '
>
{new Date().toLocaleDateString('ko-KR', {
month: 'long',
day: 'numeric',
weekday: 'long',
})}
</Typography>
</div>
{/* 모바일: 오늘(기본) + 좌우 이동 */}
<DailyMenuView
key={dateKey}
dateKey={dateKey}
items={getByDate(dateKey) ?? []}
nowCategory={nowCategory}
onPrev={onPrev}
onNext={onNext}
/>
{/* 데스크톱: 주간 테이블 */}
<WeeklyMenuView groupedByDate={groupedByDate} todayKey={todayKey} nowCategory={nowCategory} />
</div>
);
}
'FrontEnd Develop > Project : Team Nova MJ Search' 카테고리의 다른 글
MJS : 학사일정 컴포넌트 및 자유게시판 HOT 게시물 컴포넌트 연동 문서 (0) | 2025.08.19 |
---|---|
MJS 광고 캐러셀 & 실시간 검색 순위 집계 Component 설계 가이드 (6) | 2025.08.18 |
MJS 명대뉴스 반응형 구현 (3) | 2025.08.16 |
MJS 반응형 리빌드 & 방송국 페이지 성능 튜닝 (4) | 2025.08.14 |
React Router Link 클릭 시 기본 이동 동작 제어하기 (0) | 2025.08.12 |