onChange는 사실 조금 이상하다

2026. 1. 29. 12:38·내게 필요한 개발 공부

'특정 View가 화면에 노출되는 시점'을 감지하기 위해 커스텀 ViewModifier를 구현하던 중 요상한 문제에 부딪혔습니다. 특정 값이 변화할 때를 감지하기 위해 SwiftUI에서 흔히 사용하는 `onChange` ViewModifier를 이용해 함수를 실행하는 코드였는데, 값이 분명 변화해`onChange`가 실행되었음에도 예상하는 값과 다르게 사용되는 문제였습니다.

 

문제의 코드는 이랬습니다!

  1. 상위 View에서 전달받은 Environment 값(containerFrame)을 받아오고
  2. onChange에서 이 Environment 값의 변화를 감지하면,
  3. checkVisible 함수에서 Environment 값을 이용해 화면에 노출되었는지를 검사합니다.
struct OnVisibilityViewModifier: ViewModifier {

    @Environment(\.containerFrame) private var containerFrame
    
    func body(content: Content) -> some View {
        content
            .onChange(of: containerFrame) { _ in
                checkVisible()
            }
    }
    
    private func checkVisible() {
        containerFrame... // containerFrame을 가지고 계산 어쩌구저쩌구...
    }
}

직관적으로 containerFrame 값이 `.zero`에서 `CGRect(x: 100, y: 100, width: 100, height: 100)`로 값이 변화했을 때, 우리는 checkVisible 함수에서 새로 업데이트된 값으로 계산되기를 바랄 것입니다. 하지만 전-혀 직관적이지 않게도 전혀 다른 결과를 마주하게 됩니다.

// 1. TestView
struct TestView: View {

    @State private var frame: CGRect = .zero

    var body: some View {
        VStack {
            Button("frame 업데이트") {
                frame = .init(x: 100, y: 100, width: 100, height: 100)
            }
            // 여기서 containerFrame 값 받아와서 사용할 것
            .modifier(OnVisibilityViewModifier())
        }
        // containerFrame 값 environment로 주입
        .environment(\.containerFrame, frame)
    }
}

// 2. ViewModifier
struct OnVisibilityViewModifier: ViewModifier {

    @Environment(\.containerFrame) private var containerFrame

    func body(content: Content) -> some View {
        content
            .onAppear {
                print("onAppear 클로저에서 실행한 containerFrame: \(containerFrame)")
            }
            .onChange(of: containerFrame) { _ in
                print("onChange 클로저에서 실행한 containerFrame: \(containerFrame)")
                checkVisible()
            }
    }

    private func checkVisible() {
        print("checkVisible 함수에서 실행한 containerFrame: \(containerFrame)")
    }
}

???

onChange 클로저가 호출되었다는 뜻은, 실제로 containerFrame 값이 변화했다는 의미입니다.(그래서 print가 찍히고 있구요!) 하지만 클로저 내부에서도, checkVisible 함수에서도 containerFrame 값은 여전히 기본값을 출력하고 있습니다. 대체 여기서 찍히고 있는 저 값은 어떻게 해석해야 할까요?

 

일단 해결해보기

위 문제를 해결하는 가장 간단한 방법은,,, 클로저에 전달된 인자를 사용하는 것입니다! 같은 값일 거라고 생각하고 당연~하게도 Environment 값에 접근해 사용하던 것을 요렇게 바꿔주면 우리가 원하는 값을 찾을 수 있게 됩니다.

struct OnVisibilityViewModifier: ViewModifier {

    @Environment(\.containerFrame) private var containerFrame

    func body(content: Content) -> some View {
        content
            .onChange(of: containerFrame) { value in
                print("onChange 클로저에서 전달 받은 containerFrame: \(value)")
                checkVisible(value) // ⭐️ 전달 받은 값을 넣어주기
            }
    }

    private func checkVisible(_ value: CGRect) {
        print("checkVisible 함수에서 전달받은 containerFrame: \(value)")
    }
}

클로저로 전달된 value와 Environment가 같은 값이 아니라니,,, 뭔가 이상합니다!

 

onChange는 사실 조금 이상하다?

여기서 우리가 유추할 수 있는 것은 onChange를 사용할 때 대상이 되는 참조 값(여기서는 EnvironmentValue로 주입된 containerFrame)은 변경 전 상태를, 클로저의 인자로 전달되는 값(value)은 변경 후 상태를 나타낸다는 것입니다.

 

애플이 작성해 둔 onChange 공식 예제에서도 아래와 같이 사용하는 것을 확인할 수 있습니다.

struct MyScene: Scene {
    @Environment(\.scenePhase) private var scenePhase
    @StateObject private var cache = DataCache()

    var body: some Scene {
        WindowGroup {
            MyRootView()
        }
        .onChange(of: scenePhase) { newScenePhase in
            if newScenePhase == .background {
                cache.empty()
            }
        }
    }
}

 

하지만 조금 생각해 보면 무척이나 이상하긴 구조이긴 합니다. onChange의 의미는,,, 이름에서부터 '특정 값이 변화한 시점'에 실행되어야 하는 것으로 인식되기 쉽고, 그 맥락에 따라 직관적으로 그 대상이 되는 참조 값 또한 '변화 후의 값'을 떠올리게 됩니다.

 

왜 이미 참조할 수 있는 값(scenePhase)이 있음에도 불구하고 굳이 굳이 클로저의 인자를 사용해야 '최신 값'에 접근할 수 있는 걸까요? 아래와 같이 작성했을 때 이상해 보이지 않는 이유도 이 때문일 것입니다.

.onChange(of: scenePhase) { _ in
    if scenePhase == .background {
        cache.empty()
    }
}

 

 

그래서 등장한 새로운 onChange

애플도 이런 문제를 인지한 것인지 iOS 17.0에서 새로운 onChange를 내놓게 됩니다! 이에 따라 기존 onChange는 Deprecated 되었고, 위에서 함께 살펴본 문제에 대해 언급하며 이유를 설명해줍니다.

Deprecated
Use onChange(of:initial:_:) or onChange(of:initial:_:) instead. The trailing closure in each case takes either zero or two input parameters, compared to this method which takes one.Be aware that the replacements have slightly different behavior. This modifier’s closure captures values that represent the state before the change. The new modifiers capture values that correspond to the new state. The new behavior makes it easier to perform updates that rely on values other than the one that caused the modifier’s closure to run.

onChange(of:initial:_:) 또는 onChange(of:initial:_:)를 사용하세요. 각 메서드의 후행 클로저는 입력 매개변수를 0개 또는 2개 받는 반면, 이 메서드는 1개만 받습니다. 대체 메서드는 동작 방식이 약간 다릅니다. 기존 modifier의 클로저는 변경 전 상태를 나타내는 값을 캡처하는 반면, 새로운 modifier는 변경 후 상태에 해당하는 값을 캡처합니다. 이러한 새로운 동작 덕분에 modifier의 클로저를 실행시킨 값이 아닌 다른 값에 의존하는 업데이트를 더 쉽게 수행할 수 있습니다.

 

새롭게 만들어진 onChange는 우리가 직관적으로 해석하기 쉬운 형태인 '변경 후' 상태에 해당하는 값을 캡처한다고 하는 것이 핵심입니다! 바로 테스트 해볼까요?

struct OnVisibilityViewModifier: ViewModifier {

    @Environment(\.containerFrame) private var containerFrame

    func body(content: Content) -> some View {
        content
            .onAppear {
                print("onAppear 클로저에서 실행한 containerFrame: \(containerFrame)")
            }
            .onChange(of: containerFrame) { // ⭐️ 새로운 onChange 사용
                print("onChange 클로저에서 전달 받은 containerFrame: \(containerFrame)")
                checkVisible()
            }
    }

    private func checkVisible() {
        print("checkVisible 함수에서 전달받은 containerFrame: \(containerFrame)")
    }
}

이제야 처음 의도했던 것처럼 사용이 가능해졌습니다! 클로저의 인자를 받아 사용하지 않더라도 최신 값을 잘 받아오고 있네요.

 

만약 변경되기 이전의 값을 받아오고 싶다면 아래와 같이 사용도 가능합니다!(레거시 onChange가 '변경 전' 값을 캡처하도록 만들어진 이유도 '변경 전' 값을 사용하는 상황을 고려해서,,, 였을까요?)

.onChange(of: containerFrame) { oldValue, newValue in
    print("변경 전: \(oldValue)")
    print("변경 후: \(newValue)")
}

 

iOS 16 이하에서는 여전히 주의가 필요함!

사실 최소 지원 버전이 iOS 17 이상인 프로젝트였다면 아래와 같은 노란색 에러를 없애기 위해서라도(?) 자연스럽게 문제를 해결할 수도 있었을 것입니다.

하지만,,, 제가 진행 중인 프로젝트는 iOS 16이 최소지원 버전이었고 이를 위해 레거시 onChange를 사용해야 했기 때문에 문제를 발견하기 더 어려웠지 않았나 싶습니다.(물론 근본적인 동작 방식을 알고 있었다면 == 공식 문서를 잘 읽었다면 이런 일은 일어나지 않았겠지만 말이에요)

 

아무튼 이번 기회에 왜 굳이 잘 동작하는(그렇지 않았지만) onChange를 다시 만들었을까? 하는 작은 의문점이 있었는데 이것도 해결하고 동작 원리도 이해하는 좋은 기회가 되었던 것 같습니다! 🤠

 

번외: State-Binding 값은 어떻게 동작할까? 

문득 Environment 값이 아닌 State-Binding으로 이어줘도 레거시 onChange는 '변경 전' 값을 캡처할까? 라는 호기심이 생겼습니다.

struct TestView: View {

    @State private var frame: CGRect = .zero

    var body: some View {
        VStack {
            Button("frame 업데이트") {
                frame = .init(x: 100, y: 100, width: 100, height: 100)
            }
            .modifier(OnVisibilityViewModifier(containerFrame: $frame))
        }
    }
}

struct OnVisibilityViewModifier: ViewModifier {

    @Binding var containerFrame: CGRect

    func body(content: Content) -> some View {
        content
            .onAppear {
                print("onAppear 클로저에서 실행한 containerFrame: \(containerFrame)")
            }
            .onChange(of: containerFrame) { _ in
                print("onChange 클로저에서 전달 받은 containerFrame: \(containerFrame)")
                checkVisible()
            }
    }

    private func checkVisible() {
        print("checkVisible 함수에서 전달받은 containerFrame: \(containerFrame)")
    }
}

결과는,,,?

아니!!! 변경 전 값을 캡처한다면서요??! << 라고 생각할 수 있지만,,, 이것은 Environment와 State-Binding의 근본적인 동작 차이 때문일 것으로 추측하고 있습니다.

 

기본적으로 State-Binding은 항상 원본 값에 대한 참조, 즉 Source of Truth 원칙을 지키기 때문에 onChange에서 캡처한 시점은 '변경 전'일지 몰라도, 실제로 가져온 것은 'Source of Truth에 대한 메모리 주소 값'이기 때문에 항상 최신 값을 나타낼 수 밖에 없게 됩니다.

 

반면 Environment는 스냅샷 방식으로 동작하기 때문에 '변경 전 값을 복사'하게 됩니다. 이는 포스팅 본문에서 살펴본 것과 같이 애플의 의도(?)일 것 같기도 하네요,,,(변경 전 값을 사용해야 하는 상황이 있을 수 있음을 고려한 것일까요,,,)

 

아무튼 다시 정리하자면 iOS 16 이하 버전 + Environment 값을 사용할 때는 항상 주의, 또 주의할 것!

저작자표시 (새창열림)

'내게 필요한 개발 공부' 카테고리의 다른 글

사이드 이펙트  (1) 2026.01.11
딥링크, URL 스킴, 유니버셜 링크, 다이나믹 링크 뭐가 뭔데?  (0) 2025.10.26
함수와 메서드는 다르다.  (4) 2025.08.01
Task는 항상 부모 Context를 상속 받을까?  (5) 2025.07.22
멀티태스킹과 멀티프로세싱, 비슷한듯 다른 두 개념  (0) 2025.03.17
'내게 필요한 개발 공부' 카테고리의 다른 글
  • 사이드 이펙트
  • 딥링크, URL 스킴, 유니버셜 링크, 다이나믹 링크 뭐가 뭔데?
  • 함수와 메서드는 다르다.
  • Task는 항상 부모 Context를 상속 받을까?
thinkyside
thinkyside
스스로에게 솔직해지고 싶은 공간
  • thinkyside
    또 만드는 한톨
    thinkyside
  • 전체
    오늘
    어제
    • 모아보기 (74)
      • 솔직해보려는 회고 (2)
      • 꾸준히 글쓰기 (10)
      • 생각을 담은 독서 (8)
      • 내게 필요한 개발 공부 (27)
      • 실무 내용 내껄로 만들.. (4)
      • 트러블슈팅 (5)
      • 프로젝트 일지 (9)
      • 개발 서적 (3)
      • 취준 (3)
      • 대외활동 (2)
      • UXUI (1)
  • hELLO· Designed By정상우.v4.10.3
thinkyside
onChange는 사실 조금 이상하다
상단으로

티스토리툴바