카테고리 없음

이화여대 캡스톤 디자인 그로쓰 : flutter 앱에서 사용자의 결제내역 문자/어플 알림 읽어오기 및 파싱하기

wosrn 2025. 5. 13. 16:28

<서론>

이 글은 이화여자대학교 캡스톤 디자인과 창업프로젝트 - 그로쓰 중 팀의 프론트엔드 팀원으로서 내가 담당한 기술적 부분에 대해 다루는 글이다.

 

글의 주제는, flutter 앱에서 사용자의 결제내역 문자/어플 알림 읽어오기 및 파싱하는 방법 이다.

우리 팀의 주제는 "예산 내 소비에 어려움을 겪는Z세대를 위한 시계열 예측 AI 기반맞춤형 절약 챌린지 가계부서비스 " 이다.

가계부의 필수 기능인 사용자의 소비 내역 추적 및 관리를 위해서는, 사용자의 휴대폰으로 온 알림 중 카드사에서 보낸 결제내역 문자 및 은행 어플에서 보낸 결제내역 알림을 읽어오는 기능이 필수적이다. 뿐만 아니라, 결제내역을 api로 전송하려면 읽어온 문자/알림에서 우리가 원하는 필드를 추출하는 것 또한 필요하다. 

 

글의 전개는 다음과 같다.

1. <Android> 사용자에게 온 문자/어플알림 읽어오기 -> 플러터로 전달하기

- 사용자의 휴대폰에 온 문자 및 어플 알림을 읽어온 뒤, 이를 플러터측으로 전달한다.

 

2. <Flutter> 결제내역 문자/어플알림 필터링 & 파싱하기

- 1의 과정을 통해 전달받은 문자/알림 중, 결제내역에 해당하는 알림만을 필터링한다. 이후 서비스의 api 형식에 맞게 각 필드를 추출(파싱)한다.

 

3. 트러블 슈팅 : 에뮬레이터에선 정상작동 하는데, 실기기에서 작동하지 않는 문제 해결하기

- 나의 트러블슈팅 경험을 통해, 에뮬레이터 뿐 아니라 실기기(휴대폰)에서도 1,2의 코드가 정상작동 하도록 하기 위한 방법을 다룬다.

 

 


<본론>

본격적인 설명에 앞서, 1번과 2번의 과정을 그림과 함께 간략히 설명하자면 다음과 같다. 

안드로이드단에서 NotificationListenerService를 이용하여 사용자의 로컬 알림을 읽어오고, 이를 채널을 통해 플러터로 전달한다.

플러터에선 이를 필터링하여 원하는대로 사용한다. 

 

1.  <Android> 문자/어플알림 읽어오기 -> 플러터로 알림내용을 전달하기

사용자에게 온 문자 및 알림에 접근하기 위해선, Android에서 제공하는 NotificationListnerService 클래스를 이용해야 한다.

 

* NotificationListenerService 란?

ndroid 프레임워크에서 제공하는 클래스 (API) 중 하나로,  개발자가 이를 상속하여 알림 접근 기능을 구현하는 서비스 컴포넌트

 

 

이제 구현방법을 코드와 함께 살펴보자

 

이 과정에선 평소 flutter 앱을 개발하던 lib 폴더가 아닌, android폴더-app-main-kotlin의 경로에서 코드를 작성해야한다

 

(1) MainActvity.kt 

코드는 Flutter 앱에서 안드로이드의 알림 접근 설정 화면을 있도록 해주고, NotificationService사용할 있는 FlutterEngine미리 등록해주는 역할을 한다.

package com.example.mooney2

import android.content.Intent
import android.provider.Settings
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugins.GeneratedPluginRegistrant
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    private val CHANNEL = "notification_permission_channel"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // ✅ MethodChannel 설정 (알림 접근 설정 열기용)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
                call, result ->
            if (call.method == "openNotificationSettings") {
                val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
                startActivity(intent)
                result.success(null)
            } else {
                result.notImplemented()
            }
        }

        // ✅ FlutterEngine 캐시에 등록 (NotificationService에서 사용 가능)
        GeneratedPluginRegistrant.registerWith(flutterEngine)
        FlutterEngineCache.getInstance().put("my_engine_id", flutterEngine)
    }
}

 

- notification_permission_channel 설정 : Fluttter에서 "openNotificationSettings" 메서드를 호출하면 안드로이드의 알림 접근 설정 화면열리도록 Intent실행한다. 설정은 사용자가 앱에 알림 읽기 권한직접 부여하는 용도로 사용된다.

 

- lutterEngine 캐시에 등록 : FlutterEngine"my_engine_id"라는 이름으로 **캐시(Cache)**등록한다. 이후 NotificationListenerService(NotificationService.kt)에서 엔진을 꺼내서 Flutter 코드와 통신할 있게 된다.

 

 

 

(2) NotificationService.kt : 안드로이드 알림을 Flutter전달하는 브릿지 역할

코드는 안드로이드의 NotificationListenerService확장하여, 사용자의 알림(Notification)실시간으로 감지한다.

package com.example.mooney2

import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.plugin.common.MethodChannel

class NotificationService : NotificationListenerService() {

    private fun sendNotificationToFlutter(sbn: StatusBarNotification) {
        val extras = sbn.notification.extras
        val tickerText = sbn.notification.tickerText?.toString() ?: ""
        val title = extras.getCharSequence("android.title")?.toString() ?: ""
        val text = extras.getCharSequence("android.text")?.toString() ?: ""
        val bigText = extras.getCharSequence("android.bigText")?.toString() ?: ""

        val notificationData = HashMap<String, Any?>().apply {
            put("packageName", sbn.packageName)
            put("notificationId", sbn.id)
            put("tickerText", tickerText)
            put("title", title)  // 🔥 알림 제목 추가
            put("text", text)    // 🔥 알림 본문 추가
            put("bigText", bigText)  // 🔥 확장된 본문 추가 (더 많은 내용 포함 가능)
        }

        val engine = FlutterEngineCache.getInstance().get("my_engine_id")
        if (engine == null) {
            Log.e("NotificationService", "No cached FlutterEngine found!")
        } else {
            Log.d("NotificationService", "Using cached FlutterEngine")
            val channel = MethodChannel(engine.dartExecutor.binaryMessenger, "notification_channel")
            channel.invokeMethod("notificationPosted", notificationData)
        }
    }


    // 서비스가 시스템에 연결될 때 호출됨
    override fun onListenerConnected() {
        super.onListenerConnected()
        Log.d("NotificationService", "Listener connected. Retrieving active notifications.")
        // 현재 상태바에 남아 있는 모든 알림을 가져와 Flutter로 전달
        val activeNotifications: Array<StatusBarNotification> = getActiveNotifications()
        for (sbn in activeNotifications) {
            sendNotificationToFlutter(sbn)
        }
    }

    // 새로운 알림이 도착할 때 호출됨
    override fun onNotificationPosted(sbn: StatusBarNotification?) {
        sbn?.let {
            Log.d("NotificationService", "📬 알림 수신됨: ${it.packageName}")
            sendNotificationToFlutter(it)
        }
    }

    override fun onNotificationRemoved(sbn: StatusBarNotification?) {
        // 필요에 따라 알림 제거 시 처리할 로직 구현
    }
}

 

- 알림 수신 호출되는 onNotificationPosted() : 새로운 알림이 도착하면 자동으로 호출되어, 받은 알림을 sendNotificationToFlutter() 함수를 통해 Flutter전달한다.

 

- Flutter알림 데이터를 보내는 sendNotificationToFlutter() : 알림에서 title, text, bigText, tickerText 다양한 텍스트 데이터를 추출하여, FlutterEngineCache등록된 "my_engine_id" 엔진을 통해 Dart전송한다. 

 

 

지금까지의 로직 흐름을 그림으로 정리해보면 위와 같다. 

 

 

 

2. <Flutter> 결제내역 문자/어플알림 필터링 & 파싱하기

 

안드로이드에서 전달된 알림 결제내역 관련 알림만을 필터링한 뒤, 이를 파싱하여 API 서버에 전송하는 역할은 lib/notification_plugin.dart 파일에서 수행된다.

1) 안드로이드에서 알림 수신 -> 결제내역 알림만을 필터링

- 안드로이드에서 알림 수신

Flutter에서는 Android에서 보낸 알림 데이터를 **MethodChannel**통해 받아야 한다.
아래 코드는 AndroidNotificationService.kt에서 notification_channel보낸 데이터를 수신하는 코드다.

static const MethodChannel _channel = MethodChannel('notification_channel');

_channel.setMethodCallHandler((call) async {
  if (call.method == "notificationPosted") {
    final Map<dynamic, dynamic> notificationData = call.arguments;
    final String tickerText = notificationData['tickerText']?.replaceAll("\n", " ") ?? '';
    final String text = notificationData['text'] ?? '';

 

 

- 결제내역 알림만을 필터링

_transactionKeywords 리스트에 포함된 단어가 tickerTexttext포함되어 있다면, 해당 알림은 결제 관련 알림으로 간주된다.

static final List<String> _transactionKeywords = [
  "결제", "승인", "체크카드", "사용", "승인완료", "출금", "일시불", "입금"
];

final bool isTransaction = _transactionKeywords.any((keyword) =>
    tickerText.contains(keyword) || text.contains(keyword));

 

 

2) (1)의 과정으로 필터링된 결제내역 알림을 파싱하여, 원하는 필드로 추출하기

(1)의 과정에서는 전체 알림 중 결제 알림을 필터링했다면, 이제는 알림에서 "금액, 시간, 결제처" 같은 실제 결제 데이터를 추출해야 한다.
이 역할TransactionParser.parseTransaction() 함수에서 수행된다.

static Future<void> _sendTransactionToApi(TransactionNotification transaction) async {
    final transactionJson = TransactionParser.parseTransaction(transaction);
    print('파싱된 객체 : ${transactionJson} ');
    // ✅ 결제 or 수입을 구분하여 API 전송
    if (transactionJson.containsKey("payer")) {
      await TransactionApiService.sendIncomeTransaction(transactionJson);
    } else {
      await TransactionApiService.sendExpenseTransaction(transactionJson);
    }
  }

 

그럼 이제  TransactionParser 클래스 코드에 대해 살펴보자

전체 코드는 다음과 같다

import 'dart:convert';
import 'package:intl/intl.dart';
import 'package:mooney2/models/transaction_notification.dart'; // TransactionNotification 모델 임포트

class TransactionParser {
  static Map<String, dynamic> parseTransaction(TransactionNotification notification) {
    final String text = "${notification.tickerText} ${notification.text}".replaceAll("\n", " ");

    // ✅ "입금", "급여"가 포함된 경우 수입(income)으로 처리
    bool isIncome = text.contains("입금") || text.contains("급여");
    // 1️⃣ 전화번호 및 계좌번호 제거 (하이픈 포함된 숫자)
    final phoneNumberRegExp = RegExp(r'\(?\d{2,4}\)?[-.\s]?\d{3,4}[-.\s]?\d{4}');
    final cleanedText = text.replaceAll(phoneNumberRegExp, "");

    // 2️⃣ 날짜 및 시간 패턴 제거 (MM/DD HH:mm, YYYY-MM-DD 등)
    final dateTimeRegExp = RegExp(r'\b\d{2}/\d{2} \d{2}:\d{2}\b|\b\d{4}-\d{2}-\d{2}\b');
    final cleanedTextWithoutDates = cleanedText.replaceAll(dateTimeRegExp, "");

    // 3️⃣ (숫자)원 패턴 우선 선택
    final amountWithWonRegExp = RegExp(r'\b\d{1,3}(,\d{3})*\b원');
    List<String> amountsWithWon = amountWithWonRegExp.allMatches(cleanedTextWithoutDates)
        .map((m) => m.group(0)!.replaceAll("원", "").replaceAll(",", ""))
        .toList();

    // 4️⃣ (숫자)원 패턴이 있으면 그것을 사용
    String amount;
    if (amountsWithWon.isNotEmpty) {
      amount = amountsWithWon.first;
    } else {
      // 5️⃣ (숫자)원 패턴이 없을 경우 쉼표 포함 숫자 탐색
      final amountWithCommaRegExp = RegExp(r'\b\d{1,3}(,\d{3})+\b');
      List<String> amountsWithComma = amountWithCommaRegExp.allMatches(cleanedTextWithoutDates)
          .map((m) => m.group(0)!.replaceAll(",", ""))
          .toList();

      amount = amountsWithComma.isNotEmpty ? amountsWithComma.first : "0";
    }


    // transactionTime 추출 (MM/DD HH:mm 형식 찾기)
    final timeRegExp = RegExp(r'\b(\d{2}/\d{2}) (\d{2}:\d{2})\b'); // 03/02 13:52 형식
    final match = timeRegExp.firstMatch(text);

    String transactionTime;
    if (match != null) {
      String datePart = match.group(1)!; // MM/DD
      String timePart = match.group(2)!; // HH:mm

      // 현재 연도 가져오기
      int currentYear = DateTime.now().year;

      // MM/DD → yyyy-MM-dd 변환
      DateTime parsedDate = DateFormat("MM/dd HH:mm").parse("$datePart $timePart");
      DateTime transactionDateTime = DateTime(currentYear, parsedDate.month, parsedDate.day, parsedDate.hour, parsedDate.minute);

      // ✅ 한국 시간으로 그대로 유지된 상태에서 ISO 8601 포맷 적용 (Z 없음)
      transactionTime = transactionDateTime.toIso8601String();
    } else {
      transactionTime = "2025-03-01T09:02:26.077Z"; // 기본값
    }

   

    // payee (결제처) 추출 (금액 뒤에 나오는 단어들)
    // 1️⃣ "매장명 사용" 패턴 최우선 선택
    String payeeOrPayer = "";
    final payeeUsageRegExp = RegExp(r'(\S+)\s+사용');
    payeeOrPayer = payeeUsageRegExp.firstMatch(text)?.group(1) ?? "";
    // 2️⃣ "매장명 사용"이 없을 경우 데이터 정제 시작
    if (payeeOrPayer.isEmpty) {
      String filteredText = text;

      // ✅ 2-1: 날짜 (`03/02`, `2024-01-01` 등) 제거
      filteredText = filteredText.replaceAll(RegExp(r'\b\d{2}/\d{2}\b|\b\d{4}-\d{2}-\d{2}\b'), "");

      // ✅ 2-2: 시간 (`13:52`, `09:00 AM` 등) 제거
      filteredText = filteredText.replaceAll(RegExp(r'\b\d{2}:\d{2}\s*(AM|PM)?\b'), "");

      // ✅ 2-3: 계좌번호 (`1234-5678-9012-3456`, `02-123-4567` 등) 제거
      filteredText = filteredText.replaceAll(RegExp(r'\(?\d{2,4}\)?[-.\s]?\d{3,4}[-.\s]?\d{4}'), "");

      // ✅ 2-4: 금융 관련 단어 (`체크`, `신용`, `입금`, `출금`, `잔액`) 제거
      filteredText = filteredText.replaceAll(RegExp(r'\S*(체크|신용|입금|출금|입출금|잔액)\S*'), "");

      // ✅ 2-5: 숫자로만 이뤄진 단어 (`10000`, `1234` 등) 제거
      filteredText = filteredText.replaceAll(RegExp(r'\b\d+\b'), "");

      // ✅ 2-6: '*'이 포함된 단어 제거
      filteredText = filteredText.replaceAll(RegExp(r'\S*\*\S*'), "");

      // ✅ 2-7: 최소 한 글자 이상의 한글 또는 영문 문자가 포함된 단어만 추출
      List<String> words = filteredText.split(RegExp(r'\s+'))
          .where((word) => word.isNotEmpty && RegExp(r'[\p{L}]', unicode: true).hasMatch(word))
          .toList();

      payeeOrPayer = words.isNotEmpty ? words.first : "알 수 없음";
    }

    if (isIncome) {
      return {
        "amount": int.tryParse(amount) ?? 0,
        "transactionTime": transactionTime,
        "payer": payeeOrPayer,
      };
    } else {
      return {
        "amount": int.tryParse(amount) ?? 0,
        "transactionTime": transactionTime,
        "payee": payeeOrPayer,
      };
    }
  }
}

 

각 필드를 추출하는 기준을 상세히 살펴보자면 다음과 같다.

각 필드 추출 기준은, 여러 은행사의 결제내역 문자/알림 형식을 참고하여 최대한 많은 유형의 알림에 대응할 수 있도록 구성했다. 

 

(1) 금액 추출 : 전화번호 및 계좌번호, 날짜 등의 숫자를 먼저 제거 ->"10,000원"처럼 숫자 + 원 형태를 우선 추출 -> 없으면 쉼표 포함 숫자를 후순위로 탐색

(2) 결제시각 추출 : "03/02 13:45" 포맷으로 구성된 날짜 및 시간을 찾고, 이를 현재 연도와 합쳐 yyyy-MM-ddTHH:mm:ss 포맷으로 반환한다.

(3) 결제처 추출 : 가장 선호되는 패턴은 "결제처 사용"처럼 "사용" 앞에 나오는 단어 -> 없을 경우 아래와 같은 텍스트 정제 절차거친 후 남은 단어를 결제처로 추정한다 

<제거 항목>

날짜 03/02, 2024-01-01
시간 14:22, 09:00 AM
계좌번호 / 전화번호 02-123-4567, 010-1234-5678
금융 단어 체크, 입금, 잔액 등
숫자만 있는 단어 10000, 5000
별표 포함 문자 박*현, 카드

 

 

 

위의 로직을 테스트 해보면, 

위와 같은 결제 문자가

{
  "amount": 11000,
  "transactionTime": "2025-05-13T12:21:00",
  "payee": "에스케이플래닛"
}

으로 파싱된다.

 

 

 

 

지금까지의 과정을 통해 에뮬레이터상에서 결제내역 문자/알림 읽어오기 및 파싱이 모두 잘 동작하는 것을 확인했었고, 해당 기능의 구현이 끝난 줄 알았다

 

그런데 실기기(안드로이드 휴대폰)에서 이를 테스트해보니 에뮬레이터완 다르게 위의 기능들이 제대로 동작하지 않았다 !

 

이 트러블슈팅 과정을 3번 목차에 작성하였다.

 

3. 에뮬레이터에선 정상작동 하는데, 실기기에서 작동하지 않는 문제 해결하기

문제 상황 : 알림 접근 허용(권한 허용) 불가

 

에뮬레이터에선 설정 창>알림접근 창에 접속하면, 에뮬레이터의 알림창에 접근 가능한 어플리케이션의 목록이 떴고, 해당 목록에 내가 만든 어플리케이션을 추가하는 방법으로 권한 허용이 가능했다. 그런데 안드로이드 기기(갤럭시)에선, 휴대폰의 설정창 중 위의 기능을 하는 설정창이 존재하지 않았다. 또한 안드로이드 기기에서 앱 정보창에 들어가 보아도,

 

이렇게 허용 가능한 권한 목록 자체가 뜨지 않았다.

해당 사안에 대해 알아보니, 실기기(특히 갤럭시) 에서는 휴대폰에서 사용자가 직접 알림 접근 허용 창을 열 수 없어서, 플러터앱에서 해당 창으로 직접 이동시켜야하는 것이었다.

이에 스플래시 화면에 "알림접근 허용" 버튼을 하나 만들고, 해당 버튼을 클릭하면 알림 접근 허용 창으로 이동하도록 아래와 같이 코드를 작성했다.

import 'package:android_intent_plus/android_intent.dart';

void openNotificationAccessSettings() {
    const AndroidIntent intent = AndroidIntent(
      action: 'android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS',
    );
    intent.launch();
  }

 

 

이 과정을 통해, 드디어 알림 권한 접근을 허용하여 실기기의 알림을 우리 앱에서 접근할 수 있었다

 

 

 

 

이번 글에서는 Flutter & AndroidNotificationListenerService활용하여 로컬 알림을 수신하고, 이를 앱에서 실시간으로 처리하는 전체 흐름을 구현하는 방법을 정리해보았다.


실기기에서 발생할 있는 문제 상황과 해결 과정까지 함께 다루었으니, 유사한 기능을 개발하고자 하는 분들께 유용한 참고가 되기를 바란다.