diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts
index 1ce1f6f..9c05c5f 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 44cef28..64afe55 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[] => {
@@ -185,6 +228,7 @@ const BlogForm = () => {
setFormData({
title: localData.title || '',
subTitle: localData.subTitle || '',
+ slug: '',
content: localData.content,
seriesId: localData.seriesId || '',
tags: localData.tags || [],
@@ -196,6 +240,7 @@ const BlogForm = () => {
setFormData({
title: cloudData.title || '',
subTitle: cloudData.subTitle || '',
+ slug: '',
content: cloudData.content,
seriesId: cloudData.seriesId || '',
tags: cloudData.tags || [],
@@ -225,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 (
@@ -243,15 +288,16 @@ const BlogForm = () => {
글 {slug ? '수정' : '작성'}
setCreateSeriesOpen(true)}
+ onClickNewSeries={handleOpenCreateSeries}
onClickOverwrite={openLoadDraftOverlay}
clearDraft={openDeleteDraftOverlay}
autoSyncEnabled={autoSyncEnabled}
onToggleAutoSync={toggleAutoSync}
+ isEditMode={isEditMode}
/>
{
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}
/>
void;
autoSyncEnabled: boolean;
onToggleAutoSync: (enabled: boolean) => void;
+ isEditMode?: boolean;
formData: {
title: string;
subTitle: string;
+ slug: string;
seriesId?: string;
tags: string[];
isPrivate: boolean;
@@ -36,15 +38,43 @@ const PostMetadataForm = ({
clearDraft,
autoSyncEnabled,
onToggleAutoSync,
+ isEditMode = false,
formData,
}: PostMetadataFormProps) => {
const [tagInput, setTagInput] = useState('');
+ const [slugError, setSlugError] = useState('');
- const { title, subTitle, seriesId, tags, isPrivate, sendToSubscribers } =
- formData;
+ 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 { suggestions, isOpen, highlightedIndex, setIsOpen, setHighlightedIndex } =
- useTagAutocomplete({ tagInput, currentTags: tags });
+ const {
+ title,
+ subTitle,
+ slug,
+ seriesId,
+ tags,
+ isPrivate,
+ sendToSubscribers,
+ } = formData;
+
+ const {
+ suggestions,
+ isOpen,
+ highlightedIndex,
+ setIsOpen,
+ setHighlightedIndex,
+ } = useTagAutocomplete({ tagInput, currentTags: tags });
const selectOptions = series.map((s) => ({
value: s._id,
label: s.title,
@@ -77,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) => {
@@ -165,6 +198,27 @@ const PostMetadataForm = ({
value={subTitle}
/>
+
+
+ 슬 러 그
+
+
+
+ {slugError && (
+ {slugError}
+ )}
@@ -280,4 +334,4 @@ const PostMetadataForm = ({
);
};
-export default PostMetadataForm;
+export default memo(PostMetadataForm);
diff --git a/app/hooks/post/useCloudDraft.ts b/app/hooks/post/useCloudDraft.ts
index b747de2..b335e84 100644
--- a/app/hooks/post/useCloudDraft.ts
+++ b/app/hooks/post/useCloudDraft.ts
@@ -1,5 +1,5 @@
import axios from 'axios';
-import { useRef, useState } from 'react';
+import { useCallback, useRef, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { CloudDraft } from '@/app/types/Draft';
@@ -61,10 +61,10 @@ const useCloudDraft = () => {
}
};
- 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 8478a2f..412ae9f 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';
@@ -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: [],
@@ -52,7 +54,8 @@ 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,
author: NICKNAME,
@@ -63,7 +66,7 @@ const usePost = (slug = '') => {
tags: formData.tags,
isPrivate: formData.isPrivate,
sendToSubscribers: formData.sendToSubscribers,
- };
+ }), [formData, profileImage, thumbnailImage]);
useEffect(() => {
// 시리즈
@@ -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 || [],
@@ -200,14 +209,15 @@ 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)
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 e49dcd1..5680366 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/migrate/migrateToEnglishSlug.ts b/app/migrate/migrateToEnglishSlug.ts
new file mode 100644
index 0000000..d235783
--- /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 e1be398..66e4757 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 74c4c24..84a3602 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,18 @@ 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 },
+ { slug: 1 }
+ ).lean<{ slug: string }>();
+ 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 2069e2e..264b68a 100644
--- a/app/types/Post.d.ts
+++ b/app/types/Post.d.ts
@@ -17,7 +17,8 @@ interface Post {
tags?: string[];
isPrivate?: boolean;
sendToSubscribers?: boolean;
+ legacySlug?: string[];
}
-type PostBody = Omit;
+type PostBody = Omit;
export { Post, PostBody };