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 기반 구조화 추출과 방어적인 날짜 규칙을 훨씬 꼼꼼하게 설계하게 만든 셈이다.

프롬프트 하나로 오늘과 내일을 가르다 — 아이벤트 AI 브리핑의 컷오프 로직

아이벤트의 AI 브리핑은 매일 아침과 저녁, 부모가 앱을 열기 전에 자녀의 오늘/내일 일정과 알림장 내용을 한 문단으로 요약해준다. 이 기능을 구현하면서 가장 까다로웠던 부분은 모델 선택이나 API 연동이 아니라 “지금이 오늘 브리핑을 줄 시점인가, 내일 브리핑을 줄 시점인가”를 판단하는 로직과, 그 판단에 맞춰 프롬프트를 정확히 분기하는 일이었다.

고정 시각 cron이 아니라 동적 컷오프

AiBriefingJobcron_expression = "0 3,21 * * *"로 새벽 3시와 밤 9시에 두 번 실행된다. 하지만 이 두 시각이 그대로 “오늘 모드”/“내일 모드”를 결정하지는 않는다. 자녀마다 하교 시각과 학원 스케줄이 다르기 때문에, 그룹별로 그날 일정이 끝나는 시각을 계산해서 컷오프를 정한다.

def _max_schedule_end_minutes(self, db, children, today):
    """그룹 내 모든 자녀의 학교 하교 + 학원 종료 시각 중 MAX (분 단위). 없으면 None."""
    max_mins = None
    for child in children:
        dismissal = getattr(child, DISMISSAL_FIELDS[weekday], None)
        if dismissal:
            h, m = map(int, dismissal.split(":"))
            mins = h * 60 + m
            if max_mins is None or mins > max_mins:
                max_mins = mins
        for slot in academy_slots(child):
            end_mins = slot.start_hour * 60 + slot.start_minute + slot.duration_minutes
            if max_mins is None or end_mins > max_mins:
                max_mins = end_mins
    return max_mins

이렇게 구한 schedule_end와 기본값 18시(DEFAULT_CUTOFF_MINUTES) 중 더 이른 시각을 컷오프로 쓴다.

cutoff = min(schedule_end, DEFAULT_CUTOFF_MINUTES) if schedule_end else DEFAULT_CUTOFF_MINUTES
is_tomorrow = current_mins >= cutoff

학원이 저녁 7시에 끝나는 아이라면 컷오프는 18시(기본값)가 아니라 더 이른 시각으로 당겨질 수 있고, 반대로 학원이 없는 날은 18시가 그대로 컷오프가 된다. 밤 9시 실행분은 대부분 “내일 모드”로 떨어지지만, 하루 일과가 유난히 늦게 끝나는 그룹이면 여전히 “오늘 모드”로 처리될 수 있다는 뜻이다.

시스템 프롬프트를 통째로 두 벌 둔 이유

SYSTEM_PROMPT(오늘용)와 SYSTEM_PROMPT_TOMORROW(내일용)는 조건문으로 문구 몇 개만 바꾸는 대신 처음부터 별개의 상수로 분리되어 있다. 두 프롬프트의 규칙은 9개 중 8개가 사실상 동일하고, 차이는 이 정도다.

  • 특이사항이 없을 때 출력하는 문장: 오늘은 특별한 일정이 없어요 vs 내일은 특별한 준비사항이 없어요
  • 최우선 처리 태그: [오늘마감!] vs [내일마감!]
  • 내일 프롬프트에만 있는 10번째 규칙: "내일"이라는 단어를 사용하여 내일 일정임을 명확히 할 것

한 프롬프트에 {if is_tomorrow} 같은 분기를 넣지 않고 통째로 복제한 이유는, 모델이 조건부 지시보다 확정된 문장을 훨씬 안정적으로 따르기 때문이다. “내일 모드일 때는 다음 문장을 써라” 같은 메타 지시는 프롬프트가 길어질수록 누락되기 쉽지만, 애초에 그 모드용 프롬프트를 통째로 보내면 모델이 헷갈릴 여지가 없다.

DB 로우를 LLM이 읽을 텍스트로 직렬화하기

시스템 프롬프트가 “어떻게 요약할지”를 정한다면, _build_user_message는 “무엇을 요약할지”를 만든다. 일정 테이블과 알림장 테이블을 조인해서 사람이 읽기 쉬운 텍스트 블록으로 펼친다.

오늘 날짜: 2026-07-01 (수요일)
자녀 수: 2명 (민준, 서연)

[오늘 일정]
< 민준 >
  - 하교: 15:20
  - 태권도 (학원): 16:00-17:00 (서초동)

[최근 알림장 (7일 이내)]
* 민준 | 07/01 학교
  [숙제] 수학 받아쓰기: 교과서 42페이지 [오늘마감!]
  [준비물] 찰흙 (~07/03)

알림장은 최근 7일치를 모두 넘기되, due_date가 오늘(또는 내일)과 일치하는 항목에만 [오늘마감!]/[내일마감!] 태그를 붙인다. 시스템 프롬프트의 6번 규칙이 “이 태그가 붙은 항목을 반드시 첫 번째로 언급하라”고 못 박아두었기 때문에, 마감 임박 항목이 요약 중간에 묻히는 일이 없다. 우선순위 판단을 모델의 자체 추론에 맡기지 않고, 데이터 준비 단계에서 태그로 미리 표시해 넘기는 편이 훨씬 안정적이었다.

첫 줄만 골라 푸시로 보낸다

모델 응답은 앱 안에서 전체 브리핑으로 보여주고, 푸시 알림에는 그중 한 줄만 노출한다. 별도로 요약을 다시 요청하지 않고, 프롬프트 규칙 자체에 “최우선 항목을 첫 줄에 써라”를 넣어둔 뒤 파싱 단계에서 첫 줄만 잘라 쓴다.

def _parse_response(self, response: str) -> tuple[str, str]:
    briefing_text = response.strip()
    lines = [line.strip() for line in briefing_text.splitlines() if line.strip()]
    push_text = lines[0] if lines else briefing_text[:100]
    push_text = push_text.replace("**", "")[:100]
    return briefing_text, push_text

본문에는 굵게 표시를 위해 마크다운 **가 남아있지만, 푸시 텍스트에는 이를 제거한다. 별도 LLM 호출 없이 규칙 순서 + 문자열 슬라이싱만으로 앱 알림과 인앱 문구를 동시에 해결한 셈이다.

thinking_budget이 다 타버리면 응답이 비어있다

LocalLLMClient는 OpenAI 호환 /chat/completions를 호출하면서 reasoning 모델을 위한 thinking.budget_tokens를 함께 넘긴다. 처음 이 기능을 붙였을 때 종종 content가 빈 문자열로 오는 케이스가 있었는데, 원인은 모델이 추론(thinking) 토큰을 예산만큼 다 쓰고 정작 최종 답변을 낼 토큰이 남지 않은 경우였다.

if not content.strip():
    finish_reason = data["choices"][0].get("finish_reason", "unknown")
    reasoning_tokens = data.get("usage", {}).get("completion_tokens_details", {}).get("reasoning_tokens", 0)
    raise ValueError(
        f"LLM returned empty content (finish_reason={finish_reason}, reasoning_tokens={reasoning_tokens}). "
        "max_tokens가 너무 작거나 thinking_budget이 전체 토큰을 소진했을 수 있습니다."
    )

이 예외를 넣고 나서야 실패 원인을 바로 알 수 있게 됐다. 그 전에는 그냥 “빈 브리핑이 생성됨”으로만 보여서, 프롬프트가 잘못됐는지 파싱이 잘못됐는지 한참을 헤맸다. reasoning_tokens 값을 로그에 남겨두면 max_tokens를 늘려야 하는지 thinking_budget을 줄여야 하는지 바로 판단할 수 있다.

정리

문제 해결 방식
오늘/내일 모드 전환 고정 시각이 아니라 그룹별 하교·학원 종료 시각 기반 동적 컷오프
모드별 지시 누락 방지 조건부 문구 대신 시스템 프롬프트 자체를 통째로 분리
마감 임박 항목 우선순위 모델 추론에 맡기지 않고 데이터 단계에서 태그로 표시
푸시 문구 생성 추가 LLM 호출 없이 첫 줄 추출 + 마크다운 제거
reasoning 모델 빈 응답 finish_reason·reasoning_tokens를 로그에 남기고 명시적 예외 발생

결국 프롬프트 엔지니어링의 절반은 모델에게 무엇을 시킬지가 아니라, 모델에게 넘기기 전에 얼마나 정돈된 데이터를 만들어주는가에 달려 있었다.

APScheduler를 FastAPI에 내장하지 않고 독립 Scheduler 서비스로 분리한 이유

초기 구현: APScheduler in FastAPI

처음에는 FastAPI 앱 내에 APScheduler를 내장해 APNs 알림 스케줄링을 처리했습니다.

# ❌ 초기 구현 — FastAPI startup에서 APScheduler 시작
from apscheduler.schedulers.asyncio import AsyncIOScheduler

@asynccontextmanager
async def lifespan(app: FastAPI):
    scheduler = AsyncIOScheduler()
    scheduler.add_job(send_pending_notifications, "interval", minutes=1)
    scheduler.start()
    yield
    scheduler.shutdown()

왜 분리했는가

세 가지 이유로 독립 서비스로 분리했습니다.

1. Kubernetes 수평 확장 문제

API 서버를 replica 2개로 확장하면 APScheduler가 두 인스턴스에서 동시에 실행됩니다. 같은 알림이 2번 전송되는 중복 실행 버그가 발생했습니다.

2. 메모리/CPU 격리

AI 브리핑 생성(OpenAI API 호출)이 스케줄러에 포함되면 API 서버의 응답 지연이 증가했습니다. 스케줄러를 별도 Pod로 분리해 리소스를 격리했습니다.

3. 독립 배포

알림 로직 버그 수정 시 API 서버 전체를 재배포할 필요 없이 스케줄러만 재배포할 수 있습니다.

독립 Scheduler 서비스 구조

# scheduler/main.py
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from scheduler.core.job_queue_processor import JobQueueProcessor

async def main():
    processor = JobQueueProcessor()
    scheduler = AsyncIOScheduler(timezone="Asia/Seoul")

    # 1분마다 pending job 처리
    scheduler.add_job(
        processor.process_pending_jobs,
        "interval",
        minutes=1,
        max_instances=1  # 중복 실행 방지
    )

    # 매일 오전 6시 AI 브리핑 생성
    scheduler.add_job(
        processor.trigger_daily_briefings,
        "cron",
        hour=6, minute=0
    )

    scheduler.start()
    print("[Scheduler] 시작됨")
    try:
        await asyncio.Event().wait()
    finally:
        scheduler.shutdown()

if __name__ == "__main__":
    asyncio.run(main())

Kubernetes Deployment — replica 1 고정

# k8s/scheduler-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ivent-scheduler
spec:
  replicas: 1  # 반드시 1 — 중복 실행 방지
  selector:
    matchLabels:
      app: ivent-scheduler
  template:
    spec:
      containers:
      - name: scheduler
        image: ivent-scheduler:latest
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: ivent-secrets
              key: database-url

API 서버와 통신: DB Job Queue

API 서버는 DB에 Job을 INSERT하고, Scheduler는 주기적으로 DB를 폴링해 Job을 처리합니다. HTTP 직접 통신 대신 DB를 중간 버퍼로 사용해 느슨한 결합을 달성했습니다.

교훈

Kubernetes 환경에서 스케줄러는 반드시 독립 서비스로 분리하고 replica=1을 고정해야 합니다. APScheduler의 max_instances=1은 같은 프로세스 내 중복만 방지하고, 다중 Pod 중복은 막지 못합니다.

FastAPI + SQLAlchemy 2.0 Async ORM으로 iOS 앱 백엔드 구축하기

기술 선택 이유

iEvent 백엔드는 Python 3.12 + FastAPI + SQLAlchemy 2.0으로 구성됩니다. FastAPI를 선택한 이유는 자동 OpenAPI 문서 생성, Pydantic v2 통합, async 네이티브 지원입니다. iOS 클라이언트와의 계약을 OpenAPI 스키마로 자동 관리할 수 있어 협업 오버헤드가 크게 줄었습니다.

프로젝트 구조

backend/
├── app/
│   ├── main.py          # FastAPI 앱 초기화
│   ├── core/
│   │   ├── config.py    # 환경변수 Settings
│   │   └── security.py  # JWT, utcnow() 헬퍼
│   ├── models/
│   │   ├── base.py      # SQLAlchemy Base
│   │   ├── mixins.py    # TimestampMixin
│   │   └── child.py     # Child 모델 등
│   ├── schemas/         # Pydantic 스키마
│   ├── api/v1/          # API 라우터
│   └── services/        # 비즈니스 로직
└── alembic/             # DB 마이그레이션

TimestampMixin — 공통 필드

# app/models/mixins.py
from sqlalchemy import Column, DateTime
from sqlalchemy.sql import func
from app.core.security import utcnow

class TimestampMixin:
    created_at = Column(DateTime(timezone=True),
                        default=utcnow, server_default=func.now(), nullable=True)
    updated_at = Column(DateTime(timezone=True),
                        default=utcnow, server_default=func.now(),
                        onupdate=utcnow, nullable=True)
    deleted_at = Column(DateTime(timezone=True), nullable=True)

모델 정의 패턴

# app/models/child.py
import uuid
from sqlalchemy import Column, String, ForeignKey
from sqlalchemy.orm import relationship
from app.models.base import Base
from app.models.mixins import TimestampMixin

class Child(TimestampMixin, Base):
    __tablename__ = "children"

    id = Column(String(36), primary_key=True,
                default=lambda: str(uuid.uuid4()))
    user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
    name = Column(String(100), nullable=False)
    gender = Column(String(10), nullable=True)

    academies = relationship("Academy", back_populates="child",
                             cascade="all, delete-orphan")

Pydantic v2 스키마

# app/schemas/child.py
from pydantic import BaseModel, ConfigDict
from typing import Optional
from datetime import datetime

class ChildResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: str
    name: str
    gender: Optional[str] = None
    created_at: Optional[datetime] = None
    deleted_at: Optional[datetime] = None

FastAPI 라우터 + DB 의존성

# app/api/v1/children.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.api.deps import get_current_user

router = APIRouter()

@router.get("/children", response_model=list[ChildResponse])
def list_children(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    return db.query(Child).filter(
        Child.user_id == current_user.id,
        Child.deleted_at.is_(None)  # soft delete 필터
    ).all()

@router.post("/children", response_model=ChildResponse, status_code=201)
def create_child(
    data: ChildCreate,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    child = Child(
        id=str(uuid.uuid4()),
        user_id=current_user.id,
        name=data.name,
        gender=data.gender
    )
    db.add(child)
    db.commit()
    db.refresh(child)
    return child

Dockerfile

FROM python:3.12-slim
WORKDIR /app
ENV TZ=Asia/Seoul
RUN apt-get update && apt-get install -y build-essential libpq-dev tzdata
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ .
ENV PYTHONPATH=/app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

교훈

FastAPI의 자동 OpenAPI 문서는 iOS 개발자와의 API 계약을 명확히 합니다. model_config = ConfigDict(from_attributes=True)로 SQLAlchemy ORM 객체를 Pydantic이 직접 직렬화할 수 있어 변환 코드가 불필요합니다.

iOS 26 Liquid Glass 대응 — 시스템 컴포넌트만 쓰는 전략과 그 이유

iOS 26 Liquid Glass란

iOS 26에서 Apple은 Liquid Glass라는 새로운 디자인 언어를 도입했습니다. 반투명 유리 소재가 시스템 전체 UI에 적용되며, TabView, NavigationStack, .sheet 등 시스템 컴포넌트에 자동으로 Glass 효과가 들어갑니다.

핵심 원칙: 직접 구현하지 말고 시스템 컴포넌트 사용

// ❌ 커스텀 헤더 — iOS 26에서 Liquid Glass 미적용
private var headerView: some View {
    HStack {
        Button { } label: {
            Image(systemName: "person.crop.circle.fill")
                .frame(width: 40, height: 40)
                .background(Circle().fill(Color.systemBackground))  // ❌ 수동 배경
                .shadow(color: .black.opacity(0.1), radius: 8)      // ❌ 수동 그림자
        }
    }
}

// ✅ NavigationStack + .toolbar — 자동 Liquid Glass
NavigationStack {
    ContentView()
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem(placement: .topBarLeading) {
                Button { } label: {
                    Image(systemName: "person.crop.circle.fill")
                        .font(.system(size: 26))
                        .foregroundStyle(IVENTColor.primary(for: gender))
                }
            }
            ToolbarItem(placement: .principal) { titleView }
            ToolbarItemGroup(placement: .topBarTrailing) {
                Button { } label: { Image(systemName: "magnifyingglass") }
                Button { } label: { Image(systemName: "plus") }
            }
        }
}

자동으로 Glass가 적용되는 컴포넌트

  • TabView + .tabItem — 탭 바
  • NavigationStack + .toolbar — 네비게이션 바
  • .sheet, .fullScreenCover — 시트
  • .alert, .confirmationDialog — 다이얼로그
  • Toggle, Slider, Picker — 컨트롤

커스텀 카드에 Glass 적용: liquidGlass() 모디파이어

// LiquidGlassModifier.swift
extension View {
    @ViewBuilder
    func liquidGlass(opacity: Double = 0.5,
                     cornerRadius: CGFloat = 28,
                     tintColor: Color = Color(uiColor: .systemBackground)) -> some View {
        if #available(iOS 26.0, *) {
            self.glassEffect(.regular.tint(tintColor), in: .rect(cornerRadius: cornerRadius))
        } else {
            self.modifier(LiquidGlassModifier(opacity: opacity,
                                               cornerRadius: cornerRadius,
                                               tintColor: tintColor))
        }
    }
}

// iOS 25 이하 fallback
struct LiquidGlassModifier: ViewModifier {
    var opacity: Double = 0.5
    var cornerRadius: CGFloat = 28
    var tintColor: Color

    func body(content: Content) -> some View {
        content
            .background(
                ZStack {
                    RoundedRectangle(cornerRadius: cornerRadius)
                        .fill(tintColor.opacity(opacity))
                        .background(.ultraThinMaterial)
                    RoundedRectangle(cornerRadius: cornerRadius)
                        .stroke(
                            LinearGradient(
                                colors: [.white.opacity(0.8), .white.opacity(0.1)],
                                startPoint: .topLeading,
                                endPoint: .bottomTrailing
                            ),
                            lineWidth: 0.5
                        )
                }
            )
            .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
            .shadow(color: .black.opacity(0.06), radius: 15, x: 0, y: 8)
    }
}

여러 Glass 버튼 묶기: GlassEffectContainer

// iOS 26에서 glass-on-glass 중복 방지
if #available(iOS 26.0, *) {
    GlassEffectContainer(spacing: 8) {
        Button("필터") { }.glassEffect()
        Button("정렬") { }.glassEffect()
    }
}

교훈

iOS 26 Liquid Glass 대응의 핵심은 “직접 만들지 말 것”입니다. 시스템 컴포넌트를 사용하면 Apple이 자동으로 Glass 효과를 적용합니다. 커스텀이 필요한 카드·플로팅 패널에는 liquidGlass() 모디파이어로 버전 분기를 한 곳에서 처리합니다.

Apple Intelligence Foundation Models를 iOS 18 타겟 앱에서 사용하는 방법 — @available 우회 패턴

배경

Foundation Models 프레임워크는 iOS 26 전용입니다. 그런데 앱의 minimum deployment target이 iOS 18이라면 @available(iOS 26, *) 분기가 필수입니다. 여기서 한 가지 함정이 있습니다.

문제: @Generable 타입을 stored property로 가질 수 없음

@Generable 매크로로 생성된 타입(NoticeboardParseResult 등)은 @available(iOS 26, *)가 붙어 있습니다. 이를 class/struct의 stored property로 선언하면 iOS 18 타겟에서 링크 에러가 발생합니다.

// ❌ 컴파일 에러 — iOS 18에서 LanguageModelSession 심볼 없음
actor NoticeboardAssistantManager {
    private var session: LanguageModelSession?  // @available 없이 선언 불가
}

해결: Any?로 타입 지우기

// ✅ 실제 코드 (NoticeboardAssistantManager.swift)
actor NoticeboardAssistantManager {
    static let shared = 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 }
    }

    var isAIAvailable: Bool {
        if #available(iOS 26, *) {
            return SystemLanguageModel.default.availability == .available
        }
        return false
    }
}

@Generable 구조체 정의

// iOS 26 전용 — @available 필수
@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: "분류: homework / material / notice / schedule")
    var category: String

    @Guide(description: "마감일 yyyy-MM-dd, 없으면 빈 문자열")
    var dueDate: String
}

세션 초기화와 사용

@available(iOS 26, *)
func parseNoticeboardText(_ text: String) async throws -> AIParseSuccess {
    // lazy 초기화 — 최초 호출 시 생성
    if session == nil {
        session = LanguageModelSession {
            "당신은 초등학교 알림장 분석 전문가입니다. OCR로 추출된 텍스트에서 항목을 추출해 구조화된 데이터로 변환합니다. 한국어로 응답하세요."
        }
    }

    let response = try await session!.respond(
        to: prompt,
        generating: NoticeboardParseResult.self
    )

    // iOS 26 전용 타입 → 버전 독립 앱 모델로 변환
    return AIParseSuccess(
        items: response.content.items.map { ParsedNoticeItem(from: $0) },
        summary: response.content.summary
    )
}

iOS 26 베타 주의사항

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

교훈

  • Any? stored property 패턴으로 iOS 26 전용 타입을 안전하게 보관
  • actor 사용으로 LanguageModelSession 스레드 안전성 확보
  • iOS 26 전용 타입은 앱 공통 모델로 변환 후 TCA Action에 전달
  • SystemLanguageModel.default.availability 체크로 AI 지원 여부 확인

APNs Push + ActivityKit LiveActivity 전체 아키텍처 — Scheduler 독립 프로세스 분리 이유

전체 구조

iEvent의 푸시/LiveActivity 시스템은 iOS 앱, FastAPI 백엔드, Python Scheduler 세 개의 독립 프로세스로 구성됩니다.

iOS App (ActivityKit)
    ↕  APNs (Apple Push Notification service)
       ↑
FastAPI Backend  ←→  MySQL / Redis
       ↑
Python Scheduler (독립 프로세스, 매 분 실행)

Scheduler를 독립 프로세스로 분리한 이유

  • Event loop 보호: “종료 15분 전” 크론 작업을 FastAPI 내부에서 돌리면 event loop 블로킹 위험
  • 독립 배포: Scheduler 버그로 재시작해도 API 서버는 무중단 유지
  • 리소스 격리: LLM 호출처럼 CPU를 많이 쓰는 작업을 API 서버와 분리

iOS 측: LiveActivity 등록

// LiveActivityManager.swift
struct IVENTActivityAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var childName: String
        var scheduleTitle: String
        var endTime: Date
        var status: String
    }
    var childId: String
    var academyName: String
}

func startActivity(for schedule: Schedule) async throws {
    guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }

    let attributes = IVENTActivityAttributes(
        childId: schedule.childId,
        academyName: schedule.academyName
    )
    let state = IVENTActivityAttributes.ContentState(
        childName: schedule.childName,
        scheduleTitle: schedule.title,
        endTime: schedule.endTime,
        status: "active"
    )
    let activity = try Activity.request(
        attributes: attributes,
        content: .init(state: state, staleDate: schedule.endTime.addingTimeInterval(300))
    )
    if let token = activity.pushToken {
        await registerPushToken(token, activityId: activity.id)
    }
}

백엔드: ES256 JWT로 APNs 인증

# apns_client.py
def _make_jwt() -> str:
    from jose import jwt as jose_jwt
    key_path = settings.APNS_AUTH_KEY_PATH   # K8s Secret으로 주입된 .p8 파일
    with open(key_path, "r") as f:
        private_key = f.read()
    headers = {"kid": settings.APNS_KEY_ID}
    payload = {"iss": settings.APNS_TEAM_ID, "iat": int(time.time())}
    return jose_jwt.encode(payload, private_key, algorithm="ES256", headers=headers)

async def send_live_activity_update(self, push_token, event, content_state, environment=None):
    host = self._host_for(environment)
    topic = f"{self._bundle_id}.push-type.liveactivity"
    payload = {
        "aps": {
            "timestamp": int(time.time()),
            "event": event,   # "update" | "end"
            "content-state": content_state,
        }
    }
    headers = {
        "authorization": f"bearer {_make_jwt()}",
        "apns-push-type": "liveactivity",
        "apns-topic": topic,
        "apns-priority": "10",
    }
    async with httpx.AsyncClient(http2=True) as client:
        resp = await client.post(f"{host}/3/device/{push_token}",
                                 headers=headers, content=json.dumps(payload))
    return {"status_code": resp.status_code, "body": resp.text}

Scheduler: 매 분 체크

# push_notification_job.py
class PushNotificationJob(BaseJob):
    cron = "* * * * *"

    async def run(self, ctx: JobContext) -> JobResult:
        now_kst = datetime.now(KST)
        ending_soon = db.query(Schedule).filter(
            Schedule.end_time.between(now_kst, now_kst + timedelta(minutes=16)),
            Schedule.live_activity_token.isnot(None),
            Schedule.status == "active"
        ).all()

        for schedule in ending_soon:
            await apns_client.send_live_activity_update(
                push_token=schedule.live_activity_token,
                event="update",
                content_state={
                    "childName": schedule.child_name,
                    "scheduleTitle": schedule.title,
                    "endTime": schedule.end_time.timestamp(),
                    "status": "ending"
                },
                environment=schedule.apns_environment
            )
        return JobResult(success=True, processed=len(ending_soon))

교훈

APNs는 HTTP/2 프로토콜을 요구합니다. Python에서는 httpxhttp2=True를 반드시 지정해야 합니다. JWT는 생성 후 1시간 내에 사용해야 하므로 매 요청마다 새로 생성하는 것이 가장 안전합니다.