diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..40d24f4 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,61 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + - master + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Build + run: pnpm run build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './dist' + + deploy: + runs-on: ubuntu-latest + needs: build + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 79e1e76..50b4d61 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ +packages: + - '.' + onlyBuiltDependencies: - '@swc/core' - msw diff --git a/src/ScheduleContext.tsx b/src/ScheduleContext.tsx index 529f0dd..3b819ae 100644 --- a/src/ScheduleContext.tsx +++ b/src/ScheduleContext.tsx @@ -1,10 +1,13 @@ -import React, { createContext, PropsWithChildren, useContext, useState } from "react"; +import React, { createContext, PropsWithChildren, useCallback, useContext, useMemo, useRef, useState } from "react"; import { Schedule } from "./types.ts"; import dummyScheduleMap from "./dummyScheduleMap.ts"; interface ScheduleContextType { + tableIds: string[]; schedulesMap: Record; setSchedulesMap: React.Dispatch>>; + updateTableSchedules: (tableId: string, updater: (schedules: Schedule[]) => Schedule[]) => void; + getSchedulesMapSize: () => number; } const ScheduleContext = createContext(undefined); @@ -19,9 +22,60 @@ export const useScheduleContext = () => { export const ScheduleProvider = ({ children }: PropsWithChildren) => { const [schedulesMap, setSchedulesMap] = useState>(dummyScheduleMap); + + // schedulesMap의 최신 값을 ref로 유지하여 함수 참조를 안정적으로 유지 + const schedulesMapRef = useRef(schedulesMap); + schedulesMapRef.current = schedulesMap; + + // tableIds만 별도 state로 관리하여 변경 감지 + const [tableIds, setTableIds] = useState(() => Object.keys(dummyScheduleMap)); + + // 특정 테이블만 업데이트하는 함수 - 메모이제이션으로 불필요한 리렌더링 방지 + const updateTableSchedules = useCallback((tableId: string, updater: (schedules: Schedule[]) => Schedule[]) => { + setSchedulesMap(prev => { + const currentSchedules = prev[tableId]; + if (!currentSchedules) return prev; + + const newSchedules = updater(currentSchedules); + + // 변경사항이 없으면 이전 객체 반환 (리렌더링 방지) + if (currentSchedules === newSchedules) return prev; + + return { + ...prev, + [tableId]: newSchedules + }; + }); + }, []); + + // setSchedulesMap을 래핑하여 tableIds도 업데이트 + const wrappedSetSchedulesMap = useCallback>>>((action) => { + setSchedulesMap(prev => { + const newMap = typeof action === 'function' ? action(prev) : action; + setTableIds(Object.keys(newMap)); + return newMap; + }); + }, []); + + // schedulesMap의 크기를 가져오는 함수 (disabledRemoveButton 등에서 사용) + const getSchedulesMapSize = useCallback(() => { + return tableIds.length; + }, [tableIds]); + + // Context value 메모이제이션 + // schedulesMap을 포함하지만, updateTableSchedules가 특정 테이블만 업데이트하므로 + // 각 테이블 컴포넌트는 React.memo로 메모이제이션되어 있어서 + // schedules prop이 변경되지 않으면 리렌더링되지 않음 + const value = useMemo(() => ({ + tableIds, + schedulesMap, + setSchedulesMap: wrappedSetSchedulesMap, + updateTableSchedules, + getSchedulesMapSize, + }), [tableIds, schedulesMap, wrappedSetSchedulesMap, updateTableSchedules, getSchedulesMapSize]); return ( - + {children} ); diff --git a/src/ScheduleDndProvider.tsx b/src/ScheduleDndProvider.tsx index ca15f52..d308871 100644 --- a/src/ScheduleDndProvider.tsx +++ b/src/ScheduleDndProvider.tsx @@ -29,11 +29,11 @@ function createSnapModifier(): Modifier { const modifiers = [createSnapModifier()] export default function ScheduleDndProvider({ children }: PropsWithChildren) { - const { schedulesMap, setSchedulesMap } = useScheduleContext(); + const { schedulesMap, updateTableSchedules } = useScheduleContext(); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { - distance: 8, + distance: 0, }, }) ); @@ -43,24 +43,25 @@ export default function ScheduleDndProvider({ children }: PropsWithChildren) { const { active, delta } = event; const { x, y } = delta; const [tableId, index] = active.id.split(':'); - const schedule = schedulesMap[tableId][index]; + const schedules = schedulesMap[tableId] || []; + const schedule = schedules[Number(index)]; const nowDayIndex = DAY_LABELS.indexOf(schedule.day as typeof DAY_LABELS[number]) const moveDayIndex = Math.floor(x / 80); const moveTimeIndex = Math.floor(y / 30); - setSchedulesMap({ - ...schedulesMap, - [tableId]: schedulesMap[tableId].map((targetSchedule, targetIndex) => { + // 변경된 테이블만 업데이트 + updateTableSchedules(tableId, (currentSchedules) => { + return currentSchedules.map((targetSchedule, targetIndex) => { if (targetIndex !== Number(index)) { - return { ...targetSchedule } + return targetSchedule; // 변경되지 않은 항목은 그대로 반환 } return { ...targetSchedule, day: DAY_LABELS[nowDayIndex + moveDayIndex], range: targetSchedule.range.map(time => time + moveTimeIndex), } - }) - }) + }); + }); }; return ( diff --git a/src/ScheduleTable.tsx b/src/ScheduleTable.tsx index ea17b6a..2e78c72 100644 --- a/src/ScheduleTable.tsx +++ b/src/ScheduleTable.tsx @@ -17,7 +17,7 @@ import { Schedule } from "./types.ts"; import { fill2, parseHnM } from "./utils.ts"; import { useDndContext, useDraggable } from "@dnd-kit/core"; import { CSS } from "@dnd-kit/utilities"; -import { ComponentProps, Fragment } from "react"; +import { ComponentProps, Fragment, memo, useMemo, useCallback } from "react"; interface Props { tableId: string; @@ -38,25 +38,31 @@ const TIMES = [ .map((v) => `${parseHnM(v)}~${parseHnM(v + 50 * 분)}`), ] as const; -const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => { - - const getColor = (lectureId: string): string => { +const ScheduleTable = memo(({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => { + // 색상 매핑 메모이제이션 + const colorMap = useMemo(() => { const lectures = [...new Set(schedules.map(({ lecture }) => lecture.id))]; const colors = ["#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd"]; - return colors[lectures.indexOf(lectureId) % colors.length]; - }; + const map = new Map(); + lectures.forEach((lectureId, index) => { + map.set(lectureId, colors[index % colors.length]); + }); + return map; + }, [schedules]); + + const getColor = useCallback((lectureId: string): string => { + return colorMap.get(lectureId) || "#fdd"; + }, [colorMap]); const dndContext = useDndContext(); - const getActiveTableId = () => { + const activeTableId = useMemo(() => { const activeId = dndContext.active?.id; if (activeId) { return String(activeId).split(":")[0]; } return null; - } - - const activeTableId = getActiveTableId(); + }, [dndContext.active?.id]); return ( ( ); -}; +}); -const DraggableSchedule = ({ +ScheduleTable.displayName = 'ScheduleTable'; + +const DraggableSchedule = memo(({ id, data, bg, @@ -137,25 +145,47 @@ const DraggableSchedule = ({ }) => { const { day, range, room, lecture } = data; const { attributes, setNodeRef, listeners, transform } = useDraggable({ id }); - const leftIndex = DAY_LABELS.indexOf(day as typeof DAY_LABELS[number]); + + const leftIndex = useMemo(() => + DAY_LABELS.indexOf(day as typeof DAY_LABELS[number]), + [day] + ); const topIndex = range[0] - 1; const size = range.length; + // 위치 계산 메모이제이션 - 드래그 중 불필요한 재계산 방지 + const left = useMemo(() => `${120 + (CellSize.WIDTH * leftIndex) + 1}px`, [leftIndex]); + const top = useMemo(() => `${40 + (topIndex * CellSize.HEIGHT + 1)}px`, [topIndex]); + const width = useMemo(() => `${CellSize.WIDTH - 1}px`, []); + const height = useMemo(() => `${CellSize.HEIGHT * size - 1}px`, [size]); + + // transform 계산 최적화 - 드래그 중 매번 계산하는 것을 방지 + const transformValue = useMemo(() => { + if (!transform) return undefined; + return CSS.Translate.toString(transform); + }, [transform]); + return ( - + @@ -175,6 +205,8 @@ const DraggableSchedule = ({ ); -} +}); + +DraggableSchedule.displayName = 'DraggableSchedule'; export default ScheduleTable; diff --git a/src/ScheduleTables.tsx b/src/ScheduleTables.tsx index 44dbd7a..6cd2808 100644 --- a/src/ScheduleTables.tsx +++ b/src/ScheduleTables.tsx @@ -2,36 +2,50 @@ import { Button, ButtonGroup, Flex, Heading, Stack } from "@chakra-ui/react"; import ScheduleTable from "./ScheduleTable.tsx"; import { useScheduleContext } from "./ScheduleContext.tsx"; import SearchDialog from "./SearchDialog.tsx"; -import { useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { Schedule } from "./types.ts"; export const ScheduleTables = () => { - const { schedulesMap, setSchedulesMap } = useScheduleContext(); + const { tableIds, schedulesMap, setSchedulesMap, updateTableSchedules, getSchedulesMapSize } = useScheduleContext(); const [searchInfo, setSearchInfo] = useState<{ tableId: string; day?: string; time?: number; } | null>(null); - const disabledRemoveButton = Object.keys(schedulesMap).length === 1; + const schedulesMapSize = getSchedulesMapSize(); + const disabledRemoveButton = useMemo(() => schedulesMapSize === 1, [schedulesMapSize]); - const duplicate = (targetId: string) => { + const duplicate = useCallback((targetId: string) => { + const schedules = schedulesMap[targetId] || []; setSchedulesMap(prev => ({ ...prev, - [`schedule-${Date.now()}`]: [...prev[targetId]] - })) - }; + [`schedule-${Date.now()}`]: [...schedules] + })); + }, [setSchedulesMap, schedulesMap]); - const remove = (targetId: string) => { + const remove = useCallback((targetId: string) => { setSchedulesMap(prev => { - delete prev[targetId]; - return { ...prev }; - }) - }; + const newMap = { ...prev }; + delete newMap[targetId]; + return newMap; + }); + }, [setSchedulesMap]); + + // 각 테이블의 schedules를 가져오는 메모이제이션된 entries + // updateTableSchedules가 특정 테이블만 업데이트하므로, 변경되지 않은 테이블의 schedules 배열 참조는 유지됨 + const scheduleEntries = useMemo(() => { + return tableIds.map(tableId => [tableId, schedulesMap[tableId] || []] as [string, Schedule[]]); + }, [tableIds, schedulesMap]); + + const handleCloseSearchDialog = useCallback(() => { + setSearchInfo(null); + }, []); return ( <> - {Object.entries(schedulesMap).map(([tableId, schedules], index) => ( + {scheduleEntries.map(([tableId, schedules], index) => ( 시간표 {index + 1} @@ -43,19 +57,17 @@ export const ScheduleTables = () => { setSearchInfo({ tableId, ...timeInfo })} - onDeleteButtonClick={({ day, time }) => setSchedulesMap((prev) => ({ - ...prev, - [tableId]: prev[tableId].filter(schedule => schedule.day !== day || !schedule.range.includes(time)) - }))} + onDeleteButtonClick={({ day, time }) => updateTableSchedules(tableId, (currentSchedules) => + currentSchedules.filter(schedule => schedule.day !== day || !schedule.range.includes(time)) + )} /> ))} - setSearchInfo(null)}/> + ); } diff --git a/src/SearchDialog.tsx b/src/SearchDialog.tsx index 593951f..18e3357 100644 --- a/src/SearchDialog.tsx +++ b/src/SearchDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Button, @@ -32,7 +32,7 @@ import { import { useScheduleContext } from "./ScheduleContext.tsx"; import { Lecture } from "./types.ts"; import { parseSchedule } from "./utils.ts"; -import axios from "axios"; +import axios, { AxiosResponse } from "axios"; import { DAY_LABELS } from "./constants.ts"; interface Props { @@ -82,22 +82,84 @@ const TIME_SLOTS = [ const PAGE_SIZE = 100; -const fetchMajors = () => axios.get('/schedules-majors.json'); -const fetchLiberalArts = () => axios.get('/schedules-liberal-arts.json'); +// 전공 Checkbox 컴포넌트 - React.memo로 최적화 +interface MajorCheckboxProps { + major: string; +} + +const MajorCheckbox = memo(({ major }: MajorCheckboxProps) => { + return ( + + + {major.replace(/

/gi, ' ')} + + + ); +}); + +MajorCheckbox.displayName = 'MajorCheckbox'; + +// 강의 행 컴포넌트 - React.memo로 최적화 +interface LectureRowProps { + lecture: Lecture; + onAddSchedule: (lecture: Lecture) => void; +} -// TODO: 이 코드를 개선해서 API 호출을 최소화 해보세요 + Promise.all이 현재 잘못 사용되고 있습니다. 같이 개선해주세요. -const fetchAllLectures = async () => await Promise.all([ - (console.log('API Call 1', performance.now()), await fetchMajors()), - (console.log('API Call 2', performance.now()), await fetchLiberalArts()), - (console.log('API Call 3', performance.now()), await fetchMajors()), - (console.log('API Call 4', performance.now()), await fetchLiberalArts()), - (console.log('API Call 5', performance.now()), await fetchMajors()), - (console.log('API Call 6', performance.now()), await fetchLiberalArts()), -]); +const LectureRow = memo(({ lecture, onAddSchedule }: LectureRowProps) => { + return ( + + {lecture.id} + {lecture.grade} + {lecture.title} + {lecture.credits} + + + + + + + ); +}); + +LectureRow.displayName = 'LectureRow'; + +// 클로저를 이용한 캐시 구현 +const createCachedFetch = () => { + const cache = new Map>>(); + + return (url: string) => { + if (!cache.has(url)) { + cache.set(url, axios.get(url)); + } + return cache.get(url)!; + }; +}; + +const cachedFetch = createCachedFetch(); + +// base URL을 포함한 경로 생성 (프로덕션 환경에서 base path 지원) +const getJsonPath = (path: string) => { + // 현재 문서의 base URI를 사용하여 상대 경로를 절대 경로로 변환 + return new URL(path, document.baseURI).pathname; +}; + +const fetchMajors = () => cachedFetch(getJsonPath('schedules-majors.json')); +const fetchLiberalArts = () => cachedFetch(getJsonPath('schedules-liberal-arts.json')); + +// Promise.all에서 await 제거하여 병렬 실행, 캐시로 인해 중복 호출 방지 +const fetchAllLectures = async () => { + return Promise.all([ + (console.log('API Call 1', performance.now()), fetchMajors()), + (console.log('API Call 2', performance.now()), fetchLiberalArts()), + (console.log('API Call 3', performance.now()), fetchMajors()), + (console.log('API Call 4', performance.now()), fetchLiberalArts()), + (console.log('API Call 5', performance.now()), fetchMajors()), + (console.log('API Call 6', performance.now()), fetchLiberalArts()), + ]); +}; -// TODO: 이 컴포넌트에서 불필요한 연산이 발생하지 않도록 다양한 방식으로 시도해주세요. const SearchDialog = ({ searchInfo, onClose }: Props) => { - const { setSchedulesMap } = useScheduleContext(); + const { updateTableSchedules } = useScheduleContext(); const loaderWrapperRef = useRef(null); const loaderRef = useRef(null); @@ -111,7 +173,8 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { majors: [], }); - const getFilteredLectures = () => { + // searchOptions나 lectures가 변경될 때만 필터링 수행 + const filteredLectures = useMemo(() => { const { query = '', credits, grades, days, times, majors } = searchOptions; return lectures .filter(lecture => @@ -135,20 +198,23 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; return schedules.some(s => s.range.some(time => times.includes(time))); }); - } + }, [searchOptions, lectures]); - const filteredLectures = getFilteredLectures(); - const lastPage = Math.ceil(filteredLectures.length / PAGE_SIZE); - const visibleLectures = filteredLectures.slice(0, page * PAGE_SIZE); - const allMajors = [...new Set(lectures.map(lecture => lecture.major))]; + const lastPage = useMemo(() => Math.ceil(filteredLectures.length / PAGE_SIZE), [filteredLectures]); + + // page가 변경될 때만 visibleLectures 재계산 + const visibleLectures = useMemo(() => filteredLectures.slice(0, page * PAGE_SIZE), [filteredLectures, page]); + + // lectures가 변경될 때만 allMajors 재계산 + const allMajors = useMemo(() => [...new Set(lectures.map(lecture => lecture.major))], [lectures]); - const changeSearchOption = (field: keyof SearchOption, value: SearchOption[typeof field]) => { + const changeSearchOption = useCallback((field: keyof SearchOption, value: SearchOption[typeof field]) => { setPage(1); - setSearchOptions(({ ...searchOptions, [field]: value })); + setSearchOptions((prev) => ({ ...prev, [field]: value })); loaderWrapperRef.current?.scrollTo(0, 0); - }; + }, []); - const addSchedule = (lecture: Lecture) => { + const addSchedule = useCallback((lecture: Lecture) => { if (!searchInfo) return; const { tableId } = searchInfo; @@ -158,18 +224,15 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { lecture })); - setSchedulesMap(prev => ({ - ...prev, - [tableId]: [...prev[tableId], ...schedules] - })); + updateTableSchedules(tableId, (currentSchedules) => [...currentSchedules, ...schedules]); onClose(); - }; + }, [searchInfo, updateTableSchedules, onClose]); useEffect(() => { const start = performance.now(); console.log('API 호출 시작: ', start) - fetchAllLectures().then(results => { + fetchAllLectures().then((results: AxiosResponse[]) => { const end = performance.now(); console.log('모든 API 호출 완료 ', end) console.log('API 호출에 걸린 시간(ms): ', end - start) @@ -319,11 +382,7 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { {allMajors.map(major => ( - - - {major.replace(/

/gi, ' ')} - - + ))} @@ -351,17 +410,7 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { {visibleLectures.map((lecture, index) => ( - - - - - - - + ))}
{lecture.id}{lecture.grade}{lecture.title}{lecture.credits} - - - -
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..544da54 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly BASE_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + diff --git a/vite.config.ts b/vite.config.ts index 1cdac55..0483493 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react-swc'; export default mergeConfig( defineConfig({ plugins: [react()], + base: process.env.NODE_ENV === 'production' ? '/front_7th_chapter4-2/' : '/', }), defineTestConfig({ test: {