들어가며
이전 편에서 동작하는 v1을 띄우고 A/B 실험으로 카드 등록 전환율이 38%에서 73%로 올라간 걸 확인했다. 큰 신호였지만, 배포 직후의 평균 TTC(카드 등록 화면 진입 → 등록 완료)는 2.5분으로 직접 입력 코호트의 3.3분과 0.8분 차이밖에 나지 않았다. 잘못 인식된 번호로 등록을 시도하다가 결국 수동으로 다시 입력하는 케이스가 이 격차를 만들고 있었다.
문제는 분명했다. 정확도를 더 끌어올려야 했다. 그런데 알고리즘을 한 줄 손보려면 실기기에 빌드를 올리고 실제 카드와 다양한 조명을 다시 세팅해야 했고, 특정 실패 케이스를 같은 조건으로 재현하기도 어려웠다. 정확도와 개선 사이클이 서로의 발목을 잡고 있었다.
이 글은 그 두 문제를 푼 순서의 기록이다. 카메라 없이 파이프라인을 돌릴 수 있는 구조를 SPM 모듈로 먼저 만들고, 그 위에 Laplacian 게이팅·BIN 검증·Sliding Window를 얹어 정확도를 끌어올렸다. v1부터 켜져 있던 CLOVA OCR fallback 호출도 게이팅이 들어오면서 자연히 줄었다.
진행 배경
v1 배포 직후의 상태를 한 줄로 정리하면 이렇다.
| 코호트 | 카드 등록 화면 진입 → 등록 완료 평균 TTC |
|---|---|
| A — 직접 입력 | 3.3분 |
| B — 스캐너 (v1) | 2.5분 |
스캐너 자체는 돈다. 다만 잘못된 번호가 폼에 채워지는 케이스가 반복됐다. 광택 반사, 흔들린 프레임, 카드 배경 노이즈가 주된 원인이었고, v1에는 출력 단의 자동 검증이 따로 없어서 OCR이 카드 배경 패턴을 우연히 16자리 숫자열로 읽어 내면 그대로 사용자 확인 화면에 띄워졌다. 사용자가 한 번 검토하는 가드만 있을 뿐, 후보 자체를 거를 장치가 비어 있는 상태였다.
정확도를 끌어올리려면 Laplacian 임계값을 새로 잡거나 결과 안정화 로직을 바꿔야 했는데, 그 변경의 효과를 확인할 수 있는 유일한 방법이 매번 실기기로 카드를 들고 다양한 조명에서 직접 찍어 보는 것이었다. 알고리즘 작업을 시작하기 전에 카메라 없이 파이프라인을 돌릴 수 있는 구조부터 만들어야 했다.
카메라 없이 돌리는 구조
카메라 의존이 개선 사이클을 막는다
v1 파이프라인은 다음 순서로 한 프레임을 카드 번호 한 줄로 바꾼다.
AVCaptureSession (30fps)
│
▼
MotionGate ← 흔들림 필터링 *
│
▼
CardDetector ← 카드 영역 감지 + 원근 보정 *
│
▼
OCR Pipeline ← Vision, 텍스트 파싱, 투표 알고리즘
│
▼
카드 번호 확정
MotionGate(기기 흔들림 감지)와 CardDetector(카드 영역 원근 보정)는 이 시리즈에서 따로 다루지 않은 보조 단계다(*). 이 글의 무대는 그 사이의 핵심 파이프라인, 곧 게이팅과 OCR이다.
문제는 입구에 AVCaptureSession이 박혀 있다는 점이었다. 시뮬레이터에서 돌지 않고, CI에는 카메라도 실제 카드도 없다. 이 입구를 그대로 둔 채로는 알고리즘 한 줄을 고칠 때마다 실기기·실제 카드·다양한 조명을 다시 세팅해야 한다. 카드 스캐너 같은 기능이 자연스럽게 "수동 테스트 영역"으로 분류되어 커버리지 차트의 음영 지대로 사라지는 게 이 지점이다. 합리적으로 보이는 결론이지만, 사실은 입구를 갈아 끼울 수 있다. 그걸 가능하게 하는 단서가 v1 파이프라인이 받는 데이터 타입 자체에 이미 들어 있다.
CMSampleBuffer는 카메라 전용 타입이 아니다
AVCaptureVideoDataOutputSampleBufferDelegate의 콜백 시그니처를 보면 카메라가 매 프레임 넘기는 타입이 CMSampleBuffer라는 걸 알 수 있다. Core Media의 범용 미디어 샘플 컨테이너로, 픽셀 버퍼와 타이밍 정보, 포맷 설명을 한 묶음으로 담고 있다.
CMFormatDescription— 픽셀 포맷, 해상도, 코덱 정보CMSampleTimingInfo— 프레젠테이션·디코딩 타임스탬프, 지속 시간CVPixelBuffer(또는CMBlockBuffer) — 실제 픽셀 데이터
여기까지는 자연스럽다. 결정적인 건 다른 경로에서도 같은 타입이 나온다는 사실이다. AVAssetReader로 MOV 파일을 열고 AVAssetReaderTrackOutput에서 copyNextSampleBuffer()를 반복 호출해도 돌아오는 게 똑같이 CMSampleBuffer다.
Apple이 이렇게 설계해 둔 이유는 미디어 소스를 추상화해서 처리 파이프라인이 소스에 의존하지 않게 만들기 위해서다. 그 설계를 그대로 활용하면 된다. 파이프라인이 CMSampleBuffer만 받아 처리하는 구조라면, 그 버퍼가 카메라에서 왔는지 디스크의 MOV에서 왔는지 파이프라인 입장에서는 구분할 이유가 없다. 카메라는 교체 가능한 소스가 된다. 남은 건 이걸 코드로 강제할 경계를 만드는 일이다.
SPM 패키지로 모듈을 잘라낸다
카드 스캐너 코드를 앱 타겟 안에 그대로 둔 상태에서는 두 가지가 동시에 막힌다. 빌드 그래프 측면에서, 앱 타겟은 AppDelegate·Feature·Network·Analytics·Firebase 등 모든 노드가 한 링크 단위로 묶인 DAG다. 카드 스캐너 파일 하나를 고쳐도 Xcode가 변경 영향을 추적해야 하는 범위가 앱 전체로 번지고, 빌드는 느려지고, 의존성도 슬쩍 새어 나간다. API 측면에서, 앱 타겟 안에서는 internal로도 어디서든 접근이 가능하므로 카드 스캐너의 내부 타입이 앱 코드 어딘가에 직접 쓰여 있는지 컴파일러가 막아 주지 않는다.
그래서 카드 스캐너를 SendyCardScanner라는 로컬 SPM 패키지로 떼어 냈다.
로컬 패키지는 독립적인 컴파일 단위가 된다. 패키지 안의 internal 로직을 아무리 손봐도 public API가 그대로면 앱 타겟은 캐시된 패키지 빌드를 그대로 쓰니, OCR 파라미터를 조정하면서 앱 전체를 다시 빌드할 일이 없어진다. 그리고 패키지 경계 안에서는 public으로 선언하지 않은 타입이 외부에서 보이지 않는다. "이것만 바깥에서 써라"가 문서가 아니라 컴파일러로 강제된다.
NOTE: "내부 타입 직접 쓰지 마세요"를 README에 적어 두는 것과 컴파일 에러로 막아 두는 것 사이에는 큰 차이가 있다. 모듈 경계의 가치는 이 강제력에서 나온다.
FrameSource 프로토콜과 세 구현체
경계가 생겼으니 입구를 추상으로 바꿀 차례다. 파이프라인이 의존하는 건 "프레임 소스" 한 가지이고, 그 역할은 단순하다. CMSampleBuffer를 비동기로 흘려 보내면 된다. 이걸 그대로 타입으로 옮기면 AsyncStream<CMSampleBuffer>다.
NOTE:
AsyncStream을 고른 게 단순히 async/await을 쓰고 싶어서가 아니다. 카메라 쪽이 callback 기반 delegate(AVCaptureVideoDataOutputSampleBufferDelegate)인 반면 파이프라인 쪽은 Swift Concurrency 시퀀스로 받고 싶었는데,AsyncStream이 두 세계를 잇는 어댑터 역할을 해 준다. 생산자와 소비자가 같은 concurrency context에 묶일 필요도 없다.
같은 FrameSource 프로토콜에 세 구현체가 붙는다.
CameraFrameSource(프로덕션) —AVCaptureSession·AVCaptureDeviceInput·AVCaptureVideoDataOutput·세션 프리셋·권한 요청까지 포함. 시뮬레이터·CI에서는 못 돈다.VideoFileFrameSource(통합 테스트) —AVAssetReader로 번들 안 MOV 파일을 열어 프레임 단위로 분해해서 같은 스트림으로 흘려 보낸다. 파이프라인 입장에서 카메라와 구분되지 않는다. 앞에서 본CMSampleBuffer단서가 여기서 수확된다.MockFrameSource(단위 테스트) — 미리 정의한CMSampleBuffer배열을 순서대로 토해낸다. 영상 파일조차 필요 없다.
파이프라인은 프로토콜 타입 하나만 주입받는다. 어떤 구현체가 들어오든 파이프라인 코드는 한 줄도 바뀌지 않는다.
테스트 3계층과 Example 앱
세 구현체에 맞춰 테스트도 세 계층으로 깔린다.
단위 테스트 — Luhn, BIN 프리픽스 매칭, 텍스트 파싱 정규식, 바운딩박스 그룹핑, 자릿수별 합의 같은 순수 함수. 입력→출력이 결정론적이라 CI에서 밀리초 단위로 끝난다. 국내전용 카드(Luhn 미준수)·전화번호 오탐·Vision 좌표계 반전 같은 엣지 케이스를 입출력 쌍으로 박아 두면 알고리즘 수정 중에 소리 없이 깨지는 사고를 막을 수 있다.
통합 테스트 — VideoFileFrameSource + 번들 MOV. 실제 카드를 촬영한 클립을 패키지 리소스로 박아 두고, 파이프라인 전체 경로(Vision OCR, 바운딩박스 그룹핑, 결과 안정화)를 그대로 돌린 뒤 결과 카드 번호를 검증한다.
test_card_horizontal.MOV → VideoFileFrameSource
│
▼
CMSampleBuffer Stream
│
▼
CardScanPipeline (실제 구현)
│
▼
CardScannerResult
│
▼
XCTAssertEqual(result.cardNumber, "4111 1111 1111 1111")
가장 큰 변화는 실패 케이스 재현 방식이다. 이전에는 "이 카드, 이 조명에서 안 됐다"는 보고가 들어오면 비슷한 환경을 직접 다시 만들어야 했는데, 이제는 그 시점의 프레임을 저장해 두고 같은 입력을 반복적으로 돌릴 수 있다. 알고리즘 개선 사이클의 전제 조건이 갖춰진 셈이다.
NOTE: MOV 파일을 SPM 리소스로 추가할 때는 반드시
.copy를 쓴다..process로 넣으면 Xcode가 영상을 변환해 버려서AVAssetReader가 못 읽는 형태가 되거나 예상치 못한 변환이 들어간다. 이 실수는 빌드는 성공한 채로 테스트가 런타임에 죽는 모양이라 원인을 찾는 데 시간이 걸린다. 테스트 코드에서는 SPM이 자동 생성해 주는Bundle.module로 리소스에 접근한다.
UI 테스트 — Example 앱. 토치 밝기, 프리뷰 레이아웃, 인식 성공 애니메이션의 체감 속도, 어두운 환경에서의 동작은 수치로 단정할 수 없다. 이 영역은 패키지 안에 둔 Example 앱에서 실제 디바이스로 수동 확인한다. 카메라 권한 거부 시나리오·취소 플로우·완료 화면 전환 같은 자동화 가능한 부분은 Page Object 패턴으로 같은 앱에서 자동 테스트로 묶고, 자동화가 어려운 부분만 수동에 남긴다.
Example 앱이 테스트 한 계층만 떠받치는 건 아니다. 메인 앱을 빌드하지 않고 카드 스캐너만 띄울 수 있는 격리된 작업 환경이라, 인식 알고리즘을 한 줄 바꾸고 실제 카메라로 확인하는 사이클이 짧아진다. 메인 앱의 인증·딥링크·초기화 로직을 매번 거치지 않아도 된다. 동시에 이 패키지를 처음 만나는 사람이 사용 예시를 바로 볼 수 있는 살아 있는 문서이기도 하다. 프로토콜 주입, 라이프사이클 관리, 결과 처리가 모두 실제로 도는 코드 위에 그려져 있고, API가 바뀌면 Example 앱이 컴파일 에러를 내므로 문서와 코드가 자연히 동기화된다.
부수 효과
이 구조는 테스트 가능성만 가져온 게 아니다.
SendyCardScanner가 명시적 경계로 떨어져 있으니, 다른 앱이나 다른 팀이 같은 카드 스캐너가 필요할 때 로컬 참조 경로 한 줄만 바꿔서 그대로 가져갈 수 있다. 코드를 복사하거나 별도 라이브러리로 배포할 필요가 없다. public API에 노출된 것만 외부 기능이고 그 외에는 외부에서 요청이 와도 줄 수 없다는 신호가 코드로 박혀 있어, scope creep을 막는 자연스러운 경계도 같이 생긴다. 새 팀원이 카드 스캐너만 이해해야 할 때 패키지 하나만 열면 되고, 외부에 의존하는 것과 외부가 이 패키지에 의존하는 것이 모두 명시적으로 선언되어 있다.
여기까지가 알고리즘 개선의 전제 조건이었다. 이 구조 위에서 정확도를 손본 순서가 다음 절이다.
알고리즘 강화
인프라가 갖춰진 뒤 정확도를 손본 순서는 입력 단 게이팅 → 출력 단 검증 → 결과 안정화 였다. 입구에서 들어오는 프레임 품질을 끌어올리면 Vision 오인식 빈도와 그에 따라 호출되는 CLOVA fallback이 같이 줄고, 출력 단에 한 겹 더 검증을 붙이면 우연히 통과하는 가짜 번호가 걸리며, 마지막으로 여러 프레임을 합쳐 단일 프레임 노이즈를 평균화한다.
Laplacian 분산으로 입구를 좁힌다
v1은 ROI 크롭과 스로틀링까지만 두고 30fps 프레임을 그대로 OCR로 흘려보냈다. 그 결과 손이 살짝 흔들렸거나 카드가 비스듬한 프레임에서도 OCR이 무언가를 반환했고, Luhn까지 어떻게 통과한 오인식이 폼에 채워지는 케이스가 반복됐다. Vision이 실패하면 CLOVA로 넘어가는데, 흐린 프레임을 그대로 보내고 있으니 fallback 호출도 같이 쌓였다.
문제의 위치는 OCR이 아니라 OCR로 들어가기 전 단계였다. 30fps 스트림으로 프레임 상태별 결과를 직접 측정해 보면 이렇다.
| 프레임 상태 | OCR 결과 |
|---|---|
| 선명·정면 | 카드 번호 정확히 인식 |
| 살짝 흐림 | 일부 자리가 비슷한 형태로 대체 (예: 8→6, 0→9) |
| 심하게 흐림 | 숫자와 무관한 문자열 |
| 움직임 블러 | 인식 실패 또는 완전히 다른 결과 |
OCR 앞에 "이 프레임은 읽을 가치가 있는가"를 먼저 판단하는 필터가 필요했다. 선명도를 수치로 정량화하는 방법은 Sobel·Prewitt(1차 미분), Laplacian(2차 미분) 등 여러 가지가 있는데, 카드 번호는 폰트와 인쇄 방향이 카드마다 다르기 때문에 수평·수직에 편향된 1차 미분보다 방향에 무관한 Laplacian이 맞는다고 봤다. Sobel은 Gx·Gy를 따로 계산해 합쳐야 하지만 Laplacian은 단일 커널로 끝나는 점도 가벼웠다.
컨볼루션·픽셀 배열·필터의 일반론은 영상 처리 기초 — 픽셀, 컨볼루션, 그리고 파이프라인에 정리되어 있다.
이산 이미지에 적용하는 3×3 Laplacian 커널은 다음과 같다.
0 1 0
1 -4 1
0 1 0
각 픽셀에 이 커널을 합성곱으로 적용하면 주변 픽셀 대비 해당 픽셀이 얼마나 튀는지가 계산된다. 균일한 영역은 0에 가까운 값, 엣지에서는 큰 값. 처음에 Swift로 픽셀 반복문을 직접 돌렸더니 30fps 경로에서 프레임 드롭이 났고, Accelerate 프레임워크의 vImage(vImageConvolve_Planar8)로 옮긴 뒤에야 실시간으로 굴러갔다. SIMD로 한 명령어에 여러 픽셀을 처리한다.
NOTE:
Planar8은 8비트 단일 채널, 즉 Grayscale이다. 선명도 판단에 색상 정보는 필요 없으므로 Grayscale로 먼저 변환한다.
선명도 지표로 평균이 아니라 분산을 쓰는 이유가 있다. 선명한 이미지는 엣지 픽셀(큰 값)과 균일한 영역(작은 값)이 함께 있어 분산이 크고, 흐린 이미지는 전반적으로 값이 작고 고르게 분포해 분산이 작다. 평균은 이 차이를 잡지 못한다. 분산은 vDSP 함수로 빠르게 계산한다. vDSP_meanv로 평균, vDSP_measqv로 제곱 평균을 구한 뒤 로 두 값을 빼면 끝이다.
임계값 자체는 이론이 아니라 실측으로 정한다. 여러 조건에서 프레임을 모아 분산을 측정해 본 결과는 이렇다.
| 상태 | Laplacian 분산 |
|---|---|
| 선명·정면·충분한 빛 | 700~1500 |
| 살짝 흐림 또는 약한 빛 | 300~600 |
| 움직임 블러 | 50~200 |
| 완전히 뭉개짐 | < 50 |
여기서 임계값을 500으로 잡았다. 너무 엄격하면 인식 시도가 적어져 사용자가 오래 기다리게 되고, 너무 느슨하면 흐린 프레임이 통과해 실패율이 올라간다. "카드를 들고 있는데 인식이 안 된다"는 피드백과 "엉뚱한 번호가 채워졌다"는 피드백 사이에서 반복 조정해 수렴한 값이다.
NOTE: 조명이 매우 어둡거나 카드 표면이 홀로그램 재질이면 임계값이 달라질 수 있다. 앱 업데이트마다 테스트 디바이스에서 다시 측정했고, 이 값은 상수로 박지 말고 설정값으로 관리하는 편이 안전하다.
게이팅 도입 전후를 사내 테스트(50회 시도 기준)로 비교하면 입구 한 겹의 효과가 분명히 보인다.
| 지표 | 도입 전 (v1) | 도입 후 |
|---|---|---|
| 스캔 성공률 | 63% | 91% |
| OCR 호출 횟수 (50회 기준) | ~1,500회 | ~200회 |
| 평균 인식 소요 시간 | 3.4초 | 1.9초 |
OCR 호출이 약 87% 줄었는데 성공률은 오히려 올라갔다. 더 많이 시도해서 더 잘 맞춘 게 아니라 좋은 프레임만 골라서 시도하니 자연히 정확해진 결과다. 같은 이유로 v1부터 켜져 있던 CLOVA fallback 호출 빈도도 함께 떨어졌다.
Luhn 체크섬과 BIN으로 출력을 거른다
v1은 출력 단에 자동 검증을 두지 않고 사용자 시각 검증만으로 마무리했는데, 그 결과 카드 배경 패턴이 우연히 16자리로 읽혀 폼에 그대로 채워지는 사고가 반복됐다. 자동 가드 한 겹이 명백히 필요했다.
가장 가벼운 후보는 Luhn 체크섬이다. 1954년 IBM의 Hans Peter Luhn이 카드 청구서 입력 오타를 잡으려고 설계한 1자리 체크섬 알고리즘으로, 오른쪽 끝부터 짝수 자리에만 ×2를 걸고(결과가 10 이상이면 자릿수 합으로 환원) 모든 자릿수의 합이 10의 배수면 유효로 본다. 단일 자리 오타는 100% 잡고, 인접 두 자리 전치는 09 ↔ 90을 제외하면 거의 다 잡는다.
func passesLuhn(_ digits: [Int]) -> Bool {
var sum = 0
for (i, d) in digits.reversed().enumerated() {
if i.isMultiple(of: 2) { sum += d }
else { let doubled = d * 2; sum += doubled > 9 ? doubled - 9 : doubled }
}
return sum.isMultiple(of: 10)
}다만 Luhn 하나로는 빈틈이 크다. Luhn을 통과하는 16자리를 인위적으로 만들기가 너무 쉽다. 앞 15자리는 아무렇게나 적고 마지막 자리만 합이 10의 배수가 되도록 맞추면 끝이라, 4111 1111 1111 1111 같은 테스트 더미 번호도 그대로 통과한다. 실측에서도 카드 배경 패턴이 우연히 토해낸 숫자열이 Luhn을 통과해 후보로 떨어지는 케이스가 잡혔다. 그래서 한 겹을 더 얹었다. BIN(Bank Identification Number) 검증이다.
ISO/IEC 7812에 따르면 카드 번호 앞 6~8자리는 카드를 발급한 기관을 식별하는 IIN(흔히 BIN이라 불린다)이고, 카드 네트워크·발행 은행·카드 종류·발행 국가가 이 prefix로 결정된다. 즉 16자리가 어떤 카드 네트워크의 prefix에도 속하지 않으면 그건 카드 번호일 수 없다.
| 카드 네트워크 | BIN Prefix 패턴 | 카드 길이 |
|---|---|---|
| Visa | 4로 시작 | 13, 16, 19자리 |
| Mastercard | 51–55, 2221–2720 | 16자리 |
| American Express | 34, 37 | 15자리 |
| JCB | 3528–3589 | 16자리 |
| Discover | 6011, 622126–622925, 644–649, 65 | 16자리 |
| UnionPay | 62로 시작 | 16~19자리 |
| Diners Club | 300–305, 36, 38 | 14자리 |
| 국내전용(BC 등) | 93–95 | 16자리 |
NOTE: Mastercard의
2221~2720범위는 2017년 IIN 재편 때 추가됐다. 이 범위가 빠진 레거시 코드에서는 최근 발급된 Mastercard가 Mastercard로 인식되지 않는 사고가 종종 보고된다. 레거시를 가져다 쓸 때 한 번 확인해 볼 가치가 있다.
Luhn과 BIN을 직렬로 거치면 v1에서 흘러 들어오던 가짜 번호의 대부분이 걸린다.
4111 1111 1111 1111 → Luhn 통과 + BIN(`4` Visa) 통과 → 후보 유지
1234 5678 9012 3456 → Luhn 통과 + BIN(`1234` 미할당) 탈락
0000 0000 0000 0000 → Luhn 통과 + BIN(`0000` 미할당) 탈락
OCR이 배경 패턴을 잘못 읽어 만들어 낸 "우연히 Luhn을 통과한 숫자 배열"은 어떤 카드 네트워크의 prefix에도 속하지 않는 게 보통이라, 두 필터를 함께 깔면 출력 단의 false positive가 한 자릿수 비율로 떨어진다.
결과 안정화: Sliding Window
Luhn + BIN을 통과한 후보가 있어도 그대로 최종 결과로 쓰면 안 된다. 단일 프레임 OCR이 본질적으로 불안정하기 때문이다. Laplacian 분산 ≥ 500을 통과한 프레임도 "충분히 선명하다"는 뜻이지 "완벽하다"는 뜻은 아니라서, 16자리 중 한두 자리가 조명 반사를 맞거나 폰트 형태가 그 구간만 흐리면 그 자리에서 오인식이 나온다.
다만 이런 오인식은 매 프레임 일관되게 같은 자리에서 나오지 않고 노이즈처럼 무작위로 흩어진다. ...1111이 한 프레임에서 ...1l11로 나오더라도 다음 프레임에서는 다시 ...1111로 정확히 잡힐 가능성이 높다. 여러 프레임을 합치면 노이즈가 평균화된다.
윈도우 크기 N=5, 과반 threshold=3으로 잡으면 5프레임 안에 같은 번호가 3회 이상 일치할 때 확정된다. 30fps 기준으로 보통 160ms 안에 결판난다.
윈도우 안에서 완전 일치 과반이 안 잡히는 경우, 즉 조명이 계속 안 좋거나 특정 자릿수가 계속 흔들리면 자릿수 단위로 독립 투표한다. 자릿수별 최빈값을 모아 번호를 재조합하는 방식이다. 앞 12자리는 매 프레임 일치하는데 뒤 4자리만 흔들리면, 앞은 이미 수렴한 것이니 뒤만 더 모아 결정한다.
| Threshold | 효과 | 문제 |
|---|---|---|
| 너무 낮음 (N=3, 과반=2) | 빠른 확정, 반응성 좋음 | 오인식이 통과할 확률이 높다 |
| 너무 높음 (N=10, 과반=7) | 정확도 높음 | 사용자가 카드를 오래 들고 있어야 한다 |
카드 스캔은 사용자가 카드를 들고 기다리는 UX라 2~3초가 넘어가면 바로 답답해진다. 게이팅으로 입력 품질이 이미 보장됐으니 threshold를 과도하게 올릴 이유가 없어 N=5/3이 적정선이었다.
NOTE: 윈도우에서 오래된 프레임을 밀어낼 때 단순 FIFO를 쓰면, 게이팅 탈락이 길어지는 구간에서 윈도우가 오래된 프레임을 계속 들고 있게 된다. 시간 기반 TTL이나 "게이팅 통과 프레임 N개" 기준으로 관리하는 게 안전하다.
NOTE:
VNRecognizeTextRequest의 각 후보에는confidence점수가 붙는다. 같은 표수라도 confidence 0.99짜리와 0.71짜리를 다르게 취급하는 weighted voting도 가능하고,topCandidates(N)에서 N을 키워 1위가 숫자 외 문자를 포함할 때 2~3위를 fallback으로 쓰는 전략도 있다. 후보가 늘면 파싱이 복잡해지므로 현재는 weighted까지만 적용했다.
전체 OCR 파이프라인
개선 효과
아래 차트는 직접 입력 코호트와 스캐너 코호트의 월별 TTC 추이다.
| 코호트 | 카드 등록 화면 진입 → 등록 완료 평균 TTC |
|---|---|
| A — 직접 입력 | 3.3분 |
| B — 스캐너 (v1) | 2.5분 |
| B — 스캐너 (알고리즘 개선 후) | 1.9분 |
Mixpanel 퍼널 분석 기준 · A 코호트(2025.02–04) vs B 코호트(2025.02–2026.03) · B 초기 배포: 2025.02–2025.12 · 알고리즘 개선 배포: 2026년 1~2월(SPM 패키지 분리, Laplacian 게이팅, BIN 검증, Sliding Window 적용)
배포 직후 2.5분이던 TTC가 알고리즘 개선을 거치며 1.9분으로 내려갔다. 직접 입력 코호트가 3.3분에 머무는 동안 스캐너 코호트는 2026년 1월 2.5분에서 3월 1.9분으로 꾸준히 하강했고, 입력 단 게이팅 효과로 v1부터 켜져 있던 CLOVA fallback 호출 빈도도 같이 떨어졌다. 개선 속도 자체를 끌어올린 핵심은 알고리즘 그 자체가 아니라 카메라 없이 실패 프레임을 재현·반복할 수 있는 구조를 먼저 깔았다는 점이었다.
이후 방향성
SendyCardScanner 패키지는 카드 번호 인식에 맞춰 설계되어 있지만, FrameSource → OCRProcessor → ResultValidator라는 구조를 유지한 채 검증 로직만 교체하면 다른 문서 인식에도 그대로 얹을 수 있다. 운전면허·주민등록증·여권처럼 정형 문서가 들어오는 입구는 같고, 검증 룰만 다르다.
정확도 자체에도 여지가 남아 있다. 현재 Vision OCR은 카드 배경 노이즈와 텍스트 방향에 여전히 취약한 케이스가 있어서, 카드 번호 인식에 특화된 온디바이스 ML 모델을 OCRProcessor 자리에 끼워 넣는 방향을 다음 단계로 보고 있다. FrameSource 추상화 덕에 프로세서를 교체해도 파이프라인 나머지에는 영향이 가지 않는다.
참고 자료
- Apple: CMSampleBuffer
- Apple: AVAssetReader
- Apple: AVCaptureVideoDataOutputSampleBufferDelegate
- Apple: CVPixelBuffer
- Apple: AsyncStream
- Apple: VNRecognizeTextRequest
- Apple: Bundling Resources with a Swift Package
- Apple: Organizing Your Code with Local Packages
- Luhn algorithm — Wikipedia
- ISO/IEC 7812 — Identification of issuers
- Payment card number — Wikipedia
- Naver Cloud Platform: OCR API
- Martin Fowler: Dependency Injection
- Martin Fowler: Page Object