Neoself의 기술 블로그

EnvironmentObject로 인한 SwiftUI 뷰 재구성 이슈와 해결 과정 본문

개발지식 정리/Swift

EnvironmentObject로 인한 SwiftUI 뷰 재구성 이슈와 해결 과정

Neoself 2024. 12. 2. 19:00

앱의 QA 과정 도중, TagEditView에서 태그를 수정하게될 경우, 로딩 인디케이터가 무한히 표시되며 다른 상호작용이 동작하지 않는 특이한 현상을 발견했습니다. 

로딩 인디케이터가 표시된 이후, 다른 상호작용에 의한 뷰업데이트가 진행되지 않았던 것을 미루어 보았을때, UI 업데이트를 책임지는 메인쓰레드를 다른 작업이 항시 점유하고 있다는 것을 예상해볼 수 있었습니다. 따라서 XCode에서 제공하는 Instrument 도구의 Time Profiler 기능을 활용해 메인쓰레드를 점유하는 작업이 무엇인지 프로파일링해보았습니다.

1. XCode Instrument 툴을 활용한 뷰 재구성 이슈의 시작점 파악

1.1 쓰레드 점유 작업 확인

그 결과, TagEditView에서 태그정보에 대한 변경이 완료되는 17초 지점 가량부터, CFRunLoopRun 작업이 무한히 반복되면서 메인 쓰레드를 점유하고 있다는 것을 확인하였습니다. 특히 하위 항목들 중에서 뷰 업데이트 작업과 관련된 layoutSubViews()과 UIHostingView.layoutSubViews() 등의 작업이 많은 비중을 차지하고 있는 것을 미루어 봤을때, 뷰가 재구성되는 것이 무한반복되면서 메인쓰레드를 계속 점유하고 있는 것임을 파악할 수 있었습니다.


여기서 SwiftUI 뷰가 재구성되는 주요 원인들은 아래와 같이 정리해볼 수 있습니다.

 

  • 상태 변화: @State, @StateObject, @ObservedObject, @Binding 등의 프로퍼티 래퍼로 선언된 값이 변경될 때
  • 환경 변화: @Environment나 @EnvironmentObject로 선언된 값이 변경될 때
  • 부모 뷰의 재구성: 상위 뷰가 재구성되면 SwiftUI의 선언적 특성상 뷰 계층 구조 전체가 다시 평가됨에 따라, 모든 자식뷰들도 재평가

 

가장 먼저 버그가 발발한 위치인 TagEditView를 중심으로 뷰 재구성 조건을 분석해보았습니다. 특히 하기 콜백함수 내부에 비동기 작업이 있는지 여부를 중심으로 콘솔 출력을 통해 디버깅을 진행하였습니다.

  • didSet: 메서드를 호출한 값의 변경이 실행 트리거
  • onChange: 특정 값이 변경될 때마다 호출
  • ViewModel의 init: 뷰모델이 재구성될때 호출

콜백함수 내부에 상태변화를 초래하는 작업이 동기로 진행될 경우 상태변경이 즉시 발생함에 따라 뷰가 한번만 재구성되지만, 비동기로 진행될 경우 상태 업데이트가 지연 발생하면서(여러 렌더링 사이클에 걸쳐 처리되면서) 아래와 같은 사이클이 계속 반복될 수 있기 때문입니다.

비동기작업 완료 -> 상태 업데이트 -> 뷰 재구성 -> 또다른 비동기 작업 시작

 

하지만, TagEditViewModel의 경우, 뷰가 재구성되더라도 객체의 수명주기를 유지하는 @StateObject가 적용되어있기에 원인이 아니였으며,  didSet, onChange 메서드 내부에 배치된 비동기작업과 메서드 자체 또한 콘솔 출력을 통해 뷰 무한재구성 현상의 트리거가 뷰와 뷰모델 내부에 있지 않음을 파악할 수 있었습니다.

 


1.2 비동기 작업 확인

그 다음에 제가 취한 접근 방향은 실행된 비동기 작업을 중심으로 방향을 좁혀보는 것이였습니다. 이를 위해 저는 Instrument의 Network 기능을 활용해 뷰의 무한재구성이 발생할때, 앱에서 어떤 네트워크 통신을 하는지 프로파일링 해보았습니다. 

위 사진은 Instrument의 Network 프로파일링 결과입니다. api/tag, 그리고 api/todo/: 기간 엔드포인트를 지닌 api 모두 호출 간에 9초 내외의 소요시간을 보이고 있습니다. 

 

"api/dailyStat/all/:기간" api의 단일요청 응답시간이 106.86ms인 것을 고려하면, API성능 자체가 긴 응답시간의 원인이라고 보기에는 어렵습니다. 다시 한번 보면, 응답시간이 9초를 넘기는 api들은 모두 각각 455, 452번 호출이 되고 있죠. 때문에, 응답시간 9초의 원인은 서버의 처리 능력이 아닌, 동시에 많은 요청을 처리하면서 발생하는 대기시간이라고 보는 것이 더 적절합니다.

 

이 프로파일링 과정을 통해 뷰 무한재구성 현상에 직결되는 비동기 작업은 서버로부터 태그정보들을 가져오는 메서드, 그리고 특정 날짜에 할당된 할일들을 모두 가져오는 메서드임을 알 수 있었으며, 이 2개 작업을 동시에 진행하는 로직을 보유하고 있는 HomeView 파일이 뷰 재구성 현상 발생의 시작점이라는 결론을 도출할 수 있었습니다.

로그 결과 TagEditView가 아닌 HomeView에서 뷰 무한 재구성이 진행되고 있음을 확인하였음

2. 뷰 무한 재구성 현상의 원인 분석

문제가 발생한 코드의 구조는 다음과 같습니다.

// HomeView
struct HomeView: View {
    @StateObject private var viewModel = HomeViewModel()
    @EnvironmentObject var appState: AppState
    
    var body: some View {
        // ...
        .onAppear {
            viewModel.getTags()  // 비동기 네트워크 요청
            viewModel.setDateToTodayAndScrollCalendar(proxy)
        }
    }
}

// TagEditViewModel
class TagEditViewModel: ObservableObject {
    func editTag(_ tag: Tag) {
        tagService.updateTag(tag)
            .sink { ... } receiveValue: { [weak self] updatedTagId in
                appState.showToast(.tagEdited)  // 환경 객체 변경
                handleRefresh()
            }
    }
}

TagEditView 내부의 로컬 상태값의 변화는 외부 View 재구성의 트리거가 되지 않습니다. 때문에, EnvironmentObject로 선언된 값이 변경되면서, 뷰 무한 재구성 현상이 발생하는 것임을 파악할 수 있었습니다. 흥미로웠던 점은 이 무한 재구성 현상에 onAppear 콜백함수가 관여하고 있다는 것이었는데요.

 

onAppear 콜백함수는 기본적으로 뷰가 화면에 처음 표시될때 호출됩니다. SwiftUI는 @State, @Published, @StateObject, @ObservedObject, @Binding 속성래퍼가 적용된 변수값이 변경될 때, 해당 변수에 의존하는 영역만을 감지하고 효율적으로 업데이트합니다. 때문에 일반적인 상황에서는 상태변수값의 변경만으로 뷰가 완전히 새로 표시되는 수준의 전체 재평가가 일어나지 않으며, onAppear 콜백도 재호출되지 않습니다.

 

하지만, 예외적인 케이스가 하나 있습니다. 부모 뷰가 재구성되어 자식 뷰가 새로 생성되는 경우입니다. 이때는 전체 뷰 계층이 재평가되면서 onAppear 콜백이 다시 호출될 수 있습니다.

struct ContentView: View {
    @EnvironmentObject var appState: AppState  // 여기서 환경객체 사용
    
    var body: some View {
        ZStack {
            MainContentView()  // 하위 뷰
            if let toast = appState.currentToast {  // 환경객체의 프로퍼티 사용
                CustomToast(toastData: toast)
            }
        }
        .onChange(of: appState.currentToast?.type) { ... }  // 환경객체의 변화 감지
    }
}

위 코드는 프로젝트의 최상단 뷰의 ContentView입니다. 해당 ContentView는 appState를 환경 객체로 구독하고 있으며, 이 상태값에 따라 CustomToast가 조건부 렌더링됩니다. 이로 인해 appState가 변경될 때마다 ContentView와 그 하위의 모든 뷰들이 재평가되는 상황이 발생합니다.

 

지금까지의 디버깅과정을 통해, 뷰의 무한 재구성 문제의 핵심 플로우를 아래와 같이 정리해볼 수 있었습니다.

 

  1. TagEditView에서 토스트 표시하면서 환경 객체인 AppState 변경
  2. AppState를 구독하고 있는 ContentView가 재평가됨에 따라, 전체 View 계층 재구성
  3. 네비게이션 스택에 여전히 활성 상태로 있는 HomeView도 재구성
  4. onAppear 콜백함수에 선언한 getTags()함수 호출

하지만 이 플로우만으로는 현상을 완전히 설명할 수 없었습니다. 토스트 표시로 인한 환경객체값 변경은 1번(엄밀히는 표시와 제거로 2번)만 발생하는데 반해, getTags는 무한히 호출되고 있었기 때문입니다. getTags 메서드가 환경 객체의 상태를 변경하거나 HomeView의 전체 재평가를 유발하는 로직을 포함했다면 설명이 되었겠지만, 실제로는 HomeView의 내부 상태값만 변경하고 있어 순환 호출의 원인을 제공하지 않고 있습니다.

 

삽질을 해본 결과, 무한호출의 원인은 다소 엉뚱한 곳에 있었는데요. 바로 onAppear 콜백함수의 선언 위치였습니다.

// 문제가 되는 구조
struct HomeView: View {
    var body: some View {
        VStack {
            header  // 여기에 onAppear 배치
            content
        }
    }
    
    private var header: some View {
        VStack {
            // ... header content ...
        }
        .onAppear {  // 하위 컴포넌트에 배치된 onAppear
            viewModel.getTags()
        }
    }
}

// 수정된 구조
struct HomeView: View {
    var body: some View {
        VStack {
            header
            content
        }
        .onAppear {  // 최상위 View에 onAppear 배치
            viewModel.getTags()
        }
    }
}

 

기존 코드의 경우, onAppear 콜백함수를 HomeView 내부의 특정 컴포넌트에 한정하여 선언하였던것이 원인이였습니다. 때문에, 뷰가 전체 재평가되지 않았음에도, 로컬 상태변수 변경에 의한 부분 업데이트로 인해 onAppear가 트리거되었던 것이죠. 

따라서, 최종 무한 뷰 재구성 현상은 아래와 같이 정리해볼 수 있었습니다.

  1. TagEditView에서 토스트 표시하면서 환경 객체인 AppState 변경
  2. AppState를 구독하고 있는 ContentView가 재평가됨에 따라, 전체 View 계층 재구성
  3. 네비게이션 스택에 여전히 활성 상태로 있는 HomeView도 재구성
  4. onAppear 콜백함수에 선언한 getTags()함수 호출
  5. getTags를 통해 HomeView 내부 상태변수값 변경
  6. HomeView 뷰 부분 업데이트되며, 특정 컴포넌트에 상속되어있던 onAppear 콜백함수 실행
  7. getTags() 재실행되며, 4번으로 돌아가는 순환 형성

다만 여기서 의문점이 드는것은 HomeView를 바라보면서 getTags를 호출할때에는 왜 뷰 무한재구성현상이 나타나지 않았는가입니다.

 

심지어 header 컴포넌트는 getTags가 변경하는 내부 상태변수값을 사용하지 않기 때문에 위 6번 과정 또한 일반적인 상황에서는 설명이 되지 않았습니다. 이 원인을 확실하게 파악하고자 뷰의 생명주기와 재구성 매커니즘을 설명하는 WWDC21의 "Demystify SwiftUI" 영상을 확인해보았지만, 위와 같은 상황에서의 뷰 재구성 현상에 대해서는 찾아볼 수가 없었습니다.

 

하지만, 테스트와 디버깅을 통해 도출한 사실을 통해 말씀드릴 수 있는 것은, 직접 뷰를 바라보며 로컬 상태변수값을 변경할 경우, SwiftUI가 정확히 로컬상태변수값을 사용하는 컴포넌트만 재렌더하게 됨에 따라 onAppear 호출 시점의 예상이 쉽지만, 다른 뷰에서의 환경객체 변경을 통해 간접적으로 로컬 상태변수값을 건드릴 경우, 뷰 재구성이 보다 보수적으로 이루어져 onAppear 호출이 더 민감하게 반응한다는 것입니다.


2. 해결방안

2.0 SwiftUI의 View 정체성을 활용한 해결

앞서 분석한 문제의 핵심이 환경 객체(AppState) 변경으로 인한 전체 뷰 계층 재구성과 이로 인한 onAppear 콜백의 재호출이었던 만큼, SwiftUI의 View 정체성 메커니즘을 활용해 우선 해당 문제를 해결해보고자 했습니다.

 

Apple 문서에서는 .id() modifier에 대해 다음과 같이 설명합니다:

The identity of a view helps SwiftUI determine whether to treat a view as the same view across multiple updates, or as a completely new view.

 

이는 SwiftUI가 뷰를 업데이트할 때 다음과 같은 프로세스를 따른다는 것을 의미합니다:

// Before: 암시적 식별로 인해 환경객체 변경 시 새로운 뷰로 인식
header // 새로운 뷰로 인식 -> onAppear 재호출

// After: 명시적 식별자로 인해 동일한 뷰로 인식
header.id("homeHeadergetTag") // 동일한 뷰로 인식 -> onAppear 미호출
ScrollViewReader { proxy in
            VStack {
                ...
            }
            .onAppear {
                viewModel.getTags() // 무한 재구성의 원인
                viewModel.setDateToTodayAndScrollCalendar(proxy)
            }
            
            .id("homeViewHeader") // 명시적 정체성 부여
        }

따라서, .id() modifier를 사용해 명시적 식별자를 부여하면, 환경 객체의 상태가 변경되더라도 SwiftUI는 해당 뷰를 동일한 뷰로 인식하게 됩니다.

 

명시적 식별자를 부여하면, SwiftUI의 diffing 알고리즘이 이를 활용하여 불필요한 뷰 재구성을 방지합니다. 특히 환경 객체의 상태 변경이 발생하더라도, ID가 동일한 뷰는 재생성하지 않고 기존 뷰를 재사용하게 됩니다. 이는 onAppear 콜백의 불필요한 재호출을 방지하고, 결과적으로 API 호출 순환을 차단하게 되겠죠.

 

하지만 이 접근 방식에는 몇 가지 문제가 있었습니다:

  1. 임시방편적 해결책 - 환경 객체의 상태를 변경하는 다른 뷰들에서도 매번 id 값을 부여해야 하는 번거로움이 있었습니다.
  2. 근본적 문제 해결 실패 - 토스트 로직과 앱 상태 관리가 강하게 결합된 구조적 문제를 해결하지 못했습니다.

이러한 한계를 인식하고, 더 근본적인 해결책으로 토스트 로직을 완전히 분리하고 캡슐화하는 접근 방식을 채택하게 되었습니다.

2.1 CLToast 라이브러리 분석을 통해 토스트 로직 캡슐화

https://github.com/ValseLee/CLToaster

 

GitHub - ValseLee/CLToaster: 🌿 iOS Library for Convenient Toast Message UI & Animation!

🌿 iOS Library for Convenient Toast Message UI & Animation! - ValseLee/CLToaster

github.com

토스트 로직 캡슐화 방향의 경우 지인분께서 추천해주신 CLToast 라이브러리가 큰 도움이 되어주었습니다.

 

이 라이브러리는 다음과 같은 특징을 가지고 있었습니다:

struct CLToastViewModifier: ViewModifier {
    @Binding var isPresented: Bool
    @State private var isPresenting: Bool = false
    let style: CLToastStyle
    
    func body(content: Content) -> some View {
        content.overlay {
            if isPresenting {
                CLToastModifiedView(style: style)
                    .transition(...)
                    .task { /* 자동 해제 로직 */ }
            }
        }
    }
}

 

ContentView에 직접 Toast관련 뷰를 조건뷰 렌더링했던 저와 달리 위 라이브러리에서는 ViewModifier를 활용해 캡슐화를 하였습니다. 또한 해당 뷰 수정자 내부에서 직접 토스트 상태를 관리하며 메인 View 계층 영향을 최소화하고자 하였습니다. 이는 명확한 책임 분리와도 직결되는 내용입니다.

 

위 라이브러리를 참고해, ToastManager를 아래와 같이 구현해볼 수 있었습니다.

2.1.1 appState에 있던 토스트 관련 로직 ToastManager 싱글톤 클래스로 분리

import SwiftUI
// 기존 토스트 표시 관련 로직이 포함되어있던 AppState 환경객체 클래스
class AppState: ObservableObject {
    // @Published private(set) var currentToast: ToastData? 제거
    static let shared = AppState()
    
    private init() {}
    // 제거
    // func showToast(_ type: ToastType, action: (() -> Void)? = nil) {
    //    currentToast = ToastData(type: type, action: action)
    //}
    
    // 제거
    //func removeToast(){
    //    currentToast = nil
    //}
}
// 토스트 로직만 담당하는 ToastManager 클래스 새로 구현
final class ToastManager: ObservableObject {
    static let shared = ToastManager()
    private init() {}

    @Published var toastPresented = false
    private(set) var currentToastData: ToastData?
    
    func show(_ type: ToastType, action: (() -> Void)? = nil) {
        currentToastData = ToastData(type: type, action:action)
        toastPresented = true
    }
    
    func dismiss() {
        withAnimation {
            toastPresented = false
            currentToastData = nil
        }
    }
}
우선 기존 AppState에서 토스트 관련 프로퍼티와 메서드를 제거해주었습니다. 그 이후에, 토스트 관련 로직만을 담당하는 ToastManager 싱글톤 클래스를 분리, 생성해주었습니다. 
 
 

2.1.2 토스트메시지 관련 로직을 관리하는 ToastViewModifier 추가

그 이후, CLToast의 접근 방식을 따라 커스텀 뷰 수정자를 구현한 후, 해당 뷰 수정자를 Toast 뷰 관련 로직과 상태를 정의 및 관리하는 컨테이너 개념으로 활용하고자 했습니다.
// 캡슐화가 잘 되지 않은 방식
struct ContentView: View {
    // 다른 앱의 상태를 관리하는 환경객체에서 토스트도 관리
    @EnvironmentObject var appState: AppState 
    
    var body: some View {
        ZStack {
            MainView()
            
            if let toast = appState.currentToast {
                CustomToast(toastData: toast)
                    .frame(maxHeight: .infinity, alignment: .top)
                    .padding(.top, 40)
                    .zIndex(1)
                    .opacity(isToastPresent ? 1 : 0)
                    .offset(y: isToastPresent ? 0 : -80)
                    .animation(.spring(duration:0.5),value:isToastPresent)
            }
        }
        .onChange(of: appState.currentToast?.type) { _, newToast in
            // 토스트 표시 로직
            // 애니메이션 로직
            // 자동 해제 로직
        }
    }
}

// ViewModifier를 통한 캡슐화
struct ContentView: View {
    @StateObject private var toastManager = ToastManager.shared
    
    var body: some View {
        ZStack {/* 메인 뷰 콘텐츠 */}
        .presentToast(
            isPresented: $toastManager.toastPresented,
            data: toastManager.currentToastData
        )
    }
}

// 뷰 수정자 연결을 위한 extension
extension View {
    func presentToast(
        isPresented: Binding<Bool>,
        data: ToastData?,
        onDismiss: (() -> Void)? = nil
    ) -> some View {
        return modifier(
            ToastViewModifier(
                isPresented: isPresented,
                data: data,
                onDismiss: onDismiss
            )
        )
    }
}

// 토스트 메시지 표시 관련 로직과 상태변수값을 관리하는 ToastViewModifier
struct ToastViewModifier: ViewModifier {
    @Binding var isPresented: Bool
    @State private var animation = false  // 내부 구현 상태
    
    func body(content: Content) -> some View {
        content.overlay {
            if isPresented {
                ToastView()
                    .opacity(animation ? 1 : 0)
                    .offset(y: animation ? 0 : -20)
                    .task {
                        // 모든 토스트 관련 로직이 여기에 캡슐화됨
                    }
            }
        }
    }
}

 

2.1.3 토스트메시지 표시 방식 변경

토스트관련 로직을 appState에서 담당하지 않게 됨에 따라, 토스트 사용 방식 또한 appState에 대한 구독없이 진행할수 있게 되었습니다.

// 기존 방식
appState.showToast(.tagEdited)

// 개선된 방식
ToastManager.shared.show(.tagEdited)

 

3. 성능 비교

토스트 표시 로직의 캡슐화가 View의 불필요한 재구성을 최종적으로 감소시켰는지 검증하기 위해, 개선 전후의 성능을 Time Profiler로 측정해보았습니다.

토스트 로직 분리 전
토스트 로직 분리 후

Xcode Instruments의 Time Profiler로 토스트가 표시되고 사라지는 약 2초 동안을 측정한 결과, 메인 스레드 작업의 누적 실행 시간이 개선 전 301ms에서 개선 후 42ms로 약 86% 감소한 것을 확인할 수 있었습니다. 스택 트레이스를 살펴보면, 개선 전에는 연쇄적으로 실행되었던 UIKit/SwiftUI Core 작업들의 비중이 개선 후에는 크게 감소하며 전체적인 작업 스택이 단순화된 것을 볼 수 있는데, 이는 누적 실행 시간이 감소한 원인이 단순히 View 재구성 횟수의 감소뿐만 아니라, View 재구성에 따라 연쇄적으로 실행되었던 하위 작업들의 감소에도 있다는 것을 의미합니다.

 

이를 통해 토스트 로직을 캡슐화한 구조적 개선이 실제 성능 향상으로도 이어졌음을 확인할 수 있었습니다.

4. 트러블슈팅 및 추가적인 구조 개선을 통해 배운 점

이 트러블 슈팅 과정을 통해 배운 점을 아래와 같이 정리해볼 수 있었습니다.

 

1. 책임 분리와 캡슐화의 중요성(SOLID 원칙의 중요성)

기존에 저는 boilerplate를 제거하고자 appState에 기존 토스트 표시 관련 로직 외에도, 게스트 모드 관리, 팝업 관리와 같이 여러 책임을 부여했었습니다. 하지만, 단일 객체가 많은 책임을 지게 됨에 따라, 해당 환경객체를 구독 및 사용하는 뷰가 점차 늘어나게 되었고, 의도치 않은 시점에 뷰가 재구성되면서 위 버그가 발생하게 되었습니다. 또한 appState의 책임이 지나치게 넓다보니, 버그가 발생한 이후에도 appState가 변경되는 트리거를 파악하기 어려웠습니다.

허나 appState 내부 토스트 관련 로직을 ToastManager로 분리하는 과정을 거치면서, 이전보다 View의 재구성 시점을 쉽게 예상할 수 있게 되었으며 이로 인해 디버깅 간의 리소스가 줄어드는 것을 직접 체감해볼 수 있었습니다. 

 

2. SwiftUI의 View 재구성 매커니즘

SwiftUI의 뷰 재구성은 생각보다 복잡한 매커니즘을 가지고 있다는 것을 디버깅 과정을 통해 확인할 수 있었습니다. SwiftUI는 기본적으로 뷰의 내용과 상태를 기반으로 뷰의 식별자를 암시적으로 생성하는데, 이는 때때로 예상치 못한 재구성을 유발할 수 있습니다.

특히 아래와 같은 상황에서 뷰의 재구성 양상이 달라집니다:

1. 명시적 ID가 없는 경우:

struct HomeView: View {
    @EnvironmentObject var appState: AppState
    
    var body: some View {
        VStack {
            header  // 암시적 식별로 인해 환경객체 변경 시 새로운 뷰로 인식
        }
    }
}

 

- 직접 보고 있는 View: 로컬 상태 변경 시 해당 상태를 사용하는 부분만 정확히 업데이트됩니다.

- Navigation Stack에 있는 View: 환경 객체를 통한 간접적 상태 변경 시, SwiftUI는 보수적으로 접근하여 더 광범위한 뷰 재구성을 유발합니다.

 

2. 명시적 ID가 있는 경우:

struct HomeView: View {
    @EnvironmentObject var appState: AppState
    
    var body: some View {
        VStack {
            header
                .id("homeHeader")  // 명시적 식별자를 통해 뷰의 정체성 보장
        }
    }
}

 

 

- 직접 보고 있는 View와 Navigation Stack에 있는 View 모두: 명시적 식별자를 통해 SwiftUI의 diffing 알고리즘이 동일한 뷰임을 인식하여 불필요한 재구성을 방지합니다.

- 환경 객체의 상태가 변경되더라도, ID가 동일한 뷰는 재생성되지 않고 기존 뷰를 재사용합니다.

이러한 차이는 SwiftUI의 View Identity 메커니즘에 기인합니다. 명시적 ID가 없을 경우, SwiftUI는 뷰의 내용과 상태를 기반으로 식별자를 자동 생성하지만, 이는 환경 객체 변경과 같은 간접적인 상태 변화에 취약할 수 있습니다. 반면 명시적 ID를 부여하면, SwiftUI는 해당 ID를 기반으로 뷰의 정체성을 일관되게 유지할 수 있습니다.

 

 

3. 환경 객체(@EnvironmentObject) 사용 시 주의점

- 직접 body에 환경객체를 사용하지 않고, 선언하는 것만으로도 View가 해당 객체의 모든 @Published 속성 변화를 구독하고, 재구성될 수 있습니다.

- 불필요한 구독은 예상치 못한 View 재구성을 일으킬 수 있음을 확인할 수 있었습니다.

때문에, 환경객체 선언의 남발은 지양해야하며, 실제로 필요한 View에서만 환경 객체를 선언 및 사용해줘야한다는 것을 확인했습니다.

 

4. Xcode Instruments 사용방법

디버깅 과정에서 아래와 같이 Xcode Instruments를 사용해보면서, 각 Instrument의 용도 및 사용시점을 이해할 수 있었습니다.

- Time Profiler: 메인 쓰레드 점유 작업 식별 및 누적 실행시간 확인

- Network 프로파일링: API 호출 패턴 분석

 

5. ViewModifier를 통한 UI 로직 캡슐화

CLToast 라이브러리를 분석해보면서, ViewModifier를 통해 로직을 캡슐화하고 사용하는 방법을 시도해볼 수 있었으며, 이를 컨테이너로 활용해 상태관리 로직을 메인뷰로부터 격리하고 불필요한 뷰 계층 재구성을 방지하는 접근방식 또한 배울 수 있었습니다.

 

5. 향후 개선 방향

양방향 데이터 바인딩의 한계와 MVI 아키텍처로의 전환 고려

MVVM 아키텍처를 사용하면서 아래 한계점들을 마주하게 됨에 따라, SwiftUI의 양방향 데이터 바인딩이 앱의 규모가 커질수록 상태 관리를 복잡하게 만들 수 있다는 것을 깨달았습니다. 

- 상태 변경의 출처 추적이 어려움

- 의도치 않은 상태 전파가 발생할 수 있음

 

위 문제를 해결하기 위해 단방향 흐름 아키텍처인 MVI의 도입을 향후 시도해보고자 합니다.

 

감사합니다.