네트워크 이미지 렌더링을 최적화하여 메모리 사용량 절감하기

Ximya(심야)·2023년 11월 11일
9
post-thumbnail

Flutter에서 네트워크 이미지를 호출하여 화면에 보여줄 때 어떤 부분을 신경 써야 할까요?

더 나은 사용자 경험(UX)을 제공하기 위해 이미지 위젯을 사용할 때, Fade-In 애니메이션을 적용하거나 네트워크에서 이미지를 로딩하기 전 로딩 인디케이터를 표시하는 것을 고려할 수 있습니다.

UX를 고려한 이러한 로직은 중요하지만, 네트워크 이미지를 렌더링할 때 메모리 사용량을 절감하는 부분도 매우 중요합니다. 이미지의 사이즈가 클수록 렌더링 과정에서 많은 메모리를 필요하기 때문입니다.

제 개인 프로젝트 사례를 소개하자면, 앱을 사용하면 화면이 버벅대거나 비정상적으로 종료되는 문제가 발생했습니다. 앞서 언급한 대로, 원인은 고해상도 네트워크 이미지를 화면에 표시할 때 메모리가 과도하게 사용되었기 때문이었습니다.

저와 같은 실수를 하지 않기 위해서는 이미지를 화면에 로드할 때 렌더링 작업을 최적화하는 방법 알고 계셔야 합니다. 이번 글에서는 네트워크 이미지를 효과적으로 렌더링하여 메모리를 절감하는 방법을 소개드리고 있으니 관련 팁을 얻어가세요!

글 후반부에서 관련 예제코드도 제공하니 참고하시길 바랍니다.


Oversized 이미지 진단하기

먼저, 네트워크 이미지를 렌더링할 때 메모리 사용량이 과도한지 진단하는 것이 중요합니다. 간단한 예제를 통해 이를 확인해 보겠습니다.

 Image.network(  
    imageUrl,
    width: 250, 
 ),  

위에 이미지 위젯은 효율적으로 렌더링 되었을까요? 간단하게 알아볼 수 있는 방법이 있습니다.

Flutter Inspector에서 Highlight oversized images 버튼을 활성화해 주시면 됩니다.

NOTE
Flutter에서 제공하는 플래그를 사용할 수도 있습니다.
debugInvertOversizedImages = true;
이 코드를 앱의 시작점이나 이미지 위젯을 포함한 클래스에 추가하세요.

그러면 화면에서 이미지의 색상이 반전되고 수직으로 뒤집힌 것을 확인할 수 있을 겁니다. 이는 이미지를 디코딩하는 과정에서 필요한 것보다 더 많은 메모리를 사용했다는 것을 나타냅니다.

Display Size & Decode Size

에러 로그를 확인하면 더 구체적인 정보를 얻을 수 있습니다.

Image [...] has a display size of 750×421 but a decode size of 3840×2160, which uses an additional 41552KB (assuming a device pixel ratio of 3.0).

화면의 이미지는 750x421 사이즈를 가지고 있지만 실제로 디코드한 사이즈는 3840×2160이기 때문에 추가로 41552KB 메모리를 사용했다고 합니다.

디스플레이 사이즈(Display Size)는 이미지가 디코딩된 크기를 나타냅니다. 즉 실제 화면에 보여질 때 실제로 필요한 디스플레이 크기는 750×421 이기 때문에 이미지의 원래 크기인 3840×2160(Decod Size)를 모두 디코딩하는 것이 불필요하다는 것이죠.

조금 더 쉽게 이해하기 위해서 비유를 하나 해보죠.

여러분이 화가에게 친구와 함께 찍은 사진을 주고 그림을 그려달라는 상황을 가정해 보겠습니다. 화가에게 사진을 제공할 때, 그림을 그리는 데 필요한 크기보다 훨씬 큰 전광판 사이즈의 사진을 전달할 필요가 없습니다. 실제로 저런 큰 사진은 오히려 화가의 작업을 방해합니다.

화가가 정확하고 빠르게 그림을 그리려면 적절한 크기의 사진만 있으면 충분합니다. 이 개념은 네트워크 이미지를 Flutter에서 불러올 때도 적용됩니다. Flutter 엔진은 이미지를 디코딩할 때, 이미지의 크기가 화면에 표시할 크기(디스플레이 크기)보다 훨씬 크면 디코딩 과정에서 메모리를 낭비하게 되는 것처럼요.

화가 : Flutter 엔진
화가에게 전달한 사진 : 디스플레이 사이즈
그림을 그리는 화가의 행위 : 디코딩
화가가 그린 그림 : 이미지 위젯


이미지 Resize하기

그럼, 이미지의 디코할 이미지의 사이즈를 어떻게 조절해야 할까요? 이어지는 에러 로그에서 이미지 크기를 조절하는 방법에 대한 안내가 있습니다.

Consider resizing the asset ahead of time, supplying a cacheWidth parameter of 750, a cacheHeight parameter of 421, or using a ResizeImage

이미지를 미리 리사이징(Resizing)하는 방법은 Image.network의 cacheWidthcacheHeight 속성을 사용하는 것입니다. 이러한 속성을 사용하면 이미지를 디코딩하기 전에 원하는 크기로 조절할 수 있습니다. 이미지의 실제 디스플레이 크기와 상관없이 이러한 속성에 지정된 크기로 이미지가 디코딩됩니다. Image.network에서는 HTTP 헤더와 상관없이 모든 네트워크 이미지가 캐싱 되기 때문에 이러한 속성을 설정하는 것이 중요합니다.

NOTE
이미지를 화면에 표시할 때 실제로 표시되는 크기 'width' 및 'height' 속성에 의해 결정되지만, 렌더링 된 이미지의 크기는 'cacheWidth' 및 'cacheHeight' 에 의해 결정됩니다.

이제 로그를 참고하여 코드를 수정해 보겠습니다.

Image.network(  
  imageUrl,  
  width: 250,  
  cacheWidth: 750,  
),  
const Divider(),  
Image.network(  
  imageUrl,  
  width: 250,  
),

비교를 위해 cacheWidth 속성을 설정 안 한 위젯도 추가하였습니다. (한 쪽 cache 속성만 설정해 주면, 다른 쪽도 이미지의 비율을 유지한 채로 리사이즈 됩니다)

cacheWidth를 설정한 이미지는 overSize 에러 없이 정상적으로 표시되지만, 반대의 경우 이미지의 색상과 방향이 반전되고 수직으로 뒤집혔습니다. cacheWidth를 올바르게 설정함으로써 이미지를 리사이징하여 디코딩 프로세스에서 필요한 메모리 사용을 최적화했습니다.


디바이스별 픽셀 비율

그럼에도 여전히 문제가 발생할 수 있습니다.

cacheWidth을 설정한 동일한 코드에서 iPhone 12mini는 정상적으로 이미지가 출력되지만, 디바이스 크기가 작은 iPhone se에서는 여전히 이미지가 oversize 되었다고 표시됩니다.

왜 이런 문제가 발생할까요? 이번에도 에러 로그를 확인해 봅시다.

iPhone 12mini

Image [...] has a display size of 750×421 but a decode size of 3840×2160, which uses an additional 41552KB (assuming a device pixel ratio of 3.0).

Consider resizing the asset ahead of time, supplying a cacheWidth parameter of 750, a cacheHeight parameter of 421, or using a ResizeImage.

iPhone 12mini의 경우 이미지의 display 넓이가 750이고 디바이스 픽셀 비율은 3.0라고 합니다.

iPhone se

Image [...] has a display size of 500×281 but a decode size of 3840×2160, which uses an additional 42467KB (assuming a device pixel ratio of 2.0).

Consider resizing the asset ahead of time, supplying a cacheWidth parameter of 500, a cacheHeight parameter of 281, or using a ResizeImage.

반면에 iPhone SE의 경우 이미지의 디스플레이 크기는 500이고 디바이스 픽셀 비율은 2.0인 것으로 확인됩니다.

이 차이는 각 디바이스의 픽셀 비율(Device Pixel Ratio) 다르기 때문에 때문에 발생합니다.

디바이스 픽셀 비율은 각 디바이스의 화면에서 표시되는 픽셀의 밀도(Density)를 나타내는데, 특정 디바이스에서 화면 크기당 표시되는 픽셀의 수를 화면 크기로 나눈 것입니다.

픽셀 밀도는 일반적으로 ppi (pixcels per inch)로 측정되며, 디바이스의 화면 크기에 따라 다양한 값을 가질 수 있습니다. 예를 들어, 고해상도 디바이스는 같은 크기의 화면에 더 많은 픽셀을 포함하므로 높은 픽셀 밀도를 가지게 됩니다.

요약하면, iPhone SE의 경우 2.0의 픽셀 비율을 가지고 있어서 1인치당 2개의 픽셀이 표시되지만, iPhone 12 mini의 경우 1인치당 3개의 픽셀이 표시됩니다. 따라서 iPhone 12 mini을 기준으로 cacheWidth를 설정하면 iPhone SE에서 여전히 불필요하게 디코딩할 크기가 남아 있게 되는 것입니다.


이미지의 캐시 사이즈를 동적으로 구하기

이제 모든 힌트를 얻었으니 디바이스 픽셀 비율에 따라 cacheWidth 값을 구할 수 있겠죠?

250(위젯 사이즈) X 2(ipone se 디바이스 픽셀 비율) = 500(캐시 사이즈)

타겟하고 있는 위젯 사이즈가 250이고 iPhone se는 1인치에 2개의 픽셀이 표시되기 때문에 위젯 사이즈에 디바이스 픽셀 비율을 곱해주면 알맞는 디스플레이 사이즈(500)를 도출할 수 있습니다.

코드로 표시하면 아래와 같습니다.

Image.network(  
  imageUrl,  
  width: 250,  
  cacheWidth: (250 * MediaQuery.of(context).devicePixelRatio).round(),  
),

MediaQuery를 사용하여 디바이스의 픽셀 비율을 구하고, 이미지 위젯의 너비와 곱하여 cacheWidth 값을 설정합니다. 그리고 cacheWidth 속성은 정수형 값을 요구하므로 round 메소드를 사용하여 가장 가까운 정수로 반올림했습니다. 이렇게 코드 적용하면 이미지를 디바이스 픽셀 비율에 맞게 캐시 값을 설정하여 이미지를 리사이즈 할 수 있게 됩니다.

또한, 코드를 더 간결하게 구성하기 위해 이미지 크기를 계산하는 작업을 extension으로 구현할 수 있습니다. 아래는 extension을 구현한 코드입니다.

extension ImageExtension on num {  
  int cacheSize(BuildContext context) {  
    return (this * MediaQuery.of(context).devicePixelRatio).round();  
  }  
}

그런 다음 이미지 위젯에서 extension을 활용하여 필요한 cache값을 간결하게 설정할 수 있습니다.

Image.network(  
  imageUrl,  
  width: 250,  
  cacheWidth: 250.cacheSize(context),  
)

캐시 사이를 지정할 때 고려해야되는 부분

만약 이미지의 원본 비율과 타겟하고 있는 위젯의 비율이 다르고 이미지 위젯이 fit: BoxFit.cover 형태라면 고려해야 하는 사항이 있습니다.

일반적으로 fit: BoxFit.cover를 사용하면 이미지가 위젯에 맞게 이미지가 잘리게 되는데, 이때 이미지의 디스플레이 사이즈를 결정할 때 비율을 고려해야 합니다. 원본 이미지와 위젯의 비율이 다른 경우, 캐시 크기를 설정할 때 더 작은 측면 (넓이 또는 높이)을 기준으로 설정해야 원본 이미지의 비율을 유지하면서 이미지를 최적화할 수 있습니다.

만약 반대로 설정을 하게 된다면 낮은 해상도의 이미지가 출력될 수 있습니다.

예를 들어보겠습니다.

Image.network(  
  imageUrl,  
  width: 250,  
  height: 250,  
  cacheWidth: 250.cacheSize(context),  
  fit: BoxFit.cover,
)

  • 이미지 크기 : 3000 x 1688
  • 이미지 비율 : 1.7
  • 디코딩된 이미지 디스플레이 사이즈 : 500 x 282
  • 이미지 위젯의 크기 : 250 x 250
  • 이미지 위젯의 비율 : 1

각 넓이와 높이가 250인 이미지 위젯에 cacheWidth을 500(위젯 넓이 X 디바이스 픽셀 비율)으로 설정하면 자동으로 비율을 유치하면서 이미지의 디스플레이 높이가 결정됩니다. 근데 원본 이미지는 화면 그려져야하는 위젯의 비율과 달리, 높이보다 넓이가 큰 비율을 가지고 있기 때문에 디코딩된 이미지의 디스플레이 높이(281)는 타겟해야되는 디스플레이 높이(500)보다 낮게 설정되게 됩니다. 그래서 위에 예시 사진처럼 이미지가 흐릇하게 보일겁니다.

Image.network(  
  imageUrl,  
  width: 250,  
  height: 250,  
  cacheHeight: 250.cacheSize(context),  
  fit: BoxFit.cover,
)

반대로 cacheHeight를 설정하면 이미지의 비율을 유지한 채 최소 디스플레이 사이즈로 리사이즈 되며 선명한 해상도를 유지할 수 있습니다.

Image [...] - Resized(null×500) has a display size of 500×500 but a decode size of 889×500, which uses an additional 1013KB (assuming a device pixel ratio of 2.0).

Consider resizing the asset ahead of time, supplying a cacheWidth parameter of 500, a cacheHeight parameter of 500, or using a ResizeImage.

여전히 overSize 에러 로그는 발생합니다. 이미지 위젯이 원하는 디스플레이 사이즈의 넓이는 500이지만 디코딩된 이미지의 디스플레이 넓이는 886이기 때문입니다.

그럼에도 디코딩 되어야 하는 이미지 사이즈를 기존보다 2배 이상 줄이고, 이미지 비율을 유지하면서 선명한 이미지를 보여주었기 때문에 이미지를 최적화하였다고 볼 수 있습니다.

이미지의 비율을 고려한 동적 캐시 사이즈 지정

근데 대부분 네트워크 이미지의 비율을 클라이언트 쪽에서 미리 알고 있는 경우는 많이 없죠.

Builder(  
  builder: (context) {  
    int? cacheWidth, cacheHeight;  
    Size targetSize = const Size(250, 250);  
    const double originImgAspectRatio = 1.7;  
   
    // 원본 이미지 비율이 0보다 크면 높이보다 넓이 더 큰 이미지임을 의미합니다.
    if (originImgAspectRatio > 0) {  
      cacheHeight = targetSize.height.cacheSize(context);  
    } else {  
      cacheWidth = targetSize.width.cacheSize(context);  
    }  
  
    return Image.network(  
      imageUrl,  
      width: targetSize.width,  
      height: targetSize.height,  
      cacheWidth: cacheWidth,  
      cacheHeight: cacheHeight,  
      fit: BoxFit.cover,  
    );  
  },  
),

그래서 이때는 위 코드와 같이 원본 이미지의 비율을 조건으로 넓이와 높이 중 어떤 곳을 기준으로 cache값을 설정할지 결정 해주면 됩니다. 앞서 계속 언급했듯이, 한쪽 cache 사이즈 속성만 설정 해주면 이미지가 비율대로 라사이즈 되기 때문에 다른 쪽은 null값을 전달 해줘도 설정해 줘도 무방합니다.

원본 이미지의 비율이나 사이즈나 모르면 어떻게 해야하나요? 🤔

Flutter에는 원본 이미지의 사이즈나 비율을 구하는 방법이 있기 때문에 이미지의 비율 값을 도출할 수 있습니다. 하지만 이 방법을 권장하지는 않습니다. 원본 이미지의 비율이나 사이즈를 비동기적으로 리턴 받을 때 딜레이 시간이 꽤 길기 때문입니다.

서버로부터 데이터를 받아올 때 네트워크 이미지의 주소뿐만 아니라 이미지의 사이즈 또는 비율 정보를 같이 전달받는 것이 가장 이상적인 형태입니다. Youtube, Tmdb 등등, 여러 Open API에서 네트워크 이미지 리소스를 전달할 때 이미지 주소와 사이즈(비율) 값을 함께 제공하는 것을 확인하실 수 있습니다.


CacheNetworkImage 패키지

Flutter에서는 이미지 캐싱을 위해 Image.network 위젯을 사용할 수 있지만, cached_network_image 패키지의 사용을 권장합니다. 이 패키지는 캐싱과 관련된 세밀한 기능을 제공하여 성능을 향상시킬 수 있습니다. 다음은 cached_network_image 패키지를 활용한 코드 예제입니다

CachedNetworkImage(  
  imageUrl: imageUrl,  
  memCacheHeight: 320.cacheSize(context),  
  memCacheWidth: 250.cacheSize(context),  
),

CachedNetworkImage 위젯을 사용하면 Image.network 위젯과 동일하게 캐시 사이즈를 지정할 수 있는 memCacheHeightmemCacheWidth 속성을 활용할 수 있습니다.


마무리하면서

이번 글에서는 네트워크 이미지를 효율적으로 불러오고, 메모리 사용량을 최적화하기 위한 방법을 살펴보았습니다.

평상시 작업하면서 쉽게 놓칠 수 있는 부분이지만 꼭 필요한 작업이라고 생각합니다.
특히 고화질의 이미지가 화면에 여러 개 그려지는 어플리케이션일수록 네트워크 이미지 렌더링 최적화 작업이 꼭 필요합니다.

이미지 렌더링을 최적화하는 작업뿐만 아니라 이미지의 UX를 고려하기 위한 팁들을 얻어가시려면 해당 포스팅 (12 Image Tips and Best Practices for the Best UX Performance in Flutter)을 참고 해보세요. 잘 정리되어 있습니다.

또한 본글에서 다룬 예제 코드가 궁금하시다면 제 깃허브 레포지토리에서 확인하실 수 있습니다.

읽어주셔서 감사합니다!

레퍼펀스

profile
https://medium.com/@ximya

2개의 댓글

comment-user-thumbnail
2024년 1월 26일
1개의 답글