센디 결제카드 스캐너 개발기 (2): 카메라 없이 검증하는 모듈 설계

시리즈
센디 결제카드 스캐너 개발기
카메라에 묶인 검증 과정
카드 번호를 읽는 코드를 수정할 때마다 같은 카드를 다시 촬영해야 한다면, 수정 결과를 비교하기 어렵습니다. 같은 실패를 다시 확인하려 해도 조명과 카드를 들고 있던 각도가 달라집니다. 센디의 카드 스캐너를 앱에서 별도 모듈로 옮기면서 제가 해결하려던 문제도 같은 입력으로 변경을 확인할 수 있는가였습니다.
이전 편에서는 카드 이미지에서 번호를 얻어 사용자 확인으로 넘기는 인식 파이프라인을 다뤘습니다. 이번에는 그 파이프라인을 하나의 검증 대상으로 두고, 주변에 교체 가능한 입력과 테스트를 구성한 설계를 설명합니다. 촬영과 사용자 검토 기능도 함께 유지했습니다.
카메라 없이 같은 입력으로 인식 과정을 확인하는 구조는 2025년 3월 Billing Card 작업에서 시작했습니다. 이후 스캐너를 SPM 모듈로 분리하고 영상 입력과 테스트를 보강한 시점은 2026년입니다. 이 글에서는 테스트 가능한 구조의 시작과 현재 모듈 경계를 구분해 설명합니다.
같은 이미지를 넣어 인식 결과를 비교하는 검사와 촬영 화면의 권한 처리, 종료 동작을 확인하는 검사는 필요한 입력이 다릅니다. 저는 어떤 입력으로 어디까지 검사하는지를 기준으로 모듈과 테스트의 연결을 정했습니다.
OCR만 분리했을 때의 빈틈
스캐너에는 문자 인식 외에도 상태가 많습니다. 촬영 화면을 열면 카메라 권한을 확인하고 세션을 시작해야 합니다. 화면의 카드 가이드에 맞춰 이미지를 잘라야 하고, 분석이 끝나기 전에 다음 프레임이 들어오면 중복 처리를 막아야 합니다. 인식 후에는 번호를 검토하고 수정하거나 다시 촬영할 수 있어야 합니다.
OCR 함수만 라이브러리로 옮기면 이미지 분석은 독립적으로 검사할 수 있습니다. 하지만 카메라 프리뷰, 잘라낼 영역, 촬영과 검토 화면의 전환은 앱에 남습니다. 앱과 테스트 환경에서 이 연결을 각각 만들면, 이미지 분석은 같아도 실제 스캐너의 동작은 달라질 수 있습니다.
분리 범위에 따라 확인할 수 있는 동작도 달라집니다.
| 선택 | 가능한 확인 | 남는 제약 |
|---|---|---|
| 앱 안에서 분석기만 테스트용 대체 구현(대역)으로 교체 | 화면 상태와 결과 처리 검사 | 스캐너를 띄우려면 앱의 화면 구성에 의존 |
| 이미지 분석만 패키지로 분리 | 이미지 입력과 번호 추출 검사 | 카메라, 크롭과 촬영 UI의 연결이 앱에 남음 |
| 촬영부터 검토까지 기능 모듈로 분리 | 분석 테스트와 독립 실행 화면을 함께 구성 | 카메라 세션 생명주기와 UI 상태까지 모듈에서 관리해야 함 |
저는 촬영부터 검토까지 기능 모듈로 분리했습니다. 앱의 카드 등록 화면을 거치지 않고도 스캐너를 실행하고, 촬영 과정의 일부를 다른 입력으로 바꿀 수 있어야 했습니다.
촬영부터 검토까지의 패키지 경계
카드 스캐너를 로컬 Swift Package로 옮기고, 앱에는 화면을 표시하는 코드와 결과를 카드 등록 폼에 넘기는 코드를 남겼습니다. 분리한 범위는 카메라를 포함한 카드 스캔 기능 전체입니다.
모듈 안에는 기존 인식 파이프라인과 카메라 권한 확인, 세션 시작과 정지, 프리뷰와 토치 제어가 있습니다. 촬영 화면과 인식 결과를 수정하는 검토 화면도 모듈이 제공합니다. 앱과 Example은 이 기능을 호출하고, 테스트 타깃은 필요한 부분에 입력이나 대역을 연결합니다.
앱이 알아야 하는 것은 설정과 결과입니다. 스캔이 끝나면 카드 번호와 유효기간을 받아 앱의 카드 모델로 바꿉니다. 사용자가 나가면 취소 결과를 받습니다. 카메라 세션이나 프레임 처리기를 앱에서 직접 구성하지 않습니다.
아래는 실제 화면 호출 변경에서 핵심만 남긴 diff입니다. 이름은 공개 설명을 위해 바꿨고, 앱의 카드 모델 변환과 화면 표시 코드는 줄였습니다.
-let model = ScanViewModel { card in
- finishScanning(card)
-}
-let screen = ScanView(model: model)
+let options = ScanOptions(
+ onComplete: { scanned in
+ finishScanning(scanned.map(makeFormCard))
+ }
+)
+let screen = ScanScreen(options: options)Swift 모듈 경계가 생기면 internal 구현은 호출 앱에서 직접 접근할 수 없습니다.
화면 내부의 상태나 크롭 처리에 앱이 의존하는 대신 공개한 설정과 결과를 사용하게 됩니다. 반면 디자인과 뷰를 위한 인접 패키지 의존성은 남아 있습니다. 패키지 하나를 복사하면 어느 앱에서나 즉시 실행되는 구조는 아닙니다.
분리의 효과는 Example 앱에서 확인할 수 있습니다. 카드 스캐너 화면을 여는 작은 앱을 패키지 옆에 두고, 촬영 모드와 결과가 채워진 검토 모드에 각각 진입하도록 구성했습니다. 메인 앱의 인증이나 카드 등록 화면을 거치지 않고도 번호 편집, 재촬영, 완료 동작을 확인할 수 있습니다.
패키지 코드를 바꾸면 여전히 다시 컴파일해야 합니다. 분리 후 줄어든 것은 스캐너를 실행하기 위한 앱의 준비 과정과 테스트 구성입니다.
교체 입력의 연결 위치
입력을 바꿔도 기존 인식 파이프라인을 그대로 실행할 수 있어야 합니다. 현재 구현에서 공통으로 사용할 수 있는 데이터는 CGImage입니다. 다만 어디에 이미지를 넣는지에 따라 검사 범위가 달라집니다.
시뮬레이터에서 화면 흐름을 확인할 때는 실제 카메라 입력과 같은 ROI 크롭 앞에 이미지를 넣습니다. 이미지 분석만 검사할 때는 크롭 이후의 분석기에 직접 넣습니다. 두 검사를 하나의 입력 경로로 합치지 않았습니다.
시뮬레이터 영상의 프레임 주입
시뮬레이터에서는 카메라 서비스를 대체하는 구현을 사용합니다. 영상 URL을 설정하면 AVPlayer로 프리뷰를 표시하고, AVAssetImageGenerator로 일정한 시각의 CGImage를 추출합니다. 추출한 이미지는 프레임 처리기의 직접 이미지 입력 함수에 넣습니다.
이 입력은 실카메라와 같은 ROI 크롭을 거칩니다. ROI는 화면의 카드 가이드가 가리키는 관심 영역입니다. 따라서 Example에서는 영상 프리뷰와 카드 가이드를 표시한 채 인식 결과가 검토 화면으로 넘어가는 연결을 확인할 수 있습니다. 잘라낸 이미지를 보여주는 디버그 프리뷰도 두었습니다.
영상 재생은 같은 입력을 반복하는 용도입니다. 실제 카메라의 초점과 노출, 토치가 바뀌는 동작은 여전히 실기기에서 확인해야 합니다.

분석기에 직접 넣는 합성 이미지
번호 인식만 확인할 때는 프리뷰와 크롭이 필요하지 않습니다. 테스트에서 카드 모양과 번호를 그린 CGImage를 만들고 분석기에 직접 전달합니다. 테스트 번호를 알고 있으므로 결과가 비어 있지 않은지보다 기대 번호와 같은지를 검사할 수 있습니다.
이 경로는 1편의 이미지 분석을 실제 구현으로 실행합니다. 카메라의 샘플 버퍼 변환과 화면 좌표에 따른 ROI 크롭은 지나지 않으므로 카드 가이드의 좌표는 별도로 확인해야 합니다.
실카드가 담긴 영상은 번호가 포함된 민감한 자료입니다. 테스트 입력을 코드로 그리면 실카드 파일 없이도 번호 인식의 기본 동작을 반복할 수 있습니다. 대신 카드 폰트, 표면 재질과 반사를 충분히 대표하지 못하므로 실제 촬영 확인을 대체하지 않습니다.
테스트마다 다른 확인 범위
인식 파이프라인을 한 번 호출하는 검사만으로 전체 스캐너를 확인할 수는 없습니다. 텍스트 해석과 후보 규칙, 이미지 분석, 영상 추출, 화면 조작을 나누고 각 테스트가 통과하는 경로를 구분했습니다.
| 검사 대상 | 입력과 확인 내용 | 포함하지 않는 범위 |
|---|---|---|
| 텍스트 해석과 후보 규칙 | 1편에서 다룬 규칙을 고정 입력으로 검사 | 실제 문자 인식 |
| 이미지 크롭 | 이미지와 ROI 좌표로 잘림 검사 | 카메라 및 화면 배치 전체 |
| 이미지 분석 | 합성 카드 이미지로 번호 일치, 유효기간, 빈 배경 검사 | 샘플 버퍼 변환과 ROI 크롭 |
| 영상 프레임 추출 | 합성 영상으로 프레임 수와 크기, 반복, 정지 검사 | 추출 프레임의 번호 인식 |
| 화면 상태와 수명 | 카메라와 분석기 대역으로 종료, 재시도, 완료 검사 | 실제 카메라의 광학 특성 |
| Example UI | 검토 화면 편집과 완료, 촬영 화면의 뒤로 가기 등 검사 | 조명과 초점의 품질 |
영상 프레임 제공자는 AsyncStream<CGImage>를 노출합니다. 테스트에서는 합성 이미지로 임시 영상을 만들고 이 스트림에서 프레임을 수집해 개수와 크기, 반복 재생을 검사합니다.
정지 시나리오는 프레임을 받은 뒤 정지를 요청하고 제공자의 시작 작업이 반환하는지를 확인합니다.
다만 테스트에서 소비 루프를 직접 끝내므로, 소비자가 스트림 종료를 관측하는지도 별도로 검사해야 합니다.
이 테스트에는 분석기를 연결하지 않았습니다. 영상에서 이미지를 꺼내는 동작과 그 이미지에서 카드 번호를 읽는 동작은 각각 검사합니다. 영상 입력부터 번호 확정까지 이어지는 통합 검사와는 범위가 다릅니다.
인식하지 못한 결과의 실패 처리
이미지 분석 테스트에서 결과가 나왔을 때만 번호를 검사하면, 아무 결과도 얻지 못해도 통과할 수 있습니다. 성공 사례를 검사하는 테스트라면 결과가 없는 경우도 실패여야 합니다.
아래는 실제 합성 이미지 테스트의 조건을 축약한 예시입니다. 이름은 설명용이며, 인식 시도와 실패 처리 부분만 남겼습니다.
let expected = TestCard.number
let image = TestCard.makeImage()
var recognized: String?
for _ in 0..<6 {
if let card = try? await analyzer.analyze(image: image) {
recognized = card.number
break
}
}
#expect(recognized == expected)번호 확정에 여러 입력이 필요하므로 같은 이미지를 반복해서 전달합니다. 구체적인 확정 규칙은 1편에서 설명했습니다. 실제 테스트는 정해진 시도 안에 인식 결과가 없으면 오류를 던집니다. 반대 방향으로는 빈 이미지를 반복해 넣어도 번호가 나오지 않는지 검사합니다.
두 조건은 서로 다른 오류를 잡습니다. 기대 번호만 검사하면 배경에서 가짜 번호를 만드는 문제를 놓칠 수 있고, 빈 배경만 검사하면 어떤 카드도 읽지 않는 분석기도 통과할 수 있습니다.
합성 이미지를 회전해 같은 번호가 나오는지 확인하는 사례도 추가했습니다. 입력 방향 때문에 번호가 달라지는 결함을 다룰 때는 이 입력을 그대로 회귀 검사에 남길 수 있습니다. 사진을 다시 준비하지 않고 같은 조건에서 실패와 수정 결과를 비교할 수 있는 지점입니다.
카메라 제어의 실행 순서 검사
카메라 없는 테스트를 만든다고 카메라 제어를 검사에서 빼지는 않았습니다. 권한 응답을 기다리는 동안 사용자가 촬영 화면을 나가면, 정지 요청 뒤에 응답이 도착해 카메라가 뒤늦게 켜질 수 있습니다. 이 상황은 이미지 파일만으로는 재현할 수 없습니다.
테스트에서는 카메라 제어를 대역으로 바꾸고 권한 응답을 기다리는 지점에서 시작 작업을 멈춥니다. 그사이에 정지 요청을 처리한 뒤 응답을 풀고, 카메라 시작이 호출되지 않았는지와 최종 상태가 정지인지를 검사합니다. 문제가 되는 실행 순서를 테스트가 직접 구성합니다.
이 순서를 지키기 위해 구현에서는 요청의 유효성을 확인합니다. 테스트는 내부의 액터나 lock을 사용했는지보다, 늦게 도착한 응답이 화면의 종료 의사를 뒤집지 않는지를 확인합니다. 정지 뒤 다시 시작하는 정상 동작도 따로 검사합니다.
프레임 처리에도 별도의 검사가 있습니다. 같은 이미지를 64개 작업에서 동시에 전달해도 정확히 하나만 분석을 시작하는지 확인합니다. 이는 처리량 수치가 아니라 중복 분석을 허용하지 않는 조건입니다. 분석 중 후속 프레임을 버리는 동작과 함께 검사합니다.
이처럼 입력을 교체하는 대상은 영상만이 아닙니다. 이미지 분석에는 합성 이미지를, 카메라 제어에는 응답 시점을 조절할 대역을 쓰면 각각의 실패 조건을 고정할 수 있습니다.
단점과 남은 검증
실제 촬영에서 확인할 항목은 여전히 남습니다. 초점과 노출, 손떨림, 광택 반사, 토치 밝기는 합성 이미지에 담기지 않습니다. 시뮬레이터 영상은 녹화된 입력을 반복할 뿐이고, 카메라 권한을 처음 요청하거나 거부한 뒤 설정으로 이동하는 흐름도 실제 환경에서 확인해야 합니다.
합성 입력의 형태도 넓혀야 합니다. 같은 이미지에 흐림과 밝기 변화를 주면 반복할 수 있는 입력은 늘어나지만, 그것이 실제 촬영을 얼마나 대표하는지는 별도 문제입니다. 정상 카드의 인식 실패와 배경의 오탐을 함께 검사할 입력이 더 필요합니다.
영상 제공자의 AsyncStream<CGImage>에는 버퍼 상한을 지정하지 않았습니다. 소비가 느릴 때 이미지가 쌓일 수 있으므로 이 테스트용 흐름을 실시간 입력 전체로 확대하려면 버퍼 정책도 정해야 합니다. 이미지가 스트림으로 나온다는 사실만으로 메모리 사용량이나 동시성 안전성이 보장되지는 않습니다.
테스트에는 Apple 환경 제약도 있습니다. 이미지 분석은 Vision에, 영상 생성과 추출은 AVFoundation에 의존하고 UI 자동화는 Example 앱을 실행해야 합니다. 로컬 SPM 패키지라고 Linux에서 swift test만 실행하면 전체 검사가 끝나는 구조는 아닙니다.
CI에서 실행하려면 사용할 Xcode와 시뮬레이터를 정하고 테스트 타깃을 실행한 뒤 실패 결과를 수집해야 합니다. 현재 설명한 범위는 카메라 없이 실행할 입력과 테스트 구성을 갖춘 상태이며, CI에서 이 검사를 상시 운영한 결과까지 포함하지는 않습니다.
번호 인식에는 실제 카드의 형태를 대표하는 비식별 입력을 더하고, 시뮬레이터 경로에는 영상과 카드 가이드 좌표가 함께 바뀌는 경우를 추가할 계획입니다. 자동 검사에 추가할 수 없는 광학 조건은 Example 앱의 실제 촬영 확인으로 남깁니다.