ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI View 안에서 직접 발행, 구독 관리 방법
    Apple🍎/Combine 2025. 3. 13. 20:47

    배경 

    최근에 SwiftData를 사용해서 프로젝트를 진행하였는데요. 

    데이터의 읽을 때는 @Query를 사용하면 SwiftUI 뷰 라이프 사이클에 맞춰 알아서 조회를 해주고 

    변경사항이 생기면 알아서 변경사항을 감지했다가 이를 db에 반영해 주니

    뷰에 보여줄 정보를 관리하기 위한 별도의 viewModel 계층이 굳이 필요 없겠다는 생각이 들었습니다. 

    너무 과도한 계층 분리의 쓸모에 대해 생각하던 와중이었기 때문에 

    "아예 ViewModel을 두지 않고 View에서 모든 걸 처리하자"를 콘셉트로 프로젝트를 진행하였습니다. 

    문제 상황

    특정 프로퍼티를 변경을 감지하는 로직이 필요했기 때문에 Combine을 이용해 발행자와 onChange를 조합하여 사용하려고 했습니다. 

    따라서 발행자와 구독 관리가 필요해졌는데 평소 같으면 ViewModel(class)에서 이를 진행하였으나 이번에는 ViewModel을 두지 않았기 때문에 View(struct)에서 직접 이를 수행하려 했습니다. 

    struct DraftEditorPanel: View {
        private let titleChangedPublisher = PassthroughSubject<Void, Never>()
        private var cancellables = Set<AnyCancellable>()
        
        func setupPublishers() {
            titleChangedPublisher
                .sink { ... }
                .store(in: &cancellables) // 오류: Cannot pass immutable value as inout argument
        }
    }

    View 등장 시 onAppear에서 발행자를 설정해주고자 했기 때문에 이를 위한 setupPublishers를 메서드를 작성하던 와중에 다음과 같은 오류가 발생했습니다. "Cannot pass immutable value as inout argument" 

    처음에는 "평소에 Combine 프레임워크를 사용하면서 항상 쓰던 패턴인데 뭐가 문제지?"라고 생각했습니다. ( 여긴 View 자나 바보야 )

    상황 분석 

    Swift의 구조체(Struct)와 불변성

    값 타입은 한번 생성된 이후 변경되지 않으며 구조체 또한 값 타입이기 때문에 한번 인스턴스가 만들어진 이후에는 해당 인스턴스가 변경되지 않음을 가정합니다. 따라서 구조체 인스턴스의 메서드 내에서 해당 구조체의 프로퍼티를 변경하려면 메서드를 mutating으로 표시해야 합니다.

    struct Counter {
        var count: Int
        
        // mutating 키워드가 있어야 count를 변경할 수 있음
        mutating func increment() {
            count += 1
        }
        
        // 일반 메서드에서는 프로퍼티 변경 불가
        func printCount() {
            // count += 1  // 이 줄은 컴파일 오류 발생
            print(count)
        }
    }
    

     

    SwiftUI의 View 프로토콜을 구현하는 구조체 또한 일반 메서드를 통해서는 해당 구조체의 프로퍼티를 변경할 수 없습니다.

    inout 매개변수란?

    inout 매개변수는 함수가 호출되는 동안 변경될 수 있고, 그 변경사항이 함수 호출이 끝난 후에도 유지되는 매개변수입니다.

    함수에서 inout 매개변수를 사용할 때는 호출 시 변수 앞에 & 기호를 붙여 "이 변수는 함수 내에서 수정될 것이다"라고 컴파일러에게 알려줍니다.

    func increment(value: inout Int) {
        value += 1
    }
    
    var number = 5
    increment(value: &number) // number는 이제 6
    

    문제의 핵심: store(in: &cancellables)

    Combine 프레임워크에서 .store(in: &cancellables) 메서드는 구독 객체를 cancellables 컬렉션에 추가합니다.

    여기서 set은 inout 매개변수입니다. 따라서 이 메서드는 매개변수로 전해 받은 변수에 직접 접근하여 값을 변경합니다. 

    충돌 지점: 구조체 내부에서 inout 사용하기

    struct DraftEditorPanel: View {
        private var cancellables = Set<AnyCancellable>()
        
        func setupPublishers() {
            titleChangedPublisher
                .sink { ... }
                .store(in: &cancellables) // 오류 발생!
        }
    }
    

    이 코드가 컴파일 오류를 일으키는 이유는

    1. DraftEditorPanel은 구조체이므로 기본적으로 불변입니다.
    2. setupPublishers() 메서드는 mutating으로 표시되지 않았으므로, 구조체의 프로퍼티를 변경할 수 없습니다.
    3. .store(in: &cancellables)는 cancellables를 inout 매개변수로 전달하려고 합니다.
    4. inout 매개변수는 함수 내부에서 변경될 수 있어야 하는데, 불변 구조체 내에서는 프로퍼티를 변경할 수 없습니다.

    간단히 말해, 메서드가 구조체의 프로퍼티인 컬렉션(Set)을 수정(inout을 통해)하려 하지만 불변 구조체 내에 있어서 이 속성을 수정할 수 없는 상황인 것입니다. 

    이 문제를 해결하는 방법

    이 문제의 가장 간단한 해결책은 @State 속성 래퍼를 사용하는 것입니다.

    struct DraftEditorPanel: View {
        @State private var cancellables = Set<AnyCancellable>()
        
        func setupPublishers() {
            titleChangedPublisher
                .sink { ... }
                .store(in: &cancellables) // 이제 작동!
        }
    }
    

    @State는 SwiftUI에게 이 속성의 실제 저장소를 뷰 구조체 외부의 다른 곳에 관리하도록 지시합니다. 이렇게 하면 뷰 구조체가 불변이더라도, 실제 데이터는 참조 타입으로 관리되므로 변경이 가능해집니다. 따라서 @State 변수를 inout 매개변수로 전달하는 것은 특별히 허용됩니다.

     

    이것은 마치 실제 데이터는 구조체 바깥의 안전한 상자에 보관하고, 구조체는 그 상자를 가리키는 포인터만 가지고 있는 것과 같습니다. 포인터 자체는 변경되지 않더라도, 포인터가 가리키는 상자 안의 내용물은 변경될 수 있습니다.

     

    평소에 ViewModel(클래스)에서 사용하던 발행 - 구독 패턴을 아무 생각 없이 View(struct)에 적용해 발생한 문제였습니다. 이걸 해결하는 과정에서 struct과 inout 파라미터와 같은 기본적인 내용들을 다시 정리하며 View 프로토콜을 채택한 struct이 SwiftUI 프레임워크에 의해 어떻게 관리되고 있는지 알 수 있었습니다. 

    댓글

Designed by Tistory.