Swift Shared(Sharing)이 무엇일까? 공식문서로 같이 톺아보기 (feat. TCA)

마이노·2025년 3월 20일
0

최근 TCA에서는 기존에 있던 피쳐를 라이브러리로 분리하는 작업을 꾸준히 진행중입니다.

비교적 최근에 떨어져 나온 Sharing은 무엇일지, 어떻게 사용해야 할지 알아보고자 합니다. 공식문서를 천천히 따라가보며 한번에 정리하는 것이 이번 포스팅의 목표입니다.

Instantly share state among your app’s features and external persistence layers, including user defaults, the file system, and more.

사용자의 기본 타입의 값, 파일 시스템 등을 앱의 기능 및 외부 지속성 계층 간에 State를 즉시 공유한다.


예를 들어 다음과 같이 사용합니다.

@Observable
class A_Model {
    @ObservationIgnored
    @Shared(.fileStorage(.meetingsURL)) var meetings: [Meeting] = []
}

@Observable
class B_Model {
    @ObservationIgnored
    @Shared(.fileStorage(.meetingsURL)) var meetings: [Meeting] = []
}

Swift Macro(@Observable)는 속성 래퍼와 잘 호환되지 않으므로 @Shared에 @ObservationIgnored를 추가해야 합니다.
Shared는 그 자체적으로 관찰의 결과를 처리하기 때문에 상태가 변경되더라도 뷰는 계속 업데이트될 수 있습니다.

만약 A_Model이나 B_Model에서 mettings를 변경하게 되면 다른 모델은 즉시 변경사항을 확인할 수 있어요.

그 결과 두 인스턴스 모두 최신 데이터를 보관하도록 업데이트합니다.

Automatic persistence(자동 지속성)

앱에서 모든 종류의 데이터를 간결하고 일관성 있게 유지하는 방법을 제공합니다.

Shared는 appStorage, fileStorage, inMemory 총 3가지의 전략을 제공합니다.

1. appStorage

이 전략은 사용자의 기본값, 아주 간단한 데이터의 작은 부분을 저장하는데 특화되어 있습니다.

@Shared(.appStorage("soundsOn")) var soundsOn = true
@Shared(.appStorage("hapticsOn")) var hapticsOn = true
@Shared(.appStorage("userSort")) var userSort = UserSort.name

2. fileStorage

이 전략은 데이터를 바이트로 직렬화 하여 파일 시스템에 더 복잡한 데이터 유형을 유지하는데 특화되어 있습니다.

@Shared(.fileStorage(.meetingsURL)) var meetings: [Meeting] = []

3. inMemory

이 전략은 모든 종류의 데이터를 앱 전체에서 사용할 수 있게 공유하는데 특화되어 있지만

앱을 재시작 하게 되면 모든 데이터가 날아가는 휘발성을 가지고 있습니다.

@Shared(.inMemory("events")) var events: [String] = []

Persistence strategies(지속성 전략)

공유를 사용할 때 지속성 전략을 옵션으로 제공하여 외부 시스템과 공유되고 있음을 나타낼 수 있습니다.

1. appStorage

static func appStorage(
    _ key: String,
    store: UserDefaults? = nil
) -> Self where Self == AppStorageKey<Bool>

appStorage 함수를 살펴보겠습니다.

key: key는 사용자가 값을 읽고 쓰기 위한 용도 입니다.

store: store는 사용할 저장소를 지정할 수 있는데, 굳이 작성하지 않아도 nil을 할당해주기 때문에 기본 저장소를 사용하게 됩니다.

@Shared(.appStorage("count")) var count = 0

사용자 기본값에 값을 저장할 key, 값이 없을 경우 사용할 기본 값이 필요합니다.

예를 들어 count라는 key에 대응하는 값이 없다면 기본값인 0을 활용하여 count를 활용하겠다는 속뜻이 있습니다.

2. fileStorage

static func fileStorage<Value>(
    _ url: URL,
    decoder: JSONDecoder? = nil,
    encoder: JSONEncoder? = nil
) -> Self 
where Self == FileStorageKey<Value>, Value : Decodable, Value : Encodable, Value : Sendable

fileStorage 함수를 살펴보겠습니다.

url: 값을 읽고 쓸 파일 URL

decoder & encoder: 이름그대로

앱이 실행될 때마다 공유되는 값을 유지하는 경우, 데이터 유형이 복잡한 경우에 사용합니다.

inMemory와 유사하게 작동하지만 디스크에 데이터를 저장할 URL과 파일 시스템에 데이터가 없을 때 사용할 기본값이 제공되어야 합니다.

struct Settings: Codable {
  var hapticsEnabled = true
  // ...
}


@Shared(.fileStorage(.documentsDirectory.appending(component: "settings.json"))
var settings = Settings()

fileStorage함수에 있는 Value의 제약조건에는 Decodable, Encodable이 있기 때문에 Codable을 채택해준 모습이고,

이러한 url에 파일의 URL이 들어가게 됩니다. 기본값도 제공해준 모습을 볼 수 있습니다.

3. inMemory

static func inMemory<Value>(_ key: String) -> Self 
where Self == InMemoryKey<Value>, Value : Sendable

inMemory도 마찬가지로 key를 받습니다.

메모리에서 공유할 값을 식별하기 위해 문자열 키를 사용합니다.

inMemory는 전혀 지속되지 않는다는 점에서 가장 간단한 지속성 전략입니다. (영구적으로 메모리에 남지 않고 앱을 사용할때만 남는다 = 따라서 지속되지 않는다 라는 표현을 사용한 것)

@Shared 상태에 처음 접근할 때 사용할 기본값이 제공되어야 합니다.

fileStorage의 URL Extension방법과 마찬가지로appStorage와 inMemory의 key들도 하드코딩하지 말고 따로 관리하는 것이 좋습니다. 분명 직접 작성하다가는 언젠가는 실수가 발생할 수 있습니다.

Mutating shared state(공유 상태 변형)

공유 상태는 직접 자유롭게 변경할 수 없습니다. 변경하고 싶다면 withLock method를 거쳐서 변경해야 합니다.

불편하고 친화적이지 않아보일 수 있지만 다음 예시를 통해 이렇게까지 해서 변경해야 하는 이유를 확인해보겠습니다.

여러 스레드에서 SwiftUI 상태를 변경하여 데이터가 손실되는 형태로 경쟁 조건을 발생시킬 수 있습니다.

그 결과 @AppStorage, @State, @Binding에 영향을 끼치게 됩니다.

다음 예는 group task에서 1000개의 작업을 실행하고 각 task가 @AppStorage의 값을 1씩 증가시킵니다.

import SwiftUI


struct AppStorageRaceCondition: View {
  @AppStorage("count") var count = 0
  var body: some View {
    Form {
      Text("\(count)")
      Button("Race!") {
        Task {
          await withTaskGroup(of: Void.self) { group in
            for _ in 1...1_000 {
              group.addTask { _count.wrappedValue += 1 }
            }
          }
        }
      }
    }
  }
}


#Preview("AppStorage race condition") {
  AppStorageRaceCondition()
}

Race 버튼을 누르면 count가 1000보다 훨씬 적은 것을 알 수 있고 경쟁 조건이 있음을 확실하게 알 수 있습니다.

@AppStorage, @State, @Binding 모두 같은 결과를 나타냅니다.

Swift 6에서 경고, 오류가 없이 컴파일이 되며 가장 엄격한 동시성 설정을 가진 Swift 코드임에도 불구하고 이와 같은 결과를 확인할 수 있습니다. 경쟁 조건에는 취약함을 알 수 있었지만 적어도 데이터 손상으로부터는 안전합니다.

속성 래퍼는 값에 동기화 하기 위해 wrappedValue를 통해 접근합니다. 데이터 손상으로부터는 안전하나, 손실로부터는 보호받지 못합니다.

_count.wrappedValue += 1
이 코드는 여러 지침이 존재합니다.

  1. wrappedValue를 얻는다.

  2. wrapedValue에 1을 더한다.

  3. wrappedValue를 새로 설정한다.

값을 얻는 1번, 값을 설정하는 3번은 경쟁을 할 수 없게 값이 잠겨져 있지만 결국에 2번에서 여러 스레드가 섞여 wrappedValue가 이전 값을 쓸 가능성이 존재합니다.

따라서 경쟁 조건을 막고 싶다면 액터에서 동기화를 직접 제공해주어야 합니다.

await withTaskGroup(of: Void.self) { group in
  for _ in 1...1_000 {
    group.addTask { 
      await MainActor.run { 
        count += 1 
      }
    }
  }
}

다음과 같이 수정한다면 값의 손실은 없지만 Swift의 동시성 검사는 이 문제를 감지할 수 없습니다.

값을 수정할 때 동기화를 제공해야 한다는 것을 인지하는 방법밖에 없다는 것입니다.

이 문제는 SwiftUI뿐만 아니라 모델, UIKit 뷰 컨트롤러, 액터 등에서도 동일하게 발생하는 문제입니다.

따라서 Shared가 직접적으로 변형을 허용한다면 데이터 손실을 일으킬 수 있는 경쟁조건을 몰래 숨기는 코드를 작성할 수 있습니다.

 await withTaskGroup(of: Void.self) { group in
   for _ in 1...1_000 {
     group.addTask { 
       $count.withLock { 
         $0 += 1 
       }
     }
   }
 }

withLock은 pointfree측이 공유된 값을 변형하도록 요구하는 것이 그나마 공정한 타협이라 생각한 것입니다. 테스트가 가능하고 UIKit을 포함하여 앱 전체에서 어디서나 사용할 수 있고 지속성 전략으로 작동하기 때문에 단점보다 장점이 훨씬 큰 결정을 하게 된 속사정을 볼 수 있습니다.

Observing changes to shared state (변경 사항 관찰하기)

SwiftUI에서 observe는 자동으로 처리됩니다.

뷰에서 직접 shared 상태에 접근하기만 하면 뷰가 해당 사태에 대한 변경 사항을 알 수 있습니다.

다만 뷰에서 직접 공유 상태를 유지하는 경우에만 그렇습니다.

다음과 같은 예시를 들 수 있어요.

struct CounterView: View {
  @Shared(.appStorage("count")) var count = 0
  var body: some View {
    Form {
      Text("\(count)")
      Button("Increment") { count += 1 }
    } 
  }
}

@Observable Model에서 shared 상태를 유지하고자 하는 경우는 다음과 같아요.

@Observable class CounterModel {
  @ObservationIgnored 
  @Shared(.appStorage("count")) var count = 0
}
struct CounterView: View {
  @State var model = CounterModel()
  var body: some View {
    Form {
      Text("\(model.count)")
      Button("Increment") { model.count += 1 }
    } 
  }
}

SwiftUI는 뷰가 여러번, 다시 생성될 가능성이 있습니다.

예를 들어

$value.load(.newKey)
// or…
$value = Shared(.newKey)

다음과 같이 키를 동적으로 변경하고자 하는 경우에도 .newKey가 재설정 될 수 있습니다.

따라서 뷰에서도 공유된 상태를 보유하고자 하는 속성을 부여해 재설정되는 문제를 방지할 수 있습니다.

@State.Shared(.key) var value

또한 shared 상태의 변경 사항에 대해 Combine Publisher를 가져올 수 있어요.

모든 Shared 값에는 shared 상태가 변경이 될 때마다 값을 내보내는 퍼블리셔가 있습니다.

class Model {
  @Shared(.appStorage("count")) var count = 0


  var cancellables: Set<AnyCancellable> = []
  func startObservation() {
    $count.publisher.sink { count in
      print("count is now", count)
    }
    .store(in: &cancellables)
  }
}

이렇게 count의 변경사항을 구독해서 감지할 수 있습니다.

다만 sink내부에서 shared 상태를 변경하지 않도록 해야합니다. 무한루프에 빠지게 될테니까요.

Deriving shared state (공유 상태를 분리하는 법)
다음과 같은 모델이 있다고 할게요.

class SignUpData {
  val phoneNumber: String
  val name: String
  ...
}

@Shared(.imMemory(.signUp)) var signUpData

@Observable
class ChildModel { 
  @ObservationIgnored
  @Shared var phoneNumber: String
  // ...
}

PhoneNumberModel은 phoneNumber가 필요합니다.

signUpData의 phoneNumber를 ChildModel에게 파생시키고 싶습니다.

이렇게 가입데이터의 전체가 필요하지 않고 일부분이 필요한 경우 사용할 수 있습니다.

PhoneNumberModel(phoneNumber: $signUpData.phoneNumber)

@Shared를 SwiftUI의 Binding으로 생각하는 것이 편합니다.

공유를 시작한 값(진실의 원천, 하단 서술)이 다른 곳에 있다는 것을 표현하기 위해 Binding과 같은 방법을 사용합니다. 그럼에도 결국은 최신 값을 읽고 쓸 수 있다는 것이 중요합니다.

위 세가지의 지속성 전략을 유지하고 싶다면 동일하게 작성하면 됩니다.

@Observable
class ParentModel {
  @ObservationIgnored
  @Shared(.fileStorage(.currentUser)) var currentUser
  // ...
}

@Observable
class EditNameModel {
  @ObservationIgnored
  @Shared var currentUserName: String
  // ...
}

func editNameButtonTapped() {
  destination = .editName(
    EditNameModel(currentUserName: $currentUser.name)
  )
}

부모로부터 받은 name에 대해 수행하는 변경사항은 부모 모델의 사용자들에게 자동으로 적용되고

추가 변경사항은 fileStorage 지속성 전략으로 인해 자동으로 유지됩니다.

즉 자식의 기능은 지속성 전략을 설명하지 않고도 공유된 상태에 접근할 수 있고, 부모는 공유 상태를 유지하고 일부를 파생하여 자식에게 전달할 수 있습니다.

공유상태가 Optional이라면 자연스럽게 언래핑하면 됩니다.

@Shared var currentUser: User?


if let loggedInUser = Shared($currentUser) {
  loggedInUser  // Shared<User>
}

읽기전용으로 만들 수 있습니다.

// Parent feature needs read-write access to the option
@Shared(.appStorage("isHapticsEnabled")) var isHapticsEnabled = true


// Child feature only needs to observe changes to the option
Child(isHapticsEnabled: SharedReader($isHapticsEnabled))

모든 @Shared는 @SharedReader를 통해 읽기 전용으로 만들 수 있습니다.

Reusable, type-safe keys

@Shared(
  .fileStorage(.documentsDirectory.appending(component: "users.json"))
)
var users: [User] = []

지속성 전략을 사용할 때 다음과 같은 작성하기 귀찮은 코드라인을 보신 적이 있습니다.

매번 작성하기에는 번거로울뿐만 아니라 안전하지 않습니다.

만약 이 fileStorage를 여러 위치에서 사용했다고 가정해보겠습니다.

일반 배열대신 IdentifiedArrayOf로 리팩토링하기로 결정한다면 어떻게 할 수 있을까요?

@Shared(.fileStorage(/* ... */)) var users: IdentifiedArrayOf<User> = []

컴파일은 되지만 공유되고 있는 모든 배열을 IdentifiedArrayOf로 변환하지 않는다면 결국 값은 손상되게 되며 이 두가지 유형(일반 배열 IdentifiedArrayOf)은 서로의 상태를 공유하지 않습니다.

extension SharedKey
where Self == FileStorageKey<IdentifiedArrayOf<User>> {
  static var users: Self {
    fileStorage(/* ... */)
  }
}

따라서 다음과 같이 SharedKey protocol을 확장해서 지속성에 대한 정보를 설명하는 정적 변수를 추가합니다.

@Shared(.users) var users: IdentifiedArrayOf<User> = []

그 결과 다음번에 @Shared를 사용할 때 .fileStorage없이 직접 키를 지정할 수 있어요.

이제 일반배열에 값을 할당할 수 없습니다. 의도된 컴파일 에러를 발생시키며 유형이 변했음을 직접 바꿔줄 필요가 없어진 것입니다.

@Shared(.users) var users: [User] = []

🛑 Error 🛑
Cannot convert value of type ‘[User]’ to expected argument type ‘IdentifiedArrayOf

이를 모든 지속성 전략에 사용할 수 있습니다.

// or SharedKey
extension SharedReaderKey
where Self == InMemoryKey<IdentifiedArrayOf<User>> {
  static var users: Self {
    inMemory("users")
  }
}

extension SharedReaderKey where Self == AppStorageKey<Int> {
  static var count: Self {
    appStorage("count")
  }
}

또한 SharedReaderKey에는 기본값을 부여할 수 있는 기능이 존재합니다.

extension SharedReaderKey
where Self == FileStorageKey<IdentifiedArrayOf<User>>.Default {
  static var users: Self {
    Self[.fileStorage(URL(/* ... */)), default: []]
  }
}
// as is
@Shared(.users) var users: IdentifiedArrayOf<User> = []
// to be
@Shared(.users) var users

기본값도 생략하고 type도 생략할 수 있습니다.

Initialization rules

Shared는 속성래퍼를 사용하기 때문에 형식에 대한 사용자 지정 이니셜라이저를 작성할 때 따라야 하는 규칙이 존재합니다.

크게 3가지의 상황이 있습니다.

1. Shared상태가 지속

먼저 지속된다와 비지속된다의 개념을 구분해야 합니다.

지속되는 개념은 지속성 전략에 의해 3가지가 존재합니다.

appStorage, fileStorage, inMemory 총 3가지가 있는데, 이를 지속된다라고 Shared 문서에서는 지속적으로 표현합니다.

지속성 = Persisted

비지속성 = Non-Persisted

class FeatureModel {
  // A piece of shared state that is persisted to an external system.
  @Shared public var count: Int
  // Other fields...


  public init(count: Int, /* Other fields... */) {
    _count = Shared(wrappedValue: count, .appStorage("count"))
    // Other assignments...
  }
}

appStorage, fileStorage inMemory를 같이 사용하는 지속성 전략에서는 Non-Shared값을 사용합니다. (init의 count는 Non-Shared)

count를 클래스의 속성으로 선언했습니다.

지속성 전략은 이니셜라이저(.appStorage)에 잘 나타나있기 때문에 인수 없이 @Shared를 작성할 수 있습니다.

2. Shared를 기능별로 소유함

class FeatureModel {
  // A piece of shared state that this feature will own.
  @Shared public var count: Int
  // Other fields...


  public init(count: Int, /* Other fields... */) {
    _count = Shared(value: count)
    // Other assignments...
  }
}

이 경우에는 SharedKey가 init으로 전달되지 않습니다. count만 받고있는 형태인데 직접 Shared값을 구성하고 있습니다.

FeatureModel이 공유를 시작하는 source of truth로써 동작합니다.

비지속형 공유 상태이기 때문에 FeatureModel의 LifeCycle의 생명주기에 따라 Shared는 공유될 수 있습니다.

즉 이니셜라이저에서 직접 Shared값을 생성했기 때문에 FeatureModel이 Shared의 원 주인(소유)이라는 것을 보장합니다.

3. Shared를 다른 Feature가 소유함

class FeatureModel {
  // A piece of shared state that will be provided by whoever constructs this model.
  @Shared public var count: Int
  // Other fields...


  public init(count: Shared<Int>, /* Other fields... */) {
    _count = count
    // Other assignments...
  }
}

정확히 2번 케이스의 Child라고 보시면 됩니다.

이니셜라이저에서 Shared값을 할당받기 때문에 source of truth는 부모에게 있지만 수정을 가한다면 child, parent 둘다 값이 변경됩니다.

Gotchas of @Shared

shared 상태를 사용할 때 알아야 할 주의사항을 소개합니다.

1. Hashability

공유 유형은 래핑된 값을 기반으로 같다고 할수도 있습니다. 하지만 시간이 지나면 값이 변경될 수 있는 참조에 보관되기 때문에 해시가 불가능합니다. 즉, @Shared 속성을 포함하는 유형은 공유 값에서 해시를 계산해서는 안된다는 것을 의미합니다.

2. Codability

@Shared 타입은 래핑된 값의 원천이 로컬이 아닌 외부에서 오는 경우가 많으므로 조건부 인코딩, 디코딩이 불가능합니다.

인코딩이 가능하거나 디코딩이 가능한 데이터 유형에 shared 상태를 도입할 때 shared를 붙이지 않은 코딩 키를 명시적으로 선언해줍니다.

struct TodosFeature {
  @Shared(.appStorage("launchCount")) var launchCount = 0
  var todos: [String] = []
}


extension TodosFeature: Codable {
  enum CodingKeys: String, CodingKey {
    // 'launchCount'는 케이스에 작성하지 않아야 함
    case todos
  }
}

이와 같이 @Shared가 붙지않은 todos를 codingkey로 둘 수 있습니다.

  init(from decoder: any Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)


    // Do not decode 'launchCount'
    self._launchCount = Shared(wrappedValue: 0, .appStorage("launchCount"))
    self.todos = try container.decode([String].self, forKey: .todos)
  }
  
    func encode(to encoder: any Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(self.todos, forKey: .todos)
    // Do not encode 'launchCount'
  }

그리고는 encode, decode를 수동으로 작성해야 합니다.

3. SwiftUI Views

위에서 한번 소개했었는데요. 뷰가 다시 생성될 때 @Shared 또는 @SharedReader의 키(key)가 재설정될 수 있습니다.

키를 동적으로 변경하는 경우는 다음과 같습니다.

$value.load(.newKey)
// or…
$value = Shared(.newKey)

...
@State.Shared(.key) var value

키가 재설정되는 것을 방지하기 위해서는 @State처럼 작동하는 Shared 버전을 사용하면 됩니다.

Test

shared 상태가 앱의 어느 부분에서나 읽고 수정할 수 있다는 점에서 기본적으로 참조 형식으로 동작합니다. 이는 자칫하면 테스트가 복잡해질 수도 있고, 외부 저장소에서 주입받는일도 빈번한 @Shared에서는 여러 테스트가 서로의 값에 꼬여버릴 수 있는 위험성이 존재합니다.

하지만 Shared는 이를 염두하여 공유되지 않는 일반 상태를 유지하는 것처럼 테스트할 수 있다고 말합니다.

예를들어 appStorage에 저장된 정수를 증가시키는 모델이 있을 때 다음과 같이 테스트 할 수 있습니다.

@Observable
class CounterModel {
  @ObservationIgnored
  @Shared(.appStorage("count")) var count = 0
  func incrementButtonTapped() {
    $count.withLock { $0 += 1 }
  }
}

@Test func increment() {
  let model = CounterModel()
  model.incrementButtonTapped()
  #expect(model.count == 1)
}

@Shared를 전혀 사용하지 않는 방식과 똑같이 간단하게 테스트를 할 수 있습니다.

다른 테스트와 병렬로 실행하고 반복적으로 실행시켜도 100% 통과합니다.

테스트를 진행하는 동안 appStorage전략은 각 테스트 사례에 대해 고유한 임시 사용자 기본값을 프로비저닝하여 각 테스트는 다른 테스트에 영향을 끼치지 않고 사용자의 기본값을 원하는대로 변경할 수 있습니다.

이는 fileStorage와 inMemory와 같은 지속성 전략 모두에서 사용가능한 방식이며 전역 데이터 저장소와 상호작용은 하지만 다른 테스트와는 격리되는 방식으로 작동합니다.

커스텀 지속성 전략을 사용할때는 어떻게 테스트할 수 있을까요?

예를 들어 appStorage를 테스트하고자 합니다. 기본적으로 appStorage 지속성 전략은 제어된 환경에서 실행하기 위해 사용자 지정 값을 주입할 수 있습니다. 앱이 시뮬레이터나 실기기에서 실행될 때는 appStorage 값이 유지되도록 값을 제공합니다. 테스트나 preview환경에서는 조금 다릅니다. 고유한 임시 사용자 기본값을 사용하기 때문에 기본값으로 제공한 값이 보여지게 됩니다.

마찬가지로 fileStorage 또한 파일이 디스크에 기록되고 디스크에서 load되는 방법을 변경하기 위해 내부 종속성을 사용하게 되는데 테스트에서 종속성은 파일 시스템과 상호작용을 포기하는 대신 [URL: Data] 형식의 Dictionary에 데이터를 쓰고 load합니다.

그 결과 파일 시스템의 작동방식을 emulate하지만 다른 테스트로 번질 수 있는 실제 사용하고 있는 글로벌 파일의 데이터와는 격리되어 있게 됩니다.

Overriding shared state in tests (공유상태를 테스트 환경에서 재정의 하는 법)

3가지 지속성 전략과 함께 @Shared를 사용하는 기능을 테스트 할 때 테스트에 대한 초기 값을 당연하게도 설정할 수 있습니다.

일반적으로는 테스트 시작 시 공유 상태를 선언해 기본값을 지정할 수 있도록 작성합니다.

@Test
func basics() {
  @Shared(.appStorage("count")) var count = 42


  // Shared state will be 42 for all features using it.
  let model = FeatureModel()
  // ...
}

만약 테스트가 앱 타겟의 일부인 경우에는 어떨까요?

앱의 entry가 실행되고 잠재적으로 @Shared의 조기 액세스가 발생하게 되므로 위 지정한 42와는 다른 기본값을 캡쳐하게 됩니다.

이는 Xcode에서의 앱 테스트의 문제점이며 테스트를 진행하면서도 많은 문제를 발생시킬 수 있습니다.

가장 간단한 해결 방법은 테스트가 실행중일 때는 앱의 entry를 실행하지 않으면 됩니다.

라이브러리에서는 isTesting을 제공하여 테스트가 실행중인지 확인하여 이 작업을 수행할 수 있습니다.

@main
struct EntryPoint: App {
  var body: some Scene {
    if !isTesting {
      WindowGroup {
        // ...
      }
    }
  }
}

마치며

지금까지 공식문서를 살펴보며 Sharing에 대해 알아봤습니다.

TCA에서는 이와 같은 방식으로 사용되는 내부 라이브러리들이 많이 존재합니다. 각 세부 라이브러리들에 대해 자세히 살펴보지 않으면 히스토리를 따라가기 많이 어렵습니다. 빈번히 업데이트가 되는 것이 좋기도 하지만 공부하는 입장에서는 죽을맛이네요...ㅠㅠ

https://swiftpackageindex.com/pointfreeco/swift-sharing/main/documentation/sharing

profile
아요쓰 정벅하기🐥

0개의 댓글

관련 채용 정보