From 8cec8fb10b72511b0fb1880e6055b1a545cb6d5b Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Wed, 20 May 2026 23:19:55 +0900 Subject: [PATCH 1/4] feat: add slug field ui --- app/api/posts/route.ts | 25 ++++++++++--- app/entities/post/write/BlogForm.tsx | 3 ++ app/entities/post/write/PostMetadataForm.tsx | 37 +++++++++++++++++++- app/hooks/post/usePost.ts | 14 ++++++-- app/lib/utils/validate/validate.ts | 5 +++ app/types/Post.d.ts | 2 +- 6 files changed, 78 insertions(+), 8 deletions(-) diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index 1ce1f6f4..9c05c5fe 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -4,7 +4,6 @@ import { getServerSession } from 'next-auth'; import { isAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import { getThumbnailInMarkdown } from '@/app/lib/utils/parse'; -import { generateUniqueSlug } from '@/app/lib/utils/post'; import Post from '@/app/models/Post'; import Series from '@/app/models/Series'; @@ -123,6 +122,7 @@ export async function POST(req: Request) { await dbConnect(); const { + slug, title, subTitle, author, @@ -134,13 +134,30 @@ export async function POST(req: Request) { sendToSubscribers, } = await req.json(); - if (!title || !content || !author || !content) { + if (!title || !content || !author || !slug) { return Response.json( - { success: false, error: '제목, 소제목, 작성자, 내용은 필수입니다' }, + { success: false, error: '제목, 작성자, 내용, 슬러그는 필수입니다' }, { status: 400 } ); } + // slug 패턴 검사 + if (!/^[a-zA-Z0-9-]+$/.test(slug)) { + return Response.json( + { success: false, error: 'slug는 영문, 숫자, 하이픈(-)만 허용됩니다' }, + { status: 400 } + ); + } + + // slug 중복 검사 + const existingPost = await Post.findOne({ slug }); + if (existingPost) { + return Response.json( + { success: false, error: '이미 사용 중인 slug입니다. 다른 slug를 입력해주세요.' }, + { status: 409 } + ); + } + let thumbnailOfPost = getThumbnailInMarkdown(content); if (!thumbnailOfPost) { const defaultSeriesThumbnail = @@ -151,7 +168,7 @@ export async function POST(req: Request) { } const post = { - slug: await generateUniqueSlug(title, Post), + slug, title, subTitle, author, diff --git a/app/entities/post/write/BlogForm.tsx b/app/entities/post/write/BlogForm.tsx index 44cef280..bcc8ee7c 100644 --- a/app/entities/post/write/BlogForm.tsx +++ b/app/entities/post/write/BlogForm.tsx @@ -185,6 +185,7 @@ const BlogForm = () => { setFormData({ title: localData.title || '', subTitle: localData.subTitle || '', + slug: '', content: localData.content, seriesId: localData.seriesId || '', tags: localData.tags || [], @@ -196,6 +197,7 @@ const BlogForm = () => { setFormData({ title: cloudData.title || '', subTitle: cloudData.subTitle || '', + slug: '', content: cloudData.content, seriesId: cloudData.seriesId || '', tags: cloudData.tags || [], @@ -252,6 +254,7 @@ const BlogForm = () => { clearDraft={openDeleteDraftOverlay} autoSyncEnabled={autoSyncEnabled} onToggleAutoSync={toggleAutoSync} + isEditMode={isEditMode} /> void; autoSyncEnabled: boolean; onToggleAutoSync: (enabled: boolean) => void; + isEditMode?: boolean; formData: { title: string; subTitle: string; + slug: string; seriesId?: string; tags: string[]; isPrivate: boolean; @@ -36,11 +38,25 @@ const PostMetadataForm = ({ clearDraft, autoSyncEnabled, onToggleAutoSync, + isEditMode = false, formData, }: PostMetadataFormProps) => { const [tagInput, setTagInput] = useState(''); + const [slugError, setSlugError] = useState(''); - const { title, subTitle, seriesId, tags, isPrivate, sendToSubscribers } = + const handleSlugChange = (e: ChangeEvent) => { + const value = e.target.value; + if (/[가-힣]/.test(value)) { + setSlugError('한글은 입력할 수 없습니다. 영문, 숫자, 하이픈(-)만 사용하세요.'); + } else if (value && !/^[a-zA-Z0-9-]+$/.test(value)) { + setSlugError('영문, 숫자, 하이픈(-)만 사용할 수 있습니다.'); + } else { + setSlugError(''); + } + onFieldChange('slug', value); + }; + + const { title, subTitle, slug, seriesId, tags, isPrivate, sendToSubscribers } = formData; const { suggestions, isOpen, highlightedIndex, setIsOpen, setHighlightedIndex } = @@ -165,6 +181,25 @@ const PostMetadataForm = ({ value={subTitle} /> +
+ + 슬  러  그  + + +
+ {slugError && ( +

{slugError}

+ )}
diff --git a/app/hooks/post/usePost.ts b/app/hooks/post/usePost.ts index 8478a2fc..d6b85c38 100644 --- a/app/hooks/post/usePost.ts +++ b/app/hooks/post/usePost.ts @@ -12,6 +12,7 @@ import { Series } from '@/app/types/Series'; interface FormData { title: string; subTitle: string; + slug: string; content: string | undefined; seriesId: string; tags: string[]; @@ -29,6 +30,7 @@ const usePost = (slug = '') => { const [formData, setFormData] = useState({ title: '', subTitle: '', + slug: '', content: '', seriesId: '', tags: [], @@ -53,6 +55,7 @@ const usePost = (slug = '') => { const { draft, draftImages, updateDraft, clearDraft } = useDraft(); const postBody: PostBody = { + slug: formData.slug, title: formData.title, subTitle: formData.subTitle, author: NICKNAME, @@ -93,6 +96,7 @@ const usePost = (slug = '') => { setFormData({ title: data.post.title || '', subTitle: data.post.subTitle, + slug: data.post.slug || '', content: data.post.content, seriesId: data.post.seriesId || '', tags: data.post.tags || [], @@ -116,8 +120,12 @@ const usePost = (slug = '') => { toast.success('글이 성공적으로 발행되었습니다.'); router.push('/posts'); } - } catch (e) { - toast.error('글 발행 중 오류 발생했습니다.'); + } catch (e: any) { + if (e.response?.status === 409) { + toast.error('이미 사용 중인 slug입니다. 다른 slug를 입력해주세요.'); + } else { + toast.error('글 발행 중 오류 발생했습니다.'); + } console.error('글 발행 중 오류 발생', e); } }; @@ -152,6 +160,7 @@ const usePost = (slug = '') => { setFormData({ title: title || '', subTitle: subTitle || '', + slug: '', content: content, seriesId: seriesId || '', tags: tags || [], @@ -208,6 +217,7 @@ const usePost = (slug = '') => { // Form data (individual values for backward compatibility) title: formData.title, subTitle: formData.subTitle, + slug: formData.slug, content: formData.content, seriesId: formData.seriesId, isPrivate: formData.isPrivate, diff --git a/app/lib/utils/validate/validate.ts b/app/lib/utils/validate/validate.ts index e49dcd15..5680366e 100644 --- a/app/lib/utils/validate/validate.ts +++ b/app/lib/utils/validate/validate.ts @@ -12,6 +12,11 @@ export const validatePost = ( if (!post.content?.trim()) { errors.push('내용은 필수입니다'); } + if (!post.slug?.trim()) { + errors.push('슬러그는 필수입니다'); + } else if (!/^[a-zA-Z0-9-]+$/.test(post.slug)) { + errors.push('슬러그는 영문, 숫자, 하이픈(-)만 사용할 수 있습니다'); + } // 길이 제한 검사 if (post.title && post.title.length > 100) { diff --git a/app/types/Post.d.ts b/app/types/Post.d.ts index 2069e2e6..82e06052 100644 --- a/app/types/Post.d.ts +++ b/app/types/Post.d.ts @@ -18,6 +18,6 @@ interface Post { isPrivate?: boolean; sendToSubscribers?: boolean; } -type PostBody = Omit; +type PostBody = Omit; export { Post, PostBody }; From 0eb6ad5c2bf0fee923a7624978af35ce798a395d Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Wed, 20 May 2026 23:47:21 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=EC=97=90=EB=94=94=ED=84=B0=20?= =?UTF-8?q?=EB=A6=AC=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/entities/post/write/BlogForm.tsx | 112 ++++++++++--------- app/entities/post/write/PostMetadataForm.tsx | 39 +++++-- app/hooks/post/useCloudDraft.ts | 6 +- app/hooks/post/usePost.ts | 10 +- 4 files changed, 97 insertions(+), 70 deletions(-) diff --git a/app/entities/post/write/BlogForm.tsx b/app/entities/post/write/BlogForm.tsx index bcc8ee7c..64afe555 100644 --- a/app/entities/post/write/BlogForm.tsx +++ b/app/entities/post/write/BlogForm.tsx @@ -4,7 +4,7 @@ import '@uiw/react-md-editor/markdown-editor.css'; import '@uiw/react-markdown-preview/markdown.css'; import dynamic from 'next/dynamic'; import { useSearchParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import ImageZoomViewer from '@/app/entities/common/Overlay/Image/ImageZoomViewer'; import Overlay from '@/app/entities/common/Overlay/Overlay'; import Callout from '@/app/entities/post/detail/Callout'; @@ -44,6 +44,20 @@ const calloutCommand: ICommand = { }, }; +const editorExtraCommands = [ + calloutCommand, + commands.divider, + ...commands.getExtraCommands(), +]; + +const CalloutComponent = ({ + emoji, + children, +}: { + emoji?: string; + children?: React.ReactNode; +}) => {children}; + export interface SelectedImage { src: string; alt?: string; @@ -115,17 +129,46 @@ const BlogForm = () => { useBlockNavigate({ title: formData.title, content: formData.content || '' }); - // 이미지 클릭 핸들러 생성 - const addImageClickHandler = createImageClickHandler( - setSelectedImage + const addImageClickHandler = useMemo( + () => createImageClickHandler(setSelectedImage), + [] ); - const handleFieldChange = ( + const handleContentChange = useCallback((value: string | undefined) => { + setFormData({ content: value }); + }, [setFormData]); + + const editorPreviewOptions = useMemo(() => ({ + wrapperElement: { 'data-color-mode': theme }, + components: { callout: CalloutComponent } as any, + rehypeRewrite: ( + node: Root | RootContent, + index?: number, + parent?: Root | HastElement + ) => { + asideToCallout(node); + renderYoutubeEmbed(node, index || 0, parent as HastElement | undefined); + addImageClickHandler(node); + addDescriptionUnderImage(node, index, parent as HastElement | undefined); + }, + }), [theme, addImageClickHandler]); + + const handleFieldChange = useCallback(( field: string, value: string | boolean | string[] ) => { setFormData({ [field]: value }); - }; + }, [setFormData]); + + const metadataFormData = useMemo(() => ({ + title: formData.title, + subTitle: formData.subTitle, + slug: formData.slug, + seriesId: formData.seriesId, + tags: formData.tags, + isPrivate: formData.isPrivate, + sendToSubscribers: formData.sendToSubscribers, + }), [formData.title, formData.subTitle, formData.slug, formData.seriesId, formData.tags, formData.isPrivate, formData.sendToSubscribers]); // 로컬 + 클라우드 임시저장본 병합 const getAllDrafts = (): DraftListItem[] => { @@ -227,17 +270,17 @@ const BlogForm = () => { } }; - // 임시저장본 불러오기 오버레이 열기 - const openLoadDraftOverlay = () => { + const handleOpenCreateSeries = useCallback(() => setCreateSeriesOpen(true), []); + + const openLoadDraftOverlay = useCallback(() => { setDraftListMode('load'); setDraftListOpen(true); - }; + }, []); - // 임시저장 삭제 오버레이 열기 - const openDeleteDraftOverlay = () => { + const openDeleteDraftOverlay = useCallback(() => { setDraftListMode('delete'); setDraftListOpen(true); - }; + }, []); return (
@@ -245,11 +288,11 @@ const BlogForm = () => { 글 {slug ? '수정' : '작성'} setCreateSeriesOpen(true)} + onClickNewSeries={handleOpenCreateSeries} onClickOverwrite={openLoadDraftOverlay} clearDraft={openDeleteDraftOverlay} autoSyncEnabled={autoSyncEnabled} @@ -286,48 +329,13 @@ const BlogForm = () => {
setFormData({ content: value })} - extraCommands={[ - calloutCommand, - commands.divider, - ...commands.getExtraCommands(), - ]} + onChange={handleContentChange} + extraCommands={editorExtraCommands} height={500} minHeight={500} visibleDragbar={false} data-color-mode={theme} - previewOptions={{ - wrapperElement: { - 'data-color-mode': theme, - }, - components: { - callout: ({ - emoji, - children, - }: { - emoji?: string; - children?: React.ReactNode; - }) => {children}, - } as any, - rehypeRewrite: ( - node: Root | RootContent, - index?: number, - parent?: Root | HastElement - ) => { - asideToCallout(node); - renderYoutubeEmbed( - node, - index || 0, - parent as HastElement | undefined - ); - addImageClickHandler(node); - addDescriptionUnderImage( - node, - index, - parent as HastElement | undefined - ); - }, - }} + previewOptions={editorPreviewOptions} />
) => { const value = e.target.value; if (/[가-힣]/.test(value)) { - setSlugError('한글은 입력할 수 없습니다. 영문, 숫자, 하이픈(-)만 사용하세요.'); + setSlugError( + '한글은 입력할 수 없습니다. 영문, 숫자, 하이픈(-)만 사용하세요.' + ); } else if (value && !/^[a-zA-Z0-9-]+$/.test(value)) { setSlugError('영문, 숫자, 하이픈(-)만 사용할 수 있습니다.'); } else { @@ -56,11 +58,23 @@ const PostMetadataForm = ({ onFieldChange('slug', value); }; - const { title, subTitle, slug, seriesId, tags, isPrivate, sendToSubscribers } = - formData; + const { + title, + subTitle, + slug, + seriesId, + tags, + isPrivate, + sendToSubscribers, + } = formData; - const { suggestions, isOpen, highlightedIndex, setIsOpen, setHighlightedIndex } = - useTagAutocomplete({ tagInput, currentTags: tags }); + const { + suggestions, + isOpen, + highlightedIndex, + setIsOpen, + setHighlightedIndex, + } = useTagAutocomplete({ tagInput, currentTags: tags }); const selectOptions = series.map((s) => ({ value: s._id, label: s.title, @@ -93,7 +107,10 @@ const PostMetadataForm = ({ }; const handleTagRemove = (index: number) => { - onFieldChange('tags', tags.filter((_, i) => i !== index)); + onFieldChange( + 'tags', + tags.filter((_, i) => i !== index) + ); }; const handleSelectSuggestion = (tag: string) => { @@ -181,13 +198,15 @@ const PostMetadataForm = ({ value={subTitle} />
-
+
슬  러  그  { } }; - const toggleAutoSync = (enabled: boolean) => { + const toggleAutoSync = useCallback((enabled: boolean) => { setAutoSyncEnabled(enabled); localStorage.setItem('cloudDraftAutoSync', enabled.toString()); - }; + }, []); const getCurrentDraftId = () => draftIdRef.current; diff --git a/app/hooks/post/usePost.ts b/app/hooks/post/usePost.ts index d6b85c38..412ae9f8 100644 --- a/app/hooks/post/usePost.ts +++ b/app/hooks/post/usePost.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import { StaticImport } from 'next/dist/shared/lib/get-img-props'; import { useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { getAllSeriesData } from '@/app/entities/series/api/series'; import useDraft from '@/app/hooks/post/useDraft'; import useToast from '@/app/hooks/useToast'; @@ -54,7 +54,7 @@ const usePost = (slug = '') => { const router = useRouter(); const { draft, draftImages, updateDraft, clearDraft } = useDraft(); - const postBody: PostBody = { + const postBody = useMemo(() => ({ slug: formData.slug, title: formData.title, subTitle: formData.subTitle, @@ -66,7 +66,7 @@ const usePost = (slug = '') => { tags: formData.tags, isPrivate: formData.isPrivate, sendToSubscribers: formData.sendToSubscribers, - }; + }), [formData, profileImage, thumbnailImage]); useEffect(() => { // 시리즈 @@ -209,9 +209,9 @@ const usePost = (slug = '') => { }; // Helper functions to update form data - const updateFormData = (updates: Partial) => { + const updateFormData = useCallback((updates: Partial) => { setFormData((prev) => ({ ...prev, ...updates })); - }; + }, []); return { // Form data (individual values for backward compatibility) From c34f06a392d3149d966bfa19c9d3e01843c092b0 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Fri, 22 May 2026 23:04:41 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(seo):=20legacySlug=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20slug=20=EC=98=81?= =?UTF-8?q?=EB=AC=B8=ED=99=94=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Post 모델에 legacySlug 배열 필드 추가 (구 slug 보관 및 인덱스) - 포스트 상세 페이지에서 legacySlug 조회 후 permanentRedirect 처리 - 45개 포스트 slug 영문화 마이그레이션 스크립트 추가 - Post 타입에 legacySlug?: string[] 추가 Co-Authored-By: Claude Sonnet 4.6 --- app/migrate/migrateToEnglishSlug.ts | 89 +++++++++++++++++++++++++++++ app/models/Post.ts | 6 ++ app/posts/[slug]/page.tsx | 10 +++- app/types/Post.d.ts | 1 + 4 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 app/migrate/migrateToEnglishSlug.ts diff --git a/app/migrate/migrateToEnglishSlug.ts b/app/migrate/migrateToEnglishSlug.ts new file mode 100644 index 00000000..d2357837 --- /dev/null +++ b/app/migrate/migrateToEnglishSlug.ts @@ -0,0 +1,89 @@ +import mongoose from 'mongoose'; +import Post from '@/app/models/Post'; + +const MONGODB_URI = process.env.DB_URI!; + +const SLUG_MAP: Record = { + 'next-js로-나만의-블로그-만들기': 'building-my-own-blog-with-nextjs', + '블로그-cls-성능-최적화하기': 'optimizing-blog-cls-performance', + '내가-만드는-블로그로-알아보는-seo-최적화': 'seo-optimization-with-my-blog', + 'vercel이-말아주는-이미지-cdn-사용해보기': 'using-vercel-image-cdn', + '1년된-프로젝트-리팩토링하면서-생긴-일': 'refactoring-a-1-year-old-project', + '네트워크-품질의-성능-지표에-대해서': 'preview-network-dynamic-quality-part-1', + 'webrtc-개념-정리': 'webrtc-concepts', + '다크모드-fouc-깜빡임-현상-수정하기': 'fixing-dark-mode-fouc-flash', + '미디어-장치-권한이-없을-때-대응하기': 'handling-media-device-permission-denied', + '드래그-앤-드롭-기능-예제와-성능-최적화': 'drag-and-drop-example-and-performance-optimization', + '블로그-api-최적화': 'blog-api-optimization', + '프리뷰-네트워크-기반-동적-품질-조절-기능-개발-2편': 'preview-network-dynamic-quality-part-2', + '요청을-캐시해서-돈을-아껴보자': 'save-money-by-caching-requests', + '리드미용-블로그-최신-글-뱃지-만들기': 'creating-readme-blog-latest-post-badge', + '프리뷰-네트워크-기반-동적-품질-조절-기능-개발-3편': 'preview-network-dynamic-quality-part-3', + 'fingerprintjs로-조회수와-좋아요-기능-만들기': 'implementing-views-and-likes-with-fingerprintjs', + '직접-해보면서-깨닫는-ssr과-ssg-차이': 'solving-vercel-cold-start-with-ssg', + '티스토리-블로그-확장프로그램-storyhelper-개발-회고': 'storyhelper-tistory-extension-development-retrospective', + "schema-hasn-t-been-registered-for-model-문제-해결하기": 'fixing-schema-hasnt-been-registered-for-model', + 'development-production-환경의-lighthouse-성능-점수-차이-왜-생기는-걸까': 'why-lighthouse-score-differs-between-dev-and-prod', + 'webrtc-datachannel로-실시간-미디어-상태-공유하기': 'sharing-realtime-media-state-with-webrtc-datachannel', + 'access-token-만료시-일관적인-401-에러-응답-처리': 'handling-401-error-on-access-token-expiry', + '카메라-인디케이터-라이트-항상-표시되는-오류-해결하기': 'fixing-camera-indicator-light-always-on', + 'webrtc-공감-기능-개발하기': 'video-component-rendering-optimization', + 'hoc-패턴으로-보호된-라우트-레이아웃-컴포넌트-구현': 'implementing-protected-route-layout-with-hoc', + '블로그에-유튜브-임베딩하기': 'embedding-youtube-in-blog', + '티스토리-생산성-확장프로그램-storyhelper-사용-가이드': 'storyhelper-usage-guide', + '웹-뷰-web-view-란': 'what-is-web-view', + 'promise-all-이해하기': 'understanding-promise-all', + '리액트의-render-함수와-component-방식의-차이점을-알아보자': 'react-render-function-vs-component', + 'module-federation에-대해서': 'about-module-federation', + 'vanilla-extract에-대해서-알아봅시다': 'about-vanilla-extract', + '2025년-회고-글을-작성해요': '2025-year-end-retrospective', + '레거시와-모던의-차이점과-현대화에-필요한-것': 'legacy-vs-modern-and-modernization', + 'zod로-유효성-검사를-선언적으로-관리하기': 'declarative-validation-with-zod', + 'storyhelper에-리뷰-요청하기와-삭제시-피드백-수집-기능-만들기': 'storyhelper-review-request-and-delete-feedback', + 'storyhelper-v1-6-2-패치노트': 'storyhelper-v1-6-2-patch-notes', + '장시간-ai-처리-작업의-실시간-진행률-구현-sse': 'realtime-progress-for-long-running-ai-tasks-with-sse', + 'fastapi-동기-비동기-블로킹-이슈-해결하기': 'solving-fastapi-sync-async-blocking-issues', + 'javascript의-map-자료구조에-대해서-자세하게-알아보자': 'deep-dive-into-javascript-map', + '개인-블로그에-opengraph-카드-ui-만들기': 'creating-opengraph-card-ui-for-blog', + 'fastapi는-왜-사용하는-걸까': 'why-use-fastapi-strengths-and-weaknesses', + 'storyhelper-패치-노트-v1-7-0': 'storyhelper-v1-7-0-patch-notes', + '내-블로그에-보안-헤더-적용하면서-알아보기': 'learning-security-headers-for-my-blog', + 'storyhelper-v1-7-2-패치노트': 'storyhelper-v1-7-2-patch-notes', +}; + +async function migrate() { + await mongoose.connect(MONGODB_URI); + console.log('Connected to MongoDB'); + + let updated = 0; + let skipped = 0; + + for (const [oldSlug, newSlug] of Object.entries(SLUG_MAP)) { + const post = await Post.findOne({ slug: oldSlug }); + if (!post) { + console.warn(`⚠️ Not found: ${oldSlug}`); + skipped++; + continue; + } + + const alreadyHasLegacy = post.legacySlug?.includes(oldSlug); + await Post.updateOne( + { _id: post._id }, + { + $set: { slug: newSlug }, + ...(alreadyHasLegacy ? {} : { $addToSet: { legacySlug: oldSlug } }), + } + ); + + console.log(`✅ ${oldSlug}\n → ${newSlug}`); + updated++; + } + + console.log(`\nDone. updated: ${updated}, skipped: ${skipped}`); + await mongoose.connection.close(); +} + +migrate().catch((err) => { + console.error('Migration failed:', err); + process.exit(1); +}); diff --git a/app/models/Post.ts b/app/models/Post.ts index e1be3980..66e4757f 100644 --- a/app/models/Post.ts +++ b/app/models/Post.ts @@ -61,6 +61,12 @@ const postSchema = new Schema( required: false, default: false, }, + legacySlug: { + type: [String], + required: false, + default: [], + index: true, + }, }, { timestamps: true, // createdAt, updatedAt 자동 생성 diff --git a/app/posts/[slug]/page.tsx b/app/posts/[slug]/page.tsx index 74c4c242..a809a511 100644 --- a/app/posts/[slug]/page.tsx +++ b/app/posts/[slug]/page.tsx @@ -1,4 +1,5 @@ import { Metadata } from 'next'; +import { permanentRedirect } from 'next/navigation'; import Comments from '@/app/entities/comment/Comments'; import PostActionSection from '@/app/entities/post/detail/PostActionSection'; import PostBreadcrumbJSONLd from '@/app/entities/post/detail/PostBreadcrumbJSONLd'; @@ -27,12 +28,15 @@ export const revalidate = 300; // 300초(5분)마다 재검증 async function getPostDetail(slug: string) { await dbConnect(); + const decoded = decodeURIComponent(slug); - const post = await Post.findOne({ - slug: decodeURIComponent(slug), - }).lean(); + const post = await Post.findOne({ slug: decoded }).lean(); if (!post) { + const legacyPost = await Post.findOne({ legacySlug: decoded }).lean(); + if (legacyPost) { + permanentRedirect(`/posts/${legacyPost.slug}`); + } throw new Error('Post not found'); } diff --git a/app/types/Post.d.ts b/app/types/Post.d.ts index 82e06052..264b68a6 100644 --- a/app/types/Post.d.ts +++ b/app/types/Post.d.ts @@ -17,6 +17,7 @@ interface Post { tags?: string[]; isPrivate?: boolean; sendToSubscribers?: boolean; + legacySlug?: string[]; } type PostBody = Omit; From eda2044589aea48bb7d032a68d3b6e59f832b0da Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Fri, 22 May 2026 23:08:53 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(types):=20legacyPost=20lean=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EC=97=90=20=EC=A0=9C=EB=84=A4=EB=A6=AD=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lean<{ slug: string }>() 으로 반환 타입을 지정해 TS2339 오류 해결 Co-Authored-By: Claude Sonnet 4.6 --- app/posts/[slug]/page.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/posts/[slug]/page.tsx b/app/posts/[slug]/page.tsx index a809a511..84a36026 100644 --- a/app/posts/[slug]/page.tsx +++ b/app/posts/[slug]/page.tsx @@ -33,7 +33,10 @@ async function getPostDetail(slug: string) { const post = await Post.findOne({ slug: decoded }).lean(); if (!post) { - const legacyPost = await Post.findOne({ legacySlug: decoded }).lean(); + const legacyPost = await Post.findOne( + { legacySlug: decoded }, + { slug: 1 } + ).lean<{ slug: string }>(); if (legacyPost) { permanentRedirect(`/posts/${legacyPost.slug}`); }