
[Trouble Shooting] 붙여넣기된 base64 이미지를 CDN 주소로 갈아끼우자! (feat. tiptap)

🙄 문제 상황
특정 글의 렌더링이 말도 안되게 (무려 5-6초..일 정도로) 느리다는 문제를 마주했다.
렌더링이 정상적으로 되는 글들과 비교해보니, 원인을 알 수 있었다.
바로 느린 글들은 base64로 인코딩된 상태로 저장된 이미지들이 들어가 있다는 것!!
우리는 tiptap 라이브러리를 사용하는데,
tiptap 내장 업로드 기능으로 이미지를 업로드 할 때
이미지를 드래그앤드랍 할 때
S3에 업로드한 후 CDN 이미지 주소를 반환 받는 API 요청이 되도록 구현해놨기 때문에 base64로 저장된 이미지가 있을 수 없었다.
그런데.
여러번 테스트를 해보니 복사된 이미지를 ❗붙여넣기할 때 base64 그대로 들어가고 있다는 사실을 알게 되었다.
사실상 글을 쓸 때 이미지는 붙여넣기를 가장 많이 하다보니, 정말 치명적인 문제였다.
🎢 해결 과정
Step 1️⃣. 일단 붙여넣기된 이미지를 찾아내보자.
✅ tiptap의 onPaste
속성과, document.querySelector
를 이용해 보았다.
const pasteImg : ClipboardEventHandler<HTMLInputElement> = async (event: ClipboardEvent) => {
const targetImg = event.currentTarget.querySelector(
'img:not([src^="https://cdn.palms.blog/"]):not([class^="ProseMirror"]), img[class="ProseMirror-selectednode"]:not([src^="https://cdn.palms.blog/"])',
); // src가 cdn 주소가 아니라는 조건
};
(갑자기 PromiseMirror
같은 것이 들어가 있는 이유는, tiptap 내부 로직에 의해 저 class가 들어간 다른 이미지 태그가 생기더라.. 그래서 명확히 내가 원하는 요소를 얻어내기 위해 추가된 조건.)
Step 2️⃣. API 통신으로 CDN 주소를 받아오자!
const pasteImg: ClipboardEventHandler<HTMLInputElement> = async (event: ClipboardEvent) => {
// clipboardData로부터 파일 얻어오기
const { items } = event.clipboardData;
const fileItem = [...items].filter((item) => item.kind === 'file')[0];
const file = fileItem.getAsFile();
const { currentTarget } = event;
setTimeout(async () => {
// 붙여넣기된 img 요소 가져오기
const targetImg = currentTarget.querySelector(
'img:not([src^="https://cdn.palms.blog/"]):not([class^="ProseMirror"]), img[class="ProseMirror-selectednode"]:not([src^="https://cdn.palms.blog/"])',
);
if (!targetImg) return;
const imgSrc = targetImg.getAttribute('src');
if (!imgSrc) return;
// img url 변환해와서 src 교체
const imgUrl = await getContentCtrlVImage(file as File, String(team));
targetImg.setAttribute('src', imgUrl);
// base64 아닐 때만 sessionStorage 저장
if (!imgSrc.startsWith('data:')) {
sessionStorage.setItem(imgSrc, imgUrl);
}
}, 2500);
};
마지막에 sessionStorage에 저장하는 이유는 바로 다음 step 에 나온다.
Step 3️⃣. 테스트 + 문제 해결
잘 작동하..는 줄 알았는데,
💥 문제 발생) 에디터에서 글 작성시 바꿔놓은 이미지 주소가 다시 원래의 base64나 외부 주소로 돌아가는 문제가 종종 발생한다..!
아마 컨텐츠 변경에 의해 tiptap 내부적으로 리렌더링이 일어나면서 발생하는 일인 것 같다고 유추했다.
이 이미지들이 그대로 최종 발행되는 것을 막기 위해 글을 임시저장하거나 발행하는 시점에 변경되지 않은 이미지들이 있다면 한번에 교체해주자
=> Promise.allSettled
사용
const changeImgSrc = async (team: string) => {
// cdn 주소가 아닌 이미지들 모아const imgList = document.querySelectorAll(
'img:not([src^="https://cdn.palms.blog/"]):not([class="ProseMirror-separator"])',
);
// cdn을 반환 받는 promises 생성const promises = Array.from(imgList).map(async (img) => {
const imgUrl = await getChangedImgSrc(img, String(team));
return imgUrl;
});
const results = await Promise.allSettled(promises);
const tempImgArray = results
.filter((result) => result.status === 'fulfilled')
.map((result) => (result as PromiseFulfilledResult<string>).value);
return tempImgArray;
};
getChangedImgSrc
함수는 다음과 같이 구성되어 있다.
export const getChangedImgSrc = async (img: Element, team: string): Promise<string | undefined> => {
// src뽑아내기const imgSrc = img.getAttribute('src');let blob;
if (!imgSrc) return;
// 외부 링크인 경우if (!imgSrc.startsWith('data:')) {
// sessionStorage에 이미 값 있으면 활용 --> 위에서 sessionStorage에 저장한 이유
const imgUrl = sessionStorage.getItem(imgSrc);
if (imgUrl) img.setAttribute('src', imgUrl);
else {
try {
// 이미지 다운로드 (이미지 URI 인코딩해서 전송) 후 갈아끼우기
const response = await axios.get(`/api/get-image?imageUrl=${encodeURIComponent(imgSrc)}`, {
responseType: 'blob',
});
blob = response.data;
const file = new File([blob], 'image.png', { type: blob.type });
const imgUrl = await getContentCtrlVImage(file, String(team));
img.setAttribute('src', imgUrl);
} catch (error) {
console.log('failed to download image');
}
}
}
//base64인 경우else {
// base64 -> blob 후 갈아끼우기
blob = await fetch(imgSrc).then((res) => res.blob());
const file = new File([blob], 'image.png', { type: blob.type });
const imgUrl = await getContentCtrlVImage(file, String(team));
img.setAttribute('src', imgUrl);
}
};
한 번 cdn 주소 생성 통신을 한 이미지에 대해 또 새로운 cdn 주소를 만드는 것은 불필요한 네트워크 통신이 발생하는 것이므로, 위에서
sessionStorage
에 저장해둔 cdn url 이 있다면 재사용하도록 했다.붙여넣기 이벤트를 감지해서 이미지 url를 교체할 땐 clipboardData 활용이 가능했지만, 이 로직에서는 직접 이미지를 다운로드 받아야 했다.
외부 주소인 경우, blob으로 이미지를 다운로드하는 api 를 구현해주었다.
import axios from 'axios'; import { NextResponse } from 'next/server'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const imageUrl = searchParams.get('imageUrl'); if (!imageUrl) { return NextResponse.json({ error: 'Image URL is required' }, { status: 400 }); } try { // 바이너리 데이터 처리 const response = await axios.get(imageUrl, { responseType: 'arraybuffer', }); const contentType = response.headers['content-type'] || 'application/octet-stream'; const buffer = Buffer.from(response.data, 'binary'); return new NextResponse(buffer, { headers: { 'Content-Type': contentType, }, }); } catch (error) { return NextResponse.json({ error: (error as Error).message }, { status: 500 }); } }
트러블 슈팅한 과정을 글로 적으니 짧아보이지만, 라이브러리 환경에서 문제 해결을 하는 과정이 단순하지만은 않았던 것 같다. 그치만 한 단계씩 이런 저런 방법을 시도해 나가는 과정이 재미있었다!
블로그에서 글 렌더링 속도가 느린건 치명적인 문제였고, 또 DB에도 부담이 가는 문제였기에 해결해내서 아주 뿌듯하고 마음이 편하다 :)


