[다이어리프로젝트] 이미지 업로드 플로우

송나·2025년 3월 18일

💡 스마트 에디터 통한 이미지 업로드

목표 : 사용자가 글을 등록할 때 이미지를 업로드 하면 미리보기가 출력되고, 글 등록후에도 이미지가 보여져야함.


📑 초기 이미지 업로드 플로우

  1. 에디터 내에서 파일-이미지 업로드 (붙여넣기, 드래그&드롭 허용)
  2. 에디터 임시 저장소(blobCache)에 이미지 저장, setImgs로 이미지저장
  3. 이미지 미리보기 가능
  4. 글 등록(API 요청)
  5. 이미지있을때만 서버에 이미지저장(API 호출)
  6. URL응답후 src=" "형태로 formData에 대입
  7. 최종 content를 서버로 전송
  8. 프론트 렌더링

📑 기초 개념 이해

1. 브라우저에서 이미지 경로 처리
• 로컬 디스크에 직접 접근할 수 없음.
• 브라우저가 인식 가능한 이미지 경로
Base64 데이터 : <img src="data:image/png;base64,...">
외부 URL : <img src="https://example.com/image.png">
Blob URL (브라우저 메모리) : <img src="blob:http://localhost:3000/abc-1234">
• React 퍼블릭 폴더는 정적 파일 상대경로로 접근가능

2. file
업로드 하는 파일은 객체. 이는 브라우저 내부에 존재하는 바이너리 파일임.
이미지를 업로드하기 전에 사용자가 브라우저 상에서 미리 확인할 수 있도록 하기 위해서는 base64 문자열로 변환이 필요함. <img src="data:image/png;base64,..."> 형태가 되어야 서버 업로드전 브라우저에서 바로 볼 수 있음.

3. 바이너리(Binary)
• 0과 1로 이루어진 데이터
• 컴퓨터가 읽을 수 있는 이진 데이터.

4. blob
• blob : 바이너리 파일을 브라우저 메모리에 임시로 저장한 객체
• blob URL : blob을 가리키는 임시 URL <img src="blob:http://~">
• blobCache : TinyMCE 내부에서 blob(임시 파일)을 관리하는 캐시 저장소.
• editorUpload : TinyMCE 안에서 파일 업로드 기능 제공하는 내부 객체.
• blobUri()는 TinyMCE에서 임시 URL 생성 메서드

5. new FileReader()
• FileReader( )는 브라우저 API
• 현재 파일 읽기 상태, 결과물 (base64로 변환된 문자열), 에러, 이벤트 핸들러가 담겨있음.


📑 업로드 이미지 미리보기 및 저장

// React 미리보기  
const [content, setContent] = useState(""); // 에디터 내용
const [imgs, setImgs] = useState([]);       // 업로드 이미지(base64)


input.onchange = function () { // input에서 파일 선택 후 실행
    const file = input.files[0];
    const reader = new FileReader();
    
    reader.onload = function () { // 변환 끝나면 콜백
        const base64 = reader.result;
        setImgs(prev => [...prev, base64]);
    };
    
    reader.readAsDataURL(file); // 파일을 base64로 비동기 변환
};
  


// TinyMCE 내부 미리보기 및 임시 저장 
const editorRef = useRef(null);
  
reader.onload = function () {
                                const base64 = reader.result;
                                if (editorRef.current) {
                                    const blobCache =
                                        editorRef.current.editorUpload //미리보기용 blobCache에 저장
                                            .blobCache;
                                    const id = "blobid" + new Date().getTime(); //고유id생성 이름 중복방지
                                    const blobInfo = blobCache.create( // 임시객체 생성, 인자필수
                                        id,
                                        file,
                                        base64
                                    );
                                    blobCache.add(blobInfo); //에디터의 임시 저장소에 추가
                                    callback(blobInfo.blobUri(), { // 호출로 에디터 내 삽입, callback(src, meta)형태
                                        title: file.name,
                                    });
                                }
                            };
  

📑 이미지업로드 구조 이해

  1. 직접 올린 이미지들은 setImgs로 관리하지만 에디터 내부에서 사용자가 이미지를 붙여넣은 경우 고려함. 외부 URL https://~ 은 content에서 처리

  2. TinyMCE는 이미지 포함 전체 내용을 <p>, <img>, <h1> 등 HTML 문자열로 반환됨."<p>Hello</p><img src='data:image/png;base64,...' />" 이미지 업로드 위해서 <img src= 찾아내야함.

  3. base64를 바로 서버로 보내면 오류❗️ 크기가 크고 문자열 데이터인식함. 브라우저 메모리에서 실제 파일처럼 취급하도록 base64를 Blob으로 변환해서 파일 객체화 과정 필요함.

  4. FormData에 넣어서 서버에 업로드하기.


📑 이미지 업로드

// 이미지 base64 문자열을 Blob 객체로 변환
export function dataURLtoBlob(dataUrl) {
    // data:image/jpeg;base64,data:image/jpeg;base64,/9j/4AA.. 중복접두사오류
    // 정규식 치환
    dataUrl = dataUrl.replace(/^(data:image\/[^;]+;base64,)+/, "$1");
    const BASE64_MARKER = ";base64,";
    const parts = dataUrl.split(BASE64_MARKER);

    if (parts.length !== 2) {
        throw new Error("올바르지 않은 base64 형식입니다.");
    }

    const mime = parts[0].split(":")[1]; // image/jpeg
    const bstr = atob(parts[1]); // 순수 base64 부분만 디코딩

    // binary string의 길이만큼 빈 Uint8Array 배열 생성
    let n = bstr.length;
    const u8arr = new Uint8Array(n);

    // binary string을 1글자씩 읽어와 Uint8Array에 넣음
    for (let i = 0; i < n; i++) {
        u8arr[i] = bstr.charCodeAt(i);
    }

    // Uint8Array 데이터를 이용해 Blob 객체 생성 (type: 이미지 MIME)
    return new Blob([u8arr], { type: mime });
}

// 에디터 이미지업로드 API
export const uploadImgs = async (editorRef, setContent) => {
    const URL = process.env.REACT_APP_BACK_URL;
    const content = editorRef.current.getContent();
    const tempDiv = document.createElement("div");
    tempDiv.innerHTML = content;

    const images = tempDiv.querySelectorAll("img[src^='data:image']");

    //이미지없을때 내용업데이트
    if (images.length === 0) {
        console.log("tempDiv.innerHTML", tempDiv.innerHTML);
        return tempDiv.innerHTML;
    }
    if (images.length > 5) {
        alert("이미지는 한번에 최대 5개까지만 등록할 수 있습니다.");
        return;
    }

    const formData = new FormData();
    images.forEach((img, i) => {
        const base64String = img.src;
        const blob = dataURLtoBlob(base64String); //Blob 객체로 변환
        const file = new File([blob], `image${i}.jpg`, { type: blob.type }); // 파일명 주입. 확장자 빠짐해결
        formData.append("file", file); //file 서버 키 통일 (upload.array("file"))
    });

    const res = await axios.post(`${URL}/img/editor`, formData);
    const uploadedUrls = res.data.urls;

    images.forEach((img, i) => {
        img.src = uploadedUrls[i];
    });

    const finalContent = tempDiv.innerHTML;

    return finalContent;
};

📑 서버 응답

  1. multer 설정해서 이미지를 서버에 저장.
  2. 프론트에서 이미지가 안 뜨는 문제 발생 ❗️
    서버에는 정상적으로 저장되었지만, 프론트엔드에서 이미지를 불러오지 못함.
    프론트와 백엔드가 서로 다른 포트에서 동작이 원인❗️
  3. 절대경로 설정
    서버 URL을 포함한 전체 경로 생성
const urls = req.files.map(
            file => `http://localhost:5001/imgs/diary/upload/${file.filename}`
        );
        return res.status(200).json({ urls });

const protocol = req.protocol; // http 또는 https
const host = req.get('host'); // 호스트 정보 (예: localhost:5001)
const fileUrl = ${protocol}://${host}/imgs/diary/upload/${req.file.filename};
return res.status(200).json({ location: fileUrl });

📑 프론트 렌더링

  1. 리스트에서 텍스트 출력
    에디터에 저장된 content는 태그가 포함된 HTML 문자열로 넘어옴
<p>fscscsdfsfsc</p>
<p>&nbsp;</p>
<p><img src="https://images.unsplash.com/photo-1536782376847-5c9d14d97cc0?fm=jpg&amp;q=60&amp;w=3000&amp;ixlib=rb-4.0.3&amp;ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8JUVCJUFDJUI0JUVCJUEzJThDfGVufDB8fDB8fHww" alt="일몰 중 수역" width="316" height="209"></p>
<p>dddd</p> 
  1. 태그나 이미지를 제외한 순수 텍스트만 추출
export function extractSummary(content, limit = 50) {
    const tempDiv = document.createElement("div");
    tempDiv.innerHTML = content; //DOM 파싱

    let plainText = tempDiv.textContent || tempDiv.innerText || "";
    plainText = plainText.replace(/\s+/g, " ").trim(); //줄바꿈, 공백 제거

    return plainText.length > limit
        ? plainText.slice(0, limit) + "..."
        : plainText;
}

<p>{extractSummary(diary.content)}</p> //리스트컴포넌트에서 사용
  1. 상세페이지에서 에디터 내용 그대로 렌더링
<div dangerouslySetInnerHTML={{ __html: diary.content }} />

📑 회고

이전 프로젝트에서는 단순히 input file 을 이용해 단일 이미지 업로드 기능을 구현했다. 프론트엔드에서는 파일을 state로 관리하고 서버에서는 multer의 upload.single("file")로 파일을 받고 해당 파일명을 DB에 저장했다. 이미지를 렌더링할 때는 서버 URL + 이미지 파일명을 조합해서 화면에 출력하는 단순한 구조였다.

하지만 이번에는 이번에는 에디터 내부에서 이미지를 업로드하고 미리보기까지 제공해야했고 단순히 파일을 업로드하는 수준에서 벗어나 브라우저와 서버 간 데이터 흐름의 근본적인 이해가 필요했다. 그 과정에서 바이너리, Blob, file 객체, base64 인코딩, FormData, blobCache 개념들을 공부했다.

브라우저가 이미지를 어떻게 렌더링하는지, 왜 base64로 바로 서버에 못 보내는지등 직접 트러블슈팅하며 배웠다.

핵심은 base64로 미리보기를 띄우고
서버로 보낼 때는 Blob으로 변환하여 업로드하고
서버에서는 URL을 생성해서 응답하고
임시 base64 src를 서버URL로 대체하는것

추가로 에디터에서 가져온 HTML 콘텐츠에서 텍스트만 추출해야했다.
DOM 파싱후에 텍스트 추출하고 문자열 변환처리가 필요했다.

이번 경험은 기초를 익히는 중요한 시간이었다. 끝날때까지 끝난게 아닌느낌…
문제해결까지 시간이 오래 걸렸지만 브라우저와 서버 간 이미지 처리 흐름을 체계적으로 알게되서 만족스럽다.

다음에 이미지 업로드 기능을 다시 설계하게 된다면 트러블슈팅 속도가 두 배 이상 빨라지길 기대하며,,, 어제보다 성장한 오늘을 스스로 칭찬👊

0개의 댓글