[Flutter 공부] Flutter 멀티 플랫폼 고려사항 - iOS와 Android 플랫폼별 차이점

반응형

 

'Write once, run anywhere'는 환상이다. Flutter로 앱을 개발하면서 플랫폼 간 차이점을 무시했다가 출시 직전에 대규모 코드 수정한 경험은 누구나 한 번쯤 있을 것이다.

6년차 Flutter 개발자로, 멀티 플랫폼 앱 개발에 관한 글을 또 쓰게 됐네요. 지겨운 주제지만 아직도 많은 개발자들이 Flutter 프로젝트를 시작할 때 이 함정에 빠집니다. "Flutter를 쓰면 코드 한 번만 작성하면 된다"는 말에 현혹되어 플랫폼별 차이점을 고려하지 않다가 나중에 고생하는 경우가 허다합니다. 특히 최근 진행한 대형 금융 앱에서도 플랫폼 간 차이를 간과해서 출시 일정이 미뤄진 경험이 있는데, 이런 시행착오를 여러분은 겪지 않았으면 하는 마음에 글을 씁니다.

플랫폼 감지 및 분기 처리 전략

Flutter의 기본 철학은 '단일 코드베이스'지만, 플랫폼 간 차이를 무시하면 수많은 문제에 직면하게 된다. 플랫폼 감지는 필수적인 첫 단계다. 단순히 iOS와 Android를 구분하는 것만으로는 부족하며, 더 세분화된 플랫폼 특성까지 고려해야 한다.

가장 기본적인 플랫폼 감지 방법은 Platform.isIOSPlatform.isAndroid를 사용하는 것이다. 하지만 이 방식은 코드 전체에 if-else 문을 남발하게 만든다. 더 효율적인 접근 방식은 추상화 레이어를 만드는 것이다.

import 'dart:io' show Platform;

abstract class PlatformService {
  void showAlert(String message);
  Future<bool> requestPermission(String permission);
  String get platformSpecificPath;
}

class IOSPlatformService implements PlatformService {
  @override
  void showAlert(String message) {
    // iOS 스타일 알림
  }
  
  @override
  Future<bool> requestPermission(String permission) {
    // iOS 권한 요청 로직
  }
  
  @override
  String get platformSpecificPath => 'ios/specific/path';
}

class AndroidPlatformService implements PlatformService {
  @override
  void showAlert(String message) {
    // Android 스타일 알림
  }
  
  @override
  Future<bool> requestPermission(String permission) {
    // Android 권한 요청 로직
  }
  
  @override
  String get platformSpecificPath => 'android/specific/path';
}

// 팩토리 메서드로 적절한 구현체 제공
PlatformService getPlatformService() {
  if (Platform.isIOS) {
    return IOSPlatformService();
  } else if (Platform.isAndroid) {
    return AndroidPlatformService();
  } else {
    throw UnsupportedError('Unsupported platform');
  }
}

이런 추상화 방식은 확장성과 유지보수성이 뛰어나다. 하지만 모든 상황에 이런 복잡한 패턴을 적용할 필요는 없다. 단순한 UI 차이만 있다면 삼항 연산자로 충분하다.

UI 및 디자인 시스템 차이점

Flutter는 Material과 Cupertino 두 가지 디자인 시스템을 제공한다. 그러나 단순히 iOS에는 Cupertino, Android에는 Material을 적용하는 접근법은 실제 앱 개발에서 한계가 많다. 각 플랫폼의 네이티브 디자인을 따르면서도 앱의 일관성을 유지하려면 더 세심한 전략이 필요하다.

아래 표는 iOS와 Android 간 주요 UI 컴포넌트 차이와 Flutter에서 이를 해결하는 방법을 보여준다.

UI 요소 iOS (Cupertino) Android (Material) Flutter 해결책
내비게이션 바 CupertinoNavigationBar AppBar 플랫폼별 위젯 선택 또는 커스텀 위젯
액션 시트 CupertinoActionSheet BottomSheet 조건부 UI 렌더링
알림 다이얼로그 CupertinoAlertDialog AlertDialog Platform.isIOS ? showCupertinoDialog : showDialog
스위치 CupertinoSwitch Switch 플랫폼 감지 후 적절한 위젯 사용
탭 바 CupertinoTabBar BottomNavigationBar 플랫폼 감지 후 적절한 구현체 선택
데이트 피커 CupertinoDatePicker showDatePicker 플랫폼 감지 후 적절한 API 호출

테이블에서 볼 수 있듯이, 거의 모든 UI 요소에 플랫폼별 차이가 존재한다. 이런 차이를 관리하지 않으면 한 플랫폼에서는 자연스럽지만 다른 플랫폼에서는 어색한 UI가 만들어진다. 특히 iOS 사용자는 Material 디자인을 낯설게 느끼는 경우가 많다.

권한 처리 메커니즘 차이

권한 처리는 iOS와 Android 간에 가장 큰 차이를 보이는 영역 중 하나다. Flutter 개발자들이 흔히 저지르는 실수는 권한 처리를 단순히 플러그인에 맡기고 플랫폼별 특성을 고려하지 않는 것이다. 권한 요청 시기, 요청 방식, 권한 거부 처리 등은 플랫폼마다 다르게 접근해야 한다.

다음은 iOS와 Android 간 권한 처리 차이점과 Flutter에서 이를 다루는 방법이다:

  1. 권한 요청 시점
    • iOS: 첫 사용 시 권한 요청이 표준이며, 권한 거부 시 재요청이 어려움
    • Android: 런타임 권한 요청이 필요하며, 거부 시 다시 요청 가능
    • Flutter 접근법: 권한이 필요한 기능 사용 직전에 요청하되, 플랫폼별 로직 분리 필요
  2. 권한 설명 방식
    • iOS: Info.plist에 NSUsageDescription 키 필수, 상세한 설명 필요
    • Android: AndroidManifest.xml에 권한 등록, 필요시 상세 설명 다이얼로그 직접 구현
    • Flutter 접근법: 플랫폼별 설정 파일과 코드 모두에서 권한 설명 관리
  3. 권한 거부 처리
    • iOS: 앱 설정으로 이동하여 권한 변경 유도 필요
    • Android: 거부 이유 설명 후 재요청 가능, "다시 묻지 않음" 선택 시 설정으로 이동
    • Flutter 접근법: 플랫폼 감지 후 적절한 fallback UI 제공
  4. 백그라운드 권한
    • iOS: 백그라운드 모드별 특정 설정 필요, 제한적 기능
    • Android: 백그라운드 서비스, 작업 스케줄링 등 다양한 옵션
    • Flutter 접근법: 플랫폼 채널 또는 플러그인을 통한 네이티브 구현 연동
📝 TIP: 권한 처리 자동화

여러 권한을 처리해야 하는 복잡한 앱의 경우, 권한 관리 레이어를 만들어 관리하는 것이 효율적입니다. permission_handler 패키지를 사용하더라도, 래퍼 클래스를 만들어 앱 전체에서 일관된 방식으로 권한을 처리하세요. 특히 거부된 권한에 대한 fallback 처리 로직은 재사용 가능하게 구현하는 것이 좋습니다.

iOS와 Android 모두에서 권한 상태를 추적하고, 영구 거부된 권한에 대해서는 사용자에게 적절한 안내와 함께 앱 설정으로 이동할 수 있는 옵션을 제공하세요. 이는 앱 스토어 심사에서도 중요한 요소입니다.

플랫폼별 네이티브 기능 활용

Flutter의 크로스 플랫폼 특성에도 불구하고, 일부 기능은 플랫폼별 네이티브 구현이 필요하다. Flutter의 Method Channel, Event Channel 등을 통해 네이티브 코드와 통신할 수 있지만, 이 과정은 생각보다 복잡하다. 특히 양쪽 플랫폼에서 같은 기능을 구현하는 방식이 다르다는 점을 명심해야 한다.

네이티브 기능 활용 시 발생하는 주요 문제는 플랫폼별 API 차이를 추상화하는 것이다. 공통 인터페이스를 정의하고 플랫폼별로 구현체를 만들어야 한다. 아래 예제는 푸시 알림을 처리하는 플랫폼 채널 구현을 보여준다.

// Dart 코드: 공통 인터페이스 정의
const platform = MethodChannel('com.example.app/notifications');

// 메서드 호출
Future<void> registerForPushNotifications() async {
  try {
    await platform.invokeMethod('registerForPushNotifications');
  } on PlatformException catch (e) {
    // 플랫폼별 오류 처리
    print("Failed to register: ${e.message}");
  }
}

// iOS 구현 (Swift)
// AppDelegate.swift에 추가
@objc func registerForPushNotifications(_ call: FlutterMethodCall, 
                                      result: @escaping FlutterResult) {
  UNUserNotificationCenter.current().requestAuthorization(
    options: [.alert, .sound, .badge]) { granted, error in
    // iOS 특화 구현
    result(granted)
  }
}

// Android 구현 (Kotlin)
// MainActivity.kt에 추가
private fun registerForPushNotifications(call: MethodCall, result: Result) {
  // Android 특화 구현
  // 알림 채널 생성 등
  NotificationManagerCompat.from(context).createNotificationChannel(channel)
  result.success(true)
}

네이티브 기능을 구현할 때 주의해야 할 또 다른 점은 플랫폼별 종속성 관리다. Android에서는 Gradle 파일, iOS에서는 Podfile을 통해 의존성을 관리하게 된다. 두 플랫폼의 빌드 시스템이 다르기 때문에 이 과정에서도 별도의 관리가 필요하다.

일반적으로 네이티브 기능이 많이 필요한 앱의 경우, 각 플랫폼별로 전문 개발자가 함께 작업하는 것이 좋다. 그렇지 않으면 개발 속도가 현저히 떨어질 수 있다.

플랫폼별 테스팅 전략

Flutter 앱 테스트는 단순히 위젯 테스트만으로는 불충분하다. 특히 플랫폼별 차이가 있는 부분은 각 플랫폼에서 별도로 테스트해야 한다. 효율적인 테스트 전략을 세우지 않으면 플랫폼 특화 버그를 놓치기 쉽다.

다음 테이블은 플랫폼별 테스트 전략과 각 테스트 유형의 중요성을 보여준다:

테스트 유형 iOS 중요도 Android 중요도 특이사항
단위 테스트(Unit Test) 높음 높음 플랫폼 독립적 로직에 집중
위젯 테스트(Widget Test) 중간 중간 플랫폼별 위젯 렌더링 차이 감지 어려움
통합 테스트(Integration Test) 높음 높음 플랫폼별로 별도 실행 필수
네이티브 브릿지 테스트 매우 높음 매우 높음 플랫폼별 네이티브 코드 별도 테스트
UI 자동화 테스트 높음 높음 XCTest(iOS), Espresso(Android) 활용
디바이스 호환성 테스트 중간 매우 높음 Android 파편화 문제로 더 중요
성능 테스트 높음 높음 저사양 Android 기기에서 특히 중요

효율적인 테스트 전략을 위해서는 CI/CD 파이프라인에 양쪽 플랫폼 테스트를 자동화하는 것이 중요하다. Firebase Test Lab, AWS Device Farm 같은 서비스를 활용하면 다양한 실제 기기에서 테스트할 수 있다.

방법론적으로 BDD(Behavior-Driven Development) 접근 방식이 효과적이다. 기능을 명세하고 검증하는 과정에서 플랫폼별 차이점을 명확히 문서화할 수 있기 때문이다.

배포 및 스토어 등록 차이점

앱 개발 마지막 단계에서 만나게 되는 가장 큰 차이점은 배포 프로세스다. App Store와 Google Play 스토어는 각각 다른 정책과 요구사항을 가지고 있으며, 심사 과정과 시간도 상당히 다르다. 이 차이를 이해하지 못하면 출시 일정에 큰 차질이 생길 수 있다.

다음은 iOS와 Android 배포 과정의 주요 차이점이다:

  1. 서명 및 인증서
    • iOS: Apple Developer Program 가입 필수, 프로비저닝 프로필 및 인증서 관리가 복잡
    • Android: 키스토어 파일 관리, 상대적으로 단순한 서명 과정
    • Flutter 접근법: fastlane 같은 도구로 서명 프로세스 자동화 권장
  2. 앱 번들 관리
    • iOS: IPA 파일, App Store Connect를 통한 업로드, App Thinning 지원
    • Android: AAB(Android App Bundle) 권장, APK도 지원, 다양한 ABI 지원
    • Flutter 접근법: flutter build ios/apk/appbundle 명령어로 빌드
  3. 심사 과정
    • iOS: 수동 심사, 평균 1-2일 소요, 엄격한 UI/UX 가이드라인 준수 필요
    • Android: 자동화된 심사, 몇 시간 내 완료, 정책 위반 시 사후 제재
    • Flutter 접근법: iOS 심사 기간을 고려한 출시 일정 계획
  4. 버전 관리 및 업데이트
    • iOS: 모든 업데이트 심사 필요, 심사 없는 빠른 수정(hotfix) 제한적
    • Android: 단계적 출시 지원, 즉시 업데이트 가능, 알파/베타 채널 활용
    • Flutter 접근법: 원격 구성으로 일부 기능 동적 관리
  5. 스토어 메타데이터
    • iOS: 스크린샷 요구사항 엄격, 앱 미리보기 동영상 지원, 앱 이벤트 기능
    • Android: 다양한 기기별 스크린샷 지원, A/B 테스트 가능
    • Flutter 접근법: 스토어 메타데이터 관리 도구(예: fastlane deliver, supply) 활용
📝 TIP: CI/CD 자동화 구축

Flutter 앱의 양쪽 플랫폼 배포 프로세스를 자동화하는 것은 시간과 오류를 크게 줄여줍니다. Codemagic, Bitrise 같은 Flutter 특화 CI/CD 서비스나 GitHub Actions, GitLab CI/CD를 활용하여 빌드, 테스트, 배포를 자동화하세요.

특히 fastlane을 함께 사용하면 인증서 관리, 스크린샷 생성, 메타데이터 관리, 스토어 업로드까지 모두 자동화할 수 있습니다. iOS 배포의 복잡한 과정도 스크립트로 해결할 수 있어 휴먼 에러를 크게 줄일 수 있습니다.

CI/CD 파이프라인 구성 시 양쪽 플랫폼의 빌드 환경(Xcode 버전, Android SDK 버전 등)을 명확히 지정하고, 환경변수를 통해 API 키, 인증서 등 민감한 정보를 안전하게 관리하세요.

📝 TIP: 플랫폼별 코드 구성 전략

Flutter 프로젝트가 커질수록 플랫폼별 코드 관리는 더 복잡해집니다. 대규모 프로젝트에서는 다음과 같은 디렉토리 구조가 효과적입니다.

심플한 if-else 조건문이 프로젝트 전체에 산재하게 되면 유지보수가 어려워집니다. 대신, 기능별로 플랫폼 코드를 격리하고 추상화하는 것이 좋습니다. 특히 '기능 우선' 접근법으로 폴더를 구성하면 플랫폼 특화 코드를 더 효율적으로 관리할 수 있습니다.

초기에는 번거롭게 느껴질 수 있지만, 소스 코드에 Platform.isIOS 조건이 15개 이상 등장한다면 추상화를 고려해야 합니다. 플러그인이나 라이브러리에 과도하게 의존하기보다 직접 플랫폼 특화 코드를 작성하고 관리하는 방식이 장기적으로 더 안정적입니다.

플랫폼별 아키텍처 예제 코드

다음은 플랫폼 차이를 효과적으로 관리하기 위한 아키텍처 패턴 예제다. 간단한 위치 서비스를 구현한 사례를 통해 플랫폼별 코드를 어떻게 구조화할 수 있는지 살펴보자.

// lib/services/location/location_service.dart
import 'package:flutter/foundation.dart';
import 'location_service_interface.dart';
import 'location_service_stub.dart'
    if (dart.library.io) 'platform/location_service_mobile.dart'
    if (dart.library.html) 'platform/location_service_web.dart';

/// 위치 서비스 팩토리
class LocationService {
  static LocationServiceInterface instance = createLocationService();
}

// lib/services/location/location_service_interface.dart
abstract class LocationServiceInterface {
  Future<Position> getCurrentPosition();
  Stream<Position> getPositionStream();
  Future<bool> requestPermission();
  Future<bool> isLocationServiceEnabled();
}

// lib/services/location/platform/location_service_mobile.dart
import 'dart:io';
import '../location_service_interface.dart';

LocationServiceInterface createLocationService() {
  if (Platform.isIOS) {
    return IOSLocationService();
  } else if (Platform.isAndroid) {
    return AndroidLocationService();
  }
  return DefaultLocationService();
}

class IOSLocationService implements LocationServiceInterface {
  @override
  Future<Position> getCurrentPosition() async {
    // iOS 특화 구현
    final authStatus = await _checkPermission();
    if (authStatus == LocationAuthorizationStatus.denied) {
      throw PermissionDeniedException('위치 권한이 거부되었습니다.');
    }
    
    // iOS는 정확도 옵션이 다름
    return await Geolocator.getCurrentPosition(
      desiredAccuracy: LocationAccuracy.best,
      timeLimit: Duration(seconds: 5),
    );
  }
  
  @override
  Future<bool> requestPermission() async {
    // iOS는 사용 중일 때만/항상 옵션이 있음
    final status = await Geolocator.requestPermission();
    return status == LocationPermission.whileInUse || 
           status == LocationPermission.always;
  }
  
  // 나머지 메서드 구현...
}

class AndroidLocationService implements LocationServiceInterface {
  @override
  Future<Position> getCurrentPosition() async {
    // Android 특화 구현
    final enabled = await isLocationServiceEnabled();
    if (!enabled) {
      // Android는 위치 서비스 활성화 다이얼로그 표시 가능
      final enabledByUser = await _promptUserToEnableLocationService();
      if (!enabledByUser) {
        throw LocationServiceDisabledException('위치 서비스가 비활성화되었습니다.');
      }
    }
    
    // Android는 세분화된 권한 체크 필요
    final hasPermission = await _checkFineLocationPermission();
    if (!hasPermission) {
      final granted = await requestPermission();
      if (!granted) {
        throw PermissionDeniedException('정밀 위치 권한이 거부되었습니다.');
      }
    }
    
    return await Geolocator.getCurrentPosition(
      desiredAccuracy: LocationAccuracy.high,
      forceAndroidLocationManager: true,  // Android 특화 옵션
    );
  }
  
  @override
  Future<bool> requestPermission() async {
    // Android 13+는 정밀/대략적 위치 권한 구분
    if (await _isAndroid13OrHigher()) {
      return await _requestFineAndCoarseLocation();
    } else {
      final status = await Geolocator.requestPermission();
      return status == LocationPermission.whileInUse || 
             status == LocationPermission.always;
    }
  }
  
  // 나머지 메서드 구현...
}

위 코드는 조건부 임포트와 인터페이스를 활용한 플랫폼 추상화 패턴이다. 이 구조의 장점은,

  • 비즈니스 로직에서 플랫폼 감지 코드 제거
  • DI(의존성 주입) 패턴으로 테스트 용이성 향상
  • 새로운 플랫폼(웹, 데스크톱 등) 추가가 용이
  • 플랫폼별 특화 코드의 명확한 구분

UI 컴포넌트에서도 비슷한 패턴을 적용할 수 있다. 다음은 플랫폼별 Alert 대화상자를 구현한 예제다.

// lib/widgets/platform_alert.dart
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'dart:io' show Platform;

class PlatformAlert {
  static Future<bool?> showAlert({
    required BuildContext context,
    required String title,
    required String message,
    String? confirmText,
    String? cancelText,
  }) async {
    if (Platform.isIOS) {
      return await showCupertinoDialog<bool>(
        context: context,
        builder: (context) => CupertinoAlertDialog(
          title: Text(title),
          content: Text(message),
          actions: _buildActions(
            context, 
            confirmText ?? '확인', 
            cancelText,
          ),
        ),
      );
    } else {
      return await showDialog<bool>(
        context: context,
        builder: (context) => AlertDialog(
          title: Text(title),
          content: Text(message),
          actions: _buildActions(
            context, 
            confirmText ?? '확인', 
            cancelText,
          ),
        ),
      );
    }
  }
  
  static List<Widget> _buildActions(
    BuildContext context, 
    String confirmText, 
    String? cancelText,
  ) {
    final actions = <Widget>[];
    
    if (cancelText != null) {
      if (Platform.isIOS) {
        actions.add(
          CupertinoDialogAction(
            onPressed: () => Navigator.of(context).pop(false),
            isDestructiveAction: true,
            child: Text(cancelText),
          ),
        );
      } else {
        actions.add(
          TextButton(
            onPressed: () => Navigator.of(context).pop(false),
            child: Text(cancelText),
          ),
        );
      }
    }
    
    if (Platform.isIOS) {
      actions.add(
        CupertinoDialogAction(
          onPressed: () => Navigator.of(context).pop(true),
          isDefaultAction: true,
          child: Text(confirmText),
        ),
      );
    } else {
      actions.add(
        TextButton(
          onPressed: () => Navigator.of(context).pop(true),
          child: Text(confirmText),
        ),
      );
    }
    
    return actions;
  }
}

// 사용 예:
// await PlatformAlert.showAlert(
//   context: context,
//   title: '알림',
//   message: '변경사항을 저장하시겠습니까?',
//   confirmText: '저장',
//   cancelText: '취소',
// );

물론 Flutter의 adaptive_dialog, flutter_platform_widgets 같은 패키지를 사용할 수도 있다. 하지만 직접 구현하면 세부적인 제어와 커스터마이징이 훨씬 자유롭다. 특히 디자인 시스템이 복잡하거나 브랜드 아이덴티티가 중요한 앱에서는 자체 구현이 더 적합하다.

이런 패턴을 앱 전체로 확장하면 플랫폼별 차이를 효과적으로 관리하면서도 코드 중복을 최소화할 수 있다. 추상화 레이어를 통해 비즈니스 로직과 UI 로직이 플랫폼 특화 구현에 의존하지 않게 되므로, 앱의 유지보수성과 확장성이 크게 향상된다.

마무리

Flutter로 멀티 플랫폼 앱을 개발하는 것은 분명 생산성을 높여주지만, 플랫폼 차이를 무시하면 나중에 더 큰 비용을 치르게 된다. 실제 프로덕션 앱에서는 플랫폼별 차이점을 명확히 이해하고 체계적으로 관리하는 것이 중요하다. 여기서 다룬 내용은 빙산의 일각에 불과하며, 실제 개발 과정에서는 더 다양한 차이점과 도전 과제를 만나게 될 것이다.

내 경험에 비추어 볼 때, 플랫폼 차이는 초기 설계 단계부터 고려해야 한다. 기능 구현 중간에 플랫폼 특화 코드를 급히 추가하는 방식은 지양하고, 처음부터 플랫폼별 동작 차이를 예상하고 인터페이스로 추상화하는 습관을 들이자. 이렇게 코드베이스를 구성하면 플랫폼 특화 문제가 발생했을 때 전체 구조를 뒤흔들지 않고 해결할 수 있다.

결국 Flutter의 진정한 가치는 "한 번 작성으로 모든 플랫폼에서 동일하게 작동"이 아니라, "각 플랫폼의 특성을 살리면서도 코드 공유율을 극대화"하는 데 있다. 이런 관점에서 접근하면 Flutter는 정말 강력한 도구가 된다. 코드 중복을 최소화하면서도 각 플랫폼에 최적화된 사용자 경험을 제공할 수 있기 때문이다.

Designed by JB FACTORY