웹 개발/기타 지식

toast-ui-editor v3 이미지 업로드 최적화(커스터마이징)하기

초보군붕이 2023. 3. 27. 19:09
반응형

● 문제상황

  1. 마크다운 에디터에서 이미지 삽입시 인코딩되어 본문의 길이가 길어지는 상황
  2. 약 4kb의 이미지 삽입시 약 5100자의 문자열이 삽입된다.
  3. 본문의 길이가 늘어날수록 DB에서 차지하는 공간이 커지게되며, 삽입/삭제 등 데이터의 크기가 늘어날수록 더 많은양의 네트워크 트래픽을 요구하게된다.

4kb의 nest 이미지가 삽입된 에디터

 

 

● 해결방안

  1. 이미지 삽입 차단 : 커뮤니티를 주제로 잡은 프로젝트이므로 이미지 삽입 차단은 말이 안되는 해결책이였다.
  2. 이미지 개별 저장 : Firebase Storage, AWS S3 등 저장공간에 저장

위 처럼 2가지의 해결방안이 생각났다.

아무래도 가장 최선의 방법은 2번째 방법인 이미지를 개별의 저장공간에 보관하는 것이였다.

 

 

● 이미지 개별 저장 구현방식

우선 프로세스에 대해서 생각을 했을때 아래처럼 떠올랐다.

  1. 에디터에 이미지 삽입 이벤트 발생시 해당 이벤트를 캐치해서 스토리지에 업로드
  2. 업로드된 스토리지의 URL을 반환받아 img 태그에 삽입

다이어그램으로 표현한 간랸한 순서는 아래와 같다.

 

 

● toast-ui-editor v3 이미지 업로드 이벤트

tui-editor v2 까지는 event로 처리했지만 v3 부터는 hook이 추가되어 공식문서를 확인해봤다.

 

tui-editor v3 Code 문서 : https://nhn.github.io/tui.editor/latest/ToastUIEditorCore

 

https://nhn.github.io/tui.editor/latest/ToastUIEditorCore/

RETURNS: { Array. >Array. } - Returns the range of the selection depending on the editor mode

nhn.github.io

 

new Editor()를 통해 새로운 에디터를 생성하면서 hooks 라는 속성을 부여할수 있는데 찾고자하는 이벤트가 hooks에 정의되어있는 addImageBlobHook 이다.

 

options 일부(hooks)

 

코드의 경우 아래와 같다.

  const editor = new Editor({
    usageStatistics: true,
    el: editorRef.current,
    height: "700px",
    initialEditType: "markdown",
    previewStyle: "vertical",
    hideModeSwitch: true,
    initialValue: value,
    hooks: {
      addImageBlobHook(blob, callback) {
        console.log(blob)
      },
    }
  });

addImageBlobHook의 파라미터는 2개가 존재한다.

  • blob: Blob 형식의 데이터로서 업로드한 이미지가 변환된 데이터
  • callback: url, text를 인자로 받아 이미지 업로드 이후 본문에 저장되는 데이터를 지정해주는 콜백함수

 

아래는 addImageBlobHook과 HookCallBack에 대한 타입 정의이다.

type HookCallback = (url: string, text?: string) => void;

export type HookMap = {
  addImageBlobHook?: (blob: Blob | File, callback: HookCallback) => void;
};

 

 

● 이미지를 저장소(AWS S3)에 업로드하고 이미지의 URL 반환하기

우선 이미지가 업로드되면 formData를 통해서 서버에 이미지를 전송해준다.

 

서버측 코드부터 먼저 살펴보자

 

boards.controller.ts

  @UseGuards(JwtAuthGuard)
  @UseInterceptors(FileInterceptor('image'))
  @Post('image')
  async imageUpload(
    @UploadedFile() file: Express.Multer.File,
    @Body() imageUploadDto: ImageUploadDto,
  ) {
    const imageUrl = await this.boardsService.imageUpload(file, imageUploadDto);
    return imageUrl;
  }

 

Nestjs의 경우 FileInterceptor를 통해서 이미지를 받을 수 있다. 이는 Express의 Multer를 활용한 이미지 처리와 유사하다.  FileInterceptor의 경우 @nestjs/platform-express 패키지에서 제공하니 설치를 해주어야 한다.

npm i @nestjs/platform-express

 

boards.service.ts

async imageUpload(file: Express.Multer.File, imageUploadDto: ImageUploadDto) {
    const imageName = this.utilsService.getUUID();
    const tempBoardId = JSON.parse(JSON.stringify(imageUploadDto)).tempBoardId;

    const ext = file.originalname.split('.');
    const fileName = `boards_image/${tempBoardId}/${imageName}.${ext[ext.length - 1]}`;
    const imageUrl = await this.awsService.imageUploadToS3(fileName, file);
    return { imageUrl };
  }

첫번째로 imageName을 별도의 UUID 값으로 만든이유는 몇가지 이유가 있다.

  1. 파일명의 중복가능성 : 이미지 파일의 본명을 그대로 사용하면 중복될 가능성이 있다.
  2. 보안문제 : 대부분의 서버는 유닉스 기반 운영체제에서 동작한다. 하지만 파일의 이름을 유닉스에서 사용하는 명령어로 해놓을경우 서버에서 커맨드가 실행될 수 있는 위험이 있다.

 

두번째로 임시 게시글의 아이디이다.

클라이언트 측에서 게시글을 작성할때 이미지 업로드에 대비해서 임시로 아이디를 하나 만들어주었다.

HTTP POST 요청시 formData 내부에 포함되어 넘어오기때문에 JSON 직렬화를 통해서 임시 ID를 할당해준다.

 

세번째로 ext의 경우 확장자를 뜻하는 변수이다. fileName에 S3 내부에 저장될 디렉토리 위치와 폴더명을 지정하고 S3에 업로드를 시켜주는 방식이다.

 

 

aws.service.ts

import { Injectable } from '@nestjs/common';
import {
  S3Client,
  PutObjectCommand,
} from '@aws-sdk/client-s3';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AwsService {
  s3Client: S3Client;
  bucketName: string;

  constructor(private configService: ConfigService) {
    this.s3Client = new S3Client({
      region: 'ap-northeast-1',
      credentials: {
        accessKeyId: this.configService.get<string>('aws.accessKeyId'),
        secretAccessKey: this.configService.get<string>('aws.secretAccessKey'),
      },
    });
    this.bucketName = 'hellodeveloper';
  }

  async imageUploadToS3(fileName: string, file: Express.Multer.File) {
    const command = new PutObjectCommand({
      Bucket: this.bucketName,
      Key: fileName,
      Body: file.buffer,
      ACL: 'public-read',
    });

    await this.s3Client.send(command);
    return `https://hellodeveloper.s3.ap-northeast-1.amazonaws.com/${fileName}`;
  }
}

이제 실제로 받은 파일에 대해 업로드를 진행해주면 된다.

 

Key같은 경우는 파일의 이름이 되며 Body는 File내부에 실제 데이터인 Buffer 값이다.

또한 ACL을 public-read로 지정해줘야 추후 img 태그에서 src로 지정했을때 403 에러를 피할수있다.

 

Nodejs에서 aws-sdk를 활용한 서비스 사용방법은 공식홈페이지에 매우 잘되어있으니 참고하면된다.

AWS Javascript SDK 문서 : https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/index.html

 

@aws-sdk/client-s3

@aws-sdk/client-s3 @aws-sdk/client-s3 Description AWS SDK for JavaScript S3 Client for Node.js, Browser and React Native. Installing To install the this package, simply type add or install @aws-sdk/client-s3 using your favorite package manager: npm install

docs.aws.amazon.com

 

 

파일이 S3에 업로드된 모습

 

반환된 업로드 이미지의 URL

 

 

● 반환받은 이미지의 URL로 img 태그 생성하기

TextEditor.tsx 의 일부 코드

const addImageBlobHook = async (blob: Blob, callback: HookCallback) => {
    const formData = new FormData();
    formData.append("image", blob);
    formData.append("tempBoardId", tempBoardId);

    try {
      const res = await uploadBoardImage(formData, accessToken);
      callback(res.data.imageUrl, `image`);
    } catch (err: any) {
      console.error(err);
      callback(`이미지 업로드 실패, ${err.message}`);
    }
  };

 

 

BoardService.tsx - uploadBoardImage

export const uploadBoardImage = async (formData: FormData, accessToken: string) => {
  try {
    return await api.post(`/boards/image`, formData, {
      headers: { Authorization: `Bearer ${accessToken}` },
    });
  } catch (err: any) {
    throw err;
  }
};

 

에디터에서 이미지 업로드 이벤트가 발생하면 uploadBoardImage를 호출하여 서버에 업로드를 요청한다.

이후 반환된 이미지 URL(res.data.imageUrl)을 callback 함수를 통해서 실제 에디터에 삽입해준다.

 

AWS URL로 이미지가 삽입된 모습

 

 

 

● 데이터베이스에 이미지 URL 변환하여 저장하기

서버에서 게시글 생성 로직을 처리할때 게시글 고유 ID를 따로 만들어서 처리해주고 있다.

아래는 게시글 생성 로직의 일부코드이다. 코드가 길어 일부 코드는 생략했다.

 

boards.service.ts

 /**
   * 게시글 생성
   * @param userId - 작성자 아이디
   * @param createBoardDto - 게시글 생성 데이터
   */
  async create(userId: string, createBoardDto: CreateBoardDto) {
    const { title, content, category, tags, tempBoardId } = createBoardDto;    
    const boardId = this.utilsService.getUUID();
   
    const newBoard = new Board();
    newBoard.boardId = boardId;
    await this.boardRepository.create(newBoard);
  }

uuid를 생성하여 게시글의 아이디를 정하는데 이 때 이미지의 URL을 저장하는 이유는 아래와 같다.

  1. 게시글 수정시 이미지 추가 대비 : 게시글을 수정하면서 이미지가 늘어나는경우 새로운 임시 ID를 만들게되면 한 게시글에 대해 여러개의 폴더가 생기게된다.
  2. 게시글 삭제시 사진도 삭제필요 : 게시글을 삭제하는 경우 S3 내부에 저장한 이미지들도 삭제가 필요하다. 저장공간은 무한하지 않으며 사용하는 만큼 비용을 지불하는 방식이므로 꼭 해줘야하는 작업이다.

 

해당 코드에서는 tempBoardId를 HTT POST요청시 body로 받아 문자열을 변환시켜주는 형식으로 구현했다.

 

우선 본문의 내용부터 변환시켜보자

const boardId = this.utilsService.getUUID();
const replaceRegExp = new RegExp(`${tempBoardId}`, 'g');
const replaceContent = content.replace(replaceRegExp, boardId);

const newBoard = new Board();
newBoard.boardId = boardId;
newBoard.tempBoardId = tempBoardId;

S3에 이미지를 업로드하고 반환받은 문자열을 보면 임시로 생성한 게시글 ID로 사진이 저장되어 있다.

해당 임시 ID를 실제 게시글의 ID로 변환시켜주면 아래처럼 변하게된다.

 

String.prototype.replaceAll 메소드

 

하지만 nodejs에는 replaceAll이 구현되어있지 않아 정규식을 활용하여 진행했다.

 

본문의 저장과 변경이 끝난 후에 S3에 저장된 이미지들의 경로도 변경해주어야 한다.

 

const oldPath = `boards_image/${tempBoardId}`;
const newPath = `boards_image/${boardId}`;
await this.awsService.changeFolderName(oldPath, newPath);

기존경로와 새로운 경로를 생성하고 changeFolderName을 호출하여 변경해준다.

 

import { Injectable } from '@nestjs/common';
import {
  S3Client,
  PutObjectCommand,
  ListObjectsV2Command,
  CopyObjectCommand,
  DeleteObjectCommand,
  GetObjectAclCommand,
  PutObjectAclCommand,
} from '@aws-sdk/client-s3';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AwsService {
  s3Client: S3Client;
  bucketName: string;

  constructor(private configService: ConfigService) {
    this.s3Client = new S3Client({
      region: 'ap-northeast-1',
      credentials: {
        accessKeyId: this.configService.get<string>('aws.accessKeyId'),
        secretAccessKey: this.configService.get<string>('aws.secretAccessKey'),
      },
    });
    this.bucketName = 'hellodeveloper';
  }

  async changeFolderName(oldPath: string, newPath: string) {
    // 변경해야되는 폴더 내의 모든 데이터를 가져오기
    const listParams = {
      Bucket: this.bucketName,
      Prefix: oldPath,
    };
    const listCommand = new ListObjectsV2Command(listParams);
    const listObject = await this.s3Client.send(listCommand);

    // 가져온 데이터를 새로운 경로로 복사하고, 기존 데이터는 삭제처리진행
    const contents = listObject.Contents || [];
    for (const object of contents) {
      const oldKey = object.Key;
      const newKey = newPath + oldKey.slice(oldPath.length);
      const copyParams = {
        Bucket: this.bucketName,
        CopySource: encodeURIComponent(this.bucketName + '/' + oldKey),
        Key: newKey,
      };
      const copyCommand = new CopyObjectCommand(copyParams);

      // 폴더 권한 설정복사
      const getObjectAclParams = {
        Bucket: this.bucketName,
        Key: oldKey,
      };
      const getObjectAclCommand = new GetObjectAclCommand(getObjectAclParams);
      const aclObject = await this.s3Client.send(getObjectAclCommand);

      // ACL 설정
      const putObjectAclParams = {
        Bucket: this.bucketName,
        Key: newKey,
        AccessControlPolicy: aclObject,
      };
      const putObjectAclCommand = new PutObjectAclCommand(putObjectAclParams);

      await this.s3Client.send(copyCommand);
      await this.s3Client.send(putObjectAclCommand);

      // 기존 객체는 삭제처리
      const deleteParams = {
        Bucket: this.bucketName,
        Key: oldKey,
      };
      const deleteCommand = new DeleteObjectCommand(deleteParams);
      await this.s3Client.send(deleteCommand);
    }
  }
}

코드의 설명은 아래와 같다.

  1. oldPath에 속한 모든 파일들을 가져온다
  2. oldPath로 가져온 파일들을 newPath로 복사한다
    1. 이때 권한까지 모두 복사를 해줘야 클라이언트에서 이미지 렌더링시 403 에러를 피할수있다.
  3. 기존 oldPath에 존재하는 파일들은 삭제를 진행한다.

 

아래 사진은 이미지 업로드만 진행할때와 실제 게시글을 저장할때 S3의 경로를 비교하는 사진이다.

 

에디터에서 이미지만 업로드 했을때

 

실제 게시글을 작성했을때

 

폴더명이 바뀐것을 확인할 수 있다.

그럼 이제 실제 게시글의 ID와 폴더명이 일치하는지 확인해본다.

 

게시글 작성 결과

실제 게시글을 작성하면 d5aac...으로 시작하는 아이디가 생성된것을 확인할 수 있다.

마찬가지로 본문에 속한 img 태그로 게시글의 아이디와 일치하도록 변경됬다.

 

 

● 마무리

이런 작업을 처음 해봐서 많이 해맸지만 구현하고나니 엄청 어려운것은 아니였다..

하지만 기존 구현한 방식보다 좀더 효율적인 방법을 생각해보고 수정하는것이 목표다.

 

전체 코드는 아래 링크에서 확인 가능합니다.

 

https://github.com/imkdw/hello_developer

 

GitHub - imkdw/hello_developer: 개발자를 위한 커뮤니티, 헬로 디벨로퍼 입니다!

개발자를 위한 커뮤니티, 헬로 디벨로퍼 입니다! Contribute to imkdw/hello_developer development by creating an account on GitHub.

github.com

 

반응형