PROJECT

KIRA (Katsby)

키라&컴퍼니 · 2025.05–2026.07(1년 3개월) · iOS 주도, Android·Web 겸업 · 금융 교육/게이미피케이션

베타 1주 전 합류 → 정식 서비스

베타 출시 일주일 전 합류해 SwiftUI 기반으로 화면 구현부터 내비게이션, 딥링크, 결제, 분석까지 iOS 전반을 맡았습니다.

아래 코드는 회사 소스가 아니라 설계 아이디어를 공개 가능한 수준으로 다시 구현한 최소 예제입니다. 화면은 출시된 앱에서 누구나 보던 공개 화면만 사용했습니다.

1장

끊기지 않게 만든다

Case 01 · Prefetch / Cache

인스타식 브리핑 뷰어 · URL을 HTML로 미리 받아 캐싱

문제

슬라이드를 넘길 때마다 서버 URL을 그때그때 로드하면 흰 화면이 깜빡입니다. 봤던 장으로 되돌아올 때 같은 본문을 다시 그리는 것도 낭비였습니다.

접근

콘텐츠 파이프라인을 건드리지 않고 UX만 끌어올릴 수 있는 WKWebView 프리로드를 택했습니다. 현재 스토리 첫 장을 최우선으로, 나머지는 utility 우선순위 Task로 병렬 프리페치합니다.

구현

슬라이드 URL에서 HTML을 미리 내려받아 [slideID: HTML] 메모리 캐시에 담아 둡니다. 화면이 보일 때는 네트워크 없이 캐시에서 꺼내 그리고, 벗어나면 deinit에서 진행 중인 프리페치를 취소합니다.

결과

넘기는 즉시 다음 장이 뜨고, 되돌아와도 다시 그리지 않습니다.

실기기 데모 · 브리핑 뷰어

BriefingStore.swift · 요지
@Observable
final class BriefingStore {
    // slideID → HTML (메모리 캐시)
    private(set) var preloaded: [String: String] = [:]
    private var task: Task<Void, Never>?

    func load() {
        task?.cancel()
        task = Task { [weak self] in
            await self?.prefetch()
        }
    }

    // 첫 장 최우선, 나머지는 병렬로 미리 받아 캐시
    private func prefetch() async {
        await withTaskGroup(of: (String, String?).self) { group in
            for slide in currentSlides {
                group.addTask { (slide.id, await fetchHTML(slide)) }
            }
            for await (id, html) in group {
                if let html { preloaded[id] = html }
            }
        }
    }

    // URL → HTML 다운로드 (프리페치 내부 전용)
    private func fetchHTML(_ slide: Slide) async -> String? {
        guard let (data, _) = try? await
            URLSession.shared.data(from: slide.htmlURL)
        else { return nil }
        return String(data: data, encoding: .utf8)
    }

    // 화면이 보일 때 캐시에서 꺼내 쓴다
    func html(for id: String) -> String? { preloaded[id] }

    deinit { task?.cancel() }
}

Case 02 · URLSession / Concurrency

네트워크 요청 자동 취소 + generation 토큰

문제

이전 네트워크 계층은 Moya 콜백을 취소 핸들러 없이 async로 감싸, 상위에서 Task.cancel()을 해도 그 경계에서 신호가 끊겼습니다. HTTP 요청은 끝까지 진행됐고, 빠르게 드나들면 떠난 화면의 응답이 뒤늦게 도착해 최신 화면을 덮었습니다.

접근

URLSession async API 기반 자체 클라이언트로 재설계해 취소가 HTTP 레벨까지 전파되게 했습니다. 취소는 자원을, generation 비교는 정합을 책임지는 이중 방어입니다.

구현

Store의 Task.cancel()이 UseCase → Repository → APIClient를 타고 URLSession까지 내려갑니다. 요청마다 세대 번호를 매겨 최신 요청의 결과만 반영합니다(latest-wins).

결과

떠난 화면의 요청은 URLSession 레벨에서 끊기고, 그 틈에 도착한 응답도 generation으로 걸러집니다.

PagedListStore.swift
func load() {
    loadGeneration &+= 1
    let generation = loadGeneration
    loadTask?.cancel()
    // URLSession까지 취소 전파
    loadTask = Task {
        let result = try await
            useCase.fetch()
        guard generation
            == loadGeneration
        else { return }
        // 지난 응답 폐기
        items = result
    }
}

Case 03 · ScrollView / Prefetch

세로 맵 선 로딩 · 닿기 전에 다음 코스 준비

문제

챕터별 레슨을 세로로 길게 잇는 퀴즈맵에서, 다음 코스를 사용자가 도착한 뒤에 받으면 경계마다 로딩이 걸려 흐름이 끊깁니다.

접근

도착한 뒤 로드하면 경계마다 끊기고, 코스 전체를 미리 받으면 서버 부하와 데이터 낭비가 큽니다. 그래서 스크롤 위치를 신호로 삼아 다음 코스만 미리 받기로 했습니다. 사용자의 행동을 예측하는 게 아니라 명시적인 위치에 반응하는 편이 비용과 경험의 균형점이었습니다.

구현

다음 코스 뷰의 Y 좌표를 GeometryReader로 지켜보다 화면 하단 500pt 안쪽으로 들어오면 미리 받습니다. 중복 트리거는 플래그로 잠그고 코스가 바뀌면 리셋, 화면을 벗어나면 취소합니다.

결과

경계에 닿기 전에 다음 코스가 준비돼, 넘어갈 때 로딩 없이 곧바로 화면이 갱신됩니다.

실기기 데모 · 퀴즈맵 스크롤

QuizMapView.swift · 요지
private let prefetchTriggerDistance: CGFloat = 500

private func checkAndTriggerPrefetch(nextCourseViewY: CGFloat) {
    // 중복 트리거 차단
    guard !hasTriggeredPrefetch else { return }
    guard let nextCourseId = state.course?.nextCourse?.id
    else { return }

    let distanceFromBottom = nextCourseViewY - screenSize.height
    // 화면 하단 500pt 안이면
    guard distanceFromBottom <= prefetchTriggerDistance
    else { return }
    hasTriggeredPrefetch = true

    // 위치 기준으로 다음 챕터 미리 받기
    store.dispatch(.prefetchNextCourse(courseId: nextCourseId))
}

2장

화면 흐름을 직접 만든다

Case 04 · SwiftUI / Navigation

Route 단일 스택 · 화면 이동을 배열 하나로

문제

앱이 커지면서 깊은 화면에서 특정 지점으로 한 번에 되돌아가는 이동(특정 깊이로 pop)이 늘었습니다. SwiftUI 기본 NavigationPath는 담긴 화면을 type-erased로 감춰서, 스택 안을 들여다보거나 원하는 지점을 찾아 자를 수 없습니다.

접근

검색용 배열을 따로 두거나 TCA를 도입하는 길도 있었지만, 필요한 건 스택을 들여다보고 조작하는 기능 하나였습니다. 화면 스택을 [Route] 배열로 직접 소유하면 표준 배열 연산으로 끝나는 문제였습니다.

구현

화면 스택을 [Route] 배열 하나로 관리합니다. 특정 깊이로 되돌아갈 때는 prefix로 스택을 그 지점까지 자르고(popToPathCount), 같은 화면으로 다시 갈 때는 기존 자리를 빼 맨 위로 올립니다(navigateUnique). 추적과 복귀, 중복 제거가 전부 한 배열 위의 연산입니다.

결과

복잡해진 화면 이동을 한 자리에서 일관되게 다룹니다. 안을 들여다볼 수 없는 NavigationPath로는 불가능한 동작입니다.

Router.swift · 요지
@MainActor @Observable
final class Router {
    var path: [Route] = []  // NavigationStack(path:)에 바인딩

    // 같은 화면 재이동: 기존 자리를 빼고 맨 위로
    func navigateUnique(_ route: Route) {
        guard let index = path.firstIndex(of: route) else {
            return navigateTo(route)
        }
        var transaction = Transaction()
        transaction.disablesAnimations = true
        // 기존 중복은 애니메이션 없이 제거
        _ = withTransaction(transaction) { path.remove(at: index) }
        Task {
            try? await Task.sleep(for: .milliseconds(50))
            navigateTo(route)
        }
    }

    // 특정 깊이까지 잘라 그 지점으로 복귀
    func popToPathCount(_ count: Int) {
        let target = min(max(count, 0), path.count)
        path = Array(path.prefix(target))
    }
}

Case 05 · SwiftUI / Navigation

바텀시트 코디네이터 · 표시 계층 + 흐름 + 연타 방지

문제

화면마다 @State로 시트를 띄우면 중첩·복원 제어가 어렵고, 빠른 연타에 중복으로 쌓입니다. 표시 위치도 오버레이 아래에 깔리거나 화면을 벗어나도 시트가 남는 문제가 있었습니다.

접근

DragGesture로 직접 그리고 표시를 코디네이터로 일원화했습니다. 마운트는 여러 곳, 렌더는 한 곳이라는 원칙으로 설계했습니다.

구현

push/pop 스택이 시트를 보관했다가 닫히면 되돌립니다. 전환 애니메이션(~0.4초) 동안 isTransitioning으로 연타를 잠그고, 같은 시트 재호출은 id로 거릅니다.

결과

새 흐름도 같은 패턴으로 확장되고, 시트는 늘 의도한 화면 위에서 한 번만 뜹니다.

실기기 데모 · 시트 전환 (구매 → 충전)

BottomSheetCoordinator.swift · 요지
@MainActor @Observable
final class BottomSheetCoordinator {
    // 현재 시트를 스택에 보관하고 새 시트로
    func push(id: String, style: Style, content: AnyView) {
        // 연타·같은 시트 무시
        guard !isTransitioning, id != currentSheetId else { return }
        isTransitioning = true
        if let current = capturedContent, let currentId = currentSheetId {
            contentStack.append((currentId, style, current))
        }
        // 동시 표시가 아니라 '닫고 열기' 전환
        isPresented = false
        // 0.4초 뒤 새 시트 열기, 잠금 해제
    }

    // 닫히면 스택에서 이전 시트 복원
    // (충전 시트 닫기 → 원래 구매 시트로)
    func popSheet() { /* … */ }
}

// 마운트는 여러 곳, 렌더는 한 곳
NavigationStack { … }.bottomSheetCoordinator()
StreakView().bottomSheetCoordinator(screenId: "streak")

Case 06 · SwiftUI / Custom Calendar

연속학습 스트릭 달력 · 3분할 브릿지 + 2-패스 계산

문제

연속한 날들이 하나의 선으로 이어지려면 주 경계에서 끊겼다 다음 줄에 이어져야 합니다. 기본 캘린더 컴포넌트로는 셀 사이를 가로지르는 이 표현이 안 됩니다.

접근

그리드는 직접 계산하고 연속선은 셀 위 오버레이 조각으로 합성했습니다. 선을 시작·중간·끝으로 나누면 각 셀이 자기 조각만 그리면 되고, 주 경계는 조각 타입 교체로 풀립니다.

구현

7열 LazyVGrid + 2-패스 계산(셀 상태를 깔고 → 이전/다음 연속 여부 검사)으로 브릿지 타입을 확정합니다. 달마다 주 수가 달라 높이를 동적 계산합니다.

결과

주 경계·불연속·월 전환·단독일 어느 경우에도 브릿지가 어긋나지 않습니다.

연속학습 달력: 연속일 브릿지 표시

실기기 화면 · 스트릭 달력

StreakCalendarView.swift
enum StreakBridgeType {
    case solo
    // 앞뒤 연속 없음
    case start, middle, end
    // 연속 구간의 시작·중간·끝
    case startEdge, endEdge
    // 주·월 경계: 캡을 바꿔
    // 자연스럽게 끊는다
}

// 2-패스
// 1) 날짜 상태 매핑
// 2) 이전/다음 연속 검사
//    → 타입 확정

Case 07 · SwiftUI / Animation

커스텀 탭바 · 탭에 따라 모양이 바뀐다

문제

기본 TabView는 모양이 고정이라, 메인 탭과 플레이 탭 사이를 탭바 자체가 모양을 바꾸며 이어지는 전환으로 만들 수 없습니다.

접근

matchedGeometryEffect로 두 바를 잇는 방법은 아이콘·크기·정렬이 함께 변하는 '한 몸' 변형이 어긋나기 쉽습니다. 상태 하나에 모든 시각 속성을 묶는 명시적 모핑을 택했습니다.

구현

탭바를 직접 그리고 mode 상태 하나에 너비·높이·아이콘의 투명도와 크기를 모두 묶었습니다. 스프링 하나를 mode에 걸어 상태만 바꾸면 바 전체가 한 호흡으로 변형됩니다.

결과

영역 전환이 끊김 없이 한 호흡으로 이어집니다.

실기기 데모 · 메인 ↔ 플레이 전환

CustomTabBar.swift · 요지
enum TabBarMode { case main, play }

@State private var mode: TabBarMode = .main

var body: some View {
    ZStack(alignment: .bottom) {
        mainBar
            .opacity(mode == .main ? 1 : 0)
        playBar
            .opacity(mode == .play ? 1 : 0)
            .scaleEffect(mode == .play ? 1 : 0.3)
    }
    // 너비·높이·정렬까지 변형
    .frame(
        width:  mode == .main ? mainWidth : subWidth,
        height: mode == .main ? 83 : 64,
        alignment: mode == .main ? .center : .trailing
    )
    // 하나로 묶어 전환
    .animation(
        .spring(response: 0.4, dampingFraction: 0.85),
        value: mode
    )
}

Case 08 · DeepLink / Pending

딥링크 보류와 재개 · 오버레이가 닫힌 뒤 한 번만

문제

푸시나 URL로 들어오는 진입은 아무 때나 도착합니다. 그 순간 온보딩이나 풀스크린 오버레이가 떠 있으면 곧장 화면을 밀어버릴 수 없습니다.

접근

바로 라우팅하면 진행 중이던 맥락이 깨지고, 큐에 모두 쌓아 두면 오버레이가 닫히는 순간 화면이 연달아 점프합니다. 그래서 최신 링크 한 건만 보류했다 재개하기로 했습니다. 사용자의 의도는 결국 마지막으로 누른 그곳이기 때문입니다.

구현

도착 시점에 처리할 수 있으면 즉시 소비하고 아니면 보류해 뒀다가 오버레이가 닫히는 시점에 한 번 꺼내 처리합니다. 보류 중 새 링크가 오면 최신으로 교체합니다. 보류분은 1회 소비 후 비워 중복 실행을 구조로 막습니다.

결과

진입 맥락을 깨지 않으면서 오버레이 뒤에 도착한 링크도 놓치지 않습니다.

PendingDeepLink.swift · 요지
// 보류 저장소: 새 링크가 오면 최신으로 교체, 꺼내면 1회 소비 후 비움
enum PendingDeepLink {
    private static var destination: DeepLinkDestination?

    static func save(_ d: DeepLinkDestination) { destination = d }

    static func consume() -> DeepLinkDestination? {
        defer { destination = nil }  // 1회 소비
        return destination
    }
}

@MainActor @Observable
final class DeepLinkRouter {
    func handle(_ destination: DeepLinkDestination) {
        // 온보딩·오버레이가 떠 있으면 보류
        guard isAuthenticated, !isOnboarding else {
            return PendingDeepLink.save(destination)
        }
        pendingNavigation = destination  // 즉시 실행
    }

    // 오버레이 닫힘 시점에 보류분 1회 재개
    func consumePendingIfNeeded() {
        guard let d = PendingDeepLink.consume() else { return }
        pendingNavigation = d
    }
}

Case 09 · UIWindow / Presentation

토스트 · 뷰 계층 밖 별도 윈도우로

문제

토스트는 시트나 풀스크린, 시스템 얼럿이 떠 있어도 그 위에 보여야 합니다. SwiftUI의 표시 수단은 전부 "지금 화면"의 뷰 계층이나 그 화면이 시작한 프레젠테이션 컨텍스트에 묶입니다.

접근

화면마다 ZStack 오버레이를 두거나 루트 뷰에 modifier로 주입하는 방법은 시트나 시스템 얼럿 위로는 못 올라갑니다. "무엇이 떠 있든 그 위"라는 요구는 뷰 계층 안에서는 답이 없어, 별도 UIWindow로 올리는 윈도우 레벨의 문제였습니다.

구현

시스템 얼럿보다 높은 windowLevel(.alert + 1)의 새 UIWindowUIHostingController로 토스트를 올립니다. 윈도우를 화면 전체가 아니라 토스트 크기만큼만 만들어 hitTest 오버라이드 없이 밖 터치가 밑 화면에 그대로 전달됩니다.

결과

시트와 풀스크린, 시스템 얼럿 위에도 일관되게 토스트가 뜹니다. 최대 2개까지 쌓이고 시간이 지나면 사라지며 남은 토스트 위치를 다시 잡습니다.

ToastWindowManager.swift · 요지
@MainActor
final class ToastWindowManager {
    private var toastWindows: [ToastWindow] = []
    private let maxToastCount = 2

    func showToast(_ view: some View, duration: Double = 1.5) {
        guard let scene = UIApplication.shared.connectedScenes
            .compactMap({ $0 as? UIWindowScene }).first else { return }

        let window = UIWindow(windowScene: scene)
        window.windowLevel = .alert + 1   // 시트·얼럿 위에도
        window.backgroundColor = .clear
        // 화면 전체가 아니라 토스트 크기만큼만
        window.frame = toastFrame
        window.rootViewController = UIHostingController(rootView: view)
        window.makeKeyAndVisible()

        toastWindows.append(ToastWindow(window: window))
        repositionToasts()
    }
}

3장

원인까지 파고든다

Case 10 · @Observable / Instruments

severe hang · 거대 Store를 책임별로 분리

문제

TabBarStore 한 곳에 800줄 가까운 상태가 몰려 있었습니다. 퀘스트 타이머가 1초마다 값을 바꿀 때마다 이 Store를 구독하던 탭바 주변 화면이 통째로 다시 그려졌고, Instruments에서 메인 스레드를 오래 붙잡는 severe hang으로 잡혔습니다.

접근

원인은 "한 Store가 광범위하게 변경을 통지한다"였습니다. 국소 최적화 대신 책임별로 Store를 쪼개 구독 범위 자체를 좁히는 방향을 택했습니다.

구현

책임별 8개의 @Observable Store(탭 선택·퀘스트·쿨다운·전역 팝업 등)로 분리했습니다. 타이머는 퀘스트 시트의 로컬로 내려 시트가 떠 있을 때만 돌게 했습니다.

결과

변경 통지 범위가 좁아져 불필요한 재렌더가 사라지고 hang이 해소됐습니다. 한 책임이 한 Store에 모여 수정 시 영향 범위도 분명해졌습니다.

거대 Store 분리 · Before / After
// Before: 약 800줄, 성격 다른 책임이 한 객체에
final class TabBarStore: ObservableObject {
    @Published var selection: MainTab = .home
    @Published var questsState: DataState<[Quest]> = .idle
    @Published var isPresentAnnouncementPopup = false
    @Published var cooldownRemaining: TimeInterval = 0
    @Published var remainingTime: TimeInterval = 0
    // 타이머 틱마다 전체 통지, 탭바 주변 재평가(hang)
}

// After: 책임별 @Observable 분리 (8개 중 대표)
@Observable final class TabSelectionStore {
    var selection: MainTab = .home
}
@Observable final class QuestStore {
    var quests: [Quest] = []
}
@Observable final class CooldownStore {
    var cooldownRemaining: TimeInterval = 0
}

// 타이머는 그게 필요한 화면의 로컬로
struct QuestBoardSheet: View {
    @State private var remainingTime: TimeInterval = 0
    // 시트가 떠 있을 때만 돌고 이 화면만 갱신
}

Case 11 · Swinject / Crashlytics

런치 간헐 크래시 · Crashlytics 리포트에서 DI 스레드 문제까지

문제

Crashlytics에 앱 런치 시점 간헐 크래시가 잡혔습니다. 재현이 안 되는 유형이라 스택 트레이스를 따라가니 DI 컨테이너 내부였습니다.

접근

Swinject Container.resolve()가 thread-safe하지 않다는 것을 라이브러리 동작까지 확인해 진단했습니다. "가끔 죽는 앱" 뒤에 있는 "여러 스레드가 동시에 resolve하는 구조"가 원인이었습니다.

구현

synchronize() 기반 thread-safe resolver를 도입하고 등록은 shared, 해소는 resolver로 역할을 분리하고 코드베이스 전체에 일관 적용했습니다.

결과

런치 간헐 크래시가 재발하지 않았고, DI 접근 규칙이 코드베이스 전체에 일관되게 남았습니다.

DIContainer.swift
// Container.resolve()는
// thread-safe하지 않음
// → synchronize()로 직렬 큐 보호
private let resolver: Resolver =
    container.synchronize()

// 등록: shared
// 해소: resolver 로 역할 분리
func resolve<T>(
    _ type: T.Type
) -> T? {
    resolver.resolve(type)
}

4장

상태는 정확히 한 번만 바뀐다

Case 12 · StoreKit 2 / actor

StoreKit 2 결제 · 어디서 끊겨도 지급은 정확히 한 번

문제

소모성 재화 결제에서 앱 강제 종료, 네트워크 단절, 재로그인, 어느 시점에 끊겨도 거래가 유실되면 안 됩니다.

접근

지급 확정의 기준을 클라이언트가 아닌 서버에 뒀습니다. 서버가 확인한 후에만 finish하고, 그 전에 끊긴 거래는 "미완료"로 남겨 복구 대상으로 삼습니다.

구현

actor 리포지토리가 Transaction.updates 스트림을 상시 구독해 미완료 거래를 복구합니다. 3회 지수 백오프 + 409 처리로 중복 지급을 막고, appAccountToken으로 재로그인 후에도 계정별 추적합니다.

결과

결제 흐름의 어느 지점에서 끊겨도 지급은 정확히 한 번 일어납니다.

PurchaseRepository.swift · 요지
actor PurchaseRepository {
    func listen() {
        for await update in Transaction.updates {
            // 앱이 어디서 죽었든
            // 미완료 거래가 여기로 돌아온다
            await grant(update)
        }
    }

    private func grant(_ tx: Transaction) async {
        // 1) 서버에 지급 요청 (appAccountToken 계정 추적)
        //    재시도 3회 지수 백오프, 409는 이미 지급으로 간주
        guard await api.grant(tx) else { return }
        // 실패하면 finish하지 않는다. 미완료로 남아 복구 대상

        // 2) 서버가 확인한 뒤에만 finish
        await tx.finish()
    }
}

Case 13 · enum FSM / AsyncStream

퀴즈 상태머신 + 제출 배리어 · 불가능한 상태를 타입으로 제거

문제

isReviewing·isEnd 불리언 조합은 "복습이면서 완료" 같은 불가능한 상태가 컴파일을 통과합니다. 빠른 연타 시 화면과 서버가 어긋나는 문제도 남아 있었습니다.

접근

enum 상태머신으로 불가능한 상태를 타입 차원에서 제거했습니다. 제출은 낙관 진행 + 단원 경계 배리어로 사용자는 기다리지 않고 정합은 경계에서 한 번만 맞춥니다.

구현

진행 상태를 enum 하나로 모델링하고 전이 규칙을 전용 UseCase에 모았습니다. 경계에서 withCheckedContinuation으로 다음 진행을 붙들고 실패분은 큐에 모아 재전송합니다.

결과

불가능한 상태가 타입 차원에서 사라지고, 빠른 연타에도 화면과 서버가 어긋나지 않습니다.

실기기 데모 · 퀴즈 플로우

QuizProgressType.swift
enum QuizProgressType: Equatable {
    case study(stage: Int)
    // 풀이
    case review(stage: Int)
    // 복습 (틀린 문제만)
    case survey(stage: Int)
    // 설문
    case end(
        stage: Int,
        adType: QuizADType)
    case completed
    // "복습이면서 완료" 같은 조합이
    // 타입 차원에서 불가능해진다
}

Case 14 · actor / CheckedContinuation

퀘스트 보상 연출 · 순서대로, 껐다 켜도 한 번만

문제

퀘스트가 완료되면 진행 바, 젬 획득, 완료 표시가 순서대로 떠야 합니다. 불리언 플래그로는 순서가 꼬이고, 화면을 벗어나면 젬이 두 번 더해지고, 앱을 껐다 켜면 이미 본 연출이 다시 재생됩니다.

접근

"무엇이 바뀌었는가"의 계산을 화면이 아니라 데이터 경계로 내렸습니다. actor 저장소가 이전 스냅샷과 지금 상태의 차이(QuestChange)를 계산하고, 화면은 계산된 변화만 받아 보여줍니다.

구현

진행→젬→완료 연출을 async/await로 순차 실행하고 도중 이탈 시 Task를 취소합니다. 콜백으로만 끝나는 젬 연출은 withCheckedContinuation으로 잇되 중복 resume을 가드해 보상 중복 가산을 막고, 스냅샷 확정은 연출이 끝난 뒤에만 합니다.

결과

어떤 진입 경로에서도 연출이 한 번씩만 올바른 순서로 재생되고, 마감 시각을 영속 저장해 껐다 켜도 이미 본 연출은 다시 나오지 않습니다.

데모 · 진행 바와 완료 연출

QuestStorageImpl.swift · 요지
actor QuestStorageImpl: QuestStorage {
    private var snapshot: [Quest] = []  // 직전(본) 상태
    private var current:  [Quest] = []

    func getChangedQuests() -> [QuestChange] {
        current.compactMap { quest in
            guard let prev = snapshot.first(
                where: { $0.id == quest.id }) else { … }
            // 변경 없으면 스킵
            guard prev.currentCount != quest.currentCount
               || prev.isCompleted  != quest.isCompleted
            else { return nil }
            return QuestChange(quest: quest,
                previousCount: prev.currentCount,
                previousCompleted: prev.isCompleted)
        }
    }
    // 연출이 끝난 뒤에만 확정
    func commitSnapshot() { snapshot = current }
}

// 콜백 젬 연출을 async로: 두 번 불려도 한 번만
await withCheckedContinuation { cont in
    var isResumed = false
    gemFly { if !isResumed { isResumed = true; cont.resume() } }
}