๋ฐ˜์‘ํ˜•

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 ๋ฌธ์ž์—ด์„ ์ƒ์„ฑํ•˜๊ณ , ํฌ๋กญ ์ด๋ฏธ์ง€ ์›๋ณธ์€ ์„œ๋ฒ„๋กœ ์ „์†ก โนํฌ๋กญ ์ด๋ฏธ์ง€ ๋ Œ๋”.

๋”๋ณด๊ธฐ
์ด๋ฏธ์ง€ ์ถœ์ฒ˜ - 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 });
};

 

๋ ˆํผ๋Ÿฐ์Šค


 


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