아이폰 쓰시는 분은 다 아시겠지만 부분을 드래그해서 올리면 사용 중인 앱들을 모아보는 화면을 볼 수 있습니다. 이 동작을 앞으로 “모아보기 화면으로 나간다”라고 부르겠습니다. 이렇게 해서 앱 간에 전환을 할 수 있습니다. 저도 단어장을 사용하면서 이 기능을 종종 사용했었는데요. 이런 부분에서 버그가 발생할 것이라고는 전혀 상상도 못했습니다.
단어의 성공 / 실패를 업데이트하고 “모아보기 화면으로 나가면” 업데이트한 내역이 초기화 되었습니다. 다행히 서버에는 업데이트가 되지 않는 것은 아니었는데요. 왜 이런 버그가 발생했을까요?
일단 body가 언제 re-render되는지 알아보기 위해서 body에 break point를 걸고 “모아보기 화면으로 나가"보겠습니다. 아래 보면 “모아보기 화면으로 나갈"때 body의 코드를 다시 읽는 것을 볼 수 있습니다. 즉 re-render되는 것이죠.
위 View의 ViewModel입니다. 단어 리스트는 이중으로 관리가 되는데요. rawWords는 DB에서 fetch 해온 원본이고 @Published의 words의 경우 View에서 사용하는 배열입니다.
그리고 updateStudyState는 좌우 드래그를 통해 성공 / 실패 여부를 업데이트할 때 실행되는 함수인데 보시면 words는 수정하지 않고 rawWords에 있는 데이터만 수정하는 것을 보실 수 있습니다.
그 원인은 아래 주석에도 적었듯이 words를 수정하면 Cell 하나만 re-render되는 것이 아니라 전체 Cell이 re-render 되어 버리기 때문입니다. 따라서 불필요한 re-render를 막기 위해서 rawWords만 수정한 것이죠.
즉 하위 View인 Cell에 있는 Word의 studyState는 업데이트 되기 때문에 하위 View의 배경색이 업데이트 됩니다. 반면에 상위 View에서는 @Published를 업데이트하지 않으므로서 해당 Cell을 제외한 다른 Cell들을 굳이 업데이트하지 않습니다.
private var rawWords: [Word] = [] //👉 DB에서 fetch 해온 원본
@Published var words: [Word] = [] //👉 View에서 사용하는 배열
func updateStudyState(word: Word, state: StudyState) {
guard let wordBookID = wordBook.id else { return }
guard let wordID = word.id else { return }
WordService.updateStudyState(wordBookID: wordBookID, wordID: wordID, newState: state) { [weak self] error in
if let error = error { print(error); return }
guard let self = self else { return }
//✅ rawWords만 수정한다.
// words까지 수정하면 전체 list가 re-render되므로 낭비 (어차피 cell color는 WordCell 객체가 처리하니까)
guard let rawIndex = self.rawWords.firstIndex(where: { $0.id == wordID }) else { return }
self.rawWords[rawIndex].studyState = state
// 다만 틀린 단어만 모아볼 때이고 state가 success일 때는 View에서 제거해야하니까 filtering해서 words에 반영해야 한다.
if self.studyMode == .excludeSuccess && state == .success {
self.filterWords()
}
}
}
문제의 원인은 위와 같은 방식에 있습니다. “모아보기 화면으로 나가면” body가 re-render되는 것을 break point를 통해서 알아냈습니다. 문제는 re-render될 때 body에 연결된 @Published words의 데이터로 re-render된다는 것입니다.
위 코드에서 보듯이 words는 studyState가 업데이트된 것이 반영되지 않은 상황입니다. 따라서 업데이트 된 내용이 초기화되는 것처럼 보이는 것이죠.
자 그렇다면 화면이 사라질 때 rawWords에 반영된 업데이트 내용을 words에 반영해주면 됩니다. 아래와 같은 함수를 통해서요.
func updateWordState() {
for i in 0..<words.count {
guard let newState = rawWords.first(where: { $0.id == words[i].id })?.studyState else { continue }
words[i].studyState = newState
}
}
문제는 이 함수를 어디에서 실행할 것인가입니다. 단순하게 화면이 사라지는 것이니 onDisappear에서 실행하면 되지 않을까 생각하실 수도 있겠지만 아닙니다. onDisappear에서 위 코드를 사용해도 버그는 해결되지 않습니다.
onDisappear는 View의 생명주기에 대한 메소드입니다. 하지만 “모아보기 화면으로 나가기"는 View 보다 한 단계 다 큰 개념인 Scene의 생명주기와 관련된 것입니다. 따라서 우리가 사용할 방법은 scenePhase입니다.
scenePhase라는 환경 변수를 사용하게 되면 Scene의 생명 주기에 따라 원하는 코드를 실행할 수 있습니다. on Change를 통해서 scenePhase의 변화를 감지하고 active가 아닐 때마다 함수를 통해서 words의 StudyState를 업데이트 합니다.
@Environment(\.scenePhase) var scenePhase
// View에 onChange 메소드를 적용
.onChange(of: scenePhase) { if $0 != .active { viewModel.updateWordState() } }
enum인 scenePhase 총 3가지의 case가 있습니다.