[Flutter] Amplitude 통합하기: 초기화부터 장애물 돌파까지

길위에 히피·2025년 12월 18일

Flutter

목록 보기
56/56

들어가며

모바일 앱에서 사용자 행동을 추적하고 분석하는 것은 매우 중요합니다. 우리 팀은 기존에 Segment를 통해 Amplitude로 이벤트를 전달하는 방식을 사용하고 있었지만, 더 정확하고 안정적인 추적을 위해 Amplitude Flutter SDK를 직접 통합하기로 결정했습니다.

하지만 이 과정에서 예상치 못한 장애물을 만났고, 이를 해결하는 과정에서 많은 것을 배웠습니다. 이 글에서는 Amplitude Flutter SDK 초기 설정부터 발생한 문제, 그리고 해결 방법까지 전체 과정을 공유합니다.

Amplitude Flutter SDK 초기 설정

1. 의존성 추가

먼저 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

2. Device ID 유틸리티 함수 생성

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;
}

3. TrackingService 클래스 생성

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');
      }
    }
  }
}

4. 앱 초기화 시 TrackingService 설정

앱 시작 시 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));

  // ... 나머지 초기화 ...
}

5. 환경 변수 설정

.env 파일에 Amplitude API 키를 추가합니다:

AMPLITUDE_KEY=your_amplitude_api_key_here

Amplitude API 키는 Amplitude Dashboard에서 확인할 수 있습니다:
1. Amplitude 대시보드에 로그인
2. Settings → Projects → [프로젝트 선택]
3. API Keys 섹션에서 API Key 확인

6. 사용 예시

이벤트 전송

// 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);

7. iOS 설정 (선택사항)

iOS에서 개인정보 추적 정책을 준수하기 위해 ios/Runner/Info.plist에 다음을 추가할 수 있습니다:

<key>NSPrivacyTracking</key>
<true/>

8. Android 설정

Android는 별도의 설정이 필요하지 않습니다. android_id 패키지가 자동으로 Android ID를 가져옵니다.

설정 완료 확인

앱을 실행하고 로그를 확인합니다:

[Amplitude] Generated new device ID: dev_1234567890123
[Amplitude] Device ID set successfully: dev_1234567890123 (length: 20)

이제 Amplitude 대시보드에서 이벤트가 수신되는지 확인할 수 있습니다!


문제 상황: Invalid id length 에러

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의 요구사항

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_iddevice_id는 최소 5자 이상이어야 하며, 그렇지 않으면:
1. ID 값이 이벤트에서 제거됨
2. user_id와 device_id가 모두 없는 이벤트는 400 에러로 거부됨

우리 코드의 문제점

초기 구현에서는 다음과 같은 문제가 있었습니다:

  1. SDK의 minIdLength 설정 오해

    • minIdLength: 5로 설정했지만, 이것은 SDK 레벨의 검증일 뿐
    • 실제로 device_id가 5자 미만이면 SDK가 ID를 제거할 수 있음
    • 결과적으로 user_id와 device_id가 모두 없어져 400 에러 발생
  2. device_id 자동 생성의 불확실성

    • getUID()로 가져온 device_id가 5자 미만일 수 있음
    • 특히 Android의 경우 짧은 Android ID가 반환될 수 있음
  3. 명시적 device_id 설정 부재

    • SDK 초기화 시 device_id를 명시적으로 설정하지 않음
    • SDK가 자동 생성한 device_id가 요구사항을 만족하지 않을 수 있음

해결 과정

1단계: minIdLength 설정 변경

먼저 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,
);

2단계: device_id 검증 및 생성 로직 구현

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');
  }
}

이 로직의 핵심은:

  • 영구 저장: 생성된 device_id를 SharedPreferences에 저장하여 앱 재시작 시에도 일관성 유지
  • 타임스탬프 활용: 짧은 device_id에 타임스탬프를 추가하여 고유성과 길이 보장
  • 패딩 처리: 그래도 5자 미만이면 패딩 추가

3단계: 명시적 device_id 설정

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');
}

4단계: user_id 검증 강화

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);
      // ...
    }
  }
}

교훈 및 결론

1. API 문서를 꼼꼼히 읽어야 한다

처음에는 SDK의 minIdLength 설정만으로 해결될 것이라고 생각했습니다. 하지만 실제로는 API 서버 레벨의 요구사항이 있었고, SDK 레벨과 서버 레벨의 검증이 다를 수 있다는 것을 배웠습니다.

2. 명시적 설정이 중요하다

SDK가 자동으로 처리해줄 것이라고 가정하지 말고, 중요한 값들은 명시적으로 설정하는 것이 안전합니다. 특히 device_id와 같은 핵심 식별자는 반드시 검증하고 설정해야 합니다.

3. 영구 저장을 고려해야 한다

device_id를 SharedPreferences에 저장함으로써:

  • 앱 재시작 시에도 일관된 추적 가능
  • 사용자 경험 향상 (같은 사용자로 인식)
  • 데이터 분석의 정확성 향상

4. 에러 로그를 자세히 확인해야 한다

처음 에러가 발생했을 때, 단순히 "에러가 발생했다"고만 생각하지 않고 로그를 자세히 분석한 덕분에 정확한 원인을 파악할 수 있었습니다.

5. 단계별 접근이 효과적이다

한 번에 모든 것을 해결하려고 하지 않고, 문제를 작은 단계로 나누어 해결한 것이 효과적이었습니다:
1. minIdLength 설정 변경
2. device_id 검증 로직 추가
3. 명시적 설정
4. user_id 검증 추가

마무리

Amplitude Flutter SDK 통합 과정에서 만난 장애물을 해결하면서, API 문서의 중요성, 명시적 설정의 필요성, 그리고 단계별 문제 해결의 가치를 다시 한 번 깨달았습니다.

이제 우리 앱은 안정적으로 사용자 행동을 추적할 수 있게 되었고, 더 나은 데이터 기반 의사결정을 할 수 있게 되었습니다.

혹시 비슷한 문제를 겪고 계신다면, 이 글이 도움이 되기를 바랍니다. 질문이나 피드백이 있으시면 언제든지 댓글로 남겨주세요!


참고 자료:

profile
마음맘은 히피인 일꾼러

0개의 댓글