Syw.Frontend

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

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

1-4. Storage ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ & ๊ฒŒ์‹œ๊ธ€ ์—ฐ๋™

๐ŸŽฏ ๋ชฉํ‘œ

  • 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์ด ์žˆ์œผ๋ฉด(์ˆ˜์ • ํ™”๋ฉด) ์ตœ์ดˆ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋กœ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.

ํ๋ฆ„

  1. input[type=file] ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ์—์„œ ํŒŒ์ผ ์ถ”์ถœ
  2. ์ด๋ฏธ์ง€ ํƒ€์ž…์ธ์ง€ ํ™•์ธ (file.type.startsWith('image/'))
  3. ์ตœ๋Œ€ ์šฉ๋Ÿ‰(MB) ์ œํ•œ ๊ฒ€์‚ฌ
  4. ํ†ต๊ณผ ์‹œ URL.createObjectURL(file)๋กœ ๋กœ์ปฌ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ƒ์„ฑ
  5. โ€œ์„ ํƒ ํ•ด์ œโ€ ํด๋ฆญ ์‹œ ์„ ํƒ ์ทจ์†Œ + ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ดˆ๊ธฐํ™”

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์˜ ๊ณต๊ฐœ(๋˜๋Š” ์ ‘๊ทผ ๊ฐ€๋Šฅํ•œ) URL
  • thumbPath?: 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) ๋ฏธ๋ฆฌ๋ณด๊ธฐ์™€ ๊ต์ฒด๋ฅผ ์ง€์›.

์ œ์ถœ ํ๋ฆ„

  1. ์ œ๋ชฉ ๊ณต๋ฐฑ ์ฒดํฌ
  2. onSubmit({ title, content, isPublic, file }) ํ˜ธ์ถœ
  3. ์ƒ์œ„(ํŽ˜์ด์ง€)์—์„œ ์—…๋กœ๋“œ/๋ฌธ์„œ ์—…๋ฐ์ดํŠธ๋ฅผ ์ฒ˜๋ฆฌ โ†’ ๊ด€์‹ฌ์‚ฌ ๋ถ„๋ฆฌ

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๋กœ ๊ฒฝ๋กœ ๊ตฌ์„ฑํ•˜์—ฌ ์—…๋กœ๋“œ โ†’ ๋ฌธ์„œ ์—…๋ฐ์ดํŠธ

    โ†’ ๊ฒฝ๋กœ๊ฐ€ ์•ˆ์ •์ ์ด๊ณ , ์ค‘๊ฐ„ ์‹คํŒจ ์‹œ ๋กค๋ฐฑ(๋ฌธ์„œ ์‚ญ์ œ)๋„ ๋ช…ํ™•ํ•ฉ๋‹ˆ๋‹ค.

ํ๋ฆ„

  1. ๋กœ๊ทธ์ธ ํ™•์ธ(๋ฏธ๋กœ๊ทธ์ธ ์‹œ /login์œผ๋กœ replace)
  2. addDoc์œผ๋กœ ๋ฌธ์„œ ์ƒ์„ฑ(์ดˆ๊ธฐ thumbUrl/thumbPath๋Š” null)
  3. ํŒŒ์ผ์ด ์žˆ์œผ๋ฉด users/{uid}/posts/{docId}/{timestamp-name}๋กœ ์—…๋กœ๋“œ
    • ์—…๋กœ๋“œ ์„ฑ๊ณต ์‹œ thumbUrl, thumbPath๋ฅผ updateDoc์œผ๋กœ ๋ฐ˜์˜
  4. ์™„๋ฃŒ ํ›„ ์ƒ์„ธ ํŽ˜์ด์ง€๋กœ 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>
  );
}

์ˆ˜์ •(๊ต์ฒด) ํ๋ฆ„

  1. ์ƒˆ ํŒŒ์ผ์„ ์„ ํƒํ•œ ๊ฒฝ์šฐ
    • ์ƒˆ ํŒŒ์ผ๋ช…(timestamp-prefix)์œผ๋กœ ์—…๋กœ๋“œ
    • ์—…๋กœ๋“œ ์„ฑ๊ณต ํ›„, ์ด์ „ thumbPath๊ฐ€ ์กด์žฌํ•˜๊ณ  ์„œ๋กœ ๋‹ค๋ฅด๋ฉด deleteByPath(oldPath)๋กœ ์ •๋ฆฌ
    • ๋ฌธ์„œ์˜ thumbUrl, thumbPath ์—…๋ฐ์ดํŠธ
  2. ํŒŒ์ผ์„ ๋ฐ”๊พธ์ง€ ์•Š์€ ๊ฒฝ์šฐ
    • ํ…์ŠคํŠธ ํ•„๋“œ๋งŒ ์—…๋ฐ์ดํŠธ (updatedAt ๊ฐฑ์‹ )

์‚ญ์ œ ํ๋ฆ„

  1. ๋ฌธ์„œ ์‚ญ์ œ ์ „, ์—ฐ๊ฒฐ๋œ ํŒŒ์ผ์ด ์žˆ์œผ๋ฉด deleteByPath(post.thumbPath)๋กœ ๋จผ์ € ์ œ๊ฑฐ
  2. 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 ์ €์žฅ
  • ์ˆ˜์ • ์‹œ ์ƒˆ ์ด๋ฏธ์ง€๋กœ ๊ต์ฒดํ•˜๋ฉด ์ด์ „ ํŒŒ์ผ ์‚ญ์ œ
  • ๊ธ€ ์‚ญ์ œ ์‹œ ์ธ๋„ค์ผ ํŒŒ์ผ๋„ ํ•จ๊ป˜ ์‚ญ์ œ

์ธ๋„ค์ผ์ด ์ž˜ ๋“ฑ๋ก๋˜๋Š” ๊ฒƒ๊นŒ์ง€ ํ™•์ธ!

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

https://github.com/heroyooi/nextjs-firebase

๐Ÿ’ฌ ๋Œ“๊ธ€

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