참조
https://jobandtalent.engineering/value-integrity-in-swift-c5bf2b3f8340
위 글을 보고 번역/정리 해본 글. 자세한 내용은 위 글 참조바람~
이 글의 작성자는 Scott Wlaschin의 Domain modleing made functional 책을 토대로 글을 작성한 것으로 보임. Scott은 무결성을 데이터가 올바른 비즈니스 규칙을 따르는지 확인하는 것 으로 정의하였다고 합니다. 다음과 같은 비즈니스 규칙이 있다고 가정하고 살펴보겠슴니다.
무결성을 강화하고 올바른 값의 불변성을 보장하는 가장 좋은 방법은 일관되지 않은 데이터를 가질 수 없도록 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
}
불린 값이 있으니 사용시마다 스스로 물어봐야 합니다.
필드의 가능한 모든 조합이 해당 유형의 모든 용도에 적합한지 확인하는 것이 중요합니다. 따라서 위에 살펴본 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으로 버튼을 구현한다고 가정해 봅시다.
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.
}
}
}
때때로 운이 안 좋은 경우도 있습니다. 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)))
글 시작 부분에 있는 다른 비즈니스 규칙으로 돌아가보겠습니다.
정적으로 유형이 지정된 컴파일러 세계의 밖에서 이를 보장하는 좋은 방법은 속성 래퍼를 사용하는 것입니다.
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
}
}
유효성을 검사하는 프로퍼티를 추가하고, 위에서 살펴보았듯이 불린 값에 따라 하나의 변형만 올바른 컨텍스트에서 사용되는지도 자문해야 합니다. 이를 위해 조건을 더 추가해야 할 수 있습니다. 항상 그렇듯이 컴파일러를 통해 이러한 전제조건을 적용하는 것보다 더 좋은 방법은 없습니다.
프로퍼티 래퍼가 좋긴하지만, 이 문제를 해결하는 데 적합한 도구가 아닐 수도 있습니다. 실패 가능한 이니셜라이저를 사용하는것은 어떨까요 ?
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 보다 의미론적 오류가 더 많이 발생할 수 있습니다.
타입에서 문자열이나 정수와 같이 바인딩되지 않은 유형을 바인딩을 풀었을때 ?(lift unbounded, 해석이 힘드네야,, 할당한다는느낌인거 같기두 하구)인체 공학적으로 손실됩니다.(???)
Swift에서는 이를 복구하는 좋은 방법이 있습니다. 유저이름의 경우 ExpressibleByStringLiteral
을 준수할 수 있으므로, 문자열 리터럴을 통해 값을 생성할 수 있습니다.
extension Username: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
self.init(rawValue: value)!
}
}
강제 언래핑으로 타입 안정성을 조금 잃었지만 간단한 테스트의 경우 유용하게 사용될 것입니다.
우리가 원하는 것은 기본 값이 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는 뛰어난 타입 시스템을 갖추었기 때문에 더 정확하고 편리하며 이해하기 쉬운 코드를 더 쉽게 작성할 수 있게 되었습니다. 따라서 선택의 여지가 없습니다.(ㅋㅌㅋ 하라는거네요~)
번역 툴과 제 얕은 영어실력으로 글을 번역해보았는데 좀 힘든 해석도 있었지만 좋은 글이였습니다.
좀 배운점으로는 비즈니스 규칙을 따라 명확하게 코드를 작성하는 부분이고, 유효성 검사 부분 입니다.
타입을 활용하여 값의 유효성을 철저하게 검사하여 올바른 값만 가질 수 있도록 하는 부분이 인상 깊었습니다. 요 정도까지 해야하나 싶기도 하였는데 코드를 보고 글을 읽다보니 확실히 장점이 많다고 느꼇씀니다
결국 올바른 데이터가 비즈니스 규칙을 명확히 따를것이라는 것을 무결성으로 보고 이 글이 생겨났는데 꽤 납득도 되고 좋았슴다~