[수위프트UI/번역] 코드로 알아보는 @Published의 사용법과 위험성

@Published는 SwiftUI의 프로퍼티 래퍼 중 하나로, 변화가 발생하면 뷰를 다시 그릴 수 있게 트리거해주는 역할을 합니다. @PublishedObservableObject 프로토콜(원문, 번역)과 함께 사용하는 경우도 있지만 보통의 클래스에도 사용하기도 합니다.

@Published 프로퍼티 래퍼는 willSet 트리거에 반응하기 때문에 제대로 이해한 후 사용하지 않으면 잘못된 결과로 이어지기 십상입니다. 오늘은 @Published 프로퍼티 래퍼의 작동 방식에 대해 깊이 알아보는 시간을 가져봅시다.

This article was originally written by Antoine van der Lee.
이 글은 Antoine van der Lee가 작성한 글의 번역본입니다. 원문 링크

@Published 프로퍼티 래퍼가 뭐죠?

@Published 프로퍼티 래퍼는 다음과 같이 보통의 프로퍼티 래퍼처럼 사용하면 됩니다.

final class ArticleViewModel {

    @Published
    var title: String = "An example title"
}

@Published는 Class-constrained 한데, 이는 클래스의 인스턴스에서만 사용할 수 있다는 것을 의미합니다. 구조체에서 사용하면 아래와 같이 에러가 발생합니다.

`@Published`는 클래스의 프로퍼티에만 사용할 수 있습니다.


@Published 프로퍼티 래퍼의 wrapped value는 해당 프로퍼티의 실제 값을 나타냅니다.

let viewModel = ArticleViewModel()
print(viewModel.title) // 출력값: An example title (이전 설명 코드 참조)

퍼블리셔의 projected value는 아래와 같이 변화를 관찰하는데 사용됩니다.

var cancellable = viewModel.$title.sink(receiveValue: { newTitle in
    print("Title changed to \(newTitle)")
})
viewModel.title = "@Published explained"

// 출력값
// Title changed to An example title
// Title changed to @Published explained

여기서 projected value에 접근하기 위해 달러 기호($)를 사용했습니다. 혹시 이 문법이 익숙하지 않으시다면 글쓴이의 다른 글인 Property Wrappers in Swift explained with code examples를 참고하시기 바랍니다. (아직 번역하지 못했습니다.)

receivedValue 클로저는 구독했을 때와 값이 바뀔 때 값을 보냅니다. 이 변화를 캐치하기 위해선 sink 연산자를 사용하면 됩니다. 간단한 예시로는 특정 레이블의 초기값과 미래의 업데이트를 모두 표시하고 싶을 때 사용할 수 있겠네요.

willSet 트리거를 이해하는 것이 중요합니다

여기까지 @Published 프로퍼티 래퍼의 기본을 알아보았습니다. 하지만 변화를 일으킬 트리거가 언제 발생하는지에 대해 이해하는 것도 필수입니다. 프로퍼티의 변화는 willSet 블록에서 발생하기 때문에 이 프로퍼티를 구독하고 있는 곳은 모두 값이 변하기 전에 변할거라는 사실을 알 수 있습니다.

이해를 위해 다음과 같은 코드 예시를 준비했습니다.

var cancellable = viewModel.$title.sink(receiveValue: { newTitle in
    print("Title changed to: '\(newTitle)'")
    print("ViewModel title is: '\(viewModel.title)'")
})
viewModel.title = "@Published explained"

// 출력값
// Title changed to: '@Published explained'
// ViewModel title is: 'An example title'

위 예시에서 보셨듯이 sink에선 새로운 타이틀을 정확히 불러온 반면, 뷰모델의 프로퍼티의 값은 여전히 바뀌기 이전 값인 것을 확인하실 수 있습니다.

이러한 차이점으로 인해 실제 서비스에서 예상하지 못한 버그로 이어지기 쉽습니다. 이런 버그는 레이블의 텍스트를 변경할 때도 발생할 수 있을 정도로 흔합니다.

override func viewDidLoad() {
    super.viewDidLoad()

    cancellable = viewModel.$title.sink(receiveValue: { [weak self] newTitle in
        // 변화가 발생했으니 레이블의 값을 변경합니다.
        self?.updateTitleLabel()
    })

    // 레이블의 초기값을 넣어줍니다.
    updateTitleLabel()
}

func updateTitleLabel() {
    titleLabel.text = viewModel.title
    print("Title label text is now: '\(titleLabel.text ?? "empty")'")
}

updateTitleLabel()은 레이블의 텍스트를 업데이트하는 코드를 담고 있습니다. 그런데 우리는 이 메소드를 sink 연산자 내부에서 호출하기 때문에, willSet 단계에서 호출됩니다. 위에서 설명드린 것처럼 willSet 단게의 값은 새로운 값이 아니라 바뀌기 전의 값이라 원하는 결과가 나오지 않습니다.

위의 예시 코드의 버그는 아래와 같이 수정하면 해결할 수 있습니다.

override func viewDidLoad() {
    super.viewDidLoad()

    cancellable = viewModel.$title.sink(receiveValue: { [weak self] newTitle in
        self?.titleLabel.text = newTitle
        print("Title label text is now: '\(self?.titleLabel.text ?? "empty")'")
    })
}

publish되는 타이틀 값을 사용하기 때문에, 우리는 가장 최근에 publish된 값을 지속적으로 표시할 수 있게 되었습니다. 게다가 값을 처음 넣을 때도 publish되기 때문에 레이블의 초기값도 걱정하지 않아도 됩니다.

didSet이 아니고 willSet일까요?

willSet대신 didSet을 사용하지 않는지 궁금해 하시는 분도 계실겁니다. willSet을 사용하는 이유는 SwiftUI가 새로운 뷰를 그리려면 과거의 상태(State)와 새로운 상태 사이에서 변경이 있는지 알아야 하기 때문입니다. willSet을 사용하면 과거의 상태에도 접근할 수 있기 때문에 willSet을 사용한다고 생각하시면 되겠습니다.

@Publihsed@ObservableObject의 관계

앞에서는 보통의 클래스에서 @Published 프로퍼티 래퍼를 사용하는 것에 대한 예시였다면, 이젠 ObservedObject에서 사용하는 경우를 설명드리겠습니다.

final class ArticleViewModel: ObservableObject {

    @Published
    var title: String = "An example title"
}

ObservedObject는 자신이 가지고 있는 @Published 프로퍼티가 변화하면 방출하는 objectWillChange 퍼블리셔를 기본으로 가지고 있는 특별한 종류의 프로토콜입니다.

이 특징은 다음과 같은 코드로 대표할 수 있겠네요.

let viewModel = ArticleViewModel()

viewModel.objectWillChange.sink { _ in
    print("Articles view model changed!")
}

viewModel.title = "@Published explained"

// 출력값
// Articles view model changed!

SwiftUI는 어떠한 변화가 생겼을 때 이를 뷰에 적용하기 위해 objectWillChange 퍼블리셔를 사용합니다. 이 문법에 관해 궁금하시다면 글쓴이의 지난 글인 @StateObject vs. @ObservedObject: The differences explained(원문, 번역)을 참고해주세요.

결론

클래스 내부에서 프로퍼티의 변화를 감지하려면 @Published 프로퍼티 래퍼를 사용하면 됩니다. 어떠한 새로운 값이라도 willSet 메소드를 통해 보내질겁니다. willSet이라서 직접 접근했을 때는 최신값을 받을 수 없다는 점을 기억하시구요. 그리고 ObservableObject 프로토콜은 @Published 프로퍼티 래퍼와 찰떡이라 SwiftUI의 뷰에 어떠한 변화를 일으키기에도 좋습니다.

번역한 글 외에도 원문을 찾아보거나 더 많은 SwiftUI 지식을 아고 싶다면 SwiftUI 카테고리를 참고해주시길 바랍니다. (물론 제가 번역한 글도 있습니다.) 혹시 글쓴이에게 추가적인 팁이나 피드백을 전달하고 싶다면 트위터를 통해 연락달라고 하네요.

오늘도 읽어주셔서 감사합니다.