๋ฐ˜์‘ํ˜•

๋ธ”๋Ÿฌ placeholder๋ฅผ ์‚ฌ์šฉํ•œ Lazy Loading(GIF ํ™”์งˆ ๋•Œ๋ฌธ์— ๋ธ”๋Ÿฌ ์ด๋ฏธ์ง€๊ฐ€ ๋ญ‰๊ฐœ์ ธ์„œ ๋ณด์ด๋Š” ์  ์ฐธ๊ณ )

์›นํŽ˜์ด์ง€์—์„œ ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ๊ฐ€์žฅ ๋งŽ์ด ์ฃผ๋Š” ๋ถ€๋ถ„์ด ์ด๋ฏธ์ง€ / ๋น„๋””์˜ค ๊ฐ™์€ ๋ฏธ๋””์–ด ์š”์†Œ๋‹ค. ํŠนํžˆ ์ด๋ฏธ์ง€๋Š” ๋ฐฐ๋„ˆ, ์ œํ’ˆ ์‚ฌ์ง„, ๋กœ๊ณ  ๋“ฑ ํŽ˜์ด์ง€ ๊ตฌ์„๊ตฌ์„์—์„œ ์‚ฌ์šฉํ•œ๋‹ค. HTTP Archive Data์— ๋”ฐ๋ฅด๋ฉด ์ „์ฒด ์›นํŽ˜์ด์ง€ ์šฉ๋Ÿ‰์˜ 45%๋ฅผ ์ด๋ฏธ์ง€๊ฐ€ ์ฐจ์ง€ํ•œ๋‹ค๊ณ  ํ•œ๋‹ค. ์ด๋ฏธ์ง€๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๊ฑด ๋ถˆ๊ฐ€๋Šฅํ•˜์ง€๋งŒ, ํ™”๋ฉด์— ๋…ธ์ถœ๋  ๋•Œ๋งŒ ์ด๋ฏธ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋ฐฉ์‹์œผ๋กœ ํŽ˜์ด์ง€ ๋กœ๋”ฉ ์‹œ๊ฐ„์„ ๋‹จ์ถ•์‹œํ‚ฌ ์ˆ˜ ์žˆ๋‹ค. ์ด๋Ÿฐ ๋ฐฉ์‹์„ Lazy Loading์ด๋ผ๊ณ  ํ•œ๋‹ค.

 

Lazy Loading ๊ตฌํ˜„ ๋ฐฉ๋ฒ•


๐Ÿ’ก Lazy Loading์ด ์ ์šฉ๋œ ์ด๋ฏธ์ง€๊ฐ€ ๋ทฐํฌํŠธ์— ๊ทผ์ ‘ํ•ด์„œ ์ด๋ฏธ์ง€๋ฅผ ๋กœ๋“œํ•˜๋ฉด ์ฝ˜ํ…์ธ ๊ฐ€ ๋ฐ€๋ ค๋‚˜๋Š” ํ˜„์ƒ์ด ๋ฐœ์ƒํ•œ๋‹ค. ์ด๋ฅผ ๋ฐฉ์ง€ํ•˜๋ ค๋ฉด ์ด๋ฏธ์ง€๋ฅผ ๊ฐ์‹ธ๋Š” ์ปจํ…Œ์ด๋„ˆ ์š”์†Œ์— ๋†’์ด / ๋„ˆ๋น„๋ฅผ ์ง€์ •ํ•˜๋ฉด ๋œ๋‹ค.

 

Lazy Loading์€ ํฌ๊ฒŒ Chrome Native ๋ฐฉ์‹๊ณผ JavaScript๋ฅผ ์ด์šฉํ•œ ๋ฐฉ์‹์œผ๋กœ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค.

 

(1) Native Lazy Loading ๋ฐฉ์‹

Chrome 76 ๋ฒ„์ „ ์ด์ƒ๋ถ€ํ„ฐ Native ๋ฐฉ์‹์˜ Lazy Loading์„ ์ง€์›ํ•œ๋‹ค. ์ ์šฉํ•  ์ด๋ฏธ์ง€ ํƒœ๊ทธ์˜ loading ์†์„ฑ์— lazy๋งŒ ์ถ”๊ฐ€ํ•˜๋ฉด ๋œ๋‹ค. ์‚ฌ์šฉ๋ฒ•์€ ๊ฐ„๋‹จํ•˜์ง€๋งŒ 76๋ฒ„์ „ ์ด์ƒ์˜ ํฌ๋กฌ ๋ธŒ๋ผ์šฐ์ €์—์„œ๋งŒ ์ ์šฉ๋˜๋Š” ๋‹จ์ ์ด ์žˆ๋‹ค.

<img src="example.jpg" loading="lazy" width="200" height="200" alt="..." />

 

  • lazy : ๋ทฐํฌํŠธ์™€ ๊ทผ์ ‘ํ–ˆ์„ ๋•Œ๋งŒ ์ด๋ฏธ์ง€ ๋กœ๋“œ — Lazy Loading
  • eager : ๋ทฐํฌํŠธ์™€ ์ƒ๊ด€์—†์ด ๋ชจ๋“  ์ด๋ฏธ์ง€ ๋กœ๋“œ
  • auto : loading ์†์„ฑ์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š์•˜์„ ๋•Œ์˜ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ lazy ์†์„ฑ์„ ์ ์šฉํ•œ ๊ฒƒ๊ณผ ๋™์ผ.

 

(2) JavaScript ํ™œ์šฉ ๋ฐฉ์‹

์ด๋ฏธ์ง€ ํƒœ๊ทธ์˜ src ์†์„ฑ์—” ๋กœ๋”ฉ ์ „ ๋ณด์—ฌ์งˆ placeholder ์ด๋ฏธ์ง€๋ฅผ ์ถ”๊ฐ€ํ•ด๋‘๊ณ , ๋ฐ์ดํ„ฐ ํ”„๋กœํผํ‹ฐ๋ฅผ ์ด์šฉํ•ด ์›๋ณธ ์ด๋ฏธ์ง€๋ฅผ data-src ์†์„ฑ์— ํ• ๋‹นํ•ด๋‘”๋‹ค. ์ด๋ฏธ์ง€ ํƒœ๊ทธ๊ฐ€ ํ™”๋ฉด์— ๋…ธ์ถœ๋˜๋ฉด data-src ์†์„ฑ์— ์ง€์ •ํ•ด๋†“์€ ์›๋ณธ ์ด๋ฏธ์ง€๋ฅผ src ์†์„ฑ์— ํ• ๋‹นํ•ด์„œ ์›๋ณธ ์ด๋ฏธ์ง€๋ฅผ ๋กœ๋“œํ•œ๋‹ค.

<!-- ํ™”๋ฉด ๋…ธ์ถœ ์ „ -->
<img data-src="์›๋ณธ ์ด๋ฏธ์ง€ ์ฃผ์†Œ" src="placeholder ์ด๋ฏธ์ง€ ์ฃผ์†Œ" />

<!-- ํ™”๋ฉด ๋…ธ์ถœ ํ›„ -->
<img data-src="์›๋ณธ ์ด๋ฏธ์ง€ ์ฃผ์†Œ" src="์›๋ณธ ์ด๋ฏธ์ง€ ์ฃผ์†Œ" />

 

์œ„ ๋ฐฉ์‹์„ ๊ตฌํ˜„ํ•˜๋ ค๋ฉด ์ด๋ฏธ์ง€ ํƒœ๊ทธ์˜ ํ™”๋ฉด ๋…ธ์ถœ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•ด์•ผ๋œ๋‹ค. ๊ตฌํ˜„ ๋ฐฉ๋ฒ•์€ ์•„๋ž˜ 2๊ฐ€์ง€๊ฐ€ ์žˆ๋‹ค.

 

Scroll ์ด๋ฒคํŠธ + ๊ธฐํ•˜ ํ”„๋กœํผํ‹ฐ ํ™œ์šฉ

์Šคํฌ๋กค ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•  ๋•Œ๋งˆ๋‹ค ์š”์†Œ๊ฐ€ ํ™”๋ฉด์— ๋…ธ์ถœ๋๋Š”์ง€ ํ™•์ธํ•˜๋Š” ๋ฐฉ์‹. getBoundingClientRect ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•ด์„œ ๋ทฐํฌํŠธ ๊ธฐ์ค€์˜ ์š”์†Œ ์œ„์น˜ ์ขŒํ‘œ๊ฐ’์„ ์–ป์–ด์•ผ ํ•œ๋‹ค. ์ด ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•  ๋•Œ๋งˆ๋‹ค ๋ฆฌํ”Œ๋กœ์šฐ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š” ๋‹จ์ ์ด ์žˆ์œผ๋ฉฐ, ๋งˆ์šฐ์Šค๋ฅผ ์Šคํฌ๋กคํ•  ๋•Œ๋งˆ๋‹ค ์ด๋ฒคํŠธ๊ฐ€ ๊ณ„์† ํ˜ธ์ถœ๋˜๋ฏ€๋กœ ์Šค๋กœํ‹€๋„ ์ ์šฉํ•ด์•ผ ๋œ๋‹ค.

// ์ฝ”๋“œ ์ฐธ๊ณ  via StackOverFlow
// ์š”์†Œ๊ฐ€ ํ™”๋ฉด์— ์™„์ „ํžˆ ๋“ค์–ด์™”๋Š”์ง€ ํ™•์ธํ•˜๋Š” ํ•จ์ˆ˜
function checkInView(el) {
  // ์ฃผ์–ด์ง„ ์š”์†Œ์˜ ๋ทฐํฌํŠธ ๊ธฐ์ค€ ์œ„์น˜ ๋ฐ ํฌ๊ธฐ ๋ฐ˜ํ™˜
  const rect = el.getBoundingClientRect();
  return (
    rect.top >= 0 && // ๋ทฐํฌํŠธ ์ƒ๋‹จ๋ณด๋‹ค ์•„๋ž˜ ์žˆ๋Š”์ง€ ์—ฌ๋ถ€
    rect.left >= 0 && // ๋ทฐํฌํŠธ ์™ผ์ชฝ๋ณด๋‹ค ์˜ค๋ฅธ์ชฝ์— ์žˆ๋Š”์ง€ ์—ฌ๋ถ€
    rect.bottom <= window.innerHeight && // ๋ทฐํฌํŠธ ํ•˜๋‹จ ์ด๋‚ด์— ์žˆ๋Š”์ง€ ์—ฌ๋ถ€
    rect.right <= window.innerWidth // ๋ทฐํฌํŠธ ์˜ค๋ฅธ์ชฝ ์ด๋‚ด์— ์žˆ๋Š”์ง€ ์—ฌ๋ถ€
  );
}

window.addEventListener('scroll', () => {
  document.querySelectorAll('.lazy-img').forEach((image) => {
    if (checkInView(image)) {
      image.src = image.dataset.src; // ์ด๋ฏธ์ง€ ํƒœ๊ทธ๊ฐ€ ํ™”๋ฉด์— ๋…ธ์ถœ๋˜๋ฉด src ์†์„ฑ์— ์›๋ณธ์ฃผ์†Œ ํ• ๋‹น
    }
  });
});
๋”๋ณด๊ธฐ
์ด๋ฏธ์ง€ ์ถœ์ฒ˜ JavaScript Info

 

Intersection Observer API ํ™œ์šฉ โญ๏ธ

๐Ÿ’ก Intersection Observer API์™€ ๊ด€๋ จํ•œ ๋” ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋งํฌ ์ฐธ๊ณ 

 

Intersection Observer๋Š” ์š”์†Œ๊ฐ€ ํ™”๋ฉด์— ๋…ธ์ถœ๋๋Š”์ง€ ์•Œ๋ ค์ฃผ๋Š” API๋‹ค. ๋น„๋™๊ธฐ๋กœ ์ž‘๋™ํ•˜๊ณ  ๋ฉ”์ธ ์Šค๋ ˆ๋“œ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ์„ฑ๋Šฅ๋ฉด์—์„œ ๋” ์œ ๋ฆฌํ•˜๋‹ค. ์‚ฌ์šฉ๋ฒ•๋„ ๊ฐ„๋‹จํ•˜๋‹ค. Lazy Loading ์ ์šฉ ํƒœ๊ทธ๋ฅผ IO์˜ ๊ด€์ฐฐ ๋Œ€์ƒ์œผ๋กœ ๋“ฑ๋กํ•˜๊ณ , ํ•ด๋‹น ํƒœ๊ทธ๊ฐ€ ํ™”๋ฉด์— ๋…ธ์ถœ๋˜๋ฉด src ์†์„ฑ์— ์›๋ณธ ์ฃผ์†Œ๋ฅผ ํ• ๋‹นํ•œ ๋’ค ๊ด€์ฐฐ์„ ํ•ด์ œํ•˜๋ฉด ๋œ๋‹ค.

const ioHandler = (entries, io) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.src = entry.target.dataset.src; // src ์†์„ฑ์— ์›๋ณธ ์ฃผ์†Œ ํ• ๋‹น
      io.unobserve(entry.target); // ํ™”๋ฉด์— ๋…ธ์ถœ๋ผ์„œ ์›๋ณธ ์ด๋ฏธ์ง€๋ฅผ ๋กœ๋“œํ–ˆ์œผ๋ฏ€๋กœ ๊ด€์ฐฐ ํ•ด์ œ
    }
  });
};

const observer = new IntersectionObserver(ioHandler); // IO ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ
document.querySelectorAll('.lazy-img').forEach((el) => observer.observe(el)); // ๊ด€์ฐฐ ๋Œ€์ƒ ๋“ฑ๋ก

 

IO API ํ™œ์šฉํ•ด์„œ ๊ตฌํ˜„ํ•˜๊ธฐ


์ด๋ฏธ์ง€ API

Lorem Picsum๋Š” Unsplash์˜ ์ผ๋ถ€ ์ด๋ฏธ์ง€๋ฅผ ๊ฐ€์ ธ์™€์„œ ์ œ๊ณตํ•˜๋Š” ๋ฌด๋ฃŒ API๋‹ค. ์ด๋ฏธ์ง€ ์‚ฌ์ด์ฆˆ ์ง€์ •, ํ‘๋ฐฑ / ๋ธ”๋Ÿฌ ํšจ๊ณผ, ๋žœ๋ค ์ด๋ฏธ์ง€ ๋“ฑ์˜ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•œ๋‹ค. Lorem Picsum์—์„  ์ด๋ฏธ์ง€ ๋ฆฌ์ŠคํŠธ๋„ ์ œ๊ณตํ•˜๋Š”๋ฐ ์ด๋ฅผ ์ด์šฉํ•˜๋ฉด ๋ฌดํ•œ์Šคํฌ๋กค + Lazy Loading์„ ๊ตฌํ˜„ํ•˜๊ธฐ ๋”ฑ ์ ๋‹นํ•˜๋‹ค. ํŽ˜์ด์ง€๋ณ„๋กœ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ 1๊ฐœ ํŽ˜์ด์ง€์— ๋ช‡๊ฐœ์˜ ์ด๋ฏธ์ง€ ์ •๋ณด๋ฅผ ๋ฐ›์•„์˜ฌ์ง€๋„ ์ •ํ•  ์ˆ˜ ์žˆ๋‹ค.

# API ์ฃผ์†Œ ์˜ˆ์‹œ
https://picsum.photos/v2/list?page=2&limit=100 [์ด๋ฏธ์ง€ ๋ฆฌ์ŠคํŠธ]
https://picsum.photos/200/300/?blur=2 [๋ธ”๋Ÿฌ ํšจ๊ณผ๊ฐ€ ์ ์šฉ๋œ 200x300 ์ด๋ฏธ์ง€]
https://picsum.photos/id/237/200/300 [ID๊ฐ€ 237์ธ 200x300 ์ด๋ฏธ์ง€]
// ์ด๋ฏธ์ง€ ๋ฆฌ์ŠคํŠธ GET 200 OK
[
  {
    "id": "0",
    "author": "Alejandro Escamilla",
    "width": 5616,
    "height": 3744,
    "url": "https://unsplash.com/...",
    "download_url": "https://picsum.photos/..."
  }, 
]

 

IO Custom Hook

Intersection Observer API๋Š” ๋ฌดํ•œ ์Šคํฌ๋กค์€ ๋ฌผ๋ก  Lazy Loading, ๊ด‘๊ณ  ๊ฐ€์‹œ์„ฑ ํ™•์ธ ๋“ฑ ๋‹ค์–‘ํ•œ ๊ณณ์—์„œ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ ์ปค์Šคํ…€ Hook์œผ๋กœ ๋ถ„๋ฆฌํ•ด์„œ ์žฌ์‚ฌ์šฉํ•˜๋ฉด ์ข‹๋‹ค. ์ปค์Šคํ…€ Hook์˜ ์ธ์ž๋Š” ์•„๋ž˜ 3๊ฐœ๋ฅผ ๋ฐ›๋„๋ก ์ž‘์„ฑํ•œ๋‹ค. ํ•จ์ˆ˜ ํ˜ธ์ถœ์‹œ ์ธ์ž ์ˆœ์„œ์— ๊ตฌ์• ๋ฐ›์ง€ ์•Š๋„๋ก RORO ํŒจํ„ด์œผ๋กœ ์ž‘์„ฑํ•œ๋‹ค.

 

  1. callback : ๊ต์ฐจ ์ƒํƒœ์—์„œ ์‹คํ–‰ํ•  ๋กœ์ง์ด ๋‹ด๊ธด ์ฝœ๋ฐฑ ํ•จ์ˆ˜
  2. unObserver : ๊ต์ฐจ ์ƒํƒœ ํ›„ ํ•ด๋‹น ํƒ€๊ฒŸ์˜ ๊ด€์ฐฐ์„ ์ค‘์ง€ํ• ์ง€ ์—ฌ๋ถ€. true | false
    ๐Ÿ’ก ๋ฌดํ•œ์Šคํฌ๋กค์€ ๊ต์ฐจ ์ƒํƒœ ์ดํ›„์—๋„ ๊ฐ€์žฅ ํ•˜๋‹จ์˜ ๋นˆ ์š”์†Œ๋ฅผ ๊ณ„์† ๊ด€์ฐฐํ•ด์•ผ ํ•˜์ง€๋งŒ(false), Lazy Loading ๋“ฑ์„ ๊ตฌํ˜„ํ•  ๋• ์ด๋ฏธ ํ™”๋ฉด์— ๋…ธ์ถœ๋ผ์„œ ์ด๋ฏธ์ง€๋ฅผ ๋กœ๋“œํ•œ ์ƒํƒœ๋ฉด ๋”์ด์ƒ ๊ด€์ฐฐํ•  ํ•„์š”๊ฐ€ ์—†๋‹ค(true).
  3. options : Intersection Observer ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ ์‹œ ๋„˜๊ธธ ์˜ต์…˜

 

์ปค์Šคํ…€ Hook์€ Ref ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ํ•œ๋‹ค. ๊ทธ๋Ÿผ Hook์„ ํ˜ธ์ถœํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ์—์„œ Ref ๊ฐ์ฒด๋ฅผ ๋ฐ›์€ ๋’ค ๊ด€์ฐฐํ•˜๊ณ  ์‹ถ์€ ์ปดํฌ๋„ŒํŠธ์˜ ref ์†์„ฑ์— ๋ฐ”๋กœ ํ• ๋‹นํ•˜๋ฉด ๋œ๋‹ค. ์‚ฌ์šฉํ•˜๊ธฐ ๋” ํŽธํ•˜๋‹ค.

๋”๋ณด๊ธฐ
// ์ฝ”๋“œ ์ผ๋ถ€ ์ฐธ๊ณ  : https://mrcoles.com/intersection-observer-react-hook/
import { useCallback, useEffect, useRef } from 'react';

export default function useIntersectionObserver({
  callback,
  unObserve = false,
  options = {
    root: null, // ๋…ธ์ถœ&๋น„๋…ธ์ถœ ์—ฌ๋ถ€๋ฅผ ์–ด๋–ค ์š”์†Œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํ• ์ง€ ์ง€์ •ํ•˜๋Š” ์˜ต์…˜. ๊ธฐ๋ณธ๊ฐ’ null(๋ทฐํฌํŠธ)
    rootMargin: '0px', // ๋ฐ”๊นฅ ์—ฌ๋ฐฑ(Margin)์„ ์ด์šฉํ•ด Root ๋ฒ”์œ„๋ฅผ ํ™•์žฅ/์ถ•์†Œํ•˜๋Š” ์˜ต์…˜. ๊ธฐ๋ณธ๊ฐ’ 0px
    threshold: 0, // ํ™”๋ฉด์— ์–ผ๋งŒํผ ๋…ธ์ถœ๋ผ์•ผ ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ• ์ง€ ๊ฒฐ์ •ํ•˜๋Š” ์˜ต์…˜. ๊ธฐ๋ณธ๊ฐ’ 0(1px ๋ผ๋„ ํ™”๋ฉด์— ๋ณด์ด๋ฉด ์ฝœ๋ฐฑ ํ˜ธ์ถœ)
  },
}) {
  const ioRef = useRef(null);

  // Intersection Observer ์ฝœ๋ฐฑ ์ •์˜(useCallback ์ ์šฉ์€ ้€‰้กน)
  // (์ธ์ž1) entries: ๊ด€์ฐฐ ์ค‘์ธ ๋ชจ๋“  ๋Œ€์ƒ์ด ๋‹ด๊ธด ๋ฐฐ์—ด, (์ธ์ž2) observer: ๊ด€์ฐฐ์ž ๊ฐ์ฒด
  const ioHandler = useCallback((entries, observer) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        // ํ™”๋ฉด ์•ˆ์— ํƒ€๊ฒŸ ์š”์†Œ๊ฐ€ ๋“ค์–ด์™”๋Š”์ง€ ์ฒดํฌ
        callback(); // ๊ต์ฐจ ์‹œ์ ์—(๊ด€์ฐฐ ๋Œ€์ƒ์ด ๋ทฐํฌํŠธ์— ๋…ธ์ถœ) ์ธ์ž๋กœ ๋ฐ›์€ ์ฝœ๋ฐฑ ํ˜ธ์ถœ
        if (unObserve) observer.unobserve(entry.target); // (์กฐ๊ฑด ๋งŒ์กฑ์‹œ) ํ•ด๋‹น ํƒ€๊ฒŸ์€ ๊ด€์ฐฐ ์ค‘์ง€
      }
    });
  }, []);

  useEffect(() => {
    if (window.IntersectionObserver) {
      const observer = new IntersectionObserver(ioHandler, options); // ์ฝœ๋ฐฑ&์˜ต์…˜ ๋“ฑ๋ก ๋ฐ IO ๊ฐ์ฒด ์ƒ์„ฑ
      observer.observe(ioRef.current); // ์ธ์ž๋กœ ๋„˜๊ธด ์š”์†Œ๋ฅผ ๊ด€์ฐฐ ๋Œ€์ƒ์œผ๋กœ ๋“ฑ๋ก

      return () => observer.disconnect(); // ์–ธ๋งˆ์šดํŠธ์‹œ ๋ชจ๋“  ์š”์†Œ์— ๋Œ€ํ•œ ๊ด€์ฐฐ ์ค‘์ง€
    }
  }, []);

  return ioRef;
}
๋”๋ณด๊ธฐ
// ์ฝ”๋“œ ์ผ๋ถ€ ์ฐธ๊ณ  : https://mrcoles.com/intersection-observer-react-hook/
import { useCallback, useEffect, useRef } from 'react';

interface IOProps {
  callback: VoidHandler;
  unObserve?: boolean;
  options?: IntersectionObserverInit;
}

export default function useIntersectionObserver<T extends HTMLElement>({
  callback,
  unObserve = false,
  options = {
    root: null, // ๋…ธ์ถœ&๋น„๋…ธ์ถœ ์—ฌ๋ถ€๋ฅผ ์–ด๋–ค ์š”์†Œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํ• ์ง€ ์ง€์ •ํ•˜๋Š” ์˜ต์…˜. ๊ธฐ๋ณธ๊ฐ’ null(๋ทฐํฌํŠธ)
    rootMargin: '0px', // ๋ฐ”๊นฅ ์—ฌ๋ฐฑ(Margin)์„ ์ด์šฉํ•ด Root ๋ฒ”์œ„๋ฅผ ํ™•์žฅ/์ถ•์†Œํ•˜๋Š” ์˜ต์…˜. ๊ธฐ๋ณธ๊ฐ’ 0px
    threshold: 0, // ํ™”๋ฉด์— ์–ผ๋งŒํผ ๋…ธ์ถœ๋ผ์•ผ ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ• ์ง€ ๊ฒฐ์ •ํ•˜๋Š” ์˜ต์…˜. ๊ธฐ๋ณธ๊ฐ’ 0(1px ๋ผ๋„ ํ™”๋ฉด์— ๋ณด์ด๋ฉด ์ฝœ๋ฐฑ ํ˜ธ์ถœ)
  },
}: IOProps) {
  const ioRef = useRef<T>(null);

  // Intersection Observer ์ฝœ๋ฐฑ ์ •์˜(useCallback ์ ์šฉ์€ ้€‰้กน)
  // (์ธ์ž1) entries: ๊ด€์ฐฐ ์ค‘์ธ ๋ชจ๋“  ๋Œ€์ƒ์ด ๋‹ด๊ธด ๋ฐฐ์—ด, (์ธ์ž2) observer: ๊ด€์ฐฐ์ž ๊ฐ์ฒด
  const ioHandler: IntersectionObserverCallback = useCallback(
    (entries, observer) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          // ํ™”๋ฉด ์•ˆ์— ํƒ€๊ฒŸ ์š”์†Œ๊ฐ€ ๋“ค์–ด์™”๋Š”์ง€ ์ฒดํฌ
          callback(); // ๊ต์ฐจ ์‹œ์ ์—(๊ด€์ฐฐ ๋Œ€์ƒ์ด ๋ทฐํฌํŠธ์— ๋…ธ์ถœ) ์ธ์ž๋กœ ๋ฐ›์€ ์ฝœ๋ฐฑ ํ˜ธ์ถœ
          if (unObserve) observer.unobserve(entry.target); // (์กฐ๊ฑด ๋งŒ์กฑ์‹œ) ํ•ด๋‹น ํƒ€๊ฒŸ์€ ๊ด€์ฐฐ ์ค‘์ง€
        }
      });
    },
    [callback, unObserve],
  );

  useEffect(() => {
    if (!window.IntersectionObserver || !ioRef.current) return undefined;
    const observer = new IntersectionObserver(ioHandler, options); // ์ฝœ๋ฐฑ&์˜ต์…˜ ๋“ฑ๋ก ๋ฐ IO ๊ฐ์ฒด ์ƒ์„ฑ
    observer.observe(ioRef.current); // ์ธ์ž๋กœ ๋„˜๊ธด ์š”์†Œ๋ฅผ ๊ด€์ฐฐ ๋Œ€์ƒ์œผ๋กœ ๋“ฑ๋ก

    return () => observer.disconnect(); // ์–ธ๋งˆ์šดํŠธ์‹œ ๋ชจ๋“  ์š”์†Œ์— ๋Œ€ํ•œ ๊ด€์ฐฐ ์ค‘์ง€
  }, [ioHandler, options]);

  return ioRef;
}

 

(1) Single color Placeloader

ํšŒ์ƒ‰ placeholder๋ฅผ ์ด์šฉํ•œ Lazy Loading

LazyImage ์ปดํฌ๋„ŒํŠธ

Lazy Loading์„ ์ ์šฉํ•  ์ด๋ฏธ์ง€๋Š” LazyImage ์ปดํฌ๋„ŒํŠธ๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค. data-src์— ์›๋ณธ ์ด๋ฏธ์ง€ ์ฃผ์†Œ๋ฅผ ํ• ๋‹นํ•˜๊ณ  ํ™”๋ฉด์— ๋…ธ์ถœ๋์„ ๋•Œ src ์†์„ฑ์œผ๋กœ ๋ฐ”๊พธ๋Š” ๋ฐฉ๋ฒ•์ด ์•„๋‹Œ, ํ™”๋ฉด ๋…ธ์ถœ ์—ฌ๋ถ€๋ฅผ isInView ์ƒํƒœ์—์„œ ๊ด€๋ฆฌํ•˜๋„๋ก ํ–ˆ๋‹ค. ํ™”๋ฉด ๋…ธ์ถœ์ „์—” ์ด๋ฏธ์ง€ ํƒœ๊ทธ๋ฅผ ๊ฐ์‹ธ๋Š” ํšŒ์ƒ‰ ๋ฐฐ๊ฒฝ์˜ ๋ถ€๋ชจ ์—˜๋ฆฌ๋จผํŠธ(placeholder)๋งŒ ๋ณด์ด๊ณ , ํ™”๋ฉด์— ๋…ธ์ถœ ๋์„ ๋•Œ isInView ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•ด์„œ ์ด๋ฏธ์ง€ ํƒœ๊ทธ๋ฅผ ๋ณด์—ฌ์ฃผ๋Š” ๋ฐฉ์‹์ด๋‹ค.

 

์ฒซ ํ™”๋ฉด์—” Lazy Loading ์ ์šฉ ์—†์ด ์ด๋ฏธ์ง€๋ฅผ ๋ฐ”๋กœ ๋ณด์—ฌ์ค˜์•ผ ํ•˜๋ฏ€๋กœ noLazy๋ผ๋Š” props๋ฅผ ๋ฐ›์•„์„œ ์ง€์—ฐ ๋กœ๋”ฉ ์—ฌ๋ถ€๋ฅผ ํŒ๋‹จํ•  ์ˆ˜ ์žˆ๋„๋ก ํ–ˆ๋‹ค(์ฒ˜์Œ 5๊ฐœ ์ด๋ฏธ์ง€๋Š” noLazy๋กœ ์„ค์ •ํ–ˆ๋‹ค).

// LazyImage.js
function LazyImage({ src, noLazy, details }) {
  const [isInView, setIsInView] = useState(!!noLazy);
  const [isLoaded, setIsLoaded] = useState(false);

  const imageRef = useIntersectionObserver({ // IO ์ปค์Šคํ…€ ํ›…
    callback: () => setIsInView(true), // ๊ด€์ฐฐ ๋Œ€์ƒ์ด ํ™”๋ฉด์— ๋…ธ์ถœ๋˜๋ฉด isInView ์ƒํƒœ ๋ณ€๊ฒฝ
    unObserve: true, // ๊ด€์ฐฐ ๋Œ€์ƒ์ด ํ™”๋ฉด์— ๋…ธ์ถœ(๊ต์ฐจ)๋˜๋ฉด ์ด๋ฏธ์ง€๋ฅผ ๋กœ๋“œํ•œ ์ƒํƒœ์ด๋ฏ€๋กœ ๊ด€์ฐฐ ํ•ด์ œ
    options: { rootMargin: '40%' }, // ์Šคํฌ๋กค์ด ํ•˜๋‹จ์— ๋„๋‹ฌํ–ˆ์„์ฆˆ์Œ ๋‹ค์Œ ์ด๋ฏธ์ง€๋ฅผ ๋ฏธ๋ฆฌ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ์œ„ํ•ด rootMargin ์„ค์ •
  });

  const imgClasses = classnames(
    `transition-opacity ease-in-out ${!noLazy && 'duration-1000'}`,
    // ์ด๋ฏธ์ง€ ์„œ์„œํžˆ ๋ณด์ด๋Š” ํšจ๊ณผ ('opacity-0': !isLoaded ๋งŒ ์ž…๋ ฅํ•ด๋„ ์ž‘๋™ํ•จ)
    { 'opacity-100': isLoaded, 'opacity-0': !isLoaded },
  );

  return (
    <div
      ref={imageRef} // IO ์ปค์Šคํ…€ ํ›…์ด ๋ฐ˜ํ™˜ํ•œ ref ๊ฐ์ฒด ํ• ๋‹น
      className="bg-gray-200 w-4/6 aspect-[5/3] rounded-md overflow-hidden"
      // ์ด๋ฏธ์ง€๋ฅผ ๊ฐ์‹ธ๋Š” ์ปจํ…Œ์ด๋„ˆ ์š”์†Œ์— ๋„ˆ๋น„/๋†’์ด๋ฅผ ์ง€์ •ํ•ด์„œ ์ด๋ฏธ์ง€ ๋กœ๋”ฉ์‹œ ๋ฐ€๋ฆผ ํ˜„์ƒ์„ ๋ฐฉ์ง€ํ•œ๋‹ค
    >
      {isInView && (
        <img
          className={imgClasses}
          src={src}
          onLoad={() => setIsLoaded(true)} // ์ด๋ฏธ์ง€ ๋กœ๋“œ๋ฅผ ์™„๋ฃŒํ•˜๋ฉด isLoaded ์ƒํƒœ ๋ณ€๊ฒฝ
          alt={details.author}
        />
      )}
    </div>
  );
}

 

์ด๋ฏธ์ง€ ํƒœ๊ทธ์˜ opacity(ํˆฌ๋ช…๋„)๋Š” ๊ธฐ๋ณธ๊ฐ’ 0์—์„œ โžŠํ™”๋ฉด์— ๋…ธ์ถœ๋œ ํ›„ โž‹์ด๋ฏธ์ง€ ๋กœ๋“œ๋ฅผ ์™„๋ฃŒํ•˜๋ฉด 1๋กœ ๋ณ€๊ฒฝํ•˜๋„๋ก ์„ค์ •ํ•œ๋‹ค. ์—ฌ๊ธฐ์— transition: opacity 1s ease-in-out CSS ์†์„ฑ์„ ์ฃผ๋ฉด ์ด๋ฏธ์ง€๊ฐ€ ์„œ์„œํžˆ ๋ณด์ด๋Š” ํšจ๊ณผ๋ฅผ ์ค„ ์ˆ˜ ์žˆ๋‹ค. ์ฒซ ํ™”๋ฉด์—์„  ์ง€์—ฐ ๋กœ๋”ฉ์ด ํ•„์š” ์—†์œผ๋ฏ€๋กœ noLazy ํ”„๋กญ์ด false์ผ ๋•Œ๋งŒ transition ํšจ๊ณผ ์ง€์† ์‹œ๊ฐ„์ด 1์ดˆ๊ฐ€ ๋˜๋„๋ก(duration-1000) ์ž‘์„ฑํ•œ๋‹ค.

const imgClasses = classnames(
  `transition-opacity ease-in-out ${!noLazy && 'duration-1000'}`,
  // ์ด๋ฏธ์ง€ ์„œ์„œํžˆ ๋ณด์ด๋Š” ํšจ๊ณผ ('opacity-0': !isLoaded ๋งŒ ์ž…๋ ฅํ•ด๋„ ์ž‘๋™ํ•จ)
  { 'opacity-100': isLoaded, 'opacity-0': !isLoaded },
);

 

LazyLoading ์ปดํฌ๋„ŒํŠธ

API ํ˜ธ์ถœ ์—ญ์‹œ ์ปค์Šคํ…€ ํ›…(useFetchData.js)์œผ๋กœ ๋งŒ๋“ค์–ด์„œ ์‚ฌ์šฉํ•œ๋‹ค. ๋ Œ๋”๋ง ๋ฆฌ์ŠคํŠธ์—” ์œ„์—์„œ ๋งŒ๋“  IO ์ปค์Šคํ…€ ํ›…์„ ์ด์šฉํ•ด์„œ ๋ฌดํ•œ ์Šคํฌ๋กค์„ ์ ์šฉํ•œ๋‹ค. ํ™”๋ฉด ๊ฐ€์žฅ ์•„๋ž˜๋กœ ์Šคํฌ๋กคํ•˜๋ฉด page ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋˜๊ณ , page ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋œ๊ฑธ ๊ฐ์ง€ํ•œ useFetchData ์ปค์Šคํ…€ ํ›…์ด ํ•ด๋‹น page์— ๋Œ€ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ค์‹œ ๋ฐ›์•„์˜จ๋‹ค.

// LazyLoading.js
export default function LazyLoading() {
  const [page, setPage] = useState(1);
  const { data, loading } = useFetchData({
    url: 'https://picsum.photos/v2/list', // ์ด๋ฏธ์ง€ ๋ฆฌ์ŠคํŠธ API ์ฃผ์†Œ
    params: { page, limit: 20 }, // page ์ƒํƒœ ๋ณ€๊ฒฝ ์‹œ ํ•ด๋‹น page์— ๋Œ€ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ค์‹œ ๋ฐ›์•„์˜จ๋‹ค
  });

  const loaderRef = useIntersectionObserver({
    // ๋ฌดํ•œ ์Šคํฌ๋กค Intersection Observer
    callback: () => setPage((prev) => prev + 1), // ํ™”๋ฉด ๊ฐ€์žฅ ์•„๋ž˜๋กœ ์Šคํฌ๋กค ์‹œ page ์ƒํƒœ ๋ณ€๊ฒฝ
  });

  return (
    <section className="flex flex-col justify-center items-center p-8 gap-8">
      {data?.map(({ id, ...details }, i) => (
        <LazyImage
          key={id}
          details={details} // ์ด๋ฏธ์ง€ ์„ธ๋ถ€์ •๋ณด
          src={`https://picsum.photos/id/${id}/1280/768`}
          noLazy={i < 5} // ์ฒซ ํ™”๋ฉด์—์„  ์ง€์—ฐ๋กœ๋”ฉ์ด ํ•„์š” ์—†์œผ๋ฏ€๋กœ ์ฒ˜์Œ 5๊ฐœ ์ด๋ฏธ์ง€๋Š” ๋ฐ”๋กœ ๋ณด์—ฌ์ค€๋‹ค
        />
      ))}
      <div className={`${loading ? 'visibility' : 'invisible'}`}>
        ๐Ÿ”๏ธ Fetching items...
      </div>

      {/* ํ™”๋ฉด ๊ฐ€์žฅ ์•„๋ž˜๊นŒ์ง€ ์Šคํฌ๋กค ํ–ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•œ ๋นˆ์š”์†Œ */}
      <div ref={loaderRef} className={`w-full ${data ? 'block' : 'hidden'}`} />
    </section>
  );
}
๋”๋ณด๊ธฐ

page, query ์ฟผ๋ฆฌ์ŠคํŠธ๋ง(key=value)์€ ๊ฒ€์ƒ‰์ฐฝ์ด๋‚˜ ๋ฌดํ•œ ์Šคํฌ๋กค ๋“ฑ์„ ๊ตฌํ˜„ํ•  ๋•Œ value๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ค์‹œ ๋ฐ›์•„์•ผ ๋œ๋‹ค. ๋”ฐ๋ผ์„œ useEffect ์ข…์†์„ฑ ๋ฐฐ์—ด์— ์ถ”๊ฐ€ํ•ด์„œ ๊ฐ’์ด ๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค ๋‹ค์‹œ ํ˜ธ์ถœ ๋˜๋„๋ก ํ•œ๋‹ค. key๋Š” API๋งˆ๋‹ค ๋‹ค๋ฅด๋ฏ€๋กœ(q=value, query=value ๋“ฑ) ์ƒํ™ฉ์— ๋งž๊ฒŒ ๋ณ€๊ฒฝํ•ด์„œ ์‚ฌ์šฉํ•œ๋‹ค. 

import { useEffect, useState } from 'react';
import axios from 'axios';

const useFetchData = ({
  method = 'get',
  url,
  payload,
  params: { page, query, ...restParams },
}) => {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true); // API ํ˜ธ์ถœ ์‹œ์ž‘ - ๋กœ๋”ฉ ์ƒํƒœ ๋ณ€๊ฒฝ
    axios({
      method, // ๊ธฐ๋ณธ๊ฐ’ get
      url,
      data: payload, // payload ๊ฐ’์ด null ํ˜น์€ undefined ์ด๋ฉด ์š”์ฒญ ๋ฐ”๋””์— ์ถ”๊ฐ€๋˜์ง€ ์•Š๋Š”๋‹ค
      params: { page, q: query, ...restParams },
    })
      .then((res) => {
        setData((prev) => (prev ? [...prev, ...res.data] : res.data));
      })
      .catch((err) => {
        setError(err);
      })
      .finally(() => {
        setLoading(false); // API ํ˜ธ์ถœ ์ข…๋ฃŒ - ๋กœ๋”ฉ ์ƒํƒœ ๋ณ€๊ฒฝ
      });
  }, [page, query]); // page ๋ฐ query ๊ฐ’์ด ๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค API ํ˜ธ์ถœ

  return { loading, data, error };
};

export default useFetchData;

 

(2) Dominant color placeholder

๐Ÿ’ก Dominant color placeholder๋Š” ๋”ฐ๋กœ ๊ตฌํ˜„ํ•˜์ง€ ์•Š๊ณ  ์„ค๋ช…๋งŒ ์ž‘์„ฑํ–ˆ๋‹ค.

 

์ด๋ฏธ์ง€ ์ถœ์ฒ˜ manu.ninja

Pinterest, Google Image ๋“ฑ์˜ ์‚ฌ์ดํŠธ๋Š” ์ด๋ฏธ์ง€๊ฐ€ ๋กœ๋“œ๋˜๊ธฐ ์ „ ํ•ด๋‹น ์ด๋ฏธ์ง€์˜ ๋ฉ”์ธ ์ปฌ๋Ÿฌ๋ฅผ ๋จผ์ € ๋ณด์—ฌ์ค€๋‹ค. ์ด๋ฏธ์ง€์˜ ์ฒ˜์Œ 1x1 ํ”ฝ์…€๋กœ ์Šค์ผ€์ผ์„ ๊ฐ์†Œ์‹œํ‚จ ํ›„ ํ•ด๋‹น ํ”ฝ์…€๋กœ placeholder๋ฅผ ์ฑ„์šฐ๋Š” ๋ฐฉ์‹์œผ๋กœ ๊ตฌํ˜„ํ–ˆ๋‹ค๊ณ  ํ•œ๋‹ค. ํด๋ผ์ด์–ธํŠธ์—์„  ์ด๋ฏธ์ง€ ๋ฆฌ์†Œ์Šค๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ์ง€ ์•Š์œผ๋ฏ€๋กœ ์„œ๋ฒ„์—์„œ Dominant Color๋ฅผ ์ถ”์ถœํ•œ ํ›„ ํด๋ผ์ด์–ธํŠธ๋กœ ๋ณด๋‚ด์ฃผ๋Š” ์ž‘์—…์ด ํ•„์š”ํ•  ๊ฒƒ ๊ฐ™๋‹ค.

 

Imagekit์€ ๊ฐ์ข… ์ตœ์ ํ™” ๊ธฐ๋Šฅ์ด ํฌํ•จ๋œ ์ด๋ฏธ์ง€ CDN์ด๋‹ค. ์ด๋ฏธ์ง€ URL์— ์ฟผ๋ฆฌ์ŠคํŠธ๋ง๋งŒ ์ถ”๊ฐ€ํ•˜๋ฉด Dominant ์ƒ‰์ƒ์ด ์ฑ„์›Œ์ง„ placeholder ์ด๋ฏธ์ง€๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ๋‹ค. Imagekit์— ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•œ ํ›„ URL ๋์— ?tr=w-1,h-1:w-๋„ˆ๋น„,h-๋†’์ด ์ฟผ๋ฆฌ ์ŠคํŠธ๋ง๋งŒ ์ถ”๊ฐ€ํ•˜๋ฉด ๋œ๋‹ค. ๋ฌด๋ฃŒ ๋ฒ„์ „์€ 20GB ๋Œ€์—ญํญ๊นŒ์ง€ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ํ† ์ด ํ”„๋กœ์ ํŠธ ๊ทœ๋ชจ์— ์‚ฌ์šฉํ•˜๊ธฐ ์ ๋‹นํ•ด๋ณด์ธ๋‹ค.

 

์›๋ณธ ์ด๋ฏธ์ง€ (130kb)
Dominant Color ์ด๋ฏธ์ง€ (2kb)
๋ธ”๋Ÿฌ 30 / ํ€„๋ฆฌํ‹ฐ 50 ์ด๋ฏธ์ง€ (5kb)

# ์ถœ์ฒ˜ ImageKit ๊ณต์‹ ๋ฌธ์„œ

        URL-endpoint        transformation       image path                                    
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
https://ik.imagekit.io/demo/tr:w-300,h-300/medium_cafe_B1iTdD0C.jpg
# Imagekit์— ์—…๋กœ๋“œํ•œ 1280x768 ํฌ๊ธฐ์˜ ์›๋ณธ ์ด๋ฏธ์ง€ URL (130kb)
https://ik.imagekit.io/colorfilter/lazy-loading/image-01.jpg

# Dominant ์ปฌ๋Ÿฌ๋กœ ์ฑ„์›Œ์ง„ 1280x768 ํฌ๊ธฐ์˜ ์ด๋ฏธ์ง€ URL (2kb)
https://ik.imagekit.io/colorfilter/lazy-loading/image-01.jpg?tr=w-1,h-1:w-1280,h-768

 

Imagekit์—์„œ ์ œ๊ณตํ•˜๋Š” ์ด๋ฏธ์ง€ transformation ๊ธฐ๋Šฅ์œผ๋กœ ์ €ํ™”์งˆ์˜ ๋ธ”๋Ÿฌ ์ฒ˜๋ฆฌ๋œ ์ด๋ฏธ์ง€๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜๋„ ์žˆ๋‹ค. ๋ธ”๋Ÿฌ ํšจ๊ณผ๋Š” 1~100, ํ€„๋ฆฌํ‹ฐ ์กฐ์ ˆ์€ 1~100(๊ธฐ๋ณธ๊ฐ’ 80) ์‚ฌ์ด์˜ ๊ฐ’์„ ์ž…๋ ฅํ•˜๋ฉด ๋œ๋‹ค. ์›๋ณธ ์ด๋ฏธ์ง€ ์šฉ๋Ÿ‰์ด 130kb ์ •๋„์˜€๋Š”๋ฐ ํ€„๋ฆฌํ‹ฐ๋ฅผ ๋‚ฎ์ถ”๊ณ  ๋ธ”๋Ÿฌ ํšจ๊ณผ๋ฅผ ์ ์šฉํ•˜๋‹ˆ 5kb ์ •๋„๋กœ ์ค„์—ˆ๋‹ค. placeholder๋กœ ์‚ฌ์šฉํ•˜๊ธฐ ์ ์ ˆํ•˜๋‹ค.

# ๋ธ”๋Ÿฌ 30, ํ€„๋ฆฌํ‹ฐ 50์ด ์ ์šฉ๋œ 1280x768 ํฌ๊ธฐ์˜ ์ด๋ฏธ์ง€ URL (5kb)
https://ik.imagekit.io/colorfilter/lazy-loading/image-01.jpg?tr=w-1280,h-768,bl-30,q-50

 

๋‹ค์–‘ํ•œ ์ด๋ฏธ์ง€ transformation ๊ธฐ๋Šฅ ์™ธ์—๋„ React ๊ฐ™์€ ํ”„๋ ˆ์ž„์›Œํฌ์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ํŒจํ‚ค์ง€๋„ ์ œ๊ณตํ•œ๋‹ค. ๊ธฐ์กด ์‚ฌ์šฉํ•˜๋˜ ์ €์žฅ์†Œ(Amazon S3, Google Storage ๋“ฑ)๊ฐ€ ์žˆ๋‹ค๋ฉด Imagekit๊ณผ ํ†ตํ•ฉํ•ด์„œ ์‚ฌ์šฉํ•  ์ˆ˜๋„ ์žˆ๋‹ค.

 

(3) Blur image placeholder โญ๏ธ

๋ธ”๋Ÿฌ placeholder๋ฅผ ์‚ฌ์šฉํ•œ Lazy Loading(GIF ํ™”์งˆ ๋•Œ๋ฌธ์— ๋ธ”๋Ÿฌ ์ด๋ฏธ์ง€๊ฐ€ ๋ญ‰๊ฐœ์ ธ์„œ ๋ณด์ด๋Š” ์  ์ฐธ๊ณ )

๐Ÿ’ก ๋ Œ๋”๋ง ๊ณผ์ • : โžŠํ™”๋ฉด์— ๋…ธ์ถœ๋˜๊ธฐ ์ „๊นŒ์ง„ ํšŒ์ƒ‰ ๋ฐฐ๊ฒฝ์˜ ๋นˆ ์š”์†Œ ํ‘œ์‹œ → โž‹ํ™”๋ฉด์— ๋…ธ์ถœ๋˜๋ฉด placeholder ์ด๋ฏธ์ง€ ํ‘œ์‹œํ•˜๊ณ  ์›๋ณธ ์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹œ์ž‘ → โžŒ์›๋ณธ ์ด๋ฏธ์ง€ ๋กœ๋“œ๋ฅผ ๋งˆ์น˜๋ฉด ํ™”๋ฉด์— ํ‘œ์‹œ

 

Lorem Picsum API์— Imagekit ๊ฐ™์€ ๋‹ค์–‘ํ•œ transformation ๊ธฐ๋Šฅ์€ ์—†์ง€๋งŒ Blur ํšจ๊ณผ๋ฅผ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. ์ด๋ฏธ์ง€ ์‚ฌ์ด์ฆˆ๋„ ์กฐ์ ˆํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์ž‘์€ ์‚ฌ์ด์ฆˆ์˜ ๋ธ”๋Ÿฌ ํšจ๊ณผ๊ฐ€ ์ ์šฉ๋œ ์ด๋ฏธ์ง€๋ฅผ Placeholder๋กœ ์‚ฌ์šฉํ•˜๋ฉด ๋œ๋‹ค. ์ฐธ๊ณ ๋กœ CSS์˜ filter ์†์„ฑ์œผ๋กœ๋„ ๋ธ”๋Ÿฌ ํšจ๊ณผ๋ฅผ ์ค„ ์ˆ˜ ์žˆ๋‹ค. ex) filter: blur(10x)

 

๋ธ”๋Ÿฌ ํšจ๊ณผ๋Š” 1~10 ์‚ฌ์ด์˜ ๊ฐ•๋„๋กœ ์กฐ์ ˆํ•  ์ˆ˜ ์žˆ๋‹ค. 50x30 ์ •๋„์˜ ์ž‘์€ ์‚ฌ์ด์ฆˆ ์ด๋ฏธ์ง€์˜ ํฌ๊ธฐ๋ฅผ ๋Š˜๋ฆฌ๋ฉด ๊นจ์ง ํ˜„์ƒ์ด ๋‚˜ํƒ€๋‚˜์ง€๋งŒ ๋ธ”๋Ÿฌ ํšจ๊ณผ๋ฅผ ์ ์šฉํ–ˆ๊ธฐ ๋•Œ๋ฌธ์— ๊นจ์ง„ ๋ถ€๋ถ„์ด ๋ธ”๋Ÿฌ ํšจ๊ณผ์— ์˜ํ•ด ๊ฐ€๋ ค์ง„๋‹ค. ์šฉ๋Ÿ‰๋„ 1kb ์•ˆํŒŽ์ด๋‹ค.

 

โœ๏ธ Placeholder ์ด๋ฏธ์ง€๋ฅผ ๋ฐ›์•„์˜ค๊ธฐ ์œ„ํ•ด ๋„คํŠธ์›Œํฌ ์š”์ฒญ์„ 1๋ฒˆ ๋” ํ•ด์•ผ๋˜๋Š” ๋‹จ์ ์ด ์žˆ๋‹ค. 

# 1280x768 ์ด๋ฏธ์ง€ (1024๋Š” ์ด๋ฏธ์ง€ ID)
https://picsum.photos/id/1025/1280/768

# blur 3์ด ์ ์šฉ๋œ 50x30 ์ด๋ฏธ์ง€
https://picsum.photos/id/1025/50/30?blur=3

 

1280x768 ์ด๋ฏธ์ง€ (146kb)
๋ธ”๋Ÿฌ ํšจ๊ณผ๊ฐ€ ์ ์šฉ๋œ 50x30 ์ด๋ฏธ์ง€ (1.1kb)

 

LazyLoading ์ปดํฌ๋„ŒํŠธ ์ˆ˜์ •

LazyImage ์ปดํฌ๋„ŒํŠธ๋กœ ๋„˜๊ธฐ๋Š” props์— Placeholder๋กœ ์‚ฌ์šฉํ•  ์ด๋ฏธ์ง€ ์ฃผ์†Œ์ธ thumb๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค.

export default function LazyLoading() {
  // ...์ƒ๋žต

  return (
    <section className="flex flex-col justify-center items-center p-8 gap-8">
      {data?.map(({ id, ...details }, i) => (
        <LazyImage
          key={id}
          details={details} // ์ด๋ฏธ์ง€ ์„ธ๋ถ€์ •๋ณด
          src={`https://picsum.photos/id/${id}/1280/768`} // original ์ด๋ฏธ์ง€
          thumb={`https://picsum.photos/id/${id}/50/30?blur=3`} // placeholder ์ด๋ฏธ์ง€
          noLazy={i < 5} // ์ฒซ ํ™”๋ฉด์—์„  ์ง€์—ฐ๋กœ๋”ฉ์ด ํ•„์š” ์—†์œผ๋ฏ€๋กœ ์ฒ˜์Œ 5๊ฐœ ์ด๋ฏธ์ง€๋Š” ๋ฐ”๋กœ ๋ณด์—ฌ์ค€๋‹ค
        />
      ))}
        {/* ์ƒ๋žต */}
    </section>
  );
}
๋”๋ณด๊ธฐ
export default function LazyLoading() {
  const [page, setPage] = useState(1);
  const { data, loading } = useFetchData({
    url: 'https://picsum.photos/v2/list', // ์ด๋ฏธ์ง€ ๋ฆฌ์ŠคํŠธ API ์ฃผ์†Œ
    params: {
      page, // page ์ƒํƒœ ๋ณ€๊ฒฝ ์‹œ ํ•ด๋‹น page ์— ๋Œ€ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ค์‹œ ๋ฐ›์•„์˜จ๋‹ค
      limit: 20, // 1๊ฐœ ํŽ˜์ด์ง€์— ์ตœ๋Œ€ 20๊ฐœ ์•„์ดํ…œ์œผ๋กœ ์ œํ•œ
    },
  });

  // ๋ฌดํ•œ ์Šคํฌ๋กค Intersection Observer
  const loaderRef = useIntersectionObserver({
    callback: () => {
      setPage((prev) => prev + 1); // ํ™”๋ฉด ๊ฐ€์žฅ ์•„๋ž˜๋กœ ์Šคํฌ๋กค ์‹œ page ์ƒํƒœ ๋ณ€๊ฒฝ
    },
  });

  return (
    <section className="flex flex-col justify-center items-center p-8 gap-8">
      {data?.map(({ id, ...details }, i) => (
        <LazyImage
          key={id}
          details={details} // ์ด๋ฏธ์ง€ ์„ธ๋ถ€์ •๋ณด
          src={`https://picsum.photos/id/${id}/1280/768`} // original ์ด๋ฏธ์ง€
          thumb={`https://picsum.photos/id/${id}/50/30?blur=3`} // placeholder ์ด๋ฏธ์ง€
          noLazy={i < 5} // ์ฒซ ํ™”๋ฉด์—์„  ์ง€์—ฐ๋กœ๋”ฉ์ด ํ•„์š” ์—†์œผ๋ฏ€๋กœ ์ฒ˜์Œ 5๊ฐœ ์ด๋ฏธ์ง€๋Š” ๋ฐ”๋กœ ๋ณด์—ฌ์ค€๋‹ค
        />
      ))}
      <div className={`${loading ? 'visibility' : 'invisible'}`}>
        ๐Ÿ”๏ธ Fetching items...
      </div>
      {/* ํ™”๋ฉด ๊ฐ€์žฅ ์•„๋ž˜๊นŒ์ง€ ์Šคํฌ๋กค ํ–ˆ๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•œ ๋นˆ์š”์†Œ */}
      <div ref={loaderRef} className={`w-full ${data ? 'block' : 'hidden'}`} />
    </section>
  );
}

 

LazyImage ์ปดํฌ๋„ŒํŠธ ์ˆ˜์ •

โžŠthumb props๋ฅผ ์ถ”๊ฐ€๋กœ ๋ฐ›์œผ๋ฏ€๋กœ ์ปดํฌ๋„ŒํŠธ ํŒŒ๋ผ๋ฏธํ„ฐ์— ์ถ”๊ฐ€ํ•˜๊ณ , โž‹Placeholder ์ด๋ฏธ์ง€๋ฅผ ์œ„ํ•œ <img> ํƒœ๊ทธ๋„ ์ถ”๊ฐ€ํ•œ๋‹ค. Placeholder / ์›๋ณธ ์ด๋ฏธ์ง€ 2๊ฐœ๋ฅผ ๋ชจ๋‘ ํ•œ ์œ„์น˜์—์„œ ํ‘œ์‹œํ•˜๋ฏ€๋กœ ์ด๋ฏธ์ง€๋ฅผ ๊ฐ์‹ธ๋Š” โžŒdiv ์ปจํ…Œ์ด๋„ˆ์˜ position ์†์„ฑ์€ relative, โž์ž์‹ ํƒœ๊ทธ์ธ <img>๋Š” absolute๋กœ ๋ณ€๊ฒฝํ•œ๋‹ค. โžŽPlaceholder ์ด๋ฏธ์ง€ ํฌ๊ธฐ๋Š” 50x30 ์ •๋„๋กœ ์ž‘์œผ๋ฏ€๋กœ ๋ถ€๋ชจ ์š”์†Œ(div)์˜ ๋„ˆ๋น„/๋†’์ด ๋งŒํผ ํฌ๊ธฐ๋ฅผ ํ‚ค์šด๋‹ค.

function LazyImage({ src, noLazy, thumb, details }) { // โ‘ด
  // ...

  return (
    <div
      ref={imageRef} // IO ์ปค์Šคํ…€ ํ›…์ด ๋ฐ˜ํ™˜ํ•œ ref ๊ฐ์ฒด ํ• ๋‹น
      className="relative bg-gray-200 w-4/6 aspect-[5/3] rounded-md overflow-hidden" // โ‘ถ
    >
      {isInView && (
        <>
          <img // placeholder ์ด๋ฏธ์ง€ โ‘ต
            className="absolute w-full h-full object-covers" // โ‘ท โ‘ธ
            src={thumb}
            alt={details.author}
          />
          <img // original ์ด๋ฏธ์ง€
            className={imgClasses}
            src={src}
            onLoad={() => setIsLoaded(true)} // ์ด๋ฏธ์ง€ ๋กœ๋“œ๋ฅผ ์™„๋ฃŒํ•˜๋ฉด isLoaded ์ƒํƒœ ๋ณ€๊ฒฝ
            alt={details.author}
          />
        </>
      )}
    </div>
  );
}

 

Placeholder (๋ธ”๋Ÿฌ)์ด๋ฏธ์ง€ → ์›๋ณธ ์ด๋ฏธ์ง€ ์ „ํ™˜์„ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ํ•˜๊ธฐ ์œ„ํ•ด transition-delay(์ „ํ™˜ ํšจ๊ณผ๋ฅผ ์‹œ์ž‘ํ•˜๊ธฐ ์ „์˜ ๋Œ€๊ธฐ์‹œ๊ฐ„) CSS ์†์„ฑ๋„ 200์œผ๋กœ ์„ค์ •ํ–ˆ๋‹ค. ๊ทธ๋Ÿผ ์›๋ณธ ์ด๋ฏธ์ง€๊ฐ€ ํˆฌ๋ช…(opacity: 0)์—์„œ ๋ถˆํˆฌ๋ช…(opacity: 1)์œผ๋กœ ๋ณ€ํ•˜๊ธฐ ์ „ 200ms ๋Œ€๊ธฐ ์‹œ๊ฐ„์„ ๊ฐ–๊ฒŒ๋œ๋‹ค. ์ฆ‰, ๋ธ”๋Ÿฌ ์ด๋ฏธ์ง€๊ฐ€ 200ms ๋™์•ˆ ๋” ๋ณด์ด๋Š” ๊ฒƒ.

const imgClasses = classnames(
  `absolute transition-opacity ease-in-out ${!noLazy && 'delay-200 duration-1000'}`,
  // ์ด๋ฏธ์ง€ ์„œ์„œํžˆ ๋ณด์ด๋Š” ํšจ๊ณผ ('opacity-0': !isLoaded ๋งŒ ์ž…๋ ฅํ•ด๋„ ์ž‘๋™ํ•จ)
  { 'opacity-100': isLoaded, 'opacity-0': !isLoaded },
);
๋”๋ณด๊ธฐ
function LazyImage({ src, noLazy, thumb, details }) {
  const [isInView, setIsInView] = useState(!!noLazy);
  const [isLoaded, setIsLoaded] = useState(false);

  const imageRef = useIntersectionObserver({
    callback: () => {
      setIsInView(true); // ๊ด€์ฐฐ ๋Œ€์ƒ์ด ํ™”๋ฉด์— ๋…ธ์ถœ๋˜๋ฉด isInView ์ƒํƒœ ๋ณ€๊ฒฝ
    },
    unObserve: true, // ๊ด€์ฐฐ ๋Œ€์ƒ์ด ํ™”๋ฉด์— ๋…ธ์ถœ(๊ต์ฐจ)๋˜๋ฉด ์ด๋ฏธ์ง€๋ฅผ ๋กœ๋“œํ•œ ์ƒํƒœ์ด๋ฏ€๋กœ ๊ด€์ฐฐ ํ•ด์ œ
    options: { rootMargin: '40%' }, // ์Šคํฌ๋กค์ด ํ•˜๋‹จ์— ๋„๋‹ฌํ–ˆ์„์ฆˆ์Œ ๋‹ค์Œ ์ด๋ฏธ์ง€๋ฅผ ๋ฏธ๋ฆฌ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ์œ„ํ•ด rootMargin ์„ค์ •
  });

  const imgClasses = classnames(
    `absolute transition-opacity ease-in-out 
    ${!noLazy && 'delay-200 duration-1000'}`,
    {
      'opacity-100': isLoaded,
      'opacity-0': !isLoaded,
    },
  );

  return (
    <div
      ref={imageRef} // IO ์ปค์Šคํ…€ ํ›…์ด ๋ฐ˜ํ™˜ํ•œ ref ๊ฐ์ฒด ํ• ๋‹น
      className="relative bg-gray-200 w-4/6 aspect-[5/3] rounded-md overflow-hidden"
      // ์ด๋ฏธ์ง€๋ฅผ ๊ฐ์‹ธ๋Š” ์ปจํ…Œ์ด๋„ˆ ์š”์†Œ์— ๋„ˆ๋น„/๋†’์ด๋ฅผ ์ง€์ •ํ•ด์„œ ์ด๋ฏธ์ง€ ๋กœ๋”ฉ์‹œ ๋ฐ€๋ฆผ ํ˜„์ƒ์„ ๋ฐฉ์ง€ํ•œ๋‹ค
    >
      {isInView && (
        <>
          <img // placeholder image
            className="absolute w-full h-full object-covers"
            src={thumb}
            alt={details.author}
          />
          <img // ์›๋ณธ ์ด๋ฏธ์ง€
            className={imgClasses}
            src={src}
            onLoad={() => setIsLoaded(true)} // ์ด๋ฏธ์ง€ ๋กœ๋“œ๋ฅผ ์™„๋ฃŒํ•˜๋ฉด isLoaded ์ƒํƒœ ๋ณ€๊ฒฝ
            alt={details.author}
          />
        </>
      )}
    </div>
  );
}

 

๋ ˆํผ๋Ÿฐ์Šค


 


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