이미지 최적화 및 성능 개선
기존 프로젝트의 이미지가 온전히 원본 이미지를 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;
}
`;
결과