모바일 앱에서 사용자 행동을 추적하고 분석하는 것은 매우 중요합니다. 우리 팀은 기존에 Segment를 통해 Amplitude로 이벤트를 전달하는 방식을 사용하고 있었지만, 더 정확하고 안정적인 추적을 위해 Amplitude Flutter SDK를 직접 통합하기로 결정했습니다.
하지만 이 과정에서 예상치 못한 장애물을 만났고, 이를 해결하는 과정에서 많은 것을 배웠습니다. 이 글에서는 Amplitude Flutter SDK 초기 설정부터 발생한 문제, 그리고 해결 방법까지 전체 과정을 공유합니다.
먼저 pubspec.yaml 파일에 필요한 패키지들을 추가합니다:
dependencies:
flutter:
sdk: flutter
# Amplitude Flutter SDK
amplitude_flutter: ^4.3.9
# Device ID 가져오기용
device_info_plus: ^9.1.2
android_id: ^0.3.6
# Device ID 영구 저장용
shared_preferences: ^2.5.3
# 상태 관리 (GetX 사용 예시)
get: 4.6.6
의존성을 추가한 후 패키지를 설치합니다:
flutter pub get
Device ID를 가져오기 위한 유틸리티 함수를 생성합니다. lib/app/core/utils/device_info.dart 파일을 생성합니다:
import 'dart:io';
import 'package:android_id/android_id.dart';
import 'package:device_info_plus/device_info_plus.dart';
/// 플랫폼별 고유 Device ID를 가져옵니다
Future<String?> getUID() async {
var deviceInfo = DeviceInfoPlugin();
if (Platform.isIOS) {
IosDeviceInfo iosDeviceInfo = await deviceInfo.iosInfo;
return iosDeviceInfo.identifierForVendor; // iOS의 Identifier for Vendor
} else if (Platform.isAndroid) {
const _androidIdPlugin = AndroidId();
return await _androidIdPlugin.getId(); // Android의 Android ID
}
return null;
}
Amplitude를 관리할 서비스 클래스를 생성합니다. lib/app/data/service/psx/tracking_service.dart 파일을 생성합니다:
import 'package:amplitude_flutter/amplitude.dart';
import 'package:amplitude_flutter/configuration.dart';
import 'package:amplitude_flutter/default_tracking.dart';
import 'package:amplitude_flutter/events/base_event.dart';
import 'package:amplitude_flutter/events/identify.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:psx_flutter/app/core/utils/device_info.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Amplitude Flutter SDK 4를 사용한 트래킹 서비스
class TrackingService extends GetxService {
Amplitude? _amplitude;
/// Amplitude 초기화
Future<TrackingService> init({String? amplitudeApiKey}) async {
if (amplitudeApiKey != null && amplitudeApiKey.isNotEmpty) {
try {
// device_id 가져오기 및 검증
String? deviceId = await getUID();
// device_id가 null이거나 5자 미만인 경우 처리
if (deviceId == null || deviceId.isEmpty || deviceId.length < 5) {
final prefs = await SharedPreferences.getInstance();
String? savedDeviceId = prefs.getString('amplitude_device_id');
if (savedDeviceId != null && savedDeviceId.length >= 5) {
deviceId = savedDeviceId;
debugPrint('[Amplitude] Using saved device ID: $deviceId');
} else {
// 새로운 device_id 생성
final timestamp = DateTime.now().millisecondsSinceEpoch.toString();
String newDeviceId = deviceId == null || deviceId.isEmpty
? 'dev_$timestamp'
: '${deviceId}_$timestamp';
// 5자 미만이면 패딩 추가
while (newDeviceId.length < 5) {
newDeviceId = '${newDeviceId}0';
}
deviceId = newDeviceId;
await prefs.setString('amplitude_device_id', deviceId);
debugPrint('[Amplitude] Generated new device ID: $deviceId');
}
}
final String finalDeviceId = deviceId!;
// Amplitude Configuration 설정
final configuration = Configuration(
apiKey: amplitudeApiKey,
logLevel: LogLevel.debug, // 프로덕션에서는 LogLevel.none 권장
defaultTracking: DefaultTrackingOptions.all(),
minIdLength: 1, // SDK 레벨에서는 짧은 ID도 허용
);
_amplitude = Amplitude(configuration);
await _amplitude!.isBuilt; // 초기화 완료 대기
// deviceId 명시적 설정
await _amplitude!.setDeviceId(finalDeviceId);
debugPrint('[Amplitude] Device ID set successfully: $finalDeviceId');
} catch (e) {
debugPrint('TrackingService Amplitude init error: $e');
}
}
return this;
}
/// 이벤트 전송
Future<void> sendEvent({
required String eventName,
Map<String, dynamic>? eventProperty,
}) async {
if (_amplitude != null && eventName.isNotEmpty) {
try {
final baseEvent = BaseEvent(
eventName,
eventProperties: eventProperty,
);
await _amplitude!.track(baseEvent);
debugPrint('[Amplitude] Event tracked: $eventName');
} catch (e) {
debugPrint('[Amplitude] Failed to track event: $e');
}
}
}
/// 사용자 식별 및 속성 설정
Future<void> setUser({
String? userId,
Map<String, dynamic>? property,
}) async {
if (userId == null || userId.isEmpty || _amplitude == null) return;
try {
// user_id 최소 5자 검증
if (userId.length < 5) {
debugPrint('[Amplitude] User ID too short: $userId');
return; // device_id만 사용
}
await _amplitude!.setUserId(userId);
debugPrint('[Amplitude] User ID set: $userId');
// 사용자 속성 설정
if (property != null && property.isNotEmpty) {
final identify = Identify();
property.forEach((key, value) {
if (key.isNotEmpty && value != null) {
identify.set(key, value);
}
});
await _amplitude!.identify(identify);
debugPrint('[Amplitude] User properties set');
}
} catch (e) {
debugPrint('[Amplitude] Failed to set user: $e');
}
}
/// 즉시 이벤트 전송 (테스트/디버깅용)
Future<void> flush() async {
if (_amplitude != null) {
try {
await _amplitude!.flush();
debugPrint('[Amplitude] Events flushed');
} catch (e) {
debugPrint('[Amplitude] Failed to flush: $e');
}
}
}
}
앱 시작 시 TrackingService를 초기화합니다. lib/app/app.dart 또는 main.dart에서:
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:psx_flutter/app/data/service/psx/tracking_service.dart';
Future<void> initServices() async {
// ... 다른 서비스 초기화 ...
// Amplitude API 키 가져오기 (환경 변수에서)
String? amplitudeApiKey = dotenv.env['AMPLITUDE_KEY'];
// TrackingService 초기화
final trackingService = TrackingService();
await Get.putAsync(() => trackingService.init(amplitudeApiKey: amplitudeApiKey));
// ... 나머지 초기화 ...
}
.env 파일에 Amplitude API 키를 추가합니다:
AMPLITUDE_KEY=your_amplitude_api_key_here
Amplitude API 키는 Amplitude Dashboard에서 확인할 수 있습니다:
1. Amplitude 대시보드에 로그인
2. Settings → Projects → [프로젝트 선택]
3. API Keys 섹션에서 API Key 확인
// TrackingService 가져오기
final trackingService = Get.find<TrackingService>();
// 이벤트 전송
await trackingService.sendEvent(
eventName: 'button_clicked',
eventProperty: {
'button_name': 'signup',
'screen': 'login',
},
);
// 로그인 후 사용자 식별
await trackingService.setUser(
userId: 'user_12345', // 최소 5자 이상
property: {
'email': 'user@example.com',
'name': '홍길동',
'plan': 'premium',
},
);
// 로그아웃 시 user_id 제거 (device_id는 유지)
await trackingService.setUser(userId: null);
iOS에서 개인정보 추적 정책을 준수하기 위해 ios/Runner/Info.plist에 다음을 추가할 수 있습니다:
<key>NSPrivacyTracking</key>
<true/>
Android는 별도의 설정이 필요하지 않습니다. android_id 패키지가 자동으로 Android ID를 가져옵니다.
앱을 실행하고 로그를 확인합니다:
[Amplitude] Generated new device ID: dev_1234567890123
[Amplitude] Device ID set successfully: dev_1234567890123 (length: 20)
이제 Amplitude 대시보드에서 이벤트가 수신되는지 확인할 수 있습니다!
SDK를 통합하고 앱을 실행한 직후, 다음과 같은 에러가 발생했습니다:
W/Amplitude: Failed to get input stream, falling back to error stream
D/Amplitude: Handle response, status: BAD_REQUEST, error: Invalid id length for user_id or device_id
D/Amplitude: --> remove file: [...], dropped events: 5, retry events: 0
이벤트가 서버에서 거부되고 있었고, 5개의 이벤트가 삭제되고 있었습니다. 이는 사용자 행동 추적에 심각한 문제를 야기할 수 있었습니다.
Amplitude HTTP V2 API 문서를 확인한 결과, 다음과 같은 요구사항을 발견했습니다:
Device IDs and User IDs minimum length
Device IDs and User IDs must be strings with a length of 5 characters or more. This is to prevent potential instrumentation issues. If an event contains a device ID or user ID that's too short, the ID value is removed from the event.
If the event doesn't have a user_id or device_id value, Amplitude can reject the with a 400 status.
즉, user_id와 device_id는 최소 5자 이상이어야 하며, 그렇지 않으면:
1. ID 값이 이벤트에서 제거됨
2. user_id와 device_id가 모두 없는 이벤트는 400 에러로 거부됨
초기 구현에서는 다음과 같은 문제가 있었습니다:
SDK의 minIdLength 설정 오해
minIdLength: 5로 설정했지만, 이것은 SDK 레벨의 검증일 뿐device_id 자동 생성의 불확실성
getUID()로 가져온 device_id가 5자 미만일 수 있음명시적 device_id 설정 부재
먼저 SDK의 minIdLength를 1로 변경했습니다. 이렇게 하면 SDK가 ID를 제거하지 않고, 실제로는 5자 이상의 device_id를 명시적으로 설정할 수 있습니다.
final configuration = Configuration(
apiKey: amplitudeApiKey,
logLevel: LogLevel.debug,
defaultTracking: DefaultTrackingOptions.all(),
// SDK 레벨에서는 짧은 ID도 허용하지만, 실제 device_id는 5자 이상으로 설정
minIdLength: 1,
);
device_id가 5자 미만인 경우를 처리하기 위한 로직을 구현했습니다:
// device_id 가져오기 및 검증
String? deviceId = await getUID();
// device_id가 null이거나 5자 미만인 경우 처리
if (deviceId == null || deviceId.isEmpty || deviceId.length < 5) {
// SharedPreferences에서 저장된 device_id 확인
final prefs = await SharedPreferences.getInstance();
String? savedDeviceId = prefs.getString('amplitude_device_id');
if (savedDeviceId != null && savedDeviceId.length >= 5) {
deviceId = savedDeviceId;
debugPrint('[Amplitude] Using saved device ID: $deviceId');
} else {
// 새로운 device_id 생성 (타임스탬프 추가하여 5자 이상 보장)
final timestamp = DateTime.now().millisecondsSinceEpoch.toString();
String newDeviceId;
if (deviceId == null || deviceId.isEmpty) {
newDeviceId = 'dev_$timestamp';
} else {
newDeviceId = '${deviceId}_$timestamp';
}
// 5자 미만이면 패딩 추가
while (newDeviceId.length < 5) {
newDeviceId = '${newDeviceId}0';
}
deviceId = newDeviceId;
// SharedPreferences에 저장
await prefs.setString('amplitude_device_id', deviceId);
debugPrint('[Amplitude] Generated new device ID: $deviceId');
}
}
이 로직의 핵심은:
SDK 초기화 후 setDeviceId()를 호출하여 device_id를 명시적으로 설정했습니다:
_amplitude = Amplitude(configuration);
await _amplitude!.isBuilt;
// deviceId 명시적 설정 (초기화 후에 설정해야 함)
try {
await _amplitude!.setDeviceId(finalDeviceId);
debugPrint('[Amplitude] Device ID set successfully: $finalDeviceId');
} catch (e) {
debugPrint('[Amplitude] Failed to set device ID: $e');
}
user_id도 동일한 요구사항을 만족하도록 검증 로직을 추가했습니다:
Future<void> setUser({String? userId, Map<String, dynamic>? property}) async {
if (userId == null || userId.isEmpty == true) return;
if (_amplitude != null) {
// Amplitude API는 user_id에 최소 5자 길이를 요구합니다
if (userId.length < 5) {
// userId가 너무 짧은 경우, Amplitude에 user_id를 설정하지 않음 (device_id만 사용)
return;
}
await _amplitude!.setUserId(userId);
// ...
}
}
전체 구현 코드는 다음과 같습니다:
class TrackingService extends GetxService {
Amplitude? _amplitude;
Future<TrackingService> init({String? amplitudeApiKey}) async {
if (amplitudeApiKey != null && amplitudeApiKey.isNotEmpty) {
try {
// device_id 가져오기 및 검증
String? deviceId = await getUID();
// device_id가 null이거나 5자 미만인 경우 처리
if (deviceId == null || deviceId.isEmpty || deviceId.length < 5) {
final prefs = await SharedPreferences.getInstance();
String? savedDeviceId = prefs.getString('amplitude_device_id');
if (savedDeviceId != null && savedDeviceId.length >= 5) {
deviceId = savedDeviceId;
} else {
final timestamp = DateTime.now().millisecondsSinceEpoch.toString();
String newDeviceId = deviceId == null || deviceId.isEmpty
? 'dev_$timestamp'
: '${deviceId}_$timestamp';
while (newDeviceId.length < 5) {
newDeviceId = '${newDeviceId}0';
}
deviceId = newDeviceId;
await prefs.setString('amplitude_device_id', deviceId);
}
}
final String finalDeviceId = deviceId!;
final configuration = Configuration(
apiKey: amplitudeApiKey,
logLevel: LogLevel.debug,
defaultTracking: DefaultTrackingOptions.all(),
minIdLength: 1, // SDK 레벨에서는 짧은 ID도 허용
);
_amplitude = Amplitude(configuration);
await _amplitude!.isBuilt;
// deviceId 명시적 설정
await _amplitude!.setDeviceId(finalDeviceId);
} catch (e) {
debugPrint('TrackingService Amplitude init error: $e');
}
}
return this;
}
Future<void> setUser({String? userId, Map<String, dynamic>? property}) async {
if (userId == null || userId.isEmpty == true) return;
if (_amplitude != null) {
// user_id 최소 5자 검증
if (userId.length < 5) {
return; // device_id만 사용
}
await _amplitude!.setUserId(userId);
// ...
}
}
}
처음에는 SDK의 minIdLength 설정만으로 해결될 것이라고 생각했습니다. 하지만 실제로는 API 서버 레벨의 요구사항이 있었고, SDK 레벨과 서버 레벨의 검증이 다를 수 있다는 것을 배웠습니다.
SDK가 자동으로 처리해줄 것이라고 가정하지 말고, 중요한 값들은 명시적으로 설정하는 것이 안전합니다. 특히 device_id와 같은 핵심 식별자는 반드시 검증하고 설정해야 합니다.
device_id를 SharedPreferences에 저장함으로써:
처음 에러가 발생했을 때, 단순히 "에러가 발생했다"고만 생각하지 않고 로그를 자세히 분석한 덕분에 정확한 원인을 파악할 수 있었습니다.
한 번에 모든 것을 해결하려고 하지 않고, 문제를 작은 단계로 나누어 해결한 것이 효과적이었습니다:
1. minIdLength 설정 변경
2. device_id 검증 로직 추가
3. 명시적 설정
4. user_id 검증 추가
Amplitude Flutter SDK 통합 과정에서 만난 장애물을 해결하면서, API 문서의 중요성, 명시적 설정의 필요성, 그리고 단계별 문제 해결의 가치를 다시 한 번 깨달았습니다.
이제 우리 앱은 안정적으로 사용자 행동을 추적할 수 있게 되었고, 더 나은 데이터 기반 의사결정을 할 수 있게 되었습니다.
혹시 비슷한 문제를 겪고 계신다면, 이 글이 도움이 되기를 바랍니다. 질문이나 피드백이 있으시면 언제든지 댓글로 남겨주세요!
참고 자료: