← 전체 글로 돌아가기

Docker

이미지 로딩 중에 입력이 먹히지 않는 UI 버그

사용자가 폼을 입력하는데 도중에 큰 이미지 로딩이 간섭해서 UI가 응답 안 할 때의 원인 파악.

리액트 폼을 채우고 있는데, 배경에서 이미지가 로딩되고 있으면 UI가 "끊긴다". 인풋 필드를 클릭해도 반응이 느리고, 이미지가 다 로드될 때까지 버벅거린다.

이 문제는 메인 스레드가 이미지 디코딩과 렌더링으로 블로킹되기 때문이다. JavaScript는 싱글 스레드인데, 큰 이미지를 처리하느라 사용자 입력을 무시하게 된다.

1단계: 병목 지점 확인

Chrome DevTools의 Performance 탭을 사용한다:

1. F12 열기 → Performance 탭
2. 폼 입력하면서 이미지 로딩 시작
3. 기록 시작 (Ctrl+Shift+E)
4. 입력하면서 느려지는 순간 기록 중단
5. 플레임 차트 분석

보통 두 가지가 보인다:

  • Long tasks: 50ms 이상 걸리는 메인 스레드 작업
  • Rendering: 레이아웃, 페인트 같은 브라우저 작업

이미지 로딩 중에 Long task가 생기면, 그게 원인이다.

2단계: 어느 이미지가 느린지 확인

# Network 탭에서 이미지별 크기와 다운로드 시간 확인

DevTools의 Network 탭에서:

  • 이미지 크기: 수 MB라면 너무 크다
  • 다운로드 시간: 3G 네트워크라고 가정해도 5초 이상이면 긴 편
// 또는 JavaScript에서 확인
const img = new Image()
img.onload = () => {
  console.log(`Image loaded in ${Date.now() - start}ms`)
  console.log(`Image size: ${img.naturalWidth}x${img.naturalHeight}`)
}
img.src = url
const start = Date.now()

3단계: 이미지 포맷 최적화

// 나쁜 예: 5MB PNG
<img src="/hero.png" />

// 좋은 예: 여러 포맷 제공
<picture>
  <source srcSet="/hero.webp" type="image/webp" />
  <source srcSet="/hero.jpg" type="image/jpeg" />
  <img src="/hero.jpg" alt="Hero" />
</picture>

WebP는 JPEG보다 30-40% 작다. Next.js Image 컴포넌트는 자동으로 최적화한다:

import Image from 'next/image'

<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={630}
  priority  // 중요한 이미지면 우선순위 높임
/>

4단계: 이미지 로딩을 Non-blocking으로 만들기

// 나쁜 패턴: await로 블로킹
async function loadImage(url) {
  const img = new Image()
  return new Promise((resolve) => {
    img.onload = () => resolve(img)
    img.src = url  // 여기서 블로킹 가능
  })
}

// 좋은 패턴: 백그라운드에서 로딩
function preloadImage(url) {
  const img = new Image()
  img.src = url  // 비동기로 로딩
  return new Promise((resolve) => {
    img.onload = () => resolve(img)
  })
}

사용자 입력에 직접 영향을 주지 않는 이미지는 preload로 미리 준비하되, 기다리지 않는다.

5단계: Web Workers로 이미지 처리

큰 이미지를 여러 장 처리해야 한다면, 메인 스레드가 아닌 웹 워커에서 처리한다:

// worker.js
self.onmessage = async (event) => {
  const { imageUrl } = event.data

  const img = new Image()
  await new Promise((resolve) => {
    img.onload = resolve
    img.src = imageUrl
  })

  // 이미지 처리 (메인 스레드와 무관)
  self.postMessage({ status: 'done', imageUrl })
}

// 메인 스레드
const worker = new Worker('worker.js')
worker.postMessage({ imageUrl: '/large-image.jpg' })
worker.onmessage = (event) => {
  console.log('Image ready:', event.data)
}

메인 스레드는 자유로우므로 사용자 입력에 즉시 반응한다.

6단계: 사용자 입력에 즉각적인 피드백 주기

// onChange 핸들러
const handleChange = (e) => {
  // 1. 즉시 상태 업데이트 (동기)
  setValue(e.target.value)

  // 2. 유효성 검사를 debounce로 미룸 (비동기)
  clearTimeout(validationTimer)
  validationTimer = setTimeout(() => {
    validateValue(e.target.value)
  }, 500)
}

입력은 즉시 반영되지만, 무거운 작업 (검증, API 호출)은 나중에 처리한다.

7단계: Lazy loading 이미지

<img
  src="/placeholder.jpg"
  loading="lazy"
  alt="Content"
/>

// 또는 Intersection Observer
const imageRef = useRef()

useEffect(() => {
  const observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting) {
      imageRef.current.src = imageRef.current.dataset.src
      observer.unobserve(imageRef.current)
    }
  })
  observer.observe(imageRef.current)
}, [])

<img
  ref={imageRef}
  src="/placeholder.jpg"
  data-src="/actual-image.jpg"
  alt="Content"
/>

뷰포트에 진입할 때만 이미지를 로드한다.

8단계: 용량 줄이기

# ImageMagick으로 이미지 압축
convert large.jpg -quality 80 -resize 1920x1080 optimized.jpg

# 또는 ffmpeg
ffmpeg -i large.jpg -q:v 5 optimized.jpg

체감상 품질 저하가 크지 않으면서도 파일 크기를 50-70% 줄일 수 있다.

9단계: 서드파티 스크립트 확인

// Analytics, ads 같은 스크립트가 이미지 로딩을 간섭하는가?
// DevTools Network 탭에서 타이밍 확인

Google Analytics, Facebook Pixel 같은 스크립트가 메인 스레드를 블로킹하면, 이미지 로딩과 상관없이 UI가 느려진다.

필요하면 이들을 async 또는 defer 속성으로 로드하자:

<script async src="https://analytics.google.com/..."></script>

최종 체크리스트

  1. DevTools Performance 탭에서 Long task 확인
  2. Network 탭에서 이미지 크기와 로딩 시간 확인
  3. 이미지 포맷 최적화 (WebP, 압축)
  4. 폼 입력이 즉시 반영되도록 구조 변경
  5. 무거운 작업은 debounce로 미룸
  6. 필요한 이미지만 미리 로드 (priority 속성)
  7. 불필요한 이미지는 lazy loading
  8. 메인 스레드를 블로킹하는 JavaScript 없는지 확인

대부분의 "느린 UI" 문제는 이미지 최적화와 작업 분산으로 해결된다.