iOS 2주차 스터디 - 이론

농담고미고미·2025년 3월 24일
0

프론트엔드

목록 보기
9/12
post-thumbnail

개념부터!! 왜 velog에는 목차 기능이 없는걸까?? 불편하다...

이번주는 데이터를 어떻게 묶을지에 대해 공부했다. 개념이 헷갈리기 때문에 따로 정리해야함.

@State와 @Binding

상태 프로퍼티는 뷰의 데이터 변화를 관리하고, 상태가 변경될 때 UI를 자동으로 업데이트하는 역할을 한다.

뷰 내부에서만 상태를 유지할 수 있으며, 뷰가 새로 생성될 때마다 초기화된다. 값이 변경되면 자동으로 UI가 업데이트된다.

@State private var count = 0
(코드생략)
count += 1

count 변수는 $ 없이 바로 사용되었다. $ 표시는 Binding을 통해 값을 전달하거나 @Binding 속성을 가진 변수에 할당할 때 필요하다.

@Binding과 @State의 차이 : 바인딩은 실제 값을 저장하지 않고 상위 뷰의 @State 변수를 참조한다. 바인딩을 사용하면 값이 복사되지 않고, 원본 값의 메모리 주소를 참조하여 직접 수정할 수 있다. @State 변수를 Binding이 필요한 곳에 전달할 때 $ 기호를 붙이면 Binding을 생성하여 원본 값을 직접 수정할 수 있도록 하는 역할을 한다.

바인딩은 값을 복사하지 않고 원본 값을 참조하기 때문에 불필요한 데이터 복사 없이 성능을 최적화한다.

@StateObject와 @ObservedObject

뷰 계층이 복잡해지면 바인딩을 계속 전달하기 어려워지며, 앱의 중요한 상태는 @State만으로 효율적으로 관리하기 어렵다. 그리고 뷰가 상태를 직접 관리하므로 MVVM 구조를 구현하기 어렵다.

ObservableObject 객체는 뷰가 직접 상태를 관리하는 것이 아니라, 상태를 따로 관리하는 객체를 만들고 그 변화를 감지하여 UI를 자동으로 갱신하는 방식을 사용한다. 게시자와 구독자 간의 관계를 쓴다.

이 동작을 위해 ObservableObject와 함께 @Published 속성 래퍼를 사용하면 특정 값이 변경될 때 이를 자동으로 감지하고 UI를 업데이트 할 수 있다.

@Published의 특징 :
ObservableObject 내에서 선언된 속성을 감지하고 병경 사항을 자동으로 알린다.
@Published 속성이 변경되면 이를 구독하는 모든 뷰가 자동으로 업데이트 된다.
SwiftUI에서 @State와 달리 클래스 기반 객체에서 사용 가능하다.

class CounterViewModel: ObservableObject {
	@Published var count: Int = 0
}
struct CounterView: View {
	@ObservedObject var viewModel: CounterViewModel = .init()
    
    var body: some View {
        VStack {
            Text("\(viewModel.count)")
            
            Button(action: {
                viewModel.count += 1
            }, label: {
                Text("카운트 증가 버튼")
            })
        }
    }

여기서 .init()은 뭘까?
.init()은 스위프트에서 생성자 호출을 간략하게 표현하는 문법이다. 보통 객체를 생성할 때 CounterViewModel()처럼 클래스의 이름을 직접 써서 인스턴트를 만들지만, 스위프트에서는 타입이 이미 명확한 경우 .init()으로 생략해서 쓸 수 있다.

그럼 왜 .init()이 가능한가?
변수 viewModel의 타입이 CounterViewModel이라고 이미 명확하게 선언되어 있어서 컴파일러가 CounterViewModel()이라고 안 써도 자동으로 CounterViewModel()을 호출한다.

즉,

var viewModel: CounterViewModel = .init()
➡ 컴파일러가 내부적으로 아래와 같이 변환함

var viewModel: CounterViewModel = CounterViewModel()

그럼 다시 ObservableObject로 돌아와서, 얘의 단점은 뭘까?

@ObservedObject는 객체의 생명주기를 직접 관리하지 않는다. @ObservedObject는 상위 뷰에서 전달 받은 ObservableObject를 구독할 수는 있지만, 뷰가 새로 생성될 때마다 새로운 인스턴스를 참조할 가능성이 있다.
즉, 뷰가 다시 렌더링될 때 기존의 ObservableObject 인스턴스를 유지하는 것이 아니라, 새로운 객체를 참조할 가능성이 있어 기존 상태가 초기화될 위험이 존재한다.

@ObservedObject는 부모 뷰에서 항상 인스턴스를 전달해야 하기 때문에 뷰 계층이 깊어질수록 상태를 전달하는 과정이 복잡해지는 문제가 발생한다.

그렇다면 생명주기란 무엇일까? 생명 주기를 직접 관리하지 않는다는 건 정확히 어떤 의미일까? 또한 위의 단점 사례를 보자.

생명 주기란 객체가 생성되고 사용되다가 소멸하는 과정을 의미한다.

SwiftUI에서 View 생명 주기:
스유에서는 데이터가 변경되면 관련된 뷰가 다시 렌더링되는데, 이 과정에서 뷰가 다시 생성되거나, 사라졌다가 새로 그려질 수 있다. 즉, 뷰는 필요할 때마다 동적으로 생성되고 파괴도는 구조다. 이때 뷰가 다시 생성될 때 기존 상태를 유지할 수 있느냐 없느냐가 중요하다.

뷰가 직접 객체의 생명주기를 관리하지 않는다는 것은 뷰가 새로 생성될 때, 기존 인스턴스를 유지하는 보장이 없다는 뜻이다.

import SwiftUI

class CounterViewModel: ObservableObject {
    @Published var count = 0
}

struct CounterView: View {
    @ObservedObject var viewModel = CounterViewModel() // <- 새로운 객체가 매번 생성될 가능성이 있음

    var body: some View {
        VStack {
            Text("Count: \(viewModel.count)")
            Button("증가") {
                viewModel.count += 1
            }
        }
    }
}

다시 한번 코드를 보면, CounterView는 스유에서 상태 벼놔에 따라 다시 렌더링될 수 있다. 만약 CounterView가 다시 렌더링되면, @ObservedObject var viewModel = CounterViewModel() 부분이 다시 실행된다. 그러면 기전 CounterViewModel의 인스턴스가 아니라, 새로운 CounterViewModel()이 생성된다. 결과적으로 이전 상태(count값)가 초기화되어 0으로 리셋되는 문제가 발생할 수 있다!

import SwiftUI

class CounterViewModel: ObservableObject {
    @Published var count = 0
}

struct ParentView: View {
    @ObservedObject var viewModel = CounterViewModel() // 부모에서 객체를 생성

    var body: some View {
        VStack {
            Text("부모 뷰: \(viewModel.count)")
            ChildView(viewModel: viewModel) // 자식 뷰에 전달
        }
    }
}

struct ChildView: View {
    @ObservedObject var viewModel: CounterViewModel // 부모에서 전달받음

    var body: some View {
        VStack {
            Text("자식 뷰: \(viewModel.count)")
            Button("증가") {
                viewModel.count += 1
            }
        }
    }
}

부모에서 자식으로 반드시 @ObservedObject를 넘겨줘야한다. 그러나 만약 뷰 계층이 3~4단계 이상 깊어진다면? 계속해서 @ObservedObject를 전달해야 해서 코드가 복잡해진다. 또, 모든 뷰가 같은 객체를 공유해야 하므로 의존성이 강해진다.

@ObservedObject는 CounterViewModel의 생명주기를 직접 관리하지 않기 때문에 만약 ParentView가 사라졌다가 다시 생성되면, viewModel도 새로 만들어질 수 있다. 그러면 count 값이 초기화되어 이전 상태를 잃을 위험이 있다!

이러한 단점을 보완하고자 등장한게 @StateObject다.

@StateObject

그럼 뭐가 좋아졌을까?

  1. 뷰가 다시 렌더링되더라도 기존의 상태를 유지한다. @StateObject는 뷰의 생명주기 동안 객체를 유지하며, 뷰가 다시 생성될 때도 새로운 객체를 만들지 않고 기존 객체를 유지한다. 즉, @StateObject를 사용하면 참조를 선언한 뷰가 소유권을 가지게 된다. 그러므로 소유하는 뷰 혹은 자식 뷰에 의해 계속 필요로 하는 동안에는 메모리에서 파괴되지 않는다.

  2. 객체의 생명 주기를 스유가 자동으로 관리한다.
    @StateObject는 객체를 뷰의 생명 주기에 맞춰 자동으로 생성 및 해제하므로, 우리가 직접 객체의 생명 주기를 관리할 필요가 없다.
    메모리 관리와 상태 유지 측면에서 매우 중요한 개선점이다.

  3. 상태 관리가 더욱 직관적으로 이뤄진다.
    @ObservedObject와 달리, 부모 뷰에서 항상 상태를 전달할 필요 없이, 개별 뷰에서 상태를 직접 관리할 수 있다.

스위프트에서 @StateObject를 어떻게 구현한걸까?

@StateObject는 스유의 프로퍼티 래퍼 중 하나이며, 내부적으로 객체의 생명주기를 스유가 직접 관리할 수 있도록 설계되었다.
이를 위해 @StateObject는 일반적인 프로퍼티가 아니라 스유의 상태 저장 메커니즘과 깊게 연관되어 있다.

SwiftUI의 @StayeObject를 직접적으로 구현한 코드를 볼 수는 없지만, 비슷한 방식으로 구현해보자.

  1. @StateObject가 적용된 프로퍼티는 뷰가 생성될 때 단 한 번만 객체를 초기화함.

  2. SwiftUI는 이 객체를 해당 뷰의 생명주기 동안 유지하고 관리함.

  3. 뷰가 다시 렌더링되더라도, 기존의 객체가 유지되도록 동작함.

  4. SwiftUI의 State Storage Mechanism을 이용해 객체의 메모리를 자동으로 관리함.

  5. 내부적으로 DynamicProperty를 준수하여, SwiftUI가 상태 변경을 감지할 수 있도록 함.

라는 특성이 있다는 걸 리마인드하자.

@propertyWrapper
struct CustomStateObject<ObjectType: ObservableObject>: DynamicProperty {
	@State private var storedObject: ObjectType
    
    init(wrappedValue: @autoclosure @escaping() -> ObjectType) {
    	self._storedObject = State(wrappedValue: wrappedValue())
    }
    
    var wrappedValue: ObjectType {
    	get { storedObject }
    }
    
    var projectedValue: ObjectType {
    	get {storedObject}
    }
}

설명:
@propertyWrapper: 스위프트의 프로퍼티 래퍼 기능을 사용하여 @StateObject처럼 동작하는 커스텀 프로퍼티를 만들었다.

DynamicProperty: 스유에서 동적인 상태를 저장하고 변경을 감지할 수 있도록 하는 프로토콜이다. @StateObject도 내부적으로 DynamicProperty를 준수하는 구조체이지 않을까? 싶었다. 이를 통해 스유가 이 객체의 변화를 감지하고, 뷰를 자동으로 업데이트할 수 있게 된다.

@State private var storedObject : @State를 사용해서 내부적으로 객체를 저장하고 관리하도록 했다. @State는 스유에서 상태를 저장하는데, 뷰가 다시 렌더링되더라도 기존의 객체를 유지하는 역할을 한다.

생성자에서 @autoclosure 사용: @autoclosure를 사용하면 wrappedValue로 전달된 인스턴스를 필요할 때만 생성할 수 있다. 객체를 지연 생성(lazy initialization)할 수 있게 한다. 즉, 객체 생성 시점을 SwiftUI가 관리할 수 있게 되는 것이다.

그럼 @escaping은 뭘까?

@autoclosure은 기본적으로 비탈출(Non-Escaping) 클로저다. 즉, 원래 함수 내부에서만 실행할 수 있고, 저장할 수 없다. 하지만 우리는 이 클로저를 State 프로퍼티에 저장해야 하므로, 탈출 가능하게 만들어야 한다. 그래서 @escaping을 붙여서 객체를 나중에 실행할 수 있도록 했다.

그렇다면 클로저는 뭘까?

클로저란 코드에서 변수처럼 저장하고 전달할 수 있는 익명 함수다. 변수처럼 사용할 수 있는 함수라고 생각하면 된다.

func add(_ a: Int, _ b: Int) -> Int {
    return a + b
}

이 함수를 클로저로 변형해보자.

let addClosure: (Int, Int) -> Int = { (a: Int, b: Int) in
	return a + b
}

여기서 { (a: Int, b: Int) in ... } 부분이 클로저(Closure)다.

스위프트는 클로저 문법을 더 짧게 쓸 수 있도록 다양한 Syntax Sugar(축약 문법)을 제공한다.

let addClosure: (Int, Int) -> Int = { a, b in
    return a + b
}

클로저의 타입을 이미 명확하게 선언했으므로 (Int, Int) -> Int 클로저 내부에서 파라미터의 타입을 생략 가능하다.

let addClosure: (Int, Int) -> Int = { a, b in a + b }

클로저 내부의 코드가 한 줄이면 return을 생략할 수 있다.

let addClosure: (Int, Int) -> Int = { $0 + $1 }

$0, $1은 첫 번째, 두 번째 매개변수를 의미한다.

self._storedObject = State(wrappedValue: wrappedValue()) :
wrappedValue()를 호출해서 객체를 한번 실행한 후, State 래퍼에 저장한다. 즉, 뷰가 리렌더링되어도 storedObject는 기존 객체를 유지할 수 있게 된다.

객체 접근 방식으로 wrappedValue와 projectedValue 를 사용했다. wrappedValue 프로퍼티를 사용하면 객체에 접근할 수 있다. projectedValue는 필요하면 $변수명 형태로 바인딩된 객체를 가져올 수도 있다.

warppedValue란?
@propertyWrapper를 사용하면 래퍼 내부에서 감싸고 있는 값을 wrappedValue라고 한다. 즉, wrappedValue는 CustomStateObject 안에서 관리할 실제 객체를 의미한다. 여기서는 ObjectType이 ObservableObject이므로 wrappedValue는 ObservableObject의 인스턴스가 된다.

다시 @StateObject로 돌아가자. 예제코드는 아래와 같다.

/* viewModel 작성 */

import SwiftUI

class CounterViewModel: ObservableObject {
    @Published var count: Int = 0
}
import SwiftUI
  
struct CounterView: View {
    @StateObject var viewModel: CounterViewModel = .init()

    var body: some View {
        VStack {
            Text("\(viewModel.count)")
            
            Button(action: {
                viewModel.count += 1
            }, label: {
                Text("카운트 증가 버튼")
            })
        }
    }
}

그럼 언제 @StateObject와 @ObservedObject를 사용해야할까?

@StateObject를 사용해야하는 경우 :
뷰에서 새로운 ObservableObject를 생성하고, 해당 객체를 해당 뷰의 생명 주기 동안 유지해야할 때 (ViewModel을 뷰에서 직접 생성하여 해당 뷰의 상태를 독립적으로 관리할 경우)
뷰가 다시 생성되더라도 기존 상태를 유지해야할 때 (네트워크 요청 상태를 유지해야하는 경우)

@ObservedObject를 사용해야 하는 경우:
부모 뷰에서 생성된 ObservableObject를 하위 뷰에서 사용해야 할 때,
객체의 생명 주기를 직접 관리하고 싶을 때 (ObservableObject가 특정 뷰에 의존하지 않고, 앱 전반에서 사용될 경우)

@Observalbe 매크로를 활용한 방식: 기존의 @Published 프로퍼티 래퍼와 @ObservableObject 프로토콜을 대체하는 역할을 하며, Swift Macro 기능을 활용하여 자동으로 옵저버블 동작을 수행한다.

/* viewModel */

import Foundation

@Observable
class CounterViewModel {
    var count = 0
}
/* View */

import SwiftUI

struct ContentView: View {
    
    var viewModel: CounterViewModel = .init()
    
    var body: some View {
        VStack {
            
            Text("\(viewModel.count)")
            
            Button(action: {
                viewModel.count += 1
            }, label: {
                Text("카운트 증가합니다.")
            })
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

기존에는 @Binding을 이용했으나 새로운 방식에서는 @Bindable을 활용한다. 다만, @Observable 매크로가 걸려있을 경우에만 이용 가능하다.

import SwiftUI
import Observation

@Observable
class Counter {
	var count = 0
}

struct CounterView: View {
	private var counter = Counter()

    var body: some View {
        VStack {
            Text("Count: \(counter.count)")
            Button("Increment") {
                counter.count += 1
            }
            ChildView(counter: counter)
        }
    }
}

struct ChildView: View {
	@Bindable var counter: Counter
    
    var body: some View {
    	Button("Child Increment") {
        	counter.count += 1
        }
    }
}

@Observable를 사용하면 얻을 수 있는 이점 :
코드가 간결해짐. 클래스에 @Observable만 붙이면 자동으로 상태 변화를 감지한다.
뷰 업데이트 메커니즘이 더 효율적으로 바뀜. 뷰의 body에서 프로퍼티를 직접 읽어야지만 뷰가 업데이트된다.
데이터 모델 객체를 Optional로 가질 수 있다.

@Environment

상위 뷰에서 설정된 값들을 하위 뷰에서 자동으로 가져와 사용할 수 있도록 하는 전역적인 데이터 전달 방식이다.

@Environment의 주요 특징:
뷰 계층을 따라 자동으로 데이터 전달
뷰 간 데이터 전달을 쉽게 할 수 있다 : @Binding처럼 상위 뷰에서 일일이 데이터를 전달할 필요 없어, 전역적으로 설저오딘 값을 사용할 수 있어 보다 간결한 코드 작성이 가능하다.
사용자 정의 @EnvironmentObject 값 활용 가능 : @EnvironmentObject는 ObservableObject를 활용하여 앱의 전역적인 상태를 관리할 때 사용된다.

import SwiftUI

struct EnvironmentExampleView: View {
    @Environment(\.colorScheme) var colorScheme

    var body: some View {
        VStack {
            Text("현재 색상 모드: \(colorScheme == .dark ? "다크 모드" : "라이트 모드")")
                .padding()
                .background(colorScheme == .dark ? Color.black : Color.white)
                .fore(colorScheme == .dark ? Color.white : Color.black)
        }
        .padding()
    }
}

#Preview {
    EnvironmentExampleView()
}

왜 @EnvironmentObject를 사용할까?
전역적으로 상태를 공유할 수 있다.
데이터를 자동으로 업데이트한다. : @Published 속성을 사용하면 데이터가 변경될 때 자동으로 UI가 갱신된다.

class UserViewModel: ObservableObject {
	@Published var username: String = "초기 사용자"
}
import SwiftUI

struct ParentView: View {
	@StateObject var userViewModel: UserViewModel = .init()
    
    var body: some View {
    	NavigationStack {
        	VStack {
            	Text("현재 사용자: \(userViewModel.username)")
                    .font(.title)
                    
                NavigationLink("프로필 화면으로 이동", destination: ProfileView().environmentObject(userViewModel)
                NavigationLink("설정 화면으로 이동",
                               destination: SettingsView().environmentObject(userViewModel)
                )
            }
            
        }
    }
}
/* 자식 뷰(프로필 뷰) */
import SwiftUI

struct ProfileView: View {
    @EnvironmentObject var userViewModel: UserViewModel  // 부모 뷰에서 전달된 환경 객체 사용

    var body: some View {
        VStack {
            Text("프로필 화면")
                .font(.largeTitle)

            Text("사용자 이름: \(userViewModel.username)")  // 부모 뷰의 상태가 자동 반영됨
                .font(.title)

            Button("이름 변경") {
                userViewModel.username = "새로운 사용자"  // 값이 변경되면 모든 뷰에 즉시 반영
            }
            .padding()
            .background(Color.blue)
            .foregroundStyle(.white)
        }
    }
}

#Preview {
    ProfileView()
        .environmentObject(UserViewModel())
}
/* 자식 뷰(환경 설정 뷰) */
import SwiftUI

struct SettingsView: View {
    @EnvironmentObject var userViewModel: UserViewModel  // 동일한 전역 객체 사용

    var body: some View {
        VStack {
            Text("설정 화면")
                .font(.largeTitle)

            TextField("사용자 이름 변경", text: $userViewModel.username)  // TextField로 직접 변경 가능
                .textFieldStyle(RoundedBorderTextFieldStyle())
        }
    }
}

#Preview {
    SettingsView()
        .environmentObject(UserViewModel())
}

@AppStorage

우선 UserDefaults부터 보자. UserDefaults는 싱글톤 패턴을 따르는 클래스로 설계되어 있다.

UserDefaults를 통해 데이터를 저장하려면 UserDefaults.standard 객체의 set(_:forKey:) 메서드를 사용한다.

let defaults = UserDefaults.standard

// 기본 자료형 데이터 저장하기
defaults.set(true, forKey: "isLoggedIn")   // Bool 값 저장
defaults.set(25, forKey: "userAge")        // Int 값 저장
defaults.set("John", forKey: "username")   // String 값 저장

// 배열과 딕셔너리 저장하기
defaults.set(["Apple", "Banana", "Orange"], forKey: "fruits")
defaults.set(["name": "John", "age": 25], forKey: "userDetails")

@AppStorage는 내부적으로 UserDefaults를 활용하며, 간단한 키-값 저장 방식을 제공한다.

@AppStorage("username") var username: String = "Guest"

위 코드는 기본 형태입니다. “userName”이라는 키에 해당하는 값이 UserDefaults에 저장된다.

앱을 종료하고 다시 실행해도 “userName” 키의 값은 유지된다.

GeometryReader

스유에서 화면 크기나 뷰의 상대적인 위치를 동적으로 조정할 때 사용한다.

import SwiftUI


struct ContentView: View {

	var screenSize: CGRect {
    	guard let windowScene = UIApplication.shared.connectedScnes.first as? UIWindowScene else {
        	return .zero
        }
        return windowScene.screen.bounds
    }
    
    var body: some View {
        VStack {
                    Text("Screen Width: \(screenSize.width)")
                    Text("Screen Height: \(screenSize.height)")
                }
    }
}

UIApplication.shared는 현재 실행 중인 앱의 공용(application-wide) 객체이다. iOS 앱에서 앱 전체의 상태를 관리하는 싱글톤 객체(Singleton Object) 이다. 앱의 생명 주기(lifecycle), 알림(notification), UI 상태 관리 등을 담당한다.

Scene이란?
앱에서 여러 개의 화면(윈도우)를 가질 수 있으며, 각 윈도우는 Scene 객체로 관리된다. connectedScenes에는 현재 실행 중인 모든 Scene이 포함된다.

.first as? UIWindowScene:
connectedScenes는 Set 타입이라 순서가 없지만, first를 사용하면 첫 번째 Scene을 가져온다. UIWindowScene은 앱이 실행 중인 화면(Window)을 포함하는 Scene이다. iOS에서 UIWindowScene을 통해 현재 화면의 크기, 상태 등을 가져올 수 있다.

return windowScene.screen.bounds:
windowScene.screen은 현재 UIWindowScene이 연결된 UIScreen 객체를 나타낸다. UIScreen은 iPhone, iPad의 물리적인 화면 정보를 제공한다. screen.bounds는 해당 화면의 크기를 CGRect로 반환한다. 이 값은 픽셀 단위가 아니라 point 단위로 주어진다. 예를 들어, iPhone 14 Pro Max의 실제 픽셀 해상도는 2796 × 1290이지만, bounds의 크기는 430 × 932이다. 이는 iOS가 점(point) 단위로 좌표를 관리하고, 필요에 따라 Retina 스케일(배율) 을 적용하기 때문이다.

디바이스 화면 크기에 따라 자동으로 크기 조절되는 버튼 만들기

import SwiftUI

struct ResponsiveButtonView: View {
    var body: some View {
        GeometryReader { geometry in
            VStack {
                Text("반응형 버튼")
                    .frame(width: geometry.size.width * 0.6, height: geometry.size.height * 0.1)
                    .background(Color.blue)
                    .foregroundStyle(.white)
                    .clipShape(RoundedRectangle(cornerRadius: 10))
            }
            .frame(width: geometry.size.width, height: geometry.size.height)
        }
    }
}
profile
농담곰을 좋아해요 말랑곰탱이

0개의 댓글