본문 바로가기

프론트엔드

[에디터2] TUI Editor에 이미지 업로드 시 firebase Storage 이용하기

ToastUI Editor 이미지 업로드

  • toastUI에서 이미지를 추가하면, 아래와 같이 base64 인코딩된 문자열로 업로드되고, 스크롤을 보면 알겠지만 그 길이가 어마어마하며, 이상태로 DB에 저장되게 된다.

나는 몽고디비 무료버전을 사용하고 있기 때문에 이미지를 별도의 서버에 저장하고, 경로만 받아오도록 작업했다.

firebase는 이전에 개인 포트폴리오 작업 시에 사용해본 적이 있던터라, 파이어베이스 Storage를 이미지서버로 사용하였다.

Firebase 스토리지에 이미지 업로드하기

🔥 firebase 스토리지 생성 및 설정

1. 🔥 Firebase 문서에 따라 신규 프로젝트를 생성하고, storage를 만든다.

2. firebase 설치

// yarn 설치
yarn add firebase

// npm 설치
npm install firebase

3. firebase config 파일 생성

// firebase/config.js
import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";
import { getStorage, ref } from "firebase/storage";

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGINGSENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
  measurementId: process.env.NEXT_PUBLIC_MEASUREMENT_ID,
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);
const appAuth = getAuth();
const storage = getStorage(app);

export { appAuth, storage }

4. firebase 이미지를 읽어올 때, 외부 서버의 이미지를 가져오는 것이기 때문에 next.config.js 파일에서 images domain을 설정해준다.

    /** @type {import('next').NextConfig} */
    const nextConfig = {
        images: {
            domains: ['firebasestorage.googleapis.com'],
            formats: ['image/avif', 'image/webp'],
            remotePatterns: [
                {
                    protocol: 'https',
                    hostname: 'firebasestorage.googleapis.com',
                    port: '',
                    pathname: '/image/upload/**',
                },
            ]
        }
    }
    
    module.exports = nextConfig

📷 firebase storage 이미지 업로드

  • TUI는 addImageBlobHook을 제공하고있어서 에디터 영역에 이미지를 추가할 경우에 그 이벤트를 제어할 수 있다.

  • onUploadImage 함수는 blob과 callback을 인자를 받을 수 있다. callback(url, string)은 파일 경로와 대체텍스트를 인자로 받는데, 응답으로 받은 서버에 저장된 경로와 대체텍스트를 전달한다.
    'use client';
    import '@toast-ui/editor/dist/toastui-editor.css';
    import { Editor } from '@toast-ui/react-editor';
    import { MutableRefObject, useEffect, useState } from 'react';
    import { storage } from '@/util/firebase/config';
    import { getDownloadURL, ref, uploadBytes } from 'firebase/storage';
    import { Images } from '@/util';

    const onUploadImage = async (blob:Blob, callback: (url: string, altText?: string) => void) => {
        const fileName = `${Date.now().toString()}_${blob.name}`;
        const storageRef = ref(storage, `images/${fileName}`); // 1. config에서 가져온 storage내에 파일 넣을 경로
    
        try {
          const snapshot = await uploadBytes(storageRef, blob); // 2. 위 경로에 이미지파일을 업로드
          const url = await getDownloadURL(snapshot.ref); // 3. 올라간 이미지파일 url을 받아온다.
          
          callback(url, blob.name) // 4. callback함수 실행하여 에디터에 이미지 업로드
    
        } catch (error) {
          console.log('error', error)
          alert('이미지 업로드 실패')
        }
    }

👉🏽 이미지 경로만 받아오도록 수정된 화면

📷 firebase storage 이미지 삭제

하지만, 위와 같이 구현할 경우에 에디터에 이미지를 넣을 때마다 스토리지로 올라가게 되어, 이미지를 넣었다가 지우거나 이미지를 교체할 경우에도 모두 고스란히 storage에 남아있게 된다.

그래서 최종적으로 사용하게 될 이미지만 storage에 남아있게 하기 위해 에디터에 올린 이미지들을 한 배열에 넣고, 실제 db에 게시글을 저장할 때 배열을 훑어서 본문에서 존재하지 않는 이미지가 있을 경우에 해당 이미지를 storage에서도 삭제하도록 구현하였다.

// MyEditor.tsx
const onUploadImage = async (blob:Blob, callback: (url: string, altText?: string) => void) => {
    const fileName = `${Date.now().toString()}_${blob.name}`;
    const storageRef = ref(storage, `images/${fileName}`);
    
    try {
      const snapshot = await uploadBytes(storageRef, blob);
      const url = await getDownloadURL(snapshot.ref);
      
      // ⭐️ images 배열에 storage에 올린 fileName, url 삽입
      images.current = Array.isArray(images.current) 
          ? [...images.current, {fileName: fileName, url: url.replaceAll(/&/g, '&')}] 
          : [{fileName: fileName, url: url.replaceAll(/&/g, '&')}]
  
      callback(url, 'image')

    } catch (error) {
      console.log('error', error)
      alert('이미지 업로드 실패')
    }
  }
// page.tsx

'use client';
import { Images } from '@/util';
import { storage } from '@/util/firebase/config';
import { Editor } from '@toast-ui/react-editor';
import { deleteObject, ref } from 'firebase/storage';
import { useRouter } from 'next/navigation';
import { useRef, useState } from "react";
import MyEditor from '../components/editor/MyEditor';


// 업로드했다가 최종적으로 사용하지 않은 이미지파일을 찾아 배열로 반환한다.
const findMissingUrls = (content: string, images?: Images[]): string[] => {
    const missingUrls: string[] = [];

    if(images) {
        for (const image of images) {
            if (!content.includes(image.url)) {
              missingUrls.push(image.fileName);
            }
        }
    }

    return missingUrls;
};

export default function Write() {
    ...
    const images = useRef<Images[] | undefined>(undefined);
    const editorRef = useRef<Editor>(null);
    const router = useRouter();

    const sendPost = async () => {
        const editorIns = editorRef.current?.getInstance();
        const content = editorIns?.getHTML(); // editor내용을 html으로 반환
        
        // 1. 에디터를 통해 등록한 후 최종 등록 정네 삭제한 이미지 정보를 찾는다.
        const missingFiles = findMissingUrls(content!, images.current);

        if(missingFiles) {
            // 2. 최종적으로 사용하지 않은 이미지들은 storage에서 삭제
            missingFiles.forEach(async fileName => {
                images.current = images.current?.filter(img => img.fileName !== fileName)
                const desertRef = ref(storage, `images/${fileName}`);
                await deleteObject(desertRef);
            })
        }

        // 3. 이미지배열 중 가장 첫번째 이미지를 썸네일로 등록
        const thumbnail = images.current ? images.current[0].url : null;
        const post = {
            title: title,
            content: content,
            thumbnail: thumbnail,
            tags: tags,
            images: images.current,
            createdTime: dayjs().format('YYYY-MM-DDTHH:mm:ss')
        }

        try {
            await axios.post('/api/post/write', post)
            .then((res) => {
                router.push(`/detail/${res.data}`)
            })
        } catch (error) {
            alert('write error')
            console.log('error', error)
        }
    }

    return (
        <div className='px-20 py-10'>  
            <Title title={title} setTitle={setTitle} />
            <Tag setTags={setTags} tags={tags} />
            <MyEditor editorRef={editorRef} images={images}/>
            <div className='flex my-7'>
                <button type="button" className="mr-auto" onClick={() => { router.back() }}>뒤로가기</button>
                <button type="button" className="ml-auto" onClick={ sendPost }>글작성</button>
            </div>
        </div>
    )
}