LLM이 길을 잃지 않도록 — iEvent 프로젝트의 wiki 구성 방식

iEvent는 iOS 앱, FastAPI 백엔드, React 어드민, 스케줄러까지 4개 서브시스템으로 구성된 프로젝트다. 한 세션에서 여러 영역을 넘나들다 보면 LLM이 맥락을 잃거나 불필요한 파일을 모두 읽어 토큰을 낭비하는 일이 반복됐다. 이 문제를 해결하기 위해 wiki/ 디렉토리를 중심으로 한 LLM 전용 문서 구조를 설계했다.

핵심 원칙: INDEX.md 단일 진입점

wiki/INDEX.md 상단에는 LLM을 위한 사용 규칙이 명시돼 있다.

LLM 사용 규칙: 이 파일만 먼저 읽고, 관련 항목만 추가 열람할 것.
전체 파일 일괄 읽기 금지.

모든 세션은 반드시 INDEX.md 하나를 먼저 읽는 것으로 시작한다. INDEX.md는 전체 wiki 파일의 목차이자 각 파일의 한 줄 요약을 담고 있다. LLM은 이 인덱스만 보고 지금 작업과 관련된 파일 1~3개만 골라 읽는다. 전체 wiki를 일괄로 읽는 행위는 금지다.

wiki 디렉토리 구조

wiki/
├── INDEX.md              ← LLM 진입점 (항상 먼저 읽는 파일)
├── architecture/         ← 시스템 구조, 기술 스택, 데이터 흐름, 테스트 전략
├── features/             ← 기능별 스펙 (온보딩, 홈, 스케줄, 알림장 등)
├── design/               ← UI/UX 원칙, 컬러 시스템, 와이어프레임
├── data/                 ← DB 스키마
├── flows/                ← 사용자 여정
└── decisions/            ← ADR (Architecture Decision Records)

파일 수는 30개를 넘지만 LLM이 한 번에 읽는 파일은 최대 3개다. INDEX.md가 필터 역할을 한다.

Why 중심 문서화 원칙

각 wiki 파일은 “무엇을 했는가(What)”가 아니라 “왜 그렇게 결정했는가(Why)”를 기록한다. decisions/ 디렉토리가 이 역할을 담당한다. 예시:

  • decisions/server-first-migration.md — LocalFirst에서 Server-First로 전환한 이유 (serverId 충돌, 계정 전환 복잡도 해소)
  • decisions/tca-architecture.md — TCA 채택 이유, ifLet 체이닝 타임아웃 해결 패턴
  • decisions/apple-ai-strategy.md — 온디바이스 AI를 선택한 이유 (개인정보 보호, 오프라인 동작)

코드 자체는 git에 있고, “왜 이 방식을 골랐는지”는 wiki에 있다. 이 분리가 LLM이 맥락 없이 기존 결정을 번복하는 실수를 막아준다.

실시간 변경 이력 패턴

INDEX.md의 각 항목 요약에는 [2026-06-XX] 날짜 태그로 최신 변경 사항을 인라인으로 기록한다.

| [[architecture/data-flow]] | Server-First 정책 전환 —
  [2026-06-16] POST/PUT/DELETE 서버 우선·실패 시 throw,
  GET은 LocalDB 즉시 반환+백그라운드 pull |

별도 Changelog 파일 없이, 인덱스 한 줄 안에 날짜와 변경 내용이 함께 적힌다. LLM이 “가장 최근에 어떤 결정이 바뀌었는지”를 INDEX.md만 읽고 파악할 수 있다.

CLAUDE.md / GEMINI.md 이중 지원

프로젝트 루트에는 .CLAUDE.mdGEMINI.md 두 파일이 공존한다. Claude Code와 Gemini CLI 모두 이 프로젝트에서 사용하기 때문이다. 두 파일 모두 동일한 원칙을 공유한다.

  • 작업 시작 전 wiki/INDEX.md 읽기
  • 관련 파일 1~3개만 선택 열람
  • 소스 검색 전 codegraph MCP 우선 사용
  • /doc 커맨드는 자동 실행 금지 (수동 호출만 허용)

LLM이 달라도 동일한 컨텍스트 로딩 전략을 따르게 해서, 어떤 도구를 쓰든 일관된 작업 품질을 유지한다.

온디바이스 AI: Vision OCR + Foundation Models

iEvent의 알림장 스캔 기능은 서버 AI 없이 기기 위에서 완전히 동작한다.

사진(Data)
  → VNRecognizeTextRequest (Vision OCR)
  → raw text
  → LanguageModelSession + @Generable (Foundation Models)
  → AIParseSuccess { items, summary }

아이의 학교 알림장은 개인정보가 민감하다. 서버로 이미지를 업로드하지 않고 기기 위에서 처리함으로써 프라이버시를 보호하고, 네트워크 없는 환경에서도 동작하게 했다.

Foundation Models는 iOS 26+에서만 동작하므로, iOS 18 최소 타겟과 공존하기 위해 @available(iOS 26, *) 래퍼 패턴을 사용한다. TCA Action enum에는 FoundationModels 타입이 노출되지 않도록 도메인 타입(AIParseSuccess)으로 변환 후 반환한다.

서버 LLM: AI 브리핑 파이프라인

온디바이스 AI와 별개로, 매일 아침 가족에게 오늘 일정 브리핑을 푸시하는 기능은 서버 LLM을 사용한다. scheduler/llm/local_llm_client.py가 그 역할을 한다.

class LocalLLMClient:
    async def chat(self, system_prompt: str, user_message: str) -> str:
        payload = {
            "model": self.model,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user",   "content": user_message},
            ],
            "thinking": {
                "type": "enabled",
                "budget_tokens": self.thinking_budget,
            },
        }
        response = await self._client.post(
            f"{self.base_url}/chat/completions", json=payload
        )
        ...

OpenAI-compatible /chat/completions 엔드포인트를 호출하는 얇은 클라이언트다. thinking.budget_tokens를 지원해 추론 모델도 사용할 수 있다. 스케줄러는 매일 07:30과 21:00에 이 클라이언트를 통해 오늘/내일 브리핑을 생성하고 APNs로 전송한다.

정리

목적방식
LLM 컨텍스트 로딩INDEX.md 단일 진입 → 관련 파일 1~3개만 선택
결정 기록decisions/ ADR — Why 중심, 날짜 인라인 태그
다중 LLM 지원.CLAUDE.md + GEMINI.md 동일 원칙 공유
온디바이스 AIVision OCR + Foundation Models (iOS 26, 개인정보 보호)
서버 LLMLocalLLMClient — OpenAI-compatible, thinking budget 지원

wiki 구조 자체가 LLM의 동작 방식에 맞게 설계돼 있다. “전체를 읽을 수 없으니 인덱스를 먼저 읽어라”는 제약을 문서 설계에 내재화한 것이다.

@Dependency를 @Sendable 클로저 안에서 쓰면 안 되는 이유 — TCA Dependency 올바른 패턴

문제: @Sendable 클로저 캡처 경고

TCA Reducer에서 @Dependency를 선언하고 .run { send in ... } 내부에서 self로 접근하면 Swift Concurrency 경고가 발생합니다.

// ❌ 잘못된 패턴
struct HomeFeature: Reducer {
    @Dependency(\.childRepository) var childRepo

    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        case .loadChildren:
            return .run { send in
                // ⚠️ Capture of 'self' with non-sendable type in @Sendable closure
                let children = try await self.childRepo.fetchAll()
                await send(.childrenLoaded(children))
            }
    }
}

문제 원인: @Dependency 프로퍼티가 Sendable을 보장하지 않는 구조체에 속해 있는데, 이를 @Sendable 클로저인 .run { } 안에서 캡처하려 하기 때문입니다.

해결 1: 로컬 let으로 먼저 바인딩

// ✅ 클로저 외부에서 먼저 캡처
func reduce(into state: inout State, action: Action) -> Effect<Action> {
    case .loadChildren:
        let repo = childRepo  // ← 로컬 변수로 먼저 꺼내기
        return .run { send in
            let children = try await repo.fetchAll()
            await send(.childrenLoaded(children))
        }
}

해결 2: Repository 자체를 Sendable로 설계

// ✅ 가장 근본적인 해결 — Repository를 Sendable struct로
struct ChildRepository: Sendable {
    var fetchAll: @Sendable () async throws -> [Child]
    var create: @Sendable (ChildCreate) async throws -> Child
    var delete: @Sendable (String) async throws -> Void
}

// Sendable이므로 캡처 리스트로 안전하게 전달
return .run { [childRepo] send in
    let children = try await childRepo.fetchAll()
    await send(.childrenLoaded(children))
}

TCA 공식 권장 패턴: struct + @Sendable 클로저

// TCA 스타일 — 모든 Repository/Client를 이렇게 정의
struct ScheduleRepository: Sendable {
    var fetchToday: @Sendable () async throws -> [Schedule]
    var create: @Sendable (ScheduleCreate) async throws -> Schedule
    var update: @Sendable (String, ScheduleUpdate) async throws -> Schedule
    var delete: @Sendable (String) async throws -> Void

    static var live: Self {
        Self(
            fetchToday: { try await APIClient.shared.request("schedules/today") },
            create: { try await APIClient.shared.request("schedules/", method: "POST", body: ...) },
            update: { id, body in try await APIClient.shared.request("schedules/\(id)", method: "PUT", body: ...) },
            delete: { id in try await APIClient.shared.request("schedules/\(id)", method: "DELETE") }
        )
    }
}

교훈

TCA와 Swift Concurrency를 함께 쓸 때는 Sendable 준수 여부에 항상 주의해야 합니다. Repository/Service 타입을 struct + @Sendable 클로저 프로퍼티로 설계하면 TCA @Dependency로 사용할 때 캡처 문제가 근본적으로 해결됩니다.

SwiftData + 계정 전환: 로그아웃 시 LocalDB wipe와 Keychain 통일로 데이터 격리 구현

문제: 계정 전환 후 이전 계정 데이터 노출

같은 디바이스에서 다른 계정으로 로그인할 수 있는 앱에서, 로그아웃 시 UserDefaults 토큰만 삭제하면 SwiftData 로컬 DB가 그대로 남습니다. 다음 사용자가 로그인하면 이전 사용자의 자녀 정보와 일정이 화면에 잠깐 노출되는 심각한 프라이버시 버그가 발생했습니다.

해결: 로그아웃 시 4단계 완전 초기화

// AuthService.swift
func logout() async {
    // 1. 서버에 로그아웃 알림 (토큰 무효화)
    try? await APIClient.shared.request("auth/logout", method: "POST")

    // 2. Keychain 전체 삭제
    KeychainManager.deleteAll()

    // 3. UserDefaults 초기화
    let domain = Bundle.main.bundleIdentifier!
    UserDefaults.standard.removePersistentDomain(forName: domain)

    // 4. SwiftData LocalDB wipe
    await MainActor.run {
        do {
            let context = ModelContext.shared
            try context.delete(model: LocalChild.self)
            try context.delete(model: LocalSchedule.self)
            try context.delete(model: LocalAcademy.self)
            try context.save()
        } catch {
            // 실패 시 저장소 파일 직접 삭제
            resetSwiftDataStore()
        }
    }

    // 5. TCA Store 리셋
    await store.send(.logout).finish()
}

Keychain 단일 저장소 통일

초기에는 토큰을 UserDefaults와 Keychain 두 곳에 저장했습니다. 로그아웃 시 한 곳만 삭제하면 다른 곳에 남아있는 문제가 생겼습니다. 해결책은 Keychain 단일 저장소로 통일하는 것입니다.

enum KeychainManager {
    static let service = "com.ivent.app"

    static func save(_ value: String, for key: String) {
        let data = value.data(using: .utf8)!
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: key,
            kSecValueData: data
        ]
        SecItemDelete(query as CFDictionary)
        SecItemAdd(query as CFDictionary, nil)
    }

    static func load(for key: String) -> String? {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: key,
            kSecReturnData: true
        ]
        var result: AnyObject?
        SecItemCopyMatching(query as CFDictionary, &result)
        return (result as? Data).flatMap { String(data: $0, encoding: .utf8) }
    }

    static func deleteAll() {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service
        ]
        SecItemDelete(query as CFDictionary)
    }
}

userId 기반 데이터 격리 (이중 방어)

@Model class LocalChild {
    var id: String
    var userId: String  // 계정 격리를 위한 필드
    var name: String
}

// 조회 시 항상 userId 필터 적용
let userId = KeychainManager.load(for: "IVENT_USER_ID") ?? ""
let descriptor = FetchDescriptor<LocalChild>(
    predicate: #Predicate { $0.userId == userId }
)

교훈

다중 계정 앱에서 로그아웃은 토큰 삭제가 아닙니다. 로컬 DB, 캐시, UserDefaults, Keychain의 모든 레이어에서 이전 사용자 데이터를 완전히 제거해야 합니다. userId 기반 격리를 이중으로 적용하는 것이 안전합니다.

LocalFirst에서 Server-First로 — 가족 공유 앱에서의 동기화 전략 전환기

배경: LocalFirst의 한계

iEvent 초기에는 모든 데이터를 SwiftData로 로컬에 저장하고 백그라운드에서 서버와 비동기 동기화하는 LocalFirst 전략을 채택했습니다. 빠른 UI 반응성이 장점이었지만 가족 공유 기능을 추가하면서 치명적인 문제가 드러났습니다.

  • 충돌 해결 복잡도: 엄마와 아빠가 동시에 같은 일정을 수정하면 어느 버전이 정답인가?
  • 오프라인 변경 동기화 실패: 오프라인에서 학원을 삭제했는데 서버에 반영 안 되는 케이스
  • ID 불일치: 로컬과 서버의 ID가 달라 중복 레코드가 생성되는 버그

Server-First 전략

쓰기 작업(POST/PUT/DELETE)은 서버를 먼저 거치고 성공 시에만 로컬 DB를 갱신합니다. 읽기(GET)는 로컬 DB를 먼저 반환해 빠른 UI를 유지합니다.

// ChildRepository.swift — Server-First 실제 구현
static func live(syncEngine: SyncEngineClient) -> ChildRepository {
    let encoder = JSONEncoder()
    encoder.dateEncodingStrategy = .iso8601

    return ChildRepository(
        // 읽기: LocalDB 즉시 반환
        fetchAll: {
            let userId = UserDefaults.standard.string(forKey: "IVENT_USER_ID") ?? ""
            let descriptor = FetchDescriptor<LocalChild>(
                predicate: #Predicate { $0.userId == userId && $0.deletedAt == nil },
                sortBy: [SortDescriptor(\LocalChild.createdAt)]
            )
            return try ModelContext.main.fetch(descriptor).map { $0.toChild() }
        },

        // 쓰기: 서버 먼저, 성공 시 LocalDB 갱신
        create: { childCreate in
            let created: Child = try await APIClient.shared.request(
                "children/", method: "POST", body: encoder.encode(childCreate)
            )
            await MainActor.run {
                let local = LocalChild(fromServer: created, userId: userId)
                context.insert(local)
                context.saveLogged("ChildRepository.create")
            }
            return created
        },

        delete: { childId in
            try await APIClient.shared.request("children/\(childId)", method: "DELETE")
            await MainActor.run {
                if let local = try? context.fetch(
                    FetchDescriptor<LocalChild>(predicate: #Predicate { $0.id == childId })
                ).first {
                    local.deletedAt = Date()
                    try? context.save()
                }
            }
        }
    )
}

DependencyKey 패턴

private enum ChildRepositoryKey: DependencyKey {
    static var liveValue: ChildRepository {
        @Dependency(\.syncEngineClient) var syncEngine
        return .live(syncEngine: syncEngine)
    }
}

extension DependencyValues {
    var childRepository: ChildRepository {
        get { self[ChildRepositoryKey.self] }
        set { self[ChildRepositoryKey.self] = newValue }
    }
}

전환 후 달라진 점

  • 충돌 해결 로직 완전 제거 — 서버가 단일 진실 소스(Single Source of Truth)
  • 가족 구성원 간 데이터 일관성 보장
  • SyncEngine 관련 버그 80% 감소
  • 트레이드오프: 오프라인 쓰기 지원 포기

교훈

LocalFirst는 단일 사용자 앱에 적합하지만 다중 사용자 공유 앱에서는 충돌 해결 복잡도가 서버 왕복 비용을 초과합니다. 서버를 단일 진실 소스로 두고 읽기만 로컬 캐시를 활용하는 CQRS 스타일이 더 실용적입니다.

TCA ifLet 체이닝 7개 이상 시 컴파일 타임아웃 — 레이어 분리 패턴으로 해결하기

문제 발견

HomeFeature에 모달/시트를 하나씩 추가하다 보니 어느 순간부터 Xcode 빌드가 120초 이상 걸리거나 “Expression was too complex to be solved in reasonable time” 에러가 발생했습니다. 원인을 추적하니 .ifLet 체이닝이 7개를 넘으면서 Swift 타입 체커가 기하급수적으로 느려지는 문제였습니다.

// ❌ 문제 패턴 — ifLet 7개 이상 체이닝
var body: some ReducerOf<Self> {
    Reduce { state, action in ... }
    .ifLet(\.$childDetail, action: \.childDetail) { ChildDetailFeature() }
    .ifLet(\.$scheduleAdd, action: \.scheduleAdd) { ScheduleAddFeature() }
    .ifLet(\.$academyRegist, action: \.academyRegist) { AcademyRegistFeature() }
    .ifLet(\.$noticeboard, action: \.noticeboard) { NoticeboardFeature() }
    .ifLet(\.$scheduleEdit, action: \.scheduleEdit) { ScheduleEditFeature() }
    .ifLet(\.$profileEdit, action: \.profileEdit) { ProfileEditFeature() }
    .ifLet(\.$childAdd, action: \.childAdd) { ChildAddFeature() }
    // 여기부터 컴파일 타임아웃 발생!
    .ifLet(\.$settingsSheet, action: \.settingsSheet) { SettingsFeature() }
}

Swift 타입 체커의 한계

Swift의 타입 추론 시스템은 some ReducerOf<Self> 반환 타입 계산 시 .ifLet 각각을 중첩 제네릭 타입으로 확장합니다. 체이닝이 길어질수록 중첩 깊이가 지수적으로 증가해 컴파일러가 포기합니다.

해결: CombineReducers로 레이어 분리

// ✅ 올바른 패턴 — 논리 그룹별 레이어 분리
var body: some ReducerOf<Self> {
    CombineReducers {
        HomeBaseReducer()      // 핵심 비즈니스 로직
        HomeModalReducer()     // 모달 관련 .ifLet 묶음 (최대 3개)
        HomeScheduleReducer()  // 일정 관련 .ifLet 묶음 (최대 3개)
    }
}

// HomeModalReducer.swift — 별도 파일로 분리
struct HomeModalReducer: Reducer {
    typealias State = HomeFeature.State
    typealias Action = HomeFeature.Action

    var body: some ReducerOf<HomeFeature> {
        Reduce { _, _ in .none }
        .ifLet(\.$childDetail, action: \.childDetail) { ChildDetailFeature() }
        .ifLet(\.$scheduleAdd, action: \.scheduleAdd) { ScheduleAddFeature() }
        .ifLet(\.$profileEdit, action: \.profileEdit) { ProfileEditFeature() }
    }
}

// HomeScheduleReducer.swift
struct HomeScheduleReducer: Reducer {
    typealias State = HomeFeature.State
    typealias Action = HomeFeature.Action

    var body: some ReducerOf<HomeFeature> {
        Reduce { _, _ in .none }
        .ifLet(\.$academyRegist, action: \.academyRegist) { AcademyRegistFeature() }
        .ifLet(\.$noticeboard, action: \.noticeboard) { NoticeboardFeature() }
        .ifLet(\.$scheduleEdit, action: \.scheduleEdit) { ScheduleEditFeature() }
    }
}

빌드 시간 비교

  • 개선 전: clean build ~140초, ifLet 체이닝 부분에서 대부분 소모
  • 개선 후: clean build ~45초, 레이어별 독립 컴파일

추가 팁: 타입 명시로 힌트 제공

// Effect 반환 타입을 명시하면 타입 추론 부담 감소
func reduce(into state: inout State, action: Action) -> Effect<Action> {
    // 명시적 타입으로 컴파일러 힌트 제공
    let effect: Effect<Action> = .none
    return effect
}

교훈

TCA의 .ifLet은 강력하지만 Swift 타입 체커의 한계를 인식해야 합니다. 체이닝이 5개를 넘기 시작하면 미리 CombineReducers로 레이어를 분리하세요. 코드 구조도 개선되고 컴파일 속도도 향상되는 일석이조의 패턴입니다.

TCA(The Composable Architecture)를 선택한 이유 — 예측 가능한 iOS 앱 상태 관리

배경: 왜 상태 관리 프레임워크가 필요했는가

iEvent는 자녀의 학교·학원 일정을 가족이 함께 관리하는 앱입니다. 홈 화면 하나에서 여러 자녀의 오늘 일정, 등하원 시각, 날씨, AI 브리핑을 동시에 보여주어야 합니다. 여기에 LiveActivity, 알림장 OCR, 학원 등록 플로우까지 얽히면 상태가 폭발적으로 복잡해집니다.

초기에는 @StateObject + ObservableObject 조합으로 시작했지만, 화면이 4개를 넘어서자 상태 흐름을 추적하기 어려워졌습니다. “어디서 이 값이 바뀌었지?”라는 질문에 답하려면 10개 이상의 파일을 열어봐야 했습니다.

TCA를 선택한 핵심 이유

1. 단방향 데이터 흐름

TCA는 State → View → Action → Reducer → State의 단방향 흐름을 강제합니다. 뷰는 상태를 읽기만 하고, 변경은 반드시 Action을 통해서만 가능합니다. 버그가 생기면 Reducer 하나만 보면 됩니다.

// HomeFeature.swift
@Reducer
struct HomeFeature {
    @ObservableState
    struct State: Equatable {
        var children: [Child] = []
        var selectedChildId: String = ""
        var todaySchedules: [Schedule] = []
        var aiBriefing: String? = nil
        var isLoading: Bool = false

        @Presents var childDetail: ChildDetailFeature.State?
        @Presents var scheduleAdd: ScheduleAddFeature.State?
    }

    enum Action {
        case onAppear
        case childSelected(String)
        case schedulesLoaded(Result<[Schedule], Error>)
        case aiBriefingLoaded(Result<String, Error>)
        case childDetail(PresentationAction<ChildDetailFeature.Action>)
        case scheduleAdd(PresentationAction<ScheduleAddFeature.Action>)
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .onAppear:
                state.isLoading = true
                return .run { send in
                    await send(.schedulesLoaded(Result { try await scheduleRepo.fetchToday() }))
                }
            case let .childSelected(id):
                state.selectedChildId = id
                return .none
            default: return .none
            }
        }
        .ifLet(\.$childDetail, action: \.childDetail) { ChildDetailFeature() }
        .ifLet(\.$scheduleAdd, action: \.scheduleAdd) { ScheduleAddFeature() }
    }
}

2. @Dependency 주입으로 테스트 용이성

TCA의 @Dependency는 의존성 주입을 프레임워크 수준에서 지원합니다. 실제 API 클라이언트 대신 테스트용 mock을 손쉽게 주입할 수 있습니다. 실제 ChildRepository의 DependencyKey 패턴입니다.

// ChildRepository.swift — DependencyKey 패턴
private enum ChildRepositoryKey: DependencyKey {
    static var liveValue: ChildRepository {
        @Dependency(\.syncEngineClient) var syncEngine
        return .live(syncEngine: syncEngine)
    }
    static var testValue: ChildRepository {
        .mock()
    }
}

extension DependencyValues {
    var childRepository: ChildRepository {
        get { self[ChildRepositoryKey.self] }
        set { self[ChildRepositoryKey.self] = newValue }
    }
}

// Reducer에서 사용
struct HomeFeature {
    @Dependency(\.childRepository) var childRepo
}

3. @ObservableState로 세밀한 재렌더링 제어

TCA 1.7+의 @ObservableState 매크로는 Swift Observation 프레임워크를 활용해, 실제로 변경된 프로퍼티에 의존하는 뷰만 재렌더링합니다. @State + ObservableObject 조합보다 성능이 크게 향상됩니다.

// 뷰에서는 단순히 store를 관찰
struct HomeView: View {
    @Bindable var store: StoreOf<HomeFeature>

    var body: some View {
        // store.children이 바뀔 때만 이 뷰가 재렌더링됨
        ForEach(store.children) { child in
            ChildCardView(child: child)
        }
    }
}

실제 겪은 함정: @Presents와 옵셔널 상태

TCA에서 모달/시트를 띄울 때는 @Presents 매크로와 .ifLet 체이닝을 사용합니다. 초기에는 이 패턴을 모르고 직접 Bool 플래그로 관리했는데, 시트가 닫힐 때 상태가 남아있거나 두 번 닫히는 버그가 발생했습니다.

// ❌ 잘못된 패턴 — Bool 플래그로 직접 관리
struct State {
    var isShowingDetail: Bool = false
    var detailChild: Child? = nil
}

// ✅ 올바른 패턴 — @Presents 사용
struct State {
    @Presents var childDetail: ChildDetailFeature.State?
}

// Reducer에서 .ifLet 체이닝
var body: some ReducerOf<Self> {
    Reduce { state, action in ... }
    .ifLet(\.$childDetail, action: \.childDetail) {
        ChildDetailFeature()
    }
}

TCA 도입 후 달라진 것

  • 버그 추적 시간 80% 감소: 상태 변경이 항상 Action → Reducer를 통하므로 중단점 하나로 모든 변경 추적 가능
  • 테스트 커버리지 확대: TestStore를 사용해 Action 시퀀스별 상태 변화를 단위 테스트로 검증
  • 팀 온보딩 단순화: 새 기능 추가 시 State/Action/Reducer 세 곳만 보면 됨

교훈

TCA는 학습 곡선이 있지만, 상태가 복잡한 앱일수록 그 가치가 커집니다. 특히 가족 공유처럼 여러 사용자의 데이터가 실시간으로 동기화되는 앱에서는 단방향 데이터 흐름이 필수적입니다. 처음부터 TCA를 선택한 것은 이 프로젝트에서 가장 잘한 결정 중 하나였습니다.

LocalFirst에서 Server-First로 — 가족 공유 앱에서의 동기화 전략 전환기

iEvent는 가족이 함께 자녀의 일정을 관리하는 앱이다. 초기에는 “빠른 응답성”을 위해 LocalFirst 전략을 채택했다. 사용자 입력을 즉시 로컬 DB에 저장하고, 백그라운드에서 서버와 동기화하는 방식이었다. 그런데 이 전략이 가족 공유 앱에서 구조적인 버그의 온상이 되었다.

LocalFirst의 구조적 문제

serverId ?? localId.uuidString 패턴의 위험성

LocalFirst에서 가장 흔한 패턴은 서버 ID가 없으면 로컬 UUID를 임시 ID로 사용하는 것이다. 코드 곳곳에 이런 표현이 있었다.

// ❌ LocalFirst의 전형적인 안티패턴
func getAcademyId() -> String {
    return serverId ?? localId.uuidString  // 서버 동기화 전: "550e8400-e29b..."
                                           // 동기화 후: "server-real-id-123"
}
// 문제: UI가 로컬 UUID로 화면을 그린 상태에서 동기화가 완료되면
// UI는 여전히 이전 UUID를 참조하고 있음 → stale reference

처음에는 동기화 완료 시 UI를 강제 업데이트하는 방식을 검토했다. 하지만 참조 전파 경로가 너무 많았다. 자녀 ID가 학원에서, 학원이 일정에서, 일정이 알림에서 참조된다. 서버 ID 변경을 전파하는 비용이 버그를 추적하는 비용보다 더 컸다.

가족 구성원 간 충돌

// 시나리오: 엄마와 아빠가 동시에 같은 자녀의 학원을 추가하는 경우
// 엄마: localId = "uuid-A", serverId = nil (pending)
// 아빠: localId = "uuid-B", serverId = nil (pending)
// 서버 동기화 후: 둘 다 serverChild.id = "server-child-123"
// → UI에서 자녀가 두 번 표시되거나 참조가 엉킴

에러 처리의 함정

LocalFirst에서 서버 동기화 실패는 “silent retry”로 처리된다. 사용자는 저장이 성공했다고 믿지만, 실제로는 서버에 반영되지 않은 상태다. 가족 앱에서 이 문제는 치명적이다. 엄마가 추가한 일정이 아빠 폰에는 보이지 않을 수 있다.

결정: Server-First 전략으로 전환

2026년 6월, LocalFirst를 포기하고 Server-First로 전환했다. 핵심 원칙은 단순하다.

작업 정책
POST / PUT / DELETE 서버 API 먼저 → 성공 시 LocalDB 반영
GET / 목록 조회 LocalDB 즉시 반환 → 백그라운드 서버 pull
서버 실패 시 throw → UI에 에러 노출, 로컬 저장 없음

실제 구현: ChildRepository

create — 서버 먼저, 성공 시 LocalDB

// ChildRepository.swift — Server-First create
create: { childCreate in
    let encoder = JSONEncoder()
    // 1. 서버 API 호출 (실패 시 throw → UI에 에러 표시)
    let created: Child = try await APIClient.shared.request(
        "children/", method: "POST", body: encoder.encode(childCreate)
    )
    // 2. 서버 응답(정본)을 LocalDB에 저장
    await MainActor.run {
        let context = DatabaseContainer.shared.mainContext
        let userId = SessionContext.currentUserId
        let all = (try? context.fetch(FetchDescriptor())) ?? []
        // 중복 방지: serverId로 이미 존재하면 update, 없으면 insert
        if let existing = all.first(where: { $0.serverId == created.id }) {
            existing.applyServerUpdate(created)
        } else {
            let local = LocalChild(fromServer: created, userId: userId)
            context.insert(local)
        }
        context.saveLogged("ChildRepository.create")
    }
    return created  // 서버 응답이 정본
},

softDelete — 서버 먼저, cascade 처리

// ChildRepository.swift — Server-First softDelete
softDelete: { id in
    // 1. 로컬에서 serverId 조회
    let (serverId, _): (String?, UUID?) = await MainActor.run {
        let context = DatabaseContainer.shared.mainContext
        let all = (try? context.fetch(FetchDescriptor())) ?? []
        let local = all.first(where: { $0.serverId == id || $0.localId.uuidString == id })
        return (local?.serverId, local?.localId)
    }
    // 2. 서버 삭제 (404는 이미 삭제된 것으로 허용)
    if let serverId {
        do {
            let _: EmptyResponse = try await APIClient.shared.request(
                "children/\(serverId)", method: "DELETE"
            )
        } catch {
            if case NetworkError.requestFailedWithStatus(let code) = error, code == 404 {
                // 이미 서버에서 삭제됨 — 로컬만 정리
            } else {
                throw error  // 다른 에러는 propagate
            }
        }
    }
    // 3. 로컬 DB cascade 삭제 (학원 → 일정 → 다이어리)
    await MainActor.run {
        let context = DatabaseContainer.shared.mainContext
        let all = (try? context.fetch(FetchDescriptor())) ?? []
        guard let local = all.first(where: { $0.serverId == id || $0.localId.uuidString == id }) else { return }
        
        // @Relationship(deleteRule: .cascade) 대신 수동 cascade
        let academies = (try? context.fetch(FetchDescriptor())) ?? []
        for a in academies where a.childLocalId == local.localId { context.delete(a) }
        let schedules = (try? context.fetch(FetchDescriptor())) ?? []
        for s in schedules where s.childLocalId == local.localId { context.delete(s) }
        let diaries = (try? context.fetch(FetchDescriptor())) ?? []
        for d in diaries where d.childLocalId == local.localId { context.delete(d) }
        context.delete(local)
        context.saveLogged("ChildRepository.softDelete")
    }
},

fetchAll — LocalDB 즉시 반환, 백그라운드 sync

// ChildRepository.swift — Server-First fetchAll
fetchAll: {
    await MainActor.run {
        let context = DatabaseContainer.shared.mainContext
        let userId = SessionContext.currentUserId
        // LocalDB 즉시 반환 (빠른 응답성 유지)
        let descriptor = FetchDescriptor(
            predicate: #Predicate {
                $0.deletedAt == nil && ($0.userId == userId || $0.userId == "")
            },
            sortBy: [SortDescriptor(\.createdAt)]
        )
        return (try? context.fetch(descriptor))?.map { $0.toDomain() } ?? []
    }
    // 백그라운드 pull은 별도로 triggererd (syncFromServer)
},

syncFromServer: {
    guard SyncThrottle.shouldSync(key: "children") else { return }
    guard let serverChildren = try? await APIClient.shared.request("children/") as [Child] else { return }
    SyncThrottle.markSynced(key: "children")

    await MainActor.run {
        let context = DatabaseContainer.shared.mainContext
        let existing = (try? context.fetch(FetchDescriptor())) ?? []
        
        for serverChild in serverChildren {
            if let local = existing.first(where: { $0.serverId == serverChild.id }) {
                // 서버 데이터로 업데이트
                guard local.syncStatus == SyncStatus.synced.rawValue else { continue }
                local.applyServerUpdate(serverChild)
            } else {
                // 새 자녀 (다른 가족 구성원이 추가한 것)
                let local = LocalChild(fromServer: serverChild, userId: userId)
                context.insert(local)
            }
        }
        context.saveLogged("ChildRepository.syncFromServer")
    }
}

SyncEngine 단순화

가장 큰 변화 중 하나는 SyncEngine이 대폭 단순해진 것이다. 기존에는 push와 pull 양방향을 모두 처리했다.

// Before (LocalFirst): SyncEngine이 push+pull 모두 처리
// performSync() → pushPendingCreates/Updates/Deletes → pullLatestChanges()

// After (Server-First): pull만 처리
// performSync() → pullLatestChanges()
// push 계열 메서드 전부 제거

// 신규 레코드는 항상 .synced 상태 (pending 없음)
let local = LocalChild(fromServer: created, userId: userId)
local.syncStatus = SyncStatus.synced.rawValue  // ← 항상 synced

기존 사용자 마이그레이션 브릿지

LocalFirst → Server-First 전환 시 기존 사용자의 pendingCreate/Update/Delete 레코드가 LocalDB에 남아 있었다. 이를 한 번만 push하고 Server-First로 넘어가는 브릿지가 필요했다.

// AppFeature.swift — splash.onAppear
// 앱 버전 1에서 2로 마이그레이션
let version = UserDefaults.standard.integer(forKey: "IVENT_DB_SCHEMA_VERSION")
if version < 2 {
    // 기존 pending 레코드를 1회만 push (레거시 SyncEngine.performSyncImmediate())
    await SyncEngine.shared.performSyncImmediate()
    UserDefaults.standard.set(2, forKey: "IVENT_DB_SCHEMA_VERSION")
    // 이후에는 SyncStatus 필드가 남아 있어도 pendingCreate 참조 코드 없음
}

포기한 접근법

낙관적 업데이트 (Optimistic UI)

로컬 먼저 보여주고 서버 실패 시 롤백하는 방식도 검토했다. 하지만 가족 공유 데이터에서 롤백 로직은 너무 복잡해진다. 엄마가 추가한 학원을 아빠 폰에서 롤백해야 한다면? 서버가 정본이어야 하는 앱에서 낙관적 업데이트는 오히려 혼란을 가중시킨다.

오프라인 쓰기 지원

Server-First에서 오프라인 상태에서는 쓰기 작업이 실패한다. 의도된 동작이다. 오프라인 큐를 구현하면 LocalFirst의 문제가 다시 재현된다. 현재 앱 특성(가족 공유 데이터)에서 오프라인 쓰기는 이점보다 복잡도가 크다.

전환 후 달라진 점

  • ID 불일치 버그 제거: 서버 응답이 항상 정본이므로 serverId ?? localId 패턴 완전 제거
  • 가족 동기화 신뢰성: 서버에 저장된 데이터만 화면에 표시, 가족 간 불일치 없음
  • 에러 가시성: 서버 실패가 즉시 UI에 에러로 표시 (silent failure 없음)
  • 코드 단순화: SyncEngine push 계열 제거, pending 상태 처리 코드 제거

가족 공유 앱에서 동기화 전략은 단순히 기술적 선택이 아니다. 어느 데이터가 "정본"인지에 대한 비즈니스 결정이다. iEvent에서는 서버가 정본이어야 한다는 결론이 Server-First 전환을 이끌었다.