ScenePhase - 유저가 강제로 종료해버리면 어떡하지?

Taeyoung Won·2023년 4월 26일
1

SwiftUI

목록 보기
6/7
post-thumbnail

GitSpace 프로젝트에서 유저의 돌발 행동을 커버해야하는 문제가 발생했다.

유저가 어플리케이션 바깥으로 나가는 케이스를 커버하는 ScenePhase를 알아보자.

배경

나는 GitSpace 프로젝트의 채팅 기능 담당이다.

위의 화면처럼 아직 읽지 않은 메세지 갯수를 카운트하는 기능을 만드려고한다.




첫 번째 아이디어

처음 아이디어는 메세지 모델 필드에 읽었는지 여부를 저장하고, 수신 유저가 처음 메세지 버블을 봤을 때 true로 만들어주려고 했다.

그런데 실제 사용 사례를 생각해보니, 화면에서 메세지 버블을 볼 때마다 서버에 업데이트 요청을 하면 서버에 주는 부담이 너무 클 것 같았다.

요청 횟수를 줄이면서도 데이터를 일관성 있게 유지할 수 있는 다른 방법을 생각해봐야할 것 같다.




두 번째 아이디어

두 번째 아이디어는 채팅방에 입장할 때, 다시 나갈 때 카운트를 초기화하는 방법이었다.

이 방법이면 서버 업데이트 요청도 2회로 줄일 수 있고, 다른 채팅 레퍼런스에서도 실제로 메세지 버블이 화면에 나타났는지 여부로 읽음 처리를 하지 않기 때문에 적합한 솔루션이라고 생각했다.

효율적으로 관리하기 위해 읽지 않은 메세지 카운트 필드를 Chat 모델에 추가해서 관리하기로 했다.

struct Chat {
	...
    
    var unreadMessageCount: [String: Int] // [유저ID: 읽지 않은 메세지 카운트]
}




문제 상황

struct ChatRoomView: View {
	...
    var body: some View {
		...
        .task {
            await clearUnreadMessageCount()
        }
        .onDisAppear {
            Task {
                await clearUnreadMessageCount()
            }
        }
	}
}

위 로직을 적용하기 위해 task, onDisAppear에서 한번 씩 clearUnreadMessageCount 함수를 호출해서 서버에 초기화를 요청했다.

입장할 때 지금까지 쌓인 안 읽은 메세지 카운트가 초기화되었고, 채팅방에 있는동안 실시간으로 받은 메세지도 나갈 때 모두 읽음 처리를 해주었다.

그런데 유저 시나리오를 이것저것 생각하다보니까 그런 생각이 들었다.

"유저가 채팅방을 안 나가고 앱을 백그라운드로 보내버리면 어떡하지? 그랬다가 그냥 강제로 앱을 종료해버리면 어떡하지?"

혹시 앱이 백그라운드로 갈 때도 onDisAppear로 인식해서 호출이 될까 테스트해봤지만, 호출되지 않았다.




ScenePhase

여기서 이번 주제인 ScenePhase가 등장한다.

SwiftUI에서는 앱의 라이프사이클을 확인하고 이를 트리거로 사용할 수 있도록 ScenePhase라는 Environment 래퍼 변수를 지원해준다.

공식 문서에서는 "장면의 작동 상태를 나타낸다"라고 설명하고있다.




라이프사이클 케이스

ScenePhase는 열거형으로 세 가지 상태 케이스를 가진다.

  • active: 앱 화면이 UI에 표시되고 상호작용이 가능한 상태
  • inactive: 앱 화면이 UI에 표시는 되지만 상호작용이 불가능한 상태
  • background: 앱 화면이 현재 UI에 표시되지 않고 백그라운드에 있는 상태




사용 예시

실제로 어떻게 작동하는지 확인해보자.

struct ContentView: View {
  
  @Environment(\.scenePhase) private var sceneStatus
  
  var body: some View {
    Text("현재 화면")
      .font(.largeTitle)
      .onChange(of: sceneStatus) { status in
        switch status {
          case .active:
            print("Active")
          
          case .inactive:
            print("Inactive")
          
          case .background:
            print("Background")
          
          @unknown default:
            print("Unknown Phase Status")
        }
      }
  }
}

Environment 래퍼로 scenePhase를 변수에 담아준다.

라이프사이클에 따라 sceneStatus의 값이 변경되기 때문에 이를 감지하기 위해 onChange를 달아주었다.

열거형이기 때문에 switch를 통해서 각 케이스에 따른 로직을 수행할 수 있다.

물론 특정 케이스에 대한 처리만 필요하다면 if로 분기 처리해도 괜찮다.



라이프사이클을 조작할 때마다 onChange가 감지하고 출력해준다.

active와 background는 조건이 명확하지만, inactive가 조금 모호할 것 같다.

inactive는 foreground지만 전화나 알림 수신 혹은 실행 중인 앱 간에 전환하는 화면?이 표시되어 사용자 이벤트를 받을 수 없는 상태를 의미한다.


이 화면을 공식적으로 뭐라고 부르는지 찾아봤는데

... 제목이 진짜 열려 있는 앱 간에 전환하기였구나

실행 중인 앱이 스택으로 나오는 저 화면은 앱 전환기라고 부른다.


의외였던 것은 홈 버튼을 눌러서 앱 전환기를 거치지 않고 바로 홈 화면으로 이동해도 inactive 상태를 거친다는 점이었다.




프로젝트 적용

다시 프로젝트로 돌아오자.

struct ChatRoomView: View {
	...
    @Environment(\.scenePhase) private var scenePhase
    
    var body: some View {
		...
        .task {
            await clearUnreadMessageCount()
        }
        .onDisAppear {
            Task {
                await clearUnreadMessageCount()
            }
        }
        // 유저가 앱 화면에서 벗어났을 때 수행되는 로직
        .onChange(of: scenePhase) { currentPhase in
            if currentPhase == .inactive {
                Task {
                    await clearUnreadMessageCount()
                }
            }
        }
	}
}

채팅방 화면에도 scenePhase를 적용하고, inactive 상태일 때도 clearUnreadMessageCount를 호출했다.

이제 유저가 채팅방 화면을 정상적으로 벗어나지 않고 앱을 강제로 종료해도, 데이터가 정상적으로 업데이트된다.

이렇게 서버 요청 횟수를 줄이면서 데이터 일관성을 유지할 수 있는 방법으로 문제를 해결했다.

profile
iOS Developer

0개의 댓글