본문 바로가기
tech epilogue

브라우저 렌더링 최적화: Canvas vs CSS Transform, 그리고 WebWorker

by rami_ 2026. 6. 26.

지도가 뚝뚝 끊기거나, 애니메이션이 버벅이거나, 이미지가 느리게 로드될 때 가장 먼저 드는 생각은 'Canvas 써야 하나? CSS로 처리해야 하나?'다.

결론부터 말하면 이 두 가지는 경쟁 관계가 아니다.
처리하는 레이어 자체가 다르다.

Canvas를 써야 할 때, CSS Transform을 써야 할 때, 그리고 WebWorker가 필요한 순간을 구분하는 기준은 하나다.

브라우저의 어느 단계를 건드리는가.

 

 

1.  Canvas VS CSS Transform

두 접근법은 목적과 처리 레이어가 근본적으로 다르다.

구분 canvas CSS Transform
처리 위치 CPU(주로) -> 픽셀 데이터 변경 GPU -> 렌더링 힌트
원본 데이터 실제로 변형/압축됨 변형 없음, 시각적 효과만
결과물 새 이미지 파일 생성 가능 DOM 표시 방식만 바꿈
네트워크 절감 가능 불가
렌더링 성능 상대적으로 느림 매우 빠름

 

1-1.  Canvas를 써야 할 때

픽셀 레벨에서 이미지를 직접 조작한다

1. 사용자가 이미지를 업로드하고, 서버로 보내기 전에 용량을 줄여야 할 때

2. 이미지 편집 기능(크롭, 필터, 워터마크)

3. 불필요한 메타데이터 제거

4. 동적 썸네일 생성

 

1-2. CSS Transform을 써야 할 때

렌더링 파이프라인 최적화에 가깝다.

1. 이미지 갤러리 줌 인/아웃 애니메이션

2. 스크롤 기반 이미지 전환 효과

3. 썸네일 hover 확대 효과

4. 애니메이션 중 reflow/repaint 방지

5. 스크롤 성능 개선

6. 레이어 분리로 다른 요소와 독립적 렌더링

7. 60fps 유지가 필요한 인터랙션

 

1-3. Canvas + Css Transform 함께 쓸 때 

Canvas로 최적화된 이미지를 생성한 뒤, 그 이미지를 DOM에 올리고 CSS Transform으로 인터랙션을 하는 것이 이상적인 조합.

 

 

2. 렌더링 파이프라인

2-1. CSS Transform이 빠른 이유

Layout → Paint → Composite
                    ↑
         Transform은 여기서만 처리
         → Layout, Paint 단계 생략 가능

 

Canvas는 Paint 단계에 개입하기 때문에 애니메이션 루프에 넣으면 오히려 성능 병목이 생긴다.

 

2-2. Layout을 건드리지 않는게 왜 중요할까?

 

브라우저 렌더링은 5단계로 이루어진다.

JavaScript → Style → Layout → Paint → Composite

 

각 단계의 비용은 다음과 같다.

단계 트리거 예시 비용 GPU 활용
Layout width, height, padding, margin, top 매우 큼 X
Paint background, color, box-shadow 중간 X
Composite transform, opacity 거의 없음 O

 

Layout이 변경되면 Reflow가 일어난다.

padding 변경 
 -> 이 요소의 크기 재계산
   -> 부모 요소 크기 재계산 
     -> 형제 요소 위치 재계산
       -> 자식 요소 전부 재계산
         -> Paint도 다시
           -> Composite도 다시

 

하나를 건드리면 DOM 트리 전체가 연쇄적으로 다시 계산된다. 60fps 기준으로 보면 차이가 명확하다.

60fps = 1프레임당 16.7ms 이내에 모든 작업 완료해야 함

Layout 발생 시:
  JS 실행        ~2ms
  Style 계산     ~1ms
  Layout(Reflow) ~8ms  ← 여기서 대부분 소비
  Paint          ~3ms
  Composite      ~1ms
  합계           ~15ms  ← 간당간당

transform만 사용 시:
  Composite만    ~1ms  ← 여유 충분

 

transform은 이미 레이어가 분리해놓은 요소를 GPU가 '이동/회전/확대'만 처리하면 끝이다. DOM 트리계산도 없고, 다른 요소에 영향도 없고, 메인 스레드개입도 최소화된다.

GPU는 이미지를 픽셀 단위로 이동시키는걸 하드웨어 수준에서 처리한다.

 

실제코드비교

첫번째 코드는 내가 실제로 구현했던 코드인데, 첫번째 코드와 두번째 코드는 시각적으로 결과는 동일하지만 브라우저가 하는 일의 양이 완전히 다르다.

// ❌ padding transition → Reflow 발생
// 애니메이션 매 프레임마다 DOM 트리 전체 재계산
<div className={`transition-[padding] ${bokLanded ? 'pt-[140px]' : 'pt-[81px]'}`}>

// ✅ transform → Composite만
// 이 요소만 GPU가 Y축으로 이동, 나머지 DOM은 그대로
<div style={{ transform: `translateY(${bokLanded ? 59 : 0}px)` }}>

 

이 차이가 체감되는 상황은 다음과 같다.

1. 저사양 안드로이드 기기

2. 애니메이션이 여러개 동시에 돌아갈 때

3. 스크롤과 애니메이션이 겹칠때

 

이 상황에서 Reflow가 있으면 프레임드랍으로 뚝뚝 끊기는 애니메이션이 된다.

 

2-3. Canvas에 이미지를 올리면 어떻게 되나

const ctx = canvas.getContext('2d');
ctx.drawImage(imgElement, 0, 0);

이 순간 이미지는 픽셀 배열(ImageData)로 디코딩되어 CPU 메모리에 올라간다.

 

렌더링 관점

img 태그:
  브라우저가 GPU 텍스처로 캐싱
  → transform 애니메이션 시 Composite만 발생
  → GPU가 텍스처를 그냥 이동/회전

Canvas:
  픽셀 데이터를 CPU 메모리에 보관
  → 매 프레임 ctx.drawImage() 호출 시 CPU → GPU 전송
  → Paint 단계 발생
  → 60fps 유지가 어려워짐

 

메모리 관점

img 태그:
  브라우저가 알아서 메모리 관리
  화면 밖으로 나가면 자동 해제

Canvas:
  ImageData가 명시적으로 해제될 때까지 메모리 점유
  모바일에서 고해상도 이미지면 즉시 OOM 위험

 

 

결론적으로 판단 기준은 하나다.

 

픽셀을 '가공'해야하는가? canvas

픽셀을 '움직여야'하는가?  img + CSS transform

 

3. 지도 최적화

일반 이미지 : 픽셀 고정, 한 번 로드하면 끝.

지도 : 줌/패닝 마다 새타일 요청, 수백개의 레이어가 동시에 존재, 사용자 인터랙션이 매우 빈번, 벡터 데이터 + 래스터 이미지 혼재

 

3-1. 타일 요청 최적화

// ❌ 패닝할 때마다 새 타일 요청
map.on('move', fetchNewTiles);

// ✅ debounce로 요청 묶기
map.on('move', debounce(fetchNewTiles, 150));

// ✅ 뷰포트보다 약간 넓게 미리 로드 (프리패칭)
const bounds = map.getBounds().pad(0.3); // 30% 여유

 

3-2. 마커 대량 렌더링 - DOM 대신 Canvas

마커 1000개를 DOM으로:
  → img 태그 1000개
  → 각각 Layout/Paint 대상
  → 스크롤/줌마다 Reflow 1000번

마커 1000개를 Canvas로:
  → Canvas 1개
  → 마커 위치만 ctx.drawImage()
  → DOM은 전혀 건드리지 않음

 

3-3. 클러스터링

마커가 많을수록 클러스터링은 필수다.

zoom 낮음:  ●(253)  ●(41)   // 클러스터
zoom 높음:  • • • • •        // 개별 마커

 

3-4. 레이어 분리 전략

지도를 레이어로 분리해서 변경되는 것만 다시 그린다.

[베이스맵 레이어]   → 거의 안 바뀜, 캐시 적극 활용
[마커 레이어]       → 자주 바뀜, Canvas로 분리
[UI 오버레이]       → DOM으로 분리 (말풍선, 버튼 등)
// React에서 레이어 분리 예시
<div className="relative">
  <BaseMapCanvas />          {/* WebGL Canvas */}
  <MarkerCanvas />           {/* 2D Canvas */}
  <div className="absolute inset-0 pointer-events-none">
    <UIOverlay />            {/* DOM */}
  </div>
</div>

 

 

3-5. 가상화

뷰포트 밖 타일/마커는 렌더링하지 않는다.

[  ] [  ] [  ]   ← 뷰포트 밖, 언로드
[  ] [■■] [  ]   ← 뷰포트, 렌더링
[  ] [  ] [  ]   ← 뷰포트 밖, 언로드

 

 

4. WebWorker

4-1. WebWorker란?

브라우저에서 JS를 메인스레드와 별개로 실행할 수 있는 백그라운드 스레드다.

메인 스레드  ──── A ──────────────── C ──▶
Worker 스레드 ────────── B(무거운 작업) ──▶

A와 B가 동시에 진행된다.

 

4-2. 왜 필요한가?

메인 스레드는 하나다. JS 실행, UI 렌더링, 사용자 인터랙션 처리, 데이터 파싱이 전부 한줄에서 순서대로 처리된다. 데이터를 파싱하는 동안 지도 줌/패닝이 멈추는 이유는 여기에 있다.

 

4-3. 제약 조건

Worker 안에서는 DOM을 접근할 수 없다. 계산만 하고 결과를 메인스레드에 넘겨주는 역할에 최적화되어있다.

 

4-4. 메인 스레드에 둘 것 vs Worker로 뺄 것

메인 스레드에 두는것

• DOM 업데이트
 Canvas 렌더링
 사용자 이벤트 처리
 애니메이션

 

WebWorker로 빼는것

 JSON 파싱
 좌표 변환 계산
 클러스터링 계산
 필터링/정렬
 GeoJSON 파싱
 거리계산

 

4-5. 실제 구현 흐름

메인 스레드                    WebWorker
─────────────────              ──────────────────
API 응답 수신
  │
  ▼
raw data를 Worker에 전달 ────▶ JSON.parse()
                               좌표 변환
                               클러스터링
                               필터링
                      ◀──────  가공된 데이터 반환
  │
  ▼
Canvas에 마커 렌더링

 

❌ Worker 없이:
메인 스레드: ──파싱(40ms)──클러스터(30ms)──렌더────▶
사용자 입력:              ← 이 70ms 동안 블로킹 →

✅ Worker 사용:
메인 스레드: ──────────────────────────렌더────────▶
Worker:      ──파싱(40ms)──클러스터(30ms)──▶ 완료 전달
사용자 입력: 항상 즉각 반응

 

 

5. 메인스레드 점유 측정 방법

5-1. 판단기준

~5ms 이하   → 그냥 메인 스레드에서 해도 됨
10ms~50ms   → Worker 고려
50ms 이상   → Worker 거의 필수

 

5-2. Chrome Devtools Performance 탭으로 측정하기

1. Chrome DevTools 열기 (F12)
2. Performance 탭 선택
3. 녹화 버튼(●) 클릭
4. 측정하고 싶은 동작 실행
5. 녹화 중지 후 Main 행 확인

 

실제로 측정하면 아래와 같이 보인다.

 

하단 요약 탭에서 각 출처별 기본 스레드 점유시간을 확인할 수 있다.

50ms 이상 메인 스레드를 점유하면 Long Task로 분류된다.

 

왜 50ms인가? 

사람이 100ms이상 지연을 느끼기 시작하기 때문이다. 50ms 작업 + 나머지 처리 여유 = 100ms 안에 응답 가능한 한계선이다.

 

5-3. 측정 결과별 액션

~10ms : 신경쓸 필요 없음.
10~50ms : 자주 호출되는 함수면 최적화 고려
50ms ~ : Worker 또는 태스크 분할 필요
100ms ~ : 사용자가 이미 느끼고 있음. 즉시 개선 필요.

 

 

 

성능 최적화는 '무조건 다 적용'이 아니라 '측정 → 병목 파악 → 적절한 도구 선택' 순서가 맞다.
픽셀을 가공해야 하면 Canvas, 움직이기만 하면 CSS Transform, 메인 스레드가 50ms 이상 막히면 WebWorker.
도구보다 먼저 DevTools를 열고 측정하는 습관이 가장 빠른 최적화의 시작점이다.