Flutter 튜토리얼 20편: 에셋과 이미지 처리
요약
핵심 요지
- 문제 정의: 앱에서 이미지와 데이터 파일을 효율적으로 관리하지 않으면 메모리 부족과 성능 저하가 발생한다.
- 핵심 주장: Flutter의 에셋 시스템과 해상도 인식 기능을 활용하면 다양한 기기에서 최적화된 리소스를 제공할 수 있다.
- 주요 근거:
Image.asset()1으로 번들 이미지를 로드하고,Image.network()2로 원격 이미지를 가져오며,cached_network_image3로 캐싱을 적용한다. - 실무 기준: 4K 이미지는 30MB 메모리를 사용하므로,
cacheWidth/cacheHeight로 메모리 사용량을 최적화한다. - 한계: 지원하지 않는 이미지 포맷은 플랫폼별 처리가 필요하다.
문서가 설명하는 범위
- pubspec.yaml에서 에셋을 등록하는 방법
- 해상도별 이미지 변형(variant) 관리
- 로컬과 네트워크 이미지 로딩
- 캐싱과 메모리 최적화 전략
읽는 시간: 12분 | 난이도: 초급
참고 자료
- Adding assets and images - 에셋 및 이미지 공식 가이드
- Image class - Image 위젯 API 문서
- cached_network_image - 네트워크 이미지 캐싱 패키지
문제 상황
앱을 만들 때 이미지, 아이콘, JSON 데이터 같은 정적 파일이 필요합니다. 이런 파일들을 효율적으로 관리하지 않으면 여러 문제가 발생합니다.
에셋 관리의 어려움
문제 1: 기기마다 화면 밀도가 다름 → 이미지가 흐리거나 너무 큼문제 2: 네트워크 이미지를 매번 다운로드 → 데이터 낭비, 느린 로딩문제 3: 고해상도 이미지 무분별 사용 → 메모리 부족, 앱 크래시문제 4: 에셋 경로 하드코딩 → 오타로 인한 런타임 에러해결 방법
Flutter는 체계적인 에셋 관리 시스템을 제공합니다. 해상도 인식 이미지, 효율적인 번들링, 다양한 이미지 로딩 방식을 지원합니다.
챕터 1: pubspec.yaml에 에셋 등록하기
Why
NOTE에셋을 앱에서 사용하려면 먼저 Flutter에게 어떤 파일을 번들에 포함할지 알려야 합니다.
pubspec.yaml4에 등록하지 않은 파일은 빌드에서 제외됩니다.프로젝트 파일 → pubspec.yaml 등록 → 빌드 시 번들에 포함 → 런타임에 로드 가능
What
NOTE에셋은
pubspec.yaml의flutter섹션 아래assets키에 등록합니다.
개별 파일이나 디렉토리 단위로 지정할 수 있습니다.
How
TIP개별 파일 등록
flutter:assets:- assets/my_icon.png- assets/background.png- assets/data/config.json디렉토리 전체 등록
flutter:assets:- assets/images/- assets/data/디렉토리로 등록하면 해당 폴더의 모든 파일이 포함됩니다.
권장 프로젝트 구조
my_app/├── assets/│ ├── images/│ │ ├── logo.png│ │ ├── 2.0x/│ │ │ └── logo.png│ │ └── 3.0x/│ │ └── logo.png│ ├── icons/│ │ └── menu.svg│ └── data/│ └── config.json├── lib/│ └── main.dart└── pubspec.yaml
Watch out
WARNING
pubspec.yaml에서 들여쓰기가 중요합니다.
assets:는flutter:아래에 정확히 2칸 들여쓰기해야 합니다.# 올바른 예시flutter:assets:- assets/images/# 잘못된 예시 - 에러 발생flutter:assets: # 들여쓰기 없음- assets/images/디렉토리 끝에
/를 붙이면 해당 디렉토리의 파일만 포함됩니다. 하위 디렉토리는 포함되지 않습니다.
결론: pubspec.yaml에 에셋을 등록해야 앱 번들에 포함되어 런타임에 사용할 수 있습니다.
챕터 2: 해상도별 이미지 변형 관리
Why
NOTE기기마다 화면 픽셀 밀도가 다릅니다.
저해상도 이미지를 고밀도 화면에 표시하면 흐릿하게 보이고, 고해상도 이미지만 사용하면 메모리를 낭비합니다.graph LR A[1.0x: mdpi 기준] --> B[1.5x: hdpi] B --> C[2.0x: xhdpi] C --> D[3.0x: xxhdpi] D --> E[4.0x: xxxhdpi]
What
NOTEFlutter는 기기의
devicePixelRatio5에 따라 적절한 해상도의 이미지를 자동으로 선택합니다.
해상도별 변형은1.5x/,2.0x/,3.0x/,4.0x/폴더에 같은 이름으로 저장합니다.
How
TIP해상도별 이미지 배치
assets/├── images/│ ├── logo.png # 1.0x (기준, 예: 72×72px)│ ├── 1.5x/│ │ └── logo.png # 1.5x (108×108px)│ ├── 2.0x/│ │ └── logo.png # 2.0x (144×144px)│ ├── 3.0x/│ │ └── logo.png # 3.0x (216×216px)│ └── 4.0x/│ └── logo.png # 4.0x (288×288px)pubspec.yaml에는 기준 에셋만 등록
flutter:assets:- assets/images/logo.pngFlutter가 변형 폴더의 이미지를 자동으로 번들에 포함합니다.
사용 시에는 기준 경로만 지정
Image.asset('assets/images/logo.png')기기 화면 밀도에 맞는 이미지가 자동으로 로드됩니다.
Watch out
WARNING모든 해상도 변형을 만들 필요는 없습니다.
Flutter는 가장 가까운 해상도를 선택해서 스케일링합니다.// 3.0x 기기에서 3.0x 이미지가 없으면// 2.0x 이미지를 확대하거나 4.0x 이미지를 축소합니다실무에서는
1.0x,2.0x,3.0x세 가지만 준비해도 충분합니다.
결론: 해상도별 변형을 폴더로 관리하면 Flutter가 기기에 최적화된 이미지를 자동 선택합니다.
챕터 3: Image 위젯으로 이미지 표시
Why
NOTEFlutter에서 이미지를 화면에 표시하려면
Image위젯을 사용합니다.
이미지 소스에 따라 다른 생성자를 선택합니다.에셋 이미지 → Image.asset()네트워크 이미지 → Image.network()파일 이미지 → Image.file()메모리 이미지 → Image.memory()
What
NOTE
Image위젯은 다양한 소스의 이미지를 표시하고, 크기 조절, 정렬, 색상 블렌딩 등 시각적 속성을 제어합니다.
지원 포맷: JPEG, PNG, GIF, Animated GIF, WebP, Animated WebP, BMP, WBMP
How
TIP에셋 이미지 로드
// 기본 사용법Image.asset('assets/images/logo.png')// 크기와 맞춤 지정Image.asset('assets/images/background.png',width: 200,height: 150,fit: BoxFit.cover,)// 색상 블렌딩 적용Image.asset('assets/images/icon.png',color: Colors.blue,colorBlendMode: BlendMode.srcIn,)네트워크 이미지 로드
// 기본 사용법Image.network('https://example.com/image.jpg')// 로딩 상태 표시Image.network('https://example.com/image.jpg',loadingBuilder: (context, child, loadingProgress) {if (loadingProgress == null) return child;return Center(child: CircularProgressIndicator(value: loadingProgress.expectedTotalBytes != null? loadingProgress.cumulativeBytesLoaded /loadingProgress.expectedTotalBytes!: null,),);},errorBuilder: (context, error, stackTrace) {return const Icon(Icons.error);},)BoxFit 옵션 비교
BoxFit 설명 contain비율 유지, 전체가 보이도록 cover비율 유지, 영역을 꽉 채움 fill비율 무시, 영역에 맞춤 fitWidth너비에 맞춤 fitHeight높이에 맞춤 none원본 크기 scaleDown큰 경우만 축소
Watch out
WARNING
Image.network()는 캐싱을 제공하지 않습니다.
화면이 다시 빌드될 때마다 이미지를 다시 다운로드합니다.// 매번 다운로드됨 - 비효율적ListView.builder(itemBuilder: (context, index) {return Image.network('https://example.com/image$index.jpg');},)캐싱이 필요하면
cached_network_image패키지를 사용하세요 (챕터 5 참고).
결론: Image 위젯의 적절한 생성자와 BoxFit 옵션을 선택해서 이미지를 표시합니다.
챕터 4: 텍스트 에셋 로드
Why
NOTE앱에서 JSON 설정 파일, 텍스트 데이터, HTML 템플릿 등을 로드해야 할 때가 있습니다.
rootBundle6이나DefaultAssetBundle을 사용해서 텍스트 에셋을 읽습니다.에셋 번들 → 런타임에 문자열로 로드 → JSON 파싱 또는 직접 사용
What
NOTE
rootBundle은 앱 전역에서 에셋을 로드하고,DefaultAssetBundle은 위젯 컨텍스트에서 에셋을 로드합니다.
DefaultAssetBundle은 테스트와 지역화에 유리합니다.
How
TIProotBundle 사용 (위젯 외부)
import 'package:flutter/services.dart' show rootBundle;Future<String> loadConfig() async {return await rootBundle.loadString('assets/data/config.json');}DefaultAssetBundle 사용 (위젯 내부, 권장)
class ConfigLoader extends StatelessWidget {const ConfigLoader({super.key});@overrideWidget build(BuildContext context) {return FutureBuilder<String>(future: DefaultAssetBundle.of(context).loadString('assets/data/config.json'),builder: (context, snapshot) {if (snapshot.connectionState == ConnectionState.waiting) {return const CircularProgressIndicator();}if (snapshot.hasError) {return Text('Error: ${snapshot.error}');}return Text(snapshot.data ?? '');},);}}JSON 데이터 파싱
import 'dart:convert';Future<Map<String, dynamic>> loadJsonConfig(BuildContext context) async {final jsonString = await DefaultAssetBundle.of(context).loadString('assets/data/config.json');return json.decode(jsonString) as Map<String, dynamic>;}// 사용 예시void initConfig(BuildContext context) async {final config = await loadJsonConfig(context);print('API URL: ${config['apiUrl']}');print('Timeout: ${config['timeout']}');}
Watch out
WARNING에셋 로드는 비동기 작업입니다.
initState()에서 직접 호출하면 에러가 발생합니다.// 잘못된 사용@overridevoid initState() {super.initState();// BuildContext 사용 불가, await 불가final data = DefaultAssetBundle.of(context).loadString('...');}// 올바른 사용@overridevoid didChangeDependencies() {super.didChangeDependencies();_loadData();}Future<void> _loadData() async {final data = await DefaultAssetBundle.of(context).loadString('assets/data/config.json');setState(() => _config = data);}
결론: 텍스트 에셋은 DefaultAssetBundle을 사용해서 비동기로 로드합니다.
챕터 5: 네트워크 이미지 캐싱
Why
NOTE네트워크 이미지를 매번 다운로드하면 데이터를 낭비하고 사용자 경험이 나빠집니다.
캐싱을 적용하면 한 번 다운로드한 이미지를 재사용할 수 있습니다.graph TD A[이미지 요청] --> B{캐시 확인} B -->|있음| C[캐시에서 로드] B -->|없음| D[네트워크 다운로드] D --> E[캐시에 저장] E --> C
What
NOTE
cached_network_image패키지는 디스크와 메모리 캐싱을 자동으로 관리합니다.
플레이스홀더, 에러 위젯, 진행률 표시기를 지원합니다.
How
TIP패키지 설치
dependencies:cached_network_image: ^3.3.1기본 사용법
import 'package:cached_network_image/cached_network_image.dart';CachedNetworkImage(imageUrl: 'https://example.com/image.jpg',placeholder: (context, url) => const CircularProgressIndicator(),errorWidget: (context, url, error) => const Icon(Icons.error),)진행률 표시
CachedNetworkImage(imageUrl: 'https://example.com/large-image.jpg',progressIndicatorBuilder: (context, url, progress) {return CircularProgressIndicator(value: progress.progress,);},errorWidget: (context, url, error) => const Icon(Icons.error),)커스텀 이미지 빌더
CachedNetworkImage(imageUrl: 'https://example.com/profile.jpg',imageBuilder: (context, imageProvider) {return CircleAvatar(backgroundImage: imageProvider,radius: 50,);},placeholder: (context, url) => const CircleAvatar(radius: 50,child: Icon(Icons.person),),errorWidget: (context, url, error) => const CircleAvatar(radius: 50,child: Icon(Icons.error),),)페이드 애니메이션 설정
CachedNetworkImage(imageUrl: 'https://example.com/image.jpg',fadeInDuration: const Duration(milliseconds: 300),fadeOutDuration: const Duration(milliseconds: 300),fadeInCurve: Curves.easeIn,fadeOutCurve: Curves.easeOut,)
Watch out
WARNING
placeholder와progressIndicatorBuilder는 함께 사용할 수 없습니다.
둘 중 하나만 선택해야 합니다.// 잘못된 사용 - 둘 다 지정하면 progressIndicatorBuilder만 동작CachedNetworkImage(imageUrl: '...',placeholder: (context, url) => const Icon(Icons.image),progressIndicatorBuilder: (context, url, progress) =>CircularProgressIndicator(value: progress.progress),)// 올바른 사용 - 하나만 선택CachedNetworkImage(imageUrl: '...',progressIndicatorBuilder: (context, url, progress) =>CircularProgressIndicator(value: progress.progress),errorWidget: (context, url, error) => const Icon(Icons.error),)
결론: cached_network_image로 네트워크 이미지를 캐싱하면 성능과 사용자 경험이 개선됩니다.
챕터 6: 이미지 메모리 최적화
Why
NOTE고해상도 이미지는 엄청난 메모리를 사용합니다.
4K 이미지(3840×2160)는 약 30MB의 메모리를 소비합니다.
리스트에서 여러 장을 표시하면 쉽게 메모리 부족이 발생합니다.4K 이미지: 3840 × 2160 × 4bytes = ~30MB작은 썸네일로 캐시: 384 × 216 × 4bytes = ~330KB
What
NOTE
cacheWidth와cacheHeight속성을 사용하면 이미지를 지정한 크기로 디코딩해서 메모리에 저장합니다.
원본 크기 대신 표시 크기에 맞게 캐싱하면 메모리 사용량을 크게 줄일 수 있습니다.
How
TIP에셋 이미지 최적화
Image.asset('assets/images/large-photo.jpg',cacheWidth: 400, // 논리적 픽셀 기준cacheHeight: 300,fit: BoxFit.cover,)devicePixelRatio 적용
Widget build(BuildContext context) {final pixelRatio = MediaQuery.devicePixelRatioOf(context);final displayWidth = 200; // 화면에 표시할 크기return Image.asset('assets/images/photo.jpg',width: displayWidth.toDouble(),// 실제 픽셀 크기로 캐시cacheWidth: (displayWidth * pixelRatio).round(),fit: BoxFit.cover,);}리스트에서 최적화 적용
class OptimizedImageList extends StatelessWidget {final List<String> imageUrls;const OptimizedImageList({super.key, required this.imageUrls});@overrideWidget build(BuildContext context) {final pixelRatio = MediaQuery.devicePixelRatioOf(context);return ListView.builder(itemCount: imageUrls.length,itemBuilder: (context, index) {return CachedNetworkImage(imageUrl: imageUrls[index],memCacheWidth: (150 * pixelRatio).round(),memCacheHeight: (150 * pixelRatio).round(),maxWidthDiskCache: 600,maxHeightDiskCache: 600,fit: BoxFit.cover,placeholder: (context, url) => Container(width: 150,height: 150,color: Colors.grey[300],),);},);}}ResizeImage로 직접 크기 조절
Image(image: ResizeImage(const AssetImage('assets/images/large.jpg'),width: 200,height: 200,),fit: BoxFit.cover,)
Watch out
WARNING
cacheWidth/cacheHeight는 논리적 픽셀 기준입니다.
실제 렌더링 품질을 위해서는devicePixelRatio를 곱해야 합니다.// 잘못된 방법 - 고밀도 화면에서 흐릿하게 보임Image.asset('assets/images/photo.jpg',width: 100,cacheWidth: 100, // 3.0x 화면에서는 300px 영역에 100px 이미지)// 올바른 방법Image.asset('assets/images/photo.jpg',width: 100,cacheWidth: (100 * MediaQuery.devicePixelRatioOf(context)).round(),)디코딩 크기가 너무 작으면 이미지 품질이 저하됩니다. 표시 크기보다 약간 크게 설정하는 것이 좋습니다.
결론: cacheWidth/cacheHeight로 표시 크기에 맞게 이미지를 캐싱하면 메모리 사용량을 크게 줄일 수 있습니다.
한계
Flutter의 에셋 시스템은 대부분의 상황을 처리하지만 몇 가지 제약이 있습니다.
- SVG 미지원: 기본 Image 위젯은 SVG를 지원하지 않습니다.
flutter_svg패키지가 필요합니다. - HEIC/HEIF 제한: iOS의 HEIC 포맷은 직접 지원하지 않으며 플랫폼 변환이 필요합니다.
- 에셋 핫 리로드 제한: pubspec.yaml의 에셋 변경은 앱을 다시 실행해야 반영됩니다.
- 번들 크기: 모든 해상도 변형을 포함하면 앱 크기가 증가합니다.
Footnotes
-
Image.asset(): 앱 번들에 포함된 에셋 이미지를 로드하는 생성자다. ↩
-
Image.network(): URL에서 이미지를 다운로드해서 표시하는 생성자다. ↩
-
cached_network_image: 네트워크 이미지를 디스크와 메모리에 캐싱하는 Flutter 패키지다. ↩
-
pubspec.yaml: Flutter 프로젝트의 의존성, 에셋, 메타데이터를 정의하는 설정 파일이다. ↩
-
devicePixelRatio: 논리적 픽셀과 물리적 픽셀의 비율이다. 3.0x 화면은 논리적 1픽셀이 물리적 3×3픽셀이다. ↩
-
rootBundle: 앱 번들에 포함된 에셋에 접근하는 전역 AssetBundle 객체다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!