๐ฏ ๋ชฉํ
- Storage ๋ณด์ ๊ท์น์ผ๋ก ์ฌ์ฉ์ ๋๋ ํฐ๋ฆฌ ๋จ์ ๊ถํ ์ค์
- ์ธ๋ค์ผ ์ ๋ก๋/๋ฏธ๋ฆฌ๋ณด๊ธฐ/๊ต์ฒด/์ญ์ ๊ตฌํ
- Firestore
posts๋ฌธ์์thumbUrl,thumbPathํ๋ ์ ์ฅ/๋๊ธฐํ
0) Storage ๊ท์น (๊ฐ๋ฐ์ฉ โ ์ค์ ์๋ ์ฌ์ฉ ๊ฐ๋ฅ)
Firebase Console โ Storage โ ๊ท์น์ ์ ์ฉ:
// Storage Rules
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
// ๊ฐ ์ฌ์ฉ์์ ์ ์ฉ ํด๋
match /users/{uid}/{allPaths=**} {
// ์ฝ๊ธฐ: ๊ณต๊ฐ ์๋น์ค๋ฉด true, ๋ด๋ถ ์๋น์ค๋ฉด ์ ํ ๊ฐ๋ฅ
allow read: if true;
// ์ฐ๊ธฐ/์ญ์ : ๋ณธ์ธ๋ง ๊ฐ๋ฅ
allow write: if request.auth != null && request.auth.uid == uid;
}
}
}
๋ชฉ์
users/{uid}/**๊ฒฝ๋ก์ ๋ํด ์์ ์๋ง ์ฐ๊ธฐ/์ญ์ ๊ฐ๋ฅ, ๊ณต๊ฐ ์ฝ๊ธฐ๋ ํ์ฉ(ํ์ ์ ์ ํ)ํฉ๋๋ค.
๋์ ์๋ฆฌ
match /users/{uid}/{allPaths=**}: ์ฌ์ฉ์๋ณ ์ต์์ ๋๋ ํฐ๋ฆฌ๋ฅผ UID๋ก ๋ค์์คํ์ด์คํํฉ๋๋ค.allow read: if true;: ํ์ต/๋ฐ๋ชจ ๋ชฉ์ ์ ๊ณต๊ฐ ์ฝ๊ธฐ. ์ค์ ์์๋ ๋ค์ด๋ก๋ ๊ถํ์ ์ธ๋ถํ(์: ํฌ์คํ ์ธ๋ค์ผ๋ง ๊ณต๊ฐ)ํ๋ ๊ฒ์ด ์ข์ต๋๋ค.allow write: if request.auth != null && request.auth.uid == uid;โ ๋ก๊ทธ์ธ + ์์ ์ UID ๊ฒฝ๋ก์์๋ง ์ ๋ก๋/์ญ์ ๊ฐ๋ฅ. ํ์ธ์ ๊ฒฝ๋ก ์ ๊ทผ์ ์ฐจ๋จ๋ฉ๋๋ค.
๊ฒฝ๋ก ์ ๋ต
users/{uid}/posts/{postId}/{filename}โ ๊ธ ๋จ์๋ก ํด๋๋ฅผ ๋ถ๋ฆฌํด ์ ๋ฆฌ/์ ์ฑ /์ญ์ (์ ๋ฆฌ) ์์ ์ด ๊ฐ๋จํด์ง๋๋ค.
์ค์ ํ
- ์ธ๋ค์ผ๋ง ๊ณต๊ฐํ ๊ฒฝ์ฐ, ์ธ๋ค์ผ ๊ฒฝ๋ก๋ง ๊ณต๊ฐ ์ฝ๊ธฐ, ์๋ณธ์ ๋น๊ณต๊ฐ๋ก ๋๊ณ ์๋ฒ(๋๋ Cloud Functions)๋ก ์๋ช URL์ ๋ฐ๊ธํ๋ ๋ฐฉ์๋ ๊ณ ๋ คํ์ธ์.

1) ์ ๋ก๋/์ญ์ ์ ํธ
lib/storage.ts
'use client';
import { storage } from '@/lib/firebase.client';
import { ref, uploadBytes, getDownloadURL, deleteObject } from 'firebase/storage';
export async function uploadImageByPath(file: File, fullPath: string) {
const fileRef = ref(storage, fullPath);
const snap = await uploadBytes(fileRef, file, {
contentType: file.type || 'application/octet-stream',
cacheControl: 'public,max-age=31536000,immutable',
});
const url = await getDownloadURL(snap.ref);
return { url, path: snap.ref.fullPath };
}
export async function deleteByPath(path?: string) {
if (!path) return;
try {
await deleteObject(ref(storage, path));
} catch (e) {
// ์๋ ํ์ผ์ผ ์ ์์ผ๋ฏ๋ก ์กฐ์ฉํ ๋ฌด์
console.warn('deleteByPath warning:', e);
}
}
uploadImageByPath(file, fullPath)
- ์ญํ : ์ฃผ์ด์ง
fullPath๋ก ํ์ผ์ ์ ๋ก๋ํ๊ณ , ์ฆ์ ๋ค์ด๋ก๋ URL๊ณผ fullPath๋ฅผ ๋ฐํํฉ๋๋ค. - ์
๋ก๋ ์ต์
contentType: ๋ธ๋ผ์ฐ์ ๋ฏธ๋ฆฌ๋ณด๊ธฐยท์บ์ ๋์์ ์ํด ์ง์ cacheControl: public,max-age=31536000,immutableโ ์ธ๋ค์ผ์ ๋ณ๊ฒฝ๋ณด๋ค ์ถ๊ฐ/๊ต์ฒด๊ฐ ๋ง์ผ๋ฏ๋ก ๊ฐํ ์บ์ ์ ๋ต์ด ์ ๋ฆฌํฉ๋๋ค(๊ต์ฒด ์ ์ ํ์ผ๋ช ์ผ๋ก ์บ์ ๋ฌดํจํ).
deleteByPath(path)
- ์ญํ :
fullPath๋ก ๊ฐ์ฒด ์ญ์ . - ์์ธ ์ฒ๋ฆฌ: โ์ด๋ฏธ ์์ด์ง ํ์ผโ์ผ ์ ์์ผ๋ฏ๋ก catch ํ ๊ฒฝ๊ณ ๋ง ๊ธฐ๋กํ๊ณ ๋ฌด์ํฉ๋๋ค(UX ์ ํด ๋ฐฉ์ง)
2) ์ด๋ฏธ์ง ์ ํ ์ปดํฌ๋ํธ (๋ฏธ๋ฆฌ๋ณด๊ธฐ ํฌํจ)
components/ImagePicker.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
type Props = {
initialUrl?: string;
onFileChange: (file: File | null) => void;
accept?: string;
maxSizeMB?: number;
};
export default function ImagePicker({
initialUrl,
onFileChange,
accept = 'image/*',
maxSizeMB = 5,
}: Props) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [preview, setPreview] = useState<string | undefined>(initialUrl);
const [err, setErr] = useState<string | null>(null);
useEffect(() => setPreview(initialUrl), [initialUrl]);
const handlePick = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
if (!file) {
onFileChange(null);
setPreview(initialUrl);
return;
}
if (!file.type.startsWith('image/')) {
setErr('์ด๋ฏธ์ง ํ์ผ๋ง ์
๋ก๋ํ ์ ์์ต๋๋ค.');
onFileChange(null);
return;
}
if (file.size > maxSizeMB * 1024 * 1024) {
setErr(`ํ์ผ ์ฉ๋์ ์ต๋ ${maxSizeMB}MB๊น์ง ๊ฐ๋ฅํฉ๋๋ค.`);
onFileChange(null);
return;
}
setErr(null);
onFileChange(file);
const url = URL.createObjectURL(file);
setPreview(url);
};
const clear = () => {
if (inputRef.current) inputRef.current.value = '';
onFileChange(null);
setPreview(initialUrl);
};
return (
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input ref={inputRef} type="file" accept={accept} onChange={handlePick} />
{preview && (
<button type="button" onClick={clear} style={{ padding: '6px 10px', borderRadius: 8 }}>
์ ํ ํด์
</button>
)}
</div>
{err && <p style={{ color: 'crimson', fontSize: 13 }}>{err}</p>}
{preview && (
<img src={preview}
alt="preview"
style={{ width: 220, height: 140, objectFit: 'cover', borderRadius: 10, border: '1px solid #eee' }}
/>
)}
</div>
);
}
์ญํ
- ์ฌ์ฉ์๊ฐ ํ์ผ์ ์ ํํ๋ฉด ์ ํจ์ฑ ๊ฒ์ฌ(ํ์
/์ฉ๋) โ ๋ฏธ๋ฆฌ๋ณด๊ธฐ URL ์์ฑ โ ๋ถ๋ชจ์
File์ ๋ฌ. initialUrl์ด ์์ผ๋ฉด(์์ ํ๋ฉด) ์ต์ด ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ก ํ์ํฉ๋๋ค.
ํ๋ฆ
input[type=file]๋ณ๊ฒฝ ์ด๋ฒคํธ์์ ํ์ผ ์ถ์ถ- ์ด๋ฏธ์ง ํ์
์ธ์ง ํ์ธ (
file.type.startsWith('image/')) - ์ต๋ ์ฉ๋(MB) ์ ํ ๊ฒ์ฌ
- ํต๊ณผ ์
URL.createObjectURL(file)๋ก ๋ก์ปฌ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์์ฑ - โ์ ํ ํด์ โ ํด๋ฆญ ์ ์ ํ ์ทจ์ + ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ด๊ธฐํ
UX ํ
- ๋ชจ๋ฐ์ผ ํ๊ฒฝ์์ ๋์ฉ๋ ์ ๋ก๋๋ฅผ ๋ฐฉ์งํ๋ ค๋ฉด
maxSizeMB๋ฅผ ๋ฎ๊ฒ ์ค์ ํ๊ฑฐ๋ ํด๋ผ์ด์ธํธ ๋ฆฌ์ฌ์ด์ฆ(Canvas/Compressor) ์ ๋ต์ ์ถ๊ฐํด๋ ์ข์ต๋๋ค.
3) ํ์ ํ์ฅ
types/post.ts
export type Post = {
id: string;
uid: string;
title: string;
content?: string;
isPublic: boolean;
thumbUrl?: string; // ์ธ๋ค์ผ URL
thumbPath?: string; // Storage fullPath (์ญ์ /๊ต์ฒด์ฉ)
createdAt?: { seconds: number; nanoseconds: number } | null;
updatedAt?: { seconds: number; nanoseconds: number } | null;
};
์ถ๊ฐ ํ๋
thumbUrl?: string: Storage์ ๊ณต๊ฐ(๋๋ ์ ๊ทผ ๊ฐ๋ฅํ) URLthumbPath?: string: fullPath(์:users/{uid}/posts/{postId}/...) โ ์ญ์ /๊ต์ฒด ์ ํ์
์ค๊ณ ํฌ์ธํธ
- URL์ ์บ์ ๋์์ด๋ฏ๋ก, ์ด๋ฏธ์ง ๊ต์ฒด ์ ํ์ผ๋ช ์ ๋ฐ๊ฟ ์ ๋ก๋ํ๊ณ ๋ฌธ์์
thumbUrl๋ง ๊ฐฑ์ ํ๋ฉด ์บ์ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.
4) PostForm์ ์ด๋ฏธ์ง ํ๋ ์ถ๊ฐ
components/PostForm.tsx (๋ณ๊ฒฝ)
'use client';
import { useState, FormEvent } from 'react';
import ImagePicker from './ImagePicker';
type Props = {
initial?: {
title?: string;
content?: string;
isPublic?: boolean;
thumbUrl?: string;
};
submitText?: string;
onSubmit: (data: {
title: string;
content: string;
isPublic: boolean;
file: File | null; // ์ถ๊ฐ: ์
๋ก๋ํ ํ์ผ
}) => Promise<void>;
};
export default function PostForm({
initial,
submitText = '์ ์ฅ',
onSubmit,
}: Props) {
const [title, setTitle] = useState(initial?.title ?? '');
const [content, setContent] = useState(initial?.content ?? '');
const [isPublic, setIsPublic] = useState(initial?.isPublic ?? true);
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const handle = async (e: FormEvent) => {
e.preventDefault();
if (!title.trim()) return setErr('์ ๋ชฉ์ ์
๋ ฅํ์ธ์.');
setErr(null);
setLoading(true);
try {
await onSubmit({
title: title.trim(),
content: content.trim(),
isPublic,
file,
});
} catch (e: any) {
setErr(e?.message ?? '์ ์ฅ ์คํจ');
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handle} style={{ display: 'grid', gap: 12 }}>
<input
placeholder='์ ๋ชฉ'
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ padding: 10, border: '1px solid #ddd', borderRadius: 8 }}
/>
<textarea
placeholder='๋ด์ฉ(์ ํ)'
value={content}
onChange={(e) => setContent(e.target.value)}
rows={8}
style={{ padding: 10, border: '1px solid #ddd', borderRadius: 8 }}
/>
{/* ์ธ๋ค์ผ ์
๋ก๋ */}
<div>
<label style={{ display: 'block', marginBottom: 6, fontWeight: 600 }}>
์ธ๋ค์ผ
</label>
<ImagePicker initialUrl={initial?.thumbUrl} onFileChange={setFile} />
</div>
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type='checkbox'
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
/>
๊ณต๊ฐ๊ธ๋ก ๋ฑ๋ก
</label>
{err && <p style={{ color: 'crimson', fontSize: 14 }}>{err}</p>}
<button
disabled={loading}
style={{ padding: '10px 12px', borderRadius: 8 }}
>
{loading ? '์ฒ๋ฆฌ ์คโฆ' : submitText}
</button>
</form>
);
}
๋ณ๊ฒฝ์
onSubmit์file: File | null์ถ๊ฐ โ ํผ ๋จ์์ ์ด๋ฏธ์ง ์ ํ ์ฌ๋ถ๋ฅผ ์์๋ก ์ ๋ฌ.ImagePicker๋ฅผ ํฌํจํด ์ต์ด ์ธ๋ค์ผ(initial.thumbUrl) ๋ฏธ๋ฆฌ๋ณด๊ธฐ์ ๊ต์ฒด๋ฅผ ์ง์.
์ ์ถ ํ๋ฆ
- ์ ๋ชฉ ๊ณต๋ฐฑ ์ฒดํฌ
onSubmit({ title, content, isPublic, file })ํธ์ถ- ์์(ํ์ด์ง)์์ ์ ๋ก๋/๋ฌธ์ ์ ๋ฐ์ดํธ๋ฅผ ์ฒ๋ฆฌ โ ๊ด์ฌ์ฌ ๋ถ๋ฆฌ
5) ์ ๊ธ ์์ฑ: ์ ๋ก๋ โ ๋ฌธ์ ์์ฑ
app/posts/new/page.tsx (์
๋ฐ์ดํธ)
'use client';
import { useRouter } from 'next/navigation';
import { addDoc, collection, serverTimestamp } from 'firebase/firestore';
import { db } from '@/lib/firebase.client';
import { uploadImageByPath } from '@/lib/storage';
import PostForm from '@/components/PostForm';
import { useAuth } from '@/hooks/useAuth';
export default function NewPostPage() {
const router = useRouter();
const { user, loading } = useAuth();
if (loading) return null;
if (!user) {
router.replace('/login');
return null;
}
const create = async (data: {
title: string;
content: string;
isPublic: boolean;
file: File | null;
}) => {
// ์ผ๋จ ๋ฌธ์๋ถํฐ ๋ง๋ค๊ณ postId๋ฅผ ์ป์ด ๊ฒฝ๋ก๋ฅผ ์์ ์ ์ผ๋ก ๊ตฌ์ฑ
const docRef = await addDoc(collection(db, 'posts'), {
uid: user.uid,
title: data.title,
content: data.content,
isPublic: data.isPublic,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
thumbUrl: null,
thumbPath: null,
});
let thumbUrl: string | null = null;
let thumbPath: string | null = null;
if (data.file) {
const fileName = `${Date.now()}-${data.file.name}`;
const fullPath = `users/${user.uid}/posts/${docRef.id}/${fileName}`;
const uploaded = await uploadImageByPath(data.file, fullPath);
thumbUrl = uploaded.url;
thumbPath = uploaded.path;
// ์ธ๋ค์ผ ์ ๋ณด๋ง ์
๋ฐ์ดํธ
await (
await import('firebase/firestore')
).updateDoc(docRef, {
thumbUrl,
thumbPath,
updatedAt: serverTimestamp(),
});
}
router.replace(`/posts/${docRef.id}`);
};
return (
<main style={{ maxWidth: 720, margin: '40px auto', padding: 16 }}>
<h1>์ ๊ธ ์์ฑ</h1>
<PostForm onSubmit={create} submitText='๋ฑ๋ก' />
</main>
);
}
ํต์ฌ ์ ๋ต
๋จผ์ ๋ฌธ์ ์์ฑ โ postId ํ๋ณด โ ๊ทธ postId๋ก ๊ฒฝ๋ก ๊ตฌ์ฑํ์ฌ ์ ๋ก๋ โ ๋ฌธ์ ์ ๋ฐ์ดํธ
โ ๊ฒฝ๋ก๊ฐ ์์ ์ ์ด๊ณ , ์ค๊ฐ ์คํจ ์ ๋กค๋ฐฑ(๋ฌธ์ ์ญ์ )๋ ๋ช ํํฉ๋๋ค.
ํ๋ฆ
- ๋ก๊ทธ์ธ ํ์ธ(๋ฏธ๋ก๊ทธ์ธ ์
/login์ผ๋กreplace) addDoc์ผ๋ก ๋ฌธ์ ์์ฑ(์ด๊ธฐthumbUrl/thumbPath๋null)- ํ์ผ์ด ์์ผ๋ฉด
users/{uid}/posts/{docId}/{timestamp-name}๋ก ์ ๋ก๋- ์
๋ก๋ ์ฑ๊ณต ์
thumbUrl,thumbPath๋ฅผ updateDoc์ผ๋ก ๋ฐ์
- ์
๋ก๋ ์ฑ๊ณต ์
- ์๋ฃ ํ ์์ธ ํ์ด์ง๋ก
replace
์ค์ ํ
- ์ ๋ก๋ ์คํจ ์ ๋ฌธ์๋ง ๋จ๋ ์ํฉ์ ํผํ๋ ค๋ฉด, try/catch๋ก ์ ๋ก๋ ์คํจ ์ ๋ฌธ์๋ฅผ ์ญ์ ํ๊ฑฐ๋
thumb์๋ ์ํ๋ ์ ํจํ ์คํค๋ง๋ก ์ธ์ ํ์ธ์.
6) ์์ธ/์์ : ๊ต์ฒด ์ ๋ก๋ + ๊ธฐ์กด ํ์ผ ์ญ์
app/posts/[id]/page.tsx (์ค์ ๋ถ๋ถ๋ง ๊ฐฑ์ )
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { auth, db } from '@/lib/firebase.client';
import {
deleteDoc,
doc,
getDoc,
updateDoc,
serverTimestamp,
} from 'firebase/firestore';
import { onAuthStateChanged } from 'firebase/auth';
import type { Post } from '@/types/post';
import PostForm from '@/components/PostForm';
import { deleteByPath, uploadImageByPath } from '@/lib/storage';
export default function PostDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const [post, setPost] = useState<Post | null>(null);
const [uid, setUid] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
useEffect(() => onAuthStateChanged(auth, (u) => setUid(u?.uid ?? null)), []);
useEffect(() => {
const load = async () => {
const snap = await getDoc(doc(db, 'posts', id));
if (!snap.exists()) return router.replace('/posts');
setPost({ id: snap.id, ...(snap.data() as any) });
};
load();
}, [id, router]);
const isOwner = uid && post && uid === post.uid;
const update = async (data: {
title: string;
content: string;
isPublic: boolean;
file: File | null;
}) => {
if (!post) return;
let nextThumbUrl = post.thumbUrl ?? null;
let nextThumbPath = post.thumbPath ?? null;
// ํ์ผ ๊ต์ฒด๊ฐ ์์ผ๋ฉด ์๋ก ์
๋ก๋ โ ์ด์ ํ์ผ ์ญ์
if (data.file && uid) {
const fileName = `${Date.now()}-${data.file.name}`;
const fullPath = `users/${uid}/posts/${post.id}/${fileName}`;
const uploaded = await uploadImageByPath(data.file, fullPath);
// ์ด์ ํ์ผ ์ญ์ (์์ผ๋ฉด)
if (post.thumbPath && post.thumbPath !== uploaded.path) {
await deleteByPath(post.thumbPath);
}
nextThumbUrl = uploaded.url;
nextThumbPath = uploaded.path;
}
await updateDoc(doc(db, 'posts', id), {
title: data.title,
content: data.content,
isPublic: data.isPublic,
thumbUrl: nextThumbUrl,
thumbPath: nextThumbPath,
updatedAt: serverTimestamp(),
});
setEditing(false);
const snap = await getDoc(doc(db, 'posts', id));
setPost({ id: snap.id, ...(snap.data() as any) });
};
const remove = async () => {
if (!post) return;
if (!confirm('์ญ์ ํ์๊ฒ ์ต๋๊น?')) return;
// ํ์ผ ๋จผ์ ์ ๋ฆฌ(์์ผ๋ฉด)
await deleteByPath(post.thumbPath);
await deleteDoc(doc(db, 'posts', id));
router.replace('/posts');
};
if (!post) return null;
return (
<main style={{ maxWidth: 720, margin: '40px auto', padding: 16 }}>
{!editing ? (
<>
<h1>{post.title}</h1>
{post.thumbUrl && (
<img
src={post.thumbUrl}
alt='thumbnail'
style={{
width: '100%',
maxWidth: 720,
borderRadius: 12,
margin: '12px 0',
}}
/>
)}
{post.content && (
<p style={{ whiteSpace: 'pre-wrap', marginTop: 12 }}>
{post.content}
</p>
)}
<div style={{ fontSize: 12, opacity: 0.7, marginTop: 8 }}>
{post.isPublic ? '๊ณต๊ฐ' : '๋น๊ณต๊ฐ'} ยท ์์ฑ์ {post.uid.slice(0, 6)}โฆ
</div>
{isOwner && (
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button
onClick={() => setEditing(true)}
style={{ padding: '8px 12px', borderRadius: 8 }}
>
์์
</button>
<button
onClick={remove}
style={{
padding: '8px 12px',
borderRadius: 8,
border: '1px solid #f33',
color: '#f33',
}}
>
์ญ์
</button>
</div>
)}
</>
) : (
<>
<h1>๊ธ ์์ </h1>
<PostForm
initial={{
title: post.title,
content: post.content,
isPublic: post.isPublic,
thumbUrl: post.thumbUrl,
}}
submitText='์์ ์๋ฃ'
onSubmit={update}
/>
<button onClick={() => setEditing(false)} style={{ marginTop: 12 }}>
์ทจ์
</button>
</>
)}
</main>
);
}
์์ (๊ต์ฒด) ํ๋ฆ
- ์ ํ์ผ์ ์ ํํ ๊ฒฝ์ฐ
- ์ ํ์ผ๋ช (timestamp-prefix)์ผ๋ก ์ ๋ก๋
- ์
๋ก๋ ์ฑ๊ณต ํ, ์ด์
thumbPath๊ฐ ์กด์ฌํ๊ณ ์๋ก ๋ค๋ฅด๋ฉดdeleteByPath(oldPath)๋ก ์ ๋ฆฌ - ๋ฌธ์์
thumbUrl,thumbPath์ ๋ฐ์ดํธ
- ํ์ผ์ ๋ฐ๊พธ์ง ์์ ๊ฒฝ์ฐ
- ํ
์คํธ ํ๋๋ง ์
๋ฐ์ดํธ (
updatedAt๊ฐฑ์ )
- ํ
์คํธ ํ๋๋ง ์
๋ฐ์ดํธ (
์ญ์ ํ๋ฆ
- ๋ฌธ์ ์ญ์ ์ , ์ฐ๊ฒฐ๋ ํ์ผ์ด ์์ผ๋ฉด
deleteByPath(post.thumbPath)๋ก ๋จผ์ ์ ๊ฑฐ deleteDoc์ผ๋ก ๋ฌธ์ ์ญ์ โ ๋ชฉ๋ก์ผ๋ก ์ด๋
์ ํฉ์ฑ ํฌ์ธํธ
- โํ์ผ ๋จผ์ ์ญ์ โ ๋ฌธ์ ์ญ์ โ ์์๋ก ์งํ ์, ๋ฌธ์ ์ญ์ ์คํจ์๋ ๊ณ ์ ํ์ผ์ด ์ค์ด๋ญ๋๋ค.
- ๋ฐ๋๋ก โ๋ฌธ์ ๋จผ์ ์ญ์ โ ์ ์ ๋ฆฌ ๋ฐฐ์น(Cloud Functions)๋ก ๋จ์ ํ์ผ์ ์ญ์ ํ๋ ๋น๋๊ธฐ ์ ๋ฆฌ ์ ๋ต๋ ๊ฐ๋ฅํฉ๋๋ค.
7) ๋ชฉ๋ก์์ ์ธ๋ค์ผ ๋ ธ์ถ(์ ํ)
app/posts/page.tsx์ ๋ฆฌ์คํธ ์์ดํ
์ ์ธ๋ค์ผ ์ถ๊ฐ:
{posts.map((p) => (
<li
key={p.id}
style={{
display: 'flex',
gap: 12,
border: '1px solid #eee',
borderRadius: 8,
padding: 12,
}}
>
{p.thumbUrl && (
<img
src={p.thumbUrl}
alt=''
style={{
width: 96,
height: 64,
objectFit: 'cover',
borderRadius: 8,
}}
/>
)}
<div style={{ display: 'grid' }}>
<Link href={`/posts/${p.id}`} style={{ fontWeight: 600 }}>
{p.title}
</Link>
<div style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
{p.isPublic ? '๊ณต๊ฐ' : '๋น๊ณต๊ฐ'} ยท {p.uid.slice(0, 6)}โฆ
</div>
</div>
</li>
))}
๋ชฉ์
- ๋ชฉ๋ก์์
thumbUrl์ด ์์ผ๋ฉด ์์ ์ธ๋ค์ผ์ ํจ๊ป ๋ ธ์ถ โ ์๊ฐ์ ํ์์ฑ ํฅ์. - ์ด๋ฏธ์ง ํฌ๊ธฐ๋ ๊ณ ์ (์: 96ร64) +
objectFit: 'cover'๋ก ์์ ์ ์ธ ์นด๋ ๋ ์ด์์ ์ ์ง.
์ฑ๋ฅ ํ
- ๋ค๊ฑด ๋ชฉ๋ก์ ์ด๋ฏธ์ง ๋ก๋ฉ์๋
loading="lazy"(Next Image ์ฌ์ฉ ์loading="lazy"์๋)์ ์ ํ์ง ํ๋ฆฌ๋ทฐ(LQIP) ์ ๋ต์ ๊ณ ๋ คํ์ธ์.
โ ์ฒดํฌ๋ฆฌ์คํธ
- Storage ๊ท์น ์ ์ฉ ํ
users/{uid}/...๊ฒฝ๋ก์ ์ ์ ์ ๋ก๋/์ญ์ ๊ฐ๋ฅ - ์ ๊ธ ์์ฑ ์ ์ด๋ฏธ์ง ์ ํ โ ์
๋ก๋ โ
thumbUrl,thumbPath์ ์ฅ - ์์ ์ ์ ์ด๋ฏธ์ง๋ก ๊ต์ฒดํ๋ฉด ์ด์ ํ์ผ ์ญ์
- ๊ธ ์ญ์ ์ ์ธ๋ค์ผ ํ์ผ๋ ํจ๊ป ์ญ์
.png?alt=media&token=63ab55e7-59f5-4bf7-97f4-91ae6f3eaa07)
์ธ๋ค์ผ์ด ์ ๋ฑ๋ก๋๋ ๊ฒ๊น์ง ํ์ธ!
๐ฌ ๋๊ธ
โป ๋ก๊ทธ์ธ ํ ๋๊ธ์ ์์ฑํ ์ ์์ต๋๋ค.