Flutter 튜토리얼 20편: 에셋과 이미지 처리

요약#

핵심 요지#

  • 문제 정의: 앱에서 이미지와 데이터 파일을 효율적으로 관리하지 않으면 메모리 부족과 성능 저하가 발생한다.
  • 핵심 주장: Flutter의 에셋 시스템과 해상도 인식 기능을 활용하면 다양한 기기에서 최적화된 리소스를 제공할 수 있다.
  • 주요 근거: Image.asset()1으로 번들 이미지를 로드하고, Image.network()2로 원격 이미지를 가져오며, cached_network_image3로 캐싱을 적용한다.
  • 실무 기준: 4K 이미지는 30MB 메모리를 사용하므로, cacheWidth/cacheHeight로 메모리 사용량을 최적화한다.
  • 한계: 지원하지 않는 이미지 포맷은 플랫폼별 처리가 필요하다.

문서가 설명하는 범위#

  • pubspec.yaml에서 에셋을 등록하는 방법
  • 해상도별 이미지 변형(variant) 관리
  • 로컬과 네트워크 이미지 로딩
  • 캐싱과 메모리 최적화 전략

읽는 시간: 12분 | 난이도: 초급


참고 자료#


문제 상황#

앱을 만들 때 이미지, 아이콘, JSON 데이터 같은 정적 파일이 필요합니다. 이런 파일들을 효율적으로 관리하지 않으면 여러 문제가 발생합니다.

에셋 관리의 어려움#

문제 1: 기기마다 화면 밀도가 다름 → 이미지가 흐리거나 너무 큼
문제 2: 네트워크 이미지를 매번 다운로드 → 데이터 낭비, 느린 로딩
문제 3: 고해상도 이미지 무분별 사용 → 메모리 부족, 앱 크래시
문제 4: 에셋 경로 하드코딩 → 오타로 인한 런타임 에러

해결 방법#

Flutter는 체계적인 에셋 관리 시스템을 제공합니다. 해상도 인식 이미지, 효율적인 번들링, 다양한 이미지 로딩 방식을 지원합니다.

챕터 1: pubspec.yaml에 에셋 등록하기#

Why#

NOTE

에셋을 앱에서 사용하려면 먼저 Flutter에게 어떤 파일을 번들에 포함할지 알려야 합니다.
pubspec.yaml4에 등록하지 않은 파일은 빌드에서 제외됩니다.

프로젝트 파일 → pubspec.yaml 등록 → 빌드 시 번들에 포함 → 런타임에 로드 가능

What#

NOTE

에셋은 pubspec.yamlflutter 섹션 아래 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#

NOTE

Flutter는 기기의 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.png

Flutter가 변형 폴더의 이미지를 자동으로 번들에 포함합니다.

사용 시에는 기준 경로만 지정

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#

NOTE

Flutter에서 이미지를 화면에 표시하려면 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#

TIP

rootBundle 사용 (위젯 외부)

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});
@override
Widget 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()에서 직접 호출하면 에러가 발생합니다.

// 잘못된 사용
@override
void initState() {
super.initState();
// BuildContext 사용 불가, await 불가
final data = DefaultAssetBundle.of(context).loadString('...');
}
// 올바른 사용
@override
void 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

placeholderprogressIndicatorBuilder는 함께 사용할 수 없습니다.
둘 중 하나만 선택해야 합니다.

// 잘못된 사용 - 둘 다 지정하면 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

cacheWidthcacheHeight 속성을 사용하면 이미지를 지정한 크기로 디코딩해서 메모리에 저장합니다.
원본 크기 대신 표시 크기에 맞게 캐싱하면 메모리 사용량을 크게 줄일 수 있습니다.

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});
@override
Widget 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#

  1. Image.asset(): 앱 번들에 포함된 에셋 이미지를 로드하는 생성자다.

  2. Image.network(): URL에서 이미지를 다운로드해서 표시하는 생성자다.

  3. cached_network_image: 네트워크 이미지를 디스크와 메모리에 캐싱하는 Flutter 패키지다.

  4. pubspec.yaml: Flutter 프로젝트의 의존성, 에셋, 메타데이터를 정의하는 설정 파일이다.

  5. devicePixelRatio: 논리적 픽셀과 물리적 픽셀의 비율이다. 3.0x 화면은 논리적 1픽셀이 물리적 3×3픽셀이다.

  6. rootBundle: 앱 번들에 포함된 에셋에 접근하는 전역 AssetBundle 객체다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 20편: 에셋과 이미지 처리
https://moodturnpost.net/posts/flutter/flutter-assets-images/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차