FrontEnd Develop/Project : Team Nova MJ Search

MJS 식단 페이지 리팩토링 기획안

Frisbeen 2025. 10. 10. 16:01

 

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>
  );
}