Swift / NavigationStack 중첩 문제

iOS 앱개발 공부

목록 보기
17/30

🚨 문제 발생

Base가 되는 화면의 구현을 마치고 push를 통해 다른 뷰 화면으로 이동하기 위한 작업을 진행 중 뷰가 중첩되는 문제가 발생했다.
분명 push를 1번 눌렀는데, 실제로는 5개의 view가 push되어 중첩되는 문제가 발생한 것이다.

왜 이런 문제가 발생했고, 어떻게 해결할지 함께 알아보도록 하자.


⚒️ 원인분석

먼저 push 이벤트가 5번이 발생해서 view가 5번 쌓인 것인지, 혹은 push 이벤트는 한 번만 발생했는데 view가 5번 쌓인 것인지 확인해야한다.
어떤 경우냐에 따라 해결방법이 완전히 달라지기 때문이다.

이를 확인하기 위해 Xcode debuger를 사용하기로 하였고, push 이벤트가 발생하는 지점에 breakpoint를 걸어 결과를 확인했다.

그 결과 해당 부분에서 break가 5번 걸리는 것을 확인할 수 있었다.
즉, push 이벤트가 5번이나 발생하고 있다는 의미이다.

RxSwift나 Combine같은 비동기 프로그래밍을 하고 있었다면 버튼 자체에 쓰로틀을 거는 식으로 손쉽게 해결할 수 있었겠지만, 아쉽게도 이번 프로젝트에서는 둘 다 사용하지 않는다. 게다가 일반적인 button을 사용하는 것이 아닌 클로저를 사용하고 있었기 때문에 5번이 아닌 1번만 이벤트가 실행되도록 조절하는 것이 쉽지 않았다.

어쨌든 원인을 파악했으니 이제 문제를 직접 해결해보자.


✅ 문제 해결하기

1. 상태값을 통한 push 이벤트 조절

이게 가장 쉬운 방법인데, push 이벤트를 발생시키는 UIViewController에 isPushing이라는 Bool 타입의 변수를 하나 만들고, push 이벤트가 발생하면 상태를 true로 바꿔주는 것이다.
이 변수가 true인 경우 push가 작동되지 않도록 guard문을 사용하고, push 작업이 완료되면 다시 false로 변경시켜 언제든 다시 push할 수 있도록 하는 것이다.

var isPushing: Bool = false // 기본값은 false

// ...

pushActionClosure = { [weak self] in
	guard self?.isPushing == false else { return }
    self?.isPushing = true
    
    Task { @MainActor in // SwiftUI의 렌더링이 끝난 후 호출되도록 @MainActor 활용
		coordinator.push(.nextView)
		self?.isPushing = false
    }
}

이제 테스트를 해보자.

보는 대로 무사히 문제를 해결할 수 있었다.
이제 끝.
.
.
.
.
.
.
이면 좋겠지만, 이 방식은 크나큰 문제점이 있다.

첫 번째로, 여전히 뷰 중첩 가능성이 있다는 것이다. 변경된 코드는 현재 push 중인지 아닌지만을 판별하여 push를 막기 때문에, 만약 일시적인 오류 등으로 타이밍이 꼬이게 된다면 똑같은 뷰가 다중으로 push될 수도 있다.

두 번째로, 매번 isPushing과 같은 상태를 확인하는 변수를 정의해야한다는 점이다. 무슨 소리냐 하면, 지금 방식을 사용하게 되면 앞으로 push가 필요한 모든 UIViewController에서 isPushing과 같은 push 중인지 아닌지를 판별할 수 있는 변수가 필요해진다는 의미이다. 그럼 중복되는 코드가 많아지기 때문에 이를 방지하는 것이 좋다.

그럼 어떤 방법을 사용하면 좋을까?

2. Coordinator 상태 변화 감지

바로 Coordinator 내부에서 현재 상태를 확인하고 push 여부를 결정하도록 하는 것이다.
즉, 위에서 만든 isPushing과 같은 상태를 확인할 수 있는 변수를 Coordinator 내부에 구현하고, 이를 통해 push를 할 것인지 말 것인지를 구현하는 것이다.

class Coordinator: Coordinator {
	enum State {
    	case home
        case calendar(Date)
        case setting
    }
    
    private var state: State = .home
    
    // ...
    
    func push(_ view: State) {
    	guard view != self.state else { return }
        self.state = view
    	self.path.append(view) // path는 NavigationPath()
    }
}

이처럼 구현하면 push가 실행될 때 현재 coordinator의 속성이 새롭게 push를 시도하는 속성과 동일한지 판별하고, 동일한 경우에는 return하여 push가 이뤄지지 않게 막아주기 때문에 더욱 안전하게 push를 사용할 수 있게된다.

다만 위의 코드는 2가지 문제점이 있어 보완이 조금 필요하다.

첫 번째는, back 버튼을 통한 pop을 하게되면, 다시는 해당 뷰로 push하지 못한다는 점이다.
무슨 소리냐 하면, push를 통해 setting이란 뷰로 이동을 했다고 치면, 화면 상단에 NavigationBar가 있고 back버튼이 활성화되어 있을 것이다. 그리고 coordinator의 state는 setting인 상태일 것이고, 이 상태에서 back 버튼을 누르게되면 pop 이벤트는 발생하지만 coordinator의 state는 변하지 않는다.
때문에 다시 setting이란 뷰로 이동을 하려고 해도, state가 동일하기 때문에 push 이벤트가 발생하지 않는 문제가 발생한다.

이를 해결하기 위해 다양한 방법이 있겠지만, 이번에는 SwiftUI 환경에서 진행하기 때문에 .onDisappear를 활용하려고 한다.

struct CoordinatorView: View {
    @EnvironmentObject private var coordinator: Coordinator
    
    var body: some View {
        NavigationStack(path: coordinator.path) {
            HomeView()
                .onDisappear {
                    coordinator.state = .home
                }
                .navigationDestination(for: Coordinator.State.self) { state in
                    coordinator.build(state)
                }
        }
        .environmentObject(coordinator)
    }
}

root가 되는 View 타입에 .onDisappear를 사용하여 해당 뷰가 화면에 표시될 때마다 coordinator의 state 속성이 home으로 변경해주는 것이다. 이러면 back버튼을 통해 root로 돌아오더라도 state가 변경되기 때문에 push를 문제없이 사용할 수 있게된다.
만약 push된 뷰 안에서 한번 더 push를 하게되면 또 문제가 생길 수도 있지만, 그 때는 push 메서드에서 state를 직접 명시하는 코드를 제거하고, 모든 뷰에 .onDisappear를 설정하면 해결할 수 있을 것이다.

두 번째 문제는 case calendar(Date)상태일 때 push 이벤트가 항상 승인되는 것이다.
coordinator의 state가 calendar이고 새롭게 들어오는 state가 calendar일 때, 의도대로라면 state는 변경되면 안되고 push도 발동하지 않아야 하지만, 실제로는 state가 변경되며 push가 발생하게 된다.
이는 case가 가진 연관값인 Date 속성 때문이다.

Swift에서 Date 타입은 시간을 매우 정밀하게 나타내는 구조체이다.
만약 현재 state가 calendar이고, 2025-11-05 10:00:00.000123 이라는 연관값을 가졌다고 할 때, 새로운 state의 연관값이 2025-11-05 10:00:00.000124만 되어도 다른 값이라고 인식하여 guard문을 통과하게 되는 것이다.

이를 해결하기 위해서 switch문을 사용할 것이다.

private var state: State = .home

// ...

func push(_ view: State) {
	switch (view, self.state) {
    case (.calendar, .calendar): return
    default: guard view != self.state else { return }
    }
    
    self.state = view
    coordinator.push(view)
}

아까 구현한 push 메서드 내부에 switch문을 사용하여 새롭게 들어온 state와 현재 state를 동시에 비교하여 둘다 calendar인 경우 return 하는 수식을 구현했다.
calenar 외에는 guard문을 통한 비교로 충분하기 때문에 default로 guard문을 똑같이 구현해주면 된다.
이렇게 하면 연관값인 Date값이 바뀌더라도, 같은 state이기 때문에 push 이벤트가 작동하는 것을 방지할 수 있다.


✒️ 결론

오늘은 push를 통한 화면전환을 시도하다가 5개의 뷰가 중첩되는 문제를 맞이하고 시행착오를 여러번 겪게되었다.
아무래도 SwiftUI와 UIKit을 함께 사용하다보니 더 많은 문제가 발생하는 것 같은데, 무척 도움이 되면서도 시간을 많이 쓰게 되어서 프로젝트 마감일에 일정을 맞출 수 있을지가 걱정이 된다.

profile
이유있는 코드를 쓰자!!

0개의 댓글