Tags :

Date :

KeyPath

Swift의 구조체와 클래슨느 프로퍼티를 참조할 수 있는 KeyPath를 사용할 수 있다. KeyPath는 특정 타입의 프로퍼티 위치를 값처럼 표현하는 기능이다.

struct User { 
    let id: Int 
    var name: String 
}

\User.id // KeyPath<User, Int> 
\User.name // WritableKeyPath<User, String>
  • KeyPath<Root, Value>는 Root 타입 안에 있는 Value 타입 프로퍼티를 가리킨다.



let user = User(id: 1, name: "Donghyeon") 
let nameKeyPath = \User.name 
print(user[keyPath: nameKeyPath]) // Donghyeon
  • 예를 들어 \User.name은 User 안의 name 프로퍼티를 가리키는 값이다.



var user = User(id: 1, name: "Donghyeon") 
user[keyPath: \User.name] = "Kim" 
print(user.name) // Kim
  • var 프로퍼티라면 값을 변경하는 것도 가능하다.



즉, KeyPath를 사용하면 어떤 프로퍼티를 읽거나 수정할지 코드로 직접 고정하지 않고, 프로퍼티 접근 자체를 값처럼 전달할 수 있다.
이 개념은 SwiftUI에서도 자주 사용된다. 예를 들어 Binding, Environment, dynamicMemberLookup 같은 API 내부에서 KeyPath가 자연스럽게 활용된다.



enum에서는 KeyPath를 바로 사용할 수 없다

enum UserAction { 
    case home(HomeAction) 
    case settings(SettingsAction) 
}

\UserAction.home // 🛑 Key path cannot refer to enum case 'home'

enum의 case에는 일반적인 KeyPath를 사용할 수 없다.
KeyPath는 기본적으로 구조체나 클래스의 저장 프로퍼티를 가리키는 기능이기 떄문이다.
enum의 case는 저장 프로퍼티가 아니다. enum은 case중 하나의 상태를 표현하는 타입이고, associated value는 특정 case일 때만 존재한다.



enum에서는 KeyPath를 바로 사용할 수 없는 이유

enum UserAction { 
    case home(HomeAction) 
    case settings(SettingsAction) 
}

let action = UserAction.settings(SettingsAction())
  • home은 항상 존재하는 프로퍼티가 아니다.
  • let action 상태는 home이라는 값이 존재하지 않는다. 따라서 Swift 기본 KeyPath 시스템은 \UserAction.home처럼 enum case 내부 값을 직접 가리킬 수 없다.



enum에서는 기본적으로 switch로 꺼낸다

enum UserAction { 
    case home(HomeAction) 
    case settings(SettingsAction) 
}

switch action { 
    case let .home(homeAction): 
        print("home action:", homeAction) 
    case let .settings(settingsAction): 
        print("settings action:", settingsAction) 
}
  • Swift에서 enum의 associated value를 다루는 가장 기본적인 방법은 switch 또는 if case를 사용하는 것이다.



if case let .home(homeAction) = action { 
    print(homeAction)
}
  • 특정 case만 보고 싶다면 if case를 사용할 수 있다.



enum Destination { 
    case home(HomeState) 
    case settings(SettingsState) 
} 

var destination = Destination.home(HomeState(title: "Home")) 
switch destination { 
    case var .home(state): 
        state.title = "New Home" 
        destination = .home(state) 
    case .settings: 
        break 
}
  • 값을 수정해야 한다면, enum을 switch로 분해한 뒤 새로운 enum 값으로 다시 만들어줘야 한다.
  • 즉, enum에서는 구조체처름 특정 내부 값을 KeyPath로 바로 수정하는 것이 아니라 현재 case를 확인하고 associated value를 꺼낸 뒤 다시 감싸는 방식으로 처리한다.



정리

\User.name // 가능
  • 구조체와 클래스에서는 KeyPath를 사용해 프로퍼티 접근을 값처럼 다룰 수 있다.



\UserAction.home // 불가능

if case let .home(homeAction) = action { 
    // homeAction 사용 
}
  • 하지만 enum case는 저장 프로퍼티가 아니기 때문에 Swift의 기본 KeyPath로 접근할 수 없다.
    • 그래서 enum의 associated value를 다룰 때는 기본적으로 switch, if case, guard case를 사용한다.
  • Swift의 기본 KeyPath는 구조체와 클래스의 프로퍼티 접근에는 강력하지만, enum case까지 표현하지는 못한다. 그래서 enum을 더 일반적으로 다루고 싶다면 Swift의 기본 KeyPath가 아니라, case를 추출하고 다시 삽입할 수 있는 별도의 추상화가 필요하다.



CasePath

enum UserAction { 
    case home(HomeAction) 
    case settings(SettingsAction) 
}

\UserAction.home // 🛑 Key path cannot refer to enum case 'home'
  • 앞에서 살펴본 것처럼 Swift의 기본 KeyPath는 구조체와 클래스의 프로퍼티에는 사용할 수 있지만, enum의 case에는 사용할 수 없다.
  • 하지만 실제 앱을 개발하다 보면 enum의 associated value를 일반화해서 다루고 싶은 경우가 매우 많다.
enum Destination { 
    case home(HomeState) 
    case settings(SettingsState) 
}
  • 예를 들어 위와 같은 상태가 있다고 가정해 보자.
// 방법 1
if case let .home(state) = destination { 
    // state 사용 
}

// 방법 2
switch destination { 
    case var .home(state): 
        state.title = "새 제목" 
        destination = .home(state) 
    case .settings: 
        break 
}
  • Destination이 현재 home인지 확인하고, if case let 나 switch로 수정한 뒤 다시 enum으로 감싸야 한다.
  • 이러한 코드는 한두 번은 괜찮지만 enum을 자주 다루게 되면 반복되는 switch와 패턴 매칭 코드가 계속 등장하게 된다.

CasePath

앞에서 살펴본 것처럼 Swift의 기본 KeyPath는 구조체와 클래스의 프로퍼티에는 사용할 수 있지만, enum의 case에는 사용할 수 없다.

enum UserAction {
  case home(HomeAction)
  case settings(SettingsAction)
}

\UserAction.home
// 🛑 key path cannot refer to static member 'home'
  • KeyPath가 구조체나 클래스 내부의 프로퍼티를 가리킨다면, CasePath는 enum의 특정 case를 가리킨다.
  • Point-Free의 swift-case-paths는 이 개념을 Swift에서 사용할 수 있게 해주는 라이브러리다.
  • 공식 설명에서도 CasePath를 “enum case를 위한 key path”라고 설명한다.

CasePath를 활성화하는 방법

import CasePaths 

@CasePathable 
enum UserAction { 
    case home(HomeAction) 
    case settings(SettingsAction) 
}

\UserAction.Cases.home // CaseKeyPath<UserAction, HomeAction> 
\UserAction.Cases.settings // CaseKeyPath<UserAction, SettingsAction>

// 타입을 추론할 수 있는 상황에서는 더 짧게 쓸 수도 있다.
\.home as CaseKeyPath<UserAction, HomeAction> 
\.settings as CaseKeyPath<UserAction, SettingsAction>
  • CasePath를 사용하려면 enum에 @CasePathable 매크로를 붙인다.
  • 그러면 enum의 Cases 네임스페이스를 통해 case path를 만들 수 있다.

CasePath가 할 수 있는 일

// KeyPath는 어떤 값의 프로퍼티를 읽고 수정할 수 있다.
user[keyPath: \User.name] = "Blob" 
user[keyPath: \.name] // "Blob"

// CasePath는 enum에서 특정 case의 associated value를 꺼내거나 수정할 수 있다.
// 현재 enum 값이 원하는 case라면 associated value를 반환하고, 다른 case라면 nil을 반환한다.
var userAction = UserAction.home(.onAppear) 
userAction[case: \.home] // Optional(HomeAction.onAppear) 
userAction[case: \.settings] // nil

// 값을 수정하는 것도 가능하다.
// 이 코드는 현재 userAction이 .home case일 때 내부의 HomeAction 값을 수정한다.
userAction[case: \.home] = .buttonTapped

Embed: 값을 다시 enum으로 감싸기

CasePath는 enum에서 값을 꺼내는 것뿐만 아니라, associated value를 다시 enum case로 감쌀 수도 있다.

let homeCase = \UserAction.Cases.home 
let action = homeCase(.onAppear) 
// UserAction.home(.onAppear)

HomeAction.onAppear 
          
UserAction.home(.onAppear)
  • 이 부분이 일반 KeyPath와 다른 중요한 차이다. KeyPath는 이미 존재하는 구조체나 클래스 안으로 들어가 프로퍼티를 읽고 수정한다. 반면 CasePath는 associated value를 새로운 enum 값으로 만들 수도 있다.

case인지 확인하기

userAction.is(\.home) // true
userAction.is(\.settings) // false

// 배열에서도 유용하다.
let actions: [UserAction] = [
  .home(.onAppear),
  .settings(.purchaseButtonTapped),
  .home(.buttonTapped)
]

let homeActionsCount = actions.count { $0.is(\.home) } // 2
  • @CasePathable을 사용하면 특정 enum 값이 원하는 case인지 확인할 수도 있다.

associated value를 제자리에서 수정하기

var result = Result<String, Error>.success("Blob") 

// 기본 Swift만 사용했다면 switch로 case를 꺼내고, 값을 수정한 뒤, 다시 enum으로 감싸야 한다.
switch result { 
    case var .success(value): 
        value += ", Jr." 
       result = .success(value) 
    case .failure: 
        break 
}

// modify를 사용하면 이 반복 코드를 줄일 수 있다.
result.modify(\.success) { 
    $0 += ", Jr." 
} 
result // .success("Blob, Jr.")
  • CasePath는 특정 case의 associated value를 제자리에서 수정하는 modify도 제공한다.

CasePath도 조합할 수 있다

@CasePathable 
enum AppAction { 
    case user(UserAction) 
} 

@CasePathable 
enum UserAction { 
    case home(HomeAction) 
    case settings(SettingsAction) 
}
  • KeyPath를 조합할 수 있듯이 CasePath도 조합할 수 있다.
  • 예를 들어 액션이 중첩되어 있다고 가정해 보자.
// 그러면 AppAction에서 바로 HomeAction까지 들어가는 CasePath를 만들 수 있다.
\AppAction.Cases.user.home // CaseKeyPath<AppAction, HomeAction>

// 또는 직접 append할 수도 있다.
let appActionToUser = \AppAction.Cases.user
let userActionToHome = \UserAction.Cases.home
let appActionToHome = appActionToUser.append(path: userActionToHome) // CaseKeyPath<AppAction, HomeAction>
  • 이 덕분에 큰 enum을 작은 enum 단위로 나누고, 필요한 case만 선택해서 다룰 수 있다.

Dynamic Member Lookup과 함께 사용하기

@CasePathable과 @dynamicMemberLookup을 함께 사용하면 enum case를 프로퍼티처럼 접근할 수도 있다.

@CasePathable 
@dynamicMemberLookup 
enum UserAction { 
    case home(HomeAction) 
    case settings(SettingsAction) 
}
let action: UserAction = .home(.onAppear)
action.home // Optional(HomeAction.onAppear)
action.settings // nil
let actions: [UserAction] = [ 
    .home(.onAppear), 
    .settings(.purchaseButtonTapped), 
    .home(.buttonTapped) 
]
actions.compactMap(\.home) // [.onAppear, .buttonTapped]
  • 배열에서도 key path 표현식처럼 사용할 수 있다.
  • 이 문법은 enum을 구조체 프로퍼티처럼 다룰 수 있게 만들어준다.
// @dynamicMemberLookup 없음
let action = UserAction.home(.onAppear)
let value = action[case: \.home]
// Optional(.onAppear)

// @dynamicMemberLookup 있음
let action = UserAction.home(.onAppear)
let value = action.home
// Optional(.onAppear)

SwiftUI Binding과 CasePath

let user: Binding<User> = ... 
let name: Binding<String> = user.name
  • 공식 README에서도 Binding 예시를 통해 CasePath의 필요성을 설명한다.
  • SwiftUI의 Binding은 WritableKeyPath를 이용해 하위 프로퍼티의 바인딩을 만들 수 있다.
enum Destination { 
    case home(HomeState) 
    case settings(SettingsState) 
}

let destination: Binding<Destination> = ... 
destination.home // 기본 Swift만으로는 불가능
  • 하지만 enum에서는 기본적으로 이런 식의 접근이 불가능하다.
@CasePathable
enum Destination { 
    case home(HomeState) 
    case settings(SettingsState) 
}

destination.home // Binding<HomeState>? 
destination.settings // Binding<SettingsState>?
  • CasePath를 사용하면 enum의 특정 case에 대한 바인딩을 만들 수 있다.

@dynamicMemberLookup 유무 차이

CasePath를 이해할 때는 먼저 두 가지를 구분해야 한다.

// 1. CasePath 없음
// Swift 기본 enum만 사용하는 상태

// 2. CasePath 있음
// @CasePathable을 붙여 enum case를 CasePath로 다룰 수 있는 상태

// 3. @dynamicMemberLookup 있음
// CasePath 접근을 점 문법으로 더 짧게 사용할 수 있는 상태

즉, @dynamicMemberLookup은 CasePath 자체를 만들어주는 기능이 아니다.
CasePath를 더 편한 문법으로 사용할 수 있게 해주는 옵션에 가깝다.


1. CasePath 없음

Swift 기본 enum만 사용하면 associated value를 꺼내기 위해 switch 또는 if case를 사용해야 한다.

enum UserAction {
  case home(HomeAction)
  case settings(SettingsAction)
}

let action = UserAction.home(.onAppear)

if case let .home(value) = action {
  print(value)
}
// .onAppear

이 상태에서는 아래 문법을 사용할 수 없다.

action[case: \.home] // 불가능
action.home          // 불가능

2. CasePath 있음, @dynamicMemberLookup 없음

@CasePathable을 붙이면 enum case를 CasePath로 다룰 수 있다.

import CasePaths

@CasePathable
enum UserAction {
  case home(HomeAction)
  case settings(SettingsAction)
}

let action = UserAction.home(.onAppear)

let value = action[case: \.home]
// Optional(.onAppear)

이때는 CasePath 기능은 사용할 수 있지만, 점 문법은 사용할 수 없다.

action[case: \.home] // 가능
action.home          // 불가능

3. CasePath 있음, @dynamicMemberLookup 있음

@dynamicMemberLookup까지 붙이면 CasePath 접근을 점 문법으로 더 짧게 쓸 수 있다.

import CasePaths

@CasePathable
@dynamicMemberLookup
enum UserAction {
  case home(HomeAction)
  case settings(SettingsAction)
}

let action = UserAction.home(.onAppear)

let value = action.home
// Optional(.onAppear)

이 코드는 사실상 아래 코드와 같은 의미다.

let value = action[case: \.home]
// Optional(.onAppear)

즉, action.homeaction[case: \.home]의 축약 문법이라고 보면 된다.


최종 비교

// CasePath 없음
let action = UserAction.home(.onAppear)

if case let .home(value) = action {
  print(value)
}
// .onAppear
// CasePath 있음
let action = UserAction.home(.onAppear)

let value = action[case: \.home]
// Optional(.onAppear)
// CasePath 있음 + @dynamicMemberLookup 있음
let action = UserAction.home(.onAppear)

let value = action.home
// Optional(.onAppear)

정리하면 다음과 같다.

상태 가능한 문법 의미
CasePath 없음 if case let .home(value) Swift 기본 패턴 매칭
CasePath 있음 action[case: \.home] CasePath로 associated value 추출
CasePath 있음 + @dynamicMemberLookup 있음 action.home CasePath 접근을 점 문법으로 축약

따라서 핵심은 다음과 같다.

  • @CasePathable은 enum case를 CasePath로 다룰 수 있게 해준다.
  • @dynamicMemberLookupaction[case: \.home]action.home처럼 짧게 쓸 수 있게 해준다.
  • 즉, @dynamicMemberLookup은 필수가 아니라 편의 문법이다.