반응형

[최종 수정일 : 2017.07.11]

원문 : https://www.swiftbysundell.com/posts/modelling-state-in-swift

Swift에서 상태 모델링하기(Modelling state in Swift)

앱을 만들고 시스템을 설계 할 때 가장 힘든 것중 하나는 상태를 처리하는 모델링 방법을 결정하는 것입니다. 코드 관리 상태는 앱의 일부에서 예상치 못한 상태로 끝이날때, 버그의 매우 일반적인 소스 입니다.

이번 주에, 상태 변경을 처리하고 반응하는 코드를 쉽게 작성할수 있는 몇가지 기술을 살펴 봅니다 - 더 견고하고 오류를 발생시키지 않아야 합니다. 이 글에서 특정 프레임워크나 더 큰 프레임워크를 사용하지 않으며, 앱 전체 아키텍쳐를 변경하지(RxSwift, ReSwift, ELM에 영감(inspired)을 받은 아키텍쳐) 않습니다. - 대신에 정말로 유용한 작은 팁, 트릭(tricks)과 패턴에 집중하고 싶습니다.

단일 소스에서 상태 처리(A single source of truth)

여러가지 상태를 모델링 할때 명심해야 할 핵심원칙 하나는 가능한한 single source of truth를 고수하려고 노력하는 것입니다. 이를 살펴보는 쉬운 방법중 하나는 현재 상태를 확인하기 위해 여러 조건(multiple conditions)들을 확인할 필요가 없다는 것입니다. 예제를 살펴 봅시다.

적(enemices)들이 게임중이거나 아닌 것을 결정하는것 뿐만아니라, 특정 체력 상태를 가지는 게임을 만들고 있다고 가정해 봅시다. 우리는 다음과 같이 Enemy 클래스에 두개의 프로퍼티를 사용하여 모델링 할 수 있습니다.

class Enemy {
    var health = 10
    var isInPlay = false
}

위쪽을 똑바로 보는 동안, 우리는 상태의 여러가지 원인을 가지는 상황에 쉽게 빠질수 있습니다. 적의 체력이 0에 도달하자마자, 그것은 게임에서 제외해야 합니다. 따라서 코드 어딘가에서, 그것을 처리 할 로직이 있습니다.

func enemyDidTakeDamage() {
    if enemy.health <= 0 {
        enemy.isInPlay = false
    }
}

새로운 코드 경로를 사용할때 위의 검사를 수행하는 것을 잊어버리는 문제가 발생합니다. 예를 들어, 플레이어에게 모든 적의 체력을 바로 0으로 만드는 특별한 공격을 할 수 있습니다.

func performSpecialAttack() {
    for enemy in allEnemies {
        enemy.health = 0
    }
}

위에서 볼수 있는 것처럼, 우리는 모든 적의 health 프로퍼티를 업데이트하였지만, isInPlay 업데이트 하는 것을 잊었습니다. 이것은 버그로 이어지고 정의되지 않는 상태로 끝나는 상황이 될수 있습니다.

이와 같은 상황에서, 다음과 같이, 여러개의 확인을 추가하여 문제를 해결할 수 있습니다.

if enemy.isInPlay && enemy.health > 0 {
    // Enemy is *really* in play
} else {
    // Enemy is *really* defeated
}

임시 해결책인 미봉책(band aid)으로 동작하는 동안, 우리가 더 많은 조건과 더 복작한 상태를 추가할때 쉽게 깨질것이고 코드를 읽기 어려워질 것입니다. 이에 대해서 생각한 경우, 위와 같이 작업을 하는 것은 방어적으로 코딩해야 하기 때문에, 우리 자신의 API를 신뢰하지 않는 것과 같습니다.

이 문제를 해결하고 단일 소스에서 상태를 처리하는 것을 확실히 하는 한 가지 방법은, Enemy클래스 내부의 isInPlay프로퍼티를 health프로퍼티에서 didSet을 사용해서 자동으로 업데이트 하는 것입니다.

class Enemy {
    var health = 10 {
        didSet { putOutOfPlayIfNeeded() }
    }

    // Important to only allow mutations of this property from within this class
    private(set) var isInPlay = true

    private func putOutOfPlayIfNeeded() {
        guard health <= 0 else {
            return
        }

        isInPlay = false
        remove()
    }
}

이 방법으로 우리는 적의 체력을 업데이트 하는 것에 대해서만 걱정하면 되고, isInPlay 속성은 항상 동기화된 상태로 유지됩니다.

유일한 상태로 만들기(Making states exclusive)

위의 Enemy 예제는 매우 간단했으며, 따라서 랜더링하고 그에 따라 반응하는 연관된 값을 가지는 더 복잡한 상태를 다루는 다른 하나를 살펴볼것입니다.

특정 URL에서 비디오를 다운로드 하고 시청할 수 있는, 동영상 플레이어를 제작한다고 가정해 보세요. 비디오를 모델링하기 위해, 다음과 같이 struct를 사용할 수 있습니다.

struct Video {
    let url: URL
    var downloadTask: Task?
    var file: File?
    var isPlaying = false
    var progress: Double = 0
}

위의 방법의 문제는 우리가 많은 옵션을 갖게 된다는 것이고, 모델 코드를 읽음으로써 비디오의 상태를 정확히 알수가 없습니다. 또한 일반적으로 입력하면 안되는 코드 경로가 포함되는 복잡한 처리를 작성해야 합니다.

if let downloadTask = video.downloadTask {
    // Handle download
} else if let file = video.file {
    // Perform playback
} else {
    // Uhm... what to do here? 🤔
}

이 문제를 다음과 같이 enum을 사용해서 매우 명확하고 유일한 상태로 해결합니다.

struct Video {
    enum State {
        case willDownload(from: URL)
        case downloading(task: Task)
        case playing(file: File, progress: Double)
        case paused(file: File, progress: Double)
    }

    var state: State
}

위에서 볼수 있듯이, 선택 가져오고, 모든 특정 상태 값은 사용하게 되는 상태에 편입되었습니다. 우리는 재생 정보에 대해 다른 상태를 도입함으로써 중복을 제거할 수 있습니다.

extension Video {
    struct PlaybackState {
        let file: File
        var progress: Double
    }
}

playing과 paused 모두 사용할 수 있습니다.

case playing(PlaybackState)
case paused(PlaybackState)

반응하여 랜더링하기(Rendering reactively)

하지만, 위와 같이 상태 모델링을 시작하는 경우, 상태 처리하는 코드를 작성해야 하며(위와 같이 if/else 문을 여러개 사용), 매우 보기 흉할 것입니다. 우리가 필요로 하는 모든 정보가 다양한 정보로 숨겨져(hidden)있으며, 꺼내기(get it out) 위해 switch 또는 if case let문이 많이 필요할 것입니다.

상태 열거형을 결합하는데 필요한 것은 반응형 상태 처리 코드 입니다. 예를 들어, 동영상 플레이어 뷰컨트롤러에서 액션 버튼을 업데이트 하는 코드를 어떻게 작성하는지 봅시다.

class VideoPlayerViewController: UIViewController {
    var video: Video {
        // Every time the video changes, we re-render
        didSet { render() }
    }

    fileprivate lazy var actionButton = UIButton()

    private func render() {
        renderActionButton()
    }

    private func renderActionButton() {
        let actionButtonImage = resolveActionButtonImage()
        actionButton.setImage(actionButtonImage, for: .normal)
    }

    private func resolveActionButtonImage() -> UIImage {
        // The image for the action button is declaratively resolved
        // directly from the video state
        switch video.state {
            // We can easily discard associated values that we don't need
            // by simply omitting them
            case .willDownload:
                return .wait
            case .downloading:
                return .cancel
            case .playing:
                return .pause
            case .paused:  
                return .play
        } 
    }
}

이제 동영상 상태가 바뀔때마다, 우리의 UI는 자동적으로 업데이트 될것입니다. 우리는 단일 소스에서 상태에 처리하고 있고, 정의되지 않은 상태가 없습니다. 상태가 변경될때 모든 UI 가 자동으로 업데이트 되도록 render 메소드를 확장할 수 있습니다.

func render() {
    renderActionButton()
    renderVideoSurface()
    renderNavigationBarButtonItems()
    ...
}

상태 변경 처리(Handling state changes)

랜더링은 하나만 하지만, 상태가 변경될때 보통 우리는 어떤 형태의 로직이 필요합니다. 우리는 다른 상태로 전환하거나 작업을 시작하고 싶을지도 모릅니다. 좋은 점은 로직을 수행하기 위해 랜더링을 하는 것과 똑같이 동일한 패턴을 사용할 수 있다는 것입니다.

video프로퍼티의 didSet에서 호출되는 handleStateChange메소드를 작성하며, 현재 어떤 상태에 있는지에 따라 다양한 로직을 실행합니다.

private extension VideoPlayerViewController {
    func handleStateChange() {
        switch video.state {
        case .willDownload(let url):
            // Start a download task and enter the 'downloading' state
            let task = Task.download(url: url)
            task.start()
            video.state = .downloading(task: task)
        case .downloading(let task):
            // If the download task finished, start playback
            switch task.state {
            case .inProgress:
                break
            case .finished(let file):
                let playbackState = Video.PlaybackState(file: file, progress: 0)
                video.state = .playing(playbackState)
            }
        case .playing:
            player.play()
        case .paused:
            player.pause()
        }
    }
}

정보 추출하기(Extracting information)

지금까지 우리는 랜더링과 상태처리를 모두 수행하기 위해 switch 문을 사용해 왔습니다. 합당한 이유 - 모든 상태와 모든 경우를 고려하도록 강요(forces)하고, 그것들 각각에 대한 적절한 로직을 작성합니다. 또한, 우리가 처리하지 않는 새로운 상태가 되면 컴파일러를 활용해서 오류를 줄수 있습니다.

하지만, 때때로 특정 상태에만 영향을 주는 매우 독특한 것이 필요합니다. 뷰 컨트롤러가 화면 밖으로 나가는 경우 진행중인 다운로드 작업을 취소해야 한다고 가정해 봅시다.

extension VideoPlayerViewController {
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)

        // Ideally, we'd like an API like this, that let's us cancel any ongoing
        // download task without having to write a huge switch statement
        video.downloadTask?.cancel()
    }
}

위와 같이 특정 프로퍼티에 접근 할수 있는 것이 매우 멋지고, 상태를 처리하기 위해 switch문을 항상(always) 사용하기로 결정한 한 경우, 작성해야 하는 많은 상용구를 제거하는데 도움이 될수 있습니다.

따라서 그렇게 되도록 만들어 봅시다! 이를 위해 Swift의 guard case let 패턴 일치 구문을 사용해서 진행중인 다운로드 작업을 가져오는 Video의 확장을 간단하게 만듭니다.

xtension Video {
    var downloadTask: Task? {
        guard case let .downloading(task) = state else {
            return nil  
        }

        return task
    }
}

결론(Conclusion)

상태 처리를 할때 묘책(silver bullets)은 없으며, 모호성을 제거하고 명확하게 정의된 상태를 적용하는 방법으로 모델링하면 더 강력한 코드가 될것입니다.

반응형 형식으로 단일 소스에서 상태를 처리하고 상태 변경을 처리하는 것은 일반적으로 읽기 쉽고 추론가능한 코드를 작성할 수 있고, 또한 확장이나 리펙토링하기 더 쉽습니다(case를 추가하거나 제거하기만 하면 되고, 컴파일러는 업데이트를 해야 하는 코드를 알려줍니다).

이 글에서 언급한 해결책과 팁은 확실한 상충관계(tradeoffs)가 있으며, 더 많은 상용구 코드를 작성하도록 요구되고, 상태 열거형에 대한 Equatable 구현은 때로는 다소 까다로울수 있습니다(나중에 코드 생성과 스크립트를 사용해서 더 쉽게 작성하는 방법을 살펴볼 것입니다).

어떻게 생각하나요? 이 글에서 언급한 기술 중 일부를 이미 사용하고 있거나 시험해 볼것인가요? 다른 질문이나 의견이 있으면 알려주세요.

반응형
Posted by 까칠코더
,