<서론>
이 글은 이화여자대학교 캡스톤 디자인과 창업프로젝트 - 그로쓰 중 팀의 프론트엔드 팀원으로서 내가 담당한 기술적 부분에 대해 다루는 글이다.
글의 주제는, 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**을 통해 받아야 한다.
아래 코드는 Android의 NotificationService.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 리스트에 포함된 단어가 tickerText나 text에 포함되어 있다면, 해당 알림은 결제 관련 알림으로 간주된다.
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 & Android의 NotificationListenerService를 활용하여 로컬 알림을 수신하고, 이를 앱에서 실시간으로 처리하는 전체 흐름을 구현하는 방법을 정리해보았다.
실기기에서 발생할 수 있는 문제 상황과 그 해결 과정까지 함께 다루었으니, 유사한 기능을 개발하고자 하는 분들께 유용한 참고가 되기를 바란다.