Swift의 가치 무결성

rbw·2023년 3월 1일
0

TIL

목록 보기
72/97

참조

https://jobandtalent.engineering/value-integrity-in-swift-c5bf2b3f8340

위 글을 보고 번역/정리 해본 글. 자세한 내용은 위 글 참조바람~


이 글의 작성자는 Scott Wlaschin의 Domain modleing made functional 책을 토대로 글을 작성한 것으로 보임. Scott은 무결성을 데이터가 올바른 비즈니스 규칙을 따르는지 확인하는 것 으로 정의하였다고 합니다. 다음과 같은 비즈니스 규칙이 있다고 가정하고 살펴보겠슴니다.

  • 사용자는 길이가 5~20자 사이인 유효한 사용자 이름을 가지고 있어야 함
  • 사용자는 회사(any job)에 지원하기 전 확인된 전화번호가 있어야 함

유형을 통한 무결성 강화

무결성을 강화하고 올바른 값의 불변성을 보장하는 가장 좋은 방법은 일관되지 않은 데이터를 가질 수 없도록 API를 제한하는 것입니다.

잘못된 코드를 작성하는 것을 방지하는 방식으로 이러한 제약 조건을 전달할 수 있도록 해야합니다. Swift에서는 연관된 값을 가진 열거형 개념이 있고, 이를 보통 sum type이라고 부름니다.

// Apple에서 Result가 정의된 방식. sum type으로 볼 수 있다~
@frozen enum Result<Success, Failure> where Failure : Error

sum type은 도메인에서 무결성을 적용하는 가장 좋은 방법 중 하나입니다.

불린 값의 문제

위에 설명한 sum type의 좋은 사용 예시중 하나는 도메인에 불린 값이 있을 때입니다. 아래와 같은 전화번호를 모델링 하고 싶다고 할 때

struct PhoneNumber {
  let phoneNumber: String
  let isVerified: Bool
}

불린 값이 있으니 사용시마다 스스로 물어봐야 합니다.

  1. 나머지 필드가 불린 값의 true/false 모두에 대해 의미가 있는가? 만약, true일 때만 의미 있는 일부 필드를 추가해야 한다면 더 나은 디자인이 필요하다는 신호입니다.
  2. 이 값은 true/false 하나에만 의미가 있는 컨텍스트에서 사용되나요? 그렇다면 디자인이 올바르지 않다는 또 다른 신호입니다.

필드의 가능한 모든 조합이 해당 유형의 모든 용도에 적합한지 확인하는 것이 중요합니다. 따라서 위에 살펴본 sum type을 사용하는 것이 대안이 될 수 있습니다.

enum PhoneNumber {
  case unverified(UnverifiedPhoneNumber)
  case verified(VerifiedPhoneNumber)
}

위 처럼 작성하면, 유효된 번호와 유효하지 않은 번호에 대해 간단하게 살펴볼 수 있습니다. 실제로 글의 첫 부분의 조건에서 이러한 비즈니스 규칙이 존재합니다.(사용자는 회사(any job)에 지원하기 전 확인된 전화번호가 있어야 함)

나이브한 구현은 다음과 같습니다

struct Job {
  let applicants: [User]
}
struct User {
  let phoneNumber: PhoneNumber
}

이러한 디자인을 사용한다면, 모델에 기대하는 다양한 전제 조건을 강제하는 assert(user[index].phoneNumber.isVerified)를 여기저기서 발견해도 놀랍지 않을 것입니다. 문제는 컴파일러가 도움을 주지 않아 런타임이 되어서야 문제를 확인 가능하다는 점입니다. 따라서 컴파일러가 우리를 도와주게끔 디자인을 바꿔보겠슴다.

struct Job {
  let applicants: [VerifiedUser]
}
struct VerifiedUser {
  let phoneNumber: VerifiedPhoneNumber
}

UIKit과 로컬 추론(local reasoning?)의 문제점

디자인에 적용하고 싶은 다른 제약조건이 있지만, 프레임워크를 고려할 떄 허용되지 않거나 편리하지 않아 런타임 어설션으로 돌아가야 하는 경우가 있습니다.

액션 콜백이 매우 특정한 모양의 상태를 기대하는 UIKit으로 버튼을 구현한다고 가정해 봅시다.

enum LoadingState {
  case loading
  case loaded(Data)
}
// The button is disabled when the state is loading, so it is 
// "guaranteed" that this function will only be called when the
// state is .loaded.
@IBAction private func buttonTapped() {
  switch state {
    case .loading:
      preconditionFailure("Cannot happen")
    
    case .loaded(let data):
        // Do something with data
  }
}
func render() {
  switch state {
    case .loading:
      button.isEnabled = false
    
    case .loaded(let data):
      button.isEnabled = true
  }
}

위 코드를 살펴보면, state = .loaded 에서만 버튼이 활성화 됨을 알 수 있습니다. 하지만 향후 render() 코드가 변경되어 버튼 탭 로직이 깨질지 누가 알겠습니까? 이는 큰 문제가 될 수 있습니다. 함수를 분리해서 분석할 수 없습니다. 버튼 탭 동작이 렌더 메서드와 결합되어 있기 때문에 프로그래밍에서 가장 중요한 부분 중 하나인 로컬 추론이 사라졌습니다.

이 문제를 해결하려면 target-selector 패턴을 버려야합니다. 따라서 뷰가 렌더링될 때마다 탭 핸들러 클로저를 설정할 수 있습니다.

func render() {
  switch state {
    case .loading:
      button.isEnabled = false
    case .loaded(let data):
      button.isEnabled = true
      button.tapHandler = {
        // Do something with data. 
      }
    }
}

Dependent types(종속 유형)

때때로 운이 안 좋은 경우도 있습니다. Result type과 비슷한 방식으로 Veridated tpye은 발생할 수 있는 모든 다른 유효성 검사 실패를 누적합니다.

enum Validated<T, E: Error> {
  case valid(T)
  case invalid([E])
}

위의 invalid를 처리할 때 하나 이상의 오류가 있어야 한다는 것을 알지만 컴파일러는 이를 강제할 수 없습니다. 따라서 로직에서 발생할 수 있는 버그를 숨기거나 묻어두지 않기 위해 일종의 어설트 등을 추가할 수 있습니다.

비어 있지 않은 목록은 또 다른 매우 일반적인 예입니다. 표준라이브러리에서 이를 구현하는 하스켈과 같은 언어가 있습니다. Swift에서는 다음과 같이 쉽게 구현이 가능합니다.

struct NonEmptyList<A> {
    let first: A
    let rest: [A]
}

// 또는 재귀적 sum type을 사용한 예시
indirect enum NonEmptyList<A> {
    case head(A)
    case rest(A, self)
}

따라서 올바른 Validate type은 다음과 같습니다

enum Validated<T, E: Error> {
    case valid(T)
    case invalid(NonEmptyList<E>)
}

하지만 이러한 구현에는 몇 가지 문제가 있습니다.

인체공학적인 측면에서 일반적으로 사용되는 일부 시퀀스 메서드를 사용할 수 없으므로 이러한 프로토콜을 수동으로 준수해야 합니다.

그렇게 하더라도 Swift가 내부적으로 수행하는 성능 최적화의 이점을 잃게 됩니다.

그래도 비어 있지 않은 목록을 구현한 좋은 예가 몇 가지 있습니다. 아래 깃허브 참고

https://github.com/pointfreeco/swift-nonempty/blob/main/Sources/NonEmpty/NonEmpty.swift

정수 값을 제한할 때도 마찬가지입니다. Swift에서는(Rust와 달리) 자연수를 표현하는 기본 제공 방법이 없지만, 사용자가 직접 쉽게 구축할 수 있습니다.

indirect enum Nat {
    case one
    case successor(Self)
}
let four: Nat = .successor(.successor(.successor(.one)))

Property wrappers

글 시작 부분에 있는 다른 비즈니스 규칙으로 돌아가보겠습니다.

  • 사용자는 5~20자 사이의 유효한 사용자 이름을 가져야 합니다.

정적으로 유형이 지정된 컴파일러 세계의 밖에서 이를 보장하는 좋은 방법은 속성 래퍼를 사용하는 것입니다.

struct Validation<Value> {
  let predicate: (Value) -> Bool
}
// Having types wrapping non-nominal types (like function types) 
// lets us create nice APIs by extending the type and adding 
// proper constructors.
extension Validation where Value == String { 
  static func range(_ range: ClosedRange<Int>) -> Self {
    .init { 
        range ~= $0.count 
    }
  }
}
@propertyWrapper
struct Validated<A> {
  var value: A? = nil
  var validation: Validation<A>
  var wrappedValue: A? {
    get {
      value.flatMap { validation.predicate($0) ? $0 : nil }
    }
    set {
      value = newValue
    }
  }
}
struct User {
  @Validated(
    value: "luisrecuenco",
    validation: .range(5...20)
  )  
  var username: String?
}

위 코드처럼 속성 래퍼를 적용하면 조건에 맞는 올바른 username이 아니라면 nil을 반환하고 있습니다. 하지만 nil을 반환하는 사용자라는 점은 실제 세계에서 의미가 조금 이상합니다.

유저는 이름이 유효하여야만 유효하다는 조건을 넣어줘야합니다.

struct User {
    @Validated(
        value: "luisrecuenco",
        validation: .range(5...20)
    )
    var username: String?

    var isValid: Bool {
        username != nil
    }
}

유효성을 검사하는 프로퍼티를 추가하고, 위에서 살펴보았듯이 불린 값에 따라 하나의 변형만 올바른 컨텍스트에서 사용되는지도 자문해야 합니다. 이를 위해 조건을 더 추가해야 할 수 있습니다. 항상 그렇듯이 컴파일러를 통해 이러한 전제조건을 적용하는 것보다 더 좋은 방법은 없습니다.

Failable and throwable initializers

프로퍼티 래퍼가 좋긴하지만, 이 문제를 해결하는 데 적합한 도구가 아닐 수도 있습니다. 실패 가능한 이니셜라이저를 사용하는것은 어떨까요 ?

struct Username: RawRepresentable {
  let rawValue: String
  init?(rawValue: String) {
    guard 5...20 ~= rawValue.count else { return nil }
    
    self.rawValue = rawValue
  }
}
struct User {
  var username: Username
}

도메인에서 의미 있는(사용자이름)에 고유한 이름을 부여했을 뿐만 아니라 전제 조건을 이니셜라이저로 이동하여 정확한 값이 주어져야만 사용자 이름을 생성할 수 있도록 하였습니다. 이는 모든 유저 값에 올바른 사용자 이름이 있는지 확인할 수 있다는 의미이기도 합니다. 다른 컨텍스트에서 유저 값을 주입하는것 또한 항상 유효한 사용자를 의미하게 됩니다.

런타임 검사 범위를 유저네임의 이니셜라이저로 한정 하여 나머지 코드에서 더 많은 어설션을 제거하였습니다.

비슷한 방식으로 throwable initializer를 사용할 수도 있지만, 이 경우 nil 보다 의미론적 오류가 더 많이 발생할 수 있습니다.

Ergonomics(인간공학?)

타입에서 문자열이나 정수와 같이 바인딩되지 않은 유형을 바인딩을 풀었을때 ?(lift unbounded, 해석이 힘드네야,, 할당한다는느낌인거 같기두 하구)인체 공학적으로 손실됩니다.(???)

Swift에서는 이를 복구하는 좋은 방법이 있습니다. 유저이름의 경우 ExpressibleByStringLiteral을 준수할 수 있으므로, 문자열 리터럴을 통해 값을 생성할 수 있습니다.

extension Username: ExpressibleByStringLiteral {
  init(stringLiteral value: String) {
    self.init(rawValue: value)!
  }
}

강제 언래핑으로 타입 안정성을 조금 잃었지만 간단한 테스트의 경우 유용하게 사용될 것입니다.

Refundement Types(정제 유형, 단순 유형?)

우리가 원하는 것은 기본 값이 Predicate에서 정의한 기준을 충족할 때만 생성될 수 있는 유형입니다.

Refined<PhantomType, Predicate>

팬텀 유형을 사용하면 동일한 Predicate를 사용하는 여러 유형을 구분할 수 있습니다. Predicate 유형의 경우, 이전의 Validation<T>는 T 유형에 대해 모든 유효성 검사를 나타내므로 충분하지 않습니다. 구체적인 유효성 검사를 나타내는 다른 유형이 필요합니다. 가능한 해결책으로 이전 유효성 검사 유형을 사용하는 구체화 프로토콜을 사용하는것입니다.

protocol Refinement {
  associatedtype RawType
  static var validation: Validation<RawType> { get }
}

struct Refined<T, R> where R: Refinement {
  let rawValue: R.RawType
  
  init?(rawValue: R.RawType) {
    guard R.validation.predicate(rawValue) else { return nil }
    self.rawValue = rawValue
  }
}

좀 더 유연해진 모습이네여,,

그리고 다음과 같이 유저이름 부분을 만들 수 있슴니다.

struct UsernameRefinement: Refinement {
  static let validation: Validation<String> = .range(5...20)
}
enum UsernameTag {}
typealias Username = Refined<UsernameTag, UsernameRefinement>
Username(rawValue: "luisrecuenco") // valid
Username(rawValue: "luis") // nil

이제 다른 프로퍼티들도 Refinement, Validation만 추가하여 유효성을 판단할 수 있습니다 !

결론

유닉스 디자인은 미니멀리즘과 모듈식 아키텍처로 알려져 있습니다. 레고 블록처럼 한 가지 일을 잘 수행하는 프로그램들이 조합되어 더 크고 복잡한 프로그램을 만들 수 있습니다. 이 설계에는 몇 가지 원칙이 있는데 그 중 하나가 대표성 규칙입니다.

  • 지식을 데이터로 변환하여 프로그램 로직이 더 똑똑하고 견고해질 수 있도록 하세요

타입이란 것은 프로그램 곳곳에 흩어져 있을 복잡한 로직(위에서 계속 이야기한, 런타임 검사)을 선언적 방식으로 캡슐화할 수 있는 강력한 프로그래밍 도구 중 하나입니다

타입을 명제로, 프로그램을 증명으로 보고 수학과 프로그래밍 사이의 간극을 좁히면 코드가 확실히 더 정확해질 것입니다. 하지만 더 정확하다고 반드시 더 나은것은 아니라는 점에 유의해야합니다.

편리한 코드와 올바른 코드 사이는 항상 트레이드오프가 존재합니다

우리가 사용하는 Swift는 뛰어난 타입 시스템을 갖추었기 때문에 더 정확하고 편리하며 이해하기 쉬운 코드를 더 쉽게 작성할 수 있게 되었습니다. 따라서 선택의 여지가 없습니다.(ㅋㅌㅋ 하라는거네요~)


번역 툴과 제 얕은 영어실력으로 글을 번역해보았는데 좀 힘든 해석도 있었지만 좋은 글이였습니다.

좀 배운점으로는 비즈니스 규칙을 따라 명확하게 코드를 작성하는 부분이고, 유효성 검사 부분 입니다.

타입을 활용하여 값의 유효성을 철저하게 검사하여 올바른 값만 가질 수 있도록 하는 부분이 인상 깊었습니다. 요 정도까지 해야하나 싶기도 하였는데 코드를 보고 글을 읽다보니 확실히 장점이 많다고 느꼇씀니다

결국 올바른 데이터가 비즈니스 규칙을 명확히 따를것이라는 것을 무결성으로 보고 이 글이 생겨났는데 꽤 납득도 되고 좋았슴다~

profile
hi there 👋

0개의 댓글