들어가며
안녕하세요. 요즘 플러피라는 온라인 시험 문제 제작 및 관리 서비스를 개발하고 있습니다. 기존에는 시험 지문에 단순 텍스트만 작성할 수 있었어요. 보통 시험 문제에는 이미지가 필요한 경우가 많아 현재 방식은 문제를 만드는데 불편함이 있었습니다. 그래서 마크다운 에디터를 도입하게 되었고, 이를 활용한 이미지 업로드 기능을 추가하게 되었습니다. 이번 글에서는 마크다운 에디터에 이미지 업로드 후 이미지 링크를 삽입하는 방식을 알아보겠습니다.
개발 환경
현재 플러피 서비스의 웹 클라이언트는 다음과 같은 환경에서 개발되고 있습니다.
- React.js
- TypeScript
- @uiw/react-md-editor
다양한 마크다운 에디터 라이브러리 중 @uiw/react-md-editor를 선택한 이유는 다음과 같습니다.
- React.js 기반
- 활발한 개발 및 유지보수
- 높은 Star 수
- 커스텀 기능 추가가 용이
마크다운 에디터의 이미지 업로드 방식의 문제점
마크다운 라이브러리 중 대부분은 이미지 주소 링크를 통해서 삽입하거나 Base64 방식으로 인코딩해서 이미지를 삽입하는 방식이 있습니다.
@uiw/react-md-editor같은 경우 이미지를 링크를 통해서만 삽입할 수 있습니다. 이 경우 원하는 이미지를 삽입할 수 없고, 이미 존재하는 이미지 주소를 복사해야하는 불편함이 있습니다.

quill이나 toast-ui 같은 경우 Base64 방식으로 이미지를 삽입합니다. Base64 방식은 인코딩 시 용량이 약 33% 정도 늘어납니다. 이미지가 인코딩된 텍스트의 경우 길이가 굉장히 긴데, 이 텍스트가 마크다운 텍스트와 함께 데이터베이스에 저장될 경우 문제가 생길 수 있습니다.

기존 이미지 업로드 방식의 해결 방법
이 방법들을 해결하기 위해서는 이미지를 서버를 통해서 클라우드 스토리지에 저장하고, 저장된 경로를 사용할 수 있습니다. 예를 들어, 깃허브의 이슈 작성 시 이미지를 업로드하면 이미지가 업로드된 경로를 제공해줍니다. 이를 이용하면 이미지를 업로드하고, 업로드된 이미지를 사용할 수 있습니다.

이미지 업로드 시 이미지 주소로 대체하는 것과 다음과 같은 이미지 업로드 방법들을 구현해보겠습니다.
- 클립보드에서 붙여넣는 방식으로 이미지 업로드
- 드래그 앤 드랍 방식으로 이미지 업로드
- 이미지 첨부를 통한 이미지 업로드
이미지 업로드 기능 구현
마크다운 에디터 기본 구조
이미지 업로드 기능을 설명하기에 앞서, 마크다운 에디터 코드의 기본 틀을 알아보겠습니다. MarkdownEditor의 경우 외부에서 시험 아이디, 마크다운 텍스트를 의미하는 value, 그것을 변경하는 함수인 onChange를 props로 받습니다.
마크다운 에디터는 textareaProps를 통해서 placeholder를 지정했고, rehypeSanitize 플러그인을 통해서 XSS에 취약한 문제를 막았습니다.
import MDEditor from '@uiw/react-md-editor';
import rehypeSanitize from 'rehype-sanitize';
interface MarkdownEditorProps {
examId: number;
value: string;
onChange: (value: string) => void;
}
const MarkdownEditor = ({ examId, value, onChange }: MarkdownEditorProps) => {
return (
<div className="mt-4">
<MDEditor
value={value}
onChange={(v) => onChange(v!)}
textareaProps={{
placeholder: '지문이 필요한 경우 입력하세요... (선택사항)',
}}
previewOptions={{
rehypePlugins: [[rehypeSanitize]],
}}
/>
</div>
);
};
export default MarkdownEditor;
이미지 업로드 후 링크 삽입
구현하고자 하는 방식은 다음과 같습니다. 먼저, 이미지 이름을 활용한 임시 텍스트를 삽입하고, 서버를 통한 이미지 업로드가 완료되면, 임시 텍스트를 반환된 이미지 주소로 변환시키는 것입니다.
const handleImageUpload = async (image: File) => {
const imageName = image?.name || '이미지.png';
const loadingText = `<!-- Uploading "${imageName}"... -->`;
const insertMarkdown = insertToTextArea(loadingText); // (1)
if (!insertMarkdown) return;
onChange(insertMarkdown);
const { path } = await ExamAPI.uploadImage({ examId, image }); // (2)
const finalMarkdown = insertMarkdown.replace(loadingText, `})`);
onChange(finalMarkdown);
};
임시 텍스트를 삽입하고, 서버로 이미지를 업로드한 후 임시 텍스트를 이미지 주소로 변환하는 큰 틀의 메서드를 만들었습니다.
임시 텍스트가 들어갈 위치를 찾고, 임시 텍스트가 추가된 마크다운 텍스트를 얻기 위해 insertToTextArea 메서드를 만들어야 합니다. document.querySelector('textarea')를 하는 이유는 마크다운 에디터가 textarea이기 때문입니다. 문제가 될 것 같으면 id를 부여하셔도 됩니다. insertToTextArea의 코드는 지정된 문자열을 커서 위치에 삽입하고, 텍스트 영역을 업데이트합니다. 마지막으로 삽입된 문자열 길이만큼 커서 위치를 조정해줍니다.
const insertToTextArea = (insertString: string) => {
const textarea = document.querySelector('textarea');
if (!textarea) return;
const sentence = textarea.value;
const pos = textarea.selectionStart;
const end = textarea.selectionEnd;
const updatedSentence = sentence.slice(0, pos) + insertString + sentence.slice(pos);
textarea.value = updatedSentence;
textarea.selectionEnd = end + insertString.length;
return updatedSentence;
};
다음으로, 서버로 이미지를 업로드하는 기능을 만들어야 합니다. 이를 위해 FormData 객체에 "image"라는 키를 추가하여 multipart 파일 형식으로 서버에 전송합니다. 서버에서 이미지를 저장하는 방법은 스프링에서 AWS S3를 이용한 이미지 업로드 방법을 참고하시면 좋을 것 같습니다.
스프링에서 AWS S3를 이용한 이미지 업로드 방법
들어가며안녕하세요. 요즘 플러피라는 온라인 시험 문제 제작 및 관리 서비스를 개발하고 있습니다. 기존에는 시험 지문에 단순 텍스트만 작성할 수 있었어요. 보통 시험 문제
alstn113.tistory.com
const { path } = await ExamAPI.uploadImage({ examId, image });
export const ExamAPI = {
uploadImage: async ({ examId, image }: { examId: number; image: File }) => {
const formData = new FormData();
formData.append('image', image);
const { data } = await apiV1Client.post<{ path: string }>(`/exams/${examId}/images`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data;
}
}
(1) 클립보드에서 붙여넣는 방식으로 이미지 업로드
쉬운 이해를 위해서 전체 코드를 먼저 보겠습니다. 새로 생긴 부분은 이미지가 업로드되고 있음을 표시하는 로딩과 handlePasteOrDrop 메서드입니다. MDEdtior의 onPaste의 경우 e.clipboardData의 타입이 DataTransfer이기 때문에 그에 맞는 메서드를 만들어줘야 합니다. handlePasteOrDrop 메서드에서는 파일이 여러 개일 경우 첫 번째를 선택하고, 위에서 설명한 handleImageUpload 메서드를 실행합니다.
import { ExamAPI } from '@/api/examAPI';
import MDEditor from '@uiw/react-md-editor';
import rehypeSanitize from 'rehype-sanitize';
import ImageLoadingSpinner from './ImageLoadingSpinner';
import { FiPaperclip } from 'react-icons/fi';
import { useState } from 'react';
interface MarkdownEditorProps {
examId: number;
value: string;
onChange: (value: string) => void;
}
const MarkdownEditor = ({ examId, value, onChange }: MarkdownEditorProps) => {
const noDragOverText = 'Paste, drop, or click to add files';
const [dropText, setDropText] = useState(noDragOverText);
const [isImageLoading, setIsImageLoading] = useState(false);
const handlePasteOrDrop = async (data: DataTransfer) => {
const files = data.files;
if (!files || !files.length) return;
const image = files.item(0) as File;
await handleImageUpload(image);
};
const handleImageUpload = async (image: File) => {
const imageName = image?.name || '이미지.png';
const loadingText = `<!-- Uploading "${imageName}"... -->`;
const insertMarkdown = insertToTextArea(loadingText);
if (!insertMarkdown) return;
onChange(insertMarkdown);
const { path } = await ExamAPI.uploadImage({ examId, image });
const finalMarkdown = insertMarkdown.replace(loadingText, `})`);
onChange(finalMarkdown);
};
return (
<div className="mt-4">
<MDEditor
value={value}
onChange={(v) => onChange(v!)}
textareaProps={{
placeholder: '지문이 필요한 경우 입력하세요... (선택사항)',
}}
previewOptions={{
rehypePlugins: [[rehypeSanitize]],
}}
onPaste={async (e) => {
setIsImageLoading(true);
await handlePasteOrDrop(e.clipboardData);
setIsImageLoading(false);
}}
/>
<div className="w-full mt-2 hover:bg-gray-100 p-3 rounded-md cursor-pointer transition-colors">
<div className="flex text-gray-500 text-sm">
<div className="flex items-center justify-center gap-2">
{isImageLoading ? <ImageLoadingSpinner /> : <FiPaperclip size={20} />}
<div>{dropText}</div>
</div>
</div>
</div>
</div>
);
};
const insertToTextArea = (insertString: string) => {
const textarea = document.querySelector('textarea');
if (!textarea) return;
const sentence = textarea.value;
const pos = textarea.selectionStart;
const end = textarea.selectionEnd;
const updatedSentence = sentence.slice(0, pos) + insertString + sentence.slice(pos);
textarea.value = updatedSentence;
textarea.selectionEnd = end + insertString.length;
return updatedSentence;
};
export default MarkdownEditor;
아래는 클립보드에서 붙여넣는 방식으로 이미지를 업로드하는 화면입니다.

(2) 드래그 앤 드랍 방식으로 이미지를 업로드
MDEditor의 onDrop, onDragOver, onDragLeave가 추가되었습니다. 이미지가 해당 위치로 드래그 될 경우(onDragOver) 드래그되었다는 의미로 텍스트를 변환하고, 해당 위치에서 드래그가 나갈 경우(onDragLeave) 드래드되지 않았다는 의미로 텍스트를 변환합니다. 해당 위치에 이미지를 드랍할 경우(onDrop) 로딩을 시작하고, handlePasteOrDrop 메서드를 실행합니다. 끝나면 로딩을 취소합니다.
<MDEditor
value={value}
onChange={(v) => onChange(v!)}
textareaProps={{
placeholder: '지문이 필요한 경우 입력하세요... (선택사항)',
}}
previewOptions={{
rehypePlugins: [[rehypeSanitize]],
}}
onPaste={async (e) => {
setIsImageLoading(true);
await handlePasteOrDrop(e.clipboardData);
setIsImageLoading(false);
}}
onDrop={async (e) => {
e.preventDefault();
setIsImageLoading(true);
setDropText(noDragOverText);
await handlePasteOrDrop(e.dataTransfer);
setIsImageLoading(false);
}}
onDragOver={(e) => {
e.preventDefault();
setDropText(dragOverText);
}}
onDragLeave={() => {
setDropText(noDragOverText);
}}
/>
아래는 드래그 앱 드랍 방식으로 이미지를 업로드하는 화면입니다.

(3) 이미지 첨부를 통한 이미지 업로드
이미지 첨부를 위해 useUpload 훅을 만들어보겠습니다. useUpload 훅은 이미지 파일 업로드 기능을 제공합니다. input 태그를 만들어서 파일 선택 창을 열어줍니다. 사용자가 이미지를 선택하면 해당 파일을 상태에 저장하고, 선택된 파일을 콜백 함수에 전달합니다.
import { useState, useCallback } from 'react';
const useUpload = () => {
const [file, setFile] = useState<File | null>(null);
const upload = useCallback((callback: (file: File | null) => void) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = () => {
if (!input.files || input.files.length === 0) return;
const selectedFile = input.files[0];
setFile(selectedFile);
callback(selectedFile);
};
input.click();
}, []);
return { upload, file };
};
export default useUpload;
아래는 추가된 코드입니다. useUpload 훅과 handleUpload 메서드가 추가되었습니다. onClick을 통해 handleUpload 메서드를 실행합니다. handleUpload 메서드는 이미지 선택 창을 열고, 선택되면 로딩 후 서버로 이미지를 업로드합니다. 완료되면, 로딩이 중지됩니다.
const MarkdownEditor = ({ examId, value, onChange }: MarkdownEditorProps) => {
... 생략 ...
const { upload } = useUpload();
const handleUpload = async () => {
upload(async (file) => {
if (!file) return;
setIsImageLoading(true);
await handleImageUpload(file);
setIsImageLoading(false);
});
};
... 생략 ...
return (
<div className="mt-4">
<div
className="w-full mt-2 hover:bg-gray-100 p-3 rounded-md cursor-pointer transition-colors"
onClick={handleUpload} // 추가된 부분
>
<div className="flex text-gray-500 text-sm">
<div className="flex items-center justify-center gap-2">
{isImageLoading ? <ImageLoadingSpinner /> : <FiPaperclip size={20} />}
<div>{dropText}</div>
</div>
</div>
</div>
</div>
);
};
... 생략 ...
export default MarkdownEditor;
아래는 이미지 첨부를 통해 이미지를 업로드하는 화면입니다.

마치며
이번 글에서는 마크다운 에디터에서 이미지를 업로드하는 방법에 대해서 알아보았습니다. 설명을 위한 코드가 길어, 글에서는 간단하게 설명했습니다. 전체 코드는 플러피 서비스 깃허브에서 확인하실 수 있습니다. 피드백이 있으시다면 댓글로 남겨주세요.
GitHub - alstn113/fluffy: 온라인 시험 문제 제작 및 관리 서비스 - 플러피
온라인 시험 문제 제작 및 관리 서비스 - 플러피. Contribute to alstn113/fluffy development by creating an account on GitHub.
github.com
긴 글 읽어주셔서 감사합니다 :D
참고
'웹 프론트엔드' 카테고리의 다른 글
낙관적 업데이트와 디바운스로 [좋아요 기능] 사용자 경험 향상시키기 with Tanstack Query (0) | 2025.01.22 |
---|---|
CSS로 최대 라인 수와 최소 라인 수를 고정하는 방법을 알아보자 (0) | 2025.01.01 |
모바일에서 vh대신 svh를 사용해서 주소창을 제외한 높이를 구해보자 (0) | 2024.12.22 |