개발 기술/사소하지만 놓치기 쉬운 개발 지식

이미지 리스트 성능 문제는 DOM에서 끝나지 않는다

by GicoMomg 2026. 1. 13.

0. 들어가며…

vue, react를 이용해 dom을 렌더링하는 게 자연스러워진 요즘.
각 프레임워크에서 리스트를 표현할 때는 Vue에서는 v-for, React에서는 map을 사용해 여러 DOM 요소를 렌더링한다.
이 방식은 단순하다. 그래서 데이터 배열만 있으면 누구나 손쉽게 리스트 UI를 만들 수 있다.
하지만 이 단순한 방식이 언제나 좋은 선택은 아니다.

DOM을 계속 늘려도 괜찮을까?

만약 다음 조건이라면 큰 문제는 없다.

  • 데이터 개수가 100개 미만이고
  • 노출되는 리스트 DOM이 많지 않으며
  • 한 화면에서 무거운 연산을 하지 않는 경우

이런 상황에서는 단순 렌더링 방식이 성능 리스크로 이어질 가능성은 크지 않다.
하지만 리스트 DOM이 100개를 넘어 1,000개, 혹은 그 이상까지 늘어날 수 있는 구조라면?
이 시점부터는 “DOM을 이렇게 많이 렌더링하는 게 정말 옳은 선택인지” 고민해봐야 한다.

그래서 등장한 해결책, 가상 스크롤

이 문제를 해결하기 위해 가장 흔히 사용되는 방법이 바로 가상 스크롤(Virtual Scroll)이다.
안드로이드 개발에서는 과거 RecyclerView라는 이름으로 널리 사용되던 개념이다.
가상 스크롤의 방식은 명확하다.

  • 화면에 필요한 정해진 개수의 DOM(n개)만 유지하고
  • 사용자가 스크롤할 때마다
  • DOM은 그대로 두고, 그 안의 데이터만 교체해서 보여준다.

이렇게 하면 DOM을 무한정 생성하지 않기에 렌더링 비용을 아끼고, 성능 부하를 줄일 수 있다.

그럼 가상 스크롤은 만능일까?

그럼, 리스트를 표현해야 한다면, 가상 스크롤만 쓰면 된다고 생각할 수 있다.
실제로 텍스트 위주의 단순한 리스트라면 가상 스크롤을 적용하는 것만으로도 성능 개선 효과는 크다.

하지만, 이미지 리스트라면?

문제는 여기서부터다.
만약 리스트의 각 아이템이 고화질 이미지를 포함하고 있다면, 가상 스크롤을 “적용하는 것만으로” 충분할까?
이미지는 텍스트와 달리, (1) 네트워크 요청 (2) 디코딩 (3) GPU 업로드 (4) 합성 등의 과정을 거쳐야 화면에 노출된다.
즉, DOM 개수만 줄인다고 해서 모든 비용이 사라지지는 않는다.
가상 스크롤은 분명 좋은 도구지만, 이미지 리스트에서는 추가로 고려해야할 점이 있다.

이번 시간에는 다음 두 가지를 중심으로 살펴보려 한다.

  1. 이미지 요소가 브라우저에서 화면에 노출되기까지의 과정
  2. 이미지 리스트에 가상 스크롤을 적용할 때 반드시 고려해야 할 포인트




1. 이미지 리스트와 가상 스크롤

1) 이미지 리스트에 가상 스크롤 말고 고려할 점이 많다.

💡 (키 포인트) 이미지 리스트 성능 문제는 DOM 문제로 시작하지만, DOM 문제로 끝나지 않는다.

가상 스크롤은 DOM 비용만 줄여준다.
이미지 렌더링의 핵심 병목은 그 이후 단계에 있다.


(1) 우리가 흔히 하는 착각

  • 성능 최적화를 하다 보면 이런 흐름에 익숙해진다.
    • preload를 붙이기
    • 캐싱 전략을 고민하기
    • lazy loading을 적용하기
  • 그러다 리스트가 많은 화면을 발견하면, 자연스럽게 이렇게 사고가 이어진다.
현상) DOM이 많다
문제) 렌더링이 느리다

해결) DOM을 줄이면 빨라질 것이다

  • 그래서 가상 스크롤을 적용하게 된다.
이미지 리스트가 느리다

→ DOM이 많다
→ 가상 스크롤을 적용하자
→ 해결
  • 이 방식이 완전히 틀린 건 아니다. 하지만 이미지 리스트에서는 고려할 사항이 더 있다.

(2) 왜 가상 스크롤만으로 부족할까?

  • DOM은 분명 비용이 들기 때문에, 줄이는 게 좋다.
  • 하지만 이미지 리스트 성능에는 DOM은 물론 복합적으로 고려할 사항이 많다.
이미지 리스트 성능

= DOM 비용
+ 이미지 디코딩(CPU)
+ GPU 업로드 / 합성
+ 레이아웃 안정성(CLS)

  • 가상 스크롤은 여기서 단 하나, DOM 비용만 해결해준다.
항목 가상 스크롤로 해결되는가?
DOM 개수
이미지 다운로드
이미지 디코딩 (CPU)
GPU 업로드 / 합성
레이아웃 흔들림(CLS)
  • DOM은 줄었지만, 이미지는 여전히 새로 디코딩되고 GPU로 올라간다.
  • 이미지 성능 문제의 대부분은 DOM 이후 단계에서 발생한다.
  • 따라서 이미지 리스트에서는 렌더링 파이프라인 전체를 기준으로 전략을 세워야 한다.



2) 브라우저는 하나를 그리기 위해 무슨 일을 할까?

💡 이미지는 “다운로드되면 끝”이 아니다.
화면에 그려지기까지 CPU와 GPU가 모두 개입한다.

  • 이미지를 렌더링할 때 어떤 과정을 거칠까?
  • 어쩌면 다음과 같이 단순한 형태만 생각할 수 있다.
<img> 발견
→ 다운로드
→ 화면에 표시
  • 하지만 실제로 이미지를 렌더링하기 위해 여러 단계를 거치게 된다.

브라우저의 이미지 렌더링 파이프라인

  • 브라우저가 <img> 하나를 화면에 그리기까지 7가지 과정을 거친다.
단계 설명
1. 요청 우선순위 판단 이 이미지를 지금 당장 받아야 하는지 아니면 나중에 받아야 하는지 판단
2. 네트워크 다운로드 서버로부터 압축된 바이너리 데이터를 수신
3. HTTP 캐시 확인 이미 받은 적 있다면 로컬 파일로 대체
4. 디코딩(Decoding) ⭐ 압축을 풀어 '픽셀(RGBA)'로 변환. (CPU 소모)
5. 레이아웃 계산 이미지의 자리를 확보하고 주변 요소의 위치를 정함
6. GPU 텍스처 업로드 ⭐ 픽셀 데이터를 비디오 메모리(VRAM)로 보냄
7. 합성(Composite) 및 화면 표시 레이어들을 겹쳐서 최종 화면을 완성
  • 여기서 기억해야 할 포인트는 이것이다.
  • 성능 문제의 대부분은 4번(디코딩)과 6번(GPU 업로드)에서 발생한다는 것!
  • 이제 각 단계별로 어떤 일을 하는 지 살펴보자!

(1) 1단계, 요청 우선순위 판단 (Fetch Priority)

  • 브라우저는 <img>를 발견하자마자 다운로드하지 않는다.
  • 먼저 “이 이미지가 지금 얼마나 급한가?”를 평가한다.
- 판단 기준
    : 지금 화면(Viewport) 내부에 있는가?
    : 화면 상단에 위치하는가? (LCP 후보)
    : loading="lazy"가 지정되어 있는가?
    : viewport와의 거리
    : CSS로 "display: none" 상태는 아닌가?
    : fetchpriority="high"라고 명시했나?
  • 이 판단 결과에 따라 요청 우선순위(priority) 가 정해진다.

만약 가상 스크롤을 쓰게 되면 요청 우선순위가 어떻게 바뀔까?

  • 일반 리스트는 브라우저가 이미지를 점진적으로 발견하며 요청한다.
  • 그래서 화면에 노출하는 이미지가 100개라고 한번에 100개를 요청하지 않고 분산 요청한다.


  • 아래 캡처에서 일반 리스트의 이미지 요청은 i(idle) 우선순위를 갖는다.
  • 이는 브라우저가 해당 이미지를 즉시 처리해야 할 렌더링 작업이 아니라, 주요 작업이 끝난 뒤 여유가 있을 때 처리해도 되는 리소스로 분류했음을 보여준다.


  • 그에 비해 가상 스크롤은 스크롤 시점에 필요한 이미지를 DOM에 한꺼번에 주입한다.


  • 그 이유는 가상 스크롤로 새롭게 노출되는 이미지들이 Priority: u=1 (urgency level 1)로 분류되기 때문이다.
  • 이는 브라우저가 해당 이미지를 지금 즉시 화면에 표시되어야 하는 리소스로 판단했음을 의미한다.


  • 결국, 가상 스크롤 구간에서는 높은 우선순위(u=1)의 이미지 요청이 동일한 시점에 집중적으로 발생하며,
  • 요청 수는 줄어들더라도 요청의 ‘순간 밀도’는 오히려 높아진다.

(2) 2단계, 네트워크 다운로드

  • 요청 우선순위 판단이 끝났다면, 이제 네트워크를 통해 이미지를 다운로드한다.
  • 여기서 또 하나의 착각이 나온다.

“이미지 다운로드가 끝났으니 GPU가 바로 그린다”는 착각!

  • 우리가 받은 데이터(JPG, PNG, WebP)는 고도로 압축된 바이너리 파일이다.
  • GPU(그래픽 카드)는 이 압축된 상태를 이해하지 못한다.
  • 즉, 다운로드가 됐다고 해도 렌더링을 할 수 없다. 디코딩/GPU 업로드를 거쳐야 렌더링할 수 있다.

(3) 3단계, HTTP 캐시 확인

  • 이미지가 이미 캐시되어 있다면 브라우저는 네트워크 요청 없이 바로 이미지를 가져올 수 있다.

  • 그래서 일반적으로 화면에 이미지가 더 빨리 노출될 것이라 기대한다.
  • 하지만 여기서 중요한 사실이 하나 있다.
  • 바로, “이미지가 캐시되어 있어도 디코딩과 GPU 업로드 과정은 여전히 필요”하다는 것!

왜 캐시되어 있는데도 후처리가 필요할까?
이 질문의 답은 “브라우저가 무엇을 캐시하는가”를 보면 명확해진다.

  • 브라우저에는 이미지와 관련된 캐시가 두 단계로 존재한다.
캐시 종류 저장 내용 성능 영향
HTTP 캐시 이미지 파일 원본 (압축 데이터) 네트워크 재다운로드 비용 감소
Bitmap 캐시 디코딩 완료된 픽셀 데이터 CPU 디코딩 비용 감소
  • 여기서 핵심은 “브라우저는 이미지 원본만 HTTP 캐시에 저장”한다는 것이다.
  • 즉, JPG / PNG / WebP 같은 압축 파일은 캐시되지만 디코딩이 끝난 픽셀 데이터는 항상 남아 있지 않다.
  • 그래서 캐시에서 이미지를 가져와도, 디코딩 과정으로 인해 버벅임이 발생할 수 있다.

그렇다면 디코딩된 이미지를 캐시하면 되지 않을까?

  • 만약 디코딩된 이미지(Bitmap)를 유지할 수 있다면 렌더링은 훨씬 빨라질 것이다.
  • 하지만 브라우저는 의도적으로 Bitmap 캐시를 오래 유지하지 않는다.
  • 그 이유는 단순한데, 메모리 비용이 너무 크기 때문이다.

  • 예를 들어 다음 이미지가 몇 장만 있어도 탭 하나가 빠르게 메모리를 잠식할 것이다.
- 1920 × 1080 이미지
- 약 2M 픽셀
  → 약 8MB 메모리 사용
  • 그래서 브라우저는 다음과 같은 전략을 취한다.
    • Bitmap 캐시는 최소한으로 유지
    • 메모리 압박이 오면 가장 먼저 제거
    • 탭 전환, 스크롤 이동 시에도 적극적으로 삭제

(4) 4단계 , ⭐ 디코딩(Decoding)

  • 이미지는 압축 파일이며, GPU는 압축된 이미지를 직접 그릴 수 없다.
  • 그래서 CPU가 압축 이미지를 변환하는 “디코딩”을 수행한다.
압축 이미지

→ RGBA 픽셀 배열

[. 이미지: 디코딩 전의 압축된 바이너리 데이터(Raw Data)의 모습 ]

[. 이미지: 디코딩 후 모습 ]

이 디코딩은 성능에 영향을 주는 요인 중 하나이다.

  • 영향을 주는 첫 번째 이유(연산량의 증가)
    • 해상도가 가로/세로 2배만 커져도 픽셀 수는 4배가 된다.
    • 예를 들어, 1000px 이미지는 100만 개의 픽셀 데이터를 처리해야 한다.
  • 영향을 주는 두 번째 이유(메인 스레드 점유)
    • 브라우저는 보통 메인 스레드에서 이 작업을 처리한다.
    • CPU가 디코딩을 처리하는 동안 사용자의 스크롤 이벤트, 버튼 클릭 등은 무시된다.
  • 결국, 디코딩이 몰리면 JS 실행, 스크롤, 클릭이 동시에 막힌다.

(5) 5단계, 레이아웃(Layout)

  • 이미지 로드 전, 브라우저는 해당 이미지의 크기를 모른다.
  • 그래서 일단 높이를 0으로 잡고 나중에 이미지가 로드되면 그 때 공간을 재배열한다.
  • 이때 발생할 수 있는게 바로 CLS(Cumulative Layout Shift)이다.
  • CLS란, 갑자기 이미지가 툭 튀어나오며 아래 요소들이 밀려나는 현상을 말한다.
  • 그래서 이런 CLS를 예방하기 위해 aspect-ratio나 고정 width/height 속성을 지정하는 게 중요하다.

(6) 6단계, ⭐ GPU 업로드 & 메모리 관리

  • 디코딩이 완료된 이미지는 CPU 메모리에 RGBA 픽셀 버퍼 형태로 존재한다.
  • 이 픽셀 데이터는 화면에 그려지기 위해 GPU 메모리(VRAM)로 복사되어 텍스처(texture)로 변환된다.
  • 이후 브라우저의 컴포지터는 이 텍스처를 이용해 레이어를 합성하고, 최종 프레임을 생성한다.
  • 여기서 중요한 점은 GPU 메모리의 수명 주기가 DOM과 다르다는 것이다.

DOM 제거와 GPU 메모리 해제 시점은 같지 않다.

  • 가상 스크롤을 사용하는 경우, 화면에서 벗어난 이미지는 DOM에서 빠르게 제거된다.
  • 하지만 이는 DOM 트리에서의 제거일 뿐, 다음을 의미하지는 않는다.
    • 디코딩된 비트맵이 즉시 해제된다
    • GPU에 업로드된 텍스처가 바로 반환된다
  • 브라우저는 성능을 위해 다음과 같은 전략을 취한다.
    • 최근 사용된 이미지 텍스처를 캐시로 유지
    • 곧 다시 사용될 가능성이 있으면 GPU 메모리에 잠시 보존
    • GC / 메모리 압박 시점까지 해제를 지연
  • 그 결과, DOM에서는 사라진 이미지의 픽셀 정보가 GPU 메모리에는 한동안 남아 있는다.
  • 만약 이 현상이 누적되면, 특정 스크롤 구간에서 메모리 사용량이 순간적으로 튀는 현상(메모리 스파이크)이 발생할 수 있다.



3) 이미지 리스트에 가상 스크롤 적용시 주의할 점

  • 가상 스크롤은 분명 강력하다.
  • 하지만 이미지 리스트에서는 “어디까지 해결해주고, 무엇은 남는지”를 정확히 알아야 한다.
  • 먼저 한 눈에 정리해보자.
항목 가상 스크롤의 효과 고려할 점
DOM 노드 관리 ✅ 완벽 해결 -
네트워크 부하 ⚠️ 순간적 폭주 위험 썸네일 / 요청 분산
디코딩 연산(CPU) ❌ 해결 못함 디코딩 제어 필요
메모리(VRAM) ❌ 해결 못함 overscan 조절
레이아웃(CLS) ❌ 오히려 더 민감 고정 영역 필수
  • 가상 스크롤은 “DOM 문제”만 해결한다. 이미지의 병목은 그 이후 단계에 있다.

(1) decoding="async" 지정하기

<img src="image.jpg"decoding="async" />
  • decoding="async"은 메인 스레드가 여유 있을 때 디코딩을 하도록 지연시킨다.
  • 디코딩을 렌더링·스크롤과 경쟁시키지 않도록 힌트 제공한다.
  • 단, 모든 브라우저에서 항상 비동기로 되는 것은 아니다.

(2) img.decode(), 준비된 이미지들만 렌더링

const img = newImage();
img.src = "cat.jpg";

img.decode().then(() => {
  container.appendChild(img);
});
  • 이 방식을 사용하면, 디코딩이 끝난 뒤에만 DOM에 삽입할 수 있다.
  • 다만 주의할 점도 있다.
  • 너무 많은 이미지를 한 번에 decode() 하면 Promise 대기열 + 디코딩 폭주가 발생할 수 있다.
  • 그래서, overscan / 배치 제어와 함께 사용해야 안전하다.

(3) overscan 튜닝

  • overscan은 “미리 렌더링해 두는 범위”다.
  • 이미지 리스트에서는 이 값이 특히 중요하다.
overscan 너무 작음 → 하얀 빈칸, 체커보드
overscan 너무 큼 → 디코딩 폭주, VRAM 압박
  • overscan은 서비스하는 기기 성능/이미지 해상도에 따라 조정해야 한다.

(4) 가장 강력한 해결책, 이미지 크기 줄이기

  • 가장 효과적인 방법은 이미지 자체를 가볍게 만드는 것이다!
  • 서버 썸네일을 제공하거나, WebP / AVIF 사용하기, srcset / sizes로 해상도 분기하기 등이 있다.
  • 이 방식을 사용하면, 네트워크, 디코딩, GPU 메모리를 감소시킬 수 있다.




3. 마치며…

  • 가상 스크롤은 리스트 성능 최적화의 대표 솔루션이다.
  • DOM을 필요한 만큼만 유지하고 재사용하는 방식은, 특히 텍스트 중심 리스트에서는 거의 정답에 가깝다.
  • 하지만 이미지 리스트에서는 이야기가 달라진다. 이미지는 다운로드가 끝났다고 바로 그려지는 자원이 아니다.
  • 브라우저는 <img> 하나를 그리기 위해 여러 과정을 거친다.
    • 디코딩(CPU)
    • GPU 텍스처 업로드(VRAM)
    • 합성(Composite)
    • 레이아웃 안정성(CLS)
  • 즉, 가상 스크롤이 DOM을 줄여준다 해도 디코딩과 GPU 업로드라는 핵심 병목은 그대로 남는다.
  • 그래서 이미지 리스트 최적화의 핵심은 “가상 스크롤을 적용했는가”가 아니다.
  • 이미지 리스트 최적화는 “DOM을 줄이는 문제”가 아니라 렌더링 파이프라인 전체의 비용을 분산시키는 문제다.
  • 만약 이미지 리스트의 성능 향상을 위해 가상 스크롤을 적용한다면 렌더링 파이프라인 전체 비용도 고려하는 걸 추천한다.

반응형

댓글