본문 바로가기
Category/iOS

@StateObject와 @ObservedObject의 상태 관리 방식 차이

by Corinee 2024. 9. 21.
728x90
반응형

로그인 화면과 상태 관리를 다루는 SwiftUI 예제를 통해 차이점을 알아봅시다.

import SwiftUI

class LoginViewModel: ObservableObject {
    @Published var email = ""
    @Published var password = ""
    @Published var isUserLoggedIn = false
    
    var isValidEmail: Bool {
        isValid(regex: "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,4}")
    }
    
    func login() {
        isUserLoggedIn = isValidEmail
    }
    
    private func isValid(regex: String) -> Bool {
        let test = NSPredicate(format: "SELF MATCHES %@", regex)
        return test.evaluate(with: email)
    }
}

struct LoginView1: View {
    @StateObject private var loginVM = LoginViewModel()
    
    var body: some View {
        VStack {
            Text("로그인이 필요해요").font(.largeTitle).bold()
            
            TextField("이메일을 입력해 주세요...", text: $loginVM.email)
                .roundedField()
            
            SecureField("비밀번호를 입력해 주세요...", text: $loginVM.password)
                .roundedField()
            
            Button("로그인") {
                loginVM.login()
            }
            .disabled(!loginVM.isValidEmail)
        }
        .fullScreenCover(isPresented: $loginVM.isUserLoggedIn) {
            Text(loginVM.email)
        }
    }
}

struct LoginView2: View {
    @ObservedObject private var loginVM = LoginViewModel()
    
    var body: some View {
        VStack {
            Text("로그인이 필요해요").font(.largeTitle).bold()
            
            TextField("이메일을 입력해 주세요...", text: $loginVM.email)
                .roundedField()
            
            SecureField("비밀번호를 입력해 주세요...", text: $loginVM.password)
                .roundedField()
            
            Button("로그인") {
                loginVM.login()
            }
            .disabled(!loginVM.isValidEmail)
        }
        .fullScreenCover(isPresented: $loginVM.isUserLoggedIn) {
            Text(loginVM.email)
        }
    }
}

struct ContainerView: View {
    @State private var toggle: Bool = false
    
    var body: some View {
        VStack {
            Toggle("토글해봅시다", isOn: $toggle)
            
            LoginView1()
            LoginView2()
        }
    }
}

extension View {
    func roundedField() -> some View {
        self.padding()
            .background {
                RoundedRectangle(cornerRadius: 20).stroke()
            }
            .padding([.horizontal, .bottom])
    }
}

#Preview {
    ContainerView()
}

주요 구성 요소와 상태 관리

  1. LoginViewModel 클래스 (ObservableObject)
    • 상태 관리용 ViewModel 클래스입니다.
    • SwiftUI에서 뷰에 상태 변화를 전달하는 역할을 합니다.
    • ObservableObject를 채택하여, @Published 속성이 변경될 때마다 관련된 뷰가 자동으로 업데이트되도록 합니다.

주요 속성 및 메서드

  • @Published var email: 이메일 입력값을 저장합니다.
  • @Published var password: 비밀번호 입력값을 저장합니다.
  • @Published var isUserLoggedIn: 사용자의 로그인 여부를 관리하며, 로그인이 성공하면 true로 설정됩니다.
  • isValidEmail: 유효한 이메일인지 여부를 반환하는 계산 속성입니다. 정규식을 사용하여 이메일 형식을 확인합니다.
  • login(): 이메일이 유효하면 isUserLoggedIn을 true로 설정하여 사용자를 로그인 처리합니다.

isValid 메서드

  • 이메일 유효성 검사에 사용되는 정규식을 평가합니다.
private func isValid(regex: String) -> Bool {
    let test = NSPredicate(format: "SELF MATCHES %@", regex)
    return test.evaluate(with: email)
}

2. LoginView1 (@StateObject 사용)

  • @StateObject로 LoginViewModel을 생성하고 관리합니다.
  • LoginView1은 뷰가 직접적으로 LoginViewModel 객체를 소유하고 상태를 관리합니다. 즉, LoginView1이 생성될 때 LoginViewModel도 함께 생성되고 관리됩니다.

주요 UI 구성

  • TextField와 SecureField를 사용하여 이메일과 비밀번호를 입력받습니다.
  • 로그인 버튼은 이메일이 유효하지 않을 경우 비활성화됩니다.
  • 사용자가 로그인하면 fullScreenCover가 열리고, 로그인한 이메일을 표시합니다.
@StateObject private var loginVM = LoginViewModel() // ViewModel 생성 및 관리
  • @StateObject는 해당 뷰가 상태를 소유할 때 사용됩니다. 즉, 뷰가 처음 생성될 때 loginVM을 초기화하고, 뷰가 종료될 때 loginVM도 함께 소멸됩니다.

3. LoginView2 (@ObservedObject 사용)

  • @ObservedObject로 LoginViewModel을 관찰합니다.
  • 이 뷰는 상위 뷰로부터 전달받은 ViewModel을 사용하여 상태를 관찰하며, 직접 객체를 생성하지는 않습니다.
@ObservedObject private var loginVM = LoginViewModel() // 외부로부터 전달받은 ViewModel을 관찰
  • @ObservedObject는 상위 뷰가 소유한 객체를 전달받아 관찰만 합니다. 객체의 상태가 변경되면, 해당 상태가 반영된 UI가 업데이트됩니다.
  • LoginView2는 상태를 소유하지 않으며, 상위 뷰가 상태를 소유하고 관리합니다.

4. ContainerView (LoginView1과 LoginView2를 포함하는 뷰)

  • ContainerView는 @State로 관리하는 토글 상태와 함께 두 개의 로그인 뷰 (LoginView1과 LoginView2)를 동시에 포함하고 있습니다.
  • Toggle 버튼을 통해 사용자가 토글을 변경할 수 있지만, 로그인 뷰들과는 독립적인 상태입니다.
@State private var toggle: Bool = false

5. roundedField() - View의 커스텀 수정자

  • roundedField()는 텍스트 필드와 시큐어 필드의 스타일을 설정하는 커스텀 뷰 수정자입니다.
  • 텍스트 필드에 패딩을 추가하고, 둥근 테두리를 적용합니다.
extension View {
    func roundedField() -> some View {
        self.padding()
            .background {
                RoundedRectangle(cornerRadius: 20).stroke()
            }
            .padding([.horizontal, .bottom])
    }
}

코드 흐름 정리

  1. LoginView1과 LoginView2는 모두 LoginViewModel을 사용하여 상태를 관리합니다.
  2. LoginView1은 @StateObject로 LoginViewModel을 소유하고, LoginView2는 @ObservedObject로 외부에서 전달받아 상태를 관찰합니다.
  3. ContainerView는 두 로그인 뷰를 모두 포함하며, 토글 상태를 관리합니다.

주요 개념 요약

  • @StateObject: 뷰가 상태를 소유할 때 사용됩니다. LoginView1에서 사용됩니다.
  • @ObservedObject: 외부에서 전달된 상태를 관찰할 때 사용됩니다. LoginView2에서 사용됩니다.
  • ObservableObject: 상태 변화를 관찰할 수 있도록 하는 클래스 프로토콜입니다. LoginViewModel이 ObservableObject를 채택합니다.
  • @Published: ObservableObject 클래스 내에서 상태가 변경될 때마다 자동으로 뷰에 상태 변화를 알립니다.

왜 토글 버튼 누르면 로그인뷰1의 텍스트는 유지되고 로그인뷰2의 텍스트는 사라질까요?

이 현상은 @StateObject와 @ObservedObject의 상태 관리 방식 차이 때문에 발생합니다. LoginView1과 LoginView2는 각각 LoginViewModel을 다르게 관리하기 때문에 텍스트 필드의 값이 달라지는 것입니다.

1. @StateObject vs @ObservedObject

  • @StateObject는 뷰가 객체를 처음 생성할 때 그 객체의 라이프사이클을 관리합니다. @StateObject는 뷰가 다시 렌더링되더라도 객체가 재생성되지 않고 상태를 유지합니다.
  • @ObservedObject는 외부에서 전달된 객체를 관찰하지만, 이 객체의 소유권을 갖지 않습니다. 즉, 뷰가 다시 그려질 때(body가 다시 호출될 때), @ObservedObject로 선언된 객체는 다시 초기화됩니다. 이 경우, LoginView2가 다시 렌더링될 때마다 LoginViewModel이 새로 생성되므로, 이전에 입력된 텍스트가 사라집니다.

구체적인 설명

LoginView1 (텍스트가 유지됨)

@StateObject private var loginVM = LoginViewModel() // 상태를 유지
  • LoginView1에서 @StateObject를 사용하고 있기 때문에 뷰가 재렌더링되더라도 loginVM은 유지됩니다. Toggle 버튼을 눌러도 LoginView1의 상태는 사라지지 않으며, 입력된 텍스트가 그대로 유지됩니다.

LoginView2 (텍스트가 사라짐)

@ObservedObject private var loginVM = LoginViewModel() // 상태가 사라짐
  • LoginView2에서는 @ObservedObject를 사용하고 있습니다. 뷰가 다시 렌더링될 때마다 새로운 loginVM 객체가 생성됩니다. 이 때문에 Toggle 버튼을 눌러서 뷰가 업데이트되면, LoginView2의 loginVM이 초기화되고, 입력된 텍스트가 사라지는 것입니다.

왜 이런 차이가 생기는가?

  • @StateObject는 뷰가 객체의 라이프사이클을 소유합니다. 이 때문에 뷰가 다시 그려지더라도 객체는 유지됩니다.
  • @ObservedObject는 뷰가 객체를 소유하지 않으므로, 뷰가 다시 그려질 때 객체도 다시 생성됩니다.

해결 방법

만약 LoginView2에서도 상태가 유지되기를 원한다면, 상위 뷰인 ContainerView에서 @StateObject로 LoginViewModel을 생성하고, 이를 두 뷰에 전달하면 됩니다.

해결 코드:

struct ContainerView: View {
    @State private var toggle: Bool = false
    @StateObject private var loginVM = LoginViewModel() // 상위 뷰에서 StateObject 생성
    
    var body: some View {
        VStack {
            Toggle("토글해봅시다", isOn: $toggle)
            
            LoginView1(loginVM: loginVM) // ViewModel을 두 뷰에 전달
            LoginView2(loginVM: loginVM)
        }
    }
}

struct LoginView1: View {
    @ObservedObject var loginVM: LoginViewModel // 전달된 ViewModel을 ObservedObject로 받음
    
    var body: some View {
        VStack {
            Text("로그인이 필요해요").font(.largeTitle).bold()
            
            TextField("이메일을 입력해 주세요...", text: $loginVM.email)
                .roundedField()
            
            SecureField("비밀번호를 입력해 주세요...", text: $loginVM.password)
                .roundedField()
            
            Button("로그인") {
                loginVM.login()
            }
            .disabled(!loginVM.isValidEmail)
        }
        .fullScreenCover(isPresented: $loginVM.isUserLoggedIn) {
            Text(loginVM.email)
        }
    }
}

struct LoginView2: View {
    @ObservedObject var loginVM: LoginViewModel // 전달된 ViewModel을 ObservedObject로 받음
    
    var body: some View {
        VStack {
            Text("로그인이 필요해요").font(.largeTitle).bold()
            
            TextField("이메일을 입력해 주세요...", text: $loginVM.email)
                .roundedField()
            
            SecureField("비밀번호를 입력해 주세요...", text: $loginVM.password)
                .roundedField()
            
            Button("로그인") {
                loginVM.login()
            }
            .disabled(!loginVM.isValidEmail)
        }
        .fullScreenCover(isPresented: $loginVM.isUserLoggedIn) {
            Text(loginVM.email)
        }
    }
}

변경된 동작

  • ContainerView에서 하나의 LoginViewModel을 @StateObject로 생성하여, 두 뷰에 같은 ViewModel을 전달합니다.
  • 이 경우, Toggle을 사용하여 뷰가 다시 렌더링되더라도 LoginViewModel 객체는 재사용되므로, 텍스트 필드의 내용이 유지됩니다.

결론

  • @StateObject는 뷰가 상태를 소유하고 유지할 때 사용되므로, 뷰가 다시 렌더링되더라도 상태가 유지됩니다.
  • @ObservedObject는 외부에서 전달된 상태를 관찰하는 역할을 하며, 뷰가 다시 렌더링될 때 새로운 객체가 생성될 수 있습니다.
  • 상태를 유지하고 싶다면, 상위 뷰에서 하나의 @StateObject를 생성하여 하위 뷰에 전달하는 방법이 적절합니다.

'Category > iOS' 카테고리의 다른 글

enum(열거형)이란?  (1) 2024.09.22
CaseIterable이란?  (0) 2024.09.22
ObservableObject와 @StateObject, @ObservedObject란?  (0) 2024.09.21
SwiftUI의 @Binding이란?  (0) 2024.09.21
Self._printChanges()  (0) 2024.09.21