[iOS 어플 개발][SwfitUI] AppStorage에 대한 이해 부족이 불러온 참사!!
안드로이드와 다르게 아이폰의 동작은 뭔가 다르다. 설정페이지 화면에서 핀설정을 하면, intro화면에서 핀설정 여부를 체크 후 핀 설정했다면 핀 화면을 띄우도록 개발했는데, 이때 해당 여부 값을 AppStorage를 사용하고 있다.
그런데 설정페이지에서 핀설정 토글을 활성화 하면 AppStorage에 저장하는데, 뜬금없이 앱이 종료 후 intro 페이지를 호출한것처럼 나타나고, 핀번호 입력화면이 뜬다.
이 원인을 찾기 위해 너무 많은 시간 삽질을 했다. 코딩을 도와준 제미나이에게 물어도 엉뚱한 대답만 하길래, 쳇GPT한테 해당 코드를 보여주고, 이런 증상이 나타난다고 했더니, 해결법을 알려주더라….
무엇보다 AppStorage를 사용했을 때 여파를 전혀 모르고 있었다는 것이다. ios개발은 처음이라 이렇게 하나씩 배워가는 중이다.
AppStorage
AppStorage("isPinEnabled")
와 같이 **@AppStorage
**를 사용하면 SwiftUI에서는 전역적으로 값이 변경되었음을 알리고, 해당 값을 사용하는 모든 뷰가 다시 렌더링된다네?? 와우.. 이건 무슨 신선한 액션인가? 안드로이드만 개발해오던 나로써는 신세계로 느껴질 정도이다.
만약 다른 뷰나 다른 탭, 다른 Scene에서도 이 @AppStorage("isPinEnabled")
값을 사용하고 있다면, 그 뷰들도 갱신되며 .onAppear
가 실행될 수 있다.
즉, SettingsView
와는 관계 없는 어떤 관련 뷰가 내부에서 @AppStorage("isPinEnabled")
를 참조하고 있고, 그 뷰가 다시 초기화되면서 실행되는 것이다.
@AppStorage
는 기본적으로 UserDefaults
에 매핑되며, 이를 사용하면 내부적으로 KVO(Key-Value Observing)가 작동된다. 특정 키가 변경되면 관련 뷰 혹은 로직들이 알림을 받고 작동할 수 있다.
탭 기반 앱에서 TabView
또는 NavigationStack
구조와의 간접 연관
만약 WebView를 포함한 뷰가 다른 탭에 있거나 Navigation Stack 상에 존재하는 경우에도 @AppStorage
변경이 해당 뷰의 body
를 다시 계산하게 만들 수 있다.
✅ 해결 방안
1. 값 변경 후 뷰 재평가를 피하는 방법
@AppStorage
는 자동으로 뷰를 트리거하므로, 민감한 타이밍에서 직접 State/Environment로 관리- 아래처럼 중간에 값을 복사해두고, 변경될 때 반응하지 않도록 할 수 있다.
struct IntroView: View {
@EnvironmentObject var appSettings: AppSettings
@State private var isPinEnabledSnapshot: Bool = false
@State private var showNextScreen = false
@State private var isAuthenticated = false
var body: some View {
VStack {
// intro content ...
Button("다음") {
isPinEnabledSnapshot = appSettings.isPinLockEnabled // snapshot 저장
showNextScreen = true
}
}
.fullScreenCover(isPresented: $showNextScreen) {
// snapshot 기준으로 조건 확인
if isPinEnabledSnapshot && appSettings.userPinHashSet {
LockScreenView(isAuthenticated: $isAuthenticated)
.environmentObject(appSettings)
} else {
MainView()
}
}
}
}
이 방식은:
- 버튼 클릭 시점에 pin 설정을 스냅샷으로 가져오고
- 이후 pin 설정 변경이 생겨도
.fullScreenCover
트리거에 영향을 주지 않도록 방지.
2. AppSettings에서 Combine을 써서 수동 통제
더 확장하고 싶다면 AppSettings
를 ObservableObject
로 유지하면서 @Published
대신 @Published private(set)
+ 메서드로 값 변경을 제어할 수도 있다. 뷰에서 값이 바뀌었다는 사실을 모르게 하는 거죠.
AppSettings
를 ObservableObject
로 유지하면서 @Published private(set)
을 사용하고, 값을 바꿀 때는 메서드를 통해서만 변경하도록 하면 뷰에서는 값을 변경해도 알 수 없게 된다. 이렇게 하면 SwiftUI 뷰 트리가 원하지 않는 시점에 다시 그려지는 걸 방지할 수 있다.
AppSettings 코드 리팩토링 예시
- 외부에서 직접
isPinLockEnabled
를 변경하지 못하도록 보호
AppSettings
에서만isPinLockEnabled
를 변경하도록 제어
- SwiftUI 뷰에서는 해당 변경이 즉시 반영되지 않게 하여
.fullScreenCover
트리거 방지
import Foundation
import Combine
class AppSettings: ObservableObject {
// 외부에서는 읽기만 가능, 내부에서는 수정 가능
@Published private(set) var isPinLockEnabled: Bool
@Published private(set) var userPinHashSet: Bool
private var cancellables = Set<AnyCancellable>()
init() {
// UserDefaults에서 초기값 로드
self.isPinLockEnabled = UserDefaults.standard.bool(forKey: "isPinEnabled")
self.userPinHashSet = UserDefaults.standard.bool(forKey: "pinHashSet")
}
// 메서드로만 값을 변경하게 제한
func enablePinLock(_ enabled: Bool) {
isPinLockEnabled = enabled
UserDefaults.standard.set(enabled, forKey: "isPinEnabled")
}
func setPinHashSet(_ hasSet: Bool) {
userPinHashSet = hasSet
UserDefaults.standard.set(hasSet, forKey: "pinHashSet")
}
}
사용하는 뷰에서 변경하기
@AppStorage("isPinEnabled")
대신에 이제는 appSettings.enablePinLock(true)
처럼 명시적으로 호출해야 하며, 뷰는 isPinLockEnabled
의 변경을 감지하긴 하지만, 값이 private(set)
이므로 직접 수정은 못 함.
struct SettingsView: View {
@EnvironmentObject var appSettings: AppSettings
var body: some View {
Toggle("PIN 잠금 사용", isOn: Binding(
get: { appSettings.isPinLockEnabled },
set: { newValue in
appSettings.enablePinLock(newValue)
}
))
}
}
이렇게 하면
- wiftUI는 여전히
isPinLockEnabled
상태를 표시 가능
- 하지만 내부적으로만 값이 변경되며 외부에서의 직접 변경으로 인한 예기치 않은 리렌더링 방지
✅ 추가 팁: 값 변경에 따른 부가 처리도 가능
func enablePinLock(_ enabled: Bool) {
if isPinLockEnabled != enabled {
isPinLockEnabled = enabled
UserDefaults.standard.set(enabled, forKey: "isPinEnabled")
// 필요하다면 부가작업 수행
if enabled {
print("PIN 잠금 활성화됨")
}
}
}
항목 | 설명 |
---|---|
@Published private(set) | 외부에서는 읽기만 가능, 내부에서만 수정 |
enablePinLock(_: Bool) | 명시적 메서드로 상태 변경 |
SwiftUI 리렌더 방지 | 값 변경을 직접적으로 안 하고 뷰에서 간접적으로만 반영하므로 안전 |
필요하면 이 설정을 전역적으로 공유할 수 있게 @main
에서 .environmentObject(appSettings)
해둔다.
정리
@AppStorage
는 편하지만 값 변경 시 모든 관련 뷰가 리렌더링되어 의도치 않은 동작 유발- 스냅샷을 만들어
.fullScreenCover
조건을 고정하면 해결 가능 - 혹은 AppSettings에서 Combine 또는 수동 트리거 방식 사용