일기 slecs

결산 고도화와 안드로이드 결제 모니터 엣지 케이스 정복

목차

4월이 끝났다. 638커밋, 28개월 통틀어 최고치. 새 회사 두 번째 달이라는 점을 고려하면 꽤 거칠게 달린 한 달이었다. 맥락이 쌓이기 전에 이 숫자가 나왔다는 것, 말하자면 이해보다 실행이 앞선 시기였다는 뜻이기도 하다.

결산 도메인을 처음부터 다시 설계한다는 것

slecs 결산 모듈에서 이번 달에 가장 공을 들인 건 발생주의(accrual basis) 전환이다. 현금주의는 실제로 돈이 오갔을 때 수익과 비용을 인식하고, 발생주의는 거래가 발생한 시점을 기준으로 삼는다. 이 차이가 초기엔 별거 아닌 것처럼 보이지만, 서비스 규모가 커지고 정산 사이클이 복잡해지면 현금주의 대시보드는 서서히 거짓말을 하기 시작한다. 월말에 정산 지연된 건들이 다음 달 숫자를 왜곡하고, 대시보드를 믿을 수 없게 되면 결국 엑셀로 돌아가는 수순이 된다.

그래서 이번에 기준을 바꿨다. 대시보드는 발생일 기준으로 집계하되, 아직 확정되지 않은 건들은 시각적으로 구분해서 보여주는 방식. 확정/대기를 내부 키로 분리한 이유도 여기서 나온다. UI에서 같은 날짜 행에 두 서브라인이 붙는 구조인데, 단순히 컬럼 하나 추가하는 작업이 아니다. 시간축 그리드 자체를 건드리는 작업이고, 날짜 키를 어떻게 구성할 것인가라는 설계 결정이 먼저 필요하다.

// 발생주의 타임라인 그리드 — 확정/대기 서브라인 키 예시
type DateStr = string  // 'YYYY-MM-DD'
type LineKey = `${DateStr}:confirmed` | `${DateStr}:pending`

function buildTimelineRows(entries: Entry[]): Record<LineKey, Row> {
  return entries.reduce((acc, e) => {
    const status = e.status === 'SETTLED' ? 'confirmed' : 'pending'
    const key: LineKey = `${e.occurredAt}:${status}`
    acc[key] = mergeRow(acc[key], e)
    return acc
  }, {} as Record<LineKey, Row>)
}

PENDING 카드를 어디까지 포함할지는 정책 결정이 선행됐다. 기술적으로는 전부 보여주면 간단하지만, 그러면 미확정 수익이 확정 수익처럼 읽힐 수 있다. 반대로 PENDING을 완전히 빼면 월중 대시보드가 실제보다 작게 잡히고, 운영 입장에서 "지금 어떻게 되고 있는지"를 파악하기 어려워진다. 결국 "PENDING은 보여주되, 시각적으로 구분한다"는 방향으로 잡았다. 이 결정 자체가 도메인 정책이고, 코드에서 그게 자연스럽게 읽혀야 한다.

PG 실원가(A)와 플랫폼 수수료 최솟값(B)을 분리한 것도 같은 결 위에 있다. 수수료 구조가 단순할 때는 fee 컬럼 하나로 버티지만, PG사 실제 차감액과 플랫폼에서 가져가는 최솟값은 계산 근거가 다르다. 이 둘을 하나의 필드에 합산해서 관리하면 정산 이슈가 생겼을 때 어디서 차이가 발생했는지 역추적이 안 된다. 분리가 귀찮아 보여도 결국 분리가 맞다.

이달에 docs(rules): 접두사를 붙인 커밋을 따로 쌓은 게 개인적으로 만족스러운 부분이다. 설계 결정의 근거를 코드 변경과 별개로 git 히스토리에 남기는 방식. 6개월 뒤에 내가 이 코드를 다시 봤을 때, 혹은 다른 사람이 이어받았을 때 "왜 이렇게 했지?"라는 질문에 커밋 메시지 하나가 답을 준다. ADR(Architecture Decision Record)을 별도 파일로 관리하는 방법도 있지만, 실제로 더 자주 읽히는 건 커밋 히스토리다. 파일은 찾아가야 하지만 git log는 흐름과 같이 보인다.

Android 14+에서 FGS가 터지는 구조

pay-monitor 쪽 커밋은 3개지만 하나씩 밀도가 있었다.

Android 14부터 Foreground Service 정책이 강화됐다. 핵심은 foregroundServiceType을 명시하지 않으면 런타임에 크래시가 난다는 점이다. 13 이하에서는 선언을 빠뜨려도 동작했지만, 14부터는 타입 없는 FGS 실행 자체를 허용하지 않는다. 앱이 백그라운드에서 화면을 감지하거나 지속 동작해야 하는 구조라면 이 변경이 직격이다.

<!-- AndroidManifest.xml — Android 14+ FGS 타입 명시 -->
<service
    android:name=".PayMonitorService"
    android:foregroundServiceType="mediaProjection|specialUse"
    tools:targetApi="34" />

매니페스트 선언에 더해서 런타임 권한 요청 분기도 손봤다. specialUse 타입은 Play 심사 정책이 점점 엄격해지고 있어서 장기적으로 다른 접근을 고민해야 할 수 있지만, v2.0.4 기준에서는 여기까지.

dispatchGesture로 보안 뷰를 우회한 케이스는 구조적으로 흥미로웠다. TYPE_SECURE 플래그가 붙은 윈도우는 캡처도 안 되고 접근성 API로 내용을 읽는 것도 막힌다. 선택한 방법은 뷰 내용을 읽는 게 아니라 물리 탭 이벤트 자체를 재현하는 것. 접근성 서비스를 통해 GestureDescription을 구성하고 dispatchGesture로 흘려보내면, 뷰 내용에 접근하지 않고 터치 액션만 통과한다.

// 보안 뷰 물리탭 시뮬레이션
val path = Path().apply { moveTo(targetX, targetY) }
val stroke = GestureDescription.StrokeDescription(path, 0L, 50L)
val gesture = GestureDescription.Builder().addStroke(stroke).build()
accessibilityService.dispatchGesture(gesture, null, null)

카카오페이 송금봉투 닉네임 발신자 케이스는 알림 텍스트 파싱 분기 문제였다. 실명 발신자와 닉네임 발신자의 알림 포맷이 달라서, 기존 정규식 한 세트로 두 경우를 커버하지 못했다. 패턴을 분리하고 매칭 우선순위를 정리하는 방향으로 해결했다. 카카오뱅크 송금봉투 자동수령도 같은 파이프라인에서 처리되는 구조라, 이쪽 파싱이 안정되면 전체 신뢰도가 같이 올라간다.

레포 커밋 주요 작업
slecs 625 발생주의 대시보드, 확정/대기 키 분리, PG 수수료 분리
monitoring 10 서비스 모니터링
pay-monitor 3 Android 14+ FGS 크래시 수정, 보안 뷰 우회(v2.0.4), 카카오페이 닉네임 케이스

4월 하루 평균 21커밋이라는 숫자보다, 이 달에 다룬 도메인의 스펙트럼이 더 기억에 남는다. 발생주의 회계 설계, Android FGS 정책 대응, PG 수수료 모델링 — 각각 단독으로도 며칠은 쓸 수 있는 주제들을 같은 달에 병렬로 밀었다. 컨텍스트 스위칭 빈도가 높았던 만큼, 결정을 코드 밖 히스토리에 남겨두는 습관이 더 절실하게 느껴진 달이기도 했다.

새 회사 두 번째 달. 아직 모르는 게 많고, 그게 계속 달리게 만드는 이유다. 5월엔 YouTube Shorts 자동화 파이프라인이 기다리고 있다. 또 다른 도메인이 쌓인다.

댓글 0

첫 댓글 달아줘.