센디 결제카드 스캐너 개발기 (1): 카메라 프레임에서 카드 정보 추출

시리즈
센디 결제카드 스캐너 개발기
카드 등록의 입력 부담
센디에서 결제카드를 등록하려면 카드 번호와 유효기간을 입력해야 합니다. 저는 이 입력을 돕는 iOS 카드 스캐너를 구현했습니다. 카드를 화면에 비추면 정보를 읽고, 사용자가 확인한 값을 등록 폼에 채우는 기능입니다. 이 글에서는 카메라 프레임을 받아 카드 번호와 유효기간으로 바꾸는 현재 구현을 따라가며, iOS에서 직접 실행하는 인식 경로를 설명합니다.
번호를 자동으로 채우려면 OCR 호출보다 앞뒤에 할 일이 많았습니다.
Billing Card 화면에서 카드 스캔 진입과 스캐너 화면을 연결한 작업은 2025년 2월 5일 시작했습니다. 이 글의 날짜는 그 초기 연결 작업을 가리킵니다. 이후 프레임 변환과 텍스트 조립을 SPM으로 옮기며 처리 경로를 정리했습니다. 아래 기술 설명은 당시 코드가 아니라 그 뒤에 정리한 구현을 기준으로 합니다. 사용자가 맞춘 안내 영역과 실제 분석 이미지의 위치가 일치해야 하고, 카메라가 보내는 프레임을 모두 분석하지 않도록 제어해야 합니다. 인식된 텍스트도 그대로 카드 번호가 되지는 않습니다. 숫자 조각의 위치를 보존한 채 후보를 조립하고, 사용자가 수정할 수 있는 화면까지 연결해야 했습니다.

사진 한 장을 촬영한 다음 읽으면 프레임을 고르는 처리를 줄일 수 있지만, 사용자가 촬영 결과를 보고 다시 찍을지 결정해야 합니다. 반면 카메라가 켜진 동안 프레임을 계속 받으면 카드를 움직이거나 방향을 바꾸면서 인식을 이어갈 수 있습니다. 저는 실시간 프리뷰에서 정보를 읽고 확인 화면으로 넘어가는 방식을 구현했습니다. 수동 입력도 함께 제공해 인식이 어려운 카드 때문에 등록이 막히지 않도록 했습니다.
카메라에서 받은 프레임은 이미지 처리와 텍스트 추출을 거쳐 화면 상태에 반영됩니다. 이 편에서 설명하는 핵심은 이 한 경로입니다. 프레임을 전달받는 자료형과 이미지 표현을 구분하고, 화면 안내 영역에 해당하는 부분만 OCR에 보낸 뒤, 위치가 붙은 텍스트 관측값을 카드 정보로 조립합니다. 아래 그림은 단계의 이름만 나열한 것이 아니라 이후 섹션에서 다룰 데이터 흐름의 기준입니다.
프리뷰와 분석을 위한 세션 구성
사용자는 카메라 프리뷰를 보면서 카드를 안내 영역에 맞춥니다.
동시에 앱은 같은 카메라의 프레임을 받아 OCR에 사용해야 합니다.
이를 위해 카메라 캡처를 관리하는 AVFoundation의 AVCaptureSession 하나에
입력을 연결하고, 화면 표시와 프레임 분석을 나눴습니다.
카메라는 AVCaptureDevice.default(for: .video)로 얻습니다.
권한이 허용되면 AVCaptureDeviceInput을 세션에 추가하고,
분석용 출력으로 AVCaptureVideoDataOutput을 연결합니다.
프리뷰는 같은 세션을 참조하는 AVCaptureVideoPreviewLayer가 표시합니다.
세션은 사진 품질의 출력을 위한 .photo 프리셋을 사용하고 연결 방향을 세로로 설정합니다.
이후 크롭에는 전달받은 이미지의 실제 가로와 세로 픽셀 수를 사용합니다.
프레임의 크기가 기기마다 달라도 안내 영역을 같은 방식으로 계산하기 위해서입니다.
세션을 시작하는 startRunning()은 호출한 스레드를 기다리게 하는 동기 API입니다.
화면을 그리는 메인 스레드와 분리한 실행 영역에서 세션 제어를 직렬로 처리합니다.
시작과 종료뿐 아니라 토치 설정도 같은 실행 영역을 거칩니다.
특히 세션 설정과 종료가 겹치면 예외가 발생할 수 있어, 생명주기 제어의 실행 순서를 맞춰야 합니다.
프레임 델리게이트에는 별도의 직렬 큐를 지정했습니다. 세션을 켜고 끄는 작업과 프레임을 전달받는 작업을 각각 처리하는 구조입니다. 분석용 출력은 다음처럼 설정했습니다. 권한 확인과 입출력 추가 가능 여부 검사는 생략한 코드입니다.
let output = AVCaptureVideoDataOutput()
output.alwaysDiscardsLateVideoFrames = true
output.setSampleBufferDelegate(sampleBufferDelegate, queue: bufferQueue)
session.addOutput(output)
session.sessionPreset = .photo
for connection in session.connections {
connection.videoOrientation = .portrait
}alwaysDiscardsLateVideoFrames는 처리가 늦어진 프레임을 계속 쌓지 않게 하는 설정입니다.
Apple의 프레임 누락 처리 문서도
이 설정이 출력 끝의 버퍼 큐 크기를 1로 제한한다고 설명합니다.
카드 스캐너에서는 사용자가 지금 들고 있는 카드의 상태를 읽어야 하므로,
오래된 프레임을 순서대로 모두 처리하는 것보다 최근 입력을 분석하도록 구성했습니다.
다만 이 설정이 앱에서 시작한 비동기 OCR 작업까지 관리해 주지는 않습니다.
OCR 호출 수는 뒤에서 따로 제한합니다.
샘플 버퍼에서 분석 이미지 추출
세션이 실행되면 captureOutput(_:didOutput:from:)으로 CMSampleBuffer가 전달됩니다.
코드는 먼저 프리뷰와 안내 영역이 준비됐는지, 캡처가 진행 중인지, 다른 분석이 실행 중인지 확인합니다.
직전 분석 시작부터 100ms가 지나지 않았어도 프레임을 건너뜁니다.
이 조건을 통과한 프레임만 픽셀 버퍼를 꺼내고 이미지로 변환합니다.
CMSampleBuffer는 픽셀 배열 자체가 아니라 미디어 샘플을 담는 컨테이너입니다.
샘플의 presentationTimeStamp와 포맷 설명도 이 객체를 통해 확인할 수 있습니다.
영상 픽셀은 CMSampleBufferGetImageBuffer로 얻는 CVPixelBuffer에 들어 있습니다.
즉 이 경로에서 CMSampleBuffer는 프레임을 전달하고, CVPixelBuffer는 프레임의 이미지 데이터를 제공했습니다.
CVPixelBuffer의 구체적인 픽셀 포맷은 AVCaptureVideoDataOutput.videoSettings로 요청할 수 있습니다.
당시 구현은 이 값을 별도로 지정하지 않았습니다. Apple 문서는 nil일 때 기본 비압축 포맷을 사용한다고 설명하지만,
저장소 코드만으로 모든 기기에서 같은 YUV 서브타입이 반환된다고 단정할 수는 없습니다.
포맷을 확인해야 한다면 실제 프레임에서 CVPixelBufferGetPixelFormatType(imageBuffer)를 읽어야 합니다.
따라서 그림의 YUV 표기는 이 파이프라인의 대표적인 픽셀 표현을 보여주는 개념 표기이며,
코드가 특정 YUV 포맷을 고정했다는 뜻은 아닙니다.
픽셀 버퍼에서 이미지 표현으로
이 구현에서는 CVPixelBuffer에서 VideoToolbox의 VTCreateCGImageFromCVPixelBuffer를 호출해
CGImage 표현을 얻었습니다. CGImage로 바꾼 이유는 다음 단계에서 CGImage.cropping(to:)로
안내 영역을 자르기 위해서입니다. Vision 요청 핸들러가 CVPixelBuffer도 직접 받을 수 있으므로,
CGImage 변환은 Vision 사용의 필수 조건은 아닙니다.
VideoToolbox는 영상 압축과 해제뿐 아니라 픽셀 버퍼의 이미지 변환에도 쓰입니다. 여기서 사용하는 기능은 아래 표의 마지막 행입니다.
| 작업 | 입력과 출력 | 대표 API |
|---|---|---|
| 인코딩 | 영상 프레임을 압축 데이터로 변환 | VTCompressionSession |
| 디코딩 | 압축 데이터를 영상 프레임으로 복원 | VTDecompressionSession |
| 이미지 표현 변환 | 픽셀 버퍼로 Core Graphics 이미지 생성 | VTCreateCGImageFromCVPixelBuffer |
카메라에서 받은 픽셀을 자르기 위한 변환이므로 인코딩이나 디코딩 세션은 만들지 않습니다.
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
isAnalyzing = false
return
}
var cgImage: CGImage?
VTCreateCGImageFromCVPixelBuffer(imageBuffer, options: nil, imageOut: &cgImage)
guard let fullCameraImage = cgImage else {
isAnalyzing = false
return
}이 함수가 새 이미지 표현을 만들 때 원본 픽셀 버퍼를 유지할 수도 있으므로,
이를 항상 전체 메모리 복사라고 가정하지 않았습니다. Apple 문서에 따르면 원본 CVPixelBuffer는
CGImage가 살아 있는 동안 유지될 수 있습니다. 프레임마다 만든 전체 이미지와 크롭 이미지를
불필요하게 보관하지 않도록 처리 범위를 짧게 유지하는 이유입니다. 이 경로의 실제 복사 횟수나
메모리 사용량을 측정한 자료는 없어, 성능 수치로 주장하지는 않습니다.
Vision 요청의 입력 형식
Vision은 CVPixelBuffer를 직접 받는 이미지 요청 핸들러도 제공합니다.
여기서는 뒤의 안내 영역 크롭을 CGImage.cropping(to:)로 처리하므로,
카메라 프레임을 CGImage로 만든 다음 필요한 부분을 잘라 사용합니다.
변환 결과 이미지가 없으면 해당 프레임의 처리를 끝냅니다. 이때 분석 중 상태도 풀어야 다음 프레임을 받을 수 있습니다. 이미지를 얻은 경우에만 안내 영역을 자르는 단계로 진행합니다.
프리뷰 안내 영역과 이미지 좌표
전체 프레임에는 카드 바깥의 배경도 들어옵니다. 저는 화면의 안내 영역에 해당하는 부분을 잘라 OCR에 넘겼습니다. 카드를 어디에 놓아야 하는지 사용자에게 보여 주면서, 분석 대상도 그 영역에 맞추는 방식입니다.
이때 안내 영역의 CGRect를 이미지 크롭 함수에 그대로 넣을 수는 없습니다.
화면은 포인트 단위이고 이미지는 픽셀 단위입니다.
프리뷰는 resizeAspectFill로 화면을 채우기 때문에 이미지 가장자리 일부가
화면 밖으로 잘려 나갈 수도 있습니다. 이미지와 프리뷰의 종횡비가 다르면
단순히 가로 배율과 세로 배율을 각각 곱하는 계산으로는 위치가 맞지 않습니다.
먼저 안내 영역의 원점에서 프리뷰 원점을 빼서 프리뷰 내부의 상대 좌표를 구합니다. 안내 영역과 프리뷰의 원점이 달라도 위치를 맞추기 위해서입니다. 그다음 이미지 비율과 프리뷰 비율을 비교해 실제로 화면에 보이는 이미지 부분을 구합니다.
이미지가 프리뷰보다 가로로 넓으면 높이를 기준으로 배율을 계산합니다. 그 배율로 화면에 보이는 이미지 너비를 구하고, 양옆에서 잘리는 길이를 뺍니다. 반대로 이미지가 상대적으로 세로로 길면 너비를 기준으로 배율을 구하고 위아래의 여백을 계산합니다. 이 계산으로 얻은 가시 영역을 한 번 자른 뒤, 그 안에서 안내 영역을 자릅니다.
let roiInVisibleImage = CGRect(
x: roi.origin.x * scaleFactor,
y: roi.origin.y * scaleFactor,
width: roi.size.width * scaleFactor,
height: roi.size.height * scaleFactor
)
let bounds = CGRect(
x: 0, y: 0,
width: visibleRect.width,
height: visibleRect.height
)
let finalCropRect = roiInVisibleImage.intersection(bounds)
return visibleImage.cropping(to: finalCropRect)마지막 intersection은 안내 영역이 이미지 바깥으로 나간 부분을 제한합니다.
OCR에는 사용자가 맞추고 있는 안내 영역에 대응하는 이미지가 전달됩니다.
가로와 세로 카드의 안내 영역 크기가 바뀌어도 같은 계산을 사용할 수 있습니다.
화면 배치를 바꾸면 이 크롭 계산도 함께 확인해야 합니다. 안내 영역만 보고 인식이 안 된다고 판단하면, 실제로는 엉뚱한 위치를 자른 문제를 OCR 모델의 문제로 오해할 수 있습니다. 프리뷰 좌표와 크롭 결과를 함께 확인해야 하는 이유입니다.
분석 중복과 호출 간격 제어
분석할 영역을 줄여도 모든 프레임에 OCR을 요청할 수는 없습니다. 인식 요청이 끝나기 전에 다음 요청을 계속 만들면 처리 중인 이미지가 늘어납니다. 프레임 델리게이트를 빠르게 반환하는 것과 OCR 작업이 끝나는 것은 별개이기 때문입니다.
100ms는 OCR 완료 시간을 뜻하지 않았습니다. 분석 요청을 시작하는 간격의 하한이었습니다. 한 요청이 더 오래 걸리면 완료될 때까지 다음 프레임은 분석을 시작하지 않았습니다. 이미지 변환이나 크롭에 실패하면 분석 중 상태를 바로 풀었고, 요청을 시작한 뒤에는 성공과 실패 응답을 받는 곳에서 상태를 풀었습니다.
프레임 제한은 흐림 여부를 판정하지 않았습니다. 흐린 프레임도 OCR에 들어갈 수 있었고, 텍스트를 읽지 못하면 다음 프레임에서 다시 시도했습니다. 이 구조가 제한한 것은 동시에 진행하는 인식 요청 수였으며, 입력 품질을 걸러내는 기능은 아니었습니다.
텍스트 위치를 이용한 번호 조립
안내 영역을 잘라 얻은 CGImage에는 VNRecognizeTextRequest를 실행합니다.
Vision은 이미지 속 텍스트의 내용과 위치를 함께 돌려주는 Apple의 인식 프레임워크입니다.
Vision의 텍스트 인식은
속도와 정확도를 우선하는 두 경로를 제공합니다. 저는 이 요청에 .accurate와
인식 언어 ["en", "ko"]를 지정했습니다.
각 관측값에서는 topCandidates(1)의 첫 문자열을 사용합니다.
OCR 결과를 곧바로 하나의 문자열로 합치면 위치 정보가 사라집니다.
카드에는 번호 외에도 유효기간, 이름, 안내 문구가 있고,
번호 자체가 여러 텍스트 조각으로 나뉘어 인식될 수도 있습니다.
저는 각 조각의 boundingBox를 사용할 수 있도록 관측값 배열을 추출 단계까지 전달했습니다.
다음은 반환부를 바꾼 실제 코드 변경의 발췌입니다.
- let texts = observations.compactMap { observation in
- observation.topCandidates(1).first?.string
- }
- let fullText = texts.joined(separator: " ")
- continuation.resume(returning: fullText)
+ continuation.resume(returning: observations)이 배열을 다룰 때에는 Vision의 정규화된 좌표를 그대로 사용합니다.
boundingBox.midY를 기준으로 위에서부터 정렬하고,
현재 행의 기준 관측값과 Y 차이가 0.05 미만인 조각을 같은 행으로 묶습니다.
이후 같은 행 안에서는 minX가 작은 순서로 정렬해 왼쪽부터 읽습니다.
추출 단계에서는 전화번호처럼 보이는 관측값을 먼저 제외합니다. 그다음 행마다 문자열을 공백으로 연결하고, 공백이나 하이픈을 포함할 수 있는 13~19자리 숫자 패턴을 찾습니다. 추출한 값에서는 구분자를 제거합니다. 화면에 흔히 표시하는 네 자리씩 네 묶음만을 카드 번호 형식으로 가정하지는 않았습니다.
이 방식으로 후보를 얻지 못하면 숫자 블록의 위치를 이용한 재조합도 시도합니다. 3~6자리 숫자로만 된 조각을 모아 행 안에서 결합하고, 여러 행에 걸친 번호는 인접한 행을 함께 조합합니다. 이렇게 숫자가 한 번에 인식된 경우와 여러 조각으로 나뉜 경우를 함께 처리합니다.
번호 후보 검증과 결과 안정화
숫자 조각을 이어 붙였다고 실제 카드 번호인지 알 수는 없습니다. 배경에서 읽은 숫자나 일부 자리를 잘못 읽은 값도 길이 조건을 만족할 수 있습니다. 저는 조립한 값에 후보 판정을 적용하고, 그 조건을 통과한 번호를 여러 번 모아 결과를 정하도록 구현했습니다.
후보 판정에는 BIN과 Luhn을 사용합니다.
여기서 BIN 검사는 번호의 앞 14자리가 알려진 카드 계열의 접두사 범위에 들어가는지 보는 구현입니다.
발급사 목록에서 번호를 조회하는 방식은 아닙니다.
Luhn은 숫자열의 체크섬 검사입니다. 끝자리에서 두 번째 숫자부터 한 자리씩 건너뛰며 두 배로 만들고,
두 배 한 값이 9보다 크면 9를 뺀 뒤 전체 합이 10의 배수인지 확인합니다.
여러 행에 걸친 숫자 블록은 인접한 24개 행을 순방향과 역방향으로 조합하고,
후보 판정을 통과한 조합 중 Luhn을 통과한 값이 있으면 먼저 선택합니다.
후보 판정은 BIN 또는 Luhn 중 하나를 통과하면 후보로 남기는 방식이었습니다. 접두사 규칙에는 국내전용 카드로 취급하는 범위도 포함했습니다. Luhn을 함께 필수로 요구하면, 이 접두사로 인식됐지만 체크섬을 통과하지 못한 번호는 모두 제외됩니다. 그런 후보도 유지하도록 체크섬 통과를 필수 조건으로 두지 않았습니다.
그만큼 잘못 읽은 값을 더 허용할 수 있습니다. 예를 들어 허용한 접두사는 그대로 읽고 뒤의 숫자를 틀리게 읽으면, Luhn을 통과하지 않아도 후보가 됩니다. 접두사가 알려져 있다는 사실만으로 실제 발급 여부를 알 수 없고, 체크섬을 통과해도 OCR 결과가 카드에 적힌 번호와 같다는 보장은 없습니다. 따라서 이 검사는 다음 단계에 넘길 후보를 고르는 조건으로 사용합니다.
그다음에는 최근 숫자 후보를 최대 5개 기록하고 3회 이상의 일치를 확인합니다. 13~19자리 범위를 벗어나거나 숫자가 아닌 값은 기록에 넣지 않고, 새 후보가 들어와 기록이 5개를 넘으면 가장 오래된 값을 뺍니다. 새로 들어온 번호가 이 기록에 3회 이상 있으면 그 번호를 반환합니다. 연속으로 같은 번호가 들어와야 하는 조건은 아닙니다.
번호 전체가 충분히 일치하지 않으면 자릿수별 투표를 시도합니다. 이때는 보관한 후보의 길이가 모두 같아야 합니다. 각 위치에서 3표 이상을 얻은 숫자를 골라 모든 자리를 구성할 수 있을 때 결과를 반환합니다. 한 프레임에서 한 자리를 틀리고 다른 프레임에서 다른 자리를 틀리더라도, 각 자리의 일치 조건을 만족하면 번호 전체를 만들 수 있습니다. 반대로 한 자리라도 3표를 얻지 못하면 확정하지 않고 다음 후보를 기다립니다.
자릿수별 합의로 만든 번호도 Luhn을 통과하지 않으면 버리는 구조는 아닙니다. 후보 판정과 마찬가지로 체크섬만으로 모든 번호를 제외하지 않는 정책을 유지합니다. 합의한 번호는 개별 입력에 없던 조합일 수도 있으므로, 이 결과 역시 사용자가 확인할 값입니다.
여기서 5개는 안정화 단계에 들어온 번호 후보 수입니다. 카메라가 보낸 프레임 5장이 아닙니다. 분석 중인 프레임과 최소 간격을 채우지 못한 프레임은 건너뛰고, OCR이 실패하거나 후보를 얻지 못한 입력도 기록에 추가되지 않습니다. 따라서 프레임 속도에 5를 곱하거나 나눠 번호 확정 시간을 계산할 수는 없습니다. 재촬영할 때에는 이전 후보 기록을 초기화하고 다시 모읍니다.
이 정책에서는 정상 번호를 놓치는 경우와 잘못된 번호를 받아들이는 경우를 함께 봐야 합니다. 접두사 범위를 좁히거나 Luhn을 필수로 만들면 오탐은 줄어들 수 있지만, 당시 허용하던 번호까지 빠질 수 있었습니다. 반대로 반복 인식만 강화해도 같은 모양을 계속 잘못 읽는 경우는 남습니다. 규칙 변경의 결과는 카드 표본에서 번호가 맞게 채워지는지와 배경에서 가짜 후보가 생기는지를 함께 확인해야 합니다.
유효기간 추출과 사용자 확인
유효기간은 카드 번호와 다른 규칙으로 추출합니다.
번호가 있는 행만 보는 대신 전체 관측값의 문자열을 합친 결과에서
슬래시로 구분된 월과 연도를 찾습니다. 월은 01부터 12까지 허용하고,
네 자리 연도를 얻으면 뒤의 두 자리를 사용합니다.
다만 사용한 정규식은 연도를 2~4자리까지 허용해 세 자리 연도도 통과했습니다.
출력을 항상 MMYY로 보장하려면 연도 자릿수 검사를 좁히는 추가 검증이 필요합니다.
유효기간을 못 읽어도 번호 인식 결과를 버리지는 않습니다. 이 경우에는 유효기간을 비워 둔 채 확인 화면으로 넘어갑니다. 사용자는 이미 읽힌 번호를 유지하고 빈 유효기간을 채울 수 있습니다.
분석에 성공하면 새 프레임 분석을 멈추고 확인 화면을 표시합니다. 여기서 사용자는 카드 번호와 유효기간을 수정하거나 재촬영할 수 있습니다. 확인 버튼을 눌러야 인식 결과가 호출 화면으로 반환되고 등록 폼에 반영됩니다. OCR이 끝나는 시점과 사용자가 정보를 확정하는 시점을 나눴습니다.

이 구현으로 카메라의 연속 프레임에서 번호와 유효기간을 추출해 수정 가능한 폼으로 넘기는 동작을 연결했습니다. 카드 정보를 직접 입력하는 경로도 남아 있으므로 권한을 허용하지 않거나 인식이 어려운 경우에도 등록을 이어갈 수 있습니다.
남은 인식 제약과 검증 과제
안내 영역 크롭과 호출 제한만으로 모든 촬영 조건을 해결할 수는 없습니다. 빛 반사나 손가락 가림으로 숫자가 사라진 프레임에서는 읽을 정보 자체가 부족합니다. Y 좌표 차이를 기준으로 행을 묶기 때문에 카드가 기울어져 숫자 높이가 달라지면 하나의 행이 갈라지거나 주변 텍스트와 섞일 수 있습니다.
흐린 입력을 선별하려면 선명도 판정도 확인해야 합니다. 구현에는 그레이스케일 이미지에 3×3 Laplacian 커널을 적용하고 응답의 분산을 계산하는 함수가 있습니다. 다만 모든 Vision 요청 앞에서 이 함수로 입력을 거르지는 않습니다. 이 글에서 설명한 호출 제한은 여전히 분석 중 여부와 시작 간격을 기준으로 동작합니다. 선명도 기준을 입력 필터로 적용하려면 흐린 이미지를 제외하는 동안 읽을 수 있는 카드까지 버리지 않는지, 번호 인식 결과와 처리 시간을 함께 확인해야 합니다.
번호가 틀리게 채워졌을 때 원인을 찾는 일도 남습니다. 카메라 입력과 크롭 결과, 텍스트 관측값과 조립 결과를 각각 확인해야 합니다. 카드와 조명을 다시 맞춰 전체 화면만 반복 실행해서는 어느 단계가 원인인지 구분하기 어렵습니다.
다음 편에서는 여기서 설명한 인식 경로를 모듈 안에 유지한 채 입력과 검사 지점을 넓힌 구조를 다룹니다. 파이프라인을 다시 구현한 이야기가 아니라, 같은 스캐너 기능을 앱과 분리하고 녹화 영상과 합성 이미지로 각 경계를 확인한 이야기입니다.