Performance
Overview
As your features and application grow you may run into performance problems, such as reducers becoming slow to execute, SwiftUI view bodies executing more often than expected, and more. This article outlines a few common pitfalls when developing features in the library, and how to fix them.
기능과 애플리케이션이 확장됨에 따라, 리듀서(reducer)의 실행 속도가 느려지거나 SwiftUI 뷰가 예상보다 더 자주 실행되는 등의 성능 문제에 직면할 수 있습니다. 이 글에서는 라이브러리에서 기능을 개발할 때 흔히 겪는 몇 가지 문제점과 이를 해결하는 방법에 대해 설명합니다.
Sharing logic with actions
There is a common pattern of using actions to share logic across multiple parts of a reducer. This is an inefficient way to share logic. Sending actions is not as lightweight of an operation as, say, calling a method on a class. Actions travel through multiple layers of an application, and at each layer a reducer can intercept and reinterpret the action.
리듀서(reducer)에서 여러 부분에 걸쳐 로직을 공유하기 위해 액션을 사용하는 일반적인 패턴이 있습니다. 그러나 이는 비효율적인 로직 공유 방법입니다. 액션을 보내는 것은 클래스의 메서드를 호출하는 것처럼 가벼운 작업이 아닙니다. 액션은 애플리케이션의 여러 계층을 거치며, 각 계층에서 리듀서는 액션을 가로채고 다시 해석할 수 있습니다.
It is far better to share logic via simple methods on your Reducer conformance. The helper methods can take inout State as an argument if it needs to make mutations, and it can return an Effect<Action>. This allows you to share logic without incurring the cost of sending needless actions.
로직을 공유하는 더 나은 방법은 Reducer 준수(conformance)에서 간단한 메서드를 통해 공유하는 것입니다. 이러한 도우미 메서드는 상태(State)를 변경해야 할 경우 inout으로 받아들일 수 있으며, Effect<Action>을 반환할 수 있습니다. 이렇게 하면 불필요한 액션을 보내는 비용 없이 로직을 공유할 수 있습니다.
For example, suppose that there are 3 UI components in your feature such that when any is changed you want to update the corresponding field of state, but then you also want to make some mutations and execute an effect. That common mutation and effect could be put into its own action and then each user action can return an effect that immediately emits that shared action:
예를 들어, 기능 내에 3개의 UI 구성 요소가 있고, 이들 중 하나가 변경될 때마다 상태의 해당 필드를 업데이트하고, 추가로 일부 변형(mutation)을 수행하고 효과(effect)를 실행하고 싶다고 가정해 보겠습니다. 그 공통적인 변형과 효과를 자체 액션으로 분리한 후, 각 사용자 액션이 즉시 해당 공유 액션을 방출하는 효과를 반환할 수 있습니다.
@Reducer
struct Feature {
@ObservableState
struct State { /* ... */ }
enum Action { /* ... */ }
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .buttonTapped:
state.count += 1
return .send(.sharedComputation)
case .toggleChanged:
state.isEnabled.toggle()
return .send(.sharedComputation)
case let .textFieldChanged(text):
state.description = text
return .send(.sharedComputation)
case .sharedComputation:
// Some shared work to compute something.
return .run { send in
// A shared effect to compute something
}
}
}
}
}
This is one way of sharing the logic and effect, but we are now incurring the cost of two actions even though the user performed a single action. That is not going to be as efficient as it would be if only a single action was sent.
이 방법은 로직과 효과를 공유하는 한 가지 방식이지만, 사용자가 단일 액션을 수행했음에도 불구하고 두 개의 액션 비용이 발생한다는 단점이 있습니다. 이는 단일 액션만 전송했을 때보다 효율적이지 않습니다.
Besides just performance concerns, there are two other reasons why you should not follow this pattern. First, this style of sharing logic is not very flexible. Because the shared logic is relegated to a separate action it must always be run after the initial logic. But what if instead you need to run some shared logic before the core logic? This style cannot accommodate that.
성능 문제 외에도 이 패턴을 따르지 말아야 할 두 가지 이유가 있습니다. 첫째, 이 스타일의 로직 공유는 유연성이 부족합니다. 공유 로직이 별도의 액션으로 분리되었기 때문에 항상 초기 로직 이후에 실행되어야 합니다. 하지만 경우에 따라서는 핵심 로직 이전에 공유 로직을 실행해야 할 수도 있습니다. 이 스타일로는 이를 처리할 수 없습니다.
Second, this style of sharing logic also muddies tests. When you send a user action you have to further assert on receiving the shared action and assert on how state changed. This bloats tests with unnecessary internal details, and the test no longer reads as a script from top-to-bottom of actions the user is taking in the feature:
둘째, 이 스타일의 로직 공유는 테스트를 복잡하게 만듭니다. 사용자가 액션을 보낼 때, 공유 액션을 받는지에 대해 추가적으로 확인해야 하며, 상태가 어떻게 변경되었는지도 검증해야 합니다. 이는 테스트를 불필요한 내부 세부사항으로 부풀리게 하고, 더 이상 테스트가 기능 내에서 사용자가 수행하는 액션의 순서대로 읽히지 않게 됩니다.
let store = TestStore(initialState: Feature.State()) {
Feature()
}
store.send(.buttonTapped) {
$0.count = 1
}
store.receive(\.sharedComputation) {
// Assert on shared logic
}
store.send(.toggleChanged) {
$0.isEnabled = true
}
store.receive(\.sharedComputation) {
// Assert on shared logic
}
store.send(.textFieldChanged("Hello") {
$0.description = "Hello"
}
store.receive(\.sharedComputation) {
// Assert on shared logic
}
So, we do not recommend sharing logic in a reducer by having dedicated actions for the logic and executing synchronous effects.따라서, 로직을 전용 액션으로 분리하고 동기적 효과를 실행하는 방식으로 리듀서에서 로직을 공유하는 것을 권장하지 않습니다.
Instead, we recommend sharing logic with methods defined in your feature’s reducer. The method has full access to all dependencies, it can take an inout State if it needs to make mutations to state, and it can return an Effect<Action> if it needs to execute effects.
대신, 기능의 리듀서에서 정의된 메서드를 통해 로직을 공유하는 것을 추천합니다. 이 메서드는 모든 의존성에 완전히 접근할 수 있으며, 상태(State)에 변화를 줄 필요가 있다면 inout State를 인자로 받을 수 있고, 효과(effect)를 실행할 필요가 있다면 Effect을 반환할 수 있습니다.
The above example can be refactored like so:
위의 예제를 다음과 같이 리팩터링할 수 있습니다:
@Reducer
struct Feature {
@ObservableState
struct State { /* ... */ }
enum Action { /* ... */ }
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .buttonTapped:
state.count += 1
return self.sharedComputation(state: &state)
case .toggleChanged:
state.isEnabled.toggle()
return self.sharedComputation(state: &state)
case let .textFieldChanged(text):
state.description = text
return self.sharedComputation(state: &state)
}
}
}
func sharedComputation(state: inout State) -> Effect<Action> {
// Some shared work to compute something.
return .run { send in
// A shared effect to compute something
}
}
}
You have complete flexibility to decide how, when and where you want to execute the shared logic.
이 접근 방식을 사용하면, 공유 로직을 어떻게, 언제, 어디서 실행할지 완전히 자유롭게 결정할 수 있습니다.
Further, tests become more streamlined since you do not have to assert on internal details of shared actions being sent around. The test reads like a user script of what the user is doing in the feature:
또한, 테스트가 더 간결해집니다. 공유 액션의 내부 세부사항에 대해 검증할 필요가 없기 때문에, 테스트는 기능 내에서 사용자가 수행하는 동작을 그대로 보여주는 사용자 스크립트처럼 읽히게 됩니다.
let store = TestStore(initialState: Feature.State()) {
Feature()
}
store.send(.buttonTapped) {
$0.count = 1
// Assert on shared logic
}
store.send(.toggleChanged) {
$0.isEnabled = true
// Assert on shared logic
}
store.send(.textFieldChanged("Hello") {
$0.description = "Hello"
// Assert on shared logic
}
Sharing logic in child features
There is another common scenario for sharing logic in features where the parent feature wants to invoke logic in a child feature. One can technically do this by sending actions from the parent to the child, but we do not recommend it (see above in Sharing logic with actions to learn why):
다른 로직 공유 시나리오로는 부모 기능이 자식 기능의 로직을 호출하려는 경우가 있습니다. 기술적으로는 부모에서 자식으로 액션을 보내는 방식으로 이를 수행할 수 있지만, 우리는 이 방법을 권장하지 않습니다(위의 "액션을 통한 로직 공유"에서 그 이유를 참조하세요).
// Handling action from parent feature:
case .buttonTapped:
// Send action to child to perform logic:
return .send(.child(.refresh))
Instead, we recommened invoking the child reducer directly:
case .buttonTapped:
return Child().reduce(into: &state.child, action: .refresh)
.map(Action.child)
CPU intensive calculations
Reducers are run on the main thread and so they are not appropriate for performing intense CPU work. If you need to perform lots of CPU-bound work, then it is more appropriate to use an Effect, which will operate in the cooperative thread pool, and then send actions back into the system. You should also make sure to perform your CPU intensive work in a cooperative manner by periodically suspending with Task.yield() so that you do not block a thread in the cooperative pool for too long. So, instead of performing intense work like this in your reducer:
리듀서(reducer)는 메인 스레드에서 실행되므로, CPU 집약적인 작업을 수행하는 데 적합하지 않습니다. 많은 CPU 작업을 수행해야 하는 경우에는, 협력형 스레드 풀에서 작동하는 Effect를 사용하는 것이 더 적절하며, 그런 다음 액션을 시스템으로 다시 보내야 합니다. 또한, CPU 집약적인 작업을 수행할 때는 Task.yield()를 통해 주기적으로 작업을 일시 중단하여 협력형 스레드 풀의 스레드를 너무 오랫동안 차단하지 않도록 해야 합니다. 따라서, 리듀서에서 이와 같은 고강도 작업을 수행하는 대신:
case .buttonTapped:
var result = // ...
for value in someLargeCollection {
// Some intense computation with value
}
state.result = result
…you should return an effect to perform that work, sprinkling in some yields every once in awhile, and then delivering the result in an action:
case .buttonTapped:
return .run { send in
var result = // ...
for (index, value) in someLargeCollection.enumerated() {
// Some intense computation with value
// Yield every once in awhile to cooperate in the thread pool.
if index.isMultiple(of: 1_000) {
await Task.yield()
}
}
await send(.computationResponse(result))
}
case let .computationResponse(result):
state.result = result
This will keep CPU intense work from being performed in the reducer, and hence not on the main thread.
High-frequency actions
Sending actions in a Composable Architecture application should not be thought as simple method calls that one does with classes, such as ObservableObjectconformances. When an action is sent into the system there are multiple layers of features that can intercept and interpret it, and the resulting state changes can reverberate throughout the entire application.
Composable Architecture 애플리케이션에서 액션을 보내는 것을 클래스에서 ObservableObject와 같은 것을 사용할 때의 간단한 메서드 호출로 생각해서는 안 됩니다. 액션이 시스템에 전송되면 여러 기능 계층에서 이를 가로채고 해석할 수 있으며, 그 결과로 상태 변화가 애플리케이션 전체에 영향을 미칠 수 있습니다.
Because of this, sending actions does come with a cost. You should aim to only send “significant” actions into the system, that is, actions that cause the execution of important logic and effects for your application. High-frequency actions, such as sending dozens of actions per second, should be avoided unless your application truly needs that volume of actions in order to implement its logic.
이 때문에, 액션을 보내는 데는 비용이 수반됩니다. 시스템에 “중요한” 액션만 보내는 것이 목표가 되어야 하며, 이는 애플리케이션의 중요한 로직과 효과를 실행하는 액션을 의미합니다. 초당 수십 개의 액션을 보내는 것과 같은 고빈도 액션은 애플리케이션의 로직 구현에 정말로 필요한 경우가 아니면 피해야 합니다.
However, there are often times that actions are sent at a high frequency but the reducer doesn’t actually need that volume of information. For example, say you were constructing an effect that wanted to report its progress back to the system for each step of its work. You could choose to send the progress for literally every step:
하지만, 종종 고빈도로 액션이 전송되지만 리듀서가 실제로는 그 정도의 정보가 필요하지 않은 경우가 있습니다. 예를 들어, 어떤 작업의 각 단계에서 진행 상황을 시스템에 보고하는 효과를 작성하고 있다고 가정해 보겠습니다. 이때, 모든 단계의 진행 상황을 문자 그대로 보낼 수 있을 것입니다:
case .startButtonTapped:
return .run { send in
var count = 0
let max = await self.eventsClient.count()
for await event in self.eventsClient.events() {
defer { count += 1 }
send(.progress(Double(count) / Double(max)))
}
}
}
However, what if the effect required 10,000 steps to finish? Or 100,000? Or more? It would be immensely wasteful to send 100,000 actions into the system to report a progress value that is only going to vary from 0.0 to 1.0.
Instead, you can choose to report the progress every once in awhile. You can even do the math to make it so that you report the progress at most 100 times:
하지만, 만약 효과가 완료되려면 10,000단계, 100,000단계 또는 그 이상의 단계가 필요하다면 어떻게 될까요? 진행 상황 값이 0.0에서 1.0까지 변화하는 것을 보고하기 위해 100,000개의 액션을 시스템에 전송하는 것은 매우 낭비적입니다.
대신, 진행 상황을 일정 간격으로 보고하도록 선택할 수 있습니다. 예를 들어, 최대 100번만 진행 상황을 보고하도록 계산할 수도 있습니다. 이렇게 하면 시스템에 보내는 액션의 수를 크게 줄일 수 있으며, 효율성을 높일 수 있습니다.
case .startButtonTapped:
return .run { send in
var count = 0
let max = await self.eventsClient.count()
let interval = max / 100
for await event in self.eventsClient.events() {
defer { count += 1 }
if count.isMultiple(of: interval) {
send(.progress(Double(count) / Double(max)))
}
}
}
}
This greatly reduces the bandwidth of actions being sent into the system so that you are not incurring unnecessary costs for sending actions.
Another example that comes up often is sliders. If done in the most direct way, by deriving a binding from the view store to hand to a Slider:
Slider(value: viewStore.$opacity, in: 0...1)
This will send an action into the system for every little change to the slider, which can be dozens or hundreds of actions as the user is dragging the slider. If this turns out to be problematic then you can consider alternatives.
이 방식은 슬라이더가 변경될 때마다 액션을 시스템에 전송하게 되며, 사용자가 슬라이더를 드래그하는 동안 수십 개 또는 수백 개의 액션이 전송될 수 있습니다. 만약 이런 방식이 문제가 된다면 대안을 고려할 수 있습니다.
For example, you can hold onto some local @State in the view for using with the Slider, and then you can use the trailing onEditingChanged closure to send an action to the store:
예를 들어, 뷰에서 슬라이더와 함께 사용할 @State를 로컬로 보유하고, onEditingChanged 클로저를 사용하여 액션을 저장소에 전송하는 방법을 고려할 수 있습니다.
Slider(value: self.$opacity, in: 0...1) {
self.store.send(.setOpacity(self.opacity))
}
This way an action is only sent once the user stops moving the slider.
이렇게 하면 사용자가 슬라이더의 조작을 끝낼 때만 액션을 전송할 수 있습니다.
Store scoping
In the 1.5.6 release of the library a change was made to scope(state:action:)that made it more sensitive to performance considerations.
라이브러리의 1.5.6 릴리스에서 scope(state:action:)에 대한 변경이 있어 성능 고려 사항에 더 민감해졌습니다.
The most common form of scoping, that of scoping directly along boundaries of child features, is the most performant form of scoping and is the intended use of scoping. The library is slowly evolving to a state where that is the only kind of scoping one can do on a store.
자식 기능의 경계에 직접 스코프를 설정하는 가장 일반적인 형태의 스코프가 가장 성능이 뛰어난 스코프 형태이며, 스코프의 의도된 사용 방식입니다. 라이브러리는 현재 그런 형태의 스코프만을 지원하는 방향으로 점차 발전하고 있습니다.
The simplest example of this directly scoping to some child state and actions for handing to a child view:
자식 상태와 액션을 직접 스코프하여 자식 뷰에 전달하는 가장 간단한 예는 다음과 같습니다
ChildView(
store: store.scope(state: \.child, action: \.child)
)
Another example is scoping to some collection of a child domain in order to use with ForEachStore:
ForEachStore(store.scope(state: \.rows, action: \.rows)) { store in
RowView(store: store)
}
And similarly for IfLetStore and SwitchStore. And finally, scoping to a child domain to be used with one of the libraries navigation view modifiers, such as sheet(store:onDismiss:content:), also falls under the intended use of scope:
.sheet(store: store.scope(state: \.child, action: \.child)) { store in
ChildView(store: store)
}
All of these examples are how scope(state:action:) is intended to be used, and you can continue using it in this way with no performance concerns.
이 모든 예시는 scope(state:action:)가 의도된 방식으로 사용되는 방법이며, 이 방식으로 계속 사용해도 성능 문제는 없습니다.
Where performance can become a concern is when using scope on computedproperties rather than simple stored fields. For example, say you had a computed property in the parent feature’s state for deriving the child state:
성능 문제가 발생할 수 있는 경우는 단순한 저장 필드가 아닌 계산된 속성에서 scope를 사용할 때입니다. 예를 들어, 부모 기능의 상태에서 자식 상태를 파생하기 위해 계산된 속성이 있는 경우를 가정해 보겠습니다:
extension ParentFeature.State {
var computedChild: ChildFeature.State {
ChildFeature.State(
// Heavy computation here...
)
}
}
And then in the view, say you scoped along that computed property:
ChildView(
store: store.scope(state: \.computedChild, action: \.child)
)
If the computation in that property is heavy, it is going to become exacerbated by the changes made in 1.5, and the problem worsens the closer the scoping is to the root of the application.
해당 속성에서의 계산이 무거운 경우, 1.5에서의 변경으로 인해 문제가 악화되며, 스코프가 애플리케이션의 루트에 가까워질수록 문제는 더욱 심각해집니다.
The problem is that in version 1.5 scoped stores stopped directly holding onto their local state, and instead hold onto a reference to the store at the root of the application. And when you access state from the scoped store, it transforms the root state to the child state on the fly.
문제는 1.5 버전에서 스코프된 스토어가 직접 로컬 상태를 유지하지 않고, 대신 애플리케이션 루트의 스토어에 대한 참조만을 유지하게 되었다는 점입니다. 그리고 스코프된 스토어에서 상태에 접근할 때, 루트 상태를 자식 상태로 실시간 변환합니다.
This transformation will include the heavy computed property, and potentially compute it many times if you need to access multiple pieces of state from the store. If you are noticing a performance problem while depending on 1.5+ of the library, look through your code base for any place you are using computed properties in scopes. You can even put a print statement in the computed property so that you can see first hand just how many times it is being invoked while running your application.
이 변환에는 무거운 계산된 속성도 포함되며, 스토어에서 여러 상태 조각에 접근해야 할 경우, 이를 여러 번 계산할 수 있습니다. 라이브러리 1.5+ 버전에서 성능 문제가 발생하는 경우, 코드베이스에서 스코프에 계산된 속성을 사용하는 부분을 찾아보십시오. 애플리케이션을 실행하는 동안 계산된 속성이 얼마나 자주 호출되는지 직접 확인하기 위해 계산된 속성에 프린트 문을 넣어볼 수도 있습니다.
To fix the problem we recommend using scope(state:action:) only along stored properties of child features. Such key paths are simple getters, and so not have a problem with performance. If you are using a computed property in a scope, then reconsider if that could instead be done along a plain, stored property and moving the computed logic into the child view. The further you push the computation towards the leaf nodes of your application, the less performance problems you will see.
문제를 해결하기 위해서는 scope(state:action:)를 자식 기능의 저장된 속성에만 사용하길 권장합니다. 이러한 키 경로는 단순한 getter이므로 성능 문제를 일으키지 않습니다. 만약 스코프에서 계산된 속성을 사용하고 있다면, 그것을 단순 저장된 속성으로 변경하고 계산 로직을 자식 뷰로 이동하는 것을 고려해보십시오. 애플리케이션의 리프 노드로 계산을 더 많이 밀어넣을수록 성능 문제를 줄일 수 있습니다.
'Swift > TCA' 카테고리의 다른 글
[TCA] 왜 Dependency 라이브러리를 사용해야하나요? 그냥 init에 의존성 전달하면 되는데...(커뮤니티 질문) (0) | 2024.11.09 |
---|---|
[TCA] Effect.swift 공식문서 음미하기 (0) | 2024.08.24 |
[TCA] day 13 타이머 (Effects- Timer) (0) | 2024.05.08 |
[TCA] day 12 새로고침 (Effects-Refreshable) (0) | 2024.05.07 |
[TCA] day 10 long living Effect (publisher Effect and async/await effect) (0) | 2024.05.05 |