블로그

2026년 8월 15일 · 10분 읽기

iOS 온디바이스 OCR: Vision과 PDFKit으로 서버 OCR 제거하기

iOS 온디바이스 OCR을 서버로 옮겼다가 다시 Vision과 PDFKit으로 되돌린 왕복 기록입니다. 개인정보처리방침과 코드가 어긋난 것을 확인한 시점에서 시작합니다.

  • iOS
  • OCR
  • Vision
  • PDFKit

2026년 8월 23일 업데이트

서버 OCR을 기본값으로 켜려고 동의 근거를 확인하다가, 개인정보처리방침 8항을 읽었습니다.

"OCR(이미지·문서 내 텍스트 인식)은 기기 내에서 처리되며 원본 이미지를 서버로 전송하지 않음"

사실과 정면으로 배치됐습니다. 그 시점 앱에는 온디바이스 OCR 경로가 없었습니다. import Vision이 0건이었습니다. 첨부 원본을 ocr.example.com로 multipart 전송하고 있었습니다.

한눈에 보기

문제 서버 OCR을 기본값으로 켜려고 동의 근거를 확인하다가, 개인정보처리방침 8항을 읽었습니다.
결정 PDF는 두 갈래로 나눴습니다.
결과 같은 기능을 두 번 옮겼습니다.
제약 언어를 늘리면 후보가 늘어 오인이 늘고 처리 시간도 늘어납니다.

처음에는 온디바이스였고, 성능 때문에 서버로 옮겼습니다

Apple의 Vision 프레임워크로 이미지에서 텍스트를 읽고 있었습니다. 기기 안에서 돌고 네트워크가 필요 없습니다.

품질이 문제였습니다. 한국어 문서, 특히 스캔한 문서에서 인식률이 낮았습니다.

서버로 옮기면 큰 모델을 쓸 수 있습니다. ocr.example.com에 게이트웨이를 두고 DeepSeek OCR API를 호출하는 구조로 바꿨습니다.

옮기면서 앱의 사진 OCR 경로를 정리했습니다. 다섯 갈래가 각자 Vision을 부르고 있었습니다.

경로 무엇
컴포즈 메모 작성 중 사진 첨부
홈 캡처 홈에서 카메라로 찍기
상세 사진 추가 저장된 메모에 사진 붙이기
파일 이미지 추가 파일 앱에서 이미지 가져오기
라이브 시드 테스트용 샘플 주입

다섯을 OCRGateway.index 하나로 모았습니다. 로그인 세션, 25MB 업로드 상한, 서버 사용량 한도를 문서 OCR과 같이 씁니다.

그리고 Vision 구현과 테스트, Xcode 프로젝트 참조를 삭제했습니다. 두 경로를 남겨 두면 어느 쪽이 도는지 알 수 없어집니다.

서버 쪽이 전부 나은 것은 아니었습니다

옮긴 뒤에 서버 엔진의 품질을 포맷별로 재 봤습니다. 오차는 CER로 셌습니다. 문자 단위 오류율이고, 0에 가까울수록 정확합니다.

입력 CER 무엇이 일어났나
여러 장 PPTX 0.073 정상
여러 시트 XLSX 0.317 날짜와 상품명이 잘리고 열 경계가 무너짐
빈 셀이 많은 결재선 표 hwpx 3.12 모델이 무한 반복 루프에 빠짐

마지막 줄이 완전 실패입니다. CER이 1을 넘는다는 것은 원문보다 오류가 많다는 뜻입니다. 한국어 결재선 표처럼 빈 셀이 줄줄이 이어지면 모델이 같은 문자를 끝없이 뱉었습니다.

XLSX 쪽은 모델 문제가 아니었습니다. 렌더 단계에서 열 너비를 맞추지 않아 글자가 잘려 있었습니다. 열 자동맞춤 전처리를 넣으니 같은 정규화 기준에서 CER이 0.172에서 0.0055로 떨어졌습니다.

다른 엔진도 붙여 봤습니다. 반복 루프는 사라지고 표 CER도 나아졌지만 20배에서 40배 느렸습니다. 한 쪽에 15초에서 40초입니다. 라이선스도 매출 상한이 걸려 있었습니다.

큰 모델로 옮기면 품질이 오른다는 전제가 포맷마다 달랐습니다.

삭제가 빌드를 두 번 깼습니다

Swift 파일을 지우는 것으로 끝나지 않았습니다.

첫 실패는 Xcode 프로젝트 참조였습니다. 파일은 지웠는데 .pbxproj에 build 참조, file 참조, group 참조가 남아 빌드가 깨졌습니다. 셋을 함께 지워야 했습니다.

두 번째 실패는 함수 서명이었습니다. OCRGateway.index에 행 배열과 인덱스 매개변수를 추가하는 편집을 빠뜨려 컴파일 오류가 났습니다.

검증은 AttachmentClassifierTests 7개와 서버 쪽 Go 테스트로 했습니다. TestImageUploadBypassesNormalizer가 PNG가 정규화기를 거치지 않고 OCR 업스트림으로 직행하는 것을 확인했습니다.

기본값을 켜려다 방침이 거짓인 것을 발견했습니다

서버 OCR은 처음에 꺼진 상태로 나갔습니다. 사용자가 설정에서 켜야 했습니다.

켜는 사람이 적었습니다. 그래서 기본값을 ON으로 바꾸려 했습니다. 원본 이미지가 서버로 나가는 기능이니 동의 근거를 먼저 확인해야 했습니다.

거기서 방침 8항을 읽었습니다. 방침은 기기 안에서 처리한다고 적고 있었고, 코드는 서버로 보내고 있었습니다.

import Vision을 검색해 봤습니다. 0건이었습니다. 온디바이스 경로는 존재하지 않았습니다.

순서가 이렇게 어긋났습니다

시점 코드 방침
온디바이스 OCR 시절 Vision으로 기기에서 처리 기기에서 처리한다
서버로 이전한 뒤 OCR 게이트웨이로 원본 전송 기기에서 처리한다

코드를 옮길 때 방침을 같이 고치지 않았습니다. 방침은 한 번 쓰고 나면 아무도 읽지 않습니다. 코드가 바뀔 때 방침이 바뀌는 장치가 없었습니다.

기본값을 켜는 일과 방침을 정정하는 일을 같은 작업으로 묶었습니다. 방침 정정본을 justsend.cloud/legal/privacy에 배포한 다음에 기본값을 켰습니다.

조용한 실패 셋도 같이 드러냈습니다

같은 작업에서 사용자에게 보이지 않던 실패 세 가지를 화면에 띄웠습니다.

  • OCR이 꺼져 있어서 텍스트를 못 읽은 경우
  • 첨부가 처리 대상에서 제외된 경우
  • AI 요약을 쓸 수 없는 상태인 경우

셋 다 조용히 아무 일도 하지 않았습니다. 사용자는 앱이 읽었다고 믿고 넘어갔습니다.

기능이 없는 것보다 없다는 것을 모르는 것이 나쁩니다.

다시 온디바이스로 돌아왔습니다

방침을 코드에 맞추는 것과 코드를 방침에 맞추는 것 중에 후자를 골랐습니다.

서버 OCR 경로를 제거하고 기기 안에서 추출하는 구조로 돌아왔습니다. 이번에는 Vision 하나가 아니라 세 가지를 조합했습니다.

입력 무엇으로 읽는가
사진 (JPEG, PNG 등) ImageIO로 열고 Vision으로 텍스트 인식
텍스트 레이어가 있는 PDF PDFKit에서 텍스트를 직접 꺼냅니다
스캔한 PDF (텍스트 레이어 없음) 쪽을 이미지로 렌더한 뒤 Vision
문서 파일 (docx, pptx 등) JustSendDocKit 파서
평문 UTF-8로 읽습니다

PDF는 두 갈래로 나눴습니다. 텍스트 레이어가 있으면 렌더할 이유가 없습니다. 꺼내 쓰면 정확하고 빠릅니다. 빈 쪽에서만 Vision으로 내려갑니다.

다섯 갈래를 AttachmentTextExtractor 하나의 경계로 모았습니다. 홈 캡처, 상세 첨부, 공유 확장에서 앱으로 돌아오는 경로, 테스트용 샘플이 같은 경계를 씁니다.

껐다가 아니라 지웠습니다

서버 OCR을 설정으로 끄는 방식을 쓰지 않았습니다.

계획에 이렇게 적혀 있습니다.

서버 OCR을 단순 비활성화하지 않고 OCRClient와 token/consent/entitlement
배선을 clean cutover로 제거한다.

지운 것이 넷입니다.

  • OCRClient와 게이트웨이 호출 코드
  • serverOCREnabled 런타임 설정
  • 동의 화면과 그 문구
  • 서버 OCR을 유료 혜택으로 묶은 StoreKit 연결

데이터베이스도 정리했습니다. 마이그레이션 v52에서 서버 OCR 동의 컬럼을 제거했습니다.

껐다가 남겨 두면 그 코드가 계속 살아 있습니다. 어느 조건에서 다시 켜질지 아무도 모릅니다. 방침이 "기기에서 처리한다"고 말하려면 서버로 보낼 수 있는 코드가 없어야 합니다.

네트워크를 원천 차단하고 테스트했습니다

"서버로 안 보낸다"를 증명하는 방법이 문제였습니다. 코드를 읽어서 호출이 없다고 말하는 것으로는 부족합니다.

테스트에서 네트워크를 통째로 막았습니다. 전역 URLProtocol을 차단한 상태에서 실파일 OCR 테스트를 돌렸습니다.

네트워크를 쓰려고 하면 테스트가 실패합니다. 통과한다는 것은 호출이 없다는 뜻입니다.

실패하는 테스트를 먼저 만들었습니다

검증 항목을 여섯 개로 적고, 각각 실패하는 상태부터 확보했습니다.

확인할 것 어떻게
원본 바이트 보존 저장된 파일이 입력과 같은지
페이지 순서 3페이지 PDF의 쪽 순서
네트워크 0회 URLProtocol 차단 상태에서 통과
같은 입력에 같은 결과 두 번 실행해 비교
앱을 다시 켠 뒤 검색 DB 재오픈 후 텍스트가 찾아지는지
공유 확장의 대기 재시도 실패한 항목이 다시 처리되는지

fixture는 실제 파일로 만들었습니다. 한글과 영문이 든 JPEG와 PNG, 그리고 텍스트 레이어가 없는 3페이지 스캔 PDF입니다.

합성 데이터로는 OCR을 검증할 수 없습니다. 실제 사진의 조명과 기울기가 인식률을 결정합니다.

최종 결과는 JustSendTests 424개 실행, 7 skipped, 0 failures였습니다. skip된 7개는 환경에 의존하는 것들입니다.

픽스처가 텍스트를 갖고 있으면 검증이 성립하지 않습니다

OCR 통합 테스트에 함정이 하나 있었습니다.

// app/JustSend/Tests/OCRDeviceIntegrationTests.swift:57-58
.isEmpty
}, "The PDF fixture must be image-only so PDFKit cannot satisfy the OCR test")

PDF에는 텍스트 레이어가 있을 수 있습니다. 그런 PDF는 PDFKit이 글자를 그냥 꺼내 줍니다. OCR을 돌지 않습니다.

그런 파일로 테스트하면 OCR이 고장 나 있어도 통과합니다. 텍스트가 나왔으니까요.

그래서 픽스처가 이미지 전용인지를 테스트가 먼저 확인합니다. 실패 메시지에 이유를 적어 뒀습니다. 나중에 누가 픽스처를 바꾸면 이 단정이 먼저 걸립니다.

온디바이스로 오면서 상한이 생겼습니다

서버에서는 큰 파일을 보내면 됐습니다. 기기에서 처리하면 메모리와 시간이 사용자 손에서 나옵니다.

그래서 상한을 박았습니다.

// app/JustSend/Sources/OCR/AttachmentTextExtractor.swift:23-29
maxImagePixels = 4_096      // 이미지 썸네일 한 변의 상한
maxPDFRenderPages = 64      // OCR 대상 쪽 상한
maxPDFRenderPixels = 2_048  // PDF 쪽을 렌더할 때 한 변의 상한

maxPDFRenderPages 64가 사용자에게 보이는 상한입니다. 100쪽 스캔 PDF를 넣으면 64쪽까지만 읽습니다.

이때 화면이 그 사실을 말해야 합니다. 읽은 범위를 침묵하면 사용자는 요약이 문서 전체를 담았다고 믿습니다.

확장자로 판정하고 MIME은 보지 않습니다

첨부를 어떻게 읽을지 정하는 규칙이 하나 더 있습니다.

// JustSendMemoryCore/.../AttachmentReadPlan.swift:57-95
// 확장자를 lowercase해 PDF·이미지·평문·파싱 문서·미리보기·보관 전용 순으로
// 판정하며 MIME은 보지 않는다.

MIME 타입을 보지 않는 것이 선택입니다. 공유 시트나 파일 앱에서 오는 MIME은 신뢰할 수 없습니다. 같은 파일이 출처에 따라 다른 타입으로 옵니다.

확장자는 사용자가 보는 것과 같습니다. 판정이 화면에 보이는 것과 어긋나지 않습니다.

줄 단위 인식이 표를 망가뜨렸습니다

온디바이스로 돌아오니 인식 품질을 직접 다뤄야 했습니다. 가장 크게 걸린 것이 표였습니다.

// app/JustSend/Sources/Meaning/PageSources.swift:49-53
/// 줄 단위 인식(`VNRecognizeTextRequest`)은 표를 만나면 셀 하나를 한 줄로
/// 떨어뜨린다. 급여명세서가 `발급 / 일 / 2025 / . 11. 11 / 회사명`처럼 흩어져
/// 나왔고, 그 상태로는 어떤 요약도 정확할 수 없다 — 입력이 이미 망가져 있다.
/// iOS 26의 `RecognizeDocumentsRequest`는 표를 행·열로 돌려주므로 표를 표로

발급이 갈라지고 2025. 11. 11이 갈라졌습니다. 급여명세서의 한 줄이 다섯 조각으로 흩어졌습니다.

이 상태에서는 요약이 정확할 수 없습니다. 모델이 못 하는 것이 아니라 입력이 이미 망가져 있습니다.

iOS 26의 RecognizeDocumentsRequest는 표를 행과 열로 돌려줍니다. 표를 표로 받으면 셀의 이웃 관계가 남습니다.

요약 품질을 모델 쪽에서 올리려 하기 전에 입력이 무엇인지 봐야 합니다. 여기서는 인식 API를 바꾸는 것이 프롬프트를 고치는 것보다 컸습니다.

인식 언어를 두 개로 고정했습니다

// app/JustSend/Sources/Meaning/PageSources.swift:69-73
var options = request.textRecognitionOptions
options.recognitionLanguages = [
  Locale.Language(identifier: "ko-KR"),
  Locale.Language(identifier: "en-US"),
]

한국어와 영어만 넣었습니다. 목록에 없는 언어는 인식하지 않습니다.

언어를 늘리면 후보가 늘어 오인이 늘고 처리 시간도 늘어납니다. 실제로 들어오는 문서의 언어만 넣는 편이 정확합니다.

줄 단위 폴백 경로에도 같은 값을 씁니다. 두 경로가 다른 언어 목록을 쓰면 어느 쪽으로 갔는지에 따라 결과가 달라집니다.

포맷마다 다른 경로로 갔습니다

첨부가 PDF일 수도 있고 hwp일 수도 있습니다. 어떻게 읽을지가 포맷마다 다릅니다.

// JustSendMemoryCore/Sources/JustSendMemoryCore/AttachmentReadPlan.swift:6-10
/// - PDF는 `PDFKit`으로 **쪽마다** 골라 그릴 수 있다. 다중 페이지 OCR의 유일한 경로다.
/// - `QLThumbnailGenerator`는 페이지 지정이 없어 **대표 이미지 한 장**만 준다.
///   doc·xls는 내용을 그려 주지만, hwp는 파일 아이콘만 준다.
/// - 파서가 있는 포맷은 글자가 이미 있으므로 그릴 이유가 없다.

파서가 다루는 확장자는 12개입니다. docx, xlsx, pptx, hwpx, odt, ods, odp, epub, pages, numbers, key, rtf입니다. 이 목록은 두 곳에 적혀 있습니다. 코어가 JustSendDocKit을 import하지 않기 때문입니다. 대신 계약 테스트가 두 목록이 어긋나지 않는지 확인합니다.

포맷 경로 한계
PDF PDFKit으로 쪽마다 렌더 다중 페이지 OCR의 유일한 경로
파서가 있는 12종 JustSendDocKit 글자가 이미 있어 그릴 이유가 없습니다
doc·xls·ppt·pps QLThumbnailGenerator 대표 이미지 한 장만
hwp 어디에도 없습니다 저장만 합니다

한글 문서가 두 갈래로 갈립니다. hwpx는 파서가 읽습니다. XML 컨테이너라서 글자를 꺼낼 수 있습니다. 구형 hwp는 목록 어디에도 없어서 storeOnly로 떨어집니다. 파일은 저장되고 본문은 비어 있습니다.

hwp를 미리보기 목록에 넣지 않은 이유가 주석에 적혀 있습니다. 실측에서 시스템이 내용을 못 그리고 파일 아이콘을 줬습니다. 그 아이콘을 OCR하면 파일명이 본문으로 저장됩니다.

이것을 모르고 만들면 "hwp OCR이 왜 빈 결과를 주는가"를 디버깅하게 됩니다. 원인은 OCR이 아니라 그 앞 단계입니다.

코어가 PDFKit을 import하지 않습니다

// JustSendMemoryCore/Sources/JustSendMemoryCore/PageOrchestrator.swift:5-7
/// PDF는 PDFKit이, 미지원 포맷(hwp·doc·xls·ppt)은 미리보기 뷰어가 구현한다.
/// `JustSendMemoryCore`는 PDFKit·Vision·WebKit을 모른다 — 그래야 오케스트레이션을
/// 파일 없이 테스트할 수 있고, 뷰어를 바꿔도 파이프라인이 그대로 남는다.

코어는 프로토콜만 압니다. 실제 렌더링은 앱 쪽이 구현합니다.

두 가지를 얻습니다. 파이프라인 테스트에 실제 PDF 파일이 필요 없고, 렌더러를 바꿔도 파이프라인 코드가 그대로 남습니다.

두 번째가 이 왕복에서 실제로 일했습니다. 서버 OCR을 붙였다 떼는 동안 오케스트레이션 계층은 건드리지 않았습니다.

두 번의 이사에 따라온 짐이 서로 달랐습니다

같은 기능을 두 번 옮겼습니다. 따라온 것이 달랐습니다.

온디바이스 → 서버 서버 → 온디바이스
얻은 것 인식 품질 네트워크 0회, 방침과 코드 일치
잃은 것 오프라인 동작, 프라이버시 큰 모델의 품질
새로 생긴 것 업로드 상한, 사용량 한도, 동의 화면 쪽 수·픽셀 상한
지워야 했던 것 Vision 구현, 테스트, Xcode 참조 OCRClient, 설정, 동의 UI, StoreKit 연결, DB 컬럼
예상 못 한 비용 .pbxproj 참조 세 종류 네트워크 차단 테스트 환경

오른쪽 열의 "지워야 했던 것"이 다섯 개입니다. 기능을 추가할 때보다 걷어낼 때 건드릴 곳이 많습니다.

방침과 코드가 어긋난 것을 발견한 시점도 봐야 합니다. 기능을 만들 때가 아니라 기본값을 바꾸려고 동의 근거를 확인할 때였습니다. 그 확인을 하지 않았다면 계속 어긋난 채로 있었을 것입니다.