반응형

SwiftUI Study – scenePhase와 앱 생명주기를 활용한 자동 저장·타이머·네트워크 작업 관리 패턴

 

1. 왜 중요한가 (문제 배경)

SwiftUI 앱은 UIKit처럼 AppDelegate / SceneDelegate 중심이 아니라
@main App + @Environment(\.scenePhase) 조합으로 생명주기(lifecycle) 를 관리합니다.

하지만 실무에서 다음과 같은 문제가 자주 발생합니다.

  • 사용자가 홈 버튼을 눌러 나갔을 때 입력 중이던 내용이 사라짐
  • 백그라운드로 진입했는데 타이머·Task·네트워크가 그대로 돌아감
  • 다시 돌아왔을 때 화면 상태가 오래된 값으로 남아 있음
  • 푸시/딥링크로 진입했을 때 상태 동기화가 어색함

이런 문제의 공통점은:

“앱의 생명주기 변화에 맞춰

상태 저장·정리·재로딩을 설계하지 않았다.”

SwiftUI에서 scenePhase 를 올바르게 활용하면:

  • 자동 저장 / 자동 복원
  • 타이머·Task 중지 및 재개
  • 백그라운드 진입 시 리소스 정리
  • 포그라운드 복귀 시 최신 데이터 동기화

같은 패턴을 깔끔하게 구현할 수 있다.

 

2. 잘못된 패턴 예시

❌ 예시 1: scenePhase를 사용하지 않고 항상 동작하는 타이머

final class TimerViewModel: ObservableObject {
    @Published var count = 0
    private var timer: Timer?

    func start() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            self.count += 1        // ❌ 앱이 백그라운드에서도 계속 증가
        }
    }
}

문제점

  • 사용자가 홈으로 나가도 타이머는 계속 돈다
  • 배터리 낭비 + 불필요한 연산
  • 다시 돌아왔을 때 count 값이 크게 튀어 있음

❌ 예시 2: 입력 폼을 scenePhase와 분리해서 설계

struct MemoView: View {
    @State private var text: String = ""   // ❌ 앱 종료 시 유실

    var body: some View {
        TextEditor(text: $text)
    }
}

문제점

  • 앱을 잠시 벗어났다 돌아오면 입력 내용이 사라질 수 있음
  • 자동 저장 타이밍이 없기 때문에 유실 위험 존재

❌ 예시 3: 포그라운드 복귀 시 데이터 동기화 없음

struct HomeView: View {
    @State private var items: [Item] = []

    var body: some View {
        List(items) { Text($0.title) }
        .task {
            items = await api.loadItems()   // ❌ 앱 실행 시 한 번만
        }
    }
}

문제점

  • 앱을 켜둔 상태에서 시간이 오래 지나도 UI는 오래된 데이터 유지
  • 사용자가 다시 앱으로 돌아왔을 때, 최신 데이터가 아니라 “오래된 화면”을 보게 됨

 

3. 올바른 패턴 예시

✅ 예시 1: scenePhase로 타이머/Task를 제어

@main
struct MyApp: App {
    @Environment(\.scenePhase) private var scenePhase

    @StateObject private var timerVM = TimerViewModel()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(timerVM)
        }
        .onChange(of: scenePhase) { phase in
            switch phase {
            case .active:
                timerVM.start()   // 포그라운드에서만 동작
            case .background, .inactive:
                timerVM.stop()    // 백그라운드/비활성화 시 중지
            @unknown default:
                break
            }
        }
    }
}

ViewModel:

final class TimerViewModel: ObservableObject {
    @Published var count = 0
    private var timer: Timer?

    func start() {
        guard timer == nil else { return }
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.count += 1
        }
    }

    func stop() {
        timer?.invalidate()
        timer = nil
    }
}

장점

  • 앱 상태에 따라 타이머가 정확히 멈추고 재개
  • 불필요한 백그라운드 연산 제거

✅ 예시 2: scenePhase와 AppStorage를 조합한 자동 임시 저장

struct MemoView: View {
    @AppStorage("draft_memo") private var draft: String = ""
    @Environment(\.scenePhase) private var scenePhase

    var body: some View {
        TextEditor(text: $draft)
            .padding()
            .onChange(of: scenePhase) { phase in
                if phase == .background {
                    saveDraft()
                }
            }
    }

    private func saveDraft() {
        // @AppStorage가 이미 저장을 담당하므로
        // 별도 로직이 없다면 이 함수는 로깅/추가 처리 용도
        print("Draft saved:", draft)
    }
}

장점

  • 사용자가 앱을 벗어나도 메모 내용이 유지
  • 다시 들어오면 마지막 상태 그대로 복원
  • “자동 임시 저장” 패턴을 매우 쉽게 구현

✅ 예시 3: 포그라운드 복귀 시 데이터 리프레시

struct HomeView: View {
    @Environment(\.scenePhase) private var scenePhase
    @State private var items: [Item] = []

    var body: some View {
        List(items) { Text($0.title) }
            .task {
                await reload()
            }
            .onChange(of: scenePhase) { phase in
                if phase == .active {
                    Task { await reload() }
                }
            }
    }

    func reload() async {
        items = (try? await api.loadItems()) ?? []
    }
}

장점

  • 앱 처음 실행 + 포그라운드 복귀 시 항상 최신 데이터로 유지
  • 사용자가 “오래된 화면”을 보는 현상 최소화

✅ 예시 4: scenePhase를 ViewModel로 전달해 비즈니스 로직에서 처리

@MainActor
final class SessionManager: ObservableObject {
    func appDidEnterBackground() {
        // 세션 저장, 토큰 갱신, 로그 플러시 등
    }

    func appDidBecomeActive() {
        // 필요 시 세션 복구, 토큰 상태 확인 등
    }
}

App:

@main
struct MyApp: App {
    @Environment(\.scenePhase) private var scenePhase
    @StateObject private var sessionManager = SessionManager()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environmentObject(sessionManager)
        }
        .onChange(of: scenePhase) { phase in
            switch phase {
            case .active:
                sessionManager.appDidBecomeActive()
            case .background:
                sessionManager.appDidEnterBackground()
            case .inactive:
                break
            @unknown default:
                break
            }
        }
    }
}

장점

  • 생명주기 이벤트를 View가 아니라 도메인/세션 레이어에서 처리
  • 향후 UIKit/다른 플랫폼으로 확장할 때도 로직 재사용이 쉬움

 

4. 실전 적용 팁

✔ 팁 1 – 타이머·Task·스트림은 scenePhase와 함께 설계

.active 에서만 동작하고, .background / .inactive에서는 반드시 멈추게 만드는 습관.

✔ 팁 2 – 입력 폼에는 자동 임시 저장 전략을 넣는 것이 좋다

@AppStorage 또는 로컬 DB를 활용해 “사용자 입력 유실”을 막기.

✔ 팁 3 – 포그라운드 복귀 시 가벼운 리프레시를 수행

전체 리셋이 아닌, 변경 가능성이 있는 데이터만 갱신.

✔ 팁 4 – scenePhase를 그대로 View에다 흩뿌리지 말고,

가능하면 SessionManager / AppCoordinator 같은 전담 객체로 모으기.

✔ 팁 5 – 실제 디바이스에서 백그라운드 전환/복귀 패턴을 반복 테스트

시뮬레이터 + 실제 기기 양쪽에서 동작을 꼭 확인해야 한다.

 

5. 정리

  • SwiftUI의 scenePhase는 앱 생명주기를 표현하는 핵심 도구이며,
    타이머·비동기 Task·입력 폼·세션 관리와 반드시 연결되어야 한다.
  • 백그라운드 진입 시 “멈춰야 할 것(타이머/폴링/스트림)”과
    “저장해야 할 것(입력 값/세션/로그)”을 명확히 구분해 처리해야 한다.
  • 포그라운드 복귀 시 “최신 상태 동기화” 전략까지 포함하면
    SwiftUI 앱의 안정성과 사용자 경험이 한 단계 올라간다.
반응형
Posted by 까칠코더
,