개발👩‍💻/프론트엔드

이미지 최적화 및 성능 개선

gigibean 2022. 7. 4. 14:56
728x90

기존 프로젝트의 이미지가 온전히 원본 이미지를 img src를 사용해서 가져오고 있었기 때문에 이미지 관련 작업이 필요했다.

 

1) 우선은 프론트 단에서는 이미지를 viewport에 잡힐 때 load 시키고 (Intersection Observer)

 

2) 빠르게 구현했어야 했기 때문에

이미지 캐싱, 레이지 로딩 및 압축 포매팅 등 기능을 제공하는 next image를 사용했다.

 

3) 업로드 하는 부분도 Toast UI 에디터를 사용하고 있었기 때문에 사진을 에디터에 불러오는 훅을 커스텀해서

s3에 업로드 하기 전에 Next API를 사용해서 해당 이미지 최적화 로직을 구현하고, s3에 업로드한 후 백엔드에 업로드된 이미지 주소를 보내주었다.

 

이 외에도 웹폰트, 스크롤, 스켈레톤 등을 통해서 성능을 개선할 수 있었는데

lighthouse 기준으로 평균 5번 정도 테스트 실행시

성능이 약 22점 정도 향상 했으며,
레이지로딩 -> 6초
차세대형식 이미지 -> 4초
이미지크기 설정 -> 5초
이미지 인코딩 -> 1.3초
js 줄이기 -> 0.5초
초기 서버 응답 시간 줄임 -> 170ms 
스크롤 성능 개선 

TTI 2초감소
total blocking time 460ms 감소

의 결과를 볼 수 있었다.

 

 

정리하자면,

업로드 시: 이미지 리사이징, 포매팅 후, submit 시 s3에 업로드 후 content와 백엔드에 업로드 된 주소 보내주기

로드 시: viewport에 잡힐 때 next image 사용하여 필요한 사진만 lazy하게 가져오기

 

이번 포스팅은 3번에 대해서만 작성하려함니둥

 

간단히 리사이징된 파일 하나, 포매팅 된 파일 하나씩 만들어 업로드 해보도록하자💪

이미지 리사이징, 포패팅: sharp API

pages/api/sharpimage

이미지 변환

import nextConnect from "next-connect";
import multer from "multer";
import path from "path";
import { NextApiRequest, NextApiResponse } from "next";
import sharp from "sharp";
import fs from "fs";

const localpath = path.resolve(__dirname, "public/upload");

var storage = multer.diskStorage({ //multer storage 설정
  destination: localpath, // 저장할 경로
  filename: function (req, file, cb) {
    cb(null, `${file.originalname}`);
  },
});

const app = nextConnect({ //nc 설정
  onError(error: { message: any }, req: any, res: { status: (arg0: number) => { (): any; new (): any; json: { (arg0: { error: string }): void; new (): any } } }) {
    res.status(501).json({ error: `Sorry something Happened! ${error.message}` });
  },
  onNoMatch(req: { method: any }, res: { status: (arg0: number) => { (): any; new (): any; json: { (arg0: { error: string }): void; new (): any } } }) {
    res.status(405).json({ error: `Method '${req.method}' Not Allowed` });
  },
});

var upload = multer({ storage: storage });

app.post(upload.single("file"), async function (req: NextApiRequest, res: NextApiResponse) {
  const deleteLocalFile = (filePath: string) => { // 라사이징, 포매팅 후 원본 로컬 파일 삭제
    fs.unlink(filePath, (err: any) => {
      console.log(err);
    });
  };

  try {
    const fileName = req.file.filename; // 원본 파일 이름
    const webpFileName = `formated_${fileName.split(path.extname(req.file.filename)).join(".webp")}`; // webp 형식 포매팅 후 이미지 이름

    const resizedFilePath = `${localpath}/resized_${fileName}`; // 라사이징 파일
    const formatedFilePath = `${localpath}/${webpFileName}`; // 포매팅 파일
    const uploadFilePath = `${localpath}/${fileName}`; // 원본 파일

    await sharp(uploadFilePath).resize({ width: 600 }).toFile(resizedFilePath); // sharp 사용하여 리사이즈
    deleteLocalFile(uploadFilePath); // 원본 파일 삭제
    await sharp(resizedFilePath).webp().toFile(formatedFilePath); // 리사이즈 파일 형식 변환

    return res.status(200).json({ origin: `resized_${fileName}`, format: `${webpFileName}`, origin_url: resizedFilePath, format_url: formatedFilePath });
  } catch (error) {
    return res.status(501).send(error);
  }
});

export default app;

export const config = {
  api: {
    bodyParser: false, // Disallow body parsing, consume as stream
  },
};

 

이미지 업로드

pages/image/s3image

import nextConnect from "next-connect";
import multer from "multer";
import path from "path";
import fs from "fs";
import { NextApiRequest, NextApiResponse } from "next";
const AWS = require("aws-sdk");

const s3 = new AWS.S3({
  accessKeyId: "${S3.accessKeyId}",
  secretAccessKey: "${S3.secretAccessKey}",
  region: "${S3.region}",
});

const localpath = path.resolve(__dirname, "public/upload");

var storage = multer.diskStorage({
  destination: localpath,
  filename: function (req, file, cb) {
    cb(null, `${file.originalname}`);
  },
});

const app = nextConnect({
  onError(error: { message: any }, req: any, res: { status: (arg0: number) => { (): any; new (): any; json: { (arg0: { error: string }): void; new (): any } } }) {
    res.status(501).json({ error: `Sorry something Happened! ${error.message}` });
  },
  onNoMatch(req: { method: any }, res: { status: (arg0: number) => { (): any; new (): any; json: { (arg0: { error: string }): void; new (): any } } }) {
    res.status(405).json({ error: `Method '${req.method}' Not Allowed` });
  },
});

var upload = multer({ storage: storage });
...
app.post(upload.single("file"), async function (req: NextApiRequest, res: NextApiResponse) {
  const fileName = req.file.filename;
  const fileMimeType = req.file.mimetype;
  const uploadFilePath = `${localpath}/${fileName}`;
  let awsParam = {};

  awsParam = {
    Bucket: "${S3.bucket}",
    Key: ${S3.Key},
    ACL: "public-read",
    Body: fs.createReadStream(uploadFilePath),
    ContentType: fileMimeType,
  };
  
  await s3.upload(awsParam, async (err, data) => { // image 업로드
    if (err) console.log("S3 upload error", err);
    console.log(data);

    return res.status(200).json({ data });
  });
});
...
export default app;

export const config = {
  api: {
    bodyParser: false, // Disallow body parsing, consume as stream
  },
};

pages/image

이미지 삭제 (로컬에 남아있는 변환 이미지들)

import path from "path";
import fs from "fs";
import { NextApiRequest, NextApiResponse } from "next";
type Data = {
  message: string;
};

const localpath = path.resolve(__dirname, "public/upload");
const removePath = (localpath: string) => {
  fs.stat(localpath, (err, stats) => {
    if (err) return console.log(err);

    if (!stats.isDirectory()) {
      return fs.unlink(localpath, (err) => err && console.log(err));
    }
  });
};


export default function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
  switch (method) {
  ...
    case "DELETE":
      try {
        const files = fs.readdirSync(localpath);
    	if (files.length) files.forEach((f) => removePath(path.join(localpath, f))); // 파일들 삭제 함수 호출 
    	return res.status(200).json({ message: "success!" });
  	  } catch (err) {
    	if (err) console.log(err);
  	}
    ..
  }
}

React Custom Hooks 만들기 - 1 : useImage

import axios from "axios";
import { useRef } from "react";

const useUploadImage = () => {
  let fileData;
  const result = useRef();

  const deleteImage = async () => { // 이미지 삭제
    const res = axios.delete("/api/image")

    return res;
  };

  const uploadImage = async ({ blob }: any) => { // image convert
    const formData = new FormData();
    const imageType = blob.type.split("/").pop();

    const DateString = new Date().toISOString();
    const fileName = `${DateString}.${imageType}`;
    const file = new File([blob], `${fileName}`); // blob 형식 file로 변환
    formData.append("file", file);

    const res = axios.post("/api/imagesharp", formData)

    return res;
  };

  const uploadS3Image = async (file: any) => { // s3 upload 
    const formData = new FormData();
    const convertURLtoFile = async (url: string) => { // converted file's url => file
      const response = await fetch(url); // fetch 사용하여 blob 으로 res
      const data = await response.blob(); // blob data
      const ext = url.split(".").pop(); 
      const filename = url.split("/").pop();
      const metadata = { type: `image/${ext}` };
      return new File([data], filename!, metadata);
    };
    try {
      const convertFile = await convertURLtoFile(`${file}`); // 변환 파일 받기
      formData.append("file", convertFile as File); 

      const res = await axios.post("/api/s3image", formData)
      return res;
    } catch (err) {
      console.log(err);
    }
  };

  return { deleteImage, uploadImage, uploadS3Image };
};

export default useUploadImage;

React Custom Hooks 만들기 - 2 : useEditor

Toast Ui 를 사용하고 있었기 때문에 해당 에디터에 맞는 훅을 만들었다.

기존 useImage에서 만든 훅을

에디터에서는 이미지 버튼 클릭 시 호출 되는 훅을 커스텀 해서

변환, 삭제, 업로드 등의 일련의 동작을 하는 훅을 만들었다.

 

import { useEffect, useRef, useState } from "react";
import { Editor as ToastEditor } from "@toast-ui/react-editor";
import colorSyntax from "@toast-ui/editor-plugin-color-syntax";
import { IEditor } from "components/Editor/Editor";
import useUploadImage from "hooks/useUploadImage/useUploadImage";
import axios from "axios";

const useEditor = ({ contentValue, setContentValue }: IEditor) => {
  // initial value
  // converting html to markdown
  // converting markdown to html
  // 이미지 데이터 포매팅, 리사이징 기능 콜백
  // post action
  const { deleteImage, uploadImage, uploadS3Image } = useUploadImage();

  const editorRef = useRef<ToastEditor>(null);
  ...
  // Editor Change 이벤트
  const onChangeEditor = () => {
    if (editorRef.current) {
      setContentValue(editorRef.current.getInstance().getHTML());
    }
  };

  useEffect(() => {
    if (editorRef.current) {
      // 전달받은 html값으로 초기화
      editorRef.current.getInstance().setHTML(contentValue);

      // 기존 이미지 업로드 기능 제거
      editorRef.current.getInstance().removeHook("addImageBlobHook");
      // 이미지 서버로 데이터를 전달하는 기능 추가
      editorRef.current.getInstance().addHook("addImageBlobHook", (blob, callback) => {
        (async () => {
          const res = await uploadImage({ blob }); // useImage uploadImage hook 사용
          callback(`/upload/${res.data.origin}`, "alter image"); // 에디터에서 사용할 이미지 설정 콜백 + alter 
        })();

        return false;
      });
    }
  }, []);

  ...
 
  const getUploadImages = (content: string) => {
    const originImages = content.match(/src\=\"([^"]+)/g)!.map((string: string) => string.split('src="').join("")); 
    // hook를 통해 에디터에 올라간 이미지들 찾기
    const totalImages = originImages.map((originImage: string) => {
      let splitFormat = originImage.split(".");
      splitFormat.pop();
      splitFormat.push("webp");
      const formatImage = splitFormat.join(".");
      return { origin: originImage, format: formatImage }; // origin: 이미지 리사이즈, format: 리사이즈+형식
    });
    return totalImages;
  };

  const uploadImageToS3 = async (images: { origin: string; format: string }) => {
    const { origin, format } = images;
    try {
     // 두 이미지를 s3에 업로드
      const originRes = (await uploadS3Image(origin).then((data) => {
        return data;
      })) as unknown as string;
      const formatRes = (await uploadS3Image(format).then((data) => {
        return data;
      })) as unknown as string;
      return { origin: originRes, format: formatRes };
    } catch (err) {
      console.log(err);
    }
  };
  const uploadtotalImagesToS3 = async () => {
    try {
      const totalImages = getUploadImages(contentValue); // 모든 이미지를 찾아서
      const totalPromises = totalImages.map(async (images: any) => await uploadImageToS3(images)); // 이미지 각각 업로드
      const response = await Promise.all(totalPromises);
      const totalResult = response.data;
      await deleteImage(); // 로컬 이미지 삭제

      return totalResult;
    } catch (err) {
      console.log(err);
    }
  };
	...
  const handleSubmit = async () => {
    try {
      const urls = await uploadtotalImagesToS3();
      const data = {
        content: editorRef.current ? editorRef.current.getInstance().getMarkdown() : contentValue,
        imageList: urls ?? [],
      };
      // send to back
      const res = await axios.post(.., data, { withCredentials: true });
    } catch (err) {
      console.log(err);
    }
  };

  // Editor에 사용되는 plugin 추가
  const plugins = [
    colorSyntax, // 글자 색상 추가
  ];

  return {
    editorRef,
    onChangeEditor,
    convertToMarkdown,
    convertToHTML,
    plugins,
    uploadImageToS3,
    handleSubmit,
  };
};

export default useEditor;

React Component: Editor 컴포넌트

import React, { useEffect, useState } from "react";
import styled from "styled-components";
import { NextPage } from "next";

import { Editor as ToastEditor } from "@toast-ui/react-editor";
import "@toast-ui/editor/dist/toastui-editor.css";

import "tui-color-picker/dist/tui-color-picker.css";
import "@toast-ui/editor-plugin-color-syntax/dist/toastui-editor-plugin-color-syntax.css";
import ButtonContainer from "common/Button/StyledButton";
import useEditor from "hooks/useEditor";

export interface IEditor {
  contentValue: string;
  setContentValue: React.Dispatch<React.SetStateAction<string>>;
}

const Editor: NextPage<IEditor> = ({ contentValue, setContentValue }) => {
  const { editorRef, onChangeEditor, plugins, handleSubmit } = useEditor({ contentValue, setContentValue });

  return (
    <ContentWrapper>
      <CustomToastEditor initialValue="" previewStyle="vertical" initialEditType="wysiwyg" useCommandShortcut={true} ref={editorRef} plugins={plugins} onChange={onChangeEditor} />
      <ButtonContainer theme="white" size="s" stretch onClick={handleSubmit}>
        upload
      </ButtonContainer>
    </ContentWrapper>
  );
};

export default Editor;

// style
const CustomToastEditor = styled(ToastEditor)`
  height: 300px;
`;
const ContentWrapper = styled.div`
  & > * + * {
    margin-top: 40px;
  }
`;

결과

 

해당 ok 버튼을 누르면 에디터 훅이 실행된다
ok -> 변환된 이미지와 이미지 url 리턴
각 이미지 업로드와 해당 로컬 이미지 삭제 후 content upload

반응형