Syw.Frontend

๐Ÿš€ Next.js + Firebase๋กœ ๋งŒ๋“œ๋Š” ํ’€์Šคํƒ ์›น์•ฑ ์‹ค์ „ ํ”„๋กœ์ ํŠธ

1๋‹จ๊ณ„. Next.js + Firebase ์‹ค์ „ ์•ฑ

1-3. Firestore CRUD ๊ณ ๋„ํ™” & ์‚ฌ์šฉ์ž๋ณ„ ๋ฐ์ดํ„ฐ

๐ŸŽฏ ๋ชฉํ‘œ

  • ์‚ฌ์šฉ์ž๋ณ„ ๋ฌธ์„œ ์†Œ์œ (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๋งŒ ์ฃผ์ž…๋ฐ›์•„ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.

๋™์ž‘ ํ๋ฆ„

  1. ์ดˆ๊ธฐ๊ฐ’(initial)์„ ์ƒํƒœ์— ์ฃผ์ž… โ†’ ๋“ฑ๋ก/์ˆ˜์ • ๋‘˜ ๋‹ค ์ง€์›
  2. ์ œ์ถœ ์‹œ:
    • title ๊ณต๋ฐฑ ๊ฒ€์‚ฌ(UX ๋ ˆ๋ฒจ 1์ฐจ ๋ฐธ๋ฆฌ๋ฐ์ด์…˜)
    • onSubmit({ title, content, isPublic }) ํ˜ธ์ถœ
    • ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ err ์ƒํƒœ์— ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ
  3. ๋ฒ„ํŠผ 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์œผ๋กœ ์‹ค์‹œ๊ฐ„ ๋ฐ˜์˜ (๋“ฑ๋ก/์ˆ˜์ •/์‚ญ์ œ ์ฆ‰์‹œ ์—…๋ฐ์ดํŠธ)

๋™์ž‘ ์ˆœ์„œ

  1. onAuthStateChanged๋กœ ํ˜„์žฌ ์‚ฌ์šฉ์ž UID ์ƒํƒœ(uid) ์œ ์ง€
  2. mine(๋‚ด ๊ธ€๋งŒ ๋ณด๊ธฐ)์™€ uid์— ๋”ฐ๋ผ ์ฟผ๋ฆฌ ๋ถ„๊ธฐ:
    • ๊ณต๊ฐœ๊ธ€: where('isPublic','==',true) + orderBy('createdAt','desc')
    • ๋‚ด ๊ธ€: where('uid','==',uid) + orderBy('createdAt','desc')
  3. onSnapshot(qRef, ...) ๊ตฌ๋…์œผ๋กœ ๋ฌธ์„œ ๋ณ€๊ฒฝ์‚ฌํ•ญ ์‹ค์‹œ๊ฐ„ ์ˆ˜์‹  โ†’ setPosts(list)
  4. ์–ธ๋งˆ์šดํŠธ ์‹œ 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()๋กœ ์‹œ๊ฐ„ ๊ธฐ๋ก.

๋™์ž‘ ์ˆœ์„œ

  1. useAuth()๋กœ ๋กœ๊ทธ์ธ ์—ฌ๋ถ€ ํ™•์ธ
    • ๋น„๋กœ๊ทธ์ธ: router.replace('/login') (๋’ค๋กœ๊ฐ€๊ธฐ๋กœ ๋‹ค์‹œ ์ง„์ž…ํ•˜์ง€ ์•Š๋„๋ก replace)
  2. PostForm ์ œ์ถœ โ†’ addDoc(collection('posts'), { ... }):
    • uid: user.uid(์†Œ์œ ๊ถŒ ๋ช…์‹œ)
    • createdAt/updatedAt: serverTimestamp()(์„œ๋ฒ„ ์‹œ๊ฐ„ ๊ธฐ์ค€)
  3. ์ƒ์„ฑ ํ›„ ์ƒ์„ธ ํŽ˜์ด์ง€๋กœ ์ด๋™: 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>
  );
}

์—ญํ• 

  • ๋ฌธ์„œ ์ƒ์„ธ ๋ณด๊ธฐ + ์†Œ์œ ์ž๋งŒ ์ˆ˜์ •/์‚ญ์ œ ๋ฒ„ํŠผ ๋…ธ์ถœ

๋™์ž‘ ์ˆœ์„œ

  1. URL ํŒŒ๋ผ๋ฏธํ„ฐ id๋กœ ๋ฌธ์„œ ๋กœ๋“œ(getDoc)
    • ์—†์œผ๋ฉด /posts๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ
  2. onAuthStateChanged๋กœ ํ˜„์žฌ uid ์œ ์ง€
  3. isOwner = uid === post.uid๋กœ ๊ถŒํ•œ ํŒ์ •
  4. ์ˆ˜์ •:
    • PostForm์— initial ์ „๋‹ฌ โ†’ ํŽธ์ง‘ ์™„๋ฃŒ ์‹œ updateDoc + updatedAt: serverTimestamp()
    • ์ €์žฅ ํ›„ ์ตœ์‹  ๋ฐ์ดํ„ฐ๋ฅผ ์žฌ์กฐํšŒํ•˜์—ฌ ํ™”๋ฉด ๊ฐฑ์‹ 
  5. ์‚ญ์ œ:
    • confirm ํ›„ deleteDoc โ†’ /posts๋กœ ์ด๋™

๊ทœ์น™๊ณผ์˜ ์ƒํ˜ธ์ž‘์šฉ

  • ์ˆ˜์ •/์‚ญ์ œ๋Š” ์†Œ์œ ์ž๋งŒ ํ—ˆ์šฉ(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์—์„œ ๋“ฑ๋ก โ†’ ์ƒ์„ธ ํŽ˜์ด์ง€ ์ด๋™
  • ์ƒ์„ธ์—์„œ ์†Œ์œ ์ž๋งŒ โ€œ์ˆ˜์ •/์‚ญ์ œโ€ ๋ฒ„ํŠผ ๋…ธ์ถœ
  • ๊ทœ์น™ ์œ„๋ฐ˜(ํƒ€์ธ ๊ธ€ ์ˆ˜์ •/์‚ญ์ œ) ์‹œ ๊ถŒํ•œ ์—๋Ÿฌ ๋ฐœ์ƒ

์‹ค์ œ๋กœ ๋™์ž‘ ํ…Œ์ŠคํŠธ๋ฅผ ํ•˜๋‹ค๋ณด๋ฉด ์ •๋ ฌ ์ƒ‰์ธ์ด ์ƒ์„ฑ๋˜์ง€ ์•Š์•„์„œ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์—๋Ÿฌ๊ฐ€ ๋‚ฉ๋‹ˆ๋‹ค.

๋‹ค์Œ ์—๋Ÿฌ๋Š” 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 ๋ณตํ•ฉ ์ธ๋ฑ์Šค๋ฅผ ์ƒ์„ฑํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

ํ˜น ํด๋ฆญ์ด ์•ˆ๋˜๋ฉด ๋“œ๋ž˜๊ทธํ•ด์„œ ๋ณต๋ถ™ํ•ด์„œ ๋งํฌ๋กœ ์ด๋™ํ•ด์ฃผ์„ธ์š”.

์ด๋ ‡๊ฒŒ ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋˜๋ฉด ๋ณตํ•ฉ ์ธ๋ฑ์Šค๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๋‚ด ๊ธ€๋งŒ ๋ณด๊ธฐ ์ฒดํฌ์‹œ์—๋„ ๋ณตํ•ฉ ์ธ๋ฑ์Šค๋กœ ์ธํ•ด์„œ ์—๋Ÿฌ๊ฐ€ ๋œจ๊ธฐ ๋•Œ๋ฌธ์— ํ•œ๋ฒˆ๋” ๋งํฌ๋ฅผ ํƒ€๊ณ  ๋“ค์–ด๊ฐ€ ๋ณตํ•ฉ ์ธ๋ฑ์Šค๋ฅผ ์ƒ์„ฑํ•ด์ค๋‹ˆ๋‹ค.

์ด์ œ ๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก์—์„œ ์ •์ƒ์ ์œผ๋กœ ๊ฒŒ์‹œ๊ธ€์ด ๋ณด์ด๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์‹ค์Šต์ €์žฅ์†Œ

GitHub - heroyooi/nextjs-firebase at ch1_3

๐Ÿ’ฌ ๋Œ“๊ธ€

    โ€ป ๋กœ๊ทธ์ธ ํ›„ ๋Œ“๊ธ€์„ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.