Flutter는 뛰어난 크로스플랫폼 프레임워크이지만, 때로는 네이티브 플랫폼의 고유한 기능을 깊이 있게 활용해야 할 때가 있습니다. 특히 국내 사용자들에게 익숙한 카카오맵이나 네이버지도 API를 연동하는 경우는 더욱 그렇습니다. 기존에 잘 만들어진 pub.dev 패키지를 사용하는 것도 좋은 방법이지만, 특정 기능을 커스터마이징하거나, 네이티브 코드에 대한 이해를 높이고 싶거나, 혹은 원하는 기능의 패키지가 없을 때는 직접 네이ティブ 연동을 구현해야 합니다. 이 글에서는 Flutter의 핵심 기능 중 하나인 MethodChannel과 PlatformView를 사용하여 네이티브 지도 SDK를 직접 연동하는 전체 과정을 상세하게 다룹니다.
단순히 '연결'하는 것을 넘어, 데이터가 어떤 원리로 Dart와 네이티브 코드(Kotlin/Swift) 사이를 오가는지, UI는 어떻게 그려지는지, 그리고 실제 프로덕션 환경에서 발생할 수 있는 문제들을 어떻게 처리하는지에 대한 깊이 있는 이해를 목표로 합니다. 이 과정을 통해 여러분은 지도 연동뿐만 아니라 어떤 네이티브 기능이든 Flutter 앱에 통합할 수 있는 강력한 무기를 얻게 될 것입니다.
1. 왜 네이티브 지도를 직접 연동해야 하는가?
Flutter 생태계에는 google_maps_flutter
와 같은 훌륭한 지도 패키지가 존재합니다. 그럼에도 불구하고 카카오맵이나 네이버지도 SDK를 직접 연동해야 하는 이유는 명확합니다.
- 국내 환경 최적화: 카카오맵과 네이버지도는 국내 지리 정보, 대중교통 데이터, 상호명 검색 등에서 월등한 정확성과 풍부함을 자랑합니다. 국내 사용자를 타겟으로 하는 서비스라면 이는 선택이 아닌 필수입니다.
- 고유 기능 활용: 실시간 길찾기 API, 로드뷰/거리뷰, 특정 장소에 대한 상세 정보 등 각 지도 SDK가 제공하는 고유하고 강력한 기능들을 Flutter에서 직접 제어하고 싶을 때 필요합니다.
- 커스터마이징의 자유: 기존 패키지가 제공하지 않는 UI/UX(마커, 정보창, 컨트롤러 등)를 기획에 맞게 완전히 새롭게 구현하고 싶을 때, 네이티브 코드 레벨에서의 직접 제어가 유일한 해결책입니다.
- 플랫폼 채널에 대한 학습: MethodChannel의 작동 원리를 이해하는 것은 Flutter 개발자로서 한 단계 성장하는 중요한 과정입니다. 지도 연동은 카메라, GPS, 결제 모듈 등 다른 네이티브 기능을 연동할 때도 동일하게 적용될 수 있는 핵심 기술입니다.
이 글에서는 '카카오맵 SDK'를 기준으로 안드로이드와 iOS 플랫폼에 연동하는 과정을 상세히 설명합니다. 네이버지도 역시 원리는 동일하므로 이 내용을 응용하여 충분히 구현할 수 있습니다.
2. 핵심 개념: Platform Channels와 Platform Views
본격적인 구현에 앞서, Flutter가 네이티브 플랫폼과 어떻게 소통하고 UI를 렌더링하는지에 대한 두 가지 핵심 개념을 반드시 이해해야 합니다.
2.1. Platform Channels: Dart와 Native의 통신 다리
Flutter 앱의 Dart 코드는 분리된 프로세스에서 실행됩니다. 네이티브 코드(Android의 Kotlin/Java, iOS의 Swift/Objective-C)와 직접적으로 메모리를 공유하거나 함수를 호출할 수 없습니다. 이 둘 사이의 통신을 위해 Flutter는 '플랫폼 채널(Platform Channels)'이라는 비동기 메시징 메커니즘을 제공합니다.
플랫폼 채널은 크게 세 종류가 있습니다.
- MethodChannel: 가장 일반적으로 사용되는 채널입니다. Dart에서 네이티브의 특정 함수(메서드)를 호출하고, 그 결과를 비동기적으로 받아오는 일회성 통신에 적합합니다. 예를 들어 '현재 위치 가져오기', '마커 추가하기'와 같은 명령에 사용됩니다.
- EventChannel: 네이티브에서 Dart로 지속적인 데이터 스트림을 보낼 때 사용됩니다. GPS 위치 정보의 연속적인 업데이트, 센서 데이터의 변화, 다운로드 진행 상태 알림 등에 적합합니다.
- BasicMessageChannel: 문자열이나 반구조화된 데이터를 지속적으로 주고받을 때 사용되며, 코덱을 직접 지정하여 유연한 통신이 가능합니다.
이번 예제에서는 Flutter UI의 이벤트에 따라 네이티브 지도를 제어하는 경우가 대부분이므로, MethodChannel을 중심으로 사용하게 됩니다.
Dart 코드의 MethodChannel.invokeMethod()
호출은 Flutter 엔진을 통해 플랫폼 채널로 전달되고, 해당 채널을 수신 대기하고 있는 네이티브 측의 핸들러(Handler)를 깨웁니다. 네이티브 코드는 요청받은 작업을 수행한 후, 결과를 다시 채널을 통해 Dart로 돌려보냅니다. 이 모든 과정은 비동기적으로 처리되어 앱의 UI가 멈추는 현상(Jank)을 방지합니다.
2.2. Platform Views: 네이티브 UI를 Flutter 위젯 트리에 임베딩
MethodChannel이 데이터 통신을 담당한다면, Platform Views는 네이티브 UI 컴포넌트 자체를 Flutter 위젯 트리에 통합하는 기술입니다. 지도, 웹뷰, AR 뷰어와 같이 Flutter로 직접 구현하기 어렵거나 불가능한 네이티브 UI를 그대로 가져와 쓸 수 있게 해줍니다.
동작 방식은 플랫폼별로 약간의 차이가 있습니다.
- Android:
AndroidView
위젯을 사용합니다. Flutter는 네이티브View
를 렌더링하기 위한 별도의 가상 디스플레이(Virtual Display)를 생성하고, 이를 텍스처로 변환하여 Flutter UI에 합성합니다. 이 방식은 호환성이 높지만 약간의 성능 오버헤드가 있을 수 있습니다. (Hybrid Composition 모드도 존재하며, 이는 접근성이나 키보드 입력 등에서 이점을 가집니다.) - iOS:
UiKitView
위젯을 사용합니다. 별도의UIView
를 생성하고 Flutter의 렌더링 레이어 위에 직접 배치합니다. 가상 디스플레이 방식보다 일반적으로 성능이 우수합니다.
지도 연동에서는 MethodChannel
로 지도의 상태(카메라 위치, 마커 등)를 제어하고, PlatformView
를 통해 실제 렌더링된 지도 화면을 사용자에게 보여주는 구조를 가지게 됩니다.
3. 프로젝트 준비 및 사전 설정
본격적인 코드 작성에 앞서, 개발 환경과 카카오맵 SDK 사용을 위한 준비를 마쳐야 합니다.
3.1. Flutter 프로젝트 생성
먼저 새로운 Flutter 프로젝트를 생성합니다. 터미널에서 다음 명령어를 실행하세요.
flutter create native_map_example
cd native_map_example
3.2. 카카오 개발자 등록 및 앱 생성
- 카카오 개발자 사이트에 접속하여 회원가입 및 로그인을 합니다.
- '내 애플리케이션' 메뉴에서 '애플리케이션 추가하기'를 선택합니다.
- 앱 이름, 사업자명 등을 입력하고 앱을 생성합니다.
- 생성된 앱을 선택하고, '플랫폼' 설정으로 이동합니다. 여기서 Android와 iOS 플랫폼을 각각 등록해야 합니다.
3.3. 플랫폼별 키 발급 및 설정
Android 설정
- 패키지명 등록: '플랫폼' > 'Android' 설정에서 `android/app/build.gradle` 파일에 있는 `applicationId`를 등록합니다.
- 키 해시 등록: 카카오맵 SDK는 디버그 및 릴리즈 키 해시로 앱을 식별합니다. 키 해시를 얻는 방법은 카카오 개발자 가이드에 상세히 설명되어 있습니다. 보통 디버그 키 해시는 아래 명령어로 얻을 수 있습니다.
얻은 키 해시를 카카오 개발자 사이트에 등록합니다.keytool -exportcert -alias androiddebugkey -keystore ~/.android/debug.keystore -storepass android -keypass android | openssl sha1 -binary | openssl base64
- 네이티브 앱 키 복사: '앱 키' 메뉴에서 '네이티브 앱 키'를 복사해 둡니다. 이 키는 나중에 `AndroidManifest.xml`에 사용됩니다.
iOS 설정
- 번들 ID 등록: '플랫폼' > 'iOS' 설정에서 Xcode 프로젝트의 번들 ID(Bundle Identifier)를 등록합니다.
- 네이티브 앱 키 복사: Android와 동일한 '네이티브 앱 키'를 사용합니다. 복사해 둡니다.
이 과정은 SDK 인증에 필수적이므로, 정확하게 따라 하는 것이 매우 중요합니다. 하나라도 잘못되면 지도가 정상적으로 표시되지 않습니다.
4. Android 플랫폼 연동 (Kotlin)
이제 본격적으로 Android 네이티브 프로젝트에 카카오맵 SDK를 통합하고 Flutter와 연결하는 작업을 시작하겠습니다. Kotlin을 기준으로 설명합니다.
4.1. 네이티브 SDK 의존성 추가
먼저 카카오맵 SDK를 다운로드할 수 있도록 프로젝트 레벨의 `build.gradle` 파일(`android/build.gradle`)을 수정합니다.
// android/build.gradle
allprojects {
repositories {
google()
mavenCentral()
// 카카오 SDK 저장소 추가
maven { url 'https://devrepo.kakao.com/nexus/content/groups/public/' }
}
}
다음으로, 앱 레벨의 `build.gradle` 파일(`android/app/build.gradle`)에 SDK 의존성을 추가합니다.
// android/app/build.gradle
dependencies {
// ... 기존 의존성
implementation 'com.kakao.maps.openapis:2.7.1' // 최신 버전은 카카오 개발자 사이트에서 확인
}
참고: `minSdkVersion`이 카카오맵 SDK가 요구하는 최소 버전(예: 21)보다 낮은 경우, 요구사항에 맞게 상향 조정해야 합니다.
// android/app/build.gradle
android {
defaultConfig {
// ...
minSdkVersion 21
// ...
}
}
4.2. AndroidManifest.xml 설정
`android/app/src/main/AndroidManifest.xml` 파일에 인터넷 권한과 카카오 API 키를 추가합니다.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.native_map_example">
<!-- 인터넷 사용 권한 -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- 위치 정보 사용 권한 (필요 시) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:label="native_map_example"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<!-- 카카오 네이티브 앱 키 -->
<meta-data
android:name="com.kakao.sdk.AppKey"
android:value="YOUR_KAKAO_NATIVE_APP_KEY" />
<activity
...
</activity>
...
</application>
</manifest>
YOUR_KAKAO_NATIVE_APP_KEY
부분에 아까 복사해 둔 네이티브 앱 키를 붙여넣습니다.
4.3. PlatformView 구현
이제 Flutter의 `AndroidView` 위젯이 사용할 네이티브 뷰를 만들어야 합니다. 이를 위해 `PlatformView`와 `PlatformViewFactory`를 구현합니다.
4.3.1. KakaoMapViewFactory.kt
이 클래스는 Flutter가 네이티브 뷰 생성을 요청할 때 호출되며, `KakaoMapView` 인스턴스를 생성하여 반환하는 역할을 합니다.
// android/app/src/main/kotlin/com/example/native_map_example/KakaoMapViewFactory.kt
package com.example.native_map_example
import android.content.Context
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class KakaoMapViewFactory(private val messenger: io.flutter.plugin.common.BinaryMessenger) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context?, viewId: Int, args: Any?): PlatformView {
val creationParams = args as Map<String?, Any?>?
return KakaoMapView(context!!, viewId, creationParams, messenger)
}
}
4.3.2. KakaoMapView.kt
이 클래스가 실제 네이티브 지도 뷰를 감싸고, Flutter와의 통신(MethodChannel)을 처리하는 핵심 로직을 담게 됩니다.
// android/app/src/main/kotlin/com/example/native_map_example/KakaoMapView.kt
package com.example.native_map_example
import android.content.Context
import android.view.View
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.platform.PlatformView
import net.daum.mf.map.api.MapPoint
import net.daum.mf.map.api.MapView
class KakaoMapView(
context: Context,
viewId: Int,
creationParams: Map<String?, Any?>?,
messenger: BinaryMessenger
) : PlatformView, MethodChannel.MethodCallHandler {
private val mapView: MapView = MapView(context)
private val methodChannel: MethodChannel
// PlatformView의 getView()는 Flutter에 표시될 네이티브 뷰를 반환
override fun getView(): View {
return mapView
}
// PlatformView가 파괴될 때 호출
override fun dispose() {
methodChannel.setMethodCallHandler(null)
}
init {
// Flutter와 통신할 MethodChannel을 초기화
// 'kakao_map_view_0' 처럼 viewId를 포함하여 채널 이름을 고유하게 만듦
methodChannel = MethodChannel(messenger, "kakao_map_view_$viewId")
methodChannel.setMethodCallHandler(this)
// creationParams에서 초기 위도, 경도 값 받기
val lat = creationParams?.get("lat") as? Double ?: 37.5665
val lng = creationParams?.get("lng") as? Double ?: 126.9780
val zoomLevel = creationParams?.get("zoomLevel") as? Int ?: 4
mapView.setMapCenterPoint(MapPoint.mapPointWithGeoCoord(lat, lng), true)
mapView.setZoomLevel(zoomLevel, true)
}
// Dart에서 `invokeMethod`를 호출하면 이 함수가 실행됨
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"moveCamera" -> {
val lat = call.argument<Double>("lat")
val lng = call.argument<Double>("lng")
if (lat != null && lng != null) {
mapView.setMapCenterPoint(MapPoint.mapPointWithGeoCoord(lat, lng), true)
result.success("Camera moved")
} else {
result.error("INVALID_ARGS", "Latitude or Longitude is null", null)
}
}
"addMarker" -> {
// TODO: 마커 추가 로직 구현
result.success("Marker added (not implemented)")
}
else -> {
// 정의되지 않은 메서드 호출
result.notImplemented()
}
}
}
}
위 코드의 핵심은 다음과 같습니다.
- `PlatformView` 인터페이스를 구현하여 `getView()`에서 실제 `MapView` 인스턴스를 반환합니다.
- 생성자에서 `MethodChannel`을 초기화하고 `setMethodCallHandler(this)`를 통해 Dart로부터 오는 모든 메서드 호출을 이 클래스가 처리하도록 설정합니다.
- `onMethodCall` 메서드 내에서 `call.method` 문자열을 기준으로 분기하여, Dart에서 요청한 작업을 수행합니다. 예를 들어 "moveCamera" 요청이 오면, 전달된 위도/경도 인자를 파싱하여 실제 `mapView`의 중심점을 이동시킵니다.
- 작업이 성공하면 `result.success()`, 실패하면 `result.error()`, 지원하지 않는 메서드이면 `result.notImplemented()`를 호출하여 Dart로 결과를 반환합니다.
4.4. FlutterPlugin 등록
마지막으로, 위에서 만든 `KakaoMapViewFactory`를 Flutter 엔진에 등록해야 합니다. `MainActivity.kt` 파일을 수정합니다.
// android/app/src/main/kotlin/com/example/native_map_example/MainActivity.kt
package com.example.native_map_example
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity: FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine
.platformViewsController
.registry
.registerViewFactory("kakao_map_view", KakaoMapViewFactory(flutterEngine.dartExecutor.binaryMessenger))
}
}
이 코드는 "kakao_map_view"라는 고유 식별자(viewType)로 뷰 요청이 들어오면, 우리가 만든 `KakaoMapViewFactory`를 사용하도록 Flutter 엔진에 알려주는 역할을 합니다. 이 식별자는 나중에 Dart 코드에서 `AndroidView` 위젯을 사용할 때 똑같이 사용되어야 합니다.
이제 Android 네이티브 설정은 모두 끝났습니다. 다음은 Dart 코드에서 이 네이티브 뷰를 어떻게 불러와서 사용하는지 알아볼 차례입니다.
5. Flutter(Dart)에서 네이티브 뷰 사용하기
이제 Dart 코드에서 방금 만든 네이티브 카카오맵 뷰를 화면에 띄우고, `MethodChannel`을 통해 제어하는 위젯을 만들어 보겠습니다.
5.1. 지도 위젯 생성 (kakao_map_widget.dart)
`lib` 폴더에 `kakao_map_widget.dart` 파일을 새로 만들고, `StatefulWidget`으로 지도 위젯을 구성합니다.
// lib/kakao_map_widget.dart
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class KakaoMapWidget extends StatefulWidget {
const KakaoMapWidget({super.key});
@override
State<KakaoMapWidget> createState() => _KakaoMapWidgetState();
}
class _KakaoMapWidgetState extends State<KakaoMapWidget> {
// 네이티브와 통신할 MethodChannel
MethodChannel? _channel;
@override
Widget build(BuildContext context) {
// 플랫폼별로 다른 뷰 타입을 지정
const String viewType = 'kakao_map_view';
// 네이티브 뷰에 전달할 파라미터
final Map<String, dynamic> creationParams = <String, dynamic>{
'lat': 37.5665,
'lng': 126.9780,
'zoomLevel': 4,
};
if (Platform.isAndroid) {
return Scaffold(
appBar: AppBar(title: const Text("카카오맵 in Flutter (Android)")),
body: Column(
children: [
Expanded(
child: AndroidView(
viewType: viewType,
layoutDirection: TextDirection.ltr,
creationParams: creationParams,
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: _onPlatformViewCreated,
// 지도 위에서의 제스처를 Flutter가 가로채지 않도록 설정
gestureRecognizers: <Factory<OneSequenceGestureRecognizer>>{
Factory<EagerGestureRecognizer>(() => EagerGestureRecognizer()),
},
),
),
_buildControlPanel(),
],
),
);
} else if (Platform.isIOS) {
// iOS 구현은 다음 섹션에서 진행
return Scaffold(
appBar: AppBar(title: const Text("카카오맵 in Flutter (iOS)")),
body: Column(
children: [
Expanded(
child: UiKitView(
viewType: viewType,
layoutDirection: TextDirection.ltr,
creationParams: creationParams,
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: _onPlatformViewCreated,
gestureRecognizers: <Factory<OneSequenceGestureRecognizer>>{
Factory<EagerGestureRecognizer>(() => EagerGestureRecognizer()),
},
),
),
_buildControlPanel(),
],
),
);
} else {
return const Center(child: Text('지원되지 않는 플랫폼입니다.'));
}
}
// 네이티브 뷰가 생성되면 호출되는 콜백
void _onPlatformViewCreated(int id) {
// 생성된 뷰의 고유 ID를 사용하여 MethodChannel을 초기화
_channel = MethodChannel('kakao_map_view_$id');
print('PlatformView with id:$id created. Channel is ready.');
}
// 지도 제어 버튼 패널
Widget _buildControlPanel() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Wrap(
spacing: 8.0,
runSpacing: 4.0,
alignment: WrapAlignment.center,
children: [
ElevatedButton(
onPressed: () => _moveCamera(35.1796, 129.0756), // 부산
child: const Text('부산으로 이동'),
),
ElevatedButton(
onPressed: () => _moveCamera(35.1595, 126.8526), // 광주
child: const Text('광주로 이동'),
),
],
),
);
}
// MethodChannel을 통해 네이티브의 'moveCamera' 메서드를 호출
Future<void> _moveCamera(double lat, double lng) async {
if (_channel == null) {
print('MethodChannel is not initialized yet.');
return;
}
try {
final String? result = await _channel!.invokeMethod('moveCamera', {
'lat': lat,
'lng': lng,
});
print('moveCamera result: $result');
} on PlatformException catch (e) {
print("Failed to move camera: '${e.message}'.");
}
}
}
위 Dart 코드의 핵심은 다음과 같습니다.
Platform.isAndroid
를 확인하여AndroidView
위젯을 사용합니다. 여기에 `viewType`으로 네이티브에서 등록한 "kakao_map_view"를 정확히 입력합니다.creationParams
를 통해 네이티브 뷰가 처음 생성될 때 필요한 초기값(초기 위치, 줌 레벨 등)을 전달합니다. 이 데이터는 네이티브의 `KakaoMapViewFactory`를 통해 `KakaoMapView`의 생성자로 전달됩니다.onPlatformViewCreated
콜백은 네이티브 뷰가 완전히 생성되고 Flutter 위젯 트리에 연결되었을 때 호출됩니다. 이 콜백은 네이티브 뷰의 고유 `id`를 전달해주는데, 이 `id`를 사용하여 정확히 해당 뷰와 통신할 `MethodChannel`을 초기화합니다. 이는 화면에 여러 개의 지도 뷰가 존재하더라도 서로 다른 채널을 통해 독립적으로 통신할 수 있게 해주는 매우 중요한 부분입니다._moveCamera
함수에서는_channel.invokeMethod()
를 호출하여 네이티브 코드의 `onMethodCall`을 트리거합니다. 첫 번째 인자는 호출할 메서드 이름("moveCamera"), 두 번째 인자는 전달할 데이터(Map 형태)입니다. 이 호출은 `Future`를 반환하므로 `await` 키워드를 사용하여 비동기적으로 결과를 기다립니다.gestureRecognizers
설정은 지도 위에서 발생하는 스크롤, 줌 등의 제스처가 Flutter의 상위 위젯(예: `ListView`)에 의해 가로채지지 않고, 온전히 네이티브 지도 뷰로 전달되도록 하는 중요한 역할을 합니다.
5.2. main.dart 수정
마지막으로 `main.dart` 파일에서 방금 만든 `KakaoMapWidget`을 보여주도록 수정합니다.
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:native_map_example/kakao_map_widget.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Flutter Native Map Demo',
home: KakaoMapWidget(),
);
}
}
이제 Android 에뮬레이터나 실제 기기에서 앱을 실행(`flutter run`)하면, Flutter 앱 화면 안에 네이티브 카카오맵이 렌더링되고, 하단의 버튼을 누르면 지도의 중심 위치가 부드럽게 이동하는 것을 확인할 수 있습니다.
6. iOS 플랫폼 연동 (Swift)
Android 연동에 성공했다면, 이제 iOS 플랫폼에도 동일한 기능을 구현할 차례입니다. 기본적인 원리와 구조는 Android와 매우 유사하지만, 네이티브 코드의 언어(Swift)와 프레임워크가 다릅니다.
6.1. 네이티브 SDK 의존성 추가 (CocoaPods)
iOS에서는 CocoaPods를 사용하여 외부 라이브러리를 관리합니다. `ios/Podfile`을 열고 다음 내용을 추가합니다.
# ios/Podfile
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
# 카카오맵 SDK 추가
pod 'KakaoMaps-SDK', '2.7.2' # 최신 버전 확인
end
수정이 끝났으면, `ios` 디렉토리에서 터미널을 열고 `pod install` 또는 `pod update`를 실행하여 SDK를 설치합니다.
cd ios
pod install
6.2. Info.plist 설정
`ios/Runner/Info.plist` 파일에 카카오 네이티브 앱 키와 지도 사용에 대한 권한 설명 등을 추가해야 합니다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- ... 기존 설정들 ... -->
<!-- 카카오 네이티브 앱 키 -->
<key>KAKAO_APP_KEY</key>
<string>YOUR_KAKAO_NATIVE_APP_KEY</string>
<!-- 위치 정보 사용 권한 설명 -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>현재 위치를 지도에 표시하기 위해 위치 정보 접근 권한이 필요합니다.</string>
<!-- iOS 14 이상에서 App Tracking Transparency 관련 설정 (필요 시) -->
<key>NSUserTrackingUsageDescription</key>
<string>This identifier will be used to deliver personalized ads to you.</string>
<!-- Platform View 사용을 위한 설정 -->
<key>io.flutter.embedded_views_preview</key>
<true/>
</dict>
</plist>
마찬가지로 `YOUR_KAKAO_NATIVE_APP_KEY`를 실제 키로 교체해야 합니다. `io.flutter.embedded_views_preview` 키를 `true`로 설정하는 것은 Flutter가 `UiKitView`를 사용할 수 있도록 허용하는 중요한 단계입니다.
6.3. PlatformView 구현 (Swift)
Android와 마찬가지로 `PlatformViewFactory`와 `PlatformView`를 Swift로 구현합니다. Xcode를 열고 `Runner/Runner` 그룹에 새로운 Swift 파일을 생성합니다.
6.3.1. KakaoMapViewFactory.swift
// Runner/KakaoMapViewFactory.swift
import Flutter
import UIKit
class KakaoMapViewFactory: NSObject, FlutterPlatformViewFactory {
private var messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
return KakaoMapView(
frame: frame,
viewIdentifier: viewId,
arguments: args,
binaryMessenger: messenger)
}
// Flutter에서 Platform View를 생성할 때 필요한 파라미터 코덱을 설정
public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
return FlutterStandardMessageCodec.sharedInstance()
}
}
6.3.2. KakaoMapView.swift
// Runner/KakaoMapView.swift
import Flutter
import UIKit
import KakaoMapsSDK
class KakaoMapView: NSObject, FlutterPlatformView, KakaoMapEventDelegate, GuiEventDelegate {
private var _view: KMViewContainer
private var _controller: KMController?
private var _methodChannel: FlutterMethodChannel
init(
frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?,
binaryMessenger messenger: FlutterBinaryMessenger
) {
// KMViewContainer를 생성하여 지도 뷰를 담을 컨테이너를 만듦
_view = KMViewContainer(frame: frame)
// MethodChannel 초기화
_methodChannel = FlutterMethodChannel(name: "kakao_map_view_\(viewId)",
binaryMessenger: messenger)
super.init()
_methodChannel.setMethodCallHandler(self.handle)
// KakaoMapsSDK 초기화
if let appKey = Bundle.main.object(forInfoDictionaryKey: "KAKAO_APP_KEY") as? String {
SDKInitializer.shared.appKey = appKey
}
// KMController 생성
_controller = KMController(viewContainer: _view)
_controller?.delegate = self
// 지도 그리기 시작
_controller?.startEngine()
_controller?.startRendering()
// Dart에서 전달받은 초기 파라미터로 지도 위치 설정
if let params = args as? [String: Any],
let lat = params["lat"] as? Double,
let lng = params["lng"] as? Double,
let zoomLevel = params["zoomLevel"] as? Int {
self.moveCameraTo(lat: lat, lng: lng, zoomLevel: zoomLevel)
}
}
func view() -> UIView {
return _view
}
// Dart로부터 메서드 호출을 처리하는 핸들러
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "moveCamera":
if let args = call.arguments as? [String: Any],
let lat = args["lat"] as? Double,
let lng = args["lng"] as? Double {
self.moveCameraTo(lat: lat, lng: lng)
result("Camera moved on iOS")
} else {
result(FlutterError(code: "INVALID_ARGS", message: "Latitude or Longitude is null", details: nil))
}
default:
result(FlutterMethodNotImplemented)
}
}
// 실제 카메라를 이동시키는 함수
private func moveCameraTo(lat: Double, lng: Double, zoomLevel: Int = 15) {
guard let mapView = _controller?.getView("mapview") as? KakaoMap else { return }
let cameraUpdate = CameraUpdate.make(target: MapPoint(longitude: lng, latitude: lat), zoomLevel: zoomLevel, mapView: mapView)
mapView.moveCamera(cameraUpdate)
}
// KakaoMapEventDelegate: 지도가 준비되면 기본 레이어 및 뷰 설정을 추가
func addViews() {
let defaultPosition = MapPoint(longitude: 126.9780, latitude: 37.5665)
let mapviewInfo = MapviewInfo(viewName: "mapview", viewInfoName: "map", defaultPosition: defaultPosition, defaultLevel: 15)
if _controller?.addView(mapviewInfo) == Result.OK {
print("OK")
}
}
}
iOS Swift 코드의 특징은 다음과 같습니다.
- `FlutterPlatformView` 프로토콜을 준수하여 `view()` 메서드에서 실제 `UIView`를 반환합니다. 카카오맵 SDK v2에서는 `KMViewContainer`가 그 역할을 합니다.
- 생성자에서 `FlutterMethodChannel`을 초기화하고, `setMethodCallHandler`를 통해 메서드 호출을 처리할 클로저(closure)를 지정합니다.
- 카카오맵 SDK v2는 `SDKInitializer`를 통해 인증하고, `KMController`를 생성하여 지도 엔진을 시작하는 과정이 필요합니다. `addViews()` 델리게이트 메서드가 호출될 때 실제 지도 뷰를 생성하고 화면에 추가합니다.
- `handle` 함수는 Dart의 `invokeMethod`에 응답하여, Android의 `onMethodCall`과 동일한 역할을 수행합니다.
6.4. FlutterPlugin 등록 (AppDelegate.swift)
마지막으로 `ios/Runner/AppDelegate.swift` 파일에서 `KakaoMapViewFactory`를 Flutter 엔진에 등록합니다.
// Runner/AppDelegate.swift
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// 약한 참조로 self 캡처
weak var registrar = self.registrar(forPlugin: "kakao-map-plugin")
let factory = KakaoMapViewFactory(messenger: registrar!.messenger())
self.registrar(forPlugin: "")!.register(
factory,
withId: "kakao_map_view")
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
Android와 마찬가지로 "kakao_map_view"라는 동일한 `withId`(viewType)를 사용하여 팩토리를 등록합니다. 이렇게 함으로써 Dart 코드에서는 `Platform.isAndroid` 또는 `Platform.isIOS` 분기만으로 동일한 `viewType`을 사용하여 각 플랫폼에 맞는 네이티브 뷰를 불러올 수 있게 됩니다.
이제 iOS 시뮬레이터나 실제 기기에서 앱을 실행하면, Android와 동일하게 카카오맵이 표시되고 버튼으로 지도 위치를 제어할 수 있습니다.
7. 심화 주제 및 고려사항
기본적인 연동을 마쳤지만, 실제 프로덕션 수준의 앱을 개발하기 위해서는 몇 가지 더 깊이 있는 주제들을 고려해야 합니다.
7.1. 데이터 직렬화와 코덱
MethodChannel을 통해 Dart와 네이티브 간에 데이터를 주고받을 때, 데이터는 플랫폼에 맞는 형태로 직렬화/역직렬화 과정을 거칩니다. 기본적으로 `StandardMethodCodec`이 사용되며, 이는 Dart의 `null`, `bool`, `int`, `double`, `String`, `Uint8List`, `Int32List`, `Int64List`, `Float64List`, `List`, `Map` 타입과 네이티브의 상응하는 타입(예: Kotlin의 `List`, `Map`, Swift의 `NSArray`, `NSDictionary`) 간의 변환을 지원합니다.
만약 커스텀 객체를 주고받고 싶다면, 해당 객체를 `Map`으로 변환하여 보내거나, `BasicMessageChannel`과 커스텀 코덱을 구현하는 방법을 고려할 수 있습니다.
7.2. 에러 처리의 중요성
네이티브 코드에서 API 호출 실패, 권한 없음, 잘못된 파라미터 등 다양한 이유로 오류가 발생할 수 있습니다. 이때 `result.error()` (Android) 또는 `FlutterError` (iOS)를 사용하여 Dart 측으로 오류 정보를 명확하게 전달해야 합니다.
Dart에서는 `try-on-catch` 구문을 사용하여 `PlatformException`을 잡아내고, 사용자에게 적절한 피드백(예: "지도 로딩에 실패했습니다. 네트워크를 확인해주세요.")을 제공해야 합니다.
try {
await _channel!.invokeMethod('someMethod');
} on PlatformException catch (e) {
// e.code: "INVALID_ARGS"
// e.message: "Latitude or Longitude is null"
// e.details: 추가 정보
showErrorDialog(e.message);
}
7.3. 네이티브에서 Dart로의 호출 (역방향 통신)
때로는 네이티브에서 발생한 이벤트를 Dart로 알려줘야 할 때가 있습니다. 예를 들어, 사용자가 지도 위의 마커를 클릭했을 때, 해당 마커의 정보를 담은 다이얼로그를 Flutter UI로 띄우고 싶은 경우입니다. 이럴 때는 두 가지 방법을 사용할 수 있습니다.
- MethodChannel.invokeMethod 사용: 네이티브 측 `MethodChannel` 인스턴스에도 `invokeMethod`가 존재합니다. 이를 호출하여 Dart 측에 등록된 핸들러를 실행시킬 수 있습니다.
// Dart 측: 핸들러 설정 _channel.setMethodCallHandler(_handleNativeCall); Future<dynamic> _handleNativeCall(MethodCall call) async { if (call.method == 'onMarkerTapped') { final String markerId = call.arguments['id']; print('Marker $markerId was tapped!'); // Flutter 다이얼로그 띄우기 등 } }
// Android 네이티브 측: Dart 호출 // 마커 클릭 리스너 내부에서... val args = mapOf("id" to "marker_123") methodChannel.invokeMethod("onMarkerTapped", args)
- EventChannel 사용: 지속적인 이벤트 스트림(예: 지도 이동 상태, 다운로드 진행률)을 전달해야 한다면 `EventChannel`이 더 적합한 해결책입니다.
7.4. 생명주기(Lifecycle) 관리
PlatformView는 Flutter 위젯의 생명주기와 네이티브 뷰/액티비티/뷰컨트롤러의 생명주기를 모두 가집니다. 예를 들어, Flutter 화면이 `dispose`될 때 네이티브 `MapView`의 리소스도 적절히 해제(`dispose()`, `stopRendering()` 등)해주어야 메모리 누수를 방지할 수 있습니다. `PlatformView`의 `dispose()` 메서드는 이를 위한 완벽한 장소입니다.
8. 결론
지금까지 Flutter에서 MethodChannel과 PlatformView를 사용하여 네이티브 카카오맵 SDK를 직접 연동하는 전체 과정을 안드로이드와 iOS 플랫폼에 걸쳐 상세하게 살펴보았습니다. 처음에는 설정할 것도 많고, Dart, Kotlin, Swift 세 가지 언어를 넘나들어야 해서 복잡하게 느껴질 수 있습니다. 하지만 이 구조를 한 번 이해하고 나면, 그 가능성은 무한해집니다.
지도 연동을 넘어, 네이티브 결제 SDK, 커스텀 카메라 모듈, 블루투스 통신, 특정 하드웨어 제어 등 Flutter만으로는 불가능했던 거의 모든 기능을 여러분의 앱에 통합할 수 있는 문이 열린 것입니다. 이제 단순히 패키지를 사용하는 개발자를 넘어, Flutter와 네이티브 플랫폼 사이의 경계를 허물고 진정한 크로스플랫폼 전문가로 나아갈 수 있는 기반을 다지게 되었습니다.
이 글에서 다룬 코드를 기반으로 마커 추가, 폴리라인 그리기, 정보창 표시 등 다양한 기능을 직접 구현해보면서 플랫폼 채널에 대한 이해를 더욱 공고히 하시길 바랍니다.