OCR 텍스트 한 뭉치를 구조화된 데이터로 — Foundation Models @Generable로 알림장 파싱하기

아이벤트의 알림장 스캔 기능은 학교·학원 알림장 사진을 찍으면 숙제·준비물·공지·일정을 자동으로 뽑아서 각각의 항목으로 등록해준다. 서버로 이미지를 보내지 않고 iOS 26의 Vision + Foundation Models만으로 기기 안에서 전부 처리한다. 이 글은 OCR 텍스트 한 뭉치가 구조화된 데이터로 바뀌는 파이프라인을 코드 기준으로 정리한다.

파이프라인: OCR → LLM → 도메인 모델

사진(Data)
  → VNRecognizeTextRequest (Vision OCR, iOS 13+)
  → raw text
  → LanguageModelSession.respond(to:generating:) (Foundation Models, iOS 26+)
  → NoticeboardParseResult (Generable)
  → ParsedNoticeItem[] (앱 도메인 모델)

OCR 단계는 NoticeboardAssistantManagerextractText(from:)가 담당한다. 한글·영문 혼용 알림장이 많아서 recognitionLanguages["ko-KR", "en-US"]로 지정하고, 정확도를 위해 recognitionLevel = .accurate를 쓴다.

let request = VNRecognizeTextRequest { request, error in
    let observations = request.results as? [VNRecognizedTextObservation] ?? []
    let text = observations
        .compactMap { $0.topCandidates(1).first?.string }
        .joined(separator: "\n")
    continuation.resume(returning: text)
}
request.recognitionLevel = .accurate
request.recognitionLanguages = ["ko-KR", "en-US"]
request.usesLanguageCorrection = true

여기까지는 순수 Vision 프레임워크라 iOS 13부터 동작한다. 문제는 이 OCR 결과가 줄바꿈만 살아있는 날것의 텍스트라는 점이다. “수학 받아쓰기 숙제”, “체육복 준비”, “학부모 총회 6/30” 같은 문장이 뒤섞여 있는 텍스트에서 항목 단위로 잘라내고 분류하는 작업을 정규식이 아니라 Foundation Models에게 맡긴다.

@Generable로 출력 스키마를 코드로 고정하기

Foundation Models는 @Generable / @Guide로 구조체에 스키마를 붙이면, 모델이 그 스키마를 만족하는 JSON을 생성하도록 강제한다. @Guide(description:)는 사실상 필드 단위 프롬프트다.

@available(iOS 26, *)
@Generable
struct NoticeboardParseResult: Equatable {
    @Guide(description: "알림장에서 추출한 항목 목록 (숙제, 준비물, 공지사항, 일정 등을 모두 포함)")
    var items: [NoticeboardExtractedItem]

    @Guide(description: "알림장 전체 내용을 1~2문장으로 요약")
    var summary: String
}

@available(iOS 26, *)
@Generable
struct NoticeboardExtractedItem: Equatable {
    @Guide(description: "항목 제목 (간결하게, 예: '수학 받아쓰기 숙제', '체육복 준비', '학부모 총회')")
    var title: String

    @Guide(description: "항목 상세 내용")
    var detail: String

    @Guide(description: "분류 코드: homework(숙제) / material(준비물) / notice(공지사항) / schedule(일정)")
    var category: String

    @Guide(description: "마감일 또는 일정 날짜 (yyyy-MM-dd 형식, 없으면 빈 문자열)")
    var dueDate: String
}

덕분에 응답을 파싱할 때 “혹시 필드가 빠지지 않았을까”를 걱정할 필요가 없다. session!.respond(to: prompt, generating: NoticeboardParseResult.self) 호출이 성공하면 response.content는 이미 이 구조체 타입이다. 프리JSON 파싱, 키 누락 방어 코드가 전부 사라진다.

category는 문자열로, 변환은 앱 쪽에서

NoticeboardExtractedItem.category는 enum이 아니라 String이다. Foundation Models 쪽 Generable 타입과 앱의 도메인 enum(ParsedNoticeItem.Category)을 분리해두고, 변환 시점에만 매핑한다.

let items = response.content.items.map { item in
    ParsedNoticeItem(
        id: UUID(),
        title: item.title,
        detail: item.detail,
        category: ParsedNoticeItem.Category(rawValue: item.category) ?? .notice,
        dueDate: item.dueDate.isEmpty ? nil : dateFormatter.date(from: item.dueDate)
    )
}

모델이 스키마에 없는 카테고리 문자열을 뱉어도 ?? .notice로 안전하게 흡수된다. iOS 26 시스템 언어 모델처럼 계속 업데이트되는 모델을 다룰 때는, 모델이 반환하는 원시 값과 앱 내부 타입 사이에 한 겹의 변환 계층을 두는 편이 방어적이다.

날짜는 모델이 추측하게 두지 않는다

가장 공들인 부분은 dueDate 추출 규칙이다. 날짜 환각(hallucination)이 가장 치명적인 필드이기 때문에, 프롬프트에 세 가지 규칙을 명시적으로 박아뒀다.

let prompt = """
오늘 날짜는 \(today)입니다.
다음은 초등학교 알림장에서 OCR로 추출한 텍스트입니다. \
텍스트에서 모든 항목을 추출하여 분류해주세요.

[날짜(dueDate) 추출 규칙]
1. 알림장 항목에 명시적인 마감일, 제출일, 또는 일정 날짜(예: "6월 30일까지 제출", "7/1 수학 시험" 등)가 \
본문에 명확히 기재되어 있는 경우에만 'dueDate'를 해당 날짜로 입력하세요.
2. 날짜 추출 시 연도가 없으면 오늘 기준으로 가장 가까운 미래 날짜로 추정하여 입력하세요. \
(예: 오늘이 2026-06-27이고 본문에 "6/29"가 있다면 2026-06-29로 추정)
3. 만약 항목에 대해 마감일이나 수행 날짜가 본문에 전혀 명시되어 있지 않다면, 절대 임의로 \
추정하거나 오늘 날짜로 채우지 말고, 반드시 빈 문자열("")로 입력해야 합니다.

알림장 내용:
---
\(text)
---
"""

3번 규칙이 특히 중요하다. LLM에게 날짜 필드를 채우라고만 하면, 날짜가 없는 항목에도 오늘 날짜나 그럴듯한 날짜를 지어내는 경우가 있다. “없으면 반드시 빈 문자열”을 명시하고, 앱 쪽에서도 item.dueDate.isEmpty ? nil : ...로 한 번 더 방어한다. 반대로 2번 규칙은 “6/29”처럼 연도가 생략된 표기를 오늘 날짜 기준 미래로 보정하라는 규칙인데, 알림장에 연도까지 적는 경우가 거의 없어서 이 보정 없이는 대부분의 날짜가 비어버렸다.

세션은 actor 안에서 캐시하고, 실패하면 버린다

NoticeboardAssistantManageractor로 선언되어 있고, LanguageModelSession을 매 호출마다 새로 만들지 않고 재사용한다. 다만 저장 프로퍼티 타입은 Any?다.

actor NoticeboardAssistantManager {
    // Any? 로 저장해 stored property에 @available 없이 iOS 26 타입 보관
    private var _session: Any?

    @available(iOS 26, *)
    private var session: LanguageModelSession? {
        get { _session as? LanguageModelSession }
        set { _session = newValue }
    }
    ...
}

@available(iOS 26, *)는 프로퍼티나 메서드 선언에는 붙일 수 있지만, stored property 자체의 타입으로 iOS 26 전용 타입을 직접 쓰면 클래스 전체가 iOS 26 전용이 되어버린다. 이 프로젝트는 iOS 18 이상을 지원해야 하므로, 실제 저장은 Any?로 하고 iOS 26 전용 계산 프로퍼티로 타입을 좁혀서 꺼내 쓰는 방식으로 버전 경계를 피해간다.

세션 재사용에는 리스크도 있다. iOS 26 베타 시점에는 내부 safety 모델이 누락되면 DecodingError가 나는 경우가 있었는데, 이런 에러가 나면 세션 자체가 오염된 상태로 남을 수 있어 즉시 폐기한다.

do {
    let response = try await session!.respond(to: prompt, generating: NoticeboardParseResult.self)
    ...
    return AIParseSuccess(items: items, summary: response.content.summary)
} catch {
    // iOS 26 베타에서 내부 safety 모델 누락 시 DecodingError 발생 — 세션 폐기 후 재전파
    session = nil
    throw error
}

다음 호출에서 session == nil이면 시스템 프롬프트(“당신은 초등학교 알림장 분석 전문가입니다…”)로 새 세션을 다시 만든다. 세션을 캐시하되 실패 시 무조건 새로 시작하는 편이, 매번 새 세션을 만드는 것보다 평균 응답은 빠르면서도 한 번 꼬인 세션 상태가 이후 요청까지 계속 실패시키는 상황은 막아준다.

정리

문제 해결 방식
OCR 결과 구조화 정규식 대신 @Generable/@Guide로 스키마를 코드에 고정
모델 출력과 도메인 타입 불일치 category는 String으로 받고 앱 쪽에서 enum 변환 + 기본값 처리
날짜 환각 방지 프롬프트에 3단계 규칙 명시 + dueDate 빈 문자열은 nil로 이중 방어
iOS 18~25 호환 stored property는 Any?, iOS 26 전용 타입은 계산 프로퍼티로 캐스팅
세션 오염 실패 시 세션 폐기 후 다음 호출에서 재생성

서버 없이 기기 안에서 개인정보가 담긴 알림장을 처리해야 한다는 제약이, 오히려 @Generable 기반 구조화 추출과 방어적인 날짜 규칙을 훨씬 꼼꼼하게 설계하게 만든 셈이다.

글쓴이

admin

https://ivent-admin.storyqbe.top/ 를 운영하고 있는 강프로입니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다