Tunko Development Diary

SwiftUI) Null(nil) Object Pattern 활용하기 본문

Development/SwiftUI

SwiftUI) Null(nil) Object Pattern 활용하기

Tunko 2023. 9. 10. 23:56

SwiftUI) Null(nil) Object Pattern 활용하기

SwiftUI에서 Null Object Pattern을 적극 활용해보는 방법을 남겨봅니다.

Swift에선 nil Object Pattern 이라고 해야 되나 고민…🤔

실제 사용해보니 너무 유용하고? 옵셔널의 지옥에서 빠져나올 수 있고 특히 데이터모델과 View가 혼연일체? 되어있는 SwiftUI에서는 적극 사용중입니다.

우선 Null Object Pattern이란?

Null Object Pattern은 객체 지향 디자인 패턴 중 하나로, 객체가 null일 때의 동작을 처리하는 데 사용됩니다.

이 패턴의 주요 목적은 클라이언트 코드에서 null 검사를 제거하여 코드의 복잡성을 줄이는 것입니다.

Null Object Pattern은 클라이언트가 예상하는 인터페이스를 가진 객체로, 실제 객체의 행동을 모방하지만 아무런 행동이 없는 (즉, 아무 것도 하지 않는) 객체를 제공합니다.

이 패턴은 클라이언트 코드에서 null 검사나 예외 처리를 피할 수 있도록 도와줍니다. 대신, Null Object는 정상적인 객체와 동일한 방식으로 동작하며, 클라이언트는 이 객체가 Null Object인지 알 필요가 없습니다.

자. 복잡한 설명은 끝났으니 코드를 한번 봅시다.

기본형태를 가지고있는 모델을 만들었고, 해당 모델을 사용하는 ContentView 가 있습니다.

struct Model {
    let stringValue: String
    let intValue: Int
    let doubleValue: Double
    let boolValue: Bool
    let arrayValue: [String]
    let dictionaryValue: [String: Any]

    init(stringValue: String, intValue: Int, doubleValue: Double, boolValue: Bool, arrayValue: [String], dictionaryValue: [String : Any]) {
        self.stringValue = stringValue
        self.intValue = intValue
        self.doubleValue = doubleValue
        self.boolValue = boolValue
        self.arrayValue = arrayValue
        self.dictionaryValue = dictionaryValue
    }
}

struct ContentView: View {
    let model: Model = .init(stringValue: "stringValue",
                             intValue: 1,
                             doubleValue: 0.1,
                             boolValue: false,
                             arrayValue: ["🍓",
                                          "🍐",
                                          "🍊"],
                             dictionaryValue: ["딸기":"🍓",
                                               "배":"🍐",
                                               "오렌지":"🍊"])

    var body: some View {
        VStack(alignment: .leading) {
            Text("stringValue :     \(model.stringValue)")
            Text("intValue :        \(model.intValue)")
            Text("doubleValue :     \(model.doubleValue)")
            Text("boolValue :       \(String(describing: model.boolValue))")
            Text("arrayValue :      \(model.arrayValue.description)")
            Text("dictionaryValue : \(model.dictionaryValue.description)")
        }
        .padding()
    }
}

위 코드는 아무런 문제가 없습니다.

하지만 model 객체에 nil의 가능성이 추가 되면 문제가 생깁니다.

옵셔널로 wrapped된 값을 사용하기위해선 unwrapped해줘야 합니다.
따라서 아래와 같이 작성해줍니다.

var body: some View {
    VStack(alignment: .leading) {
        Text("stringValue :     \(model?.stringValue ?? "")")
        Text("intValue :        \(model?.intValue ?? 1)")
        Text("doubleValue :     \(model?.doubleValue ?? 0.1)")
        Text("boolValue :       \(String(describing: model?.boolValue))")
        Text("arrayValue :      \((model?.arrayValue ?? []).description)")
        Text("dictionaryValue : \((model?.dictionaryValue ?? [:]).description)")
    }
}

느껴지십니까? 지저분합니다… 한줄에 ?만 3개씩 달리고 여러 처리를 해주어야 합니다.
물론 이또한 장점(?)은 있다고 할 수 있습니다.

해당 model 내에 특정한 값을 사용할때 그에 대한 예외를 별게로 처리할 수 있습니다.
하지만 가독성은 포기해야 합니다.

하지만 이런 방법도 있습니다.

View내부에서 if let 을 사용해서 옵셔널을 제거하는 방법도 있습니다.

struct ExampleView: View { 
    private let model: Model?
    init(model: Model) {
        self.model = model
    }
    var body: some View {
        if let model = self.model {
            VStack(alignment: .leading) {
                Text("stringValue :     \(model.stringValue)")
                Text("intValue :        \(model.intValue)")
                Text("doubleValue :     \(model.doubleValue)")
                Text("boolValue :       \(String(describing: model.boolValue))")
                Text("arrayValue :      \((model.arrayValue).description)")
                Text("dictionaryValue : \((model.dictionaryValue).description)")
            }
        }
    }
}

위 코드의 변화된 점은

private let model: Model? 옵셔널 객체로 전환하고

if let model = self.model 로 if 처리를 했습니다. 이렇게 사용해도 사실 무방합니다. 하지만. View 코드에 if가 들어가게 됩니다. SwiftUI에선 View와 Model은 찰떡처럼 붙어다닙니다. 아닐때도 있지만 대부분이 그렇습니다.

매번 if let을 통해 옵셔널을 언랩핑해주거나

view에서 필요하는 value를 사용할때마다 ?? 을 사용하여 nil일때에 대한 처리를 하게됩니다.

여간 귀찮은게 아닙니다. 🥵🥵🥵🥵🥵

이때 사용하기 좋은 패턴이 이 글 주제인 Null Object Pattern 입니다.

바로 아래 코드입니다.

struct Model {
        static let `defulat`: Model! = Model(stringValue: "기본값",
                                         intValue: 0,
                                         doubleValue: 0,
                                         boolValue: false,
                                         arrayValue: ["☠️"],
                                         dictionaryValue: ["해곻":"☠️"])
    let stringValue: String
    let intValue: Int
    let doubleValue: Double
    let boolValue: Bool
    let arrayValue: [String]
    let dictionaryValue: [String: Any]

    init?(stringValue: String,
          intValue: Int,
          doubleValue: Double,
          boolValue: Bool,
          arrayValue: [String],
          dictionaryValue: [String : Any]) {
        self.stringValue = stringValue
        self.intValue = intValue
        self.doubleValue = doubleValue
        self.boolValue = boolValue
        self.arrayValue = arrayValue
        self.dictionaryValue = dictionaryValue
    }
}

struct ExampleView: View {
    private let model: Model
    init?(model: Model?) {
        guard let model else { return nil }
        self.model = model
    }

    var body: some View {
        VStack(alignment: .leading) {
            Text("stringValue :     \(model.stringValue)")
            Text("intValue :        \(model.intValue)")
            Text("doubleValue :     \(model.doubleValue)")
            Text("boolValue :       \(String(describing: model.boolValue))")
            Text("arrayValue :      \((model.arrayValue).description)")
            Text("dictionaryValue : \((model.dictionaryValue).description)")
        }
    }
}

struct ContentView: View {
    var body: some View {
        ExampleView(model: .init(stringValue: "stringValue",
                                 intValue: 1,
                                 doubleValue: 0.1,
                                 boolValue: false,
                                 arrayValue: ["🍓",
                                              "🍐",
                                              "🍊"],
                                 dictionaryValue: ["딸기":"🍓",
                                                   "배":"🍐",
                                                   "오렌지":"🍊"]))
    }
}

위 방식의 키포인트는 실패 가능한 초기화 구문 (Failable Initializers)입니다.

공식문서 링크를 납깁니다.

초기화 (Initialization)

위 모델을 보면 init() 이 아닌 init?() 으로 되어있습니다.

즉 nil이 될 수 있는 객체를 생성하고 ExampleView 에서 해당 model을 주입받아 원하는 View를 생성합니다.

위처럼 사용하게 되면

ExampleView 뷰가 주입 받은 Model에 대한 안정성이 보장됩니다. Model객체가 잘못 들어올일은 없을 수 있지만 nil이여도 View가 그려지는 불상사는 절대 일어나지 않습니다.

두번째 방법으로 Model자체에 기본값(defulat)을 가진 객체를 넣어줍니다.

struct Model {
    static let `defulat`: Model! = Model(stringValue: "기본값",
                                         intValue: 0,
                                         doubleValue: 0,
                                         boolValue: false,
                                         arrayValue: ["☠️"],
                                         dictionaryValue: ["해곻":"☠️"])

    let stringValue: String
    let intValue: Int
    let doubleValue: Double
    let boolValue: Bool
    let arrayValue: [String]
    let dictionaryValue: [String: Any]

    init?(stringValue: String,
          intValue: Int,
          doubleValue: Double,
          boolValue: Bool,
          arrayValue: [String],
          dictionaryValue: [String : Any]) {
        self.stringValue = stringValue
        self.intValue = intValue
        self.doubleValue = doubleValue
        self.boolValue = boolValue
        self.arrayValue = arrayValue
        self.dictionaryValue = dictionaryValue
    }
}

struct ExampleView2: View {
    let model: Model? = Model(stringValue: "stringValue",
                              intValue: 1,
                              doubleValue: 0.1,
                              boolValue: false,
                              arrayValue: ["🍓",
                                           "🍐",
                                           "🍊"],
                              dictionaryValue: ["딸기":"🍓",
                                                "배":"🍐",
                                                "오렌지":"🍊"])

    @ViewBuilder
    func example(model: Model) -> some View {
        VStack(alignment: .leading) {
            Text("stringValue :     \(model.stringValue)")
            Text("intValue :        \(model.intValue)")
            Text("doubleValue :     \(model.doubleValue)")
            Text("boolValue :       \(String(describing: model.boolValue))")
            Text("arrayValue :      \((model.arrayValue).description)")
            Text("dictionaryValue : \((model.dictionaryValue).description)")
        }
    } 

    var body: some View {
        example(model: model ?? .defulat)
    }
}

원본의 model 데이터는 옵셔널 객체로 전달 될 수 밖에 없다는 가정을 했을때 코드입니다.

model이 nil일때 기본 모델의 기본값을 미리 설정해주면서 잘못된 데이터가 들어와 model을 생성하지 못하더라도 View을 그릴 수 있도록 해줍니다.

마지막으로 데이터의 유효성을 검증 할 수 있습니다.
이전 이 주제와 관련된 내용은 아니지만 조금만 변경하면 가능하기에 팁처럼 남겨봅니다.

struct Model {
    static let `defulat`: Model! = Model(stringValue: "기본값",
                                         intValue: 0,
                                         doubleValue: 0,
                                         boolValue: false,
                                         arrayValue: ["☠️"],
                                         dictionaryValue: ["해곻":"☠️"])

    let stringValue: String
    let intValue: Int
    let doubleValue: Double
    let boolValue: Bool
    let arrayValue: [String]
    let dictionaryValue: [String: Any]

    init?(stringValue: String,
          intValue: Int,
          doubleValue: Double,
          boolValue: Bool,
          arrayValue: [String],
          dictionaryValue: [String : Any]) {
        guard stringValue.count > 0 else { return nil }
        guard intValue > 100 else { return nil }
        guard arrayValue.count > 0 else { return nil }
        self.stringValue = stringValue
        self.intValue = intValue
        self.doubleValue = doubleValue
        self.boolValue = boolValue
        self.arrayValue = arrayValue
        self.dictionaryValue = dictionaryValue
    }
}

초기화시 데이터 유효성 검증 코드를 추가할 수 있습니다.

guard stringValue.count > 0 else { return nil }
guard intValue > 100 else { return nil }
guard arrayValue.count > 0 else { return nil }

클라이언트 개발중 백엔드 API에서 데이터를 받아서 표현하는일이 주를 이룹니다.
하지만 어떨때는 데이터가 내려오지 않을때도 있고 데이터는 정상적으로 받아도 파싱과정에서 문제가 생길 수 있습니다.

최종적으로는 데이터를 표현해주는 View에서 공백이 보이든지 해당 값을 사용한 비지니즈 로직자체에 문제가 생기든지 다양한 버그에 원인이 될 수 있습니다.

이런 상황에서 Null Object Pattern은 기본적으로 View를 더미로 된 값으로라도 화면에 나타나게 해주거나 데이터 무결성을 클라이언트 내부에서도 체크할 수 있게 됩니다.

감사합니다. 🧑🏻‍💻

반응형
Comments