Flutter 개발을 진행하다 보면 벡터 그래픽의 표준인 SVG(Scalable Vector Graphics) 파일을 다룰 일이 많습니다. SVG는 어떤 해상도에서도 깨지지 않는 선명함을 자랑하기에 다양한 크기의 디바이스에 대응해야 하는 모바일 앱 개발에서 매우 유용한 자산입니다. 하지만 때로는 Flutter 프레임워크의 특정 위젯이나 서드파티 라이브러리가 SVG를 직접 지원하지 않고, 픽셀 기반의 비트맵(Bitmap) 이미지를 요구하는 상황에 직면하게 됩니다. 가장 대표적인 예가 바로 구글 맵(Google Maps)의 커스텀 마커(Custom Marker)입니다.
구글 맵의 마커 아이콘은 BitmapDescriptor
라는 객체를 필요로 하는데, 이는 이름에서 알 수 있듯이 비트맵 기반의 이미지 설명자입니다. 따라서 우리가 가진 세련된 SVG 아이콘을 지도 위에 표시하려면, 이를 BitmapDescriptor
로 변환하는 과정이 반드시 필요합니다. 이 과정에서 가장 흔하게 발생하는 문제는 바로 '화질 저하'입니다. 벡터 이미지를 픽셀 이미지로 변환하면서 발생하는 이 문제는, 특히 고해상도 디스플레이(레티나 디스플레이 등)에서 아이콘이 흐릿하게 보이는 현상으로 나타나 앱의 전체적인 완성도를 떨어뜨리는 주범이 됩니다.
이 글에서는 단순히 SVG를 비트맵으로 변환하는 코드 조각을 제시하는 것을 넘어, 왜 화질 저하가 발생하는지 근본적인 원인을 파헤치고, 디바이스의 픽셀 밀도(Device Pixel Ratio)를 고려하여 어떤 화면에서도 선명함을 유지하는 완벽한 변환 방법을 단계별로 상세히 설명합니다. 또한, 변환된 아이콘을 실제 구글 맵에 적용하는 방법과 성능 최적화를 위한 캐싱 전략까지 다루어, 여러분의 앱에 완성도 높은 커스텀 아이콘을 적용할 수 있도록 도와드리겠습니다.
1. 왜 SVG를 비트맵으로 변환해야 할까? 근본적인 이해
본격적인 코드 작성에 앞서, 왜 이 변환이 필요한지, 그리고 SVG와 비트맵의 차이가 무엇인지 명확히 이해하는 것이 중요합니다. 이 개념을 이해하면 앞으로 마주할 다양한 이미지 관련 문제에 더 유연하게 대처할 수 있습니다.
- SVG (Scalable Vector Graphics): SVG는 점, 선, 곡선 등의 수학적 방정식을 기반으로 이미지를 표현하는 벡터 방식입니다. XML 형식으로 구성되어 있으며, '어디에서 어디까지 선을 그려라', '이 경로는 어떤 색으로 채워라'와 같은 '명령'의 집합입니다. 이 때문에 이미지를 아무리 확대하거나 축소해도 수학 방정식이 다시 계산되어 렌더링되므로 화질 손상이 전혀 없습니다.
- 비트맵 (Bitmap/Raster Graphics): 비트맵은 PNG, JPEG, GIF 와 같은 형식을 말하며, 이미지를 작은 사각형 점들의 격자(Grid), 즉 픽셀(Pixel)로 표현합니다. 각 픽셀은 고유한 색상 정보를 가지고 있습니다. '첫 번째 줄 첫 번째 픽셀은 빨간색, 두 번째 픽셀은 파란색...'과 같은 방식입니다. 이 때문에 정해진 픽셀 수를 넘어서 이미지를 확대하면 픽셀 격자가 그대로 드러나 보이는 '계단 현상(Aliasing)'이나 이미지가 흐릿해지는 현상이 발생합니다.
Flutter의 많은 위젯들은 내부적으로 비트맵 이미지를 다루는 데 최적화되어 있습니다. 특히 google_maps_flutter
라이브러리가 사용하는 BitmapDescriptor
는 안드로이드와 iOS 네이티브 플랫폼의 지도 API와 직접적으로 통신하기 위해 설계된 객체입니다. 이 네이티브 API들이 비트맵 형식의 마커 이미지를 요구하기 때문에, 우리는 벡터 형식인 SVG를 플랫폼이 이해할 수 있는 비트맵 형식으로 '번역'해 주어야 하는 것입니다. 이 '번역' 과정이 바로 우리가 다룰 래스터화(Rasterization)입니다.
SVG는 확대해도 선명하지만, 비트맵은 확대하면 픽셀이 드러나 흐려집니다. 우리의 목표는 이 비트맵을 최대한 선명하게 만드는 것입니다.
2. 변환을 위한 필수 라이브러리: `flutter_svg`
Flutter에서 SVG 파일을 다루기 위해서는 공식적으로 지원하는 패키지인 flutter_svg
를 사용해야 합니다. 이 패키지는 SVG 파일을 파싱하고 Flutter 위젯으로 렌더링하거나, 더 낮은 수준의 그래픽 객체로 변환하는 강력한 기능을 제공합니다.
먼저, 프로젝트의 pubspec.yaml
파일에 `flutter_svg` 의존성을 추가합니다.
dependencies:
flutter:
sdk: flutter
# SVG 처리를 위한 필수 패키지
flutter_svg: ^2.0.10+1 # 최신 안정 버전으로 사용하는 것을 권장합니다.
# 예제에서 사용할 구글 맵 패키지
google_maps_flutter: ^2.6.1
# 기타 다른 패키지들...
pubspec.yaml
파일을 수정한 후에는 터미널에서 flutter pub get
명령을 실행하여 패키지를 프로젝트에 설치하는 것을 잊지 마세요.
또한, 변환 과정에서 사용될 아이콘 파일(예: `assets/icons/marker.svg`)을 프로젝트에 추가하고, pubspec.yaml
에 해당 애셋을 등록해야 합니다.
flutter:
uses-material-design: true
assets:
- assets/icons/ # 폴더 전체를 등록할 경우
# - assets/icons/marker.svg # 특정 파일만 등록할 경우
이제 변환을 위한 모든 준비가 끝났습니다.
3. 선명한 비트맵 생성을 위한 단계별 변환 프로세스
이제 가장 중요한 부분인 변환 함수를 구현해 보겠습니다. 단순히 코드를 복사해서 붙여넣는 것이 아니라, 각 단계가 어떤 의미를 가지며 왜 필요한지 깊이 있게 이해하는 것이 중요합니다. 특히 'Device Pixel Ratio'를 다루는 부분이 화질을 결정하는 핵심입니다.
3.1. 문제의 원인: 논리적 픽셀 vs 물리적 픽셀
대부분의 개발자가 처음 변환을 시도할 때 겪는 실수는 아이콘의 '논리적 크기'로만 비트맵을 생성하는 것입니다. 예를 들어, UI 디자인 상 48x48 크기의 아이콘이 필요하다고 해서, 48x48 픽셀 크기의 비트맵을 생성하는 경우입니다.
여기서 함정은 Flutter가 사용하는 크기 단위(논리적 픽셀)와 디스플레이가 실제로 표현하는 픽셀(물리적 픽셀)의 개수가 다르다는 점입니다. 최신 스마트폰의 고해상도 디스플레이는 1개의 논리적 픽셀 공간에 2개, 3개, 혹은 그 이상의 물리적 픽셀을 사용하여 더 선명한 이미지를 표현합니다. 이 비율을 Device Pixel Ratio (DPR)이라고 합니다.
- DPR 1.0: 1 논리적 픽셀 = 1x1 물리적 픽셀 (구형 디바이스)
- DPR 2.0: 1 논리적 픽셀 = 2x2 = 4 물리적 픽셀 (예: iPhone 8)
- DPR 3.0: 1 논리적 픽셀 = 3x3 = 9 물리적 픽셀 (예: iPhone 14 Pro)
만약 DPR 3.0인 디바이스에서 48x48 논리적 크기의 아이콘을 표시하려면, 실제로는 144x144 (48 * 3) 물리적 픽셀 공간이 할당됩니다. 이때 우리가 48x48 크기의 비트맵을 제공하면, 운영체제는 이 작은 이미지를 144x144 공간에 맞게 강제로 확대하게 됩니다. 이 과정에서 이미지가 흐릿해지는(blurry) 현상이 발생하는 것입니다.
따라서 해결책은 간단합니다. 비트맵을 생성할 때부터 목표 논리적 크기에 DPR을 곱한, '실제 물리적 픽셀' 크기로 생성하는 것입니다.
3.2. 완벽한 변환 함수 구현하기
아래는 DPR을 고려하여 SVG를 `BitmapDescriptor`로 변환하는 전체 함수 코드입니다. 코드 아래에서 각 단계를 상세히 분해하여 설명하겠습니다.
import 'dart:async';
import 'dart:ui' as ui; // ui.Image, ui.Picture 등을 사용하기 위해 필요
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
/// SVG 애셋을 디바이스의 픽셀 밀도를 고려하여 선명한 BitmapDescriptor로 변환합니다.
///
/// [context] BuildContext: MediaQuery를 통해 DPR을 얻기 위해 필요합니다.
/// [assetName] String: 변환할 SVG 파일의 애셋 경로입니다. (예: 'assets/icons/marker.svg')
/// [logicalSize] Size: 아이콘이 화면에 표시될 논리적 크기입니다. (기본값: 48x48)
Future<BitmapDescriptor> getSizedSvgIcon(
BuildContext context,
String assetName, {
Size logicalSize = const Size(48, 48),
}) async {
// 1. 애셋에서 SVG 파일을 문자열로 읽어옵니다.
final String svgString = await DefaultAssetBundle.of(context).loadString(assetName);
// 2. SVG 문자열을 flutter_svg가 이해할 수 있는 DrawableRoot로 파싱합니다.
final DrawableRoot svgDrawableRoot = await svg.fromSvgString(svgString, svgString);
// 3. 디바이스의 픽셀 밀도(DPR)를 가져옵니다. 이것이 화질의 핵심입니다.
// ignore: use_build_context_synchronously
final double devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
// 4. 논리적 크기와 DPR을 곱하여 실제 렌더링될 물리적 픽셀 크기를 계산합니다.
final double physicalWidth = logicalSize.width * devicePixelRatio;
final double physicalHeight = logicalSize.height * devicePixelRatio;
// 5. 계산된 물리적 크기를 사용하여 DrawableRoot를 ui.Picture로 렌더링합니다.
// ui.Picture는 드로잉 명령의 기록이며, 아직 픽셀화되지 않은 상태입니다.
final ui.Picture picture = svgDrawableRoot.toPicture(
size: Size(physicalWidth, physicalHeight),
);
// 6. ui.Picture를 실제 픽셀 데이터인 ui.Image로 변환합니다.
// 이 단계에서 래스터화(Rasterization)가 일어납니다.
final ui.Image image = await picture.toImage(
physicalWidth.toInt(),
physicalHeight.toInt(),
);
// 7. ui.Image를 PNG 형식의 바이트 데이터(ByteData)로 인코딩합니다.
// PNG는 투명도를 지원하므로 아이콘에 가장 적합합니다.
final ByteData? bytes = await image.toByteData(format: ui.ImageByteFormat.png);
// 8. ByteData를 Uint8List로 변환하고, 이를 사용하여 BitmapDescriptor를 생성합니다.
if (bytes == null) {
// 만약 바이트 데이터 생성에 실패하면 기본 마커를 반환하거나 에러 처리를 합니다.
return BitmapDescriptor.defaultMarker;
}
return BitmapDescriptor.fromBytes(bytes.buffer.asUint8List());
}
코드 상세 분석
- SVG 파일 읽기:
DefaultAssetBundle.of(context).loadString(assetName)
을 사용하여pubspec.yaml
에 등록된 애셋 파일을 문자열 형태로 비동기적으로 불러옵니다. SVG는 본질적으로 XML 텍스트 파일이므로 이 방법이 가능합니다. - SVG 파싱:
svg.fromSvgString()
함수는 읽어온 SVG 문자열을 `flutter_svg` 라이브러리가 내부적으로 사용하는DrawableRoot
객체로 변환합니다. 이 객체는 SVG의 모든 벡터 정보를 담고 있습니다. - Device Pixel Ratio(DPR) 얻기:
MediaQuery.of(context).devicePixelRatio
를 통해 현재 디바이스의 DPR 값을 가져옵니다. 이 값이 1.0, 2.0, 3.0 등으로 달라지며, 화질을 결정하는 가장 중요한 변수입니다. - 물리적 크기 계산: 전달받은 `logicalSize` (예: 48x48)에 DPR을 곱하여, 이미지가 래스터화될 실제 픽셀 크기(예: 144x144)를 계산합니다.
- Picture로 렌더링:
svgDrawableRoot.toPicture()
메소드는 SVG의 벡터 드로잉 명령들을 `dart:ui`의Picture
객체로 변환합니다. 이 `Picture`는 특정 크기(우리가 계산한 물리적 크기)로 렌더링될 준비가 된 상태의 드로잉 레시피와 같습니다. - Image로 변환 (래스터화):
picture.toImage()
가 바로 마법이 일어나는 순간입니다. 벡터 드로잉 명령이 담긴 `Picture`를 실제 픽셀의 격자인ui.Image
객체로 변환(래스터화)합니다. 이때 인자로 물리적 픽셀 크기를 정수형으로 넘겨주어, 고해상도 비트맵이 생성되도록 합니다. - ByteData로 인코딩:
image.toByteData()
는 메모리에 있는 픽셀 데이터를 파일 형식(여기서는 PNG)의 바이트 배열로 변환합니다. PNG를 사용하는 이유는 아이콘 배경의 투명도를 보존하기 위함입니다. - BitmapDescriptor 생성: 마지막으로, 인코딩된 바이트 데이터(
ByteData
)에서 실제 버퍼(Uint8List
)를 추출하여BitmapDescriptor.fromBytes()
생성자에 전달합니다. 이렇게 최종적으로 생성된 `BitmapDescriptor` 객체를 반환하면, 구글 맵과 같은 API에서 사용할 수 있게 됩니다.
4. 실제 적용: 구글 맵에 커스텀 마커 표시하기
이제 위에서 만든 getSizedSvgIcon
함수를 사용하여 구글 맵에 선명한 커스텀 마커를 표시하는 전체 예제를 살펴보겠습니다. 변환 과정이 비동기(async
)로 이루어지기 때문에, 마커 아이콘이 로드될 때까지 상태를 관리하는 것이 중요합니다.
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
// 위에서 만든 변환 함수(getSizedSvgIcon)가 포함된 파일을 import 합니다.
import 'svg_to_bitmap.dart';
class MapWithCustomMarkerScreen extends StatefulWidget {
const MapWithCustomMarkerScreen({super.key});
@override
State<MapWithCustomMarkerScreen> createState() => _MapWithCustomMarkerScreenState();
}
class _MapWithCustomMarkerScreenState extends State<MapWithCustomMarkerScreen> {
final Set<Marker> _markers = {};
BitmapDescriptor _customIcon = BitmapDescriptor.defaultMarker;
bool _isIconLoaded = false;
static const CameraPosition _kGooglePlex = CameraPosition(
target: LatLng(37.42796133580664, -122.085749655962),
zoom: 14.4746,
);
@override
void initState() {
super.initState();
// initState에서는 context를 사용할 수 없으므로,
// 위젯 트리가 빌드된 후 아이콘을 로드하기 위해 addPostFrameCallback을 사용합니다.
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadCustomIcon();
});
}
Future<void> _loadCustomIcon() async {
// getSizedSvgIcon 함수를 호출하여 BitmapDescriptor를 비동기적으로 가져옵니다.
// logicalSize를 36으로 지정하여 약간 작은 아이콘을 만들어 봅니다.
final icon = await getSizedSvgIcon(
context,
'assets/icons/map_marker.svg',
logicalSize: const Size(36, 36),
);
setState(() {
_customIcon = icon;
_isIconLoaded = true;
_addMarker();
});
}
void _addMarker() {
if (!_isIconLoaded) return; // 아이콘이 로드되지 않았으면 마커를 추가하지 않음
setState(() {
_markers.add(
Marker(
markerId: const MarkerId('custom_marker_1'),
position: _kGooglePlex.target,
icon: _customIcon,
infoWindow: const InfoWindow(
title: '선명한 커스텀 마커!',
snippet: 'DPR이 적용되었습니다.',
),
),
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('SVG 커스텀 마커 예제'),
),
body: GoogleMap(
mapType: MapType.normal,
initialCameraPosition: _kGooglePlex,
markers: _markers,
onMapCreated: (GoogleMapController controller) {
// 맵 컨트롤러가 준비되면 마커를 추가할 수도 있습니다.
// _addMarker();
},
),
);
}
}
이 예제 코드의 핵심은 다음과 같습니다:
initState
에서는BuildContext
에 즉시 접근할 수 없을 수 있으므로,WidgetsBinding.instance.addPostFrameCallback
을 사용하여 첫 프레임이 렌더링된 직후에_loadCustomIcon
함수를 호출합니다. 이는MediaQuery.of(context)
를 안전하게 사용하기 위함입니다._loadCustomIcon
함수는 우리가 만든getSizedSvgIcon
를 호출하고, 변환이 완료되면setState
를 호출하여_customIcon
변수를 업데이트하고_isIconLoaded
플래그를 true로 설정합니다._addMarker
함수는 로드된_customIcon
을 사용하여Marker
객체를 생성하고, 이를_markers
Set에 추가합니다.setState
를 통해 UI를 갱신하면 지도 위에 마커가 표시됩니다.- 초기에는
_customIcon
이 기본 마커(BitmapDescriptor.defaultMarker
)로 설정되어 있어, 아이콘 로딩 중에 원치 않는 기본 마커가 잠시 나타나는 것을 방지할 수 있습니다._isIconLoaded
플래그를 통해 로딩이 완료된 후에만 마커를 추가하는 로직을 구현했습니다.
5. 성능 최적화: 변환 결과 캐싱하기
만약 지도에 수십, 수백 개의 동일한 아이콘을 표시해야 하거나, 다른 화면에서도 같은 아이콘을 반복적으로 사용해야 한다면, 매번 SVG 변환 과정을 거치는 것은 심각한 성능 저하를 유발할 수 있습니다. 변환 과정은 파일을 읽고, 파싱하고, 이미지를 래스터화하는 등 상당한 비용이 드는 작업이기 때문입니다.
이러한 문제를 해결하기 위한 가장 효과적인 방법은 '캐싱(Caching)'입니다. 한번 변환된 BitmapDescriptor
를 메모리에 저장해두고, 다음부터는 동일한 아이콘 요청이 들어오면 변환 과정 없이 저장된 객체를 즉시 반환하는 것입니다.
간단한 Map
을 사용하여 캐시를 구현할 수 있습니다.
// 캐시를 저장할 전역 또는 클래스 수준의 Map
final Map<String, BitmapDescriptor> _iconCache = {};
/// SVG 아이콘을 캐싱 기능과 함께 로드하는 래퍼(Wrapper) 함수
Future<BitmapDescriptor> getCachedSvgIcon(
BuildContext context,
String assetName, {
Size logicalSize = const Size(48, 48),
}) async {
// 캐시 키를 생성합니다. 파일 경로와 크기를 조합하여 고유하게 만듭니다.
final cacheKey = '$assetName-$logicalSize';
// 1. 캐시에 이미 아이콘이 있는지 확인합니다.
if (_iconCache.containsKey(cacheKey)) {
// 캐시에 있다면, 즉시 반환합니다.
return _iconCache[cacheKey]!;
}
// 2. 캐시에 없다면, 변환 함수를 호출하여 아이콘을 생성합니다.
final icon = await getSizedSvgIcon(
context,
assetName,
logicalSize: logicalSize,
);
// 3. 생성된 아이콘을 캐시에 저장합니다.
_iconCache[cacheKey] = icon;
// 4. 생성된 아이콘을 반환합니다.
return icon;
}
이제부터는 getSizedSvgIcon
대신 getCachedSvgIcon
함수를 호출하면 됩니다. 이 함수는 내부적으로 캐시를 확인하여, 동일한 애셋 경로와 크기로 요청된 아이콘이 이미 메모리에 있다면 값비싼 변환 과정을 건너뛰고 즉시 결과를 반환해 줄 것입니다. 이 간단한 최적화만으로도 앱의 반응성과 성능을 크게 향상시킬 수 있습니다.
고급 팁: 동적으로 SVG 색상 변경하기
가끔은 SVG 아이콘의 색상을 코드에서 동적으로 변경하고 싶을 때가 있습니다 (예: 활성/비활성 상태에 따라 마커 색 변경). flutter_svg
패키지는 `colorFilter` 속성을 통해 위젯의 색상을 쉽게 변경할 수 있지만, 비트맵으로 변환하기 전에는 어떻게 해야 할까요?
가장 간단한 방법은 SVG 문자열 자체를 조작하는 것입니다. SVG 파일 내에서 색상을 정의하는 부분(예: `fill="#000000"`)을 찾아 원하는 색상 코드로 교체한 후 파싱하면 됩니다.
// ... 변환 함수 내부 ...
String svgString = await DefaultAssetBundle.of(context).loadString(assetName);
// 원하는 색상으로 교체 (주의: SVG 파일의 색상 정의 방식에 따라 동작하지 않을 수 있음)
const newColor = '#FF5733'; // 오렌지색
svgString = svgString.replaceAll('fill="#000000"', 'fill="$newColor"');
svgString = svgString.replaceAll('stroke="#000000"', 'stroke="$newColor"');
final DrawableRoot svgDrawableRoot = await svg.fromSvgString(svgString, svgString);
// ... 이후 과정은 동일 ...
이 방법은 SVG 파일이 단순하고 색상 정의가 일관적일 때 효과적입니다. 복잡한 SVG의 경우 더 정교한 XML 파싱이 필요할 수 있지만, 대부분의 아이콘에는 이 문자열 치환 방식만으로도 충분합니다.
결론: 선명한 아이콘으로 앱의 완성도를 높이다
지금까지 Flutter에서 SVG 자산을 화질 저하 없이 선명한 비트맵으로 변환하는 여정을 함께했습니다. 다시 한번 핵심 사항들을 정리해 보겠습니다.
- 변환의 필요성: `google_maps_flutter`와 같은 일부 API는 벡터(SVG)가 아닌 래스터(Bitmap) 이미지를 요구합니다.
- 화질 저하의 원인과 해결: 화질 저하는 논리적 픽셀 크기로 비트맵을 생성하여 고해상도(High-DPR) 화면에서 확대될 때 발생합니다. 이를 해결하려면 논리적 크기 × Device Pixel Ratio 공식을 사용하여 실제 물리적 픽셀 크기에 맞는 비트맵을 생성해야 합니다.
- 핵심 도구:
flutter_svg
패키지는 SVG를 파싱하고,dart:ui
라이브러리는 이를Picture
와Image
로 변환하는 핵심 기능을 제공합니다. - 성능 최적화: 동일한 아이콘을 반복해서 변환하는 것은 비효율적입니다. 변환된
BitmapDescriptor
를Map
을 이용해 캐싱하면 앱의 성능을 크게 향상시킬 수 있습니다.
이제 여러분은 어떤 디바이스에서도 선명하고 아름다운 커스텀 아이콘을 지도 위에, 또는 비트맵을 요구하는 다른 어떤 곳에서든 자유롭게 표시할 수 있는 능력을 갖추게 되었습니다. 작아 보이는 아이콘 하나의 품질이 앱 전체의 인상과 사용자 경험에 미치는 영향은 결코 작지 않습니다. 이 글에서 다룬 기술을 활용하여 여러분의 Flutter 앱을 한 단계 더 높은 수준으로 끌어올리시길 바랍니다.
0 개의 댓글:
Post a Comment