본문 바로가기
Open-source/Editor

[TOAST UI Editor] - 이미지 업로드(Image upload) 개선하기 & 데이터베이스(Database)에 에디터 내용 저장 및 불러오기

by 도뎡 2023. 8. 1.
반응형

이전 글에서는 TOAST UI Editor(이하 'TUI 에디터')가 무엇인지 가볍게 알아보고, 순수 자바스크립트(JavaScript) 코드로 HTML에 에디터를 적용해 데이터를 입력해 보았습니다.

이번에는 TUI 에디터의 이미지 업로드 기능을 개선한 후, 에디터에 입력된 내용을 DB에 저장 및 조회하는 기능을 구현해 볼 건데요. 스프링 부트(Spring Boot) 환경에서 H2 Database와 JPA를 연동해 데이터를 처리합니다.

 

 

이번 글은 2번 링크('H2 Database와 JPA 연동해 보기')의 설정과 동일한 환경에서 진행되는데요. 기존에 사용하던 데이터베이스가 있으신 분들은 본인의 환경에서 진행하셔도 무관합니다.

H2 데이터베이스를 사용해보고 싶으신 분들께서는, 2번 링크에서 프로젝트 생성 및 설정을 하신 후 이번 글을 차근차근 진행해 주시면 되겠습니다 :)


2번 링크의 프로젝트를 import 해서 진행하시는 분들께서는
application.yml에서 datasource의 username과 password를 변경한 후 진행해 주세요!


 

 

1. 페이지(HMTL) 구성하기

스프링 부트 환경에서는 일반적으로 타임리프(Thymeleaf)라는 뷰 템플릿 엔진을 이용해서 화면 처리를 하게 되는데요. 본 글에서는 순수 HTML 파일만을 이용해서 화면을 처리합니다.

스프링 부트는 정적 리소스 파일(HTML, CSS, JS)을 src/main/resources(이하 'resources') 안에 관리하는데, resources의 하위 폴더인 static 안에 있는 파일에는 컨트롤러 없이도 접근이 가능합니다.

 

1-1. 정적 리소스 파일 접근 방법 알아보기

먼저, resources 디렉터리의 전체적인 구조는 아래와 같습니다.

본 글에서 에디터에 이미지를 업로드하고, DB에 데이터를 저장 및 조회하는 과정은 resources/static/post 안에 있는 세 개의 HTML 파일을 통해 이루어지니, 우선 resources 디렉터리 구조를 아래와 동일하게 맞춰주세요.

resources 디렉터리 구조

 

이제, 스프링 부트 앱을 실행한 후 'localhost:[port]/[정적 파일명]'으로 접근하시면 되는데요. 여기서 포인트는 resources/static 경로는 주소에서 생략할 수 있다는 점이며, 아래 이미지를 보시면 쉽게 이해가 되실 겁니다.

index.html 접근 결과

 

다른 예시로, 이번에는 resources/static/post/list.html에 접근한 결과입니다.

list.html 접근 결과

 

2. 글쓰기 페이지에 TUI 에디터 적용하기

이미지 업로드와 데이터 저장은 모두 글쓰기 페이지에서 이루어지기 때문에 write.html을 가장 먼저 처리해야 합니다. 1-1에서 생성한 write.html에 아래 소스 코드를 작성해 주세요.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>글쓰기 페이지</title>

    <!-- TUI 에디터 CSS CDN -->
    <link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />
</head>
<body>
    <h2 style="text-align: center;">TOAST UI Editor 글쓰기 페이지</h2>

    <!-- 에디터를 적용할 요소 (컨테이너) -->
    <div id="content">

    </div>

    <!-- TUI 에디터 JS CDN -->
    <script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
    <script>
        const editor = new toastui.Editor({
            el: document.querySelector('#content'), // 에디터를 적용할 요소 (컨테이너)
            height: '500px',                        // 에디터 영역의 높이 값 (OOOpx || auto)
            initialEditType: 'markdown',            // 최초로 보여줄 에디터 타입 (markdown || wysiwyg)
            initialValue: '',                       // 내용의 초기 값으로, 반드시 마크다운 문자열 형태여야 함
            previewStyle: 'vertical',               // 마크다운 프리뷰 스타일 (tab || vertical)
            placeholder: '내용을 입력해 주세요.',
        });
    </script>
</body>
</html>

 

2-1) 글쓰기 페이지 접속해 보기

코드를 작성한 후 write.html에 접근해 보면, 글쓰기 페이지에 에디터가 적용된 것을 확인하실 수 있습니다.

글쓰기 페이지 - 에디터 적용 결과

 

2-2) 이미지 업로드 해보기

TUI 에디터는 별다른 설정 없이 에디터에 이미지를 업로드할 수 있습니다. 저는 마크다운(Markdown) 모드로 브라우저에서 다운로드한 무료 이미지를 추가해 보겠습니다.

TUI 에디터 이미지 업로드 방법

 

보시다시피 이미지는 정상적으로 올라갔습니다. 그런데, 화면 좌측에 '알 수 없는 긴 문자열'이 삽입되고 있습니다.

 

좌측에 삽입되는 긴 문자열은, 에디터에 업로드한 이미지를 'base64'라는 방식으로 인코딩 한 결괏값입니다. 제가 업로드한 free.jpg는 불과 70kb의 작은 파일입니다.

그러나, 인코딩 된 문자열의 길이(length)는 무려 '94598자'나 됩니다. 만약 이 문자열을 DB에 저장하게 된다면, 엄청난 공간 낭비는 물론이고, 네트워크(트래픽)에도 부하가 생길 수밖에 없습니다.

 

3. addImageBlobHook으로 이미지 업로드 로직 커스텀하기

NHN github.io에 명시되어 있는 'hooks' 옵션의 'addImageBlobHook'을 이용하면, 업로드한 파일 정보를 'Blob 또는 File 타입의 객체'로 전달받을 수 있습니다.

우리는 이를 이용해, 에디터에 업로드한 이미지를 파일 시스템(디스크)에 저장하는 구조로 변경합니다.

hooks 옵션 (출처 : NHN github.io)

 

3-1) 에디터(Editor) 생성자 코드 수정하기

우선 write.hmtl의 자바스크립트 코드에서, editor 객체 생성자 옵션에 hooks 옵션을 추가해 주세요.

    const editor = new toastui.Editor({
        el: document.querySelector('#content'), // 에디터를 적용할 요소 (컨테이너)
        height: '500px',                        // 에디터 영역의 높이 값 (OOOpx || auto)
        initialEditType: 'markdown',            // 최초로 보여줄 에디터 타입 (markdown || wysiwyg)
        initialValue: '',                       // 내용의 초기 값으로, 반드시 마크다운 문자열 형태여야 함
        previewStyle: 'vertical',               // 마크다운 프리뷰 스타일 (tab || vertical)
        placeholder: '내용을 입력해 주세요.',
        /* start of hooks */
        hooks: {
            addImageBlobHook(blob, callback) {  // 이미지 업로드 로직 커스텀
                console.log(blob);
                console.log(callback);
            }
        }
        /* end of hooks */
    });

 

3-2) 이미지 재업로드 해보기

코드를 수정한 후 다시 파일을 업로드해 보면, addImageBlobHook의 첫 번째 파라미터인 blob으로 파일(File) 객체가 넘어오는 걸 확인하실 수 있습니다.

blob으로 넘어온 파일 정보

 

아래 함수는 이미지 업로드 시 TUI 에디터 내부에서 실행되는 로직의 일부이며, 네모 박스 안의 함수는 callback 함수를 호출했을 때 에디터에 이미지를 추가해 주는 역할을 합니다.

이미지 업로드 시 실행되는 로직의 일부

 

4. 파일 시스템(디스크)에 이미지 파일 업로드하기

이미지를 업로드했을 때 업로드한 파일 객체를 전달받는 방법을 알았으니, 이제 남은 건 서버 사이드로 파일을 전송해서 디스크에 업로드한 후, callback 함수를 이용해서 업로드된 이미지를 에디터에 렌더링 해주는 일뿐입니다.

 

4-1) 업로드 이미지 처리용 컨트롤러 생성하기

저는 이미지 파일이 업로드되는 디렉터리 경로를 컨트롤러 전역 변수인 uploadDir에 선언했는데요. 업로드 경로는 여러분이 원하시는 곳으로 선언하셔도 무관합니다.

package com.study.domain.file;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.UUID;

@RestController
@RequestMapping("/tui-editor")
public class FileApiController {

    // 파일을 업로드할 디렉터리 경로
    private final String uploadDir = Paths.get("C:", "tui-editor", "upload").toString();

    /**
     * 에디터 이미지 업로드
     * @param image 파일 객체
     * @return 업로드된 파일명
     */
    @PostMapping("/image-upload")
    public String uploadEditorImage(@RequestParam final MultipartFile image) {
        if (image.isEmpty()) {
            return "";
        }

        String orgFilename = image.getOriginalFilename();                                         // 원본 파일명
        String uuid = UUID.randomUUID().toString().replaceAll("-", "");           // 32자리 랜덤 문자열
        String extension = orgFilename.substring(orgFilename.lastIndexOf(".") + 1);  // 확장자
        String saveFilename = uuid + "." + extension;                                             // 디스크에 저장할 파일명
        String fileFullPath = Paths.get(uploadDir, saveFilename).toString();                      // 디스크에 저장할 파일의 전체 경로

        // uploadDir에 해당되는 디렉터리가 없으면, uploadDir에 포함되는 전체 디렉터리 생성
        File dir = new File(uploadDir);
        if (dir.exists() == false) {
            dir.mkdirs();
        }

        try {
            // 파일 저장 (write to disk)
            File uploadFile = new File(fileFullPath);
            image.transferTo(uploadFile);
            return saveFilename;

        } catch (IOException e) {
            // 예외 처리는 따로 해주는 게 좋습니다.
            throw new RuntimeException(e);
        }
    }

    /**
     * 디스크에 업로드된 파일을 byte[]로 반환
     * @param filename 디스크에 업로드된 파일명
     * @return image byte array
     */
    @GetMapping(value = "/image-print", produces = { MediaType.IMAGE_GIF_VALUE, MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE })
    public byte[] printEditorImage(@RequestParam final String filename) {
        // 업로드된 파일의 전체 경로
        String fileFullPath = Paths.get(uploadDir, filename).toString();

        // 파일이 없는 경우 예외 throw
        File uploadedFile = new File(fileFullPath);
        if (uploadedFile.exists() == false) {
            throw new RuntimeException();
        }

        try {
            // 이미지 파일을 byte[]로 변환 후 반환
            byte[] imageBytes = Files.readAllBytes(uploadedFile.toPath());
            return imageBytes;

        } catch (IOException e) {
            // 예외 처리는 따로 해주는 게 좋습니다.
            throw new RuntimeException(e);
        }
    }

}

 

  • uploadEditorImage( ) : 에디터에서 이미지를 업로드했을 때 'blob'으로 넘어오는 File 객체를 이용해서 디스크에 파일을 저장합니다.
  • printEditorImage( ) : 디스크에 저장된 이미지 파일을 byte array로 변환해서 에디터에 이미지를 렌더링 합니다.

 

4-2)  addImageBlobHook 수정하기

이제, 에디터에 이미지를 업로드했을 때 FileApiController에 선언한 두 메서드가 실행되도록 처리해 주어야 합니다.

write.html의 자바스크립트 영역에서 editor 생성자 코드를 아래와 같이 다시 한번 수정해 주세요.

    const editor = new toastui.Editor({
        el: document.querySelector('#content'),      // 에디터를 적용할 요소 (컨테이너)
        height: '500px',                             // 에디터 영역의 높이 값 (OOOpx || auto)
        initialEditType: 'markdown',                 // 최초로 보여줄 에디터 타입 (markdown || wysiwyg)
        initialValue: '',                            // 내용의 초기 값으로, 반드시 마크다운 문자열 형태여야 함
        previewStyle: 'vertical',                    // 마크다운 프리뷰 스타일 (tab || vertical)
        placeholder: '내용을 입력해 주세요.',
        /* start of hooks */
        hooks: {
            async addImageBlobHook(blob, callback) { // 이미지 업로드 로직 커스텀
                try {
                    /*
                     * 1. 에디터에 업로드한 이미지를 FormData 객체에 저장
                     *    (이때, 컨트롤러 uploadEditorImage 메서드의 파라미터인 'image'와 formData에 append 하는 key('image')값은 동일해야 함)
                     */
                    const formData = new FormData();
                    formData.append('image', blob);

                    // 2. FileApiController - uploadEditorImage 메서드 호출
                    const response = await fetch('/tui-editor/image-upload', {
                        method : 'POST',
                        body : formData,
                    });

                    // 3. 컨트롤러에서 전달받은 디스크에 저장된 파일명
                    const filename = await response.text();
                    console.log('서버에 저장된 파일명 : ', filename);

                    // 4. addImageBlobHook의 callback 함수를 통해, 디스크에 저장된 이미지를 에디터에 렌더링
                    const imageUrl = `/tui-editor/image-print?filename=${filename}`;
                    callback(imageUrl, 'image alt attribute');

                } catch (error) {
                    console.error('업로드 실패 : ', error);
                }
            }
        }
        /* end of hooks */
    });

 

4-3) 다시 이미지 업로드 해보기

FileApiController의 uploadEditorImage 메서드 실행이 완료된 후에 확인해 보니, uploadDir에 해당되는 경로에 파일이 정상적으로 저장되었습니다.

업로드 경로에 저장된 샘플 이미지

 

아래 이미지는 두 개의 샘플 이미지를 업로드한 결과입니다.

샘플 이미지 다중 업로드 결과

 

5. 에디터 데이터를 처리할 백엔드 사이드 구성하기

이제, 에디터에 입력한 내용을 HTML 형태로 불러와 DB에 저장 및 조회하기만 하면 되는데요. 화면 처리는 거의 완료되었으니, 데이터를 처리할 자바 파일을 구성할 차례입니다.

필요한 자바 파일들은 다음과 같습니다. 패키지 구조에 알맞게 자바 파일을 생성해 주시면 됩니다.

 

  • Post : 에디터 데이터를 관리할 엔티티(Entity) 클래스
  • PostRepository : Post와 연결되며, 기본적인 CRUD 쿼리를 담당할 레파지토리 인터페이스
  • PostRequest : 에디터 데이터를 저장(Insert) 및 수정(Update) 처리할 요청 담당 클래스
  • PostResponse : 에디터 데이터를 조회(Select) 처리할 응답 담당 클래스
  • PostService : 에디터와 관련된 모든 비즈니스 로직을 담당할 서비스 클래스
  • PostApiController : 에디터 데이터를 처리할 API 통신용 컨트롤러 클래스

 

5-1) 엔티티(Entity) 클래스 생성하기

package com.study.domain.post;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "tb_post")
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String title;

    @Column(nullable = false, length = 3000)
    private String content;

    @Builder
    public Post(String title, String content) {
        this.title = title;
        this.content = content;
    }

}

 

5-2) 레파지토리(Repository) 인터페이스 생성하기

package com.study.domain.post;

import org.springframework.data.jpa.repository.JpaRepository;

public interface PostRepository extends JpaRepository<Post, Long> {
}

 

5-3) 요청(Request) 클래스 생성하기

package com.study.domain.post;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PostRequest {

    private String title;
    private String content;

    public Post toEntity() {
        return Post.builder()
                .title(title)
                .content(content)
                .build();
    }

}

 

5-4) 응답(Response) 클래스 생성하기

package com.study.domain.post;

import lombok.Getter;

@Getter
public class PostResponse {

    private Long id;
    private String title;
    private String content;

    public PostResponse(Post post) {
        this.id = post.getId();
        this.title = post.getTitle();
        this.content = post.getContent();
    }

}

 

5-5) 서비스(Service) 클래스 생성하기

package com.study.domain.post;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;

    // 게시글 저장
    @Transactional
    public Long savePost(final PostRequest params) {
        Post post = postRepository.save(params.toEntity());
        return post.getId();
    }

    // 게시글 상세정보 조회
    @Transactional(readOnly = true)
    public PostResponse findPostById(final Long id) {
        Post post = postRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("post not found : " + id));
        return new PostResponse(post);
    }

    // 게시글 목록 조회
    @Transactional(readOnly = true)
    public List<PostResponse> findAllPost() {
        List<Post> posts = postRepository.findAll();
        return posts.stream()
                .map(post -> new PostResponse(post))
                .collect(Collectors.toList());
    }

}

 

5-6) API 통신용 컨트롤러(Controller) 클래스 생성하기

package com.study.domain.post;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostApiController {

    private final PostService postService;

    // 게시글 저장
    @PostMapping
    public Long savePost(@RequestBody final PostRequest params) {
        return postService.savePost(params);
    }

    // 게시글 상세정보 조회
    @GetMapping("/{id}")
    public PostResponse findPostById(@PathVariable final Long id) {
        return postService.findPostById(id);
    }

    // 게시글 목록 조회
    @GetMapping
    public List<PostResponse> findAllPost() {
        return postService.findAllPost();
    }

}

 

6. 애플리케이션 실행해 보기

이제, 애플리케이션을 실행한 후 IDE 콘솔을 확인해 보면, 'tb_post' 테이블을 생성하는 DDL 쿼리가 실행되는 걸 확인하실 수 있습니다.

tb_post는 Post 엔티티의 @Table 어노테이션의 name으로 선언된 테이블명으로, 엔티티와 테이블명을 구분하고 싶을 땐 @Table을 이용하시면 됩니다.

tb_post 테이블 생성 로그

 

H2 데이터베이스에도 테이블이 정상적으로 생성되었습니다.

H2 데이터베이스 - tb_post 테이블 생성 결과

 

7. 에디터 콘텐츠 저장하기

이제 PostApiController의 URI를 호출해서 데이터를 저장 및 조회할 일만 남았으며, 우선순위는 당연히 데이터를 저장하는 일입니다.

 

7-1) write.html 수정하기

우선 write.html을 아래 코드와 같이 변경해 주시면 되는데요. 화면 영역에는 저장하기 버튼, 뒤로 가기 버튼, 스타일이 추가되었고, 자바스크립트 영역에는 저장하기 버튼을 클릭했을 때 실행할 'savePost' 함수가 추가되었습니다.


디프체커를 이용하면 기존 코드와 변경된 코드를 쉽게 비교해 보실 수 있습니다.


<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>글쓰기 페이지</title>

    <!-- TUI 에디터 CSS CDN -->
    <link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />

    <!-- 버튼 영역 CSS -->
    <style>
        #btnDiv { text-align: center; margin-top: 20px; }
        .btns { display: inline-block; padding: 0 10px; height: 28px; line-height: 26px; text-align: center; vertical-align: middle; border-radius: 3px; border: 1px solid transparent; font-weight: 500; }
        .btns.save { background: #139dc8; color: #fff; cursor: pointer; }
        .btns.back { background: #fff; border: 1px solid #199bc4; color: #199bc4; }
    </style>
</head>
<body>
    <h2 style="text-align: center;">TOAST UI Editor 글쓰기 페이지</h2>

    <!-- 에디터를 적용할 요소 (컨테이너) -->
    <div id="content">

    </div>

    <div id="btnDiv">
        <button type="button" class="btns save" onclick="savePost();">저장하기</button>
        <a href="/post/list.html" class="btns back">뒤로 가기</a>
    </div>

    <!-- TUI 에디터 JS CDN -->
    <script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
    <script>
        const editor = new toastui.Editor({
            el: document.querySelector('#content'),      // 에디터를 적용할 요소 (컨테이너)
            height: '500px',                             // 에디터 영역의 높이 값 (OOOpx || auto)
            initialEditType: 'markdown',                 // 최초로 보여줄 에디터 타입 (markdown || wysiwyg)
            initialValue: '',                            // 내용의 초기 값으로, 반드시 마크다운 문자열 형태여야 함
            previewStyle: 'vertical',                    // 마크다운 프리뷰 스타일 (tab || vertical)
            placeholder: '내용을 입력해 주세요.',
            /* start of hooks */
            hooks: {
                async addImageBlobHook(blob, callback) { // 이미지 업로드 로직 커스텀
                    try {
                        /*
                         * 1. 에디터에 업로드한 이미지를 FormData 객체에 저장
                         *    (이때, 컨트롤러 uploadEditorImage 메서드의 파라미터인 'image'와 formData에 append 하는 key('image')값은 동일해야 함)
                         */
                        const formData = new FormData();
                        formData.append('image', blob);

                        // 2. FileApiController - uploadEditorImage 메서드 호출
                        const response = await fetch('/tui-editor/image-upload', {
                            method : 'POST',
                            body : formData,
                        });

                        // 3. 컨트롤러에서 전달받은 디스크에 저장된 파일명
                        const filename = await response.text();
                        console.log('서버에 저장된 파일명 : ', filename);

                        // 4. addImageBlobHook의 callback 함수를 통해, 디스크에 저장된 이미지를 에디터에 렌더링
                        const imageUrl = `/tui-editor/image-print?filename=${filename}`;
                        callback(imageUrl, 'image alt attribute');

                    } catch (error) {
                        console.error('업로드 실패 : ', error);
                    }
                }
            }
            /* end of hooks */
        });


        // 게시글 저장
        async function savePost() {
            // 1. 콘텐츠 입력 유효성 검사
            if (editor.getMarkdown().length < 1) {
                alert('에디터 내용을 입력해 주세요.');
                throw new Error('editor content is required!');
            }

            // 2. url, parameter 세팅
            const url = '/api/posts';
            const params = {
                title: '1번 게시글 제목',
                content: editor.getHTML(),
            }

            // 3. API 호출
            try {
                const response = await fetch(url, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify(params),
                });

                const postId = await response.json();
                alert(postId + '번 게시글이 저장되었습니다.');
                location.href = '/post/list.html';

            } catch (error) {
                console.error('저장 실패 : ', error)
            }
        }
    </script>
</body>
</html>

 

7-2) 글쓰기 페이지 접속해 보기

변경된 글쓰기 페이지의 화면 구조는 다음과 같습니다.

버튼이 추가된 글쓰기 페이지 화면 구조

 

7-3) savePost 함수 구조 알아보기

먼저 1번 로직입니다. 전역 변수인 editor 객체의 getMarkdown 함수를 이용해 에디터에 입력된 내용을 조회한 후, 길이가 1보다 작은, 즉 아무 내용도 입력되지 않은 경우에는 '내용 입력' 메시지를 출력한 후 로직을 종료합니다.

// 1. 콘텐츠 입력 유효성 검사
if (editor.getMarkdown().length < 1) {
    alert('에디터 내용을 입력해 주세요.');
    throw new Error('editor content is required!');
}

 

여기서 포인트는 에디터에 입력된 내용을 HTML이 아닌 Markdown으로 가져온다는 점입니다. 아래 두 이미지 우측의 Console을 눈여겨 봐주시기 바랍니다.

입력된 내용 없이 getHTML을 호출한 결과

 

입력된 내용 없이 getMarkdown을 호출한 결과

 

두 이미지 모두 에디터에 아무런 내용이 입력되지 않았는데요. getHTML을 호출했을 때는 p와 br 태그를 가져오고, getMarkdown을 호출했을 때는 빈 문자열(' ')을 가져옵니다.

즉, 내용이 입력되었는지 확인하기 위해서는 getMarkdown을 이용하는 게 훨씬 편리합니다. 만약 getHTML로 유효성 검사를 한다고 가정하면, 정규식 등을 이용해 태그를 모두 제거해야 하는 등의 번거로움이 생길 테니까요.

 

 

다음은 2번 로직입니다. 설명이 필요 없을 정도로 심플합니다. PostApiController의 savePost 메서드를 호출할 URL과, PostRequest 타입의 파라미터인 params로 전달할 데이터를 세팅합니다.

DB에는 Markdown이 아닌 HTML을 저장해야 하기 때문에, getHTML을 이용해서 에디터에 입력된 내용을 HTML 형태로 content에 저장합니다.

// 2. url, parameter 세팅
const url = '/api/posts';
const params = {
    title: '1번 게시글 제목',
    content: editor.getHTML(),
}

 

 

마지막으로 3번 로직입니다. 브라우저에 내장된 함수인 fetch API를 이용해서 PostApiController의 savePost 메서드를 호출합니다.

서버로부터 요청(request)에 대한 응답(response)이 내려오면 '저장 완료' 메시지를 보여준 후 리스트 페이지로 이동시킵니다.

// 3. API 호출
try {
    const response = await fetch(url, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(params),
    });

    const postId = await response.json();
    alert(postId + '번 게시글이 저장되었습니다.');
    location.href = '/post/list.html';

} catch (error) {
    console.error('저장 실패 : ', error)
}

 

 

7-4) 데이터 저장해 보기

저는 다음과 같이 세 개의 이미지를 업로드한 후 데이터를 저장해 보았습니다.
(참고로, 업로드한 이미지를 모두 보여드리기 위해 height를 강제로 변경했습니다.)

Markdown 모드로 저장할 데이터

 

'저장하기'를 클릭해보면, '저장 완료' 알림도 잘 출력되고, 데이터도 정상적으로 DB에 들어가고 있습니다.

데이터 저장 완료 알림

 

tb_post 테이블 조회 결과

 

8. 에디터 콘텐츠 조회하기

다음은 DB에 저장된 데이터를 조회할 차례입니다.

 

8-1) list.html 수정하기

가장 먼저 리스트 페이지입니다. list.html을 아래 코드와 같이 변경해 주시면 되는데요. 딱히 복잡하거나 어려운 로직은 없으니 설명은 생략하도록 하겠습니다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>리스트 페이지</title>

    <style>
        table { width: 800px; font-size: 14px; margin: auto; }
        table, td, th { border: 1px solid black; border-collapse: collapse; text-align: center; }
        td.left { text-align: left; }
    </style>
</head>
<body>
    <h2 style="text-align: center;">TOAST UI Editor 리스트 페이지</h2>

    <table>
        <colgroup>
            <col style="width: 20%;" />
            <col style="width: 80%;" />
        </colgroup>
        <thead>
            <tr>
                <th scope="col">번호</th>
                <th scope="col">제목</th>
            </tr>
        </thead>

        <!--/* 리스트 데이터 렌더링 영역 */-->
        <tbody id="posts">

        </tbody>
    </table>

    <script>

        window.onload = async () => {
            findAllPost();
        }


        // 전체 게시글 조회
        async function findAllPost() {

            // 1. API 호출
            const url = '/api/posts';
            const response = await fetch(url);
            const list = await response.json();

            // 2. 데이터가 없는 경우, 로직 종료
            if (list.length < 1) {
                document.querySelector('#posts').innerHTML = '<tr><td colspan="2">검색된 결과가 없습니다.</td></tr>';
                return false;
            }

            // 3. 리스트 HTML 세팅
            let html = '';
            list.forEach((item, index) => {
                html += `
                    <tr>
                        <td>${index + 1}</td>
                        <td class="left"><a href="/post/view.html?id=${item.id}">${item.title}</a></td>
                    </tr>
                `;
            });

            // 4. 리스트 HTML 렌더링
            document.querySelector('#posts').innerHTML = html;
        }

    </script>
</body>
</html>

 

코드 적용 후 리스트 페이지의 화면 구조는 다음과 같습니다.

리스트 페이지 화면 구조

 

8-2) view.html 수정하기

이번 글의 관건 중 하나인 상세 페이지입니다. 마찬가지로 view.html에 아래 소스 코드를 작성해 주세요.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>상세 페이지</title>

    <style>
        #container { font-family: arial; font-size: 22px; margin: 25px; display: flex; justify-content: center; align-items: center; }
        #editorContent { height: 50px; }
        img { max-width: 350px; }
    </style>
</head>
<body>
    <h2 style="text-align: center;">TOAST UI Editor 상세 페이지</h2>

    <div id="container">
        <!--/* 에디터 콘텐츠 렌더링 영역 */-->
        <div id="editorContent">

        </div>
    </div>

    <script>

        window.onload = async () => {
            findPost();
        }


        // 게시글 상세정보 조회
        async function findPost() {

            // 1. URL 쿼리 스트링에서 게시글 번호 조회
            const searchParams = new URLSearchParams(location.search);
            const postId = Number(searchParams.get('id'));

            // 2. API 호출
            const url = `/api/posts/${postId}`;
            const response = await fetch(url);
            const post = await response.json();

            // 3. 에디터 콘텐츠 렌더링
            document.querySelector('#editorContent').innerHTML = post.content;
        }

    </script>
</body>
</html>

 

코드 적용 후 상세 페이지로 이동해 보면, 에디터에 입력했던 내용이 HTML 형태 그대로 화면에 렌더링 되는 걸 확인하실 수 있습니다.

만약 이미지를 더 크게 보고 싶으시다면, 상단 style에서 img의 max-width 값을 늘려주시면 됩니다.

상세 페이지 화면 구조

 

마치며

여기까지 TUI 에디터의 이미지 업로드 방식 개선 작업과, 데이터베이스에 에디터에 입력된 콘텐츠를 저장 및 조회하는 방법을 알아보았습니다.

이 글에서는 정말 단순하게 처리했지만, 많은 사용자들이 방문하는 실제로 운영 중인 사이트에서는 에디터에 입력되는 내용과 파일 또한 철저한 유효성 검사를 해줄 필요가 있다는 것을 꼭! 기억해 주시면 되겠습니다 :)

오늘도 방문해 주셔서 감사드립니다. 더위 조심하시고, 행복한 한 주 보내세요 :)

H2-Database.zip
0.14MB

반응형

댓글