Joyuno's Dev Blog

2026-06-09 (화) 개발일지

dev-blog

오늘은 네 가지 프로젝트에서 잡힌 이슈들을 정리했다. 특히 demo_apps 세션 리플레이 쪽이 제일 까다로웠다 — SwiftUI accessibility element 포착에서 시작해 SnapNode.role PII 유출로 번지고, 결국 iOS/Android 양쪽의 accessibility identifier 처리 경로를 전면 수정하게 됐다. bit-scalp는 다중 진입 로직이 고장난 게 아니라 RSI 게이트가 너무 타이트했던 문제였고, propermarket-marketing에선 영상 생성 파이프라인에 불필요한 재생성 버그와 모델 단가 차이를 확인했다.

iOS SwiftUI accessibility element — accessibilityIdentifier 캐스트 누락으로 nil 반환

ReplaySnapshotCapturer가 SwiftUI로 그려진 LoginView의 button/input을 전혀 포착하지 못하고 있었다. UIKit view tree walk는 돌지만 walkView()가 _UIHostingView의 accessibilityElements를 읽어도 각 UIAccessibilityElement의 accessibilityIdentifier가 전부 nil로 떨어지는 상태였다.

처음엔 SwiftUI accessibility tree 자체가 element를 안 내보내는 걸로 생각했다. isSwiftUIHostingView 분기를 추가하고 nodeForAccessibilityElement를 호출하도록 했지만 identifier는 계속 nil. accessibilityLabel은 정상적으로 읽혔다. 이 차이를 따라가다 보니 문제는 캐스트에 있었다 — 기존 코드가 (element as? UIView)?.accessibilityIdentifier로 접근하고 있었는데, UIAccessibilityElement는 UIView를 상속하지 않는다. SwiftUI가 내보내는 element는 순수 NSObject 프로토콜 기반이라 as? UIView가 항상 nil을 반환했고, 거기서 identifier를 못 꺼내고 있던 거다.

// Before
let accessId = (element as? UIView)?.accessibilityIdentifier  // 항상 nil

// After — UIAccessibilityElement 프로토콜로 직접 접근
let accessId = (element as? UIAccessibilityElement)?.accessibilityIdentifier

바꾸고 나니 LoginView의 UITextField(SwiftUI TextField의 UIKit 백업)에서 정상적으로 identifier가 잡혔다. 식별자 기반 마스킹(mask_by_exception)이 동작하는 데 이 값이 필요했다. 검증은 ReplaySnapshotCapturerTests 18개 전부 통과로 확인했다. 커밋 abbec81에 반영했고, 남은 건 이 변경이 실제 iOS 디바이스에서 돌 때 identifier 기반 예외 마스킹이 의도대로 동작하는지 런타임 확인이 필요하다.

SnapNode.role PII 유출 — iOS/Android 양쪽에서 화면 텍스트가 구조 필드로 누출

SwiftUI 보강(364010a)을 적용한 직후, QA 스크립트에서 이상한 걸 발견했다. SnapNode.role 필드가 "menu_btn_결제", "동백전", "가맹점 검색" 같은 한글 텍스트를 그대로 담고 있었다. role은 "button", "label", "input" 같은 구조 토큰만 있어야 하는데, 접근성 식별자와 리소스 ID가 그대로 들어가고 있었던 거다. 잔액/계좌번호 같은 마스킹 대상은 아니었지만 구조 필드에 텍스트가 실리는 건 근본적으로 잘못된 설계였다.

iOS 쪽을 먼저 분석했다. 유출 경로는 두 곳이었다: nodeForView에서 view.accessibilityIdentifier를 그대로 role에 할당하는 경로와, nodeForAccessibilityElement에서 SwiftUI의 accessibilityLabel 문자열을 role로 떨궜던 경로. 후자는 전에 내가 추가한 SwiftUI 보강 코드였고 — accessibilityLabel 값을 식별자라고 착각해서 role에 넣었던 게 화근이었다.

“iOS role 유출: role: menu_btn_결제 — 역할이 아니라 화면 텍스트가 구조 필드를 오염시키고 있었다.”

Android도 의심스러웠다. ReplaySnapshotCapturer.kt의 viewRole() 함수가 view.resources.getResourceEntryName(view.id)로 R.id의 entry name을 role에 넣고 있었고, nodeForAccessibilityNode에서 node.viewIdResourceName?.substringAfterLast('/')로 URL-like 리소스 이름을 role에 포함시키고 있었다. 결과는 같았다 — "tv_balance", "btn_submit" 같은 식별 문자열이 구조 필드로 누출.

수정 방향은 단순했다: role에는 구조 토큰만 남기고 텍스트/식별자는 완전히 제거한다. 매칭용 id 값은 로컬 변수로만 쓰고 payload에는 절대 포함시키지 않았다. iOS는 nodeForAccessibilityElement 내에서 accessibilityLabel을 role에 쓰던 라인을 지우고, nodeForView에서는 accessibilityIdentifier를 unmaskId 필드로 옮겼다(마스킹 예외용으로만 사용). Android는 getResourceEntryName 경로를 제거하고 accessibility node의 className 기반으로만 role을 결정하게 바꿨다.

플랫폼 Before (role 예시) After (role 예시)
iOS "menu_btn_결제" "button"
Android "tv_balance" "text"

검증은 양쪽 다 완료했다. iOS(fabca4d)는 테스트 18개 통과 + simulator에서 role 필드에 한글 0건 확인. Android(aa23741)는 모든 역할이 "view", "button", "text" 등 구조 토큰만 있는 걸 QA 스크립트로 검증했다. ClickHouse에 적재된 snapshot에도 한글 유출이 없는 걸 확인했다. 남은 과제는 Android의 OTel batch delay(2초)로 인해 일부 snapshot이 지연 수집되는 현상 — 원인은 확인했고 별도 이슈로 분리했다.

SwiftUI 텍스트 누락 보강 — accessibility identifier 추가로 모든 화면 포착

role 유출 수정을 마치고 나니 다른 문제가 눈에 들어왔다. 로그인 화면은 SwiftUI TextField가 UIKit 백업 덕에 잘 잡혔지만, 그 외 모든 화면(TransactionHistoryView, PaymentView, ChatView 등)이 103개 노드에 role이 "동백전"(NavigationBar title) 하나뿐이었다. accessibility tree가 노드 자체는 내보내지만 label/button count가 전혀 늘지 않았다. SwiftUI가 accessibility element를 자동으로 생성해 주지 않는 뷰들이 많았다.

처음엔 isSwiftUIHostingView 분기가 잘못된 걸로 생각했다. 하지만 로깅을 추가해 보니 walk 자체는 정상이었고, 각 SwiftUI 뷰가 accessibilityElements를 명시 노출하지 않아서 빈 컨테이너로만 잡히는 상황이었다. 해결 방법은 간단했다: 각 뷰에 명시적인 .accessibilityIdentifier를 추가하는 것. iOS 14+ 기준으로 View.accessibilityIdentifier(_:) modifier를 적용하면 UIKit backing view에 identifier가 박히고, walkView()가 그걸 잡아낸다.

수정 범위는 9개 SwiftUI 파일이었다: HomeView, PaymentView, TransactionHistoryView, TransferView, MerchantSearchView, QrView, NotificationCenterView, BalanceDetailView, ChatView. 각 뷰의 주요 interactive element에 identifier를 추가했다. 예를 들어 TransactionHistoryView의 empty 상태 텍스트에는 accessibilityIdentifier("empty_state_text")를, NotificationCenterView의 Picker와 NotificationRow에는 각각 "notification_filter_picker", "notification_row_\(id)" 같은 식별자를 붙였다.

검증은 꽤 까다로웠다. simulator에서 각 화면을 직접 탐색하며 snapshot을 수집했고, dongbaek dev의 ClickHouse에서 mobile.snapshot 이벤트를 쿼리했다. 6개 화면에서 18건의 snapshot이 정상 emit되었고, role 필드는 전부 구조 토큰만, 텍스트 유출은 0건이었다. 커밋 005e1e5에 반영. 남은 건 Android 쪽에도 동일한 SwiftUI(Compose) accessibility identifier 접근법이 필요한지 검토인데, Android는 SemanticsOwner reflection 경로로 이미 대부분 잡히고 있어서 당장은 불필요하다.