내가 LCP를 70% 낮춘 방법

Core Web Vitals를 개선하기 위한 고급 방법 알아보기

Arjen Karel Core Web Vitals Consultant
Arjen Karel - linkedin
Last update: 2026-03-04

Web Worker와 2단계 이미지 로딩으로 LCP 지표 개선하기

대부분의 경우 보이는 뷰포트 내의 큰 이미지 요소가 Largest Contentful Paint 요소가 됩니다. 이미지 크기 조정, 이미지 압축, WebP 변환, LCP 요소 사전 로드와 같은 모든 Lighthouse 모범 사례를 적용한 후에도 Largest Contentful Paint가 여전히 Core Web Vitals를 통과하지 못할 수 있습니다.

이를 해결하는 유일한 방법은 2단계 로딩과 같은 고급 전술을 사용하고 Web Worker로 페이지를 스레딩하여 기본 스레드의 리소스를 확보하는 것입니다.

Why should I preload the largest contentful paint image

2026년 3월 Arjen Karel이 마지막으로 검토함

배경 지식

저는 페이지 속도 전문가이며 제 웹사이트는 저의 쇼케이스입니다. 홈페이지에서 저는 제 사이트가 세계에서 가장 빠른 사이트라고 자랑스럽게 말합니다. 그렇기 때문에 페이지를 최대한 빨리 로드하고 사이트에서 모든 페이지 속도를 쥐어짜내야 합니다.

오늘 제가 보여드릴 기술은 전담적이고 재능 있는 개발팀의 지원 없이는 일반적인 (WordPress) 사이트에서 실행 불가능할 수 있습니다. 자신의 사이트에서 이 기술을 모방할 수 없더라도 이 기사를 읽고 제가 페이지 속도에 대해 어떻게 생각하고 어떤 점을 고려하는지 알아보시기 바랍니다.

문제: 보이는 뷰포트 내의 큰 이미지

보이는 뷰포트 내의 큰 이미지는 종종 Largest Contentful Paint 요소가 됩니다. 이 LCP 이미지가 Core Web Vitals를 통과하지 못하는 경우가 자주 발생합니다. 저는 매일 이런 결과를 봅니다.

bad LCP with large image

이 요소가 화면에 빠르게 나타나도록 하는 방법에는 여러 가지가 있습니다.

  1. LCP 요소 사전 로드. LCP 이미지를 사전 로드하면 브라우저가 이 이미지를 가능한 한 빨리 사용할 수 있습니다. 이를 fetchpriority="high"와 결합하여 브라우저에 다른 리소스보다 이 이미지의 우선순위를 높이도록 지시합니다.
  2. 반응형 이미지 사용. 모바일 디바이스에 데스크톱 크기의 이미지를 제공하지 않도록 합니다.
  3. 이미지 압축. 이미지 압축은 이미지 크기를 크게 줄일 수 있습니다.
  4. 차세대 이미지 형식 사용. WebP와 같은 차세대 이미지 형식은 거의 모든 경우에 JPEG 및 PNG와 같은 이전 형식보다 성능이 뛰어납니다.
  5. 중요 렌더링 경로 최소화. LCP를 지연시킬 수 있는 JavaScript 및 스타일시트와 같은 모든 렌더링 차단 리소스를 제거합니다.

안타깝게도 이러한 모든 최적화에도 불구하고 경우에 따라 LCP 지표가 여전히 Core Web Vitals 감사를 통과하지 못할 수 있습니다. 이유는 무엇일까요? 이미지의 크기만으로도 LCP의 리소스 로드 시간 단계를 지연시키기에 충분하기 때문입니다.

해결책: 2단계 로딩 및 Web Worker

제가 (사이트의 다른 모든 문제를 최적화한 후) 구현한 해결책은 2단계 이미지 로딩입니다.

아이디어는 간단합니다. 첫 번째 렌더링에서는 최종 고품질 이미지와 정확히 동일한 크기의 저품질 이미지를 보여줍니다. 해당 이미지가 표시된 직후 저품질 이미지를 고품질 이미지로 교체하는 프로세스를 시작합니다.

아주 기본적인 구현은 다음과 같을 수 있습니다. 먼저 이미지에 로드 이벤트 리스너를 추가합니다. 이미지가 로드되면 동일한 이벤트 리스너가 스스로를 분리하고 이미지의 src가 최종 고품질 이미지로 교체됩니다.

<img
     width="100"
     height="100"
     alt="some alt text"
     src="lq.webp"
     onload="this.onload=null;this.src='hq.webp'"
>

1단계: 저품질 webp 3-5kb

2단계: 고품질 webp 20-40kb

이것은 꽤 간단해 보일 수 있지만(실제로 그렇습니다) 렌더링 프로세스 초기에 많은 수의 이미지를 교체하면 기본 스레드에서 너무 많은 작업이 발생하여 다른 Core Web Vitals 지표에 영향을 미칩니다.

그래서 저는 Web Worker에 작업의 일부를 오프로드하기로 선택했습니다. Web Worker는 새 스레드에서 실행되며 현재 페이지에 실제로 액세스할 수 없습니다. Web Worker와 페이지 간의 통신은 메시징 시스템을 통해 이루어집니다. 분명한 장점은 페이지의 기본 스레드 자체를 사용하지 않고 해당 리소스를 확보한다는 것입니다. 단점은 Web Worker를 사용하는 것이 약간 번거로울 수 있다는 것입니다.

과정 자체는 그리 어렵지 않습니다. DOMContentLoaded 이벤트가 실행되면 페이지의 모든 이미지를 수집합니다. 이미지가 로드되었으면 즉시 교체합니다. 아직 로드되지 않은 경우(이미지가 지연 로드될 수 있으므로) 지연 로드 후 이미지를 교체하는 이벤트 리스너를 첨부합니다.

한 가지 중요한 주의 사항이 있습니다. 브라우저는 각 이미지 교체를 새로운 LCP 후보로 취급합니다. 고품질 이미지 교체가 2.5초 후에 발생하면 LCP는 자리 표시자 시간이 아니라 교체 시간에 측정됩니다. 그렇기 때문에 Web Worker가 이미지를 최대한 빨리 가져오고 교체하는 것이 중요합니다.

결과는 매우 놀랍습니다.

Web Worker를 통한 2단계 LCP 로딩 코드

다음은 2단계 로딩과 Web Worker를 통해 LCP 속도를 높이기 위해 사용하는 코드입니다. 기본 페이지의 코드는 이미지를 가져올 Web Worker를 호출합니다. Web Worker는 결과를 blob 형태로 기본 페이지에 전달합니다. blob을 받으면 이미지가 교체됩니다.

Worker.js

Worker는 한 가지 역할을 합니다. 메시지를 수신하는 것입니다. 메시지에는 이미지 URL과 고유 ID가 포함됩니다. 먼저 이미지 URL을 고품질 버전으로 변환합니다. 제 경우에는 이미지 URL에서 /lq를 /resize로 변경했습니다. 그런 다음 Worker는 고품질 이미지를 가져오고 blob을 생성한 다음 고유 ID와 함께 이미지 blob을 반환합니다.
self.addEventListener('message', async event => {
    const newimageURL = event.data.src.replace("/lq-","/resize-");

    const response = await fetch(newimageURL)
    const blob = await response.blob()

    // Send the image data to the UI thread!
    self.postMessage({
        uid: event.data.uid,
        blob: blob,
    })
})

Script.js

script.js는 활성 웹페이지에서 일반 스크립트로 실행됩니다. 스크립트는 먼저 Worker를 로드합니다. 그런 다음 페이지의 모든 이미지를 순환합니다. 이것은 렌더링 프로세스의 초기에 발생합니다. 이미지가 이미 로드되었을 수도 있고 아닐 수도 있습니다. 저품질 이미지가 이미 로드된 경우 교체 프로세스를 즉시 호출합니다. 아직 로드되지 않은 경우 해당 이미지가 로드되는 즉시 교체 프로세스를 시작하는 리스너를 이미지 로드 이벤트에 첨부합니다.

이미지가 로드되면 해당 이미지에 대한 고유 ID가 생성됩니다. 이를 통해 페이지에서 이미지를 다시 쉽게 찾을 수 있습니다(Worker는 DOM에 액세스할 수 없으므로 이미지 DOM 노드를 보낼 수 없음을 기억하세요). 그런 다음 이미지 URL과 고유 ID가 Worker로 전송됩니다. Worker가 이미지를 가져오면 blob 형태로 스크립트에 다시 전송됩니다. 스크립트는 최종적으로 이전 이미지 URL을 Web Worker가 생성한 blob URL로 교체합니다.

var myWorker = new Worker('/path-to/worker.js');

// send a message to worker
const sendMessage = (img) => {

        // uid makes it easier to find the image
        var uid = create_UID();

        // set data-uid on image element
        img.dataset.uid = uid;

        // send message to worker
        myWorker.postMessage({ src: img.src, uid: uid });
};

// generate the uid
const create_UID = () => {
    var dt = new Date().getTime();
    var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
        var r = (new Date().getTime() + Math.random() * 16) % 16 | 0;
        dt = Math.floor(dt / 16);
        return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
    });
    return uid;
}

// when we get a result from the worker
myWorker.addEventListener('message', event => {
    // Grab the message data from the event
    const imageData = event.data

    // Get the original element for this image
    const imageElement = document.querySelectorAll("img[data-uid='" + imageData.uid + "']");

    // We can use the `Blob` as an image source! We just need to convert it
    // to an object URL first
    const objectURL = URL.createObjectURL(imageData.blob)

    // Once the image is loaded, we'll want to do some extra cleanup
    imageElement.onload = () => {
        URL.revokeObjectURL(objectURL)
    }
    imageElement[0].setAttribute('src', objectURL)
})

// get all images
document.addEventListener("DOMContentLoaded", () => {
    document.querySelectorAll('img[loading="lazy"]').forEach(
        img => {

            // image is already visible?
            img.complete ?

                // swap immediately
                sendMessage(img) :

                // swap on load
                img.addEventListener(
                    "load", i => { sendMessage(img) }, { once: true }
                )
        })
})

실제 환경에서 LCP 개선을 확인하려면 실제 사용자 모니터링(RUM)을 사용하여 실제 방문자가 페이지를 경험하는 방식을 추적하세요. Lighthouse와 같은 실험실 도구는 개선 사항을 보여주지만 다양한 연결 환경에서 실제 사용자의 필드 데이터가 Core Web Vitals를 통과하는 데 중요합니다.

About the author

Arjen Karel is a web performance consultant and the creator of CoreDash, a Real User Monitoring platform that tracks Core Web Vitals data across hundreds of sites. He also built the Core Web Vitals Visualizer Chrome extension. He has helped clients achieve passing Core Web Vitals scores on over 925,000 mobile URLs.

Lighthouse 점수가 전부가 아닙니다.

실사용자는 4G 회선 Android 폰을 씁니다. 그 사용자들이 실제로 겪는 걸 분석합니다.

필드 데이터 분석
내가 LCP를 70% 낮춘 방법Core Web Vitals 내가 LCP를 70% 낮춘 방법