diff --git a/next.config.js b/next.config.js index a3e230d..4771757 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,4 @@ +/** @type {import('next').NextConfig} */ const nextConfig = { images: { unoptimized: true, diff --git a/package-lock.json b/package-lock.json index c8f5c70..48ea462 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@next/bundle-analyzer": "^15.5.3", "@storybook/addon-essentials": "7.6.20", "@storybook/addon-interactions": "7.6.20", "@storybook/addon-links": "7.6.20", @@ -4261,6 +4262,16 @@ "node": ">=6" } }, + "node_modules/@next/bundle-analyzer": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-15.5.3.tgz", + "integrity": "sha512-l2NxnWHP2gWHbomAlz/wFnN2jNCx/dpr7P/XWeOLhULiyKkXSac8O8SjxRO/8FNhr2l4JNtWVKk82Uya4cZYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "webpack-bundle-analyzer": "4.10.1" + } + }, "node_modules/@next/env": { "version": "14.2.32", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.32.tgz", @@ -4599,6 +4610,13 @@ "node": ">=8.9.0" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -13133,6 +13151,13 @@ "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", "license": "MIT" }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -13772,6 +13797,13 @@ "node": ">= 0.4" } }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, "node_modules/duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -16501,6 +16533,22 @@ "dev": true, "license": "MIT" }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -16702,6 +16750,13 @@ ], "license": "MIT" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -19457,6 +19512,16 @@ "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", "license": "MIT" }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -20192,6 +20257,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -23304,6 +23379,21 @@ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", "license": "MIT" }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -24606,6 +24696,16 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", @@ -25639,6 +25739,92 @@ } } }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz", + "integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "is-plain-object": "^5.0.0", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/webpack-dev-middleware": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-6.1.3.tgz", diff --git a/package.json b/package.json index f0f6f17..8a92116 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "next dev", "build": "next build", + "analyze": "npx @next/bundle-analyzer .next/static/chunks/", "start": "next start", "lint": "next lint", "prepare": "husky", @@ -74,6 +75,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@next/bundle-analyzer": "^15.5.3", "@storybook/addon-essentials": "7.6.20", "@storybook/addon-interactions": "7.6.20", "@storybook/addon-links": "7.6.20", diff --git a/src/app/activities/[activityId]/ActivityClientPage.tsx b/src/app/activities/[activityId]/ActivityClientPage.tsx index 91ae50b..d0bda85 100644 --- a/src/app/activities/[activityId]/ActivityClientPage.tsx +++ b/src/app/activities/[activityId]/ActivityClientPage.tsx @@ -1,7 +1,8 @@ 'use client'; import { useState, useEffect, useMemo } from 'react'; -import { useSuspenseQuery } from '@tanstack/react-query'; +import { useSuspenseQuery, useQueryClient } from '@tanstack/react-query'; import { getActivityDetail } from '@/app/api/activities'; +import type { ActivityDetail } from '@/types/activities.type'; import { useRecentViewedStore } from '@/store/recentlyWatched'; import ActivityImageViewer from '@/components/pages/activities/ActivityImageViewer'; import ActivityInfo from '@/components/pages/activities/ActivityInfo'; @@ -12,6 +13,7 @@ import Marker from '@/components/common/naverMaps/Marker'; import ImageMarker from '@/components/common/naverMaps/ImageMarker'; import { activityQueryKeys } from './queryKeys'; import { useUserStore } from '@/store/userStore'; +import { useIntersectionObserver } from '@/hooks/useIntersectionObserver'; /** * ActivityClient 컴포넌트 @@ -25,7 +27,13 @@ interface ActivityClientProps { export default function ActivityClient({ activityId, blurImage }: ActivityClientProps) { const [isOwner, setIsOwner] = useState(false); - const user = useUserStore((state) => state.user); + const { user } = useUserStore(); + const queryClient = useQueryClient(); + + const [mapRef, isMapVisible] = useIntersectionObserver({ + rootMargin: '100px', + triggerOnce: true, + }); // 1. 정적 데이터 (이미지, 주소, 제목, 설명) - 긴 캐시 const { data: staticInfo } = useSuspenseQuery({ @@ -47,11 +55,31 @@ export default function ActivityClient({ activityId, blurImage }: ActivityClient gcTime: 60 * 60 * 1000, // 1시간 메모리 보관 }); - // 2. 동적 데이터 (가격, 스케줄, 평점) - 짧은 캐시 + // 2. 동적 데이터 (가격, 스케줄, 평점) - 짧은 캐시, 재사용 최적화 const { data: dynamicInfo } = useSuspenseQuery({ queryKey: [...activityQueryKeys.detail(activityId), 'dynamic'], - queryFn: () => getActivityDetail(Number(activityId)), - select: (data) => ({ + queryFn: (): Promise => { + // 캐시된 데이터가 있으면 재사용, 없으면 새로 호출 + const cachedData = queryClient.getQueryData([ + ...activityQueryKeys.detail(activityId), + 'static', + ]); + const cachedState = queryClient.getQueryState([ + ...activityQueryKeys.detail(activityId), + 'static', + ]); + + // static 캐시가 fresh하면 재사용 + if ( + cachedData && + cachedState?.dataUpdatedAt && + Date.now() - cachedState.dataUpdatedAt < 2 * 60 * 1000 + ) { + return Promise.resolve(cachedData); // 캐시 재사용 + } + return getActivityDetail(Number(activityId)); // 새 호출 + }, + select: (data: ActivityDetail) => ({ price: data.price, schedules: data.schedules, rating: data.rating, @@ -69,11 +97,10 @@ export default function ActivityClient({ activityId, blurImage }: ActivityClient const addViewed = useRecentViewedStore((s) => s.addViewed); useEffect(() => { - if (activity) { + if (activity?.id) { addViewed(activity); - console.log('👀 최근 본 목록에 추가됨', activity.title); } - }, [activity, addViewed]); + }, [activity, addViewed]); // eslint 경고 해결을 위해 원복하되, 조건문 최적화 useEffect(() => { if (user?.id === activity.userId) { @@ -107,14 +134,18 @@ export default function ActivityClient({ activityId, blurImage }: ActivityClient
{/* 주소 섹션 */} -
+

오시는 길

{activity.address}

- - - - - + {isMapVisible ? ( + + + + + + ) : ( +
+ )}

{/* 후기 섹션 */} diff --git a/src/app/activities/[activityId]/error.tsx b/src/app/activities/[activityId]/error.tsx deleted file mode 100644 index e69ec42..0000000 --- a/src/app/activities/[activityId]/error.tsx +++ /dev/null @@ -1,52 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; - -interface ErrorProps { - error: Error & { digest?: string }; - reset: () => void; -} - -export default function Error({ error, reset }: ErrorProps) { - useEffect(() => { - console.log('❌ [ERROR] ActivityPage 에러 발생', { - message: error.message, - digest: error.digest, - stack: error.stack, - }); - }, [error]); - - return ( -
-
-
🚧
-

체험 정보를 불러올 수 없습니다

-

- 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요. -

-
- - -
- {process.env.NODE_ENV === 'development' && ( -
- 개발자 정보 -
-              {error.message}
-            
-
- )} -
-
- ); -} diff --git a/src/app/activities/[activityId]/not-found.tsx b/src/app/activities/[activityId]/not-found.tsx new file mode 100644 index 0000000..60279c5 --- /dev/null +++ b/src/app/activities/[activityId]/not-found.tsx @@ -0,0 +1,44 @@ +'use client'; + +import Link from 'next/link'; +import { lazy, Suspense, useState, useEffect } from 'react'; + +const Lottie = lazy(() => import('lottie-react')); + +export default function ActivityNotFound() { + const [animationData, setAnimationData] = useState(null); + + useEffect(() => { + // 404 애니메이션 동적 로드 (305KB) + import('@/assets/lottie/404 Error - Doodle animation.json') + .then((module) => setAnimationData(module.default)) + .catch(() => setAnimationData(null)); + }, []); + + return ( +
+
+ {animationData ? ( +
} + > + + + ) : ( +
+ )} +
+ +
체험을 찾을 수 없습니다.
+

+ 체험이 존재하지 않거나 삭제되었을 수 있습니다. +

+ + 홈으로 + + + ); +} diff --git a/src/app/activities/[activityId]/page.tsx b/src/app/activities/[activityId]/page.tsx index 5a74a42..0b864fa 100644 --- a/src/app/activities/[activityId]/page.tsx +++ b/src/app/activities/[activityId]/page.tsx @@ -28,18 +28,11 @@ interface ActivityStaticParams { } const ActivityPage = async ({ params }: ActivityPageProps) => { - const startTime = performance.now(); - console.log('🎬 [SSR] ActivityPage 시작'); - // params 추출 const { activityId } = await params; - // Activity 데이터 prefetch const { dehydratedState, blur } = await prefetchActivityData(activityId); - const duration = performance.now() - startTime; - console.log(`⏱️ [SSR] ActivityPage 완료: ${duration.toFixed(2)}ms`, { activityId }); - return ( }> @@ -53,7 +46,6 @@ export default ActivityPage; // SSG를 위한 정적 경로 생성 export async function generateStaticParams(): Promise { const startTime = performance.now(); - console.log('🏗️ [SSG] generateStaticParams 시작 - 인기 체험 20개 선정'); try { const activities = await getActivitiesList({ @@ -68,7 +60,7 @@ export async function generateStaticParams(): Promise { })); const duration = performance.now() - startTime; - console.log(`⏱️ [SSG] generateStaticParams 완료: ${duration.toFixed(2)}ms`, { + console.log(`⏱️ [SSG] 인기 체험 20개 선정 완료: ${duration.toFixed(2)}ms`, { count: staticParams.length, activityIds: staticParams.map((p) => p.activityId), }); diff --git a/src/app/activities/[activityId]/prefetchActivity.ts b/src/app/activities/[activityId]/prefetchActivity.ts index c470c22..b64c0fe 100644 --- a/src/app/activities/[activityId]/prefetchActivity.ts +++ b/src/app/activities/[activityId]/prefetchActivity.ts @@ -1,10 +1,11 @@ import { QueryClient, dehydrate, type DehydratedState } from '@tanstack/react-query'; import { getActivityDetail } from '@/app/api/activities'; import { getBlurDataURL } from '@/lib/utils/blur'; +import { notFound } from 'next/navigation'; /** * SSR prefetch용 통합 함수 - * Activity 기본 정보를 서버에서 미리 로드 + 상단 3장 LQIP(blur) 생성 + * Activity 기본 정보를 서버에서 미리 로드 + 모든 이미지 blur 생성 */ // NEW: 반환 타입 정의 @@ -14,9 +15,6 @@ export interface PrefetchActivityResult { } export async function prefetchActivityData(activityId: string): Promise { - // CHANGED - console.log('📡 [SSR] Activity 데이터 prefetch 시작', { activityId }); - const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -39,10 +37,9 @@ export async function prefetchActivityData(activityId: string): Promise getActivityDetail(numericId), - // 필요시 staleTime/gcTime 부여 가능 }); - // 캐시된 activity를 꺼내서 상단 3장의 blur 생성 + // 캐시된 activity를 꺼내서 모든 이미지의 blur 생성 const activity = queryClient.getQueryData<{ bannerImageUrl: string; subImages: { id: number | string; imageUrl: string }[]; @@ -52,24 +49,32 @@ export async function prefetchActivityData(activityId: string): Promise (sub.imageUrl ? getBlurDataURL(sub.imageUrl) : undefined)), ]); - blur = { banner: b, sub: [s0, s1] }; + blur = { + banner: bannerBlur, + sub: subBlurs, + }; + + console.log(`🎨 [SSR] 블러 이미지 생성 완료: 배너 1개 + 서브 ${subBlurs.length}개`); + } else { + notFound(); } console.log('✅ [SSR] Activity prefetch 성공', { activityId }); - // CHANGED: dehydratedState + blur 함께 반환 return { dehydratedState: dehydrate(queryClient), blur }; } catch (error) { console.log('⚠️ [SSR] Activity prefetch 실패, 클라이언트에서 로드', { activityId, error }); + if (error instanceof Error && error.message === 'NEXT_NOT_FOUND') { + notFound(); + } // 에러여도 최소한 dehydratedState는 반환 return { dehydratedState: dehydrate(queryClient) }; // CHANGED } diff --git a/src/app/error.tsx b/src/app/error.tsx index 61d3caf..22ef81f 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -1,8 +1,9 @@ 'use client'; -import Lottie from 'lottie-react'; -import loadingLottie from '@/assets/lottie/T-rex.json'; import Link from 'next/link'; +import { lazy, Suspense, useState, useEffect } from 'react'; + +const Lottie = lazy(() => import('lottie-react')); export default function GlobalError({ error, @@ -11,10 +12,27 @@ export default function GlobalError({ error: Error & { digest?: string }; reset: () => void; }) { + const [animationData, setAnimationData] = useState(null); + + useEffect(() => { + // 동적으로 Lottie JSON 로드 + import('@/assets/lottie/T-rex.json') + .then((module) => setAnimationData(module.default)) + .catch(() => setAnimationData(null)); + }, []); + return (
- + {animationData ? ( +
} + > + + + ) : ( +
+ )}
뭔가 잘못되었습니다.
{error.message} diff --git a/src/components/common/naverMaps/NaverMap.tsx b/src/components/common/naverMaps/NaverMap.tsx index 9830513..8b6d228 100644 --- a/src/components/common/naverMaps/NaverMap.tsx +++ b/src/components/common/naverMaps/NaverMap.tsx @@ -1,12 +1,12 @@ 'use client'; import { motion, AnimatePresence } from 'framer-motion'; -import { Suspense, useId } from 'react'; +import { Suspense, useId, lazy } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import NaverMapSkeleton from './NaverMapSkeleton'; import NaverMapError from './NaverMapError'; -import NaverMapCore from './NaverMapCore'; +const NaverMapCore = lazy(() => import('./NaverMapCore')); /** * NaverMap 컴포넌트 * - 네이버 지도 API를 사용한 지도 렌더링 컴포넌트 diff --git a/src/components/common/wsrvLoader.tsx b/src/components/common/wsrvLoader.tsx index 85ff229..1f40439 100644 --- a/src/components/common/wsrvLoader.tsx +++ b/src/components/common/wsrvLoader.tsx @@ -7,5 +7,8 @@ export const wsrvLoader = ({ width: number; quality?: number; }) => { - return `https://wsrv.nl/?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 75}`; + // WebP 포맷과 적응형 크기 지원으로 이미지 최적화 + const actualQuality = quality || 75; + + return `https://wsrv.nl/?url=${encodeURIComponent(src)}&w=${width}&q=${actualQuality}&output=webp&af`; }; diff --git a/src/components/pages/activities/ActivityImageViewer.tsx b/src/components/pages/activities/ActivityImageViewer.tsx index 8632308..362a0b4 100644 --- a/src/components/pages/activities/ActivityImageViewer.tsx +++ b/src/components/pages/activities/ActivityImageViewer.tsx @@ -4,11 +4,11 @@ import { SubImage } from '@/types/activities.type'; import Image from 'next/image'; import { useCallback } from 'react'; import { Expand, ImageIcon } from 'lucide-react'; -import ImageGalleryModal from '@/components/pages/activities/ImageGalleryModal'; import { useOverlay } from '@/hooks/useOverlay'; import { motion } from 'motion/react'; import { useImageWithFallback } from '@/hooks/useImageWithFallback'; import clsx from 'clsx'; +import { wsrvLoader } from '@/components/common/wsrvLoader'; /** * 이미지를 표시하는 컴포넌트 @@ -46,9 +46,14 @@ export default function ActivityImageViewer({ // 남은 이미지 개수 = 전체 - 표시된 3개 const remainingCount = Math.max(0, allImages.length - 3); - // 이미지 클릭 핸들러 + // 이미지 클릭 핸들러 (Dynamic Import) const handleImageClick = useCallback( - (index: number) => { + async (index: number) => { + // Dynamic import로 모달 로딩 + const { default: ImageGalleryModal } = await import( + '@/components/pages/activities/ImageGalleryModal' + ); + // 모달 열기 overlay.open(({ isOpen, close }) => ( )); }, - [bannerImageUrl, subImages, title, overlay], + [bannerImageUrl, subImages, title, overlay, blurImage], ); return ( @@ -83,10 +89,11 @@ export default function ActivityImageViewer({ transition={{ duration: 0.3, ease: 'easeOut' }} > wsrvLoader({ ...props, quality: 80 })} src={bannerImage.src} alt={title} fill - sizes='(max-width: 768px) 50vw, 25vw' + sizes='(max-width: 1024px) 100vw, 50vw' className='object-cover cursor-pointer' onClick={() => handleImageClick(0)} onError={bannerImage.onError} @@ -125,10 +132,12 @@ export default function ActivityImageViewer({ transition={{ duration: 0.3, ease: 'easeOut' }} > wsrvLoader({ ...props, quality: 75 })} + loading='lazy' src={subImage1.src} alt={`${title} 서브 이미지 1`} fill - sizes='(max-width: 768px) 50vw, 25vw' + sizes='(max-width: 1024px) 50vw, 25vw' className='object-cover cursor-pointer' onClick={() => handleImageClick(1)} onError={subImage1.onError} @@ -155,10 +164,12 @@ export default function ActivityImageViewer({ transition={{ duration: 0.3, ease: 'easeOut' }} > wsrvLoader({ ...props, quality: 75 })} + loading='lazy' src={subImage2.src} alt={`${title} 서브 이미지 2`} fill - sizes='(max-width: 768px) 50vw, 25vw' + sizes='(max-width: 1024px) 50vw, 25vw' className='object-cover cursor-pointer' onClick={() => handleImageClick(2)} onError={subImage2.onError} diff --git a/src/components/pages/activities/ActivityInfo.tsx b/src/components/pages/activities/ActivityInfo.tsx index 19f0e40..74459f9 100644 --- a/src/components/pages/activities/ActivityInfo.tsx +++ b/src/components/pages/activities/ActivityInfo.tsx @@ -2,6 +2,7 @@ import { ActivityDetail } from '@/types/activities.type'; import { MapPin, Star } from 'lucide-react'; +import { memo } from 'react'; import { twMerge } from 'tailwind-merge'; import { EditDropDown } from './EditDropDown'; @@ -18,7 +19,11 @@ interface ActivityInfoProps { isOwner: boolean; } -export default function ActivityInfo({ activity, className, isOwner }: ActivityInfoProps) { +const ActivityInfo = memo(function ActivityInfo({ + activity, + className, + isOwner, +}: ActivityInfoProps) { return (
{/* 카테고리 및 드롭다운 */} @@ -46,4 +51,6 @@ export default function ActivityInfo({ activity, className, isOwner }: ActivityI
); -} +}); + +export default ActivityInfo; diff --git a/src/components/pages/activities/ActivitySkeleton.tsx b/src/components/pages/activities/ActivitySkeleton.tsx index 858448c..666a71f 100644 --- a/src/components/pages/activities/ActivitySkeleton.tsx +++ b/src/components/pages/activities/ActivitySkeleton.tsx @@ -1,61 +1,70 @@ +import { Skeleton } from '@/components/ui/skeleton'; + /** * 선언형 스켈레톤 로딩 컴포넌트 * Suspense fallback으로 사용 */ export default function ActivitySkeleton() { return ( -
-
-
- {/* 이미지 영역 스켈레톤 */} -
-
-
- {Array.from({ length: 4 }, (_, i) => ( -
- ))} -
-
- - {/* 정보 영역 스켈레톤 */} -
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {/* 이미지 영역 스켈레톤 */} +
+ + + +
+ {/* 제목 및 기본 정보 스켈레톤 */} +
+ + + +
+
+ {/* 설명 스켈레톤 */} +
+ + +
+ {/* 지도 스켈레톤 */} +
+ + + +
+
+ {/* 리뷰 스켈레톤 */} +
+ +
+ + + + +
+ {/* 리뷰 리스트 스켈레톤 */} +
+ {Array.from({ length: 3 }, (_, i) => ( + + ))} +
+
-
- - {/* 하단 섹션 스켈레톤 */} -
- {/* 지도 섹션 스켈레톤 */} -
-
-
-
- - {/* 리뷰 섹션 스켈레톤 */} -
-
-
- {Array.from({ length: 3 }, (_, i) => ( -
-
-
-
-
-
-
-
- ))} + {/* 사이드바 스켈레톤 */} +
+
+ + + +
+ + +
+
diff --git a/src/components/pages/activities/EditDropDown.tsx b/src/components/pages/activities/EditDropDown.tsx index ecb8c0a..62bf51b 100644 --- a/src/components/pages/activities/EditDropDown.tsx +++ b/src/components/pages/activities/EditDropDown.tsx @@ -80,7 +80,7 @@ export function EditDropDown({ activityId, isOwner, open, setOpen }: EditDropDow onError: (error) => { overlay.open(({ isOpen, close }) => ( {/* 트리거 버튼 */}
)} wsrvLoader({ ...props, quality: 75 })} + placeholder='blur' + blurDataURL={allBlurImageURLs[index]} src={getCurrentImageSrc(index)} alt={`${title} - ${index + 1} 이미지`} - fill - className='object-cover' + width={600} + height={400} + className='object-cover w-auto' onLoad={() => handleImageLoad(index)} onError={() => { console.log('🖼️ Mobile image failed to load:', image.imageUrl); @@ -226,7 +235,7 @@ export default function ImageGalleryModal({ )} wsrvLoader({ ...props, quality: 85 })} + placeholder='blur' + blurDataURL={allBlurImageURLs[currentIndex]} src={getCurrentImageSrc(currentIndex)} alt={`${title} - ${currentIndex + 1}`} - width={600} - height={400} - className='object-contain rounded-2xl shadow-lg' + fill + className='object-cover rounded-2xl' onLoad={() => { handleImageLoad(currentIndex); }} onError={() => { - console.log( - '🖼️ Modal image failed to load:', - allImages[currentIndex]?.imageUrl, - ); setImageErrors((prev) => ({ ...prev, [currentIndex]: true })); }} priority={currentIndex === 0} @@ -320,13 +327,15 @@ export default function ImageGalleryModal({ whileTap={{ scale: 0.95 }} > wsrvLoader({ ...props, quality: 60 })} + loading='lazy' + placeholder='blur' + blurDataURL={allBlurImageURLs[index]} src={getCurrentImageSrc(index)} alt={`${title} thumbnail ${index + 1}`} - width={64} - height={64} - className='w-full h-full object-cover' + fill + className='object-cover' onError={() => { - console.log('🖼️ Thumbnail failed to load:', image.imageUrl); setImageErrors((prev) => ({ ...prev, [index]: true })); }} /> diff --git a/src/components/pages/activities/ReviewCard.tsx b/src/components/pages/activities/ReviewCard.tsx index 650cc25..63123c3 100644 --- a/src/components/pages/activities/ReviewCard.tsx +++ b/src/components/pages/activities/ReviewCard.tsx @@ -2,12 +2,13 @@ import { Stars } from '@/components/common/Stars'; import { Review } from '@/types/reviews.type'; +import { memo } from 'react'; interface ReviewCardProps { review: Review; } -export function ReviewCard({ review }: ReviewCardProps) { +export const ReviewCard = memo(function ReviewCard({ review }: ReviewCardProps) { return (
@@ -26,4 +27,4 @@ export function ReviewCard({ review }: ReviewCardProps) {

{review.content}

); -} +}); diff --git a/src/components/pages/activities/bookingCard/BookingConfirm.Modal.tsx b/src/components/pages/activities/bookingCard/BookingConfirm.Modal.tsx index 8af3203..cfb9342 100644 --- a/src/components/pages/activities/bookingCard/BookingConfirm.Modal.tsx +++ b/src/components/pages/activities/bookingCard/BookingConfirm.Modal.tsx @@ -46,8 +46,7 @@ const BookingConfirmModal = ({ reservationData: ReservationRequest; }) => createReservation(activityId, reservationData), - onSuccess: async (data) => { - console.log('🎫 [BookingConfirmModal] 예약 성공:', data); + onSuccess: async () => { onClose(); successToast.run('예약이 완료되었습니다!'); router.push('/mypage/reservation-list'); @@ -63,7 +62,6 @@ const BookingConfirmModal = ({ // 401 Unauthorized 에러인 경우 로그인 페이지로 리다이렉트 if (axiosError?.response?.status === 401) { - console.log('🚨 예약 실패: 로그인 필요'); overlay.open(({ isOpen, close }) => ( { - console.log('🔘 예약 확정 버튼 클릭됨'); makeReservation({ activityId, reservationData: { diff --git a/src/components/pages/activities/bookingCard/BookingContainer.tsx b/src/components/pages/activities/bookingCard/BookingContainer.tsx index b5c9970..eae130b 100644 --- a/src/components/pages/activities/bookingCard/BookingContainer.tsx +++ b/src/components/pages/activities/bookingCard/BookingContainer.tsx @@ -4,7 +4,10 @@ import { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { format } from 'date-fns'; import BookingCardDesktop from './BookingCardDesktop'; -import BookingCardMobile from './BookingCardMobile'; +import { lazy, Suspense } from 'react'; + +// 모바일 예약 카드 지연 로딩 +const BookingCardMobile = lazy(() => import('./BookingCardMobile')); import BookingError from '@/components/pages/activities/bookingCard/BookingError'; import { ErrorBoundary } from 'react-error-boundary'; import { getAvailableSchedule } from '@/app/api/activities'; @@ -13,7 +16,6 @@ import { useSchedulesByDate } from '@/hooks/useSchedulesByDate'; import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/ui/drawer'; import { Button } from '@/components/ui/button'; import { useOverlay } from '@/hooks/useOverlay'; -import BookingConfirmModal from '@/components/pages/activities/bookingCard/BookingConfirm.Modal'; interface BookingContainerProps { title: string; @@ -70,7 +72,8 @@ export default function BookingContainer({ const month = format(selectedDate, 'MM'); return getAvailableSchedule(activityId, { year, month }); }, - staleTime: 5 * 60 * 1000, // 5분 캐시 + staleTime: 0, + gcTime: 0, enabled: !!selectedDate, // 날짜가 선택된 경우에만 실행 }); const totalPrice = price * memberCount; @@ -86,7 +89,6 @@ export default function BookingContainer({ ...prev, [dateStr]: newSchedule.times, })); - console.log(`✅ [BookingCard] ${dateStr} 스케줄 업데이트됨`); } } }, [scheduleByDate, isSuccess, selectedDate]); @@ -108,15 +110,14 @@ export default function BookingContainer({ setMemberCount(count); }; - const handleBooking = () => { + const handleBooking = async () => { if (!selectedScheduleTime) return; - console.log('🎫 [BookingCard] 예약 요청:', { - activityId, - selectedScheduleTime, - memberCount, - totalPrice: price * memberCount, - }); + // Dynamic import로 예약 확인 모달 로딩 + const { default: BookingConfirmModal } = await import( + '@/components/pages/activities/bookingCard/BookingConfirm.Modal' + ); + overlay.open(({ isOpen, close }) => ( - + +
+
+ } + > + +
diff --git a/src/components/pages/activities/bookingCard/BookingDateInput.tsx b/src/components/pages/activities/bookingCard/BookingDateInput.tsx index 9c4cd19..ae95f9a 100644 --- a/src/components/pages/activities/bookingCard/BookingDateInput.tsx +++ b/src/components/pages/activities/bookingCard/BookingDateInput.tsx @@ -34,7 +34,6 @@ export function BookingDateInput({ // selectedDate가 변경될 때 각 input value 업데이트 useEffect(() => { - console.log('📅 selectedDate 변경:', selectedDate); if (selectedDate) { setYearValue(format(selectedDate, 'yyyy')); setMonthValue(format(selectedDate, 'MM')); diff --git a/src/components/pages/activities/bookingCard/BookingMember.tsx b/src/components/pages/activities/bookingCard/BookingMember.tsx index 803b34e..14a4c1d 100644 --- a/src/components/pages/activities/bookingCard/BookingMember.tsx +++ b/src/components/pages/activities/bookingCard/BookingMember.tsx @@ -28,6 +28,7 @@ export default function BookingMember({