๋ฐ˜์‘ํ˜•

Next/Image๋Š” ํฌ๊ฒŒ ๋กœ์ปฌ ์ด๋ฏธ์ง€(์ •์  ์ด๋ฏธ์ง€)์™€ ๋ฆฌ๋ชจํŠธ ์ด๋ฏธ์ง€(๋‹ค์ด๋‚˜๋ฏน ์ด๋ฏธ์ง€)๋กœ ๋‚˜๋‰œ๋‹ค. /public ํด๋”์— ์ €์žฅํ•œ ๋กœ์ปฌ ์ด๋ฏธ์ง€๋Š” ๋นŒ๋“œ ํƒ€์ž„์— importํ•œ ์ด๋ฏธ์ง€ ํŒŒ์ผ์˜ width, height๋ฅผ ์ž๋™์œผ๋กœ ์ง€์ •ํ•˜๊ณ  base64๋กœ ์ธ์ฝ”๋”ฉํ•œ ์ด๋ฏธ์ง€๊ฐ€ ์ƒ์„ฑ๋œ๋‹ค. ๋”ฐ๋ผ์„œ ์ถ”๊ฐ€ ์ž‘์—… ์—†์ด ๋ธ”๋Ÿฌ ์ฒ˜๋ฆฌ๋œ Placeholder๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

// public ํด๋”์— ์žˆ๋Š” me.png ํŒŒ์ผ์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๋‹ค
<Image
src="/me.png"
alt="Picture of the author"
placeholder="blur"
// width={500} automatically provided
// height={500} automatically provided
// blurDataURL="data:..." automatically provided
/>;

 

๊ทธ ์™ธ ์ƒํ™ฉ์€ ๋ฆฌ๋ชจํŠธ ์ด๋ฏธ์ง€๋กœ ๊ตฌ๋ถ„ํ•œ๋‹ค. ์ด๋•Œ ๋ธ”๋Ÿฌ ์ฒ˜๋ฆฌ๋œ Placeholder๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด plaiceholder ๊ฐ™์€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜ ์บ”๋ฒ„์Šค API๋ฅผ ์ด์šฉํ•ด์„œ 4ร—4 ์ •๋„์˜ ์‚ฌ์ด์ฆˆ(๋ณดํ†ต 300๋ฐ”์ดํŠธ ๋ฏธ๋งŒ)๋กœ ์ค„์ธ ํ›„ base64๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ์ž‘์—…์ด ํ•„์š”ํ•˜๋‹ค. NextJS ๊ณต์‹ ๋ฌธ์„œ์—์„  10 ํ”ฝ์…€ ๋ฏธ๋งŒ์˜ ์‚ฌ์ด์ฆˆ๋ฅผ ๊ถŒ์žฅํ•˜๊ณ  ์žˆ๋‹ค.

 

๋ฐฉ๋ฒ• 1. Canvas ํ™œ์šฉ


์‚ฌ์šฉ์ž ์ปดํ“จํ„ฐ์—์„œ ์ด๋ฏธ์ง€๋ฅผ ์„ ํƒํ•œ ํ›„ ์—…๋กœ๋“œํ•œ ๊ฒฝ์šฐ์—” ์œ ํ‹ธ ํ•จ์ˆ˜๋ฅผ ๋งŒ๋“ค์–ด์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. ์ด๋ฏธ์ง€ ํฌ๋กญ ๋“ฑ์˜ ์ƒํ™ฉ์—์„œ ์‚ฌ์šฉํ•˜๋ฉด ์œ ์šฉํ•˜๋‹ค. โถ๋กœ์ปฌ ์ปดํ“จํ„ฐ์—์„œ ์ด๋ฏธ์ง€ ์„ ํƒ โž‹์ด๋ฏธ์ง€ ํฌ๋กญ โžŒPlaceholder๋กœ ์‚ฌ์šฉํ•  base64 ๋ฌธ์ž์—ด์„ ์ƒ์„ฑํ•˜๊ณ , ํฌ๋กญ ์ด๋ฏธ์ง€ ์›๋ณธ์€ ์„œ๋ฒ„๋กœ ์ „์†ก โนํฌ๋กญ ์ด๋ฏธ์ง€ ๋ Œ๋”.

drawImage() ๋ฉ”์„œ๋“œ ํ†บ์•„๋ณด๊ธฐ
์ด๋ฏธ์ง€ ์ถœ์ฒ˜ - fromyou

s๋Š” source(์ด๋ฏธ์ง€), d๋Š” destination(์บ”๋ฒ„์Šค)์„ ์˜๋ฏธํ•œ๋‹ค. drawImage ๋ฉ”์„œ๋“œ์— ๋„˜๊ธด ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฐœ์ˆ˜์— ๋”ฐ๋ผ ์‚ฌ์šฉ๋ฒ•์ด ๋‹ฌ๋ผ์ง€๋ฏ€๋กœ ์ฃผ์˜ํ•  ๊ฒƒ. ์ฐธ๊ณ ๋กœ source ์—˜๋ฆฌ๋จผํŠธ๋ฅผ ๋„˜๊ธฐ๋Š” ์ฒซ๋ฒˆ์งธ ์ธ์ž์—” SVG ๊ฐ™์€ ์ด๋ฏธ์ง€๋Š” ๋ฌผ๋ก  ๋น„๋””์˜ค๋‚˜ ์บ”๋ฒ„์Šค ์—˜๋ฆฌ๋จผํŠธ๋„ ๊ฐ€๋Šฅํ•˜๋‹ค. โ€” MDN

  • drawImage(image, dx, dy)
  • drawImage(image, dx, dy, dw, dh)
  • drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)
// utils.js
export const toDataURL = (
img: HTMLImageElement,
width: number,
height: number,
) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('No 2d context');
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
// 1๋ฒˆ์งธ ์ธ์ž : HTMLImageElement, SVGImageElement ๋“ฑ ์ด๋ฏธ์ง€ ์†Œ์Šค ์—˜๋ฆฌ๋จผํŠธ
// 2๋ฒˆ์งธ ์ธ์ž(dx) : ์บ”๋ฒ„์Šค์— ๊ทธ๋ฆด x์ถ• ์ขŒํ‘œ
// 3๋ฒˆ์งธ ์ธ์ž(dy) : ์บ”๋ฒ„์Šค์— ๊ทธ๋ฆด y์ถ• ์ขŒํ‘œ
// 4๋ฒˆ์งธ ์ธ์ž(dw) : ์บ”๋ฒ„์Šค์— ๊ทธ๋ฆด width
// 5๋ฒˆ์žฌ ์ธ์ž(dy) : ์บ”๋ฒ„์Šค์— ๊ทธ๋ฆด height
return canvas.toDataURL(); // ๋ฆฌ์‚ฌ์ด์ฆˆํ•œ ์ด๋ฏธ์ง€๋ฅผ base64(data URL) ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜
};
// ์ด๋ฏธ์ง€ ์—˜๋ฆฌ๋จผํŠธ๋ฅผ ๋ฐ›์•„ 4ร—4๋กœ ๋ฆฌ์‚ฌ์ด์ฆˆํ•œ base64 ๋ฌธ์ž์—ด์„ ๋ฐ˜ํ™˜ํ•˜๋Š” ํ•จ์ˆ˜
export const getBlurDataURL = (img: HTMLImageElement) => {
return toDataURL(img, 4, 4);
};

 

๐Ÿ’ก URL.createObjectURL ๋Œ€์‹  FileReader API๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜๋„ ์žˆ๋‹ค(์ฐธ๊ณ  ํฌ์ŠคํŒ…)

// <input type="file" ... /> ์—˜๋ฆฌ๋จผํŠธ์˜ onChange ํ•ธ๋“ค๋Ÿฌ
const image = e.target.files?.[0];
if (image) {
const blobUrl = URL.createObjectURL(image);
const img = new Image();
img.src = blobUrl;
img.onload = () => {
const base64 = getBlurDataURL(img); // "data:image/png;base64,iVBw...
URL.revokeObjectURL(blobUrl); // ์ด๋ฏธ์ง€ ๋กœ๋“œ๋ฅผ ์™„๋ฃŒํ•˜๋ฉด ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด ํ๊ธฐ
// ...์›ํ•˜๋Š” ์ž‘์—… ์ˆ˜ํ–‰
// base64 ๋ฌธ์ž์—ด์€ Next/Image์˜ blurDataURL ์†์„ฑ์— ์‚ฌ์šฉํ•œ๋‹ค
};
}

 

4ร—4๋กœ ๋ฆฌ์‚ฌ์ด์ฆˆํ•œ ์ด๋ฏธ์ง€. ํ‰๊ท ์ ์œผ๋กœ 200๋ฐ”์ดํŠธ ๋ฏธ๋งŒ์˜ ์šฉ๋Ÿ‰์œผ๋กœ ์ค„์–ด๋“ ๋‹ค

 

๋ฐฉ๋ฒ•2. Plaiceholder ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ํ™œ์šฉ


๐Ÿ’ก TailwindCSS ์œ ํ‹ธ๋ฆฌํ‹ฐ ํด๋ž˜์Šค ํ˜•ํƒœ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” @plaiceholder/tailwindcss ํ”Œ๋Ÿฌ๊ทธ์ธ๋„ ์žˆ๋‹ค

 

plaiceholder ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋Š” LQIP(์ €ํ™”์งˆ ์ด๋ฏธ์ง€) ์ƒ์„ฑ์„ ๋„์™€์ฃผ๋Š” NodeJS ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋‹ค. Base64, SVG ๋“ฑ ํฌ๋งท์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. Next/Image์˜ blurDataURL ์†์„ฑ์— ์‚ฌ์šฉํ•˜๋ ค๋ฉด Base64๋กœ ์ƒ์„ฑํ•˜๋ฉด ๋œ๋‹ค. ๊ณต์‹ ๋ฌธ์„œ์— ๋”ฐ๋ฅด๋ฉด Base64 ํฌ๋งท์€ ์ผ๋ฐ˜์ ์œผ๋กœ ~300 Bytes ๋ฏธ๋งŒ ์‚ฌ์ด์ฆˆ๋กœ ์ƒ์„ฑ๋œ๋‹ค๊ณ  ํ•œ๋‹ค.

 

ํŒจํ‚ค์ง€ ์„ค์น˜

npm install plaiceholder @plaiceholder/next sharp

 

์ดˆ๊ธฐ ์„ธํŒ…

// next.config.js (withPlugins ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ)
const { withPlaiceholder } = require('@plaiceholder/next');
module.exports = withPlaiceholder({
images: { domains: ['images.unsplash.com'] },
// ...NextJS configs
});
// next.config.js (withPlugins ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ)
const withPlugins = require('next-compose-plugins');
const { withPlaiceholder } = require('@plaiceholder/next');
const nextConfig = {
images: { domains: ['images.unsplash.com'] },
// ...NextJS configs
};
module.exports = withPlugins([withPlaiceholder, ...], {
...nextConfig,
// ...
});

 

๊ธฐ๋ณธ ์‚ฌ์šฉ ๋ฐฉ๋ฒ•

๐Ÿ’ก plaiceholder๋Š” NodeJS ํ™˜๊ฒฝ์—์„œ๋งŒ ์ž‘๋™ํ•œ๋‹ค.

 

plaiceholder ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋Š” NodeJS ํ™˜๊ฒฝ์—์„œ๋งŒ ์ž‘๋™ํ•˜๋ฏ€๋กœ getStaticProps, getServerSideProps ๋“ฑ์—์„œ ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค. getPlaiceholder ์ฒซ๋ฒˆ์งธ ์ธ์ž์—” ์ •์  ์ด๋ฏธ์ง€ ๊ฒฝ๋กœ(public ํด๋” ๊ธฐ์ค€) ํ˜น์€ ๋ฆฌ๋ชจํŠธ ์ด๋ฏธ์ง€ ๊ฒฝ๋กœ๋ฅผ ์ž…๋ ฅํ•œ๋‹ค. ๋‘๋ฒˆ์งธ ์˜ต์…˜ ์ธ์ž์—” dir(์ •์  ์—์…‹ ํด๋” ์ง€์ •, ๊ธฐ๋ณธ๊ฐ’ ./public), size(๊ธฐ๋ณธ๊ฐ’ 4) brightness ๋“ฑ์˜ ์˜ต์…˜์„ ์ง€์ •ํ•  ์ˆ˜ ์žˆ๋‹ค.

getPlaiceholder(src, options?)

 

  • ์ •์  ์ด๋ฏธ์ง€ ๊ฒฝ๋กœ ์˜ˆ์‹œ : getPlaiceholder("/images/me.png")
    โ†’ public/images/me.png (dir ์˜ต์…˜์„ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š์•˜๋‹ค๋ฉด ์ฒ˜์Œ /๋Š” public ํด๋”๋ฅผ ๊ฐ€๋ฆฌํ‚จ๋‹ค)
  • ๋ฆฌ๋ชจํŠธ ์ด๋ฏธ์ง€ ๊ฒฝ๋กœ ์˜ˆ์‹œ : getPlaiceholder("images.unsplash.com/...")

 

getPlaiceholder ํ•จ์ˆ˜๋Š” css, svg, base64, blurhash, img ๋‹ค์„ฏ ์œ ํ˜•์˜ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. img๋Š” ์ด๋ฏธ์ง€ ํƒœ๊ทธ์— ํ•„์š”ํ•œ ์–ดํŠธ๋ฆฌ๋ทฐํŠธ๋ฅผ ํฌํ•จ(src, width, height, type)ํ•˜๊ณ  ์žˆ๋‹ค. base64 ๋ฐ˜ํ™˜๊ฐ’์€ Image ์ปดํฌ๋„ŒํŠธ์˜ blurDataURL ์†์„ฑ์— ์‚ฌ์šฉํ•˜๋ฉด ๋œ๋‹ค.

// ๊ณต์‹ ๋ฌธ์„œ ์ฝ”๋“œ ์ฐธ๊ณ 
import type { InferGetStaticPropsType } from 'next';
import { getPlaiceholder } from 'plaiceholder';
export const getStaticProps = async () => {
const { base64, img } = await getPlaiceholder('/path-to-your-image.jpg');
// img -> { src: '...', width: 382, height: 382, type: 'png' }
// base64 -> "data:image/png;base64,iVBw...
return {
props: {
imageProps: { ...img, blurDataURL: base64 },
},
};
};
export default function ImageList({
imageProps,
}: InferGetStaticPropsType<typeof getStaticProps>) {
// ...
return (
<div>
<Image {...imageProps} placeholder="blur" />
</div>
);
}

 

๋ธŒ๋ผ์šฐ์ € ํ™˜๊ฒฝ์—์„œ ์‚ฌ์šฉํ•˜๊ธฐ

plaiceholder๋Š” ๋ธŒ๋ผ์šฐ์ € ํ™˜๊ฒฝ์—์„  ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋‹ค. ๋Œ€์‹  ์•„๋ž˜์ฒ˜๋Ÿผ Base64 ๋ณ€ํ™˜ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜๋Š” NextJS API Route๋ฅผ ๋งŒ๋“ค์–ด๋‘๊ณ  ๋ธŒ๋ผ์šฐ์ €์—์„œ NextJS API๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. (์ฐธ๊ณ ๊ธ€)

// pages/api/get-base64.ts
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<string>,
) {
const { body } = req;
const { url } = body;
try {
const { base64 } = await getPlaiceholder(url);
res.status(200).send(base64);
} catch (e) {
if (e instanceof Error) res.status(500).send(e.message);
// ...
}
}
// utils.ts
export const getBase64 = async (url: string) => {
return await axios.post<string>('/api/get-base64', { url });
};

 

๋ ˆํผ๋Ÿฐ์Šค


 


๊ธ€ ์ˆ˜์ •์‚ฌํ•ญ์€ ๋…ธ์…˜ ํŽ˜์ด์ง€์— ๊ฐ€์žฅ ๋น ๋ฅด๊ฒŒ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค. ๋งํฌ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”
๋ฐ˜์‘ํ˜•

๋Œ“๊ธ€

๋Œ“๊ธ€์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.