๐ฏ ๋ชฉํ
- ์ฌ์ฉ์๋ณ ๋ฌธ์ ์์ (UID) ๊ตฌ์กฐ ์ค๊ณ
- ๊ณต๊ฐ/๋น๊ณต๊ฐ(isPublic) ํ๋๋ก ์ ๊ทผ ์ ์ด
- ๋ชฉ๋กโง๋ฑ๋กโง์์ธโง์์ โง์ญ์ ๊ตฌํ
- ์ค์๊ฐ(onSnapshot) ๋ชฉ๋ก ๋ฐ์
0) Firestore ๊ท์น(๊ฐ๋ฐโ์ค์ ํ์ผ๋ก ๊ฐํ)
Firestore Rules
// ์ฝ์ > Firestore Database > ๊ท์น
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /posts/{postId} {
// ์ฝ๊ธฐ: ๊ณต๊ฐ๊ธ์ด๊ฑฐ๋, ๋ด ๊ธ์ด๋ฉด ํ์ฉ
allow read: if resource.data.isPublic == true
|| (request.auth != null && request.auth.uid == resource.data.uid);
// ์์ฑ: ๋ก๊ทธ์ธ ์ํ์ด๋ฉฐ, ๋ณธ์ธ UID๋ก๋ง ์์ฑ ๊ฐ๋ฅ
allow create: if request.auth != null
&& request.resource.data.uid == request.auth.uid
&& request.resource.data.title is string
&& request.resource.data.title.size() > 0
&& request.resource.data.title.size() <= 120;
// ์์ /์ญ์ : ์์ ์๋ง
allow update, delete: if request.auth != null
&& request.auth.uid == resource.data.uid;
}
}
}
์ด ๊ท์น์ด ๋ณด์ฅํ๋ ๊ฒ
- ์์ ๊ถ(UID) ๊ธฐ๋ฐ ์ ๊ทผ ์ ์ด: ๋ฌธ์์
uid๊ฐrequest.auth.uid์ ๊ฐ์ ๋๋ง ์ฐ๊ธฐ(์์ /์ญ์ ) ํ์ฉ - ๊ณต๊ฐ/๋น๊ณต๊ฐ:
isPublic == true๋ฉด ๋๊ตฌ๋ ์ฝ๊ธฐ ํ์ฉ, ์๋๋ฉด ์์ ์๋ง ์ฝ๊ธฐ ๊ฐ๋ฅ - ์์ฑ ์ ํจ์ฑ: ๋ก๊ทธ์ธ ์ํ์์๋ง ์์ฑ, ๊ทธ๋ฆฌ๊ณ ํด๋ผ์ด์ธํธ๊ฐ ๋ณด๋ธ
uid๊ฐ ๋ณธ์ธ UID์ ๋์ผํด์ผ ํจ.title์ ๋ฌธ์์ด/๊ธธ์ด ์ ํ ๊ฒ์ฌ.
์ธ๋ถ ํด์ค
allow read: if resource.data.isPublic == true || (request.auth != null && request.auth.uid == resource.data.uid);โ ๋น๊ณต๊ฐ ๊ธ์ ๋ฌธ์ ์์ ์๋ง ์ฝ๊ธฐ. ๊ณต๊ฐ ๊ธ์ ์ ์ฒด ์ฝ๊ธฐ ๊ฐ๋ฅ.
allow create: if request.auth != null && request.resource.data.uid == request.auth.uid && ...โ ์์ ์ฐ๊ธฐ ๋ฐฉ์ง(๋จ์ UID๋ก ๋ฌธ์ ์์ฑ ์ฐจ๋จ).
title์ ํ์ ยท๊ธธ์ด ์ฒดํฌ๋ก ๊ธฐ๋ณธ ๋ฐธ๋ฆฌ๋ฐ์ด์ ๊น์ง ์ํ.allow update, delete: if request.auth != null && request.auth.uid == resource.data.uid;โ ๋ฌธ์๊ฐ ์ด๋ฏธ ์กด์ฌํ๋ฏ๋ก
resource.data.uid(๊ธฐ์กด ๋ฌธ์์ uid) ๊ธฐ์ค์ผ๋ก ์์ ์๋ง ์์ /์ญ์ ํ์ฉ.
ํ
- ๊ท์น์์
serverTimestamp()๋ ํ์ ์ดtimestamp๋ก ์ ์ฅ๋ฉ๋๋ค. ๋ชฉ๋ก ์ ๋ ฌ ์createdAt์ดnull์ผ ์ ์์ผ๋ ์ด๊ธฐ ๋ ๋์์null์์ ํ๊ฒ ์ฒ๋ฆฌํ์ธ์.- ์ค์๋น์ค์์๋
title์ธ์๋content๊ธธ์ด, ๊ธ์น์ด ๋ฑ ์ถ๊ฐ ๊ฒ์ฌ๋ก ์ ์ฑ ์ ๋ ฅ์ ์ค์ผ ์ ์์ต๋๋ค.
ํฌ์ธํธ
- ๋ชจ๋ ๋ฌธ์์
uid,isPublic,createdAt,updatedAt๋ฅผ ์ ์งํฉ๋๋ค.- ๊ณต๊ฐ๊ธ์ ๋ชจ๋ ์ฝ๊ธฐ ๊ฐ๋ฅ, ๋น๊ณต๊ฐ๋ ์์ ์๋ง ์ฝ๊ธฐ/์์ /์ญ์ ๊ฐ๋ฅ.

1) ํ์ ์ ์
- Firestore ๋ฌธ์ ์คํค๋ง๋ฅผ TypeScript ํ์ ์ผ๋ก ๊ณ ์ ํด ๋น๋ํ์์ ์ค๋ฅ๋ฅผ ์ค์ ๋๋ค.
createdAt/updatedAt๋ Firestore Timestamp ๊ฐ์ฒด(seconds/nanoseconds). ๋ ๋ ์new Date(seconds*1000)๋ฑ์ผ๋ก ๋ณํํด ์ฌ์ฉํฉ๋๋ค.
types/post.ts
export type Post = {
id: string;
uid: string;
title: string;
content?: string;
isPublic: boolean;
createdAt?: { seconds: number; nanoseconds: number } | null;
updatedAt?: { seconds: number; nanoseconds: number } | null;
};
ํฌ์ธํธ
id๋ ๋ฌธ์ ID(์ปฌ๋ ์ ๋ด๋ถ ํค). Firestore์์doc.id๋ก ํ๋.content๋ ์ ํ ํ๋(?). ์๋ํฐ/์์ฝ ๋ชฉ๋ก์์ undefined ๋ฐฉ์ด ํ์.
2) ๊ธ ์์ฑ ํผ(์ฌ์ฌ์ฉ ์ปดํฌ๋ํธ)
components/PostForm.tsx
'use client';
import { useState, FormEvent } from 'react';
type Props = {
initial?: { title?: string; content?: string; isPublic?: boolean };
submitText?: string;
onSubmit: (data: {
title: string;
content: string;
isPublic: boolean;
}) => 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 [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,
});
} 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 }}
/>
<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๋ง ์ฃผ์ ๋ฐ์ ๋์ํฉ๋๋ค.
๋์ ํ๋ฆ
- ์ด๊ธฐ๊ฐ(
initial)์ ์ํ์ ์ฃผ์ โ ๋ฑ๋ก/์์ ๋ ๋ค ์ง์ - ์ ์ถ ์:
title๊ณต๋ฐฑ ๊ฒ์ฌ(UX ๋ ๋ฒจ 1์ฐจ ๋ฐธ๋ฆฌ๋ฐ์ด์ )onSubmit({ title, content, isPublic })ํธ์ถ- ์ค๋ฅ ๋ฐ์ ์
err์ํ์ ๋ฉ์์ง ํ์
- ๋ฒํผ
disabled={loading}๋ก ์ค๋ณต ์ ์ถ ๋ฐฉ์ง
์ฅ์
- ๋น์ฆ๋์ค ๋ก์ง(Firestore add/update)์ ํ์ด์ง์์ ๋ด๋น, UI๋ ํผ ์ปดํฌ๋ํธ๋ก ๋ถ๋ฆฌ โ ๊ด์ฌ์ฌ ๋ถ๋ฆฌ๋ก ์ ์ง๋ณด์ ์ฉ์ด.
3) ๋ชฉ๋ก ํ์ด์ง(์ค์๊ฐ, ๊ณต๊ฐ/๋ด ๊ธ ํ ๊ธ)
app/posts/page.tsx
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { auth, db } from '@/lib/firebase.client';
import { collection, onSnapshot, orderBy, query, where } from 'firebase/firestore';
import type { Post } from '@/types/post';
import { onAuthStateChanged } from 'firebase/auth';
export default function PostsPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [mine, setMine] = useState(false);
const [uid, setUid] = useState<string | null>(null);
useEffect(() => {
const unsubAuth = onAuthStateChanged(auth, (u) => setUid(u ? u.uid : null));
return () => unsubAuth();
}, []);
const qRef = useMemo(() => {
const base = collection(db, 'posts');
// ๊ณต๊ฐ๊ธ๋ง or ๋ด ๊ธ๋ง
return mine && uid
? query(base, where('uid', '==', uid), orderBy('createdAt', 'desc'))
: query(base, where('isPublic', '==', true), orderBy('createdAt', 'desc'));
}, [mine, uid]);
useEffect(() => {
const unsub = onSnapshot(qRef, (snap) => {
const list = snap.docs.map(d => ({ id: d.id, ...(d.data() as any) })) as Post[];
setPosts(list);
});
return () => unsub();
}, [qRef]);
return (
<main style={{maxWidth:800,margin:'40px auto',padding:16}}>
<h1>๊ฒ์๊ธ ๋ชฉ๋ก</h1>
<div style={{display:'flex',gap:12,alignItems:'center',margin:'12px 0'}}>
<label style={{display:'flex',gap:8,alignItems:'center'}}>
<input type="checkbox" checked={mine} onChange={(e)=>setMine(e.target.checked)} />
๋ด ๊ธ๋ง ๋ณด๊ธฐ
</label>
<Link href="/posts/new" style={{marginLeft:'auto'}}>+ ์ ๊ธ</Link>
</div>
<ul style={{display:'grid',gap:10}}>
{posts.map(p => (
<li key={p.id} style={{border:'1px solid #eee',borderRadius:8,padding:12}}>
<Link href={`/posts/${p.id}`} style={{fontWeight:600}}>{p.title}</Link>
<div style={{fontSize:12,opacity:.7,marginTop:6}}>
{p.isPublic ? '๊ณต๊ฐ' : '๋น๊ณต๊ฐ'} ยท {p.uid.slice(0,6)}โฆ
</div>
</li>
))}
{posts.length === 0 && <p>๊ธ์ด ์์ต๋๋ค.</p>}
</ul>
</main>
);
}
๐ ์ธ๋ฑ์ค ์๋ฌ๊ฐ ๋๋ฉด ์ฝ์์์ ์ ๊ณตํ๋ ๋งํฌ๋ก ๋ณตํฉ ์ธ๋ฑ์ค ์์ฑ์ ํด์ฃผ์ธ์
(์:
where('uid','==') + orderBy('createdAt')์กฐํฉ).
์ญํ
- ๊ณต๊ฐ๊ธ ํน์ ๋ด ๊ธ๋ง ๋ณด๊ธฐ ํํฐ ์ ๊ณต
onSnapshot์ผ๋ก ์ค์๊ฐ ๋ฐ์ (๋ฑ๋ก/์์ /์ญ์ ์ฆ์ ์ ๋ฐ์ดํธ)
๋์ ์์
onAuthStateChanged๋ก ํ์ฌ ์ฌ์ฉ์ UID ์ํ(uid) ์ ์งmine(๋ด ๊ธ๋ง ๋ณด๊ธฐ)์uid์ ๋ฐ๋ผ ์ฟผ๋ฆฌ ๋ถ๊ธฐ:- ๊ณต๊ฐ๊ธ:
where('isPublic','==',true)+orderBy('createdAt','desc') - ๋ด ๊ธ:
where('uid','==',uid)+orderBy('createdAt','desc')
- ๊ณต๊ฐ๊ธ:
onSnapshot(qRef, ...)๊ตฌ๋ ์ผ๋ก ๋ฌธ์ ๋ณ๊ฒฝ์ฌํญ ์ค์๊ฐ ์์ โsetPosts(list)- ์ธ๋ง์ดํธ ์
unsub()๋ก ๊ตฌ๋ ํด์
์ฃผ์(์ธ๋ฑ์ค)
where + orderBy์กฐํฉ์ ๋ณตํฉ ์ธ๋ฑ์ค๊ฐ ํ์ํ ์ ์์ต๋๋ค. ์ฝ์ ์๋ฌ์ โCreate indexโ ๋งํฌ๋ก ์์ฑํ์ธ์.
UX ํฌ์ธํธ
posts.length === 0์ผ ๋ ์๋ด ๋ฌธ๊ตฌ- ๋ฆฌ์คํธ ํญ๋ชฉ์
title+ ๋ฉํ(isPublic,uid.slice(0,6))
4) ์ ๊ธ ์์ฑ (์์ ์ UID๋ก ์ ์ฅ)
hooks/useAuth.ts
'use client';
import { useEffect, useState } from 'react';
import { auth } from '@/lib/firebase.client';
import { onAuthStateChanged, User } from 'firebase/auth';
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsub = onAuthStateChanged(auth, (u) => {
setUser(u);
setLoading(false);
});
return () => unsub();
}, []);
return { user, loading };
}
์ญํ
- ํ์ด์ง/์ปดํฌ๋ํธ์์ ์ฝ๊ฒ ๋ก๊ทธ์ธ ์ํ๋ฅผ ์ฌ์ฌ์ฉํ๊ธฐ ์ํ ๊ฒฝ๋ ํ .
๋์
- ๋ง์ดํธ ์
onAuthStateChanged๊ตฌ๋ โ{ user, loading }๋ฐํ - ์ด๊ธฐ์
loading=true๋ก ์์ํ์ฌ ๋ณต์ ์๋ฃ ์false. ์ด ๊ฐ์ผ๋ก ๊น๋ฐ์ ๋ฐฉ์ง.
app/posts/new/page.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { addDoc, collection, serverTimestamp } from 'firebase/firestore';
import { db } from '@/lib/firebase.client';
import PostForm from '@/components/PostForm';
import { useAuth } from '@/hooks/useAuth';
export default function NewPostPage() {
const router = useRouter();
const { user, loading } = useAuth();
useEffect(() => {
if (!loading && !user) router.replace('/login');
}, [loading, user, router]);
if (loading) return null; // ๋๋ ์ค์ผ๋ ํค UI
if (!user) return null; // ๋ฆฌ๋ค์ด๋ ํธ ์ง์ ๊น๋นก์ ๋ฐฉ์ง
const create = async (data: {
title: string;
content: string;
isPublic: boolean;
}) => {
const docRef = await addDoc(collection(db, 'posts'), {
uid: user.uid,
title: data.title,
content: data.content,
isPublic: data.isPublic,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
});
router.replace(`/posts/${docRef.id}`);
};
return (
<main style={{ maxWidth: 720, margin: '40px auto', padding: 16 }}>
<h1>์ ๊ธ ์์ฑ</h1>
<PostForm onSubmit={create} submitText='๋ฑ๋ก' />
</main>
);
}
์ญํ
- ์์ ์ UID๋ก ๋ฌธ์๋ฅผ ์์ฑํ๊ณ ,
serverTimestamp()๋ก ์๊ฐ ๊ธฐ๋ก.
๋์ ์์
useAuth()๋ก ๋ก๊ทธ์ธ ์ฌ๋ถ ํ์ธ- ๋น๋ก๊ทธ์ธ:
router.replace('/login')(๋ค๋ก๊ฐ๊ธฐ๋ก ๋ค์ ์ง์ ํ์ง ์๋๋ก replace)
- ๋น๋ก๊ทธ์ธ:
PostForm์ ์ถ โaddDoc(collection('posts'), { ... }):uid: user.uid(์์ ๊ถ ๋ช ์)createdAt/updatedAt: serverTimestamp()(์๋ฒ ์๊ฐ ๊ธฐ์ค)
- ์์ฑ ํ ์์ธ ํ์ด์ง๋ก ์ด๋:
router.replace('/posts/{docId}')
๋ณด์ ์ฐ๊ณ
- ๊ท์น์์ create ์
uid๋์ผ์ฑ์ ๊ฒ์ฌํ๋ฏ๋ก, ํด๋ผ์ด์ธํธ๊ฐ ์๋ชป๋ UID๋ฅผ ๋ณด๋ด๋ฉด ๊ฑฐ๋ถ๋ฉ๋๋ค.
5) ์์ธ/์์ /์ญ์ (์์ ์๋ง ์์ ๋ ธ์ถ)
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';
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;
}) => {
await updateDoc(doc(db, 'posts', id), {
title: data.title,
content: data.content,
isPublic: data.isPublic,
updatedAt: serverTimestamp(),
});
setEditing(false);
// ์์ธ ์ฌ์กฐํ
const snap = await getDoc(doc(db, 'posts', id));
setPost({ id: snap.id, ...(snap.data() as any) });
};
const remove = async () => {
if (!confirm('์ญ์ ํ์๊ฒ ์ต๋๊น?')) return;
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.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,
}}
submitText='์์ ์๋ฃ'
onSubmit={update}
/>
<button onClick={() => setEditing(false)} style={{ marginTop: 12 }}>
์ทจ์
</button>
</>
)}
</main>
);
}
์ญํ
- ๋ฌธ์ ์์ธ ๋ณด๊ธฐ + ์์ ์๋ง ์์ /์ญ์ ๋ฒํผ ๋ ธ์ถ
๋์ ์์
- URL ํ๋ผ๋ฏธํฐ
id๋ก ๋ฌธ์ ๋ก๋(getDoc)- ์์ผ๋ฉด
/posts๋ก ๋ฆฌ๋ค์ด๋ ํธ
- ์์ผ๋ฉด
onAuthStateChanged๋ก ํ์ฌuid์ ์งisOwner = uid === post.uid๋ก ๊ถํ ํ์ - ์์ :
PostForm์initial์ ๋ฌ โ ํธ์ง ์๋ฃ ์updateDoc+updatedAt: serverTimestamp()- ์ ์ฅ ํ ์ต์ ๋ฐ์ดํฐ๋ฅผ ์ฌ์กฐํํ์ฌ ํ๋ฉด ๊ฐฑ์
- ์ญ์ :
- confirm ํ
deleteDocโ/posts๋ก ์ด๋
- confirm ํ
๊ท์น๊ณผ์ ์ํธ์์ฉ
- ์์ /์ญ์ ๋ ์์ ์๋ง ํ์ฉ(rules์
update, delete์กฐ๊ฑด) - ํ์ธ ๋ฌธ์ ์์ /์ญ์ ๋ฅผ ์๋ํ๋ฉด ๊ถํ ์๋ฌ ๋ฐ์(Firestore๊ฐ ์๋ฒ์์ ์ฐจ๋จ)
UX ํ
- ๋ก๋ฉ/์๋ฌ ์ํ๋ฅผ ๋ถ๋ฆฌ ๋ ๋ํ๋ฉด ์ฌ์ฉ์ ๊ฒฝํ์ด ์ข์์ง๋๋ค.
- ํ์ดํ๋ง ์๋ ์ต์ ๋ ๋ ํ ๋ณธ๋ฌธ/๋ฉํ๋ฅผ ์ ์ง์ ๋ ธ์ถํด๋ ์ข์ต๋๋ค.
6) ํค๋ ์์
HeaderAuth ์ปดํฌ๋ํธ์ โPostsโ ๋ฉ๋ด๋ฅผ ์ถ๊ฐํด์ ๋ก๊ทธ์ธ/๋น๋ก๊ทธ์ธ ์๊ด์์ด ์ ๊ทผ ๊ฐ๋ฅํ๊ฒ ๊ตฌ์ฑํด๋ณด๊ฒ ์ต๋๋ค. ์ด ๋ฉ๋ด๋ /posts ๋ชฉ๋ก ํ์ด์ง๋ก ์ด๋ํฉ๋๋ค.
โ
์์ ๋ components/HeaderAuth.tsx
'use client';
import { useEffect, useState } from 'react';
import { auth } from '@/lib/firebase.client';
import { onAuthStateChanged, signOut, User } from 'firebase/auth';
import Link from 'next/link';
export default function HeaderAuth() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsub = onAuthStateChanged(auth, (u) => {
setUser(u);
setLoading(false);
});
return () => unsub();
}, []);
if (loading) return null;
return (
<header
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '12px 16px',
borderBottom: '1px solid #eee',
}}
>
{/* ์ผ์ชฝ ๋ค๋น๊ฒ์ด์
*/}
<nav style={{ display: 'flex', gap: 12 }}>
<Link href='/'>Home</Link>
<Link href='/posts'>Posts</Link>
<Link href='/dashboard'>Dashboard</Link>
</nav>
{/* ์ค๋ฅธ์ชฝ ๋ก๊ทธ์ธ/๋ก๊ทธ์์ ์์ญ */}
<div style={{ marginLeft: 'auto' }}>
{user ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 14, opacity: 0.8 }}>
์๋
ํ์ธ์, {user.email}
</span>
<button
onClick={() => signOut(auth)}
style={{
padding: '6px 10px',
border: '1px solid #ddd',
borderRadius: 8,
background: 'white',
}}
>
๋ก๊ทธ์์
</button>
</div>
) : (
<div style={{ display: 'flex', gap: 12 }}>
<Link href='/login'>๋ก๊ทธ์ธ</Link>
<Link href='/signup'>ํ์๊ฐ์
</Link>
</div>
)}
</div>
</header>
);
}
๋ชฉ์
Posts๋ฉ๋ด๋ฅผ ์ถ๊ฐํด ๋๊ตฌ๋ ๋ชฉ๋ก ํ์ด์ง๋ก ์ ๊ทผ ๊ฐ๋ฅ(๊ณต๊ฐ๊ธ์ ์ ์ฒด ์ฝ๊ธฐ ํ์ฉ).Dashboard๋ ๋ก๊ทธ์ธ ์ ์ฉ.
๋ด๋น๊ฒ์ด์ ์์น
- ์ธ์ฆ ํ๋ฆ: ๋ณดํธ ํ์ด์ง๋ AuthGate/๋ฆฌ๋ค์ด๋ ํธ๋ก ์ ๊ทผ ์ ์ด
- ๊ณต๊ฐ ์์ญ:
Posts๋ ๊ณต๊ฐ๊ธ ์ฐ์ , ๋ก๊ทธ์ธ ์ โ๋ด ๊ธ๋ง ๋ณด๊ธฐโ๋ก ๊ฐ์ธ ํํฐ ์ ๊ณต
โจ ์ค๋ช
| ๊ตฌ๋ถ | ๋ด์ฉ |
|---|---|
| Home | / โ ๋ฉ์ธ ํ์ด์ง |
| Posts | /posts โ ๊ณต๊ฐ/๋ด ๊ธ ๋ณด๊ธฐ ๋ชฉ๋ก |
| Dashboard | /dashboard โ ๋ก๊ทธ์ธ ์ฌ์ฉ์ ์ ์ฉ |
| ์ค๋ฅธ์ชฝ ์์ญ | ๋ก๊ทธ์ธ ์ ์ด๋ฉ์ผ + ๋ก๊ทธ์์ ๋ฒํผ, ๋น๋ก๊ทธ์ธ ์ ๋ก๊ทธ์ธ/ํ์๊ฐ์ ๋งํฌ |
7) ๋์ ์ฒดํฌ๋ฆฌ์คํธ
-
/posts์์ ๊ณต๊ฐ๊ธ ๋ชฉ๋ก ๋ณด์ - โ๋ด ๊ธ๋ง ๋ณด๊ธฐโ ์ฒดํฌ ์ ๋ด UID ๊ธ๋ง ์ค์๊ฐ ํ๊ธฐ
-
/posts/new์์ ๋ฑ๋ก โ ์์ธ ํ์ด์ง ์ด๋ - ์์ธ์์ ์์ ์๋ง โ์์ /์ญ์ โ ๋ฒํผ ๋ ธ์ถ
- ๊ท์น ์๋ฐ(ํ์ธ ๊ธ ์์ /์ญ์ ) ์ ๊ถํ ์๋ฌ ๋ฐ์
์ค์ ๋ก ๋์ ํ ์คํธ๋ฅผ ํ๋ค๋ณด๋ฉด ์ ๋ ฌ ์์ธ์ด ์์ฑ๋์ง ์์์ ๋ค์๊ณผ ๊ฐ์ ์๋ฌ๊ฐ ๋ฉ๋๋ค.
.png?alt=media&token=b7cf4d47-9003-407e-b3d5-6a96353e4503)
๋ค์ ์๋ฌ๋ Firestore ๋ณตํฉ ์ธ๋ฑ์ค๊ฐ ์์ด ์ฟผ๋ฆฌ๋ฅผ ์คํํ ์ ์๋ค๋ ๋ป์ ๋๋ค.
FirebaseError: [code=failed-precondition]: The query requires an index.
์ฝ์์ โYou can create it hereโ ๋งํฌ๊ฐ ๊ฐ์ด ๋จ์ฃ . ๊ทธ ๋งํฌ๊ฐ ํ์ํ ์ธ๋ฑ์ค๋ฅผ ๋ฏธ๋ฆฌ ์ฑ์๋ ์์ฑ ๋งํฌ์ ๋๋ค.
์ ์๊ธฐ๋์?
where()๊ฐ 2๊ฐ ์ด์์ด๊ฑฐ๋where()+orderBy()๋ฅผ ์๋ก ๋ค๋ฅธ ํ๋๋ก ์ฐ๊ฑฐ๋in/array-contains-any์orderBy๋ฅผ ๊ฐ์ด ์ฐ๋ ๋ฑ๋จ์ผ ์ธ๋ฑ์ค๋ก๋ ๋ชป ์ปค๋ฒํ ๋ ๋ณตํฉ ์ธ๋ฑ์ค๊ฐ ํ์ํฉ๋๋ค.
์ด ์๋ฌ๋ Firestore๋ฅผ ๋ค๋ฃจ๋ค๋ณด๋ฉด ์์ฃผ ๋๋ ์๋ฌ๋ก ํด๊ฒฐ์ฑ ์ ์ฝ์์ ์๋ฌ ๋งํฌ๋ฅผ ํด๋ฆญํด์ Firestore ๋ณตํฉ ์ธ๋ฑ์ค๋ฅผ ์์ฑํ๋ฉด ๋ฉ๋๋ค.
.png?alt=media&token=72962576-cb77-406e-a150-64598941c42c)
ํน ํด๋ฆญ์ด ์๋๋ฉด ๋๋๊ทธํด์ ๋ณต๋ถํด์ ๋งํฌ๋ก ์ด๋ํด์ฃผ์ธ์.
.png?alt=media&token=c4cb2566-2e17-4510-962d-c18583a189cf)
.png?alt=media&token=a7b62e03-45a9-48ff-b11d-1846e73a4c5a)
.png?alt=media&token=a83c24dd-a5b8-4196-9147-8825e0b8818a)
.png?alt=media&token=35e3cfdc-6fdc-456e-9fbe-f9e792c19342)
์ด๋ ๊ฒ ์ํ๊ฐ ๋ณ๊ฒฝ๋๋ฉด ๋ณตํฉ ์ธ๋ฑ์ค๊ฐ ์ ์์ ์ผ๋ก ์์ฑ๋ ๊ฒ์ ๋๋ค.
๋ด ๊ธ๋ง ๋ณด๊ธฐ ์ฒดํฌ์์๋ ๋ณตํฉ ์ธ๋ฑ์ค๋ก ์ธํด์ ์๋ฌ๊ฐ ๋จ๊ธฐ ๋๋ฌธ์ ํ๋ฒ๋ ๋งํฌ๋ฅผ ํ๊ณ ๋ค์ด๊ฐ ๋ณตํฉ ์ธ๋ฑ์ค๋ฅผ ์์ฑํด์ค๋๋ค.
.png?alt=media&token=44fe6261-32e9-4226-8e77-4e86bfd2d9cb)
์ด์ ๊ฒ์๊ธ ๋ชฉ๋ก์์ ์ ์์ ์ผ๋ก ๊ฒ์๊ธ์ด ๋ณด์ด๋ ๊ฒ์ ํ์ธํ ์ ์์ต๋๋ค.
.png?alt=media&token=38fa620a-5e5e-4490-a6e6-fcbb2acd04f9)
๐ฌ ๋๊ธ
โป ๋ก๊ทธ์ธ ํ ๋๊ธ์ ์์ฑํ ์ ์์ต๋๋ค.