2026/05/19

오늘의 이야기

🧾 바코드/QR 영수증 스캐너 앱 개발기 (BarcodeVoucher0407)


앱 메인 화면



 


일상에서 쉽게 버려지는 영수증들을 스마트하게 관리할 수 있는 바코드/QR 기반 영수증 적립 및 조회 앱을 개발했습니다. 이 앱은 단순히 영수증을 저장하는 것을 넘어, AI 기반 OCR(광학 문자 인식) 로 영수증의 내용을 자동으로 파악하고, 카카오맵과 연동하여 사용처의 위치까지 저장할 수 있는 똑똑한 가계부 역할을 합니다.


🛠 1. 프로젝트 개요 및 기술 스택


🎯 제품 목표



  • 바코드/QR 스캔 및 갤러리/카메라 이미지를 통한 영수증 디지털 보관

  • Groq AI (Llama 비전 모델) 를 활용한 자동 영수증 파싱 (매장명, 금액, 결제일 등)

  • 카카오맵 API를 활용한 매장 위치 시각화 및 저장

  • 기간별(월별/일별) 및 카테고리별 지출 통계 대시보드 제공

  • Play Core를 활용한 매끄러운 앱 내 업데이트(In-app Update) 제공


💻 기술 스택



  • 언어: Kotlin

  • UI: Jetpack Compose (단방향 데이터 흐름 및 상태 관리)

  • 아키텍처: MVVM + Clean Architecture (Repository, UseCase, 점진적 DTO 분리)

  • 데이터베이스: Room (Offline-first 구조)

  • 비동기 처리: Coroutines + Flow

  • 네트워크: Ktor Client (Groq AI API 연동)

  • 의존성 주입: Dagger Hilt

  • 기타: Kakao Map API, DataStore(설정 관리), ZXing(바코드 스캔)




🏗 2. 주요 아키텍처와 리팩토링 전략


✅ Clean Architecture 와 UseCase의 도입


앱의 덩치가 커짐에 따라 비즈니스 로직이 ViewModel에 집중되는 현상을 방지하기 위해 ObserveReceiptsUseCase, AnalyzeReceiptImageUseCase 등의 UseCase 계층을 도입했습니다. 이를 통해 로직의 재사용성을 높이고 테스트 용이성을 개선했습니다.


✅ Entity와 DTO의 분리


UI 계층에 Room의 Entity 모델이 직접 노출되는 것을 막기 위해 단계적인 리팩토링을 진행했습니다. 조회용 데이터를 ReceiptSummary, ReceiptDetail과 같은 DTO (Data Transfer Object) 로 매핑하여 도메인 경계를 확실히 구분하였습니다.




🚀 3. 핵심 구현 내용 (주요 코드)


🤖 3.1. Groq AI를 활용한 영수증 OCR과 JSON 파싱 내성 강화


가장 신경 쓴 부분 중 하나는 AI 모델 응답의 불안정성 해결입니다. LLM이 생성한 JSON이 때로는 형식이 깨져서 오거나 불필요한 마크다운 백틱(```)이 붙어오는 문제가 있었습니다. 이를 해결하기 위해 3단계 Fallback 로직을 적용했습니다.


// ReceiptOcrPayloadParser.kt 발췌
private fun parsePayload(rawContent: String): ReceiptOcrPayload {
// 1단계: 마크다운 찌꺼기(BOM, 백틱 등)를 제거하고 JSON 후보 텍스트만 추출
val jsonCandidate = runCatching { rawContent.extractJsonCandidate() }
.getOrElse { rawContent.trim() }

runCatching {
return json.parseToJsonElement(jsonCandidate).jsonObject.toPayload()
}

// 2단계: JSON 파싱 실패 시, 깨진 따옴표나 쉼표를 교정하여 재시도
val repaired = jsonCandidate.repairJsonCandidate()
runCatching {
return json.parseToJsonElement(repaired).jsonObject.toPayload()
}

// 3단계: 모두 실패할 경우 rawText만이라도 보존하여 반환
return ReceiptOcrPayload(
storeName = null, totalAmount = null, currency = null,
purchasedAtIso = null, memo = null,
rawText = rawContent.take(300).trim().ifBlank { null },
)
}

// 텍스트 기반 휴리스틱 분석 (총 금액 추출)
private fun String.extractLikelyTotalAmount(): Long? {
val totalLabelRegex = Regex(
"(\\uCD1D\\s*\\uD569\\uACC4|\\uD569\\uACC4|\\uCD1D\\uC561|\\uACB0\\uC81C\\s*\\uAE08\\uC561)[^0-9]{0,8}([0-9][0-9,]{2,})",
RegexOption.IGNORE_CASE
)
return totalLabelRegex.find(this)?.groupValues?.getOrNull(2)
?.replace(",", "")?.toLongOrNull()
}

Tip: AI가 JSON 생성을 완벽히 하지 못하는 경우를 대비해, 응답 평문에서 정규식을 이용해 영수증 금액과 상호명을 2차로 추출하는 안전장치(Fallback)를 두었습니다.


📊 3.2. Compose Canvas로 직접 그린 지출 통계 차트




서드파티 라이브러리에 의존하지 않고, Jetpack Compose의 CanvasAnimatable을 활용하여 애니메이션이 포함된 바 차트(Bar Chart) 를 직접 구현했습니다.


// StatsScreen.kt 발췌
@Composable
private fun StatBarChart(
labels: List<String>,
values: List<Long>,
primaryColor: Color,
modifier: Modifier = Modifier,
) {
val maxValue = values.max().toFloat().coerceAtLeast(1f)
// 진입 시 아래에서 위로 올라오는 700ms 애니메이션
val animProgress = remember(values) { Animatable(0f) }
LaunchedEffect(values) {
animProgress.snapTo(0f)
animProgress.animateTo(1f, animationSpec = tween(700))
}

Canvas(modifier = modifier.fillMaxWidth().height(190.dp)) {
val chartW = size.width
val barMaxH = size.height * 0.68f
val slotW = chartW / values.size

values.forEachIndexed { idx, value ->
val ratio = (value.toFloat() / maxValue) * animProgress.value
val barH = barMaxH * ratio

// 막대 그리기
drawRoundRect(
color = primaryColor,
topLeft = Offset(slotW * idx + (slotW / 4f), size.height - barH - 20f),
size = Size(slotW / 2f, barH),
cornerRadius = CornerRadius(5.dp.toPx())
)
}
}
}

💾 3.3. Room DB를 이용한 강력한 SQLite 집계 통계


앱 내에서 보여지는 월별/일별 지출 통계는 앱 단에서 계산하는 대신, Room 데이터베이스의 SQLite 쿼리를 적극 활용하여 성능을 최적화했습니다.


// ReceiptDao.kt 발췌
@Query("""
SELECT
strftime('%Y-%m', datetime(COALESCE(purchasedAt, createdAt) / 1000, 'unixepoch', 'localtime')) AS month,
SUM(COALESCE(totalAmount, 0)) AS totalAmount,
COUNT(*) AS count
FROM receipts
WHERE (:startMillis IS NULL OR COALESCE(purchasedAt, createdAt) >= :startMillis)
AND (:endMillis IS NULL OR COALESCE(purchasedAt, createdAt) <= :endMillis)
GROUP BY month
ORDER BY month DESC
""")
fun observeMonthlyStats(
startMillis: Long?,
endMillis: Long?
): Flow<List<MonthlyStatRow>>

기간 필터까지 DB 단에서 처리하고, 결과를 Flow로 반환받아 UI에 즉시 리액티브하게 반영되도록 구성했습니다.




💡 4. 마무리 및 회고


단일 모듈로 시작하여 MVP를 빠르게 완성한 프로젝트입니다. 개발 과정에서 특히 흥미로웠던 부분은 AI 기반 OCR을 연동하며 발생한 다양한 예외 처리였습니다. AI의 응답은 항상 일관되지 않기 때문에 정규식 Fallback이나 JSON 교정 로직 같은 방어적 프로그래밍이 매우 중요하다는 것을 배웠습니다.


📌 향후 고도화 계획:



  1. 사용자 맞춤형 커스텀 카테고리 기능 및 도넛(파이) 차트 시각화 추가

  2. OCR 처리 속도 및 정확도 향상을 위한 AI 모델 A/B 테스트 정교화

  3. 기능 확장에 대비한 멀티 모듈(core, feature 등) 분리 작업


Jetpack Compose와 Kotlin 최신 스택들을 활용해 클린 아키텍처를 도입해 보는 뜻깊은 경험이었습니다. 🚀





댓글 없음:

댓글 쓰기

오늘의 이야기

#스치니1000프로젝트 #재미 #행운기원 #Compose #Firebase 🎯 야 너 토요일마다 로또 확인하냐? 나도 맨날 “혹시나~” 하면서 봤거든 ㅋㅋ 근데 이제는 그냥 안 해 AI한테 맡겼어 🤖✨ 그것도 구글 Gemini로다가! ...