Why This Work Exists
이미지 로딩이 느리다고 할 때 원인이 항상 네트워크는 아니다. 실제로는 다운로드는 빨라도 압축 해제, 픽셀 디코딩, 리사이징, 메인 스레드 렌더링이 병목이 되는 경우가 많다.
URL에서 이미지를 받아 `UIImage`를 만들고 `UIImageView`에 표시할 때, 실제로 어떤 단계가 지나가는지와 어디서 성능 비용이 커지는지 정리한 문서다. 핵심은 다운로드 비용, 디코딩 비용, 메인 스레드 렌더링 비용을 분리해서 이해하는 것이다.
2026-04-09
이미지 로딩이 느리다고 할 때 원인이 항상 네트워크는 아니다. 실제로는 다운로드는 빨라도 압축 해제, 픽셀 디코딩, 리사이징, 메인 스레드 렌더링이 병목이 되는 경우가 많다.
흔한 오해는 `URLSession으로 받았으니 끝났다`, `UIImage(data:) 하면 이미 다 준비됐다`, `UIImageView.image = image는 거의 공짜다`라는 생각이다.
더 정확한 이해는 이렇다. 네트워크 응답은 압축된 이미지 바이트를 가져오는 단계일 뿐이고, 실제 화면 표시를 위해서는 압축 해제와 픽셀 디코딩이 필요하며, 이 비용이 메인 스레드에 몰리면 스크롤 끊김이 생긴다.
`다운로드가 끝났다 = 화면 표시 준비 완료`가 아니다. 화면 표시 직전에 디코딩 비용이 터질 수 있다.
1단계는 네트워크다. `URLSession`은 JPEG, PNG, WebP 같은 압축된 바이트를 가져온다. 이 단계의 병목은 네트워크 속도, 응답 크기, 캐시 히트 여부다.
2단계는 이미지 객체 생성이다. `UIImage(data:)`를 호출하면 앱은 이미지 표현 객체를 얻는다. 하지만 이 시점에 항상 모든 픽셀을 미리 풀어 놓는다고 보면 안 된다.
3단계는 디코딩이다. 실제로 이미지를 화면에 그리려면 압축된 데이터를 픽셀 버퍼로 풀어야 한다. 이 작업이 첫 렌더링 시점에 지연되어 일어나면, 사용자는 “이미지는 받았는데 표시가 버벅인다”고 느낄 수 있다.
4단계는 렌더링이다. `UIImageView.image = image` 이후 실제 표시 과정에서 레이아웃, compositing, 스케일링 비용까지 합쳐져 체감 성능이 결정된다.
JPEG 1MB 파일이 메모리에서도 1MB라고 생각하면 안 된다. 디코딩 후에는 가로 x 세로 x 4 바이트 수준으로 메모리를 먹을 수 있다.
`UIImage`는 단순히 “픽셀 덩어리”가 아니라 이미지 데이터의 표현 객체다. 이 객체가 실제로 곧바로 비트맵을 전부 메모리에 펴 두는지, 아니면 나중 렌더링 때 디코딩하는지는 상황에 따라 달라질 수 있다.
`UIImageView`는 이 이미지를 실제 화면에 올리는 마지막 사용자다. 그래서 체감 성능 문제는 종종 `UIImageView`에서 발생한 것처럼 보이지만, 실제 원인은 그 직전 디코딩 비용일 수 있다.
let (data, _) = try await URLSession.shared.data(from: url)
let image = UIImage(data: data)
await MainActor.run {
imageView.image = image
}위 코드는 기본형으로는 맞다. 하지만 리스트 성능까지 챙기려면 다운샘플링, 캐시, 첫 렌더링 디코딩 비용을 별도로 더 봐야 한다.
`UIImage(data:)`는 이미지 객체를 만들지만, 실제 화면에 그릴 전체 픽셀 데이터가 그 시점에 완전히 준비됐다고 보장할 수는 없다. 그래서 첫 렌더링 시점의 디코딩 비용을 항상 같이 봐야 한다.
여기서 성능 이슈가 자주 생기는 구간은 세 번째다. 이미지 객체는 있었지만, 화면에 그리기 위한 full decode 비용이 아직 남아 있을 수 있기 때문이다.
다운샘플링은 “큰 원본 전체를 다 풀지 않고, 필요한 크기에 맞춰 더 작은 결과를 만드는 것”이다. 썸네일 UI에서는 거의 항상 유리하다.
사전 디코딩은 “처음 화면에 붙기 전에 미리 한 번 그려서 first draw 비용을 앞당기는 것”이다. 첫 표시 버벅임을 줄이는 데 도움이 될 수 있지만, 대신 미리 CPU와 메모리 비용을 쓴다.