Toast UI Editor 이미지 업로드

배윤석·2022년 7월 24일
12

Toast UI Editor

목록 보기
1/1
post-thumbnail

🎯 목표

Spring Boots를 사용해 Toast UI Editor에 이미지 업로드

🔪 시작

어느날 미래에서 삽질하고 있을 나를 위해...

현재 국비지원 4개월차이고 팀 프로젝트를 시작하게 되었습니다.
CRUD 기능이 들어가려면 에디터가 필요할거 같았습니다.
하지만 에디터의 기능 구현까지는 힘들거같았고,
API를 사용하기로 하고 검색 많은 검색 끝에 두 가지로 압축되었습니다.

결국에는 TOAST UI Editor로 사용하기로 했는데, 그 이유는 다음과 같습니다.

  1. 네이버 에디터 2.0은 22년 11월 30일에 서비스 종료 (참고 : 네이버블로그) 한다는 것.
  2. 벨로그에 글을 올리다보니 마크다운 문법이 편해진 점. (네이버는 마크다운 지원 안함)

📖 Toast UI Editor

📌 에디터 기능 구현하기

기능 구현을 하기 위해 TOAST UI Editor 공식 홈페이지에서 제공하는 예제를 참고했습니다.

🔗Basic Editor Example

먼저 CDN으로 Editor를 구성하는 JSCSS를 가져와줍니다.

<!-- TOAST UI Editor CDN(JS) -->
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<!-- TOAST UI Editor CDN(CSS) -->
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />

다음은 HTML로 Editor로 변경할 컨테이너 요소를 선언해줍니다.

<div id="editor"></div>

마지막은 JavaScript로 Editor에 옵션을 추가합니다.

const editor = new toastui.Editor({
	el: document.querySelector('#editor'),
	previewStyle: 'vertical',
	height: '500px',
	initialValue: content
});

😀 실행해보면 다음과 같이 에디터가 출력됩니다.


📌 에디터의 옵션

JavaScript의 에디터 초기 옵션들을 확인해 봅시다.
아래 공식문서의 내용들을 참고했습니다.

🔗Toast UI Editor Core Options

  • EL
    - 컨테이너 요소 선택자.
    - Javascript로 불러온 에디터의 옵션을 컨테이너 요소로 선언된 <div> 태그에 부여합니다.
  • previewStyle
    - 에디터의 세로의 크기를 지정합니다.
  • height
    - 에디터의 높이 크기를 지정합니다.
  • initialValue
    - 에디터의 초기 입력 값을 지정합니다.

개인적으로는 에디터에서 글 작성시 형광펜처럼 작성 위치가 표시되는 것이 싫어서 아래 옵션까지 추가해주겠습니다.

  • previewHighlight
    - 에디터의 커서 위치에 해당하는 항목 미리보기 요소 강조 표시

해당 옵션이 헷갈리면 아래 사진을 참고하세요.


📌 여기까지의 코드

이클립스 IDE 에서 JSP 파일로 작성했습니다.

Eclipse IDE 환경정보

Version: 2022-03 (4.23.0)
Build id: 20220310-1457

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
</head>
<body>
<div id="editor"></div>
<script>
const editor = new toastui.Editor({
	el: document.querySelector('#editor'),
	previewStyle: 'vertical',
	previewHighlight: false,
	height: '500px',
	initialValue: ''
});
</script>
</body>
</html>

📖 Toast UI Editor content 출력하기

공식 문서의 인스턴스 메서드 항목에서 확인할 수 있습니다.

🔗Toast UI Editor Core Instance Methods

editor.getHTML();

📌 주의점

여기서 주의점이 있습니다.
Editor 3.x 버전과 Editor 2.x 버전의 getHTML() 메서드 사용 방법이 틀립니다.

  • Editor 3.x 버전 : getHTML()
  • Editor 2.x 버전 : getHtml()

초반에 공식문서를 확인 안하고 구글링으로 가져온 코드를 적용했을때 getHtml is not defined 가 발생해서 당황했는데 저런 문제였습니다...


📌 브라우저에서 content 값 확인하기

방법은 두 가지가 있습니다.

  1. div 태그를 추가하고 선택자를 통해 해당 태그에 값을 입력하는 방법.
document.querySelector('#contents').insertAdjacentHTML('afterbegin', editor.getHTML());
  1. 콘솔창에 출력하는 방법
console.log(editor.getHTML());

📖 Toast UI Editor 이미지 업로드

드디어 이번 포스팅의 목표인 Toast UI Editor를 사용해 이미지를 업로드 해보겠습니다.
기본적으로 Toast UI Editor는 이미지를 업로드기능을 제공합니다.
단, 별다른 설정을 해주지 않으면 base64 형식으로 에디터에 입력됩니다.

📌 Base64 의 단점?

문제는 base64 형식은 해상도가 올라갈수록 글자수가 어마어마하게 늘어난다는 점인데요.

아래 참고사진을 확인해보세요.

단순히 이미지 하나만 넣었을 뿐인데 벌써 스크롤이 어마무시하게 생겼습니다.
글자는 몇 자인지 글자수세기 사이트에서 확인해보면?

고작 311 x 162 사진이 7536 자 입니다.

1920 x 1080 사진을 업로드하면 15만자 가까이 나오기도 합니다.
이러면 사진을 여러 장 업로드하면 DB에 다 업로드되지 않겠죠.


📌 hooks 옵션 사용하기

이럴 때를 대비해 hooks 옵션을 사용합니다.

🔗Toast UI Editor Core Options

  • hooks
    - addImageBlobHook 를 속성으로 가지고 있습니다.
    - 에디터에 업로드되는 이미지를 잠시 가져가 처리를 하고 다시 리턴해줄 수 있습니다.

목표 : Base64 ➡ URL로 바꿔 에디터에 표시하기


📌 순서도

정확하지는 않겠지만 제가 이해한 바는 아래와 같습니다.


📌 프로그램 구조

프로그램의 구조는 다음과 같습니다.


📌 코드 살펴보기

✔ Toast UI Editor - jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
	pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
    <meta charset='utf-8'>
    <meta http-equiv='X-UA-Compatible' content='IE=edge'>
    <title>Page Title</title>
    <meta name='viewport' content='width=device-width, initial-scale=1'>
    
    <!-- 디자인 수정용 CSS 추가 -->
    <style>
        #editor {
            /* border : 1px solid; */
            width: 70%;
            margin: 0 auto;
        }
        /* editor content 받을 div태그 스타일 추가. */
        #contents {
            width:50%;
            height: 100px;
            margin: 30px auto;
            border: 1px solid;
        }
        #accordion {
        	width: 70%;
        	margin: 0 auto;
        }
    </style>
 	<!-- jQuery CDN -->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
    <!-- jQuery UI CDN -->
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
	<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.min.js"></script>
	<!-- jQuery UI CSS CDN -->
	<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.css"/>
    <!-- codemirror CDN URL -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.48.4/codemirror.min.css"/>
	<!-- TOAST UI Editor CDN URL(CSS) -->
	<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />
	<!-- TOAST UI Editor CDN URL(JS) -->
	<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
    
    <script>
    	$(function() {
			$('#accordion').accordion({
				// jQuery UI accordion 본문 축소기능 활성화
				collapsible: true,
				active: false
			});
		});
    </script>
</head>
<body>
    <h1> TOAST UI Editor 만들기 </h1>
    
    <!-- Markdown을 설명할 accordion이 들어갈 div태그 -->
    <div id="accordion">
    	<h3>마크다운 편집기가 처음이신가요?</h3>
    	<div>
    		<p>다음 내용을 따라오세요.<br><br><strong>목차입니다.</strong></p>
    		<ol>
    			<li>문단 제목</li>
    			<li>굵은 글씨</li>
    			<li>기울이기</li>
    			<li>취소선</li>
    			<li>수평 가로선 생성</li>
    			<li>인용문</li>
    			<li>순서 없는 목차</li>
    			<li>순서 있는 목차</li>
    		</ol>
    	</div>
    </div>
    
    <!-- TOAST UI Editor가 들어갈 div태그 -->
    <div id="editor"></div>
    
    <!-- TOAST UI Editor 생성 JavaScript 코드 -->
    <script>
		const editor = new toastui.Editor({
		    el: document.querySelector('#editor'),
		    previewStyle: 'vertical',
		    previewHighlight: false,
		    height: '700px',
		    // 사전입력 항목
		    initialValue: '# 안녕하세요. 제목입니다.\n### 사전입력 테스트\n본문본문본문\n\n',
		    // 이미지가 Base64 형식으로 입력되는 것 가로채주는 옵션
		    hooks: {
		    	addImageBlobHook: (blob, callback) => {
		    		// blob : Java Script 파일 객체
		    		//console.log(blob);
		    		
		    		const formData = new FormData();
		        	formData.append('image', blob);
		        	
		        	let url = '/images/';
		   			$.ajax({
		           		type: 'POST',
		           		enctype: 'multipart/form-data',
		           		url: '/writeTest.do',
		           		data: formData,
		           		dataType: 'json',
		           		processData: false,
		           		contentType: false,
		           		cache: false,
		           		timeout: 600000,
		           		success: function(data) {
		           			//console.log('ajax 이미지 업로드 성공');
		           			url += data.filename;
		           			
		           			// callback : 에디터(마크다운 편집기)에 표시할 텍스트, 뷰어에는 imageUrl 주소에 저장된 사진으로 나옴
		        			// 형식 : ![대체 텍스트](주소)
		           			callback(url, '사진 대체 텍스트 입력');
		           		},
		           		error: function(e) {
		           			//console.log('ajax 이미지 업로드 실패');
		           			//console.log(e.abort([statusText]));
		           			
		           			callback('image_load_fail', '사진 대체 텍스트 입력');
		           		}
		           	});
		    	}
		    }
		});

        // editor.getHtml()을 사용해서 에디터 내용 수신
        //document.querySelector('#contents').insertAdjacentHTML('afterbegin' ,editor.getHTML());
        // 콘솔창에 표시
        //console.log(editor.getHTML());
        
    </script>   
</body>
</html>

✔ View and File Save Controller - java

package com.bookha.controller;

import java.io.File;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.ModelAndView;

import com.bookha.imageSave.FileNameModel;

@RestController
public class WriteEditorTestController {
	
	private String path = "d:/imageSaveStorage/";

	@RequestMapping(value = "/writeTest.do", method = RequestMethod.GET)
	public ModelAndView writeTestGet(HttpServletRequest request, HttpServletResponse response) {
		ModelAndView mv = new ModelAndView();
		mv.setViewName("toast_UI_writer3");
		return mv;
	}
	
	@RequestMapping(value = "/writeTest.do", method = RequestMethod.POST)
	public ModelAndView writeTestPost(@RequestParam("image") MultipartFile multi, HttpServletRequest request, HttpServletResponse response) {
		
		String url = null;
		ModelAndView mv = new ModelAndView();
		
		try {
			String uploadPath = path;
			String originFilename = multi.getOriginalFilename();
			String extName = originFilename.substring(originFilename.lastIndexOf("."), originFilename.length());
			long size = multi.getSize();
			FileNameModel fileNameModel = new FileNameModel();
			String saveFileName = fileNameModel.GenSaveFileName(extName);
			
			if(!multi.isEmpty()) {
				File file = new File(uploadPath, saveFileName);
				multi.transferTo(file);
				
				mv.addObject("filename", saveFileName);
				mv.addObject("uploadPath", file.getAbsolutePath());
				mv.addObject("url", uploadPath+saveFileName);
				System.out.println("url : " + uploadPath+saveFileName);
				
				mv.setViewName("image_Url_Json");
			} else {
				mv.setViewName("toast_UI_writer3");
			}
		} catch (Exception e) {
			// TODO: handle exception
			System.out.println("[Error] " + e.getMessage());
		}
		return mv;
	}
}

✔ File Name Model - java

package com.bookha.imageSave;

import java.util.Calendar;

public class FileNameModel {
	
	public FileNameModel() {
		// TODO Auto-generated constructor stub
	}
	
	// 메서드 사용 시간 기준으로 파일 이름 생성
	public String GenSaveFileName(String extName) {
		// TODO Auto-generated constructor stub
		String fileName = "";
		
		Calendar calendar = Calendar.getInstance();
		fileName += calendar.get(Calendar.YEAR);
		fileName += calendar.get(Calendar.MONTH);
		fileName += calendar.get(Calendar.DATE);
		fileName += calendar.get(Calendar.HOUR);
		fileName += calendar.get(Calendar.MINUTE);
		fileName += calendar.get(Calendar.SECOND);
		fileName += calendar.get(Calendar.MILLISECOND);
		fileName += extName;
		
		return fileName;
	}
}

✔ Image Controller - java

package com.bookha.controller;

import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@RestController
public class ImageController implements WebMvcConfigurer {

	@Override
	public void addResourceHandlers(ResourceHandlerRegistry registry) {
		// TODO Auto-generated method stub
		registry
			// 이미지 파일의 요청 경로를 지정한다.
			.addResourceHandler("/images/**")
			// 이미지 파일을 불러올 로컬 저장소의 위치를 지정한다.
			.addResourceLocations("file:/d:/imageSaveStorage/");
	}
}

✔ image URL json - jsp

<%@ page language="java" contentType="text/plain; charset=UTF-8"
    pageEncoding="UTF-8" trimDirectiveWhitespaces="true"%>

<%
	request.setCharacterEncoding("UTF-8");

	String url = (String)request.getAttribute("url");
	String filename = (String)request.getAttribute("filename");
	
	StringBuilder sbHtml = new StringBuilder();
	
	sbHtml.append("{");
	sbHtml.append("\"url\" : \""+url+"\",");
	sbHtml.append("\"filename\" : \""+filename+"\"");
	sbHtml.append("}");
	
	out.println(sbHtml);
%>

🧐 배운 점

  • 동기 / 비동기
  • File 객체를 전송할 때는 FormData에 append하여 전송한다.
  • ajax 비동기 처리
  • callback
  • promise
  • async await

🤔 아쉬운 점

  • addImageBlobHook 의 callback을 리턴할 때 ajax는 함수화 시켜서 사용해보고자 했는데 실패했다.
    정확히는 ajax를 외부에 함수를 만들어 이미지 URL을 가져오려는데 콘솔에는 정상적으로 출력되는데, 에디터에는 Object Promise로만 리턴되서 결국에는 그냥 hooks 옵션함수 내부에 ajax를 작성했다.
    (new Promise, async await 사용해봐도 Object Promise로만 리턴됨...)
    나중에 기회가 되면 ajax를 함수로해서 깔끔하게 코드작성 해봐야겠다.

  • 이미지를 업로드하고 가져오는 방법을 몰라 구글링했을때, 블로그에 올라온 글들이 모두 React로 처리하는 방법만 포스팅을 해서 무슨 말인지 알 수가 없었다.
    자바스크립트로만 처리하는 방법을 찾으려고 거의 대부분의 시간을 사용한거 같다.
    리액트도 공부해야봐겠다...

  • 깃허브로 코드 올리면 포스팅도 조금 더 깔끔해지기도 하고 전체 코드를 올릴 수도 있을텐데 아쉽다.
    벨로그가 인용문 접기 기능좀 지원해줬으면 좋겠다...!
    몇 일 전에 깃허브 계정은 생성했는데 깃허브도 공부해야봐겠다.

  • 이미지 업로드 하고 삭제했을때 조치는 어떻게 해야할까?
    글 수정하면서 사용하지 않는 이미지 파일들(쓰레기 파일들)이 생성되는데 이거 조치하는 방법도 생각해봐야겠다.

profile
차근차근 한 걸음씩 걸어나가는 개발자 꿈나무.

1개의 댓글

comment-user-thumbnail
2023년 7월 6일

감사합니다. 비슷한 상황에서 검색하고 있는데 좋은 래퍼런스 감사합니다

답글 달기