Apple Vision 기반 온디바이스 OCR 구현기: TDD와 Concurrency
안녕하세요.
이번에는 개인 프로젝트의 기능 중 하나인 온디바이스 OCR(광학 문자 인식)을 구현한 과정을 정리해보려 합니다.
TDD(Test-Driven Development)방식을 사용하고, Apple Vision 프레임워크의 콜백 기반 비동기 API를 async/await로 매핑했습니다.
Apple Vision Framework?
이미지에서 글자를 추출하는 OCR 엔진을 선택할 때 여러 방법이 있지만, iOS에서는 Apple의 머신러닝 Vision Framework를 사용해서 구현이 가능합니다. 아래는 몇가지 특징입니다.
- 무료: 다른 LLM API을 사용할 필요가 없으니 비용이 발생하지 않습니다.
- 프라이버시 보장 (Privacy): 서버로 이미지를 전송하지 않고 사용자의 디바이스 내부(On-Device)에서 처리되므로 개인정보 유출 우려가 없습니다.
- 오프라인: 인터넷 연결이 없는 환경에서도 사용할 수 있습니다.
- 한국어 인식률: iOS16 / macOS13 이후부터 한국어 텍스트 인식 품질이 향상되어 사용하기에 충분한 수준입니다.
💡 Apple의 온디바이스 ML(머신러닝) 개요
애플은 애플 실리콘(Apple Silicon)에 탑재된 ANE(Apple Neural Engine) 하드웨어를 활용하여 디바이스 내부(On-Device)에서 실행할 수 있는 머신러닝 프레임워크들을 제공하고 있습니다.
애플 공식 개발자 문서 Built-in Intelligence 를 기준으로 각 프레임워크와 주요 기능은 다음과 같습니다.
Vision Framework (시각 데이터 분석)
이미지 및 비디오 등 컴퓨터 비전(Computer Vision) 작업을 담당합니다.
- 텍스트 인식 (OCR):
VNRecognizeTextRequest를 사용해 다국어 텍스트와 좌표를 기기 내부에서 판독합니다. - 얼굴 및 이목구비 감지: 얼굴의 유무 및 눈, 코, 입 등의 상세 랜드마크(
VNDetectFaceLandmarksRequest)를 추적합니다. - 바코드 & QR 인식: 카메라 화면 속 바코드와 QR 코드를 탐지합니다.
- 인체 포즈 추적: 손가락 마디(
VNDetectHumanHandPoseRequest) 및 인체 관절 포즈(VNDetectHumanBodyPoseRequest)를 실시간으로 탐지합니다. - 시각적 특징 분류: 이미지의 분위기, 객체, 중요 시각 영역(Saliency)을 분석합니다.
Natural Language Framework (자연어 처리)
텍스트의 의미를 이해하고 구문을 분석하는 자연어 처리(NLP)를 담당합니다.
- 언어 식별 (Language Identification): 입력된 텍스트가 어떤 언어인지 식별합니다.
- 토큰화 (Tokenization): 텍스트를 문장, 단어 단위로 분할합니다.
- 품사 분석 및 명사구 분석: 문장 속 단어를 판별하고 그 단어의 개체명(NER, Named Entity Recognitio)을 식별합니다. ex) 오후4시 -> 시간
- 감정 분석 (Sentiment Analysis): 작성된 텍스트의 감정 점수를 분석합니다.
Speech & SoundAnalysis Framework (음성 및 음향 분석)
소리와 관련된 오디오 분석 및 텍스트 변환을 담당합니다.
- Speech (음성 인식): 오디오 파일이나 마이크 입력 속 목소리를 텍스트로 변환합니다.
- SoundAnalysis (소리 분류): 아기 울음소리, 사이렌 소리, 유리창 깨지는 소리 등의 오디오를 인식합니다.
4. Core ML & Create ML (인공지능 엔진과 커스텀 학습)
- Core ML: 애플 플랫폼의 머신러닝을 아우르는 기반 엔진입니다. PyTorch나 TensorFlow 등으로 훈련된 모델을
.mlpackage파일로 변환하여 배포, 사용할 수 있습니다. - Create ML: 개발자가 직접 이미지 분류, 사물 탐지 등을 학습시키는 도구입니다.
5. Foundation Models API (생성형 AI - Apple Intelligence)
온디바이스에서 거대 언어 모델(LLM)을 직접 호출하여 아래 동작을 제공합니다.
- 작성 지원 (Writing Tools): 텍스트 어조 변경, 문법을 교정할 수 있습니다.
- 요약 (Summarization): 본문 요약, 핵심 요점 추출할 수 있습니다.
- 스마트 응답 (Smart Reply): 메시지 답변을 추천합니다.
🔍 도메인 및 Apple Vision API 명세 분석
구현에 들어가기 전에, 이번 기능 개발에 사용한 핵심 도메인 타입들과 Apple Vision 프레임워크 API들이 각각 무엇을 담당하는지 살펴보겠습니다.
1. 도메인 레이어 (Domain Layer) 정의
외부 프레임워크나 네트워크 상태와 무관하게, 비즈니스 로직의 기준을 정의하는 타입들입니다.
OCRRepository(인터페이스)
외부 모듈이나 API에 의존하지 않는 순수 추상 인터페이스 프로토콜입니다.
import Foundation
/// 이미지 데이터로부터 글자를 추출(인식)하는 비즈니스 규칙의 추상 인터페이스(Protocol)입니다.
/// 특정 프레임워크(Vision 등)나 외부 SDK에 의존하지 않는 순수 도메인 레이어의 계약서 역할을 합니다.
protocol OCRRepository {
/// 주어진 이미지 바이너리 데이터(Data)에서 텍스트와 좌표 정보를 추출하여 도메인 모델인 OCRResult 객체로 반환합니다.
/// - Parameter imageData: 글자 판독 대상이 되는 이미지 데이터 (예: JPEG, PNG 등)
/// - Returns: 판독에 성공한 텍스트 줄(Line)들과 상대적 좌표 정보가 담긴 OCRResult
/// - Throws: 이미지 처리 실패 또는 비전 처리 내부 에러 발생 시 예외를 던집니다.
func recognizeText(in imageData: Data, rotation: RotationDegree) async throws -> OCRResult
}OCRResult&RecognizedLine&NormalizedRect(엔티티)
인식된 글자의 텍스트 본문, 신뢰도, 정규화된 좌표 범위를 담는 구조체입니다.
// 이미지 한 장에 대한 전체 OCR 결과
struct OCRResult: Equatable {
static let minimumConfidence: Float = 0.5
let lines: [RecognizedLine]
var filteredLines: [RecognizedLine] {
lines.filter { $0.confidence >= Self.minimumConfidence }
}
}
// 인식된 개별 텍스트 행 정보
struct RecognizedLine: Equatable {
let text: String
let boundingBox: NormalizedRect
let confidence: Float
}
/// 이미지 전체 가로/세로 길이를 1.0으로 기준 삼아 상대 비율로 나타낸 정규화된 좌표 사각형 모델입니다.
/// Apple Vision API의 CGRect Bounding Box에 직접 1:1로 매핑되는 도메인 내 독자 규격 데이터형입니다.
struct NormalizedRect: Hashable {
/// 사각형 좌측 모서리의 X 비율 위치 (0.0 ~ 1.0)
let x: Double
/// 사각형 하단 모서리의 Y 비율 위치 (0.0 ~ 1.0, Apple Vision 기준 Y-Up 좌표계 사용. 0.0은 이미지 최하단, 1.0은 이미지 최상단을 뜻하며 값이 클수록 상단에 가까움)
let y: Double
/// 사각형의 가로 비율 폭 (0.0 ~ 1.0)
let width: Double
/// 사각형의 세로 비율 높이 (0.0 ~ 1.0)
let height: Double
}2. Apple Vision API 명세
Vision 프레임워크를 사용해 텍스트를 검출할 때 필수적으로 연동되는 핵심 API 세 가지입니다.
VNImageRequestHandler(이미지 처리기)
단일 이미지에 대한 하나 이상의 비전 요청을 처리하는 객체입니다. 초기화 시 이미지 데이터를 받고,perform메서드를 실행하여 입력된 요청들을 처리합니다.VNRecognizeTextRequest(텍스트 인식 요청)
이미지에서 텍스트를 찾아내고 판독하는 비전 요청입니다. 정확도 수준(recognitionLevel), 보정 기능 활성화 여부, 대상 언어 목록 등을 설정할 수 있으며, 처리가 완료되면 콜백 클로저를 호출합니다.VNRecognizedTextObservation(텍스트 인식 결과 관측)
컴파일러가 텍스트 인식 요청을 완료했을 때 반환하는 결과 객체입니다. 이미지 대비 상대적인 좌표 범위(boundingBox)를 소유하고 있으며,topCandidates메서드를 통해 가장 신뢰도가 높은 인식 텍스트 문자열들을 조회할 수 있습니다.
📖 Apple 공식 가이드로 배우는 Vision OCR 기본 구조
애플의 개발자 가이드(Recognizing Text in Images)에서는 이미지에서 텍스트를 추출하기 위한 표준적인 흐름을 제시하고 있습니다. 이 프로세스는 크게 개요, 요청 설정, 요청 수행, 결과 처리의 4단계로 구성되며, 각 단계별 동작과 소스코드는 다음과 같습니다.
1. 개요 (Overview)
Vision 프레임워크는 이미지 속에서 캐릭터(글꼴) 영역을 감출하여 글자가 들어있는 가장 작은 사각형 단위인 경계 상자(Bounding Box)들을 식별합니다.
이 경계 상자는 원본 이미지의 픽셀 좌표가 아닌, 이미지 크기 전체를 가로 1.0, 세로 1.0의 비율로 잡는 정규화된 비율 좌표(0.0 ~ 1.0)로 표현됩니다. 이를 통해 이미지의 실제 표시 해상도가 달라지더라도 텍스트의 상대 위치를 일관되게 계산할 수 있습니다.
2. 요청 설정 (Configure the Request)
어떤 옵션으로 글자를 판독할지 결정하는 작업 지시서를 생성하고 설정을 입력하는 단계입니다.
// 1. 텍스트 인식 요청 객체 생성
let request = VNRecognizeTextRequest { request, error in
// 비동기 처리 콜백 (4단계에서 상술)
}
// 2. 판독 수준 설정 (.accurate: 정확도 우선, .fast: 연산 속도 우선)
request.recognitionLevel = .accurate
// 3. 언어 설정 (우선순위가 높은 언어 순으로 ISO 코드를 명시)
request.recognitionLanguages = \["ko-KR", "en-US"\]
recognitionLanguages에 적힌 배열의 앞 순서일수록 엔진이 해당 언어를 우선하여 매칭하려 시도합니다.
3. 요청 수행 (Perform the Request)
이미지 데이터를 담은 대리자(VNImageRequestHandler)를 준비하고, 2단계에서 만든 작업 지시서를 실행시킵니다.
// 1. 이미지 데이터를 바탕으로 핸들러 초기화
let handler = VNImageRequestHandler(cgImage: cgImage, options: \[:\])
// 2. 비동기 백그라운드 스레드에서 요청 수행
DispatchQueue.global(qos: .userInitiated).async {
do {
try handler.perform(\[request\])
} catch {
print("요청 수행 실패: (error)")
}
}
- 고해상도 이미지의 문자 디텍션은 매우 무거운 연산입니다. 메인 스레드에서
handler.perform을 실행할 경우 사용자 화면이 일시적으로 굳어버리므로(Freeze), 반드시 GCD(DispatchQueue.global) 등을 통해 백그라운드 스레드에서 격리 수행해야 합니다.
4. 결과 처리 (Process the Results)
인식이 성공적으로 마무리되면, 미리 지정해 둔 텍스트 인식 요청(VNRecognizeTextRequest)의 완료 클로저 콜백이 호출됩니다.
let request = VNRecognizeTextRequest { (request, error) in
if let error = error {
print("에러 발생: (error)")
return
}
// 1. 결과 데이터 배열(VNRecognizedTextObservation) 수신
guard let observations = request.results as? [VNRecognizedTextObservation] else {
return
}
// 2. 각 관측 영역에서 가장 신뢰도가 높은 1순위 문자열 후보(topCandidates) 추출
let recognizedStrings = observations.compactMap { observation -> String? in
return observation.topCandidates(1).first?.string
}
print("인식 결과: \(recognizedStrings.joined(separator: "\n"))")
}- 결과물로 주어지는
VNRecognizedTextObservation은 이미지 안에서 찾아낸 한 줄짜리 글자 덩어리입니다.topCandidates(1)를 호출해 가장 판독 확률이 높은 후보군을 선택하여 텍스트 데이터(first?.string)를 획득합니다.
🛠 실제 우리 프로젝트에서의 상황: TDD와 Swift 6 Concurrency 연결
애플 공식 가이드가 제공하는 소스코드는 훌륭하지만, 실제 우리 프로젝트에 그대로 적용하기에는 세 가지 보완해야 할 점이 있었습니다.
- 첫째, 중첩된 콜백 구조와 GCD: 요즘 Swift 표준인
async/await동시성이 아닌 예전 스타일의 완료 클로저와DispatchQueue분기를 수동으로 처리해야 합니다. - 둘째, 도메인 결합도: 도메인 엔티티(
OCRResult)와 애플의 UI 프레임워크 타겟 타입(VNRecognizedTextObservation)이 직접 엮여 결합도가 강해집니다. - 셋째, 단위 테스트 불가: 비동기 처리가 제어되지 않아
tuist test를 돌려도 성공/실패 여부를 보장할 수 없습니다.
이를 해결하기 위해, 먼저 실패하는 TDD 테스트 코드를 작성한 뒤 비동기 구조를 개선해 나갔습니다.
2. TDD 1단계: 실패하는 테스트 작성 (Red)
구현 코드를 작성하기 전에 테스트 코드를 먼저 작성했습니다.
테스트의 목표는 "Tests 폴더에 위치한 실제 이미지 파일에서 한글 텍스트를 읽어내는지 검증하는 것"이었습니다.
테스트 리소스 경로 탐색 (Swift 6)
Tuist 설정 파일인 Project.swift를 수정하여 이미지 파일을 앱 리소스 번들에 패키징하는 대신, #filePath 매크로와 Swift 6의 새로운 URL(filePath:) API를 사용하여 소스코드 기준으로 이미지 위치를 찾아내도록 처리했습니다.
// Tests/VisionOCRRepositoryTests.swift
import Testing
import Foundation
@testable import itop
@Suite("Vision OCR Repository 테스트")
struct VisionOCRRepositoryTests {
let repository = VisionOCRRepository()
@Test("이미지 파일에서 글자를 추츨하여 OCRResult로 반환하는지 테스트")
func testRecognizeTextFromRealImage() async throws {
let imageURL = URL(filePath: #filePath).deletingLastPathComponent().appending(path: "ocr_test_image.png")
let testImageData = try Data(contentsOf: imageURL)
let ocrResult = try await repository.recognizeText(in: testImageData, rotation: .degree0)
#expect(ocrResult.lines.isEmpty == false)
#expect(ocrResult.lines[0].text.contains("상승장에서는"))
#expect(ocrResult.lines[1].text.contains("판단까지"))
#expect(ocrResult.lines[2].text.contains("이유만으로"))
print("---ocr 이미지 인식 결과---")
ocrResult.lines.forEach { print($0.text) }
print("----------------------")
}
}
이 시점에는 VisionOCRRepository 클래스가 생성되지 않았으므로 컴파일 에러(첫 번째 Red)가 발생합니다.
컴파일 에러를 해결하기 위해 도메인 인터페이스(OCRRepository)와 데이터 구현체(VisionOCRRepository)의 빈 껍데기를 만들어 빌드를 통과시킨 뒤, 테스트를 실행해 실패 결과(두 번째 Red)가 떨어지는 것을 확인했습니다.
3. TDD 2단계: 실제 구현체 작성 (Green)
테스트를 성공시키기 위해 Apple의 Vision 프레임워크를 사용하는 구현 코드를 작성했습니다.
콜백 비동기 API를 async/await로 매핑하기
Apple의 Vision 프레임워크는 비동기 처리가 현대의 async/await가 아닌 클로저 기반 콜백(Callback) 형태로 설계되어 있습니다.
이를 Swift 6 Concurrency에 부합하도록 감싸기 위해 withCheckedThrowingContinuation을 사용했습니다. 이 함수는 옛날 스타일의 완료 콜백 메서드를 우리가 즐겨 쓰는 모던한 async/await 함수로 변환시켜 주는 징검다리 역할을 합니다.
import Foundation
import Vision
/// Apple의 Vision 프레임워크를 사용하여 이미지 내 텍스트를 판독하는 실제 데이터(Data) 레이어 구현체입니다.
/// `OCRRepository` 프로토콜을 구현(Conform)합니다.
final class VisionOCRRepository: OCRRepository {
init() { }
/// Apple Vision API를 호출하여 이미지 바이너리(Data)로부터 글자를 판독합니다.
/// 콜백 클로저 방식의 오래된 API를 Swift 6의 현대적인 `async/await` 동시성 흐름으로 감싸기 위해
/// `withCheckedThrowingContinuation`을 활용합니다.
///
/// - Parameter imageData: 원본 이미지의 바이너리 데이터
/// - Returns: 이미지에서 찾아낸 글자들과 바바운딩 박스(비율 좌표) 정보가 들어있는 OCRResult
func recognizeText(in imageData: Data, rotation: RotationDegree) async throws -> OCRResult {
try await withCheckedThrowingContinuation { continuation in
// 1. 특정 이미지 데이터를 분석하기 위한 요청 처리자(Handler) 생성
let requestHandler = VNImageRequestHandler(data: imageData, orientation: rotation.cgImageOrientation)
// 2. 텍스트 인식 요청(VNRecognizeTextRequest) 구성 (완료 시 콜백 수행)
let request = VNRecognizeTextRequest { request, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let observations = request.results as? [VNRecognizedTextObservation] else {
continuation.resume(returning: OCRResult(lines: []))
return
}
// 3. Apple Vision의 관측 결과(Observation)를 도메인 엔티티(RecognizedLine)로 매핑
let recognizedLines = observations.compactMap { observation -> RecognizedLine? in
guard let candidate = observation.topCandidates(1).first else { return nil }
// Apple Vision의 boundingBox는 좌측 하단(0,0)을 기준으로 하는 0.0 ~ 1.0 비율 좌표입니다.
let box = observation.boundingBox
let rect = NormalizedRect(x: box.origin.x, y: box.origin.y, width: box.size.width, height: box.size.height)
return RecognizedLine(text: candidate.string, boundingBox: rect, confidence: candidate.confidence)
}
continuation.resume(returning: OCRResult(lines: recognizedLines))
}
// 4. 비전 연산 옵션 미세 조정
request.recognitionLevel = .accurate // 최상의 판독 성능을 지향 (.fast 대비 정밀)
request.usesLanguageCorrection = true // 사전 데이터 기반 언어 자동 보정 수행
request.recognitionLanguages = ["ko-KR", "en-US"] // 한글 및 영어를 주 판독 언어로 명시
// 5. 비전 연산 처리 개시
do {
try requestHandler.perform([request])
} catch {
continuation.resume(throwing: error)
}
}
}
}
fileprivate extension RotationDegree {
var cgImageOrientation: CGImagePropertyOrientation {
switch self {
case .degree0: return .up
case .degree90: return .right
case .degree180: return .down
case .degree270: return .left
}
}
}
4. TDD 3단계: 테스트 성공 (Green) 및 리팩토링
구현을 마치고 터미널에 다시 tuist test를 실행했습니다.
Suite "Vision OCR Repository 테스트" passed after 0.285 seconds
정상적으로 테스트가 성공(Green)으로 통과되었습니다.
터미널 콘솔 로그에 한글 텍스트들과 0.99 수준의 높은 신뢰도 수치가 출력되는 것을 확인할 수 있습니다.
'programming > Swift(iOS)' 카테고리의 다른 글
| [Swift/iOS] 단순 UUID에서 Type Safe한 ID로 수정(+ Swift Macro 살짝) (0) | 2026.07.25 |
|---|---|
| [Swift/iOS] DispatchQueue와 Task의 차이 — 두 AI와 대화로 정리한 Swift 동시성 (0) | 2026.05.23 |
| [Swift/iOS] (Base) XML Parser를 만드는 과정, 설계부터 고민까지 (0) | 2026.05.09 |
| [Swift/iOS] Jailbreak Detection(탈옥 감지)의 필요성 (1) | 2026.04.25 |
| [macOS] .pkg(.dmg) 배포를 위한 서명 및 공증(Notarization) (0) | 2026.04.11 |