Skip to main content

기기 기능 사용하기

Views 11약 11분생성형 AI 활용

앱의 가치는 화면 밖에서 시작된다

지금까지 우리는 Flutter 안에서 화면을 만들고, 상태를 관리하고, API와 Firebase에 데이터를 저장하는 방법을 배웠다. 이제 앱은 한 단계 더 현실 세계로 나아가야 한다. 사용자가 사진을 찍고, 현재 위치를 확인하고, 문서를 첨부하는 순간 앱은 단순한 화면 묶음이 아니라 "기기와 연결된 도구"가 된다.

예를 들어 개인 지식 노트 앱을 만든다고 생각해 보자. 텍스트만 입력할 수 있는 노트 앱도 쓸 만하지만, 회의 사진을 찍어 붙이고, 여행지 위치를 기록하고, PDF 자료를 첨부할 수 있다면 앱의 가치는 훨씬 커진다. Flutter는 이런 기능을 직접 새로 만들라고 요구하지 않는다. Android와 iOS의 네이티브 기능을 잘 감싼 플러그인을 사용하면 Dart 코드에서 카메라, 위치, 파일 선택 기능을 호출할 수 있다.

다만 여기에는 중요한 문이 하나 있다. 바로 권한(permission)이다. 카메라, 위치, 사진 보관함, 파일 접근은 모두 사용자의 사적인 정보와 연결된다. 그래서 운영체제는 앱이 마음대로 접근하지 못하게 막고, 사용자의 동의를 먼저 받도록 한다. permission_handler 패키지는 Android와 iOS에서 런타임 권한을 확인하고 요청하며, 사용자가 권한을 영구적으로 거부한 경우 앱 설정 화면으로 이동시키는 API를 제공한다.1

이번 장에서는 기기 기능을 다룰 때 반드시 필요한 네 가지를 배운다. 권한을 언제 요청할지, 권한 결과를 어떻게 분기할지, 카메라와 사진 보관함을 어떻게 연결할지, 위치와 파일 선택 기능을 앱 구조 안에 어떻게 넣을지다.

기기 기능 사용하기

권한 요청은 "앱 시작"이 아니라 "기능 실행 순간"에 한다

초보자가 자주 하는 실수는 앱을 켜자마자 카메라, 위치, 사진, 알림, 저장소 권한을 한꺼번에 요청하는 것이다. 개발자 입장에서는 미리 받아 두면 편해 보인다. 하지만 사용자 입장에서는 이상하다. 방금 설치한 노트 앱이 갑자기 "위치를 허용하시겠습니까?", "카메라를 허용하시겠습니까?", "사진 접근을 허용하시겠습니까?"라고 묻는다면 대부분은 의심부터 한다.

권한 요청의 좋은 원칙은 간단하다. 사용자가 그 기능을 쓰려고 할 때, 왜 필요한지 알 수 있는 문맥에서 요청한다.

사용자가 "사진 촬영" 버튼을 눌렀다면 카메라 권한을 요청한다. 사용자가 "현재 위치 추가" 버튼을 눌렀다면 위치 권한을 요청한다. 사용자가 "파일 첨부" 버튼을 눌렀다면 파일 선택기를 연다. 이때 권한이 거부되어도 앱이 멈추면 안 된다. 사진 없이 노트를 저장할 수 있게 하거나, 위치 대신 사용자가 직접 장소명을 입력하게 하거나, 파일 첨부를 건너뛰는 대체 흐름을 제공해야 한다.

권한은 개발자의 편의를 위해 존재하는 절차가 아니라, 사용자의 신뢰를 얻기 위한 대화다. "이 기능을 쓰려면 카메라가 필요합니다"라고 자연스럽게 설명하고, 거부하면 "사진 없이 계속 작성할 수 있습니다"라고 안내하는 앱이 오래 살아남는다.

필요한 패키지 설치하기

이번 장에서 사용할 대표 패키지는 다음과 같다.

# pubspec.yaml
dependencies:
  permission_handler: ^12.0.1
  image_picker: ^1.1.2
  geolocator: ^14.0.2
  file_picker: ^10.0.0
  mime: ^2.0.0

버전은 책을 읽는 시점에 따라 달라질 수 있으므로 실제 프로젝트에서는 다음 명령으로 최신 호환 버전을 추가하는 편이 안전하다.

flutter pub add permission_handler image_picker geolocator file_picker mime

permission_handler는 권한 상태 확인과 요청을 담당한다. image_picker는 카메라 촬영과 갤러리 선택을 쉽게 처리한다. 공식 설명에 따르면 image_picker는 이미지 라이브러리에서 이미지를 선택하거나 카메라로 새 사진을 찍는 Flutter 플러그인이다.2 geolocator는 현재 위치, 마지막 위치, 위치 변화 스트림, 위치 서비스 활성화 여부 확인 같은 기능을 제공한다.3 file_picker는 운영체제의 기본 파일 선택기를 열어 문서, 이미지, 동영상 등을 고를 수 있게 해준다.4

플랫폼 설정: Dart 코드만으로는 부족하다

Flutter 코드에서 권한을 요청한다고 해서 모든 준비가 끝나는 것은 아니다. Android와 iOS는 앱이 사용할 민감한 기능을 네이티브 설정 파일에 미리 선언하도록 요구한다. 이 설정이 빠져 있으면 권한 요청 창이 뜨지 않거나, 패키지 호출이 실패하거나, 심하면 앱 심사에서 거절될 수 있다.

Android에서는 android/app/src/main/AndroidManifest.xml 파일에 권한을 추가한다. 권한 선언은 보통 <manifest> 태그 바로 아래, <application> 태그 바깥에 둔다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

    <application
        android:label="knowledge_note"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <!-- ... -->
    </application>
</manifest>

위치 기능에서 ACCESS_FINE_LOCATION은 비교적 정확한 위치를, ACCESS_COARSE_LOCATION은 대략적인 위치를 의미한다. geolocator 공식 문서도 Android에서 위치를 사용하려면 이 둘 중 하나를 Manifest에 추가해야 하며, 둘 다 선언하면 더 정확한 위치 권한이 사용된다고 설명한다.3

백그라운드 위치 추적이 필요한 앱이라면 ACCESS_BACKGROUND_LOCATION이나 Android 14 이상의 FOREGROUND_SERVICE_LOCATION 같은 추가 권한을 검토해야 한다. 하지만 이 책의 개인 지식 노트 앱처럼 사용자가 버튼을 눌렀을 때 현재 위치 한 번만 가져오는 정도라면 백그라운드 위치 권한은 필요하지 않다. 필요하지 않은 권한을 넣지 않는 것도 좋은 보안 설계다.

Android 13 이상에서는 사진과 동영상 접근 권한이 더 세분화되었다. 다만 image_picker는 Android 13 이상에서 Android Photo Picker를 사용하며, 일반적인 이미지 선택 흐름에서는 예전처럼 넓은 저장소 권한을 직접 요구하지 않아도 되는 경우가 많다.2 반대로 모든 파일을 마음대로 훑는 파일 관리자 앱을 만들려는 경우에는 훨씬 엄격한 정책과 별도 권한이 필요하다. 입문 단계에서는 "사용자가 직접 고른 파일만 사용한다"는 방향으로 설계하는 것이 안전하다.

iOS에서는 ios/Runner/Info.plist에 사용 목적 문구를 추가한다. 이 문구는 권한 요청 팝업에 그대로 표시되므로, 개발자용 표현이 아니라 사용자가 이해할 수 있는 문장으로 작성해야 한다.

<key>NSCameraUsageDescription</key>
<string>노트에 첨부할 사진을 촬영하기 위해 카메라 접근이 필요합니다.</string>

<key>NSPhotoLibraryUsageDescription</key>
<string>노트에 첨부할 이미지를 선택하기 위해 사진 보관함 접근이 필요합니다.</string>

<key>NSLocationWhenInUseUsageDescription</key>
<string>노트에 현재 위치를 기록하기 위해 위치 접근이 필요합니다.</string>

동영상 촬영까지 지원한다면 마이크 권한 설명도 필요할 수 있다.

<key>NSMicrophoneUsageDescription</key>
<string>동영상 촬영 시 소리를 함께 녹음하기 위해 마이크 접근이 필요합니다.</string>

permission_handler를 iOS에서 사용할 때는 Podfile의 권한 매크로 설정도 확인해야 한다. 공식 문서는 iOS에서 사용할 권한을 Info.plist에 추가해야 하며, 플러그인이 여러 SDK를 건드리기 때문에 필요한 권한 매크로와 설명 문구를 맞춰 관리해야 한다고 안내한다.1 실무에서는 "Dart 코드, AndroidManifest.xml, Info.plist, Podfile" 네 곳을 함께 점검하는 습관이 중요하다.

권한 상태를 하나의 서비스로 다루기

권한 처리 코드를 화면마다 흩뿌리면 금방 지저분해진다. 카메라 화면에서도 권한을 확인하고, 프로필 화면에서도 확인하고, 노트 첨부 화면에서도 확인하면 같은 분기문이 반복된다. 유지보수 가능한 앱을 만들려면 권한 처리를 작은 서비스 클래스로 분리하는 편이 좋다.

먼저 권한 요청 결과를 앱에서 쓰기 좋은 형태로 바꿔 보자.

// lib/core/permissions/app_permission_result.dart

enum AppPermissionResult {
  granted,
  denied,
  permanentlyDenied,
  restricted,
  limited,
}

permission_handler의 PermissionStatus에는 granted, denied, permanentlyDenied, restricted, limited, provisional 같은 상태가 있다.1 여기서는 가장 자주 만나는 다섯 가지를 다룬다.

granted는 권한이 허용된 상태다. denied는 거부되었지만 다시 요청할 수 있는 상태다. permanentlyDenied는 사용자가 "다시 묻지 않음"에 해당하는 선택을 했거나, 시스템상 앱 설정에서 직접 바꿔야 하는 상태다. restricted는 iOS의 보호자 제한 같은 시스템 정책으로 막힌 상태에 가깝다. limited는 특히 iOS 사진 보관함에서 사용자가 일부 사진만 접근하도록 허용한 상태를 다룰 때 중요하다.

// lib/core/permissions/permission_service.dart

import 'package:permission_handler/permission_handler.dart';

import 'app_permission_result.dart';

class PermissionService {
  Future<AppPermissionResult> request(Permission permission) async {
    final status = await permission.request();
    return _mapStatus(status);
  }

  Future<AppPermissionResult> check(Permission permission) async {
    final status = await permission.status;
    return _mapStatus(status);
  }

  Future<void> openSettings() => openAppSettings();

  AppPermissionResult _mapStatus(PermissionStatus status) {
    if (status.isGranted) return AppPermissionResult.granted;
    if (status.isPermanentlyDenied) {
      return AppPermissionResult.permanentlyDenied;
    }
    if (status.isRestricted) return AppPermissionResult.restricted;
    if (status.isLimited) return AppPermissionResult.limited;
    return AppPermissionResult.denied;
  }
}

이 서비스는 아주 단순하지만 효과가 크다. 화면 코드는 "카메라 권한을 요청한다"라는 의도만 표현하고, 세부 상태 매핑은 서비스가 담당한다. 나중에 Riverpod을 사용한다면 이 클래스를 Provider로 등록해 어디서든 주입받을 수 있다.

final permissionServiceProvider = Provider<PermissionService>((ref) {
  return PermissionService();
});

화면에서는 결과에 따라 사용자에게 다른 안내를 보여 준다.

Future<bool> ensureCameraPermission(
  BuildContext context,
  PermissionService permissionService,
) async {
  final result = await permissionService.request(Permission.camera);

  switch (result) {
    case AppPermissionResult.granted:
      return true;
    case AppPermissionResult.denied:
      _showMessage(context, '사진 촬영을 하려면 카메라 권한이 필요합니다.');
      return false;
    case AppPermissionResult.permanentlyDenied:
      _showSettingsGuide(
        context,
        message: '카메라 권한이 꺼져 있습니다. 설정에서 권한을 허용해 주세요.',
        onOpenSettings: permissionService.openSettings,
      );
      return false;
    case AppPermissionResult.restricted:
      _showMessage(context, '시스템 설정으로 인해 카메라를 사용할 수 없습니다.');
      return false;
    case AppPermissionResult.limited:
      return true;
  }
}

void _showMessage(BuildContext context, String message) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(content: Text(message)),
  );
}

void _showSettingsGuide(
  BuildContext context, {
  required String message,
  required VoidCallback onOpenSettings,
}) {
  showDialog<void>(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('권한이 필요합니다'),
      content: Text(message),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(),
          child: const Text('나중에'),
        ),
        FilledButton(
          onPressed: () {
            Navigator.of(context).pop();
            onOpenSettings();
          },
          child: const Text('설정 열기'),
        ),
      ],
    ),
  );
}

여기서 중요한 점은 denied와 permanentlyDenied를 구분하는 것이다. denied는 다시 요청할 가능성이 있지만, permanentlyDenied는 앱 안에서 같은 권한 팝업을 계속 띄울 수 없다. 이때는 왜 설정으로 가야 하는지 설명하고 openAppSettings()로 앱 설정 화면을 열어 준다.

카메라 촬영과 갤러리 선택 구현하기

사진 기능은 대부분의 앱에서 가장 먼저 만나게 되는 네이티브 기능이다. 프로필 사진, 상품 사진, 게시글 첨부 이미지, 영수증 촬영, 노트 이미지 첨부 등 쓰임새가 넓다.

image_picker의 기본 사용법은 간단하다.

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';

class NoteImagePicker extends StatefulWidget {
  const NoteImagePicker({super.key});

  @override
  State<NoteImagePicker> createState() => _NoteImagePickerState();
}

class _NoteImagePickerState extends State<NoteImagePicker> {
  final _picker = ImagePicker();
  final List<XFile> _images = [];

  Future<void> _takePhoto() async {
    final photo = await _picker.pickImage(
      source: ImageSource.camera,
      imageQuality: 80,
      maxWidth: 1600,
    );

    if (photo == null) return;

    setState(() {
      _images.add(photo);
    });
  }

  Future<void> _pickFromGallery() async {
    final images = await _picker.pickMultiImage(
      imageQuality: 80,
      maxWidth: 1600,
    );

    if (images.isEmpty) return;

    setState(() {
      _images.addAll(images);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Wrap(
          spacing: 8,
          children: [
            FilledButton.icon(
              onPressed: _takePhoto,
              icon: const Icon(Icons.camera_alt),
              label: const Text('사진 촬영'),
            ),
            OutlinedButton.icon(
              onPressed: _pickFromGallery,
              icon: const Icon(Icons.photo_library),
              label: const Text('갤러리 선택'),
            ),
          ],
        ),
        const SizedBox(height: 16),
        GridView.builder(
          shrinkWrap: true,
          physics: const NeverScrollableScrollPhysics(),
          itemCount: _images.length,
          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 3,
            crossAxisSpacing: 8,
            mainAxisSpacing: 8,
          ),
          itemBuilder: (context, index) {
            final image = _images[index];
            return ClipRRect(
              borderRadius: BorderRadius.circular(12),
              child: Image.file(
                File(image.path),
                fit: BoxFit.cover,
              ),
            );
          },
        ),
      ],
    );
  }
}

pickImage(source: ImageSource.camera)는 카메라 앱을 열어 사진을 촬영한다. pickMultiImage()는 여러 장의 이미지를 선택한다. 반환 타입인 XFile은 파일 경로, 이름, 바이트 읽기 같은 기능을 제공하는 파일 표현 객체다.

실제 앱에서는 이 코드 앞에 권한 확인을 붙인다.

Future<void> _takePhotoWithPermission() async {
  final canUseCamera = await ensureCameraPermission(
    context,
    PermissionService(),
  );

  if (!canUseCamera) return;

  final photo = await _picker.pickImage(
    source: ImageSource.camera,
    imageQuality: 80,
    maxWidth: 1600,
  );

  if (photo == null) {
    _showMessage(context, '사진 촬영이 취소되었습니다.');
    return;
  }

  setState(() {
    _images.add(photo);
  });
}

갤러리 선택은 플랫폼별 차이가 조금 더 있다. Android 13 이상에서는 사진 접근 권한이 이미지, 동영상 등으로 세분화되었고, 시스템 Photo Picker를 사용하면 사용자가 직접 고른 미디어에 접근하는 방식으로 동작한다. image_picker 공식 문서도 Android 13 이상에서 Android Photo Picker를 사용한다고 설명한다.2 iOS에서는 사진 보관함 권한이 전체 접근일 수도 있고, 일부 사진만 허용하는 limited 상태일 수도 있다. limited는 실패가 아니다. 사용자가 선택한 일부 사진만 사용할 수 있다는 뜻이다.

따라서 사진 선택 흐름에서는 다음처럼 안내하는 편이 좋다.

Future<void> _pickFromGalleryWithPermission() async {
  final permissionService = PermissionService();
  final result = await permissionService.request(Permission.photos);

  if (result == AppPermissionResult.permanentlyDenied) {
    _showSettingsGuide(
      context,
      message: '사진 접근 권한이 꺼져 있습니다. 설정에서 권한을 변경해 주세요.',
      onOpenSettings: permissionService.openSettings,
    );
    return;
  }

  if (result == AppPermissionResult.denied ||
      result == AppPermissionResult.restricted) {
    _showMessage(context, '사진 없이 노트를 계속 작성할 수 있습니다.');
    return;
  }

  if (result == AppPermissionResult.limited) {
    _showMessage(context, '선택한 사진만 사용할 수 있습니다.');
  }

  final images = await _picker.pickMultiImage(
    imageQuality: 80,
    maxWidth: 1600,
  );

  if (images.isEmpty) return;

  setState(() {
    _images.addAll(images);
  });
}

단, Android Photo Picker를 사용하는 경우에는 별도 사진 권한 요청 없이 선택기가 동작할 수 있다. 앱의 요구사항과 대상 OS에 따라 "직접 권한 요청 후 접근"과 "시스템 선택기를 통해 사용자가 고른 파일만 접근" 중 적절한 방식을 선택하자. 보통 입문 앱이나 콘텐츠 첨부 기능에서는 후자가 더 단순하고 사용자 친화적이다.

image_picker를 사용할 때 한 가지 더 기억해야 할 점이 있다. Android에서 카메라나 갤러리 앱이 열리는 동안 메모리 부족으로 앱의 Activity가 종료될 수 있다. 이 경우 선택 결과가 원래 호출로 돌아오지 않을 수 있으므로, 공식 문서는 앱 시작 시 retrieveLostData()를 호출해 잃어버린 데이터를 복구하는 흐름을 권장한다.2

Future<void> retrieveLostImages() async {
  final picker = ImagePicker();
  final response = await picker.retrieveLostData();

  if (response.isEmpty) return;

  final files = response.files;
  if (files != null && files.isNotEmpty) {
    // 복구한 이미지를 상태나 임시 저장소에 반영한다.
  } else if (response.exception != null) {
    // 오류 로깅 또는 사용자 안내를 처리한다.
  }
}

또한 카메라로 촬영한 이미지가 앱의 임시 캐시에 저장될 수 있다는 점도 중요하다. 오래 보관해야 하는 사진이라면 앱 문서 디렉터리나 Firebase Storage 같은 영구 저장소로 옮기는 로직을 별도로 작성해야 한다.2

현재 위치 가져오기

위치 기능은 지도 앱에만 필요한 것이 아니다. 여행 노트, 맛집 기록, 운동 기록, 출석 체크, 중고 거래, 날씨 앱 등 수많은 앱에서 위치는 중요한 맥락이 된다. 하지만 위치는 매우 민감한 정보다. "왜 위치가 필요한지"를 분명히 설명하고, 필요한 순간에만 가져와야 한다.

geolocator를 사용할 때는 권한보다 먼저 확인할 것이 있다. 바로 기기의 위치 서비스가 켜져 있는지다. 앱 권한이 허용되어도 사용자가 기기 설정에서 위치 서비스를 꺼 두었다면 현재 위치를 가져올 수 없다.

import 'package:geolocator/geolocator.dart';

class LocationService {
  Future<Position> getCurrentPosition() async {
    final serviceEnabled = await Geolocator.isLocationServiceEnabled();
    if (!serviceEnabled) {
      throw LocationException('기기의 위치 서비스가 꺼져 있습니다.');
    }

    var permission = await Geolocator.checkPermission();

    if (permission == LocationPermission.denied) {
      permission = await Geolocator.requestPermission();

      if (permission == LocationPermission.denied) {
        throw LocationException('위치 권한이 거부되었습니다.');
      }
    }

    if (permission == LocationPermission.deniedForever) {
      throw LocationException('위치 권한이 영구적으로 거부되었습니다. 설정에서 변경해 주세요.');
    }

    const settings = LocationSettings(
      accuracy: LocationAccuracy.high,
      timeLimit: Duration(seconds: 10),
    );

    return Geolocator.getCurrentPosition(
      locationSettings: settings,
    );
  }
}

class LocationException implements Exception {
  const LocationException(this.message);

  final String message;

  @override
  String toString() => message;
}

geolocator 공식 예제도 위치 서비스를 먼저 확인한 뒤, 권한이 denied이면 요청하고, deniedForever이면 더 이상 앱 안에서 요청할 수 없으므로 적절히 처리하라고 안내한다.3 이 순서를 지키면 "권한은 허용했는데 위치가 안 나와요" 같은 혼란을 줄일 수 있다.

화면에서는 예외 메시지를 사용자 친화적으로 보여 준다.

Future<void> _attachCurrentLocation() async {
  try {
    final position = await LocationService().getCurrentPosition();

    setState(() {
      _latitude = position.latitude;
      _longitude = position.longitude;
    });

    _showMessage(context, '현재 위치를 노트에 추가했습니다.');
  } on LocationException catch (e) {
    _showMessage(context, e.message);
  } catch (_) {
    _showMessage(context, '현재 위치를 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.');
  }
}

위도와 경도는 그 자체로는 사용자에게 친절하지 않다. "37.5665, 126.9780"보다 "서울특별시 중구"가 훨씬 이해하기 쉽다. 주소 변환이 필요하다면 geocoding 패키지를 함께 사용할 수 있다. 다만 geolocator는 현재 위치를 가져오는 패키지이고, 주소 변환 기능은 별도 패키지로 분리되어 있다는 점을 기억하자.3

실시간 위치 추적이 필요하다면 getPositionStream()을 사용할 수 있다.

final stream = Geolocator.getPositionStream(
  locationSettings: const LocationSettings(
    accuracy: LocationAccuracy.high,
    distanceFilter: 50,
  ),
);

final subscription = stream.listen((position) {
  debugPrint('${position.latitude}, ${position.longitude}');
});

// 화면이 사라질 때
await subscription.cancel();

그러나 노트 앱에서 "현재 위치 한 번 저장" 정도라면 실시간 스트림은 과하다. 위치 스트림은 배터리를 더 많이 쓰고, 사용자가 감시당한다고 느낄 수 있다. 기능의 크기에 맞는 API를 선택하자.

파일 선택과 첨부 처리하기

문서 첨부 기능은 앱을 훨씬 실용적으로 만든다. 회의록에 PDF를 붙이고, 강의 노트에 PPT를 저장하고, 계약 메모에 스캔 파일을 첨부할 수 있다. 이때 file_picker를 사용하면 Android, iOS, 데스크톱, 웹 등 여러 플랫폼에서 운영체제의 기본 파일 선택기를 열 수 있다. 공식 문서에 따르면 file_picker는 단일 또는 다중 파일 선택, 확장자 필터, 클라우드 파일 선택, 바이트 데이터 로드, 저장 대화상자 등을 지원한다.4

가장 단순한 파일 선택 코드는 다음과 같다.

import 'dart:typed_data';

import 'package:file_picker/file_picker.dart';
import 'package:mime/mime.dart';

class PickedAttachment {
  const PickedAttachment({
    required this.name,
    required this.size,
    required this.extension,
    required this.mimeType,
    this.path,
    this.bytes,
  });

  final String name;
  final int size;
  final String? extension;
  final String? mimeType;
  final String? path;
  final Uint8List? bytes;
}

class AttachmentPickerService {
  Future<PickedAttachment?> pickDocument() async {
    final result = await FilePicker.platform.pickFiles(
      type: FileType.custom,
      allowedExtensions: ['pdf', 'doc', 'docx', 'txt', 'png', 'jpg'],
      withData: true,
    );

    if (result == null || result.files.isEmpty) {
      return null;
    }

    final file = result.files.first;
    final mimeType = lookupMimeType(
      file.name,
      headerBytes: file.bytes,
    );

    return PickedAttachment(
      name: file.name,
      size: file.size,
      extension: file.extension,
      mimeType: mimeType,
      path: file.path,
      bytes: file.bytes,
    );
  }
}

PlatformFile에는 파일 이름, 크기, 확장자, 경로, 바이트 데이터 같은 정보가 들어 있다.4 모바일에서는 path를 이용해 File 객체를 만들 수 있는 경우가 많지만, 웹에서는 경로가 없고 bytes를 사용해야 하는 경우가 흔하다. 그래서 업로드 기능을 만들 때는 path 하나에만 의존하지 말고, 플랫폼에 따라 bytes를 사용할 수 있게 설계하는 편이 좋다.

Firebase Storage에 업로드한다면 흐름은 이렇게 이어진다.

Future<void> uploadAttachment(PickedAttachment attachment) async {
  final bytes = attachment.bytes;
  if (bytes == null) {
    throw Exception('파일 데이터를 읽을 수 없습니다.');
  }

  // 예시 경로: users/{uid}/notes/{noteId}/attachments/{fileName}
  // await FirebaseStorage.instance
  //     .ref('users/$uid/notes/$noteId/attachments/${attachment.name}')
  //     .putData(bytes);
}

이 예제에서는 실제 Firebase 호출을 주석으로 남겼다. 핵심은 파일 선택과 업로드를 분리하는 것이다. AttachmentPickerService는 파일을 고르는 일만 담당하고, AttachmentRepository는 업로드와 메타데이터 저장을 담당하게 만들면 구조가 깔끔해진다.

파일 선택에서는 권한 요청이 항상 필요한 것은 아니다. 운영체제의 파일 선택기를 통해 사용자가 직접 고른 파일은 앱이 해당 파일에 제한적으로 접근하는 흐름이기 때문이다. 하지만 앱이 특정 폴더 전체를 스캔하거나, 외부 저장소를 광범위하게 읽고 쓰려면 별도 권한과 플랫폼 정책을 검토해야 한다. 입문 앱에서는 "사용자가 고른 파일만 첨부한다"는 방식이 가장 안전하다.

권한 거부는 실패가 아니라 또 다른 사용자 흐름이다

권한이 거부되면 개발자는 당황하기 쉽다. 하지만 사용자에게 권한을 거부할 권리가 있다는 사실을 앱이 받아들여야 한다. 카메라 권한을 거부한 사용자는 사진 없이 글을 작성할 수 있어야 한다. 위치 권한을 거부한 사용자는 장소를 직접 입력할 수 있어야 한다. 사진 보관함을 제한적으로 허용한 사용자는 선택한 사진만으로 기능을 계속 이용할 수 있어야 한다.

권한 결과에 따른 기본 대응은 다음처럼 정리할 수 있다.

상태의미앱의 대응
granted권한 허용기능 실행
denied거부되었지만 재요청 가능이유를 설명하고 필요 시 다시 요청
permanentlyDenied앱 설정에서 직접 바꿔야 함안내 후 설정 화면 이동 제공
restricted시스템 정책으로 제한됨기능 사용 불가 안내
limited일부 접근만 허용됨제한된 범위에서 기능 제공

여기서 가장 나쁜 대응은 아무 설명 없이 버튼이 동작하지 않는 것이다. 사용자는 앱이 고장 났다고 느낀다. 그다음으로 나쁜 대응은 거부한 사용자에게 계속 권한 팝업을 요구하는 것이다. 사용자는 앱이 무례하다고 느낀다.

좋은 앱은 짧고 명확하게 말한다.

"사진을 첨부하려면 사진 접근 권한이 필요합니다. 권한 없이도 노트는 저장할 수 있습니다."

"현재 위치를 가져오지 못했습니다. 위치 서비스를 켜거나 장소를 직접 입력해 주세요."

"카메라 권한이 꺼져 있습니다. 설정에서 권한을 허용하면 사진을 촬영할 수 있습니다."

문구 하나가 사용자 경험을 바꾼다.

앱 구조 안에 네이티브 기능 넣기

기기 기능 코드는 UI에 바로 넣기 쉽다. 버튼을 누르면 권한을 요청하고, 사진을 고르고, 위치를 가져오고, 파일을 업로드한다. 작은 예제에서는 괜찮지만, 실전 앱에서는 금방 복잡해진다.

유지보수 가능한 구조에서는 네이티브 기능을 다음처럼 나눌 수 있다.

lib/
  core/
    permissions/
      permission_service.dart
      app_permission_result.dart
  features/
    notes/
      data/
        attachment_repository.dart
      application/
        note_editor_controller.dart
      presentation/
        note_editor_page.dart
      services/
        image_picker_service.dart
        location_service.dart
        attachment_picker_service.dart

presentation 계층의 화면은 버튼과 안내 메시지를 담당한다. application 계층의 컨트롤러는 "이미지 첨부", "현재 위치 추가", "파일 업로드" 같은 사용 사례를 조합한다. services는 image_picker, geolocator, file_picker 같은 플러그인을 감싼다. data 계층의 repository는 Firebase Storage나 Firestore와 연결한다.

예를 들어 노트 작성 컨트롤러는 다음처럼 의도를 표현할 수 있다.

class NoteEditorController {
  NoteEditorController({
    required this.imagePickerService,
    required this.locationService,
    required this.attachmentPickerService,
  });

  final ImagePickerService imagePickerService;
  final LocationService locationService;
  final AttachmentPickerService attachmentPickerService;

  Future<void> attachPhotoFromCamera() async {
    final image = await imagePickerService.takePhoto();
    if (image == null) return;

    // 상태에 이미지 추가 또는 업로드 예약
  }

  Future<void> attachCurrentLocation() async {
    final position = await locationService.getCurrentPosition();

    // 상태에 위도/경도 추가
  }

  Future<void> attachDocument() async {
    final attachment = await attachmentPickerService.pickDocument();
    if (attachment == null) return;

    // 상태에 첨부 파일 추가 또는 업로드 예약
  }
}

이렇게 하면 화면은 단순해지고, 테스트도 쉬워진다. 나중에 image_picker 대신 camera 패키지로 직접 카메라 프리뷰를 만들고 싶어져도 화면 전체를 갈아엎을 필요가 없다. 서비스 구현만 바꾸면 된다.

image_picker와 camera 패키지 중 무엇을 쓸까

사진 기능을 만들 때 자주 나오는 질문이 있다. image_picker를 쓸까, camera 패키지를 쓸까?

image_picker는 간단한 촬영과 갤러리 선택에 적합하다. 사용자가 버튼을 누르면 시스템 카메라 또는 사진 선택 화면이 열리고, 결과 파일만 앱으로 돌아온다. 프로필 사진, 노트 이미지 첨부, 리뷰 사진 등록처럼 "사진을 얻는 것"이 목적이라면 대부분 image_picker로 충분하다.

반면 camera 패키지는 앱 안에 직접 카메라 프리뷰를 띄우고, 줌, 플래시, 렌즈 전환, 연속 촬영, 커스텀 촬영 UI 등을 더 세밀하게 제어할 때 사용한다. QR 스캐너, 문서 스캐너, 필터 카메라, 영상 녹화 앱처럼 "카메라 경험 자체"가 핵심인 앱이라면 camera 패키지가 더 어울린다.

이 책의 최종 프로젝트인 개인 지식 노트 앱에서는 사진 첨부가 목적이므로 image_picker가 적절하다. 나중에 영수증 자동 인식이나 문서 스캔 기능을 붙인다면 그때 camera 패키지로 확장하는 전략이 좋다.

실전 체크리스트

기기 기능이 동작하지 않을 때는 Dart 코드만 보지 말고 플랫폼 설정과 사용자 흐름을 함께 확인해야 한다. 다음 체크리스트를 습관처럼 점검하자.

  • AndroidManifest.xml에 필요한 uses-permission이 있는가?

  • iOS Info.plist에 NSCameraUsageDescription, NSPhotoLibraryUsageDescription, NSLocationWhenInUseUsageDescription 같은 사용 목적 문구가 있는가?

  • iOS에서 permission_handler 사용 권한 매크로와 Info.plist 문구가 맞게 정리되어 있는가?

  • 권한을 앱 시작 시 한꺼번에 요청하지 않고, 사용자가 기능을 실행하는 순간 요청하는가?

  • denied, permanentlyDenied, restricted, limited 상태를 구분해 처리하는가?

  • 권한이 거부되어도 대체 흐름을 제공하는가?

  • 위치를 가져오기 전에 위치 서비스 활성화 여부를 확인하는가?

  • Android 13 이상 사진 접근 방식과 iOS limited 사진 접근을 고려했는가?

  • 파일 선택에서 모바일 경로와 웹 바이트 데이터 차이를 고려했는가?

  • 카메라 촬영 결과가 임시 캐시에 저장될 수 있음을 알고 영구 저장 로직을 분리했는가?

기기 기능은 처음에는 복잡해 보인다. 하지만 본질은 단순하다. 사용자가 어떤 행동을 했고, 그 행동에 어떤 권한이 필요하며, 권한이 허용되거나 거부되었을 때 앱이 어떻게 반응할지를 설계하는 것이다.

Flutter는 화면을 빠르게 만드는 도구이지만, 좋은 앱은 화면 안에만 머물지 않는다. 카메라로 현실을 담고, 위치로 맥락을 붙이고, 파일로 자료를 연결할 때 앱은 사용자의 일상 속으로 들어간다. 이제 여러분의 앱은 더 이상 버튼과 리스트만 있는 예제가 아니다. 사용자의 기기와 대화하는 진짜 모바일 앱에 가까워졌다.

참고

1permission_handler 패키지 문서

2image_picker 패키지 문서

3geolocator 패키지 문서

4file_picker 패키지 문서

책 목록

처음부터 배포까지 Flutter 앱 개발 입문

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기