2026년 8월 15일 · 9분 읽기
Swift Package 모듈 분리: 코어를 분리했는데 앱이 로컬 경로를 읽던 문제
Swift Package로 AI 코어를 앱에서 분리하면서 정한 경계와, 선언과 실제 빌드가 갈린 문제를 적었습니다. 함수 네 개와 실패 아홉 가지를 경계로 삼았습니다.
2026년 8월 23일 업데이트
AI 요약 코어를 별도 저장소로 떼어냈습니다. 그리고 다시 앱 저장소로 들여왔습니다. 지금 빌드가 링크하는 것은 앱 저장소 안의 JustSendMemoryCore입니다.
떼어내 둔 동안 문서와 소스가 갈라졌습니다. 문서에는 모델이 채울 칸이 한 종류라고 적혀 있고, 소스에는 둘이 있습니다. summaryChunk와 summaryTitle입니다.
한눈에 보기
| 문제 | AI 요약 코어를 별도 저장소로 떼어냈습니다. |
| 결정 | 답을 프로토콜 네 개로 고정했습니다. |
| 결과 | 2026-08-15에 docs/ai-core/를 docs/archive/2026-08-15/ai-core/로 옮겼습니다. |
| 제약 | 이 구조가 값을 하기 시작한 지점은 입력이 늘어났을 때입니다. |
앱이 구현할 것을 함수 네 개로 좁혔습니다
코어를 떼어내면 첫 질문이 생깁니다. 앱은 코어에게 무엇을 줘야 하는가.
답을 프로토콜 네 개로 고정했습니다. 그중 모델을 부르는 것은 함수 하나입니다.
// ProfileContracts.swift:480-482
public protocol ProfileModelExecuting: Sendable {
func generate(_ request: ProfileModelRequest) async throws -> Data
}
한 줄입니다. 요청을 받아 데이터를 돌려주면 됩니다. 코어는 Apple의 FoundationModels를 모릅니다. LanguageModelSession도, @Generable도, 발열 관리도 모릅니다. 그것은 앱의 일입니다.
요청에 들어가는 것도 다섯 개뿐입니다.
// ProfileContracts.swift:451-456
public struct ProfileModelRequest: Codable, Hashable, Sendable {
public let stepID: String // 어느 단계의 호출인가
public let schema: ProfileModelSchema // 어떤 모양으로 받을 것인가
public let instructions: String // 모델에게 줄 지시
public let prompt: String // 이번에 넣을 원문
public let responseTokens: Int // 답의 길이 상한
}
요청과 응답이 Codable입니다. 그래서 테스트에서 모델 없이 코어를 돌릴 수 있습니다. 파일에 저장한 요청을 넣고 파일에 저장한 응답을 돌려주면 코어의 판단만 검사됩니다.
Swift Package는 코드를 재사용 단위로 묶는 Apple의 패키지 방식입니다. 보통 원격 저장소의 태그를 가리키지만, 저장소 안의 디렉터리를 가리킬 수도 있습니다. 후자를 local path package라고 부릅니다.
나머지 셋도 앱이 채웁니다
| 프로토콜 | 앱이 해 줄 일 | 함수 수 |
|---|---|---|
ProfileModelExecuting |
모델을 부른다 | 1 |
SummaryTranslating |
문장 여러 개를 한 번에 번역한다 | 1 |
ProfileDocumentProviding |
문서의 쪽을 읽어 준다 | 3 |
ProfileExecutionStoring |
진행 상태를 저장한다 | 5 |
번역 프로토콜에는 조건이 하나 붙어 있습니다.
// ProfileContracts.swift:484-493
/// 반환 배열은 입력과 같은 순서·개수여야 한다. core는 번역 뒤 각 필드의
/// 대상 언어를 다시 증명한 뒤에만 결과를 승격한다.
public protocol SummaryTranslating: Sendable {
func translate(_ texts: [String], from sourceLanguageIdentifier: String,
to targetLanguageIdentifier: String) async throws -> [String]
}
번역기가 문장 다섯 개를 받아 넷을 돌려주면 순서가 어긋납니다. 코어는 개수를 확인하고, 번역된 문장이 실제로 목표 언어인지 다시 확인한 뒤에야 결과로 씁니다. 앱이 준 값을 그대로 믿지 않습니다.
문서 제공자에는 주석이 하나 더 있습니다
// ProfileContracts.swift:496-501
public protocol ProfileDocumentProviding: Sendable {
func pageCount(for attachmentID: String) async throws -> Int
func page(at index: Int, for attachmentID: String) async throws -> PageContent
/// 원문이 실제로 몇 쪽인가. `pageCount`는 요약 상한이 적용된 **뒤**의 수이므로
/// 둘이 다르면 요약이 문서의 일부만 봤다는 뜻이다. 그 사실을 말할 수 있어야 한다.
func originalPageCount(for attachmentID: String) async throws -> Int
}
쪽 수를 두 번 묻습니다. 요약할 쪽 수와 원문의 쪽 수입니다. 서른 쪽 상한에 걸린 백 쪽 문서라면 앞은 30, 뒤는 100입니다.
이 둘을 구분하지 않으면 사용자는 요약이 문서 전체를 담았다고 믿습니다. 화면에 "앞 30쪽만 읽었습니다"라고 띄우려면 코어가 두 숫자를 다 알아야 합니다.
스키마를 둘로 나눈 이유가 주석에 적혀 있습니다
모델이 채울 칸의 모양을 ProfileModelSchema가 정합니다. 지금 두 가지입니다.
// ProfileContracts.swift:443-449
public enum ProfileModelSchema: String, Codable, Hashable, Sendable {
/// 조각의 헤드라인과 사실 요점.
case summaryChunk
/// 여러 조각을 아우르는 선택 제목. 조각 스키마를 재사용하지 않아 불필요한
/// `points` 생성과 토큰을 없앤다.
case summaryTitle
}
summaryChunk는 제목 하나와 요점 몇 개를 받습니다. 긴 글을 조각으로 나눠 각 조각을 요약할 때 씁니다.
summaryTitle은 조각들을 아우르는 제목 하나만 받습니다. 여기서 summaryChunk를 재사용하면 모델이 요점도 함께 만듭니다. 제목만 필요한 자리에서 버릴 요점을 만드는 셈이고, 그만큼 토큰을 씁니다.
온디바이스 모델에서는 이 낭비가 바로 시간으로 나타납니다. 그래서 답의 모양을 다르게 두 개 뒀습니다.
스키마를 늘리는 것은 비용입니다
칸의 모양을 하나 더 만들면 앱도 따라 고쳐야 합니다. 앱의 실행기는 스키마마다 다른 @Generable 타입을 준비해서 Apple 모델에게 넘깁니다. 코어에 case를 하나 넣으면 앱에서 타입과 인코딩을 하나 더 만들어야 합니다.
| 스키마를 늘리면 | 고쳐야 하는 곳 |
|---|---|
| 코어 | enum에 case 추가 |
| 앱 | @Generable 타입, 실행기의 분기, JSON 인코딩 |
| 테스트 | 스키마별 고정 응답 |
두 곳을 동시에 고쳐야 한다는 것이 분리의 값입니다. 한 저장소에 있었다면 커밋 하나로 끝났을 일입니다.
그래서 새 기능을 넣을 때 먼저 묻습니다. 기존 두 칸으로 표현할 수 있는가. 대부분 됩니다.
실패를 아홉 가지로 구분합니다
경계를 나누면 실패가 어디서 났는지 말할 수 있어야 합니다. 코어는 실패를 아홉 가지로 나눕니다.
// ProfileContracts.swift:387-397
public enum ProfileFailure: Codable, Hashable, Sendable {
case invalidInput(String) // 입력이 비었거나 규격 밖
case contextWouldOverflow(estimatedTokens: Int) // 창을 넘길 것으로 추정
case malformedModelOutput(String) // 모델이 규격을 벗어난 답을 줌
case modelUnavailable // 모델을 쓸 수 없는 기기·상태
case modelFailed(String) // 모델 호출 자체가 실패
case modelCallBudgetExceeded(limit: Int) // 요청당 호출 상한을 넘김
case unsupportedProfile(ProfileIdentifier) // 등록되지 않은 프로필
case missingCheckpoint // 이어갈 진행 기록이 없음
case executionStoreUnavailable // 저장소를 열 수 없음
}
앞의 여섯은 모델과 입력의 문제이고, 뒤의 셋은 앱이 준 어댑터의 문제입니다. executionStoreUnavailable은 코어의 잘못이 아닙니다. 앱이 준 저장소가 열리지 않은 것입니다.
이 구분이 없으면 화면에 띄울 문장이 하나뿐입니다. "요약에 실패했습니다." 사용자는 다시 눌러야 할지, 기기를 바꿔야 할지, 파일이 문제인지 알 수 없습니다.
앱 쪽 실행기도 오류를 네 가지로 좁혀 코어에게 돌려줍니다.
// ProfileContracts.swift:473-478
public enum ProfileModelExecutionError: Error, Codable, Equatable, Sendable {
case unavailable // 지금 이 기기에서 못 씁니다
case transient(String) // 다시 하면 될 수도 있습니다
case rejected(String) // 이 입력은 거부됐습니다
case permanent(String) // 다시 해도 안 됩니다
}
transient와 permanent를 나눈 것이 재시도 정책의 근거입니다. 코어는 transient면 다시 부르고 permanent면 멈춥니다. Apple 모델의 수십 가지 오류를 앱이 이 넷으로 번역해 줍니다.
진행 상태는 코어가 정의하고 앱이 저장합니다
// ProfileContracts.swift:420-425
/// Durable page progress. Re-submit the identical `ProfileCommand` to continue.
public struct ProfileCheckpoint: Codable, Hashable, Sendable {
public let command: ProfileCommand // 어떤 요청이었나
public let phase: String // 어디까지 갔나
public let payload: Data // 중간 결과
public let receipt: ProfileExecutionReceipt // 단계별 기록
}
주석 한 줄이 계약 전부입니다. 같은 ProfileCommand를 다시 넣으면 이어갑니다.
문서 백 쪽을 요약하다 앱이 죽으면 서른 쪽까지의 결과가 남아 있습니다. 사용자가 다시 열면 서른한 쪽부터 시작합니다. 이 구조를 코어가 정의하고, 실제 저장은 앱의 ProfileExecutionStoring이 합니다.
Data 하나로 둔 것이 눈에 걸릴 수 있습니다. 중간 결과의 모양을 코어가 자유롭게 바꿀 수 있게 한 선택입니다. 앱은 그 바이트가 무엇인지 알 필요가 없습니다.
문서를 고치는 대신 문서를 치웠습니다
같은 사실을 두 곳이 다르게 말했습니다.
| 무엇 | 문서가 적은 것 | 소스에 있는 것 |
|---|---|---|
| 모델 스키마 | summaryChunk 한 종류 |
summaryChunk, summaryTitle 두 종류 |
| 코어를 어떻게 소비하는가 | 태그 0.4.15를 핀한다 |
path: JustSendMemoryCore |
첫 줄은 summaryTitle이 추가될 때 문서를 같이 고치지 않은 것입니다.
둘째 줄이 더 헷갈렸습니다. 문서는 공유 계약이 독립 저장소에 있고 거기 핀한 태그가 유일한 사실이라고 적었습니다. 그런데 project.yml은 이렇습니다.
# ios-prod/app/project.yml:29-30
packages:
justsend-core:
path: JustSendMemoryCore # 원격 태그가 아니라 저장소 안의 디렉터리낡음을 잡는 스크립트는 있었습니다. scripts/check-doc-anchors.py가 문서에 적힌 경로:줄 인용을 훑습니다. 오늘 돌려 보니 앵커 471개 중 6개가 낡았다고 보고했습니다. 스키마 개수 불일치는 그 여섯에 없습니다. 스크립트가 보는 것은 파일이 있는지와 줄 범위가 파일 길이 안에 있는지뿐입니다. 스크립트 자기 설명에 그렇게 적혀 있습니다. "내용이 여전히 맞는지는 검사하지 않는다. 사람이 읽어야 한다."
문서를 근거에서 뺐습니다
2026-08-15에 docs/ai-core/를 docs/archive/2026-08-15/ai-core/로 옮겼습니다. 앵커 검사는 아카이브 경로를 따라 옮겼습니다. 그리고 app/AGENTS.md가 자기 문장이 낡았다고 적었습니다. "태그를 핀하던 시절의 문장(0.4.15가 유일한 사실)은 낡았다."
원격 저장소 <org>/justsend-core의 태그는 20개이고 마지막이 0.4.15입니다. 그 뒤로 새 태그가 없습니다. 릴리스를 내던 쪽이 멈췄고 앱 저장소의 소스가 정본이 됐습니다.
되돌린 이유는 Package.swift 첫 주석에 있습니다.
// app/JustSendMemoryCore/Package.swift:4-8
// 예전에는 별도 원격 저장소(<org>/justsend-core)에 있었고 앱이 태그로 핀했다.
// 그 구조는 계약 하나를 바꿀 때마다 커밋·태그·핀 갱신 세 걸음을 요구했고,
// 실제로 앱이 핀한 버전과 작업 중인 갈래가 서로 다른 아키텍처로 벌어졌다.
// 지금은 앱 저장소 안의 로컬 패키지다 — 모듈 이름을 유지하므로 `import
// JustSendMemoryCore` 112곳은 그대로다.
세 걸음이 값이었습니다. 계약을 한 줄 고치려면 코어에 커밋하고 태그를 붙이고 앱에서 핀을 올려야 합니다. 그리고 그 세 걸음 사이에 앱이 보는 코어와 작업 중인 코어가 갈라졌습니다.
되돌릴 때 모듈 이름은 그대로 뒀습니다. import 구문 112곳을 고치지 않았습니다. 패키지의 위치가 원격에서 로컬로 바뀌었을 뿐이고 코드가 코어를 부르는 방식은 같습니다.
지금 그 패키지의 크기를 세어 봤습니다.
| 값 | |
|---|---|
| 소스 파일 | 32개 |
| 소스 줄 수 | 6,921줄 |
| 테스트 파일 | 14개 |
앱에서 import하는 곳 |
112곳 |
테스트가 소스 파일의 절반 가까이입니다. 코어를 떼어낸 이득 중 이것이 남았습니다. 모델 없이 돌 수 있는 코드라서 테스트가 붙습니다. 저장소를 합쳐도 이 성질은 그대로입니다.
경계 자체는 남았습니다. justsend-core: path: JustSendMemoryCore 한 줄은 그대로입니다. 없어진 것은 저장소 두 개이고 남은 것은 패키지 경계 하나입니다. 코어는 지금도 Apple의 모델 API를 모릅니다.
어긋남을 고치는 규칙은 테스트입니다
app/AGENTS.md에 이 상황을 위한 규칙이 있습니다. 계약 문구와 테스트가 어긋나면 테스트가 진실입니다. 회귀가 나면 고치기 전에 어느 계약이 맞는지 먼저 판단합니다. 산문은 어느 쪽도 증명하지 않습니다.
무엇을 잃었는지 적어 둡니다
Plane 기록에 분리 전후의 작업이 남아 있습니다.
IOSPROD-12는 코어를 독립 저장소로 분리하고 앱이 외부 Swift Package로 소비하게 바꾸는 작업입니다. IOSPROD-13은 분리된 코어의 오케스트레이션과 프로토타입을 앱으로 통합하는 작업입니다. IOSPROD-16은 코어 버전을 컷오버하고 런타임 소유권을 하나로 모으는 작업입니다.
세 개가 연달아 있다는 것 자체가 말해 줍니다. 떼어낸 다음에 합치는 작업이 따로 필요했습니다.
IOSPROD-142에서 그 배선을 소스만 보고 다시 훑었습니다. 출시 전에 막아야 할 정확성 결함이 네 개 나왔습니다. 조각 일부만 성공했는데 실패한 조각을 지우고 완료로 정본 본문을 덮는 것, 문서 페이지 읽기 실패를 조용히 건너뛰고 빠진 범위를 말하지 않는 것, 텍스트 조각 번호를 실제 쪽 번호처럼 화면에 내보내는 것, 숫자와 날짜와 금액과 이름이 원문에 있는지 확인하는 검증이 생산 경로에서 사라진 것입니다.
네 개 모두 경계 자체의 결함이 아니라 경계를 넘는 값의 의미가 어긋난 결함입니다. 코어는 조각 번호를 조각 번호로 넘겼고 앱은 그것을 쪽 번호로 그렸습니다. 함수 시그니처는 맞았습니다.
| 얻은 것 | 치르는 값 |
|---|---|
| 모델 없이 코어를 테스트할 수 있다 | 어댑터 네 개를 앱이 유지해야 한다 |
| 코어의 판단이 한곳에 모인다 | 계약을 바꾸면 두 곳을 고쳐야 한다 |
| 실패 지점을 아홉 가지로 말할 수 있다 | 문서가 낡는 것을 사람이 잡아야 한다 |
| 앱은 Apple API만 신경 쓴다 | 통합과 컷오버가 별도 작업이 된다 |
혼자 개발하면 오른쪽 열이 전부 자기 시간입니다. 그래도 나눈 이유는 왼쪽 첫 줄입니다. 모델을 부르지 않고 코어를 돌릴 수 있으면, 요약 로직의 회귀를 몇 초 만에 잡습니다.
입력이 하나였다면 나누지 않았을 겁니다
코어가 하는 일이 아직 요약 하나라면 굳이 나누지 않아도 됩니다. ProfileFailure 아홉 가지와 어댑터 네 개는 요약 한 가지를 위한 구조로는 과합니다.
이 구조가 값을 하기 시작한 지점은 입력이 늘어났을 때입니다. 메모, 문서, 웹링크, 음성 전사가 모두 같은 엔진으로 들어옵니다. 입력이 하나였을 때 나눴다면 비용만 먼저 냈을 것입니다.