ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Swift] Enum을 다양하게 활용해보자.
    Apple🍎/Swift 2023. 12. 11. 15:59

    모델이 가지는 상태값이 OR 일 때는 Struct대신 Enum을 쓰자.

    • 아래와 같은 채팅앱의 메세지가 가질 수 있는 유형은 다음과 같다.
      1. 참여 메시지 : “철수 님이 대화에 참여했습니다.”
      2. 일반 메시지 : “지금 롤 할 사람”
      3. 이모티콘
      4. 떠남 메시지 : 철수님이 대화방에서 떠났습니다.
      5. 작성중 메시지 : 작성중입니다…..

    위와 같은 메시지를 strcut를 통해 모델링 해보면 다음과 같이 작성할 수 있다.

    import Foundation
    
    struct  Message {
        let userId: String.      // 참여자 식별자(ID)
        let contents: String? // 메시지 내용
        let date: Date.           // 시간 
    
        let hasJoined: Bool.   // 참여
        let hasLeft: Bool.       // 떠남 
    
        let isEmogi: Bool       // 이모지 
        let isBeingDrafted: Bool.  // 작성중 
    }
    • 참여 메시지를 인스턴스로 만들면
    let joinMessage = Message(userId: "철수",  contents: nil, date: Date( ), hasJoined: true, hasLeft: false, isEmogi: false, isBeingDrafted: false)
    • 일반 메시지를 인스턴스로 만들면
    let textMessage = Message(userId: "철수",  contents: "아무도 없냐", date: Date( ), hasJoined: false, hasLeft: false, isEmogi: false, isBeingDrafted: false)

    메시지가 어떠한 유형인지를 구분하기 위해 struct내에 각 유형을 위한 property를 만들었다. 이에 따라 strcut가 너무많은 속성값을 가지며 하나의 메시지를 구성하는데 필요없는 값이 너무 많을 들어가게 된다.
    또한 property를 통해 메시지 유형을 구분하면 다음과 같이 struct를 통해 만들 수는 있지만 실제 상황에서는 로직상으로 말이 되지 않는 인스턴스가 만들어질 수 있다.

    let brokeMessage = Message(userId:"민지", contents: "야 삐졌냐", date: Date(), hasJoined : true, hasLeft: true, isEmogi: false, isBeingDrafted: false)

    -> 일반 메시지로 contents를 가지고 있으나 참여와 떠남을 동시에 나타내는 메시지 (말이 안되는 메시지)
    현실 상황에 존재할 수 없는 메시지이기때문에 앱 실행환경에서는 명백한 에러이지만 해당 인스턴스 자체는 문제가 없기 때문에 컴파일 타임에 걸리지 않는다. 따라서 에러의 발생 가능성이 있는 코드이지만 컴파일 타임에 잡지 못하고 런타임 환경까지 가봐야 한다. 이러한 불상사를 막기위해 strcut을 enum으로 바꿔 보자.

    import Foundation
    enum Message{
        case text
        case draft
        case join
        case leave
        case emogi
    }

    하나의 모델이 여러가지 유형이 될 수는 있지만 ‘동시’에 여러 유형을 가지지 못할 때
    메시지는 참여 메시지 또는 떠남 메시지가 될 수 있지만 참여메시지 이면서 떠남 메시지가 될 수 없을 때 를 “OR” 상황이라고 한다. ( 한번에 하나만 )
    OR 상황을 모델링 할때 struct 대신 enum을 사용하면 하나의 인스턴스가 무조건 하나의 유형만 되도록 강제하기 때문에 동시에 여러 유형을 갖는 에러를 피할 수 있다.
    또한 기존의 struct에서 유형을 구분하는 property(hasJoined, hasLeft, isEmogi, isBeingDrafted)외에 메시지의 내용을 나타내기위해 사용하던 property(id, contents, date)는 다음과 같이 튜플 연관값을 통해서 나타낼 수 있다.

    import Foundation
    enum Message {
        case text(userId: String, contents: String, date: Date)
        case draft(userId: String, date: Date)
        case join(userId: String, date: Date)
        case leave(userId: String, date: Date)
        case emogi(userId: String, date: Date)
    }
    • 유형의 중복사용을 피하면서 각 유형에 필요한 연관값만들을 이용해 적절한 인스턴스를 만들 수 있다.
    let textMessage = Message.text(userId: "철수", contents: "왜", date: Date( ))
    let joinMessage = Mesaage.join(userId: "민지", date: Date( ) )
    • switch 문을 이용해 유형을 구분해서 각 유형에 매칭하는 동작을 수행하는 메서드를 만들 수도 있다.
    // 각 case(유형)에 맞는 log 메시지를 찍는 메서드 
    func logMessage(message: Message) {
        switch message {
        case let .text(userId: id, contents: contents, date: date):
            print("[\(date)] User \(id) sends message: \(contents)")
        case let .draft(userId: id, date: date):
            print("[\(date)] User \(id) is drafting a message")
        case let .join(userId: id, date: date):
            print("[\(date)] User \(id) has joined the chatroom")
        case let .leave(userId: id, date: date):
            print("[\(date)] User \(id) has left the chatroom")
        case let .emogi(userId: id, date: date):
            print("[\(date)] User \(id) is sending emogi")
        }
    }
    logMessage(message: textMessage) // User "철수" sends message: 왜!
    logMessage(message: joinMessage) // User "민지" has joined the chatroom
    • 한가지 유형에만 관심이 있을 때 if case let 구문을 사용하면 switch문을 사용해 다른 유형에 대해 다루는 수고를 줄일 수 있다.
    // text 유형에 대해서만 log를 찍고 싶음 
    func logTextMessage(message: Message){
        if case let Message.text(userId: id, contents: contents, date: date) = message {
            print("Received: \(contents)")
        }
    }
    
    // 관심없는 enum의 연관값은 생략 가능 
    func logTextMessage(message: Message){
        if case let Message.text( _ , contents: contents, _) = message {
            print("Received: \(contents)")
        }
    }
    

    Enum을 사용하여 컴파일 타임에서의 유형 에러를 잡아 낼 수 있다는 것은 상당한 이점이 될 수 있다.

     

    Enum의 다형성 활용해보자.

    다형성이란 하나의 함수나, 메서드, 배열, 딕셔너리등을 다양한 타입에 대해 사용할 수 있는 것을 말한다.
    다형성이 주는 유연성을 활용해야할 때에 Enum 사용을 고려해보자.
    여러가지 type이 인스턴스들이 들어있는 배열이 있다고 해보자. ( Any 키워드 사용)

    let arr: [Any] = [Date(), "Why was six afraid of seven?", "Because...", 789]
    for element: Any in arr {
      // element is "Any" type
      switch element {
        case let stringValue as String: "received a string: \(stringValue)"
        case let intValue as Int: "received an Int: \(intValue)"
        case let dateValue as Date: "received a date: \(dateValue)"
        default: print("I am not interested in this value")
      }
    }

    여러가지 타입에 대해서 대처하기 위해 Any를 사용하게 되면 컴파일 타임에 어떤 타입이 될지 알 수 없다.
    런타임이 되어 실제 값을 받아봐야 타입이 결정되기 때문에 위험성이 따른다.
    서버에 요청을 보내고 결과값을 받을 때와 같이 어떤 결과가 나올지 확정적이지 않은 상태에서 Any를 활용해서 여러 타입에 대해 대응할 수 있지만 예상되는 결과의 수를 한정할 수 있는 경우에는 Enum을 사용해보자.
    Date와 Range 같이 서로 다른 두가지 타입을 하나의 배열에 내에서 처리해야하는 경우
    Any를 사용하기 보다

    1. 이 두 타입을 추상화한 enum을 만들고
    2. enum에 각각의 타입에 대한 case를 만든다음에
    3. case 내부에 연관값으로 해당 타입을 지정한다.

    -> Enum을 이용해 서로 다른 타입을 감싸 동시에 존재할 수 있도록 한다.
     

    • 같이 사용하고자 하는 타입을 연관값으로하여 enum으로 감싼다.
    enum DateType {
        case singleDate(Date)
        case dateRange(Range<Date>)
    }
    • 만든 enum으로 감쌌기 때문에 다형성으로 같은 타입으로 취급하여 동일한 배열 내에서 사용이 가능.
    let now = Date( )
    let hourFromNow = Date(timeIntervalSinceNow: 3600)
    
    let date: [DateType] = [ DateType.singleDate(now),DateType.dateRange(now..<hourFromNow]
    • switch문으로 각각의 경우에 따른 로직 전개 가능
    for dateType in dates {
        switch dateType {
        case .singleDate(let date) : 
            print("Date is \(date)")
        case .dateRange(let range):
            print("Range is \(range)")
        }
    }
    • 새로운 타입에 대해서 추가적으로 다루어야 할때 enum에 case만 추가해주면 되고
    enum DateType {
      case singleDate(Date)
      case dateRange(Range<Date>)
      case year(Int)                      
     }
    • 기존에 해당 enum을 사용하고 있던 로직들에 컴파일 오류를 띄워줘 새로운 타입에 대한 고려를 빼먹지 않고 해줄 수 있다.
    error: switch must be exhaustive
        switch dateType {
        ^
    
    // add missing case: '.year(_)'
     //   switch dateType {
    

     

    데이터를 계층화 할 때 서브 클래스 대신 Enum을 고려해보자.

    데이터를 계층화 할때 가장 먼저 고려하게 되는 것은 상속이다. 예를 들어 햄버거 가게를 모델링한다고 하면 FastFood라는 슈퍼클래스를 만들고 그 밑에 서브 클래스로 햄버거, 감자튀김, 콜라 등을 만들 수 있다. 이렇게 딱봐도 이치에 맞게 모델링할 수 있는 상황이면 이상적이지만 현실에서는 계층화해둔 모델에 들어 맞지 않는 경우가 생길 수 있다. 갑자기 사장이 오늘 부터는 생선구이 정식도 팔거야 이래 버리면 기존의 모델링 해둔 FastFood 밑으로 생선구이 정식을 넣을 수가 없다. 잠깐만 생각해봐도 정의하기 위해 필요한 속성이 기존과 많이 달라질 것 같지 않은가?

    현실의 객체간의 계층을 상속을 통해 모델링시 문제가 발생할 때는 enum의 사용을 고려해보자.
    이번에는 운동 기록 앱을 만드는 상황을 가정해보자. 달리기와 사이클 운동 기록이 가능하게 만들 것이다.
    이를 구현하기 위해 데이터 계층을 모델링을 다음과 같이 할 수 있을 것이다.

    • 달리기 struct
    struct Run {
        let id: String
        let startTime: Date
        let endTime: Date
        let distance: Float
        let onRunningTrack: Bool
    }
    
    • 사이클 struct
    struct Cycle {
    
        enum CycleType {
            case regular
            case mountainBike
            case racetrack
        }
        let id: String
        let startTime: Date
        let endTime: Date
        let distance: Float
        let incline: Int
        let type: CycleType
    }

    데이터를 계층화할 필요성이 생겨 운동이라는 상위 개념을 만들어 상속을 통해 해결했다.
     


    이후 앱에 푸쉬업 운동을 추가하고자 한다. 새로 추가하려는 운동의 속성을 확인해보니 먼저 만들었던 상위 계층의 운동의 속성과 달라 물려받지 못하는 속성들이 생긴다.

    class Pushups: Workout {          
        let repetitions: [Int]
        let date: Date
    }

    그렇다고 새로운 푸쉬업 운동을 하위 계층으로 넣기 위해서 아래와 같이 변경 한다면 상속을 통한 계층화가 의미가 없다. ( 상속을 통해 물려 받는 속성이 거의 없고 단순히 계층만 나눈다.)
     


    상속의 장점을 살리지 못하면서도 상속을 쓸바에는 Enum을 사용하여 상위개념으로 그룹하하고 밑의 case로서 개별 운동을 추가하는 것이 더 효과적일 것이다. 왜냐하면 이후 새로운 유형의 운동을 모델링할 때 상속 속성을 고려할 필요없이 온전히 해당 운동에 필요한 속성들만을 넣고도 여전히 계층화는 가능하기 때문이다.

    enum Workout {
        case run(Run)
        case cycle(Cycle)
        case pushups(Pushups)
    }
    let pushups = Pushups(repetitions: [22,20,10], date: Date())
    let workout = Workout.pushups(pushups)
    switch workout {
    case .run(let run):
        print("Run: \(run)")
    case .cycle(let cycle):
        print("Cycle: \(cycle)")
    case .pushups(let pushups):
        print("Pushups: \(pushups)")
    }
    enum Workout {
        case run(Run)
        case cycle(Cycle)
        case pushups(Pushups)
        case abs(Abs)              

    모델간의 계층을 나눌 필요가 있을 때 , 그룹을 지을 필요가 있을 때
    상위 개념에 포함되는 각각의 하위 객체들의 속성이 너무도 다르거나 이후 다른 하위 객체들이 추가될 가능성이 다분할 때 상속대신 enum의 사용을 고려해보자.
    (하위 객체들의 속성에 중복이 많고 이후 모델 계층에 변화가 없을 것으로 예상되는 경우에는 상속을 하자.)

     

    대수적 데이터 타입

    대수적 데이터 타입은 함수형 프로그래밍으로부터 나온 용어로서 sum type(합 타입)과 product(곱 타입)으로 나뉜다.
    맨 처음에 객체를 모델링할 때 모델이 가지는 상태값이 OR이 될 경우에는 Enum을 사용하고자 했는데
    Enum은 대표적인 sum type(합 타입) 으로 한번에 하나만 될 수 있기 때문이다.
    반대로 struct이나 tuple 의 경우에는 한번에 여러개의 상태값을 가질 수 있기 문에 product type이다.

    Sum type ( 합 타입)

    해당 타입으로 가질수 있는 값의 경우의 수를 셀 때 합 연산을 이용한다.

    enum Day {
        case monday
        case tuesday
        case wednesday
        case thursday
        case friday
        case saturday
        case sunday
    }

    -> enum Day는 될 수 있는 상태값의 수는 1 + 1 + 1 + 1 + 1 + 1 + 1 = 총 7개이다.

    enum Age {
        case known(UInt8)
        case unknown
    }

    -> 연관값으로 UInt8 (0~255)으로 총 256개의 경우의 수 + unknown 경우의 수 1개 = 총 257개

    Prouduct type (곱 타입)

    해당 타입으로 가질 수 있는 값의 경우의 수를 셀 때 곱 연산을 이용한다.

    struct BooleanContainer {
      let first: Bool
      let second: Bool
    }
    
    BooleanContainer(first: true, second: true)
    BooleanContainer(first: true, second: false)
    BooleanContainer(first: false, second: true)
    BooleanContainer(first: false, second: false)

    -> first, second는 Bool type으로 각각 true, false가 될 수 있으므로 2 * 2 = 총 4개의 경우의 수

    대수적타입을 이용한 데이터 모델링

    대수적 타입을 고려하면 각자의 어플리케이션에 맞춰 적절하게 데이터를 모델링할 수 있다.

    • 결재방식을 나타내는 Enum으로 총 3가지 경우가 있음
    enum PaymentType {
      case invoice
      case creditcard
      case cash
    }
    • 결재상태를 나타내는 struct으로 결재일, 반복여부, 결재방식 타입의 프로퍼티들을 가지고 있다.
    • 결재일(가능한 날짜) * 반복여부(yes or no) * 결재방식(invoice, creditcard, cash) 로
    • 가능한 경우의 수는 가능한 날짜 * 2 * 3
    struct PaymentStatus {
      let paymentDate: Date?
      let isRecurring: Bool
      let paymentType: PaymentType
    }

    Enum과 struct 총 2개의 타입을 이용해 데이터를 모델링한 것을 다음과 같이 하나의 Enum을 이용해서 바꿀 수 있다.
     

    • PaymentStatus의 연관값으로 struct의 PaymentStatus를 제외한 속성값들을 가지고 옴으로써 하나의 Enum만을 활용해서 데이터 모델링을 가능하게 만들었다.
    enum PaymentStatus {
      case invoice(paymentDate: Date?, isRecurring: Bool)
      case creditcard(paymentDate: Date?, isRecurring: Bool)
      case cash(paymentDate: Date?, isRecurring: Bool)
    }
    • PaymentStatus case 3개
    • 각 case에 연관값은 튜플의 형태로 : 가능한날짜 * 2 (true or false)
    • 대수적 타입을 따져 봤을 때 먼저 enum과 struct을 이용해서 모델링한 경우와가능한 값의 범위가 동일한것을 확인할 수 있다.
    • 장점 : enum과 struct 두개의 사용자 타입을 하나의 enum으로 합침으로써 데이터 구조가 단순해졌다.
    • 단점 : enum의 각 케이스의 연관값으로 값의 중복이 생겼다.

    대수적 타입을 고려하면서 데이터를 모델링하면
    첫번째로는 데이터가 가질 수 있는 값의 범위를 사전에 고려할 수 있기 때문에 이를 줄여서 안전성을 확보하는 노력을 기울일 수 있다.
    두번째로 데이터를 모델링할 때 합타입과 곱타입을 적절하게 활용하여 각자의 어플리케이션에 잘 맞는 모델을 만들 수 있다.
     


     

    GitHub - tjeerdintveen/manning-swift-in-depth: Source code for Manning's book: Swift in depth

    Source code for Manning's book: Swift in depth. Contribute to tjeerdintveen/manning-swift-in-depth development by creating an account on GitHub.

    github.com

     

    'Apple🍎 > Swift' 카테고리의 다른 글

    [Swift] Property 제대로 써보자.  (0) 2023.12.12

    댓글

Designed by Tistory.