[NestJS] React + Ckeditor5에서 미리보기로 업로드된 이미지 처리하기

Lee Seung Chan·2023년 7월 30일
1
post-thumbnail

📌 개발 동기

CK에디터는 이미지 미리보기를 위해, 이미지를 첨부하는 순간 서버에 이미지가 업로드 되고, 사용자가 글 작성 도중 페이지를 나가거나 이미지를 삭제해도 여전히 서버에 이미지가 남는다는 문제가 있습니다. 이 문제점을 해결하기 위해 하루 동안 고민하며 코드를 작성하였습니다.

예시 사진
에디터에 사진을 업로드하면 미리보기를 위해 아래처럼 파일이 생성됩니다.

📌 문제를 해결하기 위하여

  1. 이미지를 업로드하면 서버에서 이미지 파일을 받아 temp 폴더 안에 고유 UUID 폴더를 생성하여 그 안에 임시로 이미지를 저장

  • 여기서 temp 폴더를 UUID로 지정하는 이유는, 통합 관리자 계정 또는 여러 관리자가 동시에 글을 동시에 작성하고 있을 수 있기 때문

  1. 이미지를 임시로 저장해두다가, 게시글이 등록되면 임시 폴더에 있던 파일을 본 저장소로 옮김

  1. 만약 게시글을 등록하지 않고, 페이지를 벗어나면 임시 이미지를 삭제한다.

📌 개발 작업

REACT (product.tsx)

클라이언트에서 CKeditor Custom Adapter를 통하여 API를 요청하는 부분입니다.

  const generatedUUIDRef = useRef(uuidv4()); // (1)
  const imageUploadedRef = useRef(false); // (2)
  const useProductAxios = useProduct_a();
  useEffect(() => {
    return () => { // (3)
      if (imageUploadedRef.current) 
      	useProductAxios.deleteProductTempImages(generatedUUIDRef.current);
    };
  }, []);

  const customUploadAdapter = (loader: any) => {
    return {
      upload() {
        return new Promise((resolve, reject) => {
          const data = new FormData();
          loader.file.then(async (file: any) => {
            const options = {
              maxSizeMB: 1.5,
              maxWidthOrHeight: 4000,
              useWebWorker: true,
            };
            const compressedFile = await imageCompression(file, options);
            data.append("uuid", generatedUUIDRef.current); // (3)
            data.append("name", compressedFile.name);
            data.append("file", compressedFile);
            axios
              .post(`${config.SERVER_URL}/admin/imageUpload`, data)
              .then((res) => {
                if (!flag) {
                  setFlag(true);
                  setImage(res.data.name);
                  imageUploadedRef.current = true;
                }
                resolve({
                  default:`${config.LOCAL_SERVER_IMG_URL}/temp/${generatedUUIDRef.current}/${res.data.name}`,
                });
              })
              .catch((err) => reject(alertSnack(err, "error")));
          });
        });
      },
    };
  };

(1) generatedUUIDRef 임시 이미지 폴더의 이름으로 사용할 uuid를 생성합니다.
여기서 ref로 지정을 해주는 이유는, 페이지내에서 컴포넌트의 렌더링과 관계없이 값을 유지시키기 위함입니다.

(2) imageUploadedRef 이미지가 한번이라도 업로드가 되었는지 체크하는 ref입니다. 이미지가 업로드 되었을때 true로 설정됩니다. 이미지를 업로드 하지 않고 나갔을 경우에 불필요한 이미지 삭제 요청을 줄이기 위해 설정하였습니다. useEffect안에서 State를 가져올려고 한다면 useEffect에 의존성 배열을 추가해야 최신화된 State 값을 가져올 수 있으므로 접근이 편한 ref로 지정하였습니다.

(3) unmountPoint 컴포넌트가 언마운트될때, 임시 이미지를 삭제하는 Axios 요청을 API 서버에 전송합니다.


NEST (admin.controller.ts)

  @Post('/imageUpload')
  @UseInterceptors(FileInterceptor('file'))
  uploadFile(
    @UploadedFile() file: Express.Multer.File,
    @Req() req: any,
  ) {
    try {
      if (!file) {
        throw new BadRequestException({ message: '파일을 찾을 수 없습니다.' });
      }
      this.logger.error(file);
      return { name: file.filename };
    } catch (e) {
      this.logger.error(e);
      throw e;
    }
  }

NEST (multer.options.factory.ts)

@UploadedFile() 데코레이터는 컨트롤러가 호출될 때 Multer가 업로드된 파일을 처리하여 매개변수에 할당해 줍니다.
아래는 이미지를 저장하기 위한 multer 설정입니다.

  import { Logger } from '@nestjs/common';
  import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
  import multer from 'multer';
  import path from 'path';
  import fs from 'fs';
  import { v4 } from 'uuid';

  const mkdir = (directory: string) => {
      const logger = new Logger('Mkdir');
      try {
          fs.readdirSync(path.join(process.cwd(), directory));
      } catch (err) {
          fs.mkdirSync(path.join(process.cwd(), directory), { recursive: true, mode: 777 })
      }
  };
  export const multerOptionsFactory = (): MulterOptions => {
      return {
          storage: multer.diskStorage({
              destination(req, file, done) {
                  const dirPath = req.body.uuid ? `/static/uploads/temp/${req.body.uuid}` : `/static/uploads/`; // (1)
                  mkdir(dirPath); // (2)
                  done(null, path.join(process.cwd(), dirPath));
              },
              filename(req, file, done) { // (3)
                  const ext = path.extname(file.originalname);
                  const basename = path.basename(v4(), ext);
                  done(null, `${basename}_${Date.now()}${ext}`);
              },
          }),
          limits: { fileSize: 10 * 1024 * 1024 },
      };
  };

(1) 요청 받은 uuid가 존재한다면 임시 폴더를 생성해줍니다.

(2) 폴더를 생성하는 기능입니다.

  • 생성된 폴더는 삭제, 폴더 내 파일 옮기기 등을 해야하므로 모든 접근이 가능하도록 777 권한을 부여하도록 하였습니다.

(3) 이부분에서 임시 uuid 이미지 폴더에 요청 받은 모든 이미지 파일을 저장합니다.


NEST (admin.service.ts)

업로드 요청이 왔을 때, 임시 이미지 삭제 및 본 저장소로 이미지를 옮기는 작업을 마치면,
DataBase에 게시글을 최종 업로드를 하는 부분입니다.

public postUpload = async (
    post: postDto,
    files: Express.Multer.File,
    uuid: string
  ): Promise<string> => {
    try {
      let images_array = [];
      for (let i = 0; i < Object.keys(files).length; i++) {
        images_array.push(files[i].filename);
      }
      post.post_images = JSON.stringify(images_array);
      post.post_content = await this.replaceAsync(post.post_content, `uploads/temp/${uuid}`, `uploads`); // (1)
      this.moveImage(uuid);
      this.tempImagesDelete(uuid);
      await this.postRepository.save(post);
      return Object.assign({
        statusCode: 201,
        message: '상품이 등록되었습니다.',
      });
    } catch (e) {
      this.logger.debug(e);
      throw e;
    }
  };
  public async tempImagesDelete(uuid: string): Promise<string> { // (2)
    const dir = `/static/uploads/temp/${uuid}`;
    const folderPath = path.join(process.cwd(), dir);
    try {
      await fs.promises.rmdir(folderPath, { recursive: true });
      return Object.assign({
        statusCode: 201,
        message: '임시 폴더가 삭제되었습니다.',
      });
    } catch (e) {
      this.logger.debug(e);
      throw new Error('폴더 삭제에 실패했습니다.');
    }
  }
  private moveImage(uuid: string) { // (3)
    const tempPath = path.join(process.cwd(), `/static/uploads/temp/${uuid}`);
    const destinationPath = path.join(process.cwd(), `/static/uploads`);
    try {
      const files = fs.readdirSync(tempPath);
      for (const file of files) {
        const tempFilePath = path.join(tempPath, file);
        const desFilePath = path.join(destinationPath, file);
        fs.renameSync(tempFilePath, desFilePath);
      }
      return true;
    } catch (e) {
      this.logger.debug(e);
      return false;
    }
  }
  private replaceAsync = async (str: string, find: string, replace: string) => { // 동기 보장 replace
    return str.replace(new RegExp(find, 'g'), replace);
  };

(1) 미리보기를 위해 임시 이미지의 경로로 설정되어 있었던, CkEditor HTML Image태그들의 경로들을 본 저장소 경로로 바꾸어줍니다.

  • 선언부가 비동기 함수이므로, replace 또한 비동기 함수를 만들어서 await를 이용해 replace가 모두 이루어진 이후에, DB Save 로직을 실행하도록 하였습니다.

(2) node.js의 fs 내장 파일 읽기/쓰기 모듈을 사용하여, 임시 폴더를 삭제하였습니다.

(3) 마찬가지로 fs 모듈을 사용하여, uuid 폴더의 모든 파일을 본 저장소에 옮겨주었습니다.

정리하자면, 게시물 업로드 요청이 왔을때, 실제 실행 순서는 (1) => (3) => (2) 이므로
DB에 업로드할 CKeditor HTML Image tags 경로를 임시 이미지 경로에서 옮길 본 저장소로 재설정하고,
임시 uuid 폴더의 모든 이미지를 본 저장소에 업로드 후, 필요가 없어진 임시 uuid 폴더를 삭제하는 과정입니다.

📌 마무리

이렇게 모든 과정을 마치면, 사용자가 게시글에 이미지를 업로드한후, 페이지를 벗어나거나 브라우저가 닫힌다고 해도 임시 이미지가 남지 않게 됩니다.


혹시 궁금한 점이 있으시거나, 좋은 피드백이 있다면, 댓글 남겨주시면 감사하겠습니다!
처음 블로그를 시작하며, 글을 뭔가 두서없이 썼다고 생각이 드네요.
앞으로 피드백을 받고 많이 발전할 수 있도록 노력하겠습니다.
부족하지만 이 글이 어떤 누군가에게는 도움이 되었으면 좋겠습니다.
긴글 읽어주셔서 감사합니다.

profile
기억은 잊혀져도 기록은 영원하다

3개의 댓글

comment-user-thumbnail
2023년 7월 30일

많은 도움이 되었습니다, 감사합니다.

1개의 답글
comment-user-thumbnail
2024년 3월 7일

글 너무 잘봤습니다~
혹시 이미지를 여러개를 한번에 그리고 여러번 이미지를 업로드하는 경우에도 정상작동이 되는지 궁금합니다. 해당 코드에서는 useState 상태를 reset시켜주는 곳이 안보이는 것 같아서요 ㅜ

답글 달기